Selaa lähdekoodia

fix(h2c): send physical nozzle IDs for both carriages, not extruder indices (#2800)

The first pass at the H2C rack mapping had both of its hardware-derived
values wrong, and the reporter's follow-up A/B on real hardware settled
them.

The rack does not feed extruder 0. A mixed-nozzle plate extracted as
slots=[0, -1, -1, 1] with rack position 17 dispatched as
[17, -1, -1, 1], and the rack nozzle printed several millimetres above
the bed. The rack is extruder 1.

Correcting only that is not enough. The fixed hotend answers to physical
ID 1, not to its extruder index of 0, and forwarding the index produced
[0, -1, -1, 17] -- a command the printer rejected outright rather than
mis-printing. Translating both carriages gives [1, -1, -1, 17], and the
same sliced file then cleaned, levelled and printed on the correct
nozzle at the correct Z through to completion.

Both values agree with three native Bambu Studio captures from the same
machine, which carry [1, 17, ...] and [17, 1, ...] depending on filament
slot order.

An extruder index naming neither carriage now omits the field instead of
reaching the wire as a physical ID that identifies no nozzle. The
fixed-hotend-only path is unchanged but no longer a guess: Bambu Studio
sends no nozzle_mapping at all for such a plate, which is what Bambuddy
already did.

Reported, diagnosed and hardware-verified by @tru3l3gend, who ran the
mixed-nozzle A/B on both nozzles and captured what Bambu Studio sends
for fixed-only and mixed plates.
maziggy 3 viikkoa sitten
vanhempi
sitoutus
e9d9e51a81

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


+ 36 - 20
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
 
 

+ 6 - 3
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(

+ 62 - 33
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'<filament id="{slot}" group_id="{group}"/>' 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"

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä