test_printer_status_fields_g6.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Printer status fields the card reads, on both the REST status and the WebSocket.
  2. - Nozzles, firmware version and developer mode were REST-only, so the card
  3. showed them up to 30 s late (the fallback poll) after a change.
  4. - The print's AMS mapping (the "P1 / P2" slot pills, #1762) was read from the
  5. printer's push, which almost never carries it. Bambuddy captures it from the
  6. print command; that capture is what the status reports now.
  7. - The runout hint on a printer with several AMS said "check the printer" unless
  8. the snow mapping named the slot. The AMS that feeds the active nozzle names it.
  9. - AMS-HT was told apart by reporting one tray; it is the unit id 128-135.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from backend.app import main as main_module
  15. from backend.app.services.bambu_mqtt import BambuMQTTClient, NozzleInfo, PrinterState
  16. from backend.app.services.printer_manager import ams_units_to_dicts, printer_state_to_dict, resolve_expected_tray
  17. def _state(**fields) -> PrinterState:
  18. state = PrinterState()
  19. state.connected = True
  20. state.state = "IDLE"
  21. for name, value in fields.items():
  22. setattr(state, name, value)
  23. return state
  24. class TestTheWebSocketCarriesThem:
  25. def test_nozzles_firmware_and_developer_mode(self):
  26. state = _state(
  27. nozzles=[NozzleInfo(nozzle_type="hardened_steel", nozzle_diameter="0.4")],
  28. firmware_version="01.09.00.00",
  29. developer_mode=True,
  30. )
  31. pushed = printer_state_to_dict(state)
  32. assert pushed["nozzles"] == [{"nozzle_type": "hardened_steel", "nozzle_diameter": "0.4"}]
  33. assert pushed["firmware_version"] == "01.09.00.00"
  34. assert pushed["developer_mode"] is True
  35. def _push_state(**overrides):
  36. fields = {
  37. "connected": True,
  38. "state": "IDLE",
  39. "progress": 0,
  40. "layer_num": 0,
  41. "total_layers": 0,
  42. "temperatures": {},
  43. "nozzles": [],
  44. "raw_data": {},
  45. "stg_cur": 0,
  46. "fila_switch": None,
  47. "ams_switch_inlet": {},
  48. "extruder_slots": {},
  49. "cooling_fan_speed": 0,
  50. "big_fan1_speed": 0,
  51. "big_fan2_speed": 0,
  52. "chamber_light": "",
  53. "active_extruder": 0,
  54. "tray_now": 255,
  55. "door_open": False,
  56. "subtask_name": "",
  57. "gcode_file": "",
  58. "remaining_time": None,
  59. "hms_errors": [],
  60. "ams_filament_backup": None,
  61. "firmware_version": "01.09.00.00",
  62. "developer_mode": False,
  63. "print_ams_mapping": None,
  64. }
  65. fields.update(overrides)
  66. return SimpleNamespace(**fields)
  67. @pytest.fixture
  68. def ws_mgr():
  69. main_module._last_status_broadcast.clear()
  70. main_module._printer_last_connected.clear()
  71. main_module._printer_reconciled_since_connect.clear()
  72. mgr = MagicMock()
  73. mgr.send_printer_status = AsyncMock()
  74. yield mgr
  75. main_module._last_status_broadcast.clear()
  76. main_module._printer_last_connected.clear()
  77. main_module._printer_reconciled_since_connect.clear()
  78. async def _push(ws_mgr, state) -> None:
  79. relay = MagicMock()
  80. relay.on_printer_status = AsyncMock()
  81. pm = MagicMock()
  82. pm.get_printer.return_value = None
  83. pm.get_model.return_value = ""
  84. with (
  85. patch("backend.app.main.ws_manager", ws_mgr),
  86. patch("backend.app.main.mqtt_relay", relay),
  87. patch("backend.app.main.printer_manager", pm),
  88. patch("backend.app.main.spawn_background_task", side_effect=lambda coro, **kwargs: coro.close()),
  89. patch("backend.app.main.printer_state_to_dict", return_value={}),
  90. ):
  91. await main_module.on_printer_status_change(1, state)
  92. class TestAChangeAloneIsPushed:
  93. """An idle printer's other fields stand still, so the change has to move
  94. the broadcast's dedup key by itself."""
  95. @pytest.mark.asyncio
  96. @pytest.mark.parametrize(
  97. "change",
  98. [
  99. {"developer_mode": True},
  100. {"firmware_version": "01.10.00.00"},
  101. {"nozzles": [NozzleInfo(nozzle_type="stainless_steel", nozzle_diameter="0.6")]},
  102. {"print_ams_mapping": [0, 2]},
  103. ],
  104. )
  105. async def test_it_breaks_the_dedup(self, ws_mgr, change):
  106. await _push(ws_mgr, _push_state())
  107. await _push(ws_mgr, _push_state(**change))
  108. assert ws_mgr.send_printer_status.await_count == 2
  109. def _client() -> BambuMQTTClient:
  110. return BambuMQTTClient(ip_address="10.0.0.9", serial_number="SN-G6", access_code="x", model="X1C")
  111. class TestThePrintsMapping:
  112. def test_a_captured_mapping_is_in_the_status(self):
  113. client = _client()
  114. client._handle_request_message(
  115. {"print": {"command": "project_file", "url": "ftp://x.3mf", "ams_mapping": [2, 0, -1]}}
  116. )
  117. assert client.state.print_ams_mapping == [2, 0, -1]
  118. client.state.state = "RUNNING"
  119. assert printer_state_to_dict(client.state)["ams_mapping"] == [2, 0, -1]
  120. def test_it_is_gone_with_the_print(self):
  121. client = _client()
  122. client._captured_ams_mapping = [1]
  123. client._captured_ams_mapping = None
  124. assert client.state.print_ams_mapping is None
  125. assert printer_state_to_dict(client.state)["ams_mapping"] == []
  126. class TestAMappingLeftFromAPrintThatNeverStarted:
  127. """A dispatch the printer refused or that was cancelled before it ran ends
  128. no print, so its captured mapping stayed and the next print from the
  129. printer's screen showed it."""
  130. def test_the_next_prints_own_mapping_replaces_it(self):
  131. client = _client()
  132. client._handle_request_message(
  133. {"print": {"command": "project_file", "url": "ftp://old.3mf", "ams_mapping": [3]}}
  134. )
  135. client._capture_report_project_file(
  136. {
  137. "command": "project_file",
  138. "result": "SUCCESS",
  139. "url": "file:///userdata/model/history/new.gcode.3mf",
  140. "ams_mapping": [1],
  141. }
  142. )
  143. assert client.state.print_ams_mapping == [1]
  144. def test_the_echo_of_the_same_dispatch_keeps_the_slicers_copy(self):
  145. client = _client()
  146. client._handle_request_message(
  147. {"print": {"command": "project_file", "url": "ftp://job.3mf", "ams_mapping": [3]}}
  148. )
  149. client._capture_report_project_file(
  150. {"command": "project_file", "result": "SUCCESS", "url": "ftp://job.3mf", "ams_mapping": [0]}
  151. )
  152. assert client.state.print_ams_mapping == [3]
  153. class TestTheRunoutHintOnSeveralAms:
  154. """Two regular AMS, one per nozzle (H2D); no snow mapping in the push."""
  155. LAYOUT = [(0, False), (1, False)]
  156. NOZZLES = {"0": 0, "1": 1}
  157. def test_the_active_nozzles_ams_names_the_slot(self):
  158. assert resolve_expected_tray(2, self.LAYOUT, None, active_extruder=1, ams_extruder_map=self.NOZZLES) == 6
  159. def test_it_narrows_an_ambiguous_mapping(self):
  160. mapping = [0 * 256 + 2, 1 * 256 + 2]
  161. assert resolve_expected_tray(2, self.LAYOUT, mapping, active_extruder=0, ams_extruder_map=self.NOZZLES) == 2
  162. def test_the_mapping_picks_among_the_nozzles_ams(self):
  163. """Two AMS on the active nozzle: the mapping's entry for one of them
  164. decides, its entry on the other nozzle's AMS does not count."""
  165. layout = [(0, False), (1, False), (2, False)]
  166. nozzles = {"0": 0, "1": 0, "2": 1}
  167. mapping = [0 * 256 + 2, 2 * 256 + 2]
  168. assert resolve_expected_tray(2, layout, mapping, active_extruder=0, ams_extruder_map=nozzles) == 2
  169. def test_an_ams_ht_on_that_nozzle_counts_too(self):
  170. """Slot 0 can be the AMS-HT's one tray as well."""
  171. layout = [(0, False), (1, False), (128, True)]
  172. nozzles = {"0": 1, "1": 0, "128": 0}
  173. mapping = [1 * 256 + 0, 128 * 256 + 0]
  174. assert resolve_expected_tray(0, layout, mapping, active_extruder=0, ams_extruder_map=nozzles) is None
  175. assert resolve_expected_tray(0, layout, None, active_extruder=0, ams_extruder_map=nozzles) is None
  176. assert resolve_expected_tray(1, layout, None, active_extruder=0, ams_extruder_map=nozzles) == 5
  177. def test_two_ams_on_that_nozzle_still_ask(self):
  178. layout = [(0, False), (1, False), (2, False)]
  179. nozzles = {"0": 0, "1": 0, "2": 1}
  180. assert resolve_expected_tray(2, layout, None, active_extruder=0, ams_extruder_map=nozzles) is None
  181. def test_an_ams_on_the_track_switch_is_not_guessed(self):
  182. """An AMS on the Filament Track Switch has no nozzle entry: it can feed either."""
  183. assert resolve_expected_tray(2, self.LAYOUT, None, active_extruder=1, ams_extruder_map={"1": 1}) is None
  184. def test_without_a_nozzle_nothing_changes(self):
  185. assert resolve_expected_tray(2, self.LAYOUT, None) is None
  186. def test_a_fitted_track_switch_is_not_guessed_either(self):
  187. """The map keeps an AMS's old wiring after it moves onto the switch."""
  188. from backend.app.services.bambu_mqtt import FilaSwitchState
  189. state = _state(
  190. state="PAUSE",
  191. tray_tar=2,
  192. active_extruder=1,
  193. ams_extruder_map=dict(self.NOZZLES),
  194. fila_switch=FilaSwitchState(installed=True),
  195. raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
  196. )
  197. assert printer_state_to_dict(state)["expected_tray"] is None
  198. def test_rest_and_websocket_pass_the_nozzle(self):
  199. state = _state(
  200. state="PAUSE",
  201. tray_tar=2,
  202. active_extruder=1,
  203. ams_extruder_map=dict(self.NOZZLES),
  204. raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
  205. )
  206. assert printer_state_to_dict(state)["expected_tray"] == 6
  207. class TestAmsHtIsItsUnitId:
  208. def test_a_regular_ams_reporting_one_tray_is_not_ht(self):
  209. units = ams_units_to_dicts([{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}], lambda *a: None, None)
  210. assert units[0]["is_ams_ht"] is False
  211. def test_an_ht_unit_is_ht(self):
  212. units = ams_units_to_dicts([{"id": "128", "tray": [{"id": "0", "tray_type": "PLA"}]}], lambda *a: None, None)
  213. assert units[0]["is_ams_ht"] is True
  214. def test_the_scheduler_reads_the_unit_id(self):
  215. from backend.app.services.print_scheduler import PrintScheduler
  216. status = SimpleNamespace(
  217. raw_data={"ams": [{"id": "1", "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}]}]},
  218. nozzles=[],
  219. )
  220. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  221. pm.get_status.return_value = status
  222. pm.get_model.return_value = "X1C"
  223. filaments = PrintScheduler()._build_loaded_filaments(status)
  224. assert [(f["global_tray_id"], f["is_ht"]) for f in filaments] == [(4, False)]
  225. @pytest.mark.asyncio
  226. async def test_the_rest_status_reports_them_too(async_client, printer_factory):
  227. printer = await printer_factory()
  228. state = _state(
  229. state="PAUSE",
  230. tray_tar=2,
  231. active_extruder=1,
  232. ams_extruder_map={"0": 0, "1": 1},
  233. print_ams_mapping=[6, -1],
  234. raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
  235. )
  236. with patch("backend.app.api.routes.printers.printer_manager") as pm:
  237. pm.get_status = MagicMock(return_value=state)
  238. pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  239. pm.get_drying_targets = MagicMock(return_value={})
  240. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  241. body = response.json()
  242. assert (body["expected_tray"], body["ams_mapping"]) == (6, [6, -1])
  243. assert [unit["is_ams_ht"] for unit in body["ams"]] == [False, False]