test_finish_photo_moment_sync.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332
  1. """Regression tests for the #1790 producer-consumer synchronization.
  2. `on_finish_photo_moment` (producer) and `_background_finish_photo`
  3. (consumer) are dispatched back-to-back on the FINISH-state fallback path
  4. (`bambu_mqtt.py:3258-3297`). Before #1790, the consumer ran a single
  5. `pop()` on `_stage22_finish_frames` with no wait — racing past the
  6. producer with an empty result, then doing its own RTSP grab that
  7. collided with the producer's still-in-flight grab (Bambu printers allow
  8. one RTSP client). Net result: a captured frame was logged, the cache
  9. was populated ~1s later, but the notification went text-only.
  10. The fix is an `asyncio.Event` per printer registered in
  11. `_stage22_finish_in_flight` by the producer and awaited (with timeout)
  12. by the consumer. These tests pin the producer side of that contract.
  13. """
  14. import asyncio
  15. import logging
  16. from contextlib import asynccontextmanager
  17. from datetime import datetime, timezone
  18. from types import SimpleNamespace
  19. from unittest.mock import AsyncMock
  20. import pytest
  21. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  22. import backend.app.models # noqa: F401 - populate Base.metadata
  23. from backend.app import main as main_module
  24. from backend.app.core.database import Base
  25. from backend.app.main import on_finish_photo_moment
  26. from backend.app.models.archive import PrintArchive
  27. from backend.app.services import print_dispatch_context
  28. HEIGHTS = main_module.PlateHeights(16.0, 114.0)
  29. @asynccontextmanager
  30. async def _fake_session(printer):
  31. """Async-session stub that returns `printer` from scalar_one_or_none()."""
  32. result = SimpleNamespace(scalar_one_or_none=lambda: printer)
  33. session = SimpleNamespace(execute=AsyncMock(return_value=result))
  34. yield session
  35. @pytest.fixture
  36. def fake_printer():
  37. return SimpleNamespace(
  38. id=7,
  39. ip_address="192.0.2.7",
  40. access_code="x",
  41. model="X1C",
  42. external_camera_enabled=False,
  43. external_camera_url=None,
  44. external_camera_type=None,
  45. external_camera_snapshot_url=None,
  46. )
  47. @pytest.fixture(autouse=True)
  48. def _clean_state():
  49. """Don't leak event/cache dict entries across tests."""
  50. main_module._stage22_finish_in_flight.clear()
  51. main_module._stage22_finish_frames.clear()
  52. main_module._inprint_frame_bank.clear()
  53. main_module._inprint_frame_bank_ts.clear()
  54. print_dispatch_context.clear(7)
  55. yield
  56. main_module._stage22_finish_in_flight.clear()
  57. main_module._stage22_finish_frames.clear()
  58. main_module._inprint_frame_bank.clear()
  59. main_module._inprint_frame_bank_ts.clear()
  60. print_dispatch_context.clear(7)
  61. @pytest.fixture
  62. def patched_env(fake_printer, monkeypatch):
  63. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
  64. async def _get_setting(_db, key):
  65. if key == "capture_finish_photo":
  66. return "true"
  67. return None
  68. monkeypatch.setattr(
  69. "backend.app.api.routes.settings.get_setting",
  70. _get_setting,
  71. )
  72. monkeypatch.setattr(
  73. "backend.app.api.routes.camera.get_buffered_frame",
  74. lambda _pid: None,
  75. )
  76. # #2547: default the plate restore to "print height unknown", so tests that
  77. # aren't about the restore never reach the G-code path. Tests that ARE about
  78. # it override these two.
  79. async def _no_height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
  80. return None
  81. async def _not_blocked(_printer_id):
  82. return False
  83. monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _no_height)
  84. monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked)
  85. return fake_printer
  86. async def test_event_registered_before_first_await(patched_env, monkeypatch):
  87. """The consumer needs to find the event the moment it polls — that
  88. means registration must complete BEFORE any `await` yields control
  89. back to the loop."""
  90. # Slow the first await (DB session entry) so we can observe the dict
  91. # before the producer makes any real progress.
  92. seen_during_capture = {}
  93. async def _slow_capture(**_kwargs):
  94. seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
  95. await asyncio.sleep(0)
  96. return b"\xff\xd8frame"
  97. monkeypatch.setattr(
  98. "backend.app.services.camera.capture_camera_frame_bytes",
  99. _slow_capture,
  100. )
  101. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  102. assert seen_during_capture["registered"] is True
  103. async def test_event_set_after_successful_capture(patched_env, monkeypatch):
  104. async def _capture(**_kwargs):
  105. return b"\xff\xd8frame"
  106. monkeypatch.setattr(
  107. "backend.app.services.camera.capture_camera_frame_bytes",
  108. _capture,
  109. )
  110. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  111. event = main_module._stage22_finish_in_flight[patched_env.id]
  112. assert event.is_set()
  113. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  114. async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
  115. """Producer gives up (RTSP timeout, no buffered frame, no external
  116. camera) — consumer must NOT wait the full 20s for nothing."""
  117. async def _capture(**_kwargs):
  118. return None
  119. monkeypatch.setattr(
  120. "backend.app.services.camera.capture_camera_frame_bytes",
  121. _capture,
  122. )
  123. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  124. event = main_module._stage22_finish_in_flight[patched_env.id]
  125. assert event.is_set()
  126. assert patched_env.id not in main_module._stage22_finish_frames
  127. async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
  128. """Producer hit a bug or network error — `finally` still has to
  129. release the consumer."""
  130. async def _capture(**_kwargs):
  131. raise RuntimeError("camera went away")
  132. monkeypatch.setattr(
  133. "backend.app.services.camera.capture_camera_frame_bytes",
  134. _capture,
  135. )
  136. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  137. event = main_module._stage22_finish_in_flight[patched_env.id]
  138. assert event.is_set()
  139. async def test_no_event_when_timelapse_was_active(patched_env):
  140. """On the timelapse-on path the consumer takes the
  141. `_capture_finish_photo_from_timelapse` branch and shouldn't be
  142. blocked by a producer wait — the producer doesn't enter the
  143. lifecycle."""
  144. await on_finish_photo_moment(
  145. patched_env.id,
  146. {"trigger": "stage_22", "timelapse_was_active": True},
  147. )
  148. assert patched_env.id not in main_module._stage22_finish_in_flight
  149. async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
  150. """Even on the early-return-before-capture path, the event must be
  151. released so the consumer doesn't hang on a no-op producer."""
  152. async def _disabled_setting(_db, _key):
  153. return "false"
  154. monkeypatch.setattr(
  155. "backend.app.api.routes.settings.get_setting",
  156. _disabled_setting,
  157. )
  158. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  159. event = main_module._stage22_finish_in_flight[patched_env.id]
  160. assert event.is_set()
  161. async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
  162. """End-to-end sync check: a consumer-style waiter awaiting the
  163. event finishes promptly once the producer's finally fires."""
  164. async def _capture(**_kwargs):
  165. await asyncio.sleep(0.05)
  166. return b"\xff\xd8frame"
  167. monkeypatch.setattr(
  168. "backend.app.services.camera.capture_camera_frame_bytes",
  169. _capture,
  170. )
  171. producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
  172. await asyncio.sleep(0) # let the producer register
  173. event = main_module._stage22_finish_in_flight[patched_env.id]
  174. await asyncio.wait_for(event.wait(), timeout=1.0)
  175. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  176. await producer
  177. async def test_finish_state_prefers_banked_frame_when_end_gcode_was_injected(patched_env, monkeypatch):
  178. """#1867: when Bambuddy injected End G-code, a SwapMod snippet may already
  179. have ejected the plate by FINISH — so the banked in-print frame is used and
  180. the live grab must not run."""
  181. print_dispatch_context.mark_pending(patched_env.id)
  182. print_dispatch_context.adopt(patched_env.id)
  183. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  184. live_called = {"n": 0}
  185. async def _live(**_kwargs):
  186. live_called["n"] += 1
  187. return b"\xff\xd8live-post-swap"
  188. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  189. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  190. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked"
  191. assert live_called["n"] == 0
  192. async def test_finish_state_grabs_live_when_no_end_gcode_was_injected(patched_env, monkeypatch):
  193. """#2547: the ordinary case. Nothing moved the plate, the toolhead is
  194. parked, and the print is still sitting there — so the live frame is the
  195. finished print, and a banked mid-print frame must NOT win over it.
  196. Preferring the bank here unconditionally, which is what this code used to
  197. do, is how the H2C shipped a photo with the toolhead over the part."""
  198. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked-midprint"
  199. async def _live(**_kwargs):
  200. return b"\xff\xd8live-finished-print"
  201. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  202. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  203. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live-finished-print"
  204. async def test_finish_state_falls_back_to_live_when_bank_is_empty(patched_env, monkeypatch):
  205. """End G-code was injected but nothing was ever banked (feature just
  206. enabled, tiny print, capture failures). Degrade to a live grab rather than
  207. sending a text-only notification."""
  208. print_dispatch_context.mark_pending(patched_env.id)
  209. print_dispatch_context.adopt(patched_env.id)
  210. async def _live(**_kwargs):
  211. return b"\xff\xd8live"
  212. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  213. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  214. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
  215. async def test_stage_22_trigger_ignores_bank(patched_env, monkeypatch):
  216. """The `stage_22` trigger fires before any End G-code and gives cleaner
  217. (parked-toolhead, plate-still-up) framing via a live grab — the bank is only
  218. for the post-swap `finish_state` path, so it must be ignored here."""
  219. print_dispatch_context.mark_pending(patched_env.id)
  220. print_dispatch_context.adopt(patched_env.id)
  221. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  222. async def _live(**_kwargs):
  223. return b"\xff\xd8live"
  224. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  225. await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
  226. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
  227. # --- #1867 banking helper (_maybe_bank_inprint_frame) --------------------
  228. def _bank_env(monkeypatch, *, state="RUNNING", sub_stage=0, total_layers=10, printer=object()):
  229. """Wire printer_manager.get_client + the snapshot capture for the bank
  230. helper. Capture returns a distinct frame per call so updates are visible."""
  231. client = SimpleNamespace(
  232. state=SimpleNamespace(state=state, mc_print_sub_stage=sub_stage, total_layers=total_layers)
  233. )
  234. monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
  235. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(printer))
  236. counter = {"n": 0, "light": []}
  237. async def _capture(_pid, _printer, _logger, *, light=True):
  238. counter["n"] += 1
  239. counter["light"].append(light)
  240. return f"frame-{counter['n']}".encode()
  241. monkeypatch.setattr(main_module, "_capture_snapshot_for_notification", _capture)
  242. return counter
  243. async def test_bank_stores_frame_while_printing(monkeypatch):
  244. _bank_env(monkeypatch)
  245. await main_module._maybe_bank_inprint_frame(3, 5)
  246. assert main_module._inprint_frame_bank[3] == b"frame-1"
  247. async def test_bank_leaves_the_chamber_light_alone(monkeypatch):
  248. """#1655: the bank grabs a frame every 25 seconds all through a print, so
  249. holding the chamber light for it would make the light flash."""
  250. counter = _bank_env(monkeypatch)
  251. await main_module._maybe_bank_inprint_frame(3, 5)
  252. assert counter["light"] == [False]
  253. async def test_bank_throttles_within_interval(monkeypatch):
  254. counter = _bank_env(monkeypatch)
  255. await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
  256. await main_module._maybe_bank_inprint_frame(3, 6) # within 25s -> skipped
  257. assert counter["n"] == 1
  258. assert main_module._inprint_frame_bank[3] == b"frame-1"
  259. async def test_bank_throttles_on_the_last_layer_too(monkeypatch):
  260. """#2547: the last layer used to bypass the throttle so it always got a
  261. fresh frame. Now that progress advances also drive banking, that exemption
  262. would fire a camera grab on every percent tick of a multi-minute last layer
  263. — and each grab contends with the live view for the single RTSP slot."""
  264. counter = _bank_env(monkeypatch, total_layers=10)
  265. await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
  266. await main_module._maybe_bank_inprint_frame(3, 10) # last layer, within 25s
  267. assert counter["n"] == 1
  268. assert main_module._inprint_frame_bank[3] == b"frame-1"
  269. async def test_bank_refreshes_on_the_last_layer_once_the_throttle_elapses(monkeypatch):
  270. """The point of banking on progress: a three-minute last layer keeps
  271. refreshing instead of freezing at the moment that layer began."""
  272. counter = _bank_env(monkeypatch, total_layers=10)
  273. await main_module._maybe_bank_inprint_frame(3, 10) # banks frame-1
  274. # Pretend the throttle window has passed, as it does mid-last-layer.
  275. main_module._inprint_frame_bank_ts[3] -= main_module._INPRINT_BANK_MIN_INTERVAL + 1
  276. await main_module._maybe_bank_inprint_frame(3, 10)
  277. assert counter["n"] == 2
  278. assert main_module._inprint_frame_bank[3] == b"frame-2"
  279. async def test_bank_skips_when_not_running(monkeypatch):
  280. """End G-code (plate swap) runs after RUNNING ends — the bank must not
  281. update then, which is what freezes it on the finished print."""
  282. _bank_env(monkeypatch, state="FINISH")
  283. await main_module._maybe_bank_inprint_frame(3, 10)
  284. assert 3 not in main_module._inprint_frame_bank
  285. async def test_bank_skips_during_calibration_substage(monkeypatch):
  286. """layer_num ticks during pre-print calibration (non-zero sub-stage) —
  287. banking then would capture an empty bed."""
  288. _bank_env(monkeypatch, sub_stage=14)
  289. await main_module._maybe_bank_inprint_frame(3, 2)
  290. assert 3 not in main_module._inprint_frame_bank
  291. class TestStage22CacheHoldsExactlyOneRotation:
  292. """#2708. `_stage22_finish_frames` is fed from two kinds of source: live
  293. grabs, which are raw, and the #1867 in-print bank, whose bytes came from
  294. `_capture_snapshot_for_notification` and are therefore ALREADY rotated.
  295. The consumer cannot tell them apart, so the producer normalises: every
  296. entry in the cache has had the rotation applied exactly once.
  297. Rotating on the consumer side instead put two rotations on the banked
  298. path — at 180 degrees that is the reported bug reproduced exactly, and at
  299. 90/270 it lands the photo 180 degrees out.
  300. """
  301. @staticmethod
  302. def _jpeg(width, height):
  303. import io
  304. from PIL import Image
  305. buf = io.BytesIO()
  306. Image.new("RGB", (width, height), (0, 0, 255)).save(buf, format="JPEG")
  307. return buf.getvalue()
  308. @staticmethod
  309. def _size(data):
  310. import io
  311. from PIL import Image
  312. return Image.open(io.BytesIO(data)).size
  313. async def test_a_live_grab_is_rotated_before_caching(self, patched_env, monkeypatch):
  314. monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
  315. raw = self._jpeg(64, 32)
  316. async def _capture(**_kwargs):
  317. return raw
  318. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
  319. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  320. cached = main_module._stage22_finish_frames[patched_env.id]
  321. assert self._size(cached) == (32, 64)
  322. async def test_the_banked_frame_is_cached_verbatim(self, patched_env, monkeypatch):
  323. """The bank is filled by `_capture_snapshot_for_notification`, which
  324. rotates before it returns — so the producer must pass those bytes
  325. through untouched rather than rotating them a second time.
  326. Note this pins the invariant forward; it does not on its own prove the
  327. bug fixed, because the old producer didn't rotate anything either. The
  328. pair that discriminates is `test_a_live_grab_is_rotated_before_caching`
  329. (producer now rotates) plus the source guard below (consumer no longer
  330. does).
  331. """
  332. monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
  333. # #2547: the bank is only preferred when End G-code was injected.
  334. print_dispatch_context.mark_pending(patched_env.id)
  335. print_dispatch_context.adopt(patched_env.id)
  336. already_rotated = self._jpeg(32, 64) # what one rotation of a 64x32 frame looks like
  337. main_module._inprint_frame_bank[patched_env.id] = already_rotated
  338. async def _capture(**_kwargs): # pragma: no cover - must not be reached
  339. raise AssertionError("the banked frame should have been preferred")
  340. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
  341. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  342. cached = main_module._stage22_finish_frames[patched_env.id]
  343. assert cached is already_rotated
  344. assert self._size(cached) == (32, 64)
  345. async def test_a_stage22_grab_is_rotated_even_though_the_bank_is_full(self, patched_env, monkeypatch):
  346. """Only the `finish_state` trigger reads the bank. The `stage_22` and
  347. `last_layer` triggers take a live grab, which still needs rotating —
  348. a shared "did we use the bank" flag must not latch on the bank merely
  349. existing."""
  350. monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
  351. main_module._inprint_frame_bank[patched_env.id] = self._jpeg(999, 1)
  352. async def _capture(**_kwargs):
  353. return self._jpeg(64, 32)
  354. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
  355. await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
  356. cached = main_module._stage22_finish_frames[patched_env.id]
  357. assert self._size(cached) == (32, 64)
  358. async def test_no_rotation_configured_caches_the_bytes_as_captured(self, patched_env, monkeypatch):
  359. raw = self._jpeg(64, 32)
  360. async def _capture(**_kwargs):
  361. return raw
  362. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
  363. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  364. assert main_module._stage22_finish_frames[patched_env.id] is raw
  365. def test_the_consumer_does_not_rotate_the_cached_frame():
  366. """The other half of the #2708 invariant, and the half with no runtime
  367. harness: `_background_finish_photo` is a closure nested inside
  368. `on_print_complete`, so nothing can drive its cached-frame branch
  369. directly. What it must NOT do is rotate what it pops from
  370. `_stage22_finish_frames` — the producer has already done that, and doing
  371. it again upside-downs the banked path, which is the bug this fixed.
  372. Checked against the source because the alternative is no check at all.
  373. """
  374. import ast
  375. from pathlib import Path
  376. main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
  377. assert main_py.exists(), f"guard is looking in the wrong place: {main_py}"
  378. tree = ast.parse(main_py.read_text())
  379. offenders = [
  380. node.lineno
  381. for node in ast.walk(tree)
  382. if isinstance(node, ast.Call)
  383. and isinstance(node.func, ast.Name)
  384. and node.func.id == "_apply_camera_rotation"
  385. and node.args
  386. and isinstance(node.args[0], ast.Name)
  387. and node.args[0].id == "cached_frame"
  388. ]
  389. assert not offenders, (
  390. f"main.py:{offenders} rotates the frame popped from _stage22_finish_frames. "
  391. "Those bytes are already rotated by on_finish_photo_moment (#2708); rotating "
  392. "again returns a 180-degree print to upside-down."
  393. )
  394. class TestPlateRestore:
  395. """#2547 / #1145 / #1397 / #1565: put the plate back into camera framing.
  396. On X1/P1/A1 Bambu's end G-code drops the plate ~100mm as the last thing it
  397. does, so by FINISH the finished print sits well below where the camera
  398. frames it. The restore commands an ABSOLUTE Z back to just above the last
  399. printed layer.
  400. Absolute is the safety argument, and these tests pin it: the target is a
  401. height the toolhead was physically at seconds earlier, so it is inside the
  402. travel limits and leaves the nozzle above the part. It is also unambiguous
  403. across model families — Z is the nozzle-to-bed gap whether the bed moves or
  404. the toolhead does — so there is no sign to get wrong the way the relative
  405. bed-jog path had (#1334).
  406. """
  407. @pytest.fixture
  408. def printer_client(self, monkeypatch):
  409. sent: list[str] = []
  410. client = SimpleNamespace(
  411. state=SimpleNamespace(state="FINISH"),
  412. send_gcode=lambda gcode: (sent.append(gcode), True)[1],
  413. )
  414. monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
  415. monkeypatch.setattr(main_module.asyncio, "sleep", AsyncMock())
  416. client.sent = sent
  417. return client
  418. async def test_commands_an_absolute_move_above_the_print(self, printer_client):
  419. ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
  420. assert ok is True
  421. assert printer_client.sent == ["G90\nG1 Z26.00 F600"]
  422. async def test_never_touches_m211(self, printer_client):
  423. """#2579: disabling soft endstops is what let a jog drive the nozzle
  424. into the bed. This path must not reintroduce it."""
  425. await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
  426. assert not any("M211" in line for line in printer_client.sent)
  427. async def test_skipped_when_the_printer_is_no_longer_in_finish(self, printer_client):
  428. """The queue dispatches the next job the instant a print completes.
  429. Commanding a plate move into a starting print is not a race worth
  430. having, so state is re-read immediately before the move."""
  431. printer_client.state.state = "RUNNING"
  432. ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
  433. assert ok is False
  434. assert printer_client.sent == []
  435. async def test_skipped_when_the_printer_is_gone(self, monkeypatch):
  436. monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: None)
  437. ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
  438. assert ok is False
  439. async def test_reports_failure_when_the_send_fails(self, printer_client, monkeypatch):
  440. """A failed send means the plate never moved — the caller must not go on
  441. to owe it a move back down."""
  442. monkeypatch.setattr(printer_client, "send_gcode", lambda _g: False)
  443. ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
  444. assert ok is False
  445. def test_park_returns_the_plate_to_the_end_gcode_position(self, printer_client):
  446. """The park target is the Z the print's own end G-code left the plate
  447. at, sent as given. It used to be print top + 100 mm, which on a 238 mm
  448. H2C print drove the plate to the bottom of its travel: the firmware
  449. doesn't clamp MQTT G-code (#2579), and Bambu's template had stopped at
  450. print top + 2 mm for exactly that reason."""
  451. main_module._park_plate_after_finish_photo(7, 239.8, logging.getLogger(__name__))
  452. assert printer_client.sent == ["G90\nG1 Z239.80 F600"]
  453. def test_park_never_touches_m211(self, printer_client):
  454. main_module._park_plate_after_finish_photo(7, 114.0, logging.getLogger(__name__))
  455. assert not any("M211" in line for line in printer_client.sent)
  456. def test_park_skipped_once_the_next_print_has_started(self, printer_client):
  457. printer_client.state.state = "RUNNING"
  458. main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
  459. assert printer_client.sent == []
  460. class TestPlateRestoreWiring:
  461. """The restore only runs in the one situation it is correct for, and the
  462. plate always comes back down afterwards."""
  463. @pytest.fixture
  464. def restore_env(self, patched_env, monkeypatch):
  465. calls = {
  466. "restore": [],
  467. "park": [],
  468. "blocked": False,
  469. "height": main_module.PlateHeights(16.0, 114.0),
  470. "bound": [],
  471. }
  472. async def _height(_printer_id, _data, _logger, archive_id, plate_id=None):
  473. calls["bound"].append((archive_id, plate_id))
  474. return calls["height"]
  475. async def _blocked(_printer_id):
  476. return calls["blocked"]
  477. async def _restore(printer_id, max_z, _logger):
  478. calls["restore"].append((printer_id, max_z))
  479. return True
  480. def _park(printer_id, end_z, _logger):
  481. calls["park"].append((printer_id, end_z))
  482. monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _height)
  483. monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
  484. monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
  485. monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park)
  486. async def _live(**_kwargs):
  487. return b"\xff\xd8live"
  488. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  489. return calls
  490. async def test_restores_then_parks_on_the_finish_state_path(self, patched_env, restore_env):
  491. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  492. assert restore_env["restore"] == [(patched_env.id, 16.0)]
  493. assert restore_env["park"] == [(patched_env.id, 114.0)]
  494. async def test_not_restored_when_the_end_gcode_left_the_plate_in_view(self, patched_env, restore_env):
  495. """Archive 454: the H2C end G-code ended 2 mm under the nozzle (Z239.8
  496. for a 237.8 mm print). Raising "back" to print top + 10 would have
  497. lowered it, and the park after that sent it to the bottom."""
  498. restore_env["height"] = main_module.PlateHeights(237.8, 239.8)
  499. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  500. assert restore_env["restore"] == []
  501. assert restore_env["park"] == []
  502. async def test_restored_only_when_the_plate_is_below_the_restore_height(self, patched_env, restore_env):
  503. for end_z, moved in ((26.0, False), (26.01, True)):
  504. restore_env["restore"].clear()
  505. restore_env["height"] = main_module.PlateHeights(16.0, end_z)
  506. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  507. assert bool(restore_env["restore"]) is moved, end_z
  508. async def test_reads_the_binding_before_on_print_complete_pops_it(self, patched_env, restore_env, monkeypatch):
  509. """#3240: on the FINISH-state path on_print_complete is dispatched right
  510. behind the producer and pops the print's archive binding and plate. The
  511. producer must have read them first, or the restore has nothing bound to
  512. move for and never runs."""
  513. async def _yielding_get_setting(_db, key):
  514. # A real settings read suspends; this is where on_print_complete
  515. # gets to run.
  516. await asyncio.sleep(0)
  517. return "true" if key == "capture_finish_photo" else None
  518. monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _yielding_get_setting)
  519. main_module._active_prints[(patched_env.id, "job.3mf")] = 5
  520. main_module._print_plate_ids[(patched_env.id, 5)] = 3
  521. async def _on_print_complete_pops():
  522. main_module._active_prints.clear()
  523. main_module._print_plate_ids.clear()
  524. try:
  525. data = {"trigger": "finish_state", "filename": "job.3mf", "subtask_name": "job"}
  526. producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, data))
  527. completion = asyncio.create_task(_on_print_complete_pops())
  528. await asyncio.gather(producer, completion)
  529. finally:
  530. main_module._active_prints.clear()
  531. main_module._print_plate_ids.clear()
  532. assert restore_env["bound"] == [(5, 3)]
  533. async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
  534. """Stage 22 fires before the end G-code drops the plate — it is already
  535. where we want it, and moving it would only cost the settle delay."""
  536. await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
  537. assert restore_env["restore"] == []
  538. assert restore_env["park"] == []
  539. async def test_not_restored_when_the_banked_frame_is_used(self, patched_env, restore_env):
  540. """The plate has been swapped out — no move brings the print back."""
  541. print_dispatch_context.mark_pending(patched_env.id)
  542. print_dispatch_context.adopt(patched_env.id)
  543. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  544. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  545. assert restore_env["restore"] == []
  546. assert restore_env["park"] == []
  547. async def test_not_restored_when_end_gcode_was_injected_but_the_bank_is_empty(self, patched_env, restore_env):
  548. """A plate-swap machine may have just ejected its plate. Even with no
  549. banked frame to fall back on, driving Z into whatever a swap mechanism
  550. is doing is not worth a photo of a bed we know may be bare."""
  551. print_dispatch_context.mark_pending(patched_env.id)
  552. print_dispatch_context.adopt(patched_env.id)
  553. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  554. assert restore_env["restore"] == []
  555. assert restore_env["park"] == []
  556. async def test_not_restored_when_the_print_height_is_unknown(self, patched_env, restore_env):
  557. restore_env["height"] = None
  558. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  559. assert restore_env["restore"] == []
  560. assert restore_env["park"] == []
  561. async def test_not_restored_when_another_job_is_queued(self, patched_env, restore_env):
  562. restore_env["blocked"] = True
  563. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  564. assert restore_env["restore"] == []
  565. assert restore_env["park"] == []
  566. async def test_not_restored_when_the_setting_is_off(self, patched_env, restore_env, monkeypatch):
  567. async def _get_setting(_db, key):
  568. if key == "capture_finish_photo":
  569. return "true"
  570. if key == "finish_photo_restore_plate":
  571. return "false"
  572. return None
  573. monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
  574. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  575. assert restore_env["restore"] == []
  576. assert restore_env["park"] == []
  577. async def test_plate_is_parked_even_when_the_capture_throws(self, patched_env, restore_env, monkeypatch):
  578. """We raised it, so we owe the move back down — including when the grab
  579. between the two fails. Otherwise the user finds the print pinned under
  580. the nozzle."""
  581. async def _boom(**_kwargs):
  582. raise RuntimeError("camera gone")
  583. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _boom)
  584. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  585. assert restore_env["restore"] == [(patched_env.id, 16.0)]
  586. assert restore_env["park"] == [(patched_env.id, 114.0)]
  587. async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env):
  588. """#1790: the consumer's bounded wait must be released on every exit,
  589. and the restore added a new path through the producer."""
  590. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  591. assert main_module._stage22_finish_in_flight[patched_env.id].is_set()
  592. class TestRaisePlateDecision:
  593. """``_raise_plate_for_finish_photo``: the one place both finish-photo paths
  594. decide whether to move the plate and where to park it afterwards."""
  595. @pytest.fixture
  596. def decision_env(self, monkeypatch):
  597. env = {"heights": HEIGHTS, "blocked": False, "restore": [], "restored": True}
  598. async def _heights(*_args):
  599. return env["heights"]
  600. async def _blocked(_printer_id):
  601. return env["blocked"]
  602. async def _restore(printer_id, print_top, _logger):
  603. env["restore"].append((printer_id, print_top))
  604. return env["restored"]
  605. monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _heights)
  606. monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
  607. monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
  608. return env
  609. @staticmethod
  610. async def _decide():
  611. return await main_module._raise_plate_for_finish_photo(7, {}, logging.getLogger(__name__), 5, 1)
  612. async def test_raises_and_parks_at_the_end_gcode_position(self, decision_env):
  613. assert await self._decide() == 114.0
  614. assert decision_env["restore"] == [(7, 16.0)]
  615. async def test_no_move_when_the_plate_is_already_in_view(self, decision_env):
  616. decision_env["heights"] = main_module.PlateHeights(237.8, 239.8)
  617. assert await self._decide() is None
  618. assert decision_env["restore"] == []
  619. async def test_no_move_when_heights_are_unknown(self, decision_env):
  620. decision_env["heights"] = None
  621. assert await self._decide() is None
  622. assert decision_env["restore"] == []
  623. async def test_no_move_when_the_queue_has_work(self, decision_env):
  624. decision_env["blocked"] = True
  625. assert await self._decide() is None
  626. assert decision_env["restore"] == []
  627. async def test_nothing_to_park_when_the_restore_did_not_happen(self, decision_env):
  628. decision_env["restored"] = False
  629. assert await self._decide() is None
  630. def test_every_plate_raise_goes_through_the_decision():
  631. """Both finish-photo paths raise the plate. The park target is safe only
  632. because it comes from ``_raise_plate_for_finish_photo``, so no other code
  633. may call the raise directly, and nothing may compute a park height."""
  634. import ast
  635. import inspect
  636. tree = ast.parse(inspect.getsource(main_module))
  637. callers = set()
  638. for fn in ast.walk(tree):
  639. if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
  640. continue
  641. for node in ast.walk(fn):
  642. if (
  643. isinstance(node, ast.Call)
  644. and isinstance(node.func, ast.Name)
  645. and node.func.id == "_restore_plate_for_finish_photo"
  646. ):
  647. callers.add(fn.name)
  648. assert callers == {"_raise_plate_for_finish_photo"}
  649. assert not hasattr(main_module, "_PLATE_PARK_DROP_MM")
  650. async def test_producer_wait_budget_covers_the_restore():
  651. """The consumer's wait has to outlast settle + a worst-case RTSP grab, and
  652. still finish inside the notification's own photo budget — otherwise the
  653. restore path is cut off by a timeout somewhere above it."""
  654. assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
  655. def test_producer_wait_budget_covers_the_chamber_light_delay():
  656. """#1655: the producer's live grab can first wait for the chamber light.
  657. Settle + the longest light delay + a worst-case grab must still fit, or the
  658. consumer gives up and its fallback grab races the producer's (#1790)."""
  659. from backend.app.services.camera_light import MAX_DELAY_SECONDS
  660. assert (
  661. main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
  662. > main_module._PLATE_RESTORE_SETTLE_SECONDS + MAX_DELAY_SECONDS + 15
  663. )
  664. class TestMaxZResolution:
  665. """#2547 safety: the height that becomes a Z-move target must provably
  666. belong to the print that just finished.
  667. A height from another print is the one failure mode that could drive the
  668. nozzle into the model — 20mm carried onto a 200mm print commands the plate
  669. up through the part. So the resolver refuses on every ambiguity rather than
  670. falling back to "whatever ran last on this printer".
  671. Run against a real database (#3240): the earlier tests faked the session,
  672. so neither the query nor the path the archive's file is read from was ever
  673. exercised, and both were wrong.
  674. """
  675. @pytest.fixture
  676. async def resolver_env(self, monkeypatch, tmp_path):
  677. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  678. async with engine.begin() as conn:
  679. await conn.run_sync(Base.metadata.create_all)
  680. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  681. env = {"reported_layers": 30, "height": 16.0, "end_z": 114.0, "read": [], "session_maker": session_maker}
  682. monkeypatch.setattr(main_module, "async_session", session_maker)
  683. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  684. monkeypatch.setattr(
  685. main_module.printer_manager,
  686. "get_client",
  687. lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
  688. )
  689. def _extract(path, plate):
  690. env["read"].append((path, plate))
  691. return env["height"]
  692. def _extract_end(path, plate):
  693. env["read_end"] = (path, plate)
  694. return env["end_z"]
  695. monkeypatch.setattr("backend.app.utils.threemf_tools.extract_max_z_height_from_3mf", _extract)
  696. monkeypatch.setattr("backend.app.utils.threemf_tools.extract_end_z_from_3mf", _extract_end)
  697. try:
  698. yield env
  699. finally:
  700. await engine.dispose()
  701. @staticmethod
  702. async def _archive(env, **overrides) -> int:
  703. values = {
  704. "printer_id": 1,
  705. "filename": "job.gcode.3mf",
  706. "file_path": "archive/1/job/job.gcode.3mf",
  707. "file_size": 1,
  708. "print_name": "job",
  709. "status": "completed",
  710. "plate_id": 1,
  711. "total_layers": 30,
  712. }
  713. values.update(overrides)
  714. async with env["session_maker"]() as db:
  715. archive = PrintArchive(**values)
  716. db.add(archive)
  717. await db.commit()
  718. return archive.id
  719. @staticmethod
  720. async def _resolve(name, archive_id, plate_id=None):
  721. data = {"subtask_name": name} if name is not None else {}
  722. return await main_module._plate_heights_for_current_print(
  723. 1, data, logging.getLogger(__name__), archive_id, plate_id
  724. )
  725. async def test_returns_the_height_when_binding_name_and_layers_agree(self, resolver_env):
  726. archive_id = await self._archive(resolver_env)
  727. assert await self._resolve("job", archive_id) == HEIGHTS
  728. async def test_refuses_a_print_that_is_not_bound_to_an_archive(self, resolver_env):
  729. """A name alone is never enough: with no binding there is no move, even
  730. when an archive of exactly that name exists."""
  731. await self._archive(resolver_env)
  732. assert await self._resolve("job", None) is None
  733. assert resolver_env["read"] == []
  734. async def test_uses_the_bound_archive_not_a_newer_one_of_the_same_name(self, resolver_env):
  735. """The support-bundle case: a reprint reuses archive 169, while a newer
  736. archive of the same file (another plate) is 288. The name lookup took
  737. 288; only the layer check stopped it."""
  738. bound = await self._archive(resolver_env, file_path="archive/reprinted.gcode.3mf", total_layers=125)
  739. await self._archive(resolver_env, file_path="archive/other-plate.gcode.3mf", total_layers=313)
  740. resolver_env["reported_layers"] = 125
  741. assert await self._resolve("job", bound) == HEIGHTS
  742. assert resolver_env["read"][0][0].name == "reprinted.gcode.3mf"
  743. async def test_reads_the_plate_the_job_was_dispatched_with(self, resolver_env):
  744. """A reprint of one plate reuses the archive row without updating its
  745. plate_id, so the dispatched plate wins."""
  746. archive_id = await self._archive(resolver_env, plate_id=1)
  747. await self._resolve("job", archive_id, plate_id=12)
  748. assert resolver_env["read"][0][1] == 12
  749. await self._resolve("job", archive_id)
  750. assert resolver_env["read"][1][1] == 1
  751. async def test_reads_a_relative_file_path_from_the_data_directory(self, resolver_env, tmp_path):
  752. """#3240: archive paths are stored relative to the data directory. The
  753. resolver joined them to a `data_dir` setting that does not exist, so the
  754. lookup raised, was logged at debug level, and the plate never moved."""
  755. archive_id = await self._archive(resolver_env, file_path="archive/1/job/job.gcode.3mf", plate_id=2)
  756. assert await self._resolve("job", archive_id) == HEIGHTS
  757. assert resolver_env["read"] == [(tmp_path / "archive/1/job/job.gcode.3mf", 2)]
  758. async def test_an_absolute_file_path_is_read_as_stored(self, resolver_env):
  759. archive_id = await self._archive(resolver_env, file_path="/srv/bambuddy/archive/job.gcode.3mf")
  760. assert await self._resolve("job", archive_id) == HEIGHTS
  761. assert str(resolver_env["read"][0][0]) == "/srv/bambuddy/archive/job.gcode.3mf"
  762. async def test_matches_a_name_the_printer_echoed_with_underscores(self, resolver_env):
  763. """#3240: the printer reports "Cube v2" as "Cube_v2"."""
  764. archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
  765. assert await self._resolve("Cube_v2", archive_id) == HEIGHTS
  766. async def test_matches_on_the_filename_with_or_without_its_extension(self, resolver_env):
  767. archive_id = await self._archive(resolver_env, print_name=None, filename="Bracket Left.gcode.3mf")
  768. assert await self._resolve("Bracket_Left", archive_id) == HEIGHTS
  769. assert await self._resolve("Bracket_Left.gcode", archive_id) == HEIGHTS
  770. assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == HEIGHTS
  771. async def test_matches_a_file_name_that_ends_in_a_space(self, resolver_env):
  772. """#3241: "Part .gcode.3mf" is echoed as "Part_"."""
  773. archive_id = await self._archive(resolver_env, print_name="Part ", filename="Part .gcode.3mf")
  774. assert await self._resolve("Part_", archive_id) == HEIGHTS
  775. async def test_refuses_a_bound_archive_with_another_name(self, resolver_env):
  776. archive_id = await self._archive(resolver_env)
  777. assert await self._resolve("other", archive_id) is None
  778. async def test_matches_by_equality_not_substring(self, resolver_env):
  779. """ "Cube" must never resolve to "Cube v2" — a different print, quite
  780. possibly a much taller one."""
  781. archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
  782. assert await self._resolve("Cube", archive_id) is None
  783. async def test_a_truncated_echo_does_not_match(self, resolver_env):
  784. """The completion check accepts the printer's "..." truncation; a Z
  785. move must not, since the cut-off name could belong to another print."""
  786. archive_id = await self._archive(resolver_env, print_name="A very long print name indeed")
  787. assert await self._resolve("A_very_long_print...", archive_id) is None
  788. async def test_refuses_another_printers_or_a_failed_or_deleted_archive(self, resolver_env):
  789. for overrides in ({"printer_id": 2}, {"status": "failed"}, {"deleted_at": datetime.now(timezone.utc)}):
  790. archive_id = await self._archive(resolver_env, **overrides)
  791. assert await self._resolve("job", archive_id) is None, overrides
  792. async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
  793. archive_id = await self._archive(resolver_env)
  794. assert await self._resolve(None, archive_id) is None
  795. assert resolver_env["read"] == []
  796. async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
  797. """The corroboration check. The archive's layer count comes from the
  798. 3MF; the printer's comes from MQTT. If two independent sources disagree,
  799. the row is not this print whatever its name says."""
  800. archive_id = await self._archive(resolver_env)
  801. resolver_env["reported_layers"] = 240
  802. assert await self._resolve("job", archive_id) is None
  803. async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
  804. """Absent is not the same as contradictory — a print Bambuddy has no
  805. layer count for still gets its height, because binding and name agree."""
  806. archive_id = await self._archive(resolver_env)
  807. resolver_env["reported_layers"] = 0
  808. assert await self._resolve("job", archive_id) == HEIGHTS
  809. resolver_env["reported_layers"] = 30
  810. unknown_layers = await self._archive(resolver_env, total_layers=None)
  811. assert await self._resolve("job", unknown_layers) == HEIGHTS
  812. async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
  813. archive_id = await self._archive(resolver_env, file_path="")
  814. assert await self._resolve("job", archive_id) is None
  815. async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
  816. archive_id = await self._archive(resolver_env)
  817. resolver_env["height"] = None
  818. assert await self._resolve("job", archive_id) is None
  819. async def test_refuses_when_the_end_gcode_position_is_unknown(self, resolver_env):
  820. """No end Z, no park target the printer's own G-code chose, so no move
  821. at all rather than one to a height worked out here."""
  822. archive_id = await self._archive(resolver_env)
  823. resolver_env["end_z"] = None
  824. assert await self._resolve("job", archive_id) is None
  825. async def test_reads_the_end_position_from_the_same_file_and_plate(self, resolver_env, tmp_path):
  826. archive_id = await self._archive(resolver_env, plate_id=2)
  827. await self._resolve("job", archive_id)
  828. assert resolver_env["read_end"] == (tmp_path / "archive/1/job/job.gcode.3mf", 2)
  829. class TestBoundPrint:
  830. """The binding the plate restore reads (#3240)."""
  831. @pytest.fixture(autouse=True)
  832. def _clean(self):
  833. main_module._active_prints.clear()
  834. main_module._print_plate_ids.clear()
  835. yield
  836. main_module._active_prints.clear()
  837. main_module._print_plate_ids.clear()
  838. def test_finds_the_archive_under_the_keys_on_print_complete_tries(self):
  839. main_module._active_prints[(7, "Cube v2.gcode.3mf")] = 169
  840. main_module._active_prints[(7, "Cube_v2.3mf")] = 169
  841. main_module._print_plate_ids[(7, 169)] = 12
  842. bound = main_module._bound_print(7, {"filename": "Cube_v2.3mf", "subtask_name": "Cube_v2"})
  843. assert bound == (169, 12)
  844. def test_reads_without_consuming(self):
  845. """on_print_complete still has to find the binding after we looked."""
  846. main_module._active_prints[(7, "job.3mf")] = 5
  847. main_module._print_plate_ids[(7, 5)] = 2
  848. main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})
  849. assert main_module._active_prints == {(7, "job.3mf"): 5}
  850. assert main_module._print_plate_ids == {(7, 5): 2}
  851. def test_unbound_print_and_other_printers(self):
  852. main_module._active_prints[(8, "job.3mf")] = 5
  853. assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (None, None)
  854. assert main_module._bound_print(7, {}) == (None, None)
  855. def test_no_plate_recorded(self):
  856. main_module._active_prints[(7, "job.3mf")] = 5
  857. assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (5, None)
  858. def test_same_archive_as_on_print_complete_takes(self):
  859. """Keys tried in the same order, so the first one present wins in both."""
  860. main_module._active_prints[(7, "job.gcode.3mf")] = 1
  861. main_module._active_prints[(7, "job.3mf")] = 2
  862. keys = main_module._completion_print_keys(7, "job.3mf", "job")
  863. first = next(main_module._active_prints[k] for k in keys if k in main_module._active_prints)
  864. assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})[0] == first == 2
  865. class TestTimelapsePathPlateRestore:
  866. """#2547: the timelapse path falls through to a live grab whenever the
  867. video hasn't landed yet — the documented usual outcome on P1-series.
  868. `on_finish_photo_moment` returns early for those prints without raising the
  869. plate, so the photo that actually ships in the notification would be of an
  870. already-dropped plate. The consumer therefore does the restore itself, but
  871. only on that path — everywhere else the producer has already done it.
  872. """
  873. def test_notification_budget_outlasts_the_video_poll_plus_a_restore(self):
  874. """The wait has to cover polling for the video AND the restore that
  875. follows when it doesn't arrive. At the old flat 75s the fallback was
  876. cut off mid-settle, so the plate would have moved for a photo nobody
  877. was still waiting for."""
  878. assert (
  879. main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
  880. > main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
  881. )
  882. async def test_producer_skips_the_restore_when_a_timelapse_was_recording(self, patched_env, monkeypatch):
  883. """The producer returns before any of the restore code — the consumer
  884. owns it on this path, and doing it in both would move the plate twice."""
  885. moved = []
  886. async def _restore(printer_id, max_z, _logger):
  887. moved.append((printer_id, max_z))
  888. return True
  889. async def _height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
  890. return main_module.PlateHeights(16.0, 114.0)
  891. monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
  892. monkeypatch.setattr(main_module, "_plate_heights_for_current_print", _height)
  893. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
  894. assert moved == []
  895. # --- #1655 chamber light for the camera ------------------------------------
  896. class _LightClient:
  897. def __init__(self):
  898. self.state = SimpleNamespace(chamber_light=False)
  899. self.calls: list[bool] = []
  900. self.mode = "off" # the camera_light_mode setting
  901. def set_chamber_light(self, on):
  902. self.calls.append(on)
  903. self.state.chamber_light = on
  904. return True
  905. @pytest.fixture
  906. def light_client(monkeypatch):
  907. from backend.app.services import camera_light as light_svc
  908. client = _LightClient()
  909. monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
  910. async def _settings():
  911. return client.mode, 0.0
  912. monkeypatch.setattr(light_svc, "_read_settings", _settings)
  913. monkeypatch.setattr(light_svc, "_settings_cache", None)
  914. monkeypatch.setattr(light_svc, "_holders", {})
  915. monkeypatch.setattr(light_svc, "_lit_at", {})
  916. off_tasks: dict = {}
  917. monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
  918. yield client
  919. for task in off_tasks.values():
  920. task.cancel()
  921. async def test_finish_photo_live_grab_is_lit_when_the_light_mode_covers_it(patched_env, monkeypatch, light_client):
  922. light_client.mode = "all"
  923. seen = {}
  924. async def _live(**_kwargs):
  925. seen["light"] = light_client.state.chamber_light
  926. return b"\xff\xd8live"
  927. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  928. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  929. assert seen["light"] is True
  930. assert light_client.calls == [True] # off follows after the grace
  931. async def test_finish_photo_live_grab_leaves_the_light_alone_by_default(patched_env, monkeypatch, light_client):
  932. async def _live(**_kwargs):
  933. return b"\xff\xd8live"
  934. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  935. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  936. assert light_client.calls == []
  937. async def test_finish_photo_from_the_bank_does_not_turn_the_light_on(patched_env, monkeypatch, light_client):
  938. """The banked frame is already taken; lighting the printer for it would
  939. only switch the light on and off for nothing."""
  940. light_client.mode = "all"
  941. print_dispatch_context.mark_pending(patched_env.id)
  942. print_dispatch_context.adopt(patched_env.id)
  943. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  944. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  945. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked"
  946. assert light_client.calls == []
  947. async def test_notification_snapshot_is_lit_for_a_selected_printer(fake_printer, monkeypatch, light_client):
  948. light_client.mode = "selected"
  949. fake_printer.camera_light_auto = True
  950. seen = {}
  951. async def _frame(_pid, _printer, _logger):
  952. seen["light"] = light_client.state.chamber_light
  953. return b"\xff\xd8snap"
  954. monkeypatch.setattr(main_module, "_capture_snapshot_frame", _frame)
  955. assert await main_module._capture_snapshot_for_notification(7, fake_printer, logging.getLogger()) == b"\xff\xd8snap"
  956. assert seen["light"] is True
  957. assert light_client.calls == [True]
  958. def test_every_live_capture_in_main_holds_the_light():
  959. """#1655: the finish photo's last fallback lives in `_background_finish_photo`,
  960. a closure nothing can drive directly (see the rotation guard above). So the
  961. source is checked: every live camera grab in main.py runs inside
  962. `camera_light(...)`, except in `_capture_snapshot_frame`, whose only caller
  963. is the wrapper that holds the light for it.
  964. """
  965. import ast
  966. from pathlib import Path
  967. main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
  968. tree = ast.parse(main_py.read_text())
  969. parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
  970. grabs = {"capture_frame", "capture_camera_frame", "capture_camera_frame_bytes", "capture_finish_photo"}
  971. def _held(node) -> bool:
  972. while node in parents:
  973. node = parents[node]
  974. if isinstance(node, ast.AsyncFunctionDef) and node.name == "_capture_snapshot_frame":
  975. return True
  976. if isinstance(node, ast.AsyncWith) and any(
  977. isinstance(item.context_expr, ast.Call)
  978. and getattr(item.context_expr.func, "id", None) == "camera_light"
  979. for item in node.items
  980. ):
  981. return True
  982. return False
  983. calls = [
  984. node
  985. for node in ast.walk(tree)
  986. if isinstance(node, ast.Call) and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) in grabs
  987. ]
  988. assert len(calls) >= 6, "guard found fewer grabs than main.py has; is it parsing the right file?"
  989. unlit = [node.lineno for node in calls if not _held(node)]
  990. assert not unlit, f"main.py:{unlit} grabs a camera frame without camera_light(...)"
  991. wrapper_callers = [
  992. node.lineno
  993. for node in ast.walk(tree)
  994. if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_capture_snapshot_frame"
  995. ]
  996. assert len(wrapper_callers) == 1, f"_capture_snapshot_frame is called from main.py:{wrapper_callers}"