diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 621a11d45..412a6e28a 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -276,28 +276,34 @@ def apply_tray_exist_bits( # --- H2C nozzle-rack dispatch mapping (#2800) ------------------------------- # -# Physical nozzle IDs the H2C reports for its six rack slots. The two hotend -# carriage positions are 0 and 1 in the same namespace, which is why a rack -# position can never be confused with an extruder index by value. +# Physical nozzle IDs the H2C reports for its six rack slots, verified on +# hardware. They sit well clear of the fixed hotend's own physical ID, so a +# rack position is never mistakable for the nozzle on the other carriage. +# +# Extruder indices are a different namespace that happens to overlap these +# low numbers -- index 1 means the rack, physical ID 1 means the fixed hotend. +# Nothing below may pass a value from one namespace to the other untranslated; +# doing exactly that is what #2800 was. _RACK_NOZZLE_IDS = frozenset(range(16, 22)) # BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one # physical nozzle ID per filament slot, -1 for slots the plate does not print. _RACK_WIRE_SLOTS = 32 -# The extruder the rack feeds. On the H2C the swappable hotend sits on the -# right carriage, which the slicer's physical_extruder_map numbers 0 (left is -# 1) -- so a slot assigned extruder 0 is a slot that prints from whichever -# rack nozzle is currently mounted. -# -# This is the one value here taken from a single hardware observation (#2800) -# rather than from something the printer reports. It is safe to be wrong about -# for a job that prints entirely from one side: if the rack were really on -# extruder 1, no slot would match and the mapping would simply be omitted, -# which is the behaviour that existed before any of this. Only a job that -# prints from both nozzles at once could be actively harmed by a flip, and -# that is what a second hardware capture needs to confirm. -_RACK_EXTRUDER_ID = 0 +# The two carriages, as extruder indices in the form the queue stores (already +# translated through the file's physical_extruder_map). Settled on hardware in +# #2800: the same sliced mixed-nozzle plate dispatched as [17, -1, -1, 1] +# printed the rack nozzle several millimetres above the bed, and as +# [1, -1, -1, 17] printed correctly on both nozzles start to finish. +_FIXED_EXTRUDER_ID = 0 +_RACK_EXTRUDER_ID = 1 + +# The fixed hotend's physical ID, which is *not* its extruder index. The same +# hardware A/B ruled the index out: [0, -1, -1, 17] was rejected by the printer +# outright, which would not start the job at all. Native BambuStudio captures +# of a mixed plate agree -- [1, 17, ...], and [17, 1, ...] once the filament +# slot order is swapped, so the fixed side is 1 whichever slot it lands in. +_FIXED_NOZZLE_ID = 1 def resolve_rack_nozzle_mapping( @@ -323,9 +329,11 @@ def resolve_rack_nozzle_mapping( - a slot needs the rack but the printer has not reported a live rack position (mid-swap, or a stale connection); - - no slot needs the rack at all. The non-rack hotend's own physical ID is - not yet confirmed against a known-good BambuStudio capture, and this - code will not guess one. Such a job dispatches as it does today. + - no slot needs the rack at all. BambuStudio omits nozzle_mapping entirely + for a plate sliced for the fixed hotend only (#2800 capture), so this + matches it rather than naming a nozzle it does not have to name; + - a slot names a carriage that is neither of the two an H2C has, which + means the file was mapped for a machine this translation does not model; - the plate needs more slots than the wire format carries; - the input is not a list of whole numbers. @@ -363,7 +371,15 @@ def resolve_rack_nozzle_mapping( for index, extruder in enumerate(normalised): if extruder < 0: continue - wire[index] = rack_nozzle_id if extruder == _RACK_EXTRUDER_ID else extruder + if extruder == _RACK_EXTRUDER_ID: + wire[index] = rack_nozzle_id + elif extruder == _FIXED_EXTRUDER_ID: + wire[index] = _FIXED_NOZZLE_ID + else: + # An H2C has these two carriages and no others. A third index is a + # file mapped for something else, and forwarding it raw would name + # a physical nozzle by an index that does not identify one. + return None return wire diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 7d80b309d..6ef111bed 100644 --- a/backend/app/utils/printer_models.py +++ b/backend/app/utils/printer_models.py @@ -240,9 +240,12 @@ DUAL_NOZZLE_MODELS = frozenset( # Why this needs its own set rather than reusing DUAL_NOZZLE_MODELS: on every # other dual-nozzle printer the dispatch `nozzle_mapping` values ARE the MQTT # extruder indices (0 = right, 1 = left). On a rack model the wire wants the -# *physical* nozzle position, and the rack positions are reported by the -# firmware as IDs 16-21 — see `device.nozzle.info` handling in bambu_mqtt. -# Sending an extruder index where a rack position is expected makes the +# *physical* nozzle position for both carriages: the rack positions the +# firmware reports as IDs 16-21 — see `device.nozzle.info` handling in +# bambu_mqtt — and 1 for the fixed hotend, which is not its extruder index. +# Note the H2C does not follow the 0 = right convention either: extruder +# index 1 is the rack side, confirmed on hardware in #2800. +# Sending an extruder index where a physical position is expected makes the # printer clean and level with one nozzle and then print with another, at the # wrong Z (#2800). NOZZLE_RACK_MODELS = frozenset( diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py index 9e953722f..f1ebe0d5c 100644 --- a/backend/tests/unit/test_nozzle_rack_mapping_2800.py +++ b/backend/tests/unit/test_nozzle_rack_mapping_2800.py @@ -37,19 +37,23 @@ class TestIsNozzleRackModel: class TestResolveRackNozzleMapping: def test_rack_slot_takes_the_live_rack_position(self): - mapping = resolve_rack_nozzle_mapping([0], rack_nozzle_id=17) + mapping = resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) assert mapping is not None assert len(mapping) == _RACK_WIRE_SLOTS assert mapping[0] == 17 assert set(mapping[1:]) == {-1} - def test_non_rack_slots_keep_their_extruder_index(self): - """Only the rack extruder is substituted; the fixed hotend is untouched.""" - mapping = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=21) + def test_the_fixed_hotend_takes_its_own_physical_id(self): + """Both carriages are translated; neither extruder index reaches the wire. + + Sending the index for the fixed side (0) is what the printer rejected + outright on hardware — it would not start the job at all. + """ + mapping = resolve_rack_nozzle_mapping([0, 1], rack_nozzle_id=21) assert mapping[:2] == [1, 21] def test_unprinted_slots_stay_unset(self): - mapping = resolve_rack_nozzle_mapping([0, -1, 0], rack_nozzle_id=16) + mapping = resolve_rack_nozzle_mapping([1, -1, 1], rack_nozzle_id=16) assert mapping[:3] == [16, -1, 16] @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255]) @@ -59,21 +63,35 @@ class TestResolveRackNozzleMapping: Guessing here is what prints in mid-air, so returning None (and omitting nozzle_mapping) is the intended failure mode. """ - assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=rack_id) is None + assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=rack_id) is None def test_job_that_never_uses_the_rack_is_left_alone(self): - """The fixed hotend's own physical ID is not confirmed by a capture yet.""" - assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None + """BambuStudio omits nozzle_mapping for a fixed-hotend-only plate. + + Captured from the reporter's H2C: a plate sliced for the fixed side + alone carries ams_mapping and no nozzle_mapping field at all, so + naming a nozzle here would depart from what the printer expects. + """ + assert resolve_rack_nozzle_mapping([0, 0], rack_nozzle_id=17) is None + + @pytest.mark.parametrize("unknown", [2, 3, 31]) + def test_a_carriage_the_h2c_does_not_have_omits_the_field(self, unknown): + """A third index means the file was mapped for another machine. + + Forwarding it raw would name a physical nozzle by a number that does + not identify one, which is the class of mistake #2800 was. + """ + assert resolve_rack_nozzle_mapping([unknown, 1], rack_nozzle_id=17) is None @pytest.mark.parametrize( "bad_slots", [ - ["a", 0], # non-numeric - [{}, 0], # nested object - [[0], 0], # nested list - [0.5, 0], # fractional - [True, 0], # bool would reach the wire as JSON `true` - "0", # not a list at all + ["a", 1], # non-numeric + [{}, 1], # nested object + [[0], 1], # nested list + [0.5, 1], # fractional + [True, 1], # bool would reach the wire as JSON `true` + "1", # not a list at all ], ) def test_junk_input_returns_none_and_never_raises(self, bad_slots): @@ -85,23 +103,29 @@ class TestResolveRackNozzleMapping: @pytest.mark.parametrize("bad_rack", [[17], {"id": 17}, "17", 17.0, True]) def test_junk_rack_position_returns_none_and_never_raises(self, bad_rack): - assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=bad_rack) is None + assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=bad_rack) is None def test_none_entries_read_as_unprinted(self): - assert resolve_rack_nozzle_mapping([None, 0], rack_nozzle_id=17)[:2] == [-1, 17] + assert resolve_rack_nozzle_mapping([None, 1], rack_nozzle_id=17)[:2] == [-1, 17] - def test_a_flipped_rack_side_would_omit_rather_than_misfire(self): - """Guards the one assumption taken from a single hardware capture. + def test_hardware_confirmed_mixed_nozzle_plate(self): + """The exact job the reporter ran on an H2C, both ways round. - If the rack turned out to feed the other extruder, a job printing - entirely from one side matches nothing and falls back to the - firmware's own pick — the pre-#2800 behaviour — instead of naming a - nozzle confidently and wrongly. + Dispatched as [17, -1, -1, 1] the rack nozzle printed several + millimetres above the bed; dispatched as [1, -1, -1, 17] the same + sliced file printed correctly on both nozzles and completed. Native + BambuStudio captures of mixed plates on the same machine carry + [1, 17, ...] and [17, 1, ...] depending on filament slot order. """ - assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None + wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17) + assert wire[:4] == [1, -1, -1, 17] + assert set(wire[4:]) == {-1} + + swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17) + assert swapped[:2] == [17, 1] def test_more_slots_than_the_wire_carries(self): - assert resolve_rack_nozzle_mapping([0] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None + assert resolve_rack_nozzle_mapping([1] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None def test_empty_mapping(self): assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None @@ -161,7 +185,7 @@ class TestDispatch: def test_rack_model_resolves_slot_extruders(self): client = self._client("H2C") client.state.nozzle_rack_tar_id = 18 - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, -1, 0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1, -1, 1])) cmd = self._print_cmd(client) assert cmd["nozzle_mapping"][:3] == [18, -1, 18] @@ -170,12 +194,12 @@ class TestDispatch: client = self._client("H2C") client.state.nozzle_rack_src_id = 20 client.state.nozzle_rack_tar_id = 0 - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1])) assert self._print_cmd(client)["nozzle_mapping"][0] == 20 def test_unknown_rack_position_omits_the_field(self): client = self._client("H2C") - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1])) assert "nozzle_mapping" not in self._print_cmd(client) def test_studio_capture_is_never_overridden(self): @@ -185,7 +209,7 @@ class TestDispatch: client.start_print( "job.3mf", nozzle_mapping=json.dumps([16, -1, -1, 1]), - nozzle_slot_extruders=json.dumps([0, -1, 0]), + nozzle_slot_extruders=json.dumps([1, -1, 1]), ) assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1] @@ -214,9 +238,10 @@ class TestDispatch: def _write_dual_nozzle_3mf(path, group_by_slot): """Minimal 3MF carrying just what the nozzle extractor reads. - physical_extruder_map is [1, 0] as Bambu ships it: slicer group 0 is the - left extruder (MQTT index 1) and group 1 the right (index 0) — the right - being the one the H2C rack feeds. + physical_extruder_map is [1, 0] as Bambu ships it, so slicer group 0 comes + out as MQTT extruder index 1 and group 1 as index 0. On the H2C index 1 is + the rack carriage — confirmed on hardware in #2800, and the reason the + rack-side fixture below slices its filaments into group 0. """ filaments = "".join(f'' for slot, group in group_by_slot.items()) with zipfile.ZipFile(path, "w") as zf: @@ -235,19 +260,23 @@ def _write_dual_nozzle_3mf(path, group_by_slot): class TestSlotExtrudersFromFile: def test_derives_dense_per_slot_extruders(self, tmp_path): - """Slots 1 and 3 print from the right (rack) extruder; slot 2 is unused.""" + """Slots 1 and 3 print from the fixed hotend; slot 2 is unused.""" source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1}) assert extract_slot_extruders_from_3mf(source) == [0, -1, 0] def test_end_to_end_reaches_the_rack_position(self, tmp_path): """The reported failure: a two-slot job that must print from the rack.""" - source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1}) + source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 3: 0}) + assert extract_slot_extruders_from_3mf(source) == [1, -1, 1] wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17) assert wire[:3] == [17, -1, 17] def test_both_extruders(self, tmp_path): + """One slot per carriage — the mixed job that printed in mid-air.""" source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1}) assert extract_slot_extruders_from_3mf(source) == [1, 0] + wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17) + assert wire[:2] == [17, 1] def test_single_nozzle_file_yields_nothing(self, tmp_path): path = tmp_path / "single.3mf"