test_finish_photo_moment_sync.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. from contextlib import asynccontextmanager
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock
  18. import pytest
  19. from backend.app import main as main_module
  20. from backend.app.main import on_finish_photo_moment
  21. @asynccontextmanager
  22. async def _fake_session(printer):
  23. """Async-session stub that returns `printer` from scalar_one_or_none()."""
  24. result = SimpleNamespace(scalar_one_or_none=lambda: printer)
  25. session = SimpleNamespace(execute=AsyncMock(return_value=result))
  26. yield session
  27. @pytest.fixture
  28. def fake_printer():
  29. return SimpleNamespace(
  30. id=7,
  31. ip_address="192.0.2.7",
  32. access_code="x",
  33. model="X1C",
  34. external_camera_enabled=False,
  35. external_camera_url=None,
  36. external_camera_type=None,
  37. external_camera_snapshot_url=None,
  38. )
  39. @pytest.fixture(autouse=True)
  40. def _clean_state():
  41. """Don't leak event/cache dict entries across tests."""
  42. main_module._stage22_finish_in_flight.clear()
  43. main_module._stage22_finish_frames.clear()
  44. main_module._inprint_frame_bank.clear()
  45. main_module._inprint_frame_bank_ts.clear()
  46. yield
  47. main_module._stage22_finish_in_flight.clear()
  48. main_module._stage22_finish_frames.clear()
  49. main_module._inprint_frame_bank.clear()
  50. main_module._inprint_frame_bank_ts.clear()
  51. @pytest.fixture
  52. def patched_env(fake_printer, monkeypatch):
  53. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
  54. async def _get_setting(_db, key):
  55. if key == "capture_finish_photo":
  56. return "true"
  57. return None
  58. monkeypatch.setattr(
  59. "backend.app.api.routes.settings.get_setting",
  60. _get_setting,
  61. )
  62. monkeypatch.setattr(
  63. "backend.app.api.routes.camera.get_buffered_frame",
  64. lambda _pid: None,
  65. )
  66. return fake_printer
  67. async def test_event_registered_before_first_await(patched_env, monkeypatch):
  68. """The consumer needs to find the event the moment it polls — that
  69. means registration must complete BEFORE any `await` yields control
  70. back to the loop."""
  71. # Slow the first await (DB session entry) so we can observe the dict
  72. # before the producer makes any real progress.
  73. seen_during_capture = {}
  74. async def _slow_capture(**_kwargs):
  75. seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
  76. await asyncio.sleep(0)
  77. return b"\xff\xd8frame"
  78. monkeypatch.setattr(
  79. "backend.app.services.camera.capture_camera_frame_bytes",
  80. _slow_capture,
  81. )
  82. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  83. assert seen_during_capture["registered"] is True
  84. async def test_event_set_after_successful_capture(patched_env, monkeypatch):
  85. async def _capture(**_kwargs):
  86. return b"\xff\xd8frame"
  87. monkeypatch.setattr(
  88. "backend.app.services.camera.capture_camera_frame_bytes",
  89. _capture,
  90. )
  91. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  92. event = main_module._stage22_finish_in_flight[patched_env.id]
  93. assert event.is_set()
  94. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  95. async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
  96. """Producer gives up (RTSP timeout, no buffered frame, no external
  97. camera) — consumer must NOT wait the full 20s for nothing."""
  98. async def _capture(**_kwargs):
  99. return None
  100. monkeypatch.setattr(
  101. "backend.app.services.camera.capture_camera_frame_bytes",
  102. _capture,
  103. )
  104. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  105. event = main_module._stage22_finish_in_flight[patched_env.id]
  106. assert event.is_set()
  107. assert patched_env.id not in main_module._stage22_finish_frames
  108. async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
  109. """Producer hit a bug or network error — `finally` still has to
  110. release the consumer."""
  111. async def _capture(**_kwargs):
  112. raise RuntimeError("camera went away")
  113. monkeypatch.setattr(
  114. "backend.app.services.camera.capture_camera_frame_bytes",
  115. _capture,
  116. )
  117. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  118. event = main_module._stage22_finish_in_flight[patched_env.id]
  119. assert event.is_set()
  120. async def test_no_event_when_timelapse_was_active(patched_env):
  121. """On the timelapse-on path the consumer takes the
  122. `_capture_finish_photo_from_timelapse` branch and shouldn't be
  123. blocked by a producer wait — the producer doesn't enter the
  124. lifecycle."""
  125. await on_finish_photo_moment(
  126. patched_env.id,
  127. {"trigger": "stage_22", "timelapse_was_active": True},
  128. )
  129. assert patched_env.id not in main_module._stage22_finish_in_flight
  130. async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
  131. """Even on the early-return-before-capture path, the event must be
  132. released so the consumer doesn't hang on a no-op producer."""
  133. async def _disabled_setting(_db, _key):
  134. return "false"
  135. monkeypatch.setattr(
  136. "backend.app.api.routes.settings.get_setting",
  137. _disabled_setting,
  138. )
  139. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  140. event = main_module._stage22_finish_in_flight[patched_env.id]
  141. assert event.is_set()
  142. async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
  143. """End-to-end sync check: a consumer-style waiter awaiting the
  144. event finishes promptly once the producer's finally fires."""
  145. async def _capture(**_kwargs):
  146. await asyncio.sleep(0.05)
  147. return b"\xff\xd8frame"
  148. monkeypatch.setattr(
  149. "backend.app.services.camera.capture_camera_frame_bytes",
  150. _capture,
  151. )
  152. producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
  153. await asyncio.sleep(0) # let the producer register
  154. event = main_module._stage22_finish_in_flight[patched_env.id]
  155. await asyncio.wait_for(event.wait(), timeout=1.0)
  156. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  157. await producer
  158. async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch):
  159. """#1867: on the FINISH-state fallback (stage-22-less firmware, e.g. A1
  160. Mini) a live grab captures the post-swap plate. When a banked in-print
  161. frame exists it must be used instead, and the live grab must not run."""
  162. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  163. live_called = {"n": 0}
  164. async def _live(**_kwargs):
  165. live_called["n"] += 1
  166. return b"\xff\xd8live-post-swap"
  167. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  168. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  169. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked"
  170. assert live_called["n"] == 0
  171. async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeypatch):
  172. """No banked frame (feature just enabled, tiny print, capture failures) —
  173. the FINISH-state path still live-grabs so we degrade to the old behaviour
  174. rather than sending a text-only notification."""
  175. async def _live(**_kwargs):
  176. return b"\xff\xd8live"
  177. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  178. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  179. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
  180. async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch):
  181. """The `last_layer` trigger fires before the swap and gives cleaner
  182. (parked-toolhead) framing via a live grab — the bank is only for the
  183. post-swap `finish_state` fallback, so it must be ignored here."""
  184. main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
  185. async def _live(**_kwargs):
  186. return b"\xff\xd8live"
  187. monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
  188. await on_finish_photo_moment(patched_env.id, {"trigger": "last_layer"})
  189. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
  190. # --- #1867 banking helper (_maybe_bank_inprint_frame) --------------------
  191. def _bank_env(monkeypatch, *, state="RUNNING", sub_stage=0, total_layers=10, printer=object()):
  192. """Wire printer_manager.get_client + the snapshot capture for the bank
  193. helper. Capture returns a distinct frame per call so updates are visible."""
  194. client = SimpleNamespace(
  195. state=SimpleNamespace(state=state, mc_print_sub_stage=sub_stage, total_layers=total_layers)
  196. )
  197. monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
  198. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(printer))
  199. counter = {"n": 0}
  200. async def _capture(_pid, _printer, _logger):
  201. counter["n"] += 1
  202. return f"frame-{counter['n']}".encode()
  203. monkeypatch.setattr(main_module, "_capture_snapshot_for_notification", _capture)
  204. return counter
  205. async def test_bank_stores_frame_while_printing(monkeypatch):
  206. _bank_env(monkeypatch)
  207. await main_module._maybe_bank_inprint_frame(3, 5)
  208. assert main_module._inprint_frame_bank[3] == b"frame-1"
  209. async def test_bank_throttles_within_interval(monkeypatch):
  210. counter = _bank_env(monkeypatch)
  211. await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
  212. await main_module._maybe_bank_inprint_frame(3, 6) # within 25s -> skipped
  213. assert counter["n"] == 1
  214. assert main_module._inprint_frame_bank[3] == b"frame-1"
  215. async def test_bank_always_refreshes_on_last_layer(monkeypatch):
  216. counter = _bank_env(monkeypatch, total_layers=10)
  217. await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
  218. # Last layer bypasses the throttle for the best final framing.
  219. await main_module._maybe_bank_inprint_frame(3, 10)
  220. assert counter["n"] == 2
  221. assert main_module._inprint_frame_bank[3] == b"frame-2"
  222. async def test_bank_skips_when_not_running(monkeypatch):
  223. """End G-code (plate swap) runs after RUNNING ends — the bank must not
  224. update then, which is what freezes it on the finished print."""
  225. _bank_env(monkeypatch, state="FINISH")
  226. await main_module._maybe_bank_inprint_frame(3, 10)
  227. assert 3 not in main_module._inprint_frame_bank
  228. async def test_bank_skips_during_calibration_substage(monkeypatch):
  229. """layer_num ticks during pre-print calibration (non-zero sub-stage) —
  230. banking then would capture an empty bed."""
  231. _bank_env(monkeypatch, sub_stage=14)
  232. await main_module._maybe_bank_inprint_frame(3, 2)
  233. assert 3 not in main_module._inprint_frame_bank