test_obico_detection.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974
  1. """Unit tests for Obico detection service (#172)."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from backend.app.schemas.settings import AppSettingsUpdate
  5. from backend.app.services.obico_detection import (
  6. FRAME_CACHE_TTL,
  7. ObicoDetectionService,
  8. _frame_cache,
  9. pop_frame,
  10. stash_frame,
  11. )
  12. from backend.app.services.obico_smoothing import WARMUP_FRAMES
  13. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  14. class TestSettingsSchemaValidators:
  15. """Guard rails on the new obico_* AppSettings fields."""
  16. def test_sensitivity_accepts_valid_values(self):
  17. for value in ("low", "medium", "high"):
  18. u = AppSettingsUpdate(obico_sensitivity=value)
  19. assert u.obico_sensitivity == value
  20. def test_sensitivity_rejects_garbage(self):
  21. with pytest.raises(ValueError, match="obico_sensitivity"):
  22. AppSettingsUpdate(obico_sensitivity="extreme")
  23. def test_action_accepts_valid_values(self):
  24. for value in ("notify", "pause", "pause_and_off"):
  25. assert AppSettingsUpdate(obico_action=value).obico_action == value
  26. def test_action_rejects_garbage(self):
  27. with pytest.raises(ValueError, match="obico_action"):
  28. AppSettingsUpdate(obico_action="explode")
  29. def test_enabled_printers_accepts_empty(self):
  30. assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
  31. assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
  32. def test_enabled_printers_accepts_int_array(self):
  33. u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
  34. assert u.obico_enabled_printers == "[1, 2, 3]"
  35. def test_enabled_printers_rejects_non_json(self):
  36. with pytest.raises(ValueError, match="valid JSON"):
  37. AppSettingsUpdate(obico_enabled_printers="1,2,3")
  38. def test_enabled_printers_rejects_non_list(self):
  39. with pytest.raises(ValueError, match="JSON array"):
  40. AppSettingsUpdate(obico_enabled_printers='{"1": true}')
  41. def test_enabled_printers_rejects_non_int_elements(self):
  42. with pytest.raises(ValueError, match="JSON array"):
  43. AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
  44. def test_poll_interval_bounds(self):
  45. with pytest.raises(ValueError):
  46. AppSettingsUpdate(obico_poll_interval=4)
  47. with pytest.raises(ValueError):
  48. AppSettingsUpdate(obico_poll_interval=121)
  49. assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
  50. class TestGetStatus:
  51. def test_empty_initial_status(self):
  52. svc = ObicoDetectionService()
  53. s = svc.get_status()
  54. assert s["is_running"] is False
  55. assert s["per_printer"] == {}
  56. assert s["history"] == []
  57. assert "low" in s["thresholds"] and "high" in s["thresholds"]
  58. def test_thresholds_reflect_configured_sensitivity(self):
  59. """#1469 — get_status() reports the thresholds for the passed
  60. sensitivity, not a hardcoded 'medium'. Each level must be distinct so
  61. the Status panel changes when the user changes the setting."""
  62. svc = ObicoDetectionService()
  63. low = svc.get_status("low")["thresholds"]
  64. medium = svc.get_status("medium")["thresholds"]
  65. high = svc.get_status("high")["thresholds"]
  66. # Higher sensitivity → lower thresholds (easier to trigger).
  67. assert low["low"] > medium["low"] > high["low"]
  68. assert low["high"] > medium["high"] > high["high"]
  69. # Default and unknown values fall back to medium.
  70. assert svc.get_status()["thresholds"] == medium
  71. assert svc.get_status("bogus")["thresholds"] == medium
  72. class TestTestConnection:
  73. @pytest.mark.asyncio
  74. async def test_empty_url_via_route(self):
  75. """Service does not special-case empty URL — the route does."""
  76. svc = ObicoDetectionService()
  77. # This will fail DNS/connect, but should return ok=False
  78. result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
  79. assert result["ok"] is False
  80. assert result["error"] is not None
  81. @pytest.mark.asyncio
  82. async def test_healthy_response_is_ok(self):
  83. svc = ObicoDetectionService()
  84. mock_response = MagicMock(status_code=200, text="ok")
  85. mock_client = MagicMock()
  86. mock_client.get = AsyncMock(return_value=mock_response)
  87. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  88. mock_client.__aexit__ = AsyncMock(return_value=False)
  89. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  90. result = await svc.test_connection("http://obico:3333")
  91. assert result["ok"] is True
  92. assert result["status_code"] == 200
  93. assert result["body"] == "ok"
  94. assert result["error"] is None
  95. @pytest.mark.asyncio
  96. async def test_non_ok_body_is_not_ok(self):
  97. svc = ObicoDetectionService()
  98. mock_response = MagicMock(status_code=200, text="something else")
  99. mock_client = MagicMock()
  100. mock_client.get = AsyncMock(return_value=mock_response)
  101. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  102. mock_client.__aexit__ = AsyncMock(return_value=False)
  103. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  104. result = await svc.test_connection("http://obico:3333/")
  105. assert result["ok"] is False
  106. assert result["body"] == "something else"
  107. class TestMlApiToken:
  108. """Obico's ML API gates /p/ behind ML_API_TOKEN (#2733)."""
  109. def test_auth_headers_only_when_configured(self):
  110. from backend.app.services.obico_detection import auth_headers
  111. assert auth_headers("s3cret") == {"Authorization": "Bearer s3cret"}
  112. # Unconfigured must stay byte-identical to the pre-setting request.
  113. assert auth_headers("") == {}
  114. assert auth_headers(None) == {}
  115. assert auth_headers(" ") == {}
  116. # Whitespace around a real token is a paste artefact, not part of it.
  117. assert auth_headers(" s3cret ") == {"Authorization": "Bearer s3cret"}
  118. def test_settings_schema_accepts_a_token(self):
  119. assert AppSettingsUpdate(obico_ml_token="s3cret").obico_ml_token == "s3cret"
  120. assert AppSettingsUpdate(obico_ml_token="").obico_ml_token == ""
  121. assert AppSettingsUpdate().obico_ml_token is None
  122. @staticmethod
  123. def _settings(**overrides):
  124. base = {
  125. "enabled": True,
  126. "ml_url": "http://obico:3333",
  127. "ml_token": "",
  128. "sensitivity": "medium",
  129. "action": "notify",
  130. "poll_interval": 10,
  131. "enabled_printers": None,
  132. "snapshot_base_url": "http://bambuddy:8000",
  133. }
  134. base.update(overrides)
  135. return base
  136. @staticmethod
  137. def _client(response):
  138. mock_client = MagicMock()
  139. mock_client.get = AsyncMock(return_value=response)
  140. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  141. mock_client.__aexit__ = AsyncMock(return_value=False)
  142. return mock_client
  143. @pytest.mark.asyncio
  144. async def test_detection_call_carries_the_bearer_header(self):
  145. svc = ObicoDetectionService()
  146. response = MagicMock(status_code=200)
  147. response.json.return_value = {"detections": []}
  148. response.raise_for_status = MagicMock()
  149. mock_client = self._client(response)
  150. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  151. with (
  152. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  153. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  154. ):
  155. await svc._check_printer(1, status, self._settings(ml_token="s3cret"))
  156. assert mock_client.get.await_args.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
  157. @pytest.mark.asyncio
  158. async def test_detection_call_sends_no_header_without_a_token(self):
  159. svc = ObicoDetectionService()
  160. response = MagicMock(status_code=200)
  161. response.json.return_value = {"detections": []}
  162. response.raise_for_status = MagicMock()
  163. mock_client = self._client(response)
  164. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  165. with (
  166. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  167. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  168. ):
  169. await svc._check_printer(1, status, self._settings())
  170. assert mock_client.get.await_args.kwargs["headers"] == {}
  171. @pytest.mark.asyncio
  172. async def test_401_reports_the_token_rather_than_a_bare_http_error(self):
  173. svc = ObicoDetectionService()
  174. response = MagicMock(status_code=401)
  175. # raise_for_status would also raise here; the status check must come first
  176. # so the user gets an actionable message instead of "401 Unauthorized".
  177. response.raise_for_status = MagicMock(side_effect=AssertionError("must not reach raise_for_status"))
  178. mock_client = self._client(response)
  179. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  180. with (
  181. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  182. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  183. ):
  184. await svc._check_printer(1, status, self._settings(ml_token="wrong"))
  185. assert "401" in svc._last_error
  186. assert "ML_API_TOKEN" in svc._last_error
  187. # A rejected call must not be scored as a clean frame.
  188. assert 1 not in svc._states or svc._states[1].frame_count == 0
  189. @pytest.mark.asyncio
  190. async def test_401_message_does_not_leak_the_token(self):
  191. svc = ObicoDetectionService()
  192. response = MagicMock(status_code=401)
  193. response.raise_for_status = MagicMock()
  194. mock_client = self._client(response)
  195. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  196. with (
  197. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  198. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  199. ):
  200. await svc._check_printer(1, status, self._settings(ml_token="sup3rs3cret"))
  201. assert "sup3rs3cret" not in svc._last_error
  202. class TestTestConnectionTokenProbe:
  203. """/hc/ is ungated, so health alone cannot validate the token (#2733)."""
  204. @staticmethod
  205. def _client(responses):
  206. mock_client = MagicMock()
  207. mock_client.get = AsyncMock(side_effect=responses)
  208. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  209. mock_client.__aexit__ = AsyncMock(return_value=False)
  210. return mock_client
  211. @pytest.mark.asyncio
  212. async def test_healthy_but_rejected_token_is_not_ok(self):
  213. svc = ObicoDetectionService()
  214. mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=401)])
  215. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  216. result = await svc.test_connection("http://obico:3333", "wrong")
  217. assert result["ok"] is False
  218. assert result["auth_ok"] is False
  219. assert result["status_code"] == 401
  220. assert "ML_API_TOKEN" in result["error"]
  221. @pytest.mark.asyncio
  222. async def test_accepted_token_is_ok(self):
  223. svc = ObicoDetectionService()
  224. # 422 = "Invalid request params": auth passed, then the handler rejected
  225. # the img-less probe. That is the success signal.
  226. mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
  227. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  228. result = await svc.test_connection("http://obico:3333", "right")
  229. assert result["ok"] is True
  230. assert result["auth_ok"] is True
  231. assert result["error"] is None
  232. @pytest.mark.asyncio
  233. async def test_probe_failure_leaves_the_token_unknown_but_keeps_the_test_ok(self):
  234. svc = ObicoDetectionService()
  235. mock_client = self._client([MagicMock(status_code=200, text="ok"), RuntimeError("read timeout")])
  236. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  237. result = await svc.test_connection("http://obico:3333", "maybe")
  238. assert result["ok"] is True
  239. assert result["auth_ok"] is None
  240. @pytest.mark.asyncio
  241. async def test_unhealthy_server_is_not_probed(self):
  242. svc = ObicoDetectionService()
  243. mock_client = self._client([MagicMock(status_code=200, text="error")])
  244. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  245. result = await svc.test_connection("http://obico:3333", "any")
  246. assert result["ok"] is False
  247. assert result["auth_ok"] is None
  248. assert mock_client.get.await_count == 1
  249. @pytest.mark.asyncio
  250. async def test_both_requests_carry_the_header(self):
  251. svc = ObicoDetectionService()
  252. mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
  253. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  254. await svc.test_connection("http://obico:3333", "s3cret")
  255. assert [call.args[0] for call in mock_client.get.await_args_list] == [
  256. "http://obico:3333/hc/",
  257. "http://obico:3333/p/",
  258. ]
  259. for call in mock_client.get.await_args_list:
  260. assert call.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
  261. @pytest.mark.asyncio
  262. async def test_url_policy_still_applies_before_any_request(self):
  263. svc = ObicoDetectionService()
  264. result = await svc.test_connection("http://169.254.169.254/latest/meta-data/", "s3cret")
  265. assert result["ok"] is False
  266. assert result["auth_ok"] is None
  267. assert result["error"]
  268. class TestPollOneStateLifecycle:
  269. """Confirms per-printer state is reset when a new print starts."""
  270. @pytest.mark.asyncio
  271. async def test_new_task_name_resets_state(self):
  272. svc = ObicoDetectionService()
  273. # Seed a state that has been running for a while
  274. from backend.app.services.obico_smoothing import PrintState
  275. seeded = PrintState()
  276. for _ in range(WARMUP_FRAMES + 5):
  277. seeded.update(0.5)
  278. svc._states[1] = seeded
  279. svc._state_keys[1] = "old_task"
  280. svc._action_fired[1] = True
  281. settings = {
  282. "enabled": True,
  283. "ml_url": "http://obico:3333",
  284. "sensitivity": "medium",
  285. "action": "notify",
  286. "poll_interval": 10,
  287. "enabled_printers": None,
  288. "snapshot_base_url": "http://bambuddy:8000",
  289. }
  290. status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
  291. mock_response = MagicMock()
  292. mock_response.json.return_value = {"detections": []}
  293. mock_response.raise_for_status = MagicMock()
  294. mock_client = MagicMock()
  295. mock_client.get = AsyncMock(return_value=mock_response)
  296. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  297. mock_client.__aexit__ = AsyncMock(return_value=False)
  298. with (
  299. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  300. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  301. ):
  302. await svc._check_printer(1, status, settings)
  303. # State was reset (frame_count is 1 after the single update, not 36)
  304. assert svc._states[1].frame_count == 1
  305. assert svc._state_keys[1] == "new_task"
  306. assert svc._action_fired[1] is False
  307. @pytest.mark.asyncio
  308. async def test_ml_api_error_does_not_crash(self):
  309. svc = ObicoDetectionService()
  310. settings = {
  311. "enabled": True,
  312. "ml_url": "http://obico:3333",
  313. "sensitivity": "medium",
  314. "action": "notify",
  315. "poll_interval": 10,
  316. "enabled_printers": None,
  317. "snapshot_base_url": "http://bambuddy:8000",
  318. }
  319. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  320. mock_client = MagicMock()
  321. mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
  322. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  323. mock_client.__aexit__ = AsyncMock(return_value=False)
  324. with (
  325. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  326. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  327. ):
  328. await svc._check_printer(1, status, settings)
  329. assert svc._last_error is not None
  330. assert "connection refused" in svc._last_error
  331. @pytest.mark.asyncio
  332. async def test_ml_api_empty_exception_message_falls_back_to_type(self):
  333. """If str(exc) is empty, log the exception class name instead of a blank suffix."""
  334. svc = ObicoDetectionService()
  335. settings = {
  336. "enabled": True,
  337. "ml_url": "http://obico:3333",
  338. "sensitivity": "medium",
  339. "action": "notify",
  340. "poll_interval": 10,
  341. "enabled_printers": None,
  342. "snapshot_base_url": "http://bambuddy:8000",
  343. }
  344. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  345. class _SilentError(Exception):
  346. def __str__(self) -> str:
  347. return ""
  348. mock_client = MagicMock()
  349. mock_client.get = AsyncMock(side_effect=_SilentError())
  350. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  351. mock_client.__aexit__ = AsyncMock(return_value=False)
  352. with (
  353. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  354. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  355. ):
  356. await svc._check_printer(1, status, settings)
  357. assert svc._last_error is not None
  358. assert "_SilentError" in svc._last_error
  359. # The suffix is never blank
  360. assert not svc._last_error.rstrip().endswith(":")
  361. @pytest.mark.asyncio
  362. async def test_failure_fires_action_only_once(self):
  363. """Once a failure has fired for a print, subsequent failures should not re-fire."""
  364. svc = ObicoDetectionService()
  365. settings = {
  366. "enabled": True,
  367. "ml_url": "http://obico:3333",
  368. "sensitivity": "medium",
  369. "action": "notify",
  370. "poll_interval": 10,
  371. "enabled_printers": None,
  372. "snapshot_base_url": "http://bambuddy:8000",
  373. }
  374. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  375. # Seed state so the next frame crosses HIGH immediately
  376. from backend.app.services.obico_smoothing import PrintState
  377. seeded = PrintState()
  378. for _ in range(WARMUP_FRAMES + 500):
  379. seeded.update(1.0)
  380. svc._states[1] = seeded
  381. svc._state_keys[1] = "job"
  382. svc._action_fired[1] = False
  383. mock_response = MagicMock()
  384. mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
  385. mock_response.raise_for_status = MagicMock()
  386. mock_client = MagicMock()
  387. mock_client.get = AsyncMock(return_value=mock_response)
  388. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  389. mock_client.__aexit__ = AsyncMock(return_value=False)
  390. with (
  391. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  392. patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
  393. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  394. ):
  395. await svc._check_printer(1, status, settings)
  396. assert mock_action.call_count == 1
  397. assert mock_action.await_args.args[-1] == FAKE_JPEG
  398. await svc._check_printer(1, status, settings)
  399. # Second call must not dispatch again
  400. assert mock_action.call_count == 1
  401. class TestCaptureFrameSharesBroadcasterUpstream:
  402. """#1271: Obico's per-poll snapshot must reuse the live-stream broadcaster's
  403. buffered frame when a viewer is watching, instead of opening a second RTSP
  404. socket. On X2D firmware 01.01.00.00 the second socket kicks the live stream.
  405. """
  406. @pytest.mark.asyncio
  407. async def test_returns_buffered_frame_when_stream_active(self):
  408. printer = MagicMock(
  409. external_camera_enabled=False,
  410. external_camera_url=None,
  411. ip_address="192.168.1.10",
  412. access_code="12345678",
  413. model="N6",
  414. )
  415. mock_session = MagicMock()
  416. mock_session.get = AsyncMock(return_value=printer)
  417. mock_ctx = MagicMock()
  418. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  419. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  420. svc = ObicoDetectionService()
  421. with (
  422. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  423. patch(
  424. "backend.app.api.routes.camera.is_stream_active",
  425. return_value=True,
  426. ),
  427. patch(
  428. "backend.app.api.routes.camera.try_get_active_buffered_frame",
  429. return_value=FAKE_JPEG,
  430. ),
  431. patch(
  432. "backend.app.services.camera.capture_camera_frame_bytes",
  433. new=AsyncMock(return_value=b"FRESH-CAPTURE-SHOULD-NOT-BE-USED"),
  434. ) as mock_fresh,
  435. ):
  436. result = await svc._capture_frame(printer_id=1)
  437. assert result == FAKE_JPEG
  438. mock_fresh.assert_not_called()
  439. @pytest.mark.asyncio
  440. async def test_skips_poll_when_stream_active_but_buffer_empty(self):
  441. """#1348: viewer attached + buffer empty must NOT open a competing socket."""
  442. printer = MagicMock(
  443. external_camera_enabled=False,
  444. external_camera_url=None,
  445. ip_address="192.168.1.10",
  446. access_code="12345678",
  447. model="X1C",
  448. )
  449. mock_session = MagicMock()
  450. mock_session.get = AsyncMock(return_value=printer)
  451. mock_ctx = MagicMock()
  452. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  453. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  454. svc = ObicoDetectionService()
  455. with (
  456. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  457. patch(
  458. "backend.app.api.routes.camera.is_stream_active",
  459. return_value=True,
  460. ),
  461. patch(
  462. "backend.app.api.routes.camera.try_get_active_buffered_frame",
  463. return_value=None, # Stream active, but first frame not buffered yet
  464. ),
  465. patch(
  466. "backend.app.services.camera.capture_camera_frame_bytes",
  467. new=AsyncMock(return_value=b"FRESH-CAPTURE-WOULD-KICK-VIEWER"),
  468. ) as mock_fresh,
  469. ):
  470. result = await svc._capture_frame(printer_id=1)
  471. assert result is None, "must skip this poll cycle, not open a competing socket"
  472. mock_fresh.assert_not_called()
  473. @pytest.mark.asyncio
  474. async def test_falls_back_to_fresh_capture_when_no_stream(self):
  475. printer = MagicMock(
  476. external_camera_enabled=False,
  477. external_camera_url=None,
  478. ip_address="192.168.1.10",
  479. access_code="12345678",
  480. model="N6",
  481. )
  482. mock_session = MagicMock()
  483. mock_session.get = AsyncMock(return_value=printer)
  484. mock_ctx = MagicMock()
  485. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  486. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  487. svc = ObicoDetectionService()
  488. with (
  489. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  490. patch(
  491. "backend.app.api.routes.camera.is_stream_active",
  492. return_value=False,
  493. ),
  494. patch(
  495. "backend.app.services.camera.capture_camera_frame_bytes",
  496. new=AsyncMock(return_value=FAKE_JPEG),
  497. ) as mock_fresh,
  498. ):
  499. result = await svc._capture_frame(printer_id=1)
  500. assert result == FAKE_JPEG
  501. mock_fresh.assert_called_once()
  502. class TestFrameCache:
  503. """One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.
  504. Obico's ML API fetches snapshots via `GET /p/?img=URL` with `timeout=(0.1, 5)`.
  505. Our /camera/snapshot can exceed that on cold calls (RTSP keyframe wait). So the
  506. detection loop captures locally, stashes the JPEG bytes under a nonce, then hands
  507. Obico a URL that returns those bytes instantly. The cache is single-use + TTLed
  508. so a leaked nonce can't be replayed.
  509. """
  510. def setup_method(self):
  511. _frame_cache.clear()
  512. @pytest.mark.asyncio
  513. async def test_stash_and_pop_roundtrip(self):
  514. nonce = await stash_frame(FAKE_JPEG)
  515. assert nonce # non-empty URL-safe token
  516. data = await pop_frame(nonce)
  517. assert data == FAKE_JPEG
  518. @pytest.mark.asyncio
  519. async def test_nonce_is_single_use(self):
  520. nonce = await stash_frame(FAKE_JPEG)
  521. assert await pop_frame(nonce) == FAKE_JPEG
  522. # Second pop returns None — caches replay protection
  523. assert await pop_frame(nonce) is None
  524. @pytest.mark.asyncio
  525. async def test_unknown_nonce_returns_none(self):
  526. assert await pop_frame("not-a-real-nonce") is None
  527. @pytest.mark.asyncio
  528. async def test_stash_produces_unique_nonces(self):
  529. nonces = {await stash_frame(FAKE_JPEG) for _ in range(10)}
  530. assert len(nonces) == 10
  531. @pytest.mark.asyncio
  532. async def test_expired_entries_are_pruned_on_stash(self):
  533. """New entries trigger pruning of TTL-expired ones — prevents unbounded growth."""
  534. # Manually seed an entry with a stale timestamp
  535. import time as time_module
  536. _frame_cache["stale-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
  537. await stash_frame(FAKE_JPEG)
  538. # Stale entry was pruned
  539. assert "stale-nonce" not in _frame_cache
  540. @pytest.mark.asyncio
  541. async def test_pop_rejects_expired_nonce(self):
  542. """Even if the entry is still in the dict, an expired TTL returns None."""
  543. import time as time_module
  544. _frame_cache["aging-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
  545. assert await pop_frame("aging-nonce") is None
  546. class TestProviderSwitch:
  547. async def test_refresh_cancels_inflight_poll_before_it_can_act(self):
  548. import asyncio
  549. svc = ObicoDetectionService()
  550. started = asyncio.Event()
  551. action = AsyncMock()
  552. async def pending_poll():
  553. started.set()
  554. await asyncio.Future()
  555. await action()
  556. svc._task = asyncio.create_task(pending_poll())
  557. await started.wait()
  558. with patch.object(svc, "start", new_callable=AsyncMock) as restart:
  559. await svc.refresh_settings()
  560. action.assert_not_awaited()
  561. restart.assert_awaited_once()
  562. assert svc._task is None
  563. class TestCheckPrinterUsesCachedFrameUrl:
  564. """The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot."""
  565. def setup_method(self):
  566. _frame_cache.clear()
  567. @pytest.mark.asyncio
  568. async def test_ml_api_called_with_cached_frame_url(self):
  569. svc = ObicoDetectionService()
  570. settings = {
  571. "enabled": True,
  572. "ml_url": "http://obico:3333",
  573. "sensitivity": "medium",
  574. "action": "notify",
  575. "poll_interval": 10,
  576. "enabled_printers": None,
  577. "snapshot_base_url": "http://bambuddy:8000",
  578. }
  579. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  580. mock_response = MagicMock()
  581. mock_response.json.return_value = {"detections": []}
  582. mock_response.raise_for_status = MagicMock()
  583. mock_client = MagicMock()
  584. mock_client.get = AsyncMock(return_value=mock_response)
  585. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  586. mock_client.__aexit__ = AsyncMock(return_value=False)
  587. with (
  588. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  589. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  590. ):
  591. await svc._check_printer(1, status, settings)
  592. # ML API was called via GET (Obico's /p/ is GET-only)
  593. mock_client.get.assert_called_once()
  594. _args, kwargs = mock_client.get.call_args
  595. assert _args[0] == "http://obico:3333/p/"
  596. img_url = kwargs["params"]["img"]
  597. assert img_url.startswith("http://bambuddy:8000/api/v1/obico/cached-frame/")
  598. # The path segment after /cached-frame/ is the nonce itself — that nonce must
  599. # resolve back to our stashed frame (single-use guarantees freshness).
  600. nonce = img_url.rsplit("/", 1)[-1]
  601. assert await pop_frame(nonce) == FAKE_JPEG
  602. @pytest.mark.asyncio
  603. async def test_capture_failure_skips_ml_call(self):
  604. """If we can't capture a frame, don't bother the ML API."""
  605. svc = ObicoDetectionService()
  606. settings = {
  607. "enabled": True,
  608. "ml_url": "http://obico:3333",
  609. "sensitivity": "medium",
  610. "action": "notify",
  611. "poll_interval": 10,
  612. "enabled_printers": None,
  613. "snapshot_base_url": "http://bambuddy:8000",
  614. }
  615. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  616. mock_client = MagicMock()
  617. mock_client.get = AsyncMock()
  618. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  619. mock_client.__aexit__ = AsyncMock(return_value=False)
  620. with (
  621. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  622. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)),
  623. ):
  624. await svc._check_printer(1, status, settings)
  625. mock_client.get.assert_not_called()
  626. assert svc._last_error is not None
  627. assert "Failed to capture snapshot" in svc._last_error
  628. @pytest.mark.asyncio
  629. async def test_missing_snapshot_address_skips_ml_call(self):
  630. """Without a snapshot address, Obico can't reach our cached-frame endpoint."""
  631. svc = ObicoDetectionService()
  632. settings = {
  633. "enabled": True,
  634. "ml_url": "http://obico:3333",
  635. "sensitivity": "medium",
  636. "action": "notify",
  637. "poll_interval": 10,
  638. "enabled_printers": None,
  639. "snapshot_base_url": "",
  640. }
  641. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  642. mock_client = MagicMock()
  643. mock_client.get = AsyncMock()
  644. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  645. mock_client.__aexit__ = AsyncMock(return_value=False)
  646. with (
  647. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  648. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  649. ):
  650. await svc._check_printer(1, status, settings)
  651. mock_client.get.assert_not_called()
  652. assert svc._last_error is not None
  653. assert "bambuddy_internal_url" in svc._last_error
  654. @pytest.mark.asyncio
  655. async def test_successful_cycle_clears_previous_error(self):
  656. """A cold-start RTSP timeout sets _last_error; the next successful poll must clear it.
  657. Regression for #172: the Status card banner ("Failed to capture snapshot for
  658. printer 1") stuck around after a one-off cold-start failure even though every
  659. subsequent poll captured + detected successfully.
  660. """
  661. svc = ObicoDetectionService()
  662. settings = {
  663. "enabled": True,
  664. "ml_url": "http://obico:3333",
  665. "sensitivity": "medium",
  666. "action": "notify",
  667. "poll_interval": 10,
  668. "enabled_printers": None,
  669. "snapshot_base_url": "http://bambuddy:8000",
  670. }
  671. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  672. # Seed a prior transient error, as would be left by a cold-start capture timeout.
  673. svc._last_error = "Failed to capture snapshot for printer 1"
  674. mock_response = MagicMock()
  675. mock_response.json.return_value = {"detections": []}
  676. mock_response.raise_for_status = MagicMock()
  677. mock_client = MagicMock()
  678. mock_client.get = AsyncMock(return_value=mock_response)
  679. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  680. mock_client.__aexit__ = AsyncMock(return_value=False)
  681. with (
  682. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  683. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  684. ):
  685. await svc._check_printer(1, status, settings)
  686. assert svc._last_error is None
  687. class TestNoVerdictIsNotSafe:
  688. """A printer nothing is looking at must not report itself as safe (#2952).
  689. ``get_per_printer`` used to default to ``"safe"`` whenever no verdict had
  690. been recorded, and the state entry is created when the print is first seen —
  691. before the first capture, let alone the first inference. So a rejected token,
  692. an unreachable ML API, a camera that never yields a frame and an unset
  693. External URL all rendered as a green "Safe" badge at score 0.000, identical
  694. to a healthy monitored print.
  695. The reporter of #2952 read exactly that, concluded the detection loop had
  696. never started, and spent an evening on the network path — while the loop was
  697. calling the ML API every 10s and being turned away with a 401 that Obico's
  698. auth decorator rejects before its own request log ever sees it.
  699. """
  700. SETTINGS = {
  701. "enabled": True,
  702. "ml_url": "http://obico:3333",
  703. "ml_token": "wrong-token",
  704. "sensitivity": "medium",
  705. "action": "notify",
  706. "poll_interval": 10,
  707. "enabled_printers": None,
  708. "snapshot_base_url": "http://bambuddy:8000",
  709. }
  710. @staticmethod
  711. def _status():
  712. return MagicMock(state="RUNNING", task_name="job", subtask_name="")
  713. @staticmethod
  714. def _client(**kwargs):
  715. client = MagicMock()
  716. client.get = AsyncMock(**kwargs)
  717. client.__aenter__ = AsyncMock(return_value=client)
  718. client.__aexit__ = AsyncMock(return_value=False)
  719. return client
  720. @pytest.mark.asyncio
  721. async def test_rejected_token_reports_error_and_names_the_setting(self):
  722. svc = ObicoDetectionService()
  723. response = MagicMock(status_code=401)
  724. with (
  725. patch(
  726. "backend.app.services.obico_detection.httpx.AsyncClient",
  727. return_value=self._client(return_value=response),
  728. ),
  729. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  730. ):
  731. await svc._check_printer(1, self._status(), self.SETTINGS)
  732. entry = svc.get_per_printer()[1]
  733. assert entry["class"] == "error"
  734. assert "ML API Token" in entry["error"]
  735. assert entry["frame_count"] == 0
  736. @pytest.mark.asyncio
  737. async def test_unreachable_ml_api_reports_error(self):
  738. svc = ObicoDetectionService()
  739. with (
  740. patch(
  741. "backend.app.services.obico_detection.httpx.AsyncClient",
  742. return_value=self._client(side_effect=RuntimeError("connection refused")),
  743. ),
  744. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  745. ):
  746. await svc._check_printer(1, self._status(), self.SETTINGS)
  747. entry = svc.get_per_printer()[1]
  748. assert entry["class"] == "error"
  749. assert "connection refused" in entry["error"]
  750. @pytest.mark.asyncio
  751. async def test_failed_capture_reports_error(self):
  752. svc = ObicoDetectionService()
  753. with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
  754. await svc._check_printer(1, self._status(), self.SETTINGS)
  755. entry = svc.get_per_printer()[1]
  756. assert entry["class"] == "error"
  757. assert "capture" in entry["error"].lower()
  758. @pytest.mark.asyncio
  759. async def test_missing_snapshot_address_reports_error(self):
  760. svc = ObicoDetectionService()
  761. settings = {**self.SETTINGS, "snapshot_base_url": ""}
  762. with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)):
  763. await svc._check_printer(1, self._status(), settings)
  764. entry = svc.get_per_printer()[1]
  765. assert entry["class"] == "error"
  766. assert "External URL" in entry["error"]
  767. @pytest.mark.asyncio
  768. async def test_a_recovered_printer_goes_back_to_a_real_verdict(self):
  769. """The error must not stick once polling works again — otherwise the
  770. badge trades one permanent lie for another."""
  771. svc = ObicoDetectionService()
  772. with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
  773. await svc._check_printer(1, self._status(), self.SETTINGS)
  774. assert svc.get_per_printer()[1]["class"] == "error"
  775. ok = MagicMock(status_code=200)
  776. ok.json.return_value = {"detections": []}
  777. ok.raise_for_status = MagicMock()
  778. with (
  779. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=self._client(return_value=ok)),
  780. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  781. ):
  782. await svc._check_printer(1, self._status(), self.SETTINGS)
  783. entry = svc.get_per_printer()[1]
  784. assert entry["class"] == "safe"
  785. assert entry["error"] is None
  786. assert entry["frame_count"] == 1
  787. @pytest.mark.asyncio
  788. async def test_state_exists_before_the_first_inference_reports_unknown(self):
  789. """The window between "print seen" and "first result" is not safe either."""
  790. from backend.app.services.obico_smoothing import PrintState
  791. svc = ObicoDetectionService()
  792. svc._states[1] = PrintState()
  793. svc._state_keys[1] = "job"
  794. entry = svc.get_per_printer()[1]
  795. assert entry["class"] == "unknown"
  796. assert entry["error"] is None
  797. @pytest.mark.asyncio
  798. async def test_error_is_cleared_when_the_print_ends(self):
  799. """A stale error must not carry into the next print's first poll."""
  800. svc = ObicoDetectionService()
  801. with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)):
  802. await svc._check_printer(1, self._status(), self.SETTINGS)
  803. assert 1 in svc._errors
  804. idle = MagicMock(state="IDLE", task_name="", subtask_name="")
  805. manager = MagicMock()
  806. manager.get_all_statuses.return_value = {1: idle}
  807. manager.is_connected.return_value = True
  808. with patch.dict(
  809. "sys.modules",
  810. {"backend.app.services.printer_manager": MagicMock(printer_manager=manager)},
  811. ):
  812. await svc._poll_once(self.SETTINGS)
  813. assert svc._errors == {}
  814. assert svc._last_class == {}
  815. assert svc.get_per_printer() == {}