test_nozzle_rack_mapping_2800.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """Nozzle-rack (H2C) dispatch mapping — #2800.
  2. The H2C mounts one of six rack hotends on its right carriage. Dispatch has to
  3. name the *physical* rack position, not the extruder index every other
  4. dual-nozzle printer uses; get it wrong and the printer cleans and levels with
  5. one nozzle, then prints with another several millimetres off the bed.
  6. Nothing in the queue knew the rack position, so these jobs shipped with no
  7. `nozzle_mapping` at all and the firmware picked for itself.
  8. """
  9. import json
  10. import zipfile
  11. import pytest
  12. from backend.app.services.bambu_mqtt import (
  13. _RACK_WIRE_SLOTS,
  14. BambuMQTTClient,
  15. resolve_rack_nozzle_mapping,
  16. )
  17. from backend.app.utils.printer_models import is_nozzle_rack_model
  18. from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf
  19. class TestIsNozzleRackModel:
  20. @pytest.mark.parametrize("model", ["H2C", "h2c", " H2C ", "O1C", "O1C2"])
  21. def test_h2c_spellings_and_codes(self, model):
  22. """The printer row may hold either the display name or the SSDP code."""
  23. assert is_nozzle_rack_model(model) is True
  24. @pytest.mark.parametrize("model", ["H2D", "H2D Pro", "H2S", "X2D", "P1S", "O1D", "N6", "", None])
  25. def test_everything_else_is_not_a_rack_model(self, model):
  26. """Other dual-nozzle printers must keep the plain extruder-index wire."""
  27. assert is_nozzle_rack_model(model) is False
  28. class TestResolveRackNozzleMapping:
  29. def test_rack_slot_takes_the_live_rack_position(self):
  30. mapping = resolve_rack_nozzle_mapping([0], rack_nozzle_id=17)
  31. assert mapping is not None
  32. assert len(mapping) == _RACK_WIRE_SLOTS
  33. assert mapping[0] == 17
  34. assert set(mapping[1:]) == {-1}
  35. def test_non_rack_slots_keep_their_extruder_index(self):
  36. """Only the rack extruder is substituted; the fixed hotend is untouched."""
  37. mapping = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=21)
  38. assert mapping[:2] == [1, 21]
  39. def test_unprinted_slots_stay_unset(self):
  40. mapping = resolve_rack_nozzle_mapping([0, -1, 0], rack_nozzle_id=16)
  41. assert mapping[:3] == [16, -1, 16]
  42. @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255])
  43. def test_no_usable_rack_position_omits_the_field(self, rack_id):
  44. """Mid-swap or stale state must fall back to the firmware's own pick.
  45. Guessing here is what prints in mid-air, so returning None (and
  46. omitting nozzle_mapping) is the intended failure mode.
  47. """
  48. assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=rack_id) is None
  49. def test_job_that_never_uses_the_rack_is_left_alone(self):
  50. """The fixed hotend's own physical ID is not confirmed by a capture yet."""
  51. assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None
  52. @pytest.mark.parametrize(
  53. "bad_slots",
  54. [
  55. ["a", 0], # non-numeric
  56. [{}, 0], # nested object
  57. [[0], 0], # nested list
  58. [0.5, 0], # fractional
  59. [True, 0], # bool would reach the wire as JSON `true`
  60. "0", # not a list at all
  61. ],
  62. )
  63. def test_junk_input_returns_none_and_never_raises(self, bad_slots):
  64. """Nothing above this raises: `start_print` builds the MQTT command
  65. with no exception handler, and by then the queue item is already
  66. committed as `printing`. A bad value has to degrade to "firmware
  67. picks", not wedge the item in a state no print will leave."""
  68. assert resolve_rack_nozzle_mapping(bad_slots, rack_nozzle_id=17) is None
  69. @pytest.mark.parametrize("bad_rack", [[17], {"id": 17}, "17", 17.0, True])
  70. def test_junk_rack_position_returns_none_and_never_raises(self, bad_rack):
  71. assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=bad_rack) is None
  72. def test_none_entries_read_as_unprinted(self):
  73. assert resolve_rack_nozzle_mapping([None, 0], rack_nozzle_id=17)[:2] == [-1, 17]
  74. def test_a_flipped_rack_side_would_omit_rather_than_misfire(self):
  75. """Guards the one assumption taken from a single hardware capture.
  76. If the rack turned out to feed the other extruder, a job printing
  77. entirely from one side matches nothing and falls back to the
  78. firmware's own pick — the pre-#2800 behaviour — instead of naming a
  79. nozzle confidently and wrongly.
  80. """
  81. assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None
  82. def test_more_slots_than_the_wire_carries(self):
  83. assert resolve_rack_nozzle_mapping([0] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None
  84. def test_empty_mapping(self):
  85. assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None
  86. class TestRackPositionFromMqtt:
  87. @pytest.fixture
  88. def client(self):
  89. return BambuMQTTClient(
  90. ip_address="192.168.1.100",
  91. serial_number="TEST-H2C",
  92. access_code="12345678",
  93. model="H2C",
  94. )
  95. def test_src_and_tar_are_captured(self, client):
  96. client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
  97. assert client.state.nozzle_rack_src_id == 16
  98. assert client.state.nozzle_rack_tar_id == 19
  99. def test_absent_key_does_not_clear_the_last_known_value(self, client):
  100. """The firmware only pushes these when they change."""
  101. client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
  102. client._update_state({"device": {"nozzle": {"info": []}}})
  103. assert client.state.nozzle_rack_tar_id == 19
  104. def test_unparseable_value_is_ignored(self, client):
  105. client._update_state({"device": {"nozzle": {"tar_id": 19}}})
  106. client._update_state({"device": {"nozzle": {"tar_id": "nonsense"}}})
  107. assert client.state.nozzle_rack_tar_id == 19
  108. def test_starts_unknown(self, client):
  109. assert client.state.nozzle_rack_src_id is None
  110. assert client.state.nozzle_rack_tar_id is None
  111. class TestDispatch:
  112. """What actually reaches the wire."""
  113. def _client(self, model):
  114. from unittest.mock import MagicMock
  115. client = BambuMQTTClient(
  116. ip_address="192.168.1.100",
  117. serial_number="TEST-DISPATCH",
  118. access_code="12345678",
  119. model=model,
  120. )
  121. client._client = MagicMock()
  122. client.state.connected = True
  123. client._is_dual_nozzle = True
  124. return client
  125. def _print_cmd(self, client):
  126. return json.loads(client._client.publish.call_args[0][1])["print"]
  127. def test_rack_model_resolves_slot_extruders(self):
  128. client = self._client("H2C")
  129. client.state.nozzle_rack_tar_id = 18
  130. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, -1, 0]))
  131. cmd = self._print_cmd(client)
  132. assert cmd["nozzle_mapping"][:3] == [18, -1, 18]
  133. def test_src_id_used_when_tar_id_is_not_a_rack_position(self):
  134. """Between swaps the printer can report a settled src_id and nothing else."""
  135. client = self._client("H2C")
  136. client.state.nozzle_rack_src_id = 20
  137. client.state.nozzle_rack_tar_id = 0
  138. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0]))
  139. assert self._print_cmd(client)["nozzle_mapping"][0] == 20
  140. def test_unknown_rack_position_omits_the_field(self):
  141. client = self._client("H2C")
  142. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0]))
  143. assert "nozzle_mapping" not in self._print_cmd(client)
  144. def test_studio_capture_is_never_overridden(self):
  145. """A real capture is authoritative; the derived fallback must stand down."""
  146. client = self._client("H2C")
  147. client.state.nozzle_rack_tar_id = 18
  148. client.start_print(
  149. "job.3mf",
  150. nozzle_mapping=json.dumps([16, -1, -1, 1]),
  151. nozzle_slot_extruders=json.dumps([0, -1, 0]),
  152. )
  153. assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1]
  154. def test_other_dual_nozzle_models_are_untouched(self):
  155. """H2D has no rack: its extruder indices are already the wire values."""
  156. client = self._client("H2D")
  157. client.state.nozzle_rack_tar_id = 18
  158. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, 1]))
  159. assert "nozzle_mapping" not in self._print_cmd(client)
  160. def test_malformed_slot_extruders_is_logged_and_omitted(self, caplog):
  161. client = self._client("H2C")
  162. client.state.nozzle_rack_tar_id = 18
  163. with caplog.at_level("WARNING"):
  164. client.start_print("job.3mf", nozzle_slot_extruders="not json {")
  165. assert "nozzle_mapping" not in self._print_cmd(client)
  166. assert any("Invalid nozzle_slot_extruders" in rec.message for rec in caplog.records)
  167. def test_absent_slot_extruders_changes_nothing(self):
  168. client = self._client("H2C")
  169. client.state.nozzle_rack_tar_id = 18
  170. client.start_print("job.3mf")
  171. assert "nozzle_mapping" not in self._print_cmd(client)
  172. def _write_dual_nozzle_3mf(path, group_by_slot):
  173. """Minimal 3MF carrying just what the nozzle extractor reads.
  174. physical_extruder_map is [1, 0] as Bambu ships it: slicer group 0 is the
  175. left extruder (MQTT index 1) and group 1 the right (index 0) — the right
  176. being the one the H2C rack feeds.
  177. """
  178. filaments = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in group_by_slot.items())
  179. with zipfile.ZipFile(path, "w") as zf:
  180. zf.writestr(
  181. "Metadata/project_settings.config",
  182. json.dumps(
  183. {
  184. "physical_extruder_map": [1, 0],
  185. "extruder_nozzle_stats": ["Standard#1", "Standard#1"],
  186. }
  187. ),
  188. )
  189. zf.writestr("Metadata/slice_info.config", f"<config><plate>{filaments}</plate></config>")
  190. return path
  191. class TestSlotExtrudersFromFile:
  192. def test_derives_dense_per_slot_extruders(self, tmp_path):
  193. """Slots 1 and 3 print from the right (rack) extruder; slot 2 is unused."""
  194. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
  195. assert extract_slot_extruders_from_3mf(source) == [0, -1, 0]
  196. def test_end_to_end_reaches_the_rack_position(self, tmp_path):
  197. """The reported failure: a two-slot job that must print from the rack."""
  198. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
  199. wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17)
  200. assert wire[:3] == [17, -1, 17]
  201. def test_both_extruders(self, tmp_path):
  202. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1})
  203. assert extract_slot_extruders_from_3mf(source) == [1, 0]
  204. def test_single_nozzle_file_yields_nothing(self, tmp_path):
  205. path = tmp_path / "single.3mf"
  206. with zipfile.ZipFile(path, "w") as zf:
  207. zf.writestr("Metadata/project_settings.config", json.dumps({"physical_extruder_map": [0]}))
  208. assert extract_slot_extruders_from_3mf(path) is None
  209. def test_unreadable_file_is_not_fatal(self, tmp_path):
  210. path = tmp_path / "broken.3mf"
  211. path.write_bytes(b"not a zip")
  212. assert extract_slot_extruders_from_3mf(path) is None
  213. @pytest.mark.parametrize("slot_id", [50000000, 65, 0, -3])
  214. def test_out_of_range_slot_ids_are_rejected(self, tmp_path, slot_id):
  215. """Slot IDs are whatever the file claims, and this builds a dense list.
  216. Without a ceiling a corrupt or hostile 3MF declaring
  217. `filament id="50000000"` allocates a fifty-million-entry list on the
  218. dispatch path.
  219. """
  220. source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1})
  221. assert extract_slot_extruders_from_3mf(source) is None