test_status_broadcast_ams_slot_config.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. """Configuring an AMS slot must reach the printer card without a page reload.
  2. `on_printer_status_change` deduplicates WebSocket broadcasts against a
  3. `status_key`. Its AMS component used to carry only id / tray_type / state, so
  4. re-configuring a slot to a different brand or colour of the SAME material
  5. produced an identical key: the printer's pushall arrived with the new values,
  6. the handler compared, found no change, and returned without broadcasting. The
  7. card then showed the old filament until the 30s fallback poll or an F5.
  8. Reset never had the bug — it clears tray_type, which was always in the key.
  9. That asymmetry is what these tests pin: every field Configure Slot writes has
  10. to move the key, and the fields that churn every second still must not.
  11. """
  12. from types import SimpleNamespace
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app import main as main_module
  16. def _spawn_patch():
  17. """Close the reconcile coroutine the handler builds as a call argument.
  18. Same reason as test_printer_offline_notification.py: a bare MagicMock keeps
  19. it alive in call_args and it finalises unawaited during a later test's GC.
  20. """
  21. return patch(
  22. "backend.app.main.spawn_background_task",
  23. side_effect=lambda coro, **kwargs: coro.close(),
  24. )
  25. def _tray(**overrides) -> dict:
  26. """One AMS tray as the firmware reports it, mid-way through a print job.
  27. Defaults describe a configured slot: Bambu PLA Basic in black, bound to
  28. calibration slot 3.
  29. """
  30. tray = {
  31. "id": "0",
  32. "tray_type": "PLA",
  33. "state": 10,
  34. "tray_color": "000000FF",
  35. "tray_info_idx": "GFA00",
  36. "tray_sub_brands": "PLA Basic",
  37. "cali_idx": 3,
  38. "remain": 42,
  39. }
  40. tray.update(overrides)
  41. return tray
  42. def _state(trays: list[dict], ams_extra: dict | None = None) -> SimpleNamespace:
  43. """Minimal PrinterState stub carrying one AMS unit.
  44. Idle and unheated, so the handler runs straight from the dedup check to the
  45. broadcast without touching progress milestones, HMS notifications or the DB.
  46. ``ams_extra`` sets unit-level (not per-tray) fields such as the drying pair
  47. ``dry_time`` / ``dry_countdown_stalled``.
  48. """
  49. ams_unit = {"id": "0", "dry_time": 0, "tray": trays}
  50. ams_unit.update(ams_extra or {})
  51. return SimpleNamespace(
  52. connected=True,
  53. state="IDLE",
  54. progress=0,
  55. layer_num=0,
  56. temperatures={},
  57. nozzles=[],
  58. raw_data={"ams": [ams_unit]},
  59. stg_cur=0,
  60. # Real PrinterState always carries these; the status-broadcast dedup
  61. # key reads them so a Filament Track Switch rebind reaches the card.
  62. fila_switch=None,
  63. ams_switch_inlet={},
  64. extruder_slots={},
  65. cooling_fan_speed=0,
  66. big_fan1_speed=0,
  67. big_fan2_speed=0,
  68. chamber_light="",
  69. active_extruder=0,
  70. tray_now=0,
  71. door_open=False,
  72. subtask_name="",
  73. gcode_file="",
  74. remaining_time=None,
  75. hms_errors=[],
  76. ams_filament_backup=None,
  77. firmware_version=None,
  78. developer_mode=None,
  79. print_ams_mapping=None,
  80. airduct_mode=0,
  81. )
  82. @pytest.fixture(autouse=True)
  83. def _reset_edge_state():
  84. main_module._last_status_broadcast.clear()
  85. main_module._printer_last_connected.clear()
  86. main_module._printer_reconciled_since_connect.clear()
  87. yield
  88. main_module._last_status_broadcast.clear()
  89. main_module._printer_last_connected.clear()
  90. main_module._printer_reconciled_since_connect.clear()
  91. async def _push(ws_mgr, trays: list[dict], ams_extra: dict | None = None) -> None:
  92. """Deliver one status push to the handler."""
  93. relay = MagicMock()
  94. relay.on_printer_status = AsyncMock()
  95. pm = MagicMock()
  96. pm.get_printer.return_value = None # Skip the relay payload branch.
  97. pm.get_model.return_value = ""
  98. with (
  99. patch("backend.app.main.ws_manager", ws_mgr),
  100. patch("backend.app.main.mqtt_relay", relay),
  101. patch("backend.app.main.printer_manager", pm),
  102. _spawn_patch(),
  103. patch("backend.app.main.printer_state_to_dict", return_value={}),
  104. ):
  105. await main_module.on_printer_status_change(1, _state(trays, ams_extra))
  106. @pytest.fixture
  107. def ws_mgr():
  108. mgr = MagicMock()
  109. mgr.send_printer_status = AsyncMock()
  110. return mgr
  111. class TestConfigureSlotBroadcasts:
  112. """Each field Configure Slot writes must break the dedup on its own —
  113. the user may change only the colour, or only the K-profile."""
  114. @pytest.mark.asyncio
  115. @pytest.mark.parametrize(
  116. "field,new_value",
  117. [
  118. ("tray_color", "FF0000FF"),
  119. ("tray_info_idx", "GFA01"),
  120. ("tray_sub_brands", "PLA Matte"),
  121. ("cali_idx", 7),
  122. ],
  123. )
  124. async def test_a_changed_filament_field_broadcasts(self, ws_mgr, field, new_value):
  125. await _push(ws_mgr, [_tray()])
  126. assert ws_mgr.send_printer_status.await_count == 1
  127. await _push(ws_mgr, [_tray(**{field: new_value})])
  128. assert ws_mgr.send_printer_status.await_count == 2, (
  129. f"changing {field} did not reach the frontend — the card would keep "
  130. "showing the old filament until the fallback poll"
  131. )
  132. @pytest.mark.asyncio
  133. async def test_the_realistic_reconfigure_broadcasts(self, ws_mgr):
  134. """Black Bambu PLA Basic → red eSUN PLA+ with its own K-profile.
  135. The whole point of the report: same material, so every field the old key
  136. looked at is unchanged.
  137. """
  138. await _push(ws_mgr, [_tray()])
  139. await _push(
  140. ws_mgr,
  141. [
  142. _tray(
  143. tray_color="C1121FFF",
  144. tray_info_idx="GFL99",
  145. tray_sub_brands="eSUN PLA+",
  146. cali_idx=5,
  147. )
  148. ],
  149. )
  150. assert ws_mgr.send_printer_status.await_count == 2
  151. @pytest.mark.asyncio
  152. async def test_a_second_slot_is_watched_too(self, ws_mgr):
  153. """The key spans every tray, so configuring slot 2 must broadcast even
  154. though slot 1 is untouched."""
  155. trays = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG00")]
  156. await _push(ws_mgr, trays)
  157. changed = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG01")]
  158. await _push(ws_mgr, changed)
  159. assert ws_mgr.send_printer_status.await_count == 2
  160. class TestDedupStillHolds:
  161. """The dedup exists to keep a printing machine from flooding the socket.
  162. Widening the key must not have cost that."""
  163. @pytest.mark.asyncio
  164. async def test_an_identical_push_is_still_suppressed(self, ws_mgr):
  165. await _push(ws_mgr, [_tray()])
  166. await _push(ws_mgr, [_tray()])
  167. assert ws_mgr.send_printer_status.await_count == 1
  168. @pytest.mark.asyncio
  169. async def test_remaining_filament_does_not_broadcast(self, ws_mgr):
  170. """`remain` ticks down throughout a print and is deliberately absent
  171. from the key. It sits in the same tray dict as the fields we added, so
  172. this pins that we widened the key rather than hashing the whole tray."""
  173. await _push(ws_mgr, [_tray(remain=42)])
  174. await _push(ws_mgr, [_tray(remain=41)])
  175. assert ws_mgr.send_printer_status.await_count == 1
  176. class TestExistingBehaviourUnchanged:
  177. """The cases that already worked, kept working."""
  178. @pytest.mark.asyncio
  179. async def test_a_load_unload_transition_still_broadcasts(self, ws_mgr):
  180. """#784 — tray state 11→10."""
  181. await _push(ws_mgr, [_tray(state=11)])
  182. await _push(ws_mgr, [_tray(state=10)])
  183. assert ws_mgr.send_printer_status.await_count == 2
  184. @pytest.mark.asyncio
  185. async def test_resetting_a_slot_still_broadcasts(self, ws_mgr):
  186. """Reset clears the filament identity outright."""
  187. await _push(ws_mgr, [_tray()])
  188. await _push(
  189. ws_mgr,
  190. [_tray(tray_type="", tray_color="", tray_info_idx="", tray_sub_brands="", cali_idx=-1)],
  191. )
  192. assert ws_mgr.send_printer_status.await_count == 2
  193. @pytest.mark.asyncio
  194. async def test_a_printer_with_no_ams_still_broadcasts_once(self, ws_mgr):
  195. """The `else ()` branch — an AMS-less printer must not crash or
  196. double-broadcast."""
  197. relay = MagicMock()
  198. relay.on_printer_status = AsyncMock()
  199. pm = MagicMock()
  200. pm.get_printer.return_value = None
  201. pm.get_model.return_value = ""
  202. state = _state([])
  203. state.raw_data = {}
  204. for _ in range(2):
  205. with (
  206. patch("backend.app.main.ws_manager", ws_mgr),
  207. patch("backend.app.main.mqtt_relay", relay),
  208. patch("backend.app.main.printer_manager", pm),
  209. _spawn_patch(),
  210. patch("backend.app.main.printer_state_to_dict", return_value={}),
  211. ):
  212. await main_module.on_printer_status_change(1, state)
  213. assert ws_mgr.send_printer_status.await_count == 1
  214. class TestFilamentTrackSwitchBroadcasts:
  215. """Moving an AMS to the other switch inlet has to reach the printer card.
  216. The inlet binding lives in AMS ``info`` bits, so it is in neither the tray
  217. component of this key nor the AMS change-hash (which covers tray fields only
  218. — widening that would fire spurious Spoolman syncs). Without its own term
  219. here, "Join IN-B" on the printer's Manual AMS Setup screen moved no key at
  220. all and the card's inlet badges stayed stale until a page reload.
  221. """
  222. @staticmethod
  223. def _fts_state(inlets: dict[str, str], installed: bool = True):
  224. from backend.app.services.bambu_mqtt import FilaSwitchState
  225. state = _state([_tray()])
  226. state.fila_switch = FilaSwitchState(installed=installed)
  227. state.ams_switch_inlet = inlets
  228. return state
  229. async def _push_state(self, ws_mgr, state) -> None:
  230. relay = MagicMock()
  231. relay.on_printer_status = AsyncMock()
  232. pm = MagicMock()
  233. pm.get_printer.return_value = None
  234. pm.get_model.return_value = ""
  235. with (
  236. patch("backend.app.main.ws_manager", ws_mgr),
  237. patch("backend.app.main.mqtt_relay", relay),
  238. patch("backend.app.main.printer_manager", pm),
  239. _spawn_patch(),
  240. patch("backend.app.main.printer_state_to_dict", return_value={}),
  241. ):
  242. await main_module.on_printer_status_change(1, state)
  243. @pytest.mark.asyncio
  244. async def test_a_rebind_broadcasts(self, ws_mgr):
  245. await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
  246. assert ws_mgr.send_printer_status.await_count == 1
  247. await self._push_state(ws_mgr, self._fts_state({"0": "B", "1": "B"}))
  248. assert ws_mgr.send_printer_status.await_count == 2, (
  249. "moving an AMS to the other inlet did not reach the frontend — the "
  250. "card would keep showing the old inlet badge until a page reload"
  251. )
  252. @pytest.mark.asyncio
  253. async def test_fitting_the_accessory_broadcasts(self, ws_mgr):
  254. await self._push_state(ws_mgr, self._fts_state({}, installed=False))
  255. await self._push_state(ws_mgr, self._fts_state({}, installed=True))
  256. assert ws_mgr.send_printer_status.await_count == 2
  257. @pytest.mark.asyncio
  258. async def test_an_unchanged_binding_is_still_suppressed(self, ws_mgr):
  259. """The binding only moves when someone reconfigures the machine, so it
  260. must not add a broadcast to every push mid-print."""
  261. for _ in range(3):
  262. await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
  263. assert ws_mgr.send_printer_status.await_count == 1
  264. @pytest.mark.asyncio
  265. async def test_key_order_does_not_matter(self, ws_mgr):
  266. """Dict iteration order must not masquerade as a rebind."""
  267. await self._push_state(ws_mgr, self._fts_state({"0": "A", "1": "B"}))
  268. await self._push_state(ws_mgr, self._fts_state({"1": "B", "0": "A"}))
  269. assert ws_mgr.send_printer_status.await_count == 1
  270. class TestDryCountdownStallBroadcasts:
  271. """#2896: a parked drying command must be able to reach the card.
  272. ``dry_countdown_stalled`` is the one drying signal the countdown cannot
  273. carry, because the MQTT layer raises it exactly when ``dry_time`` has
  274. STOPPED moving. On the frame that flips it, every other member of the dedup
  275. key is identical, so unless the flag is itself in the key the handler
  276. returns before broadcasting. Mid-print a temperature change would eventually
  277. break the tie, but a command parked on an idle machine moves nothing else —
  278. AMS temp and humidity are not in the key — so the grey "Drying not started"
  279. badge would sit unreachable.
  280. """
  281. @pytest.mark.asyncio
  282. async def test_the_stall_flip_broadcasts(self, ws_mgr):
  283. await _push(ws_mgr, [_tray()], {"dry_time": 720})
  284. assert ws_mgr.send_printer_status.await_count == 1
  285. # Same frozen countdown again: genuinely nothing changed.
  286. await _push(ws_mgr, [_tray()], {"dry_time": 720})
  287. assert ws_mgr.send_printer_status.await_count == 1
  288. # 150s of no tick later the MQTT layer stamps the stall. dry_time is
  289. # unchanged by definition — only the flag can break the key.
  290. await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
  291. assert ws_mgr.send_printer_status.await_count == 2, (
  292. "the stall flag did not reach the frontend — the card would keep "
  293. "claiming an active 12h drying cycle that is not running"
  294. )
  295. @pytest.mark.asyncio
  296. async def test_clearing_the_stall_broadcasts(self, ws_mgr):
  297. """The recovery edge matters too: the firmware finally starts the cycle,
  298. the countdown ticks, and the badge must go back to amber."""
  299. await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
  300. await _push(ws_mgr, [_tray()], {"dry_time": 719})
  301. assert ws_mgr.send_printer_status.await_count == 2
  302. @pytest.mark.asyncio
  303. async def test_a_steady_stalled_unit_does_not_reflood(self, ws_mgr):
  304. """Once flagged, repeats of the flagged frame stay deduplicated — the
  305. flag flips once per cycle, so widening the key costs no traffic."""
  306. await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
  307. await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
  308. assert ws_mgr.send_printer_status.await_count == 1