test_nozzle_rack_mapping_2800.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  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([1], rack_nozzle_id=17)
  31. assert mapping == [17]
  32. def test_the_wire_is_as_long_as_the_plate_has_slots(self):
  33. """One entry per filament slot, not a fixed-length padded array.
  34. BambuStudio's own dispatch of a three-filament H2C plate is
  35. [1, 16, 16] -- three entries, captured from the maintainer's machine.
  36. The earlier fixed 32-length padding was a generalisation from nothing.
  37. """
  38. assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) == [17]
  39. assert resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16) == [1, 16, 1]
  40. assert len(resolve_rack_nozzle_mapping([1, 0, 0, -1], rack_nozzle_id=16)) == 4
  41. def test_the_fixed_hotend_takes_its_own_physical_id(self):
  42. """Both carriages are translated; neither extruder index reaches the wire.
  43. Sending the index for the fixed side (0) is what the printer rejected
  44. outright on hardware — it would not start the job at all.
  45. """
  46. mapping = resolve_rack_nozzle_mapping([0, 1], rack_nozzle_id=21)
  47. assert mapping[:2] == [1, 21]
  48. def test_unprinted_slots_stay_unset(self):
  49. mapping = resolve_rack_nozzle_mapping([1, -1, 1], rack_nozzle_id=16)
  50. assert mapping[:3] == [16, -1, 16]
  51. @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255])
  52. def test_no_usable_rack_position_omits_the_field(self, rack_id):
  53. """Mid-swap or stale state must fall back to the firmware's own pick.
  54. Guessing here is what prints in mid-air, so returning None (and
  55. omitting nozzle_mapping) is the intended failure mode.
  56. """
  57. assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=rack_id) is None
  58. def test_job_that_never_uses_the_rack_is_left_alone(self):
  59. """BambuStudio omits nozzle_mapping for a fixed-hotend-only plate.
  60. Captured from the reporter's H2C: a plate sliced for the fixed side
  61. alone carries ams_mapping and no nozzle_mapping field at all, so
  62. naming a nozzle here would depart from what the printer expects.
  63. """
  64. assert resolve_rack_nozzle_mapping([0, 0], rack_nozzle_id=17) is None
  65. @pytest.mark.parametrize("unknown", [2, 3, 31])
  66. def test_a_carriage_the_h2c_does_not_have_omits_the_field(self, unknown):
  67. """A third index means the file was mapped for another machine.
  68. Forwarding it raw would name a physical nozzle by a number that does
  69. not identify one, which is the class of mistake #2800 was.
  70. """
  71. assert resolve_rack_nozzle_mapping([unknown, 1], rack_nozzle_id=17) is None
  72. @pytest.mark.parametrize(
  73. "bad_slots",
  74. [
  75. ["a", 1], # non-numeric
  76. [{}, 1], # nested object
  77. [[0], 1], # nested list
  78. [0.5, 1], # fractional
  79. [True, 1], # bool would reach the wire as JSON `true`
  80. "1", # not a list at all
  81. ],
  82. )
  83. def test_junk_input_returns_none_and_never_raises(self, bad_slots):
  84. """Nothing above this raises: `start_print` builds the MQTT command
  85. with no exception handler, and by then the queue item is already
  86. committed as `printing`. A bad value has to degrade to "firmware
  87. picks", not wedge the item in a state no print will leave."""
  88. assert resolve_rack_nozzle_mapping(bad_slots, rack_nozzle_id=17) is None
  89. @pytest.mark.parametrize("bad_rack", [[17], {"id": 17}, "17", 17.0, True])
  90. def test_junk_rack_position_returns_none_and_never_raises(self, bad_rack):
  91. assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=bad_rack) is None
  92. def test_none_entries_read_as_unprinted(self):
  93. assert resolve_rack_nozzle_mapping([None, 1], rack_nozzle_id=17)[:2] == [-1, 17]
  94. def test_hardware_confirmed_mixed_nozzle_plate(self):
  95. """The exact job the reporter ran on an H2C, both ways round.
  96. Dispatched as [17, -1, -1, 1] the rack nozzle printed several
  97. millimetres above the bed; dispatched as [1, -1, -1, 17] the same
  98. sliced file printed correctly on both nozzles and completed. Native
  99. BambuStudio captures of mixed plates on the same machine carry
  100. [1, 17, ...] and [17, 1, ...] depending on filament slot order.
  101. """
  102. wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17)
  103. assert wire == [1, -1, -1, 17]
  104. swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17)
  105. assert swapped[:2] == [17, 1]
  106. def test_more_slots_than_the_wire_carries(self):
  107. assert resolve_rack_nozzle_mapping([1] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None
  108. def test_empty_mapping(self):
  109. assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None
  110. class TestRackPositionFromMqtt:
  111. @pytest.fixture
  112. def client(self):
  113. return BambuMQTTClient(
  114. ip_address="192.168.1.100",
  115. serial_number="TEST-H2C",
  116. access_code="12345678",
  117. model="H2C",
  118. )
  119. def test_src_and_tar_are_captured(self, client):
  120. client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
  121. assert client.state.nozzle_rack_src_id == 16
  122. assert client.state.nozzle_rack_tar_id == 19
  123. def test_absent_key_does_not_clear_the_last_known_value(self, client):
  124. """The firmware only pushes these when they change."""
  125. client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}})
  126. client._update_state({"device": {"nozzle": {"info": []}}})
  127. assert client.state.nozzle_rack_tar_id == 19
  128. def test_unparseable_value_is_ignored(self, client):
  129. client._update_state({"device": {"nozzle": {"tar_id": 19}}})
  130. client._update_state({"device": {"nozzle": {"tar_id": "nonsense"}}})
  131. assert client.state.nozzle_rack_tar_id == 19
  132. def test_starts_unknown(self, client):
  133. assert client.state.nozzle_rack_src_id is None
  134. assert client.state.nozzle_rack_tar_id is None
  135. class TestDispatch:
  136. """What actually reaches the wire."""
  137. def _client(self, model):
  138. from unittest.mock import MagicMock
  139. client = BambuMQTTClient(
  140. ip_address="192.168.1.100",
  141. serial_number="TEST-DISPATCH",
  142. access_code="12345678",
  143. model=model,
  144. )
  145. client._client = MagicMock()
  146. client.state.connected = True
  147. client._is_dual_nozzle = True
  148. return client
  149. def _print_cmd(self, client):
  150. return json.loads(client._client.publish.call_args[0][1])["print"]
  151. def test_rack_model_resolves_slot_extruders(self):
  152. client = self._client("H2C")
  153. client.state.nozzle_rack_tar_id = 18
  154. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1, -1, 1]))
  155. cmd = self._print_cmd(client)
  156. assert cmd["nozzle_mapping"][:3] == [18, -1, 18]
  157. def test_src_id_used_when_tar_id_is_not_a_rack_position(self):
  158. """Between swaps the printer can report a settled src_id and nothing else."""
  159. client = self._client("H2C")
  160. client.state.nozzle_rack_src_id = 20
  161. client.state.nozzle_rack_tar_id = 0
  162. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1]))
  163. assert self._print_cmd(client)["nozzle_mapping"][0] == 20
  164. def test_unknown_rack_position_omits_the_field(self):
  165. client = self._client("H2C")
  166. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1]))
  167. assert "nozzle_mapping" not in self._print_cmd(client)
  168. def test_studio_capture_is_never_overridden(self):
  169. """A real capture is authoritative; the derived fallback must stand down."""
  170. client = self._client("H2C")
  171. client.state.nozzle_rack_tar_id = 18
  172. client.start_print(
  173. "job.3mf",
  174. nozzle_mapping=json.dumps([16, -1, -1, 1]),
  175. nozzle_slot_extruders=json.dumps([1, -1, 1]),
  176. )
  177. assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1]
  178. def test_other_dual_nozzle_models_are_untouched(self):
  179. """H2D has no rack: its extruder indices are already the wire values."""
  180. client = self._client("H2D")
  181. client.state.nozzle_rack_tar_id = 18
  182. client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, 1]))
  183. assert "nozzle_mapping" not in self._print_cmd(client)
  184. def test_malformed_slot_extruders_is_logged_and_omitted(self, caplog):
  185. client = self._client("H2C")
  186. client.state.nozzle_rack_tar_id = 18
  187. with caplog.at_level("WARNING"):
  188. client.start_print("job.3mf", nozzle_slot_extruders="not json {")
  189. assert "nozzle_mapping" not in self._print_cmd(client)
  190. assert any("Invalid nozzle_slot_extruders" in rec.message for rec in caplog.records)
  191. def test_absent_slot_extruders_changes_nothing(self):
  192. client = self._client("H2C")
  193. client.state.nozzle_rack_tar_id = 18
  194. client.start_print("job.3mf")
  195. assert "nozzle_mapping" not in self._print_cmd(client)
  196. def _write_dual_nozzle_3mf(path, group_by_slot):
  197. """Minimal 3MF carrying just what the nozzle extractor reads.
  198. physical_extruder_map is [1, 0] as Bambu ships it, so slicer group 0 comes
  199. out as MQTT extruder index 1 and group 1 as index 0. On the H2C index 1 is
  200. the rack carriage — confirmed on hardware in #2800, and the reason the
  201. rack-side fixture below slices its filaments into group 0.
  202. """
  203. filaments = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in group_by_slot.items())
  204. with zipfile.ZipFile(path, "w") as zf:
  205. zf.writestr(
  206. "Metadata/project_settings.config",
  207. json.dumps(
  208. {
  209. "physical_extruder_map": [1, 0],
  210. "extruder_nozzle_stats": ["Standard#1", "Standard#1"],
  211. }
  212. ),
  213. )
  214. zf.writestr("Metadata/slice_info.config", f"<config><plate>{filaments}</plate></config>")
  215. return path
  216. class TestSlotExtrudersFromFile:
  217. def test_derives_dense_per_slot_extruders(self, tmp_path):
  218. """Slots 1 and 3 print from the fixed hotend; slot 2 is unused."""
  219. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
  220. assert extract_slot_extruders_from_3mf(source) == [0, -1, 0]
  221. def test_end_to_end_reaches_the_rack_position(self, tmp_path):
  222. """The reported failure: a two-slot job that must print from the rack."""
  223. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 3: 0})
  224. assert extract_slot_extruders_from_3mf(source) == [1, -1, 1]
  225. wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17)
  226. assert wire[:3] == [17, -1, 17]
  227. def test_both_extruders(self, tmp_path):
  228. """One slot per carriage — the mixed job that printed in mid-air."""
  229. source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1})
  230. assert extract_slot_extruders_from_3mf(source) == [1, 0]
  231. wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17)
  232. assert wire[:2] == [17, 1]
  233. def test_single_nozzle_file_yields_nothing(self, tmp_path):
  234. path = tmp_path / "single.3mf"
  235. with zipfile.ZipFile(path, "w") as zf:
  236. zf.writestr("Metadata/project_settings.config", json.dumps({"physical_extruder_map": [0]}))
  237. assert extract_slot_extruders_from_3mf(path) is None
  238. def test_unreadable_file_is_not_fatal(self, tmp_path):
  239. path = tmp_path / "broken.3mf"
  240. path.write_bytes(b"not a zip")
  241. assert extract_slot_extruders_from_3mf(path) is None
  242. @pytest.mark.parametrize("slot_id", [50000000, 65, 0, -3])
  243. def test_out_of_range_slot_ids_are_rejected(self, tmp_path, slot_id):
  244. """Slot IDs are whatever the file claims, and this builds a dense list.
  245. Without a ceiling a corrupt or hostile 3MF declaring
  246. `filament id="50000000"` allocates a fifty-million-entry list on the
  247. dispatch path.
  248. """
  249. source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1})
  250. assert extract_slot_extruders_from_3mf(source) is None
  251. def _write_h2c_3mf(path, plates):
  252. """3MF in the shape BambuStudio writes for a nozzle-rack machine.
  253. ``plates`` is a list of ``(index, {slot: group}, {group: 1-based extruder})``.
  254. The per-plate ``<nozzle>`` elements are the group-to-extruder table; the
  255. fixture above deliberately omits them, because H2D files carry group ids
  256. that already are extruder indices and both shapes have to keep working.
  257. """
  258. body = ""
  259. for index, filaments, nozzles in plates:
  260. elems = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in filaments.items())
  261. elems += "".join(f'<nozzle id="{group}" extruder_id="{ext}"/>' for group, ext in nozzles.items())
  262. body += f'<plate><metadata key="index" value="{index}"/>{elems}</plate>'
  263. with zipfile.ZipFile(path, "w") as zf:
  264. zf.writestr(
  265. "Metadata/project_settings.config",
  266. json.dumps(
  267. {
  268. "physical_extruder_map": [1, 0],
  269. # One nozzle on the fixed carriage, four racked, as the
  270. # reporter's H2C reports itself.
  271. "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"],
  272. }
  273. ),
  274. )
  275. zf.writestr("Metadata/slice_info.config", f"<config>{body}</config>")
  276. return path
  277. class TestGroupsBeyondTheExtruderCount:
  278. """A rack carriage hosts six hotends, so groups outnumber extruders.
  279. The H2C's `extruder_max_nozzle_count` is ['1', '6'], and the slicer emits
  280. one filament group per nozzle it wants rather than one per carriage. Group
  281. ids therefore run past the end of `physical_extruder_map`, which has an
  282. entry per *extruder*. Treating the group id as an index into it silently
  283. dropped those filaments, and a dropped filament densifies to -1 -- the same
  284. value that means "this plate does not print the slot".
  285. """
  286. def test_the_dropped_filament_from_the_hms_0500_4047_report(self, tmp_path):
  287. """The maintainer's own plate, first print on a new H2C.
  288. Three filaments in groups 2, 0 and 1 against a two-entry
  289. physical_extruder_map. Slot 1 fell out of the mapping and dispatched as
  290. [-1, 16, 1, ...] while ams_mapping named tray 6 for that same slot; the
  291. printer stopped with "the available hotend quantity or model does not
  292. match the sliced file". The file's own nozzle table says group 2 prints
  293. on extruder 2, the rack side, same as group 1.
  294. """
  295. source = _write_h2c_3mf(
  296. tmp_path / "benchy.3mf",
  297. [(1, {1: 2, 2: 0, 3: 1}, {0: 1, 1: 2, 2: 2})],
  298. )
  299. assert extract_slot_extruders_from_3mf(source, plate_id=1) == [0, 1, 0]
  300. wire = resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16)
  301. assert wire == [1, 16, 1]
  302. def test_a_group_the_file_never_places_omits_the_whole_mapping(self, tmp_path):
  303. """Refusing beats answering for the slots that did resolve.
  304. A partial mapping is not a smaller answer, it is a wrong one: the gap
  305. reaches the printer as -1, contradicting the ams_mapping entry for the
  306. same slot. Omitting costs only the firmware's own nozzle pick.
  307. """
  308. source = _write_h2c_3mf(
  309. tmp_path / "orphan.3mf",
  310. [(1, {1: 5, 2: 0}, {0: 1, 1: 2})],
  311. )
  312. assert extract_slot_extruders_from_3mf(source, plate_id=1) is None
  313. def test_a_slot_the_plate_genuinely_skips_still_reads_minus_one(self, tmp_path):
  314. """-1 keeps its meaning where it is earned rather than inferred."""
  315. source = _write_h2c_3mf(
  316. tmp_path / "gap.3mf",
  317. [(1, {1: 0, 3: 1}, {0: 1, 1: 2})],
  318. )
  319. assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, -1, 0]
  320. def test_filaments_grouped_and_ungrouped_in_one_plate_omit_the_mapping(self, tmp_path):
  321. """Half an answer has the same failure mode as a dropped group."""
  322. path = tmp_path / "mixed.3mf"
  323. with zipfile.ZipFile(path, "w") as zf:
  324. zf.writestr(
  325. "Metadata/project_settings.config",
  326. json.dumps(
  327. {
  328. "physical_extruder_map": [1, 0],
  329. "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"],
  330. }
  331. ),
  332. )
  333. zf.writestr(
  334. "Metadata/slice_info.config",
  335. '<config><plate><metadata key="index" value="1"/>'
  336. '<filament id="1" group_id="0"/><filament id="2"/>'
  337. '<nozzle id="0" extruder_id="1"/></plate></config>',
  338. )
  339. assert extract_slot_extruders_from_3mf(path, plate_id=1) is None
  340. def test_files_without_a_nozzle_table_keep_the_group_as_the_index(self, tmp_path):
  341. """Every H2D slice in the wild carries groups 0 and 1 and no table."""
  342. source = _write_dual_nozzle_3mf(tmp_path / "h2d.3mf", {1: 0, 2: 1})
  343. assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0]
  344. class TestPlateScoping:
  345. """A 3MF holds every plate in the project, not just the one being printed."""
  346. def test_each_plate_answers_for_itself(self, tmp_path):
  347. source = _write_h2c_3mf(
  348. tmp_path / "two.3mf",
  349. [
  350. (1, {1: 0, 2: 1}, {0: 1, 1: 2}),
  351. (2, {1: 1, 2: 0}, {0: 1, 1: 2}),
  352. ],
  353. )
  354. assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0]
  355. assert extract_slot_extruders_from_3mf(source, plate_id=2) == [0, 1]
  356. def test_an_unknown_plate_falls_back_to_the_whole_file(self, tmp_path):
  357. """Not every file indexes its plates; this must not become a hard stop."""
  358. source = _write_h2c_3mf(tmp_path / "one.3mf", [(1, {1: 0, 2: 1}, {0: 1, 1: 2})])
  359. assert extract_slot_extruders_from_3mf(source, plate_id=7) == [1, 0]
  360. def test_plates_that_disagree_about_a_group_fall_back_to_the_index(self, tmp_path):
  361. """Reading every plate at once can only be done on the old terms.
  362. With no plate asked for, two plates naming different extruders for one
  363. group leave no table worth trusting, so the group id is read as the
  364. extruder index exactly as it was before the table existed.
  365. """
  366. source = _write_h2c_3mf(
  367. tmp_path / "conflict.3mf",
  368. [
  369. (1, {1: 0}, {0: 1}),
  370. (2, {2: 0}, {0: 2}),
  371. ],
  372. )
  373. assert extract_slot_extruders_from_3mf(source, plate_id=None) == [1, 1]