Преглед изворни кода

Print an H2C two-nozzle plate from the carriage it was levelled on

The two carriages were the wrong way round: extruder index 0 was treated as
the fixed hotend and index 1 as the swappable rack, and it is the other way
about. A plate using both was levelled with one nozzle and printed with the
other, several millimetres off the plate.

Three sources agree, and disagreed with the code. Telemetry reports
ams_extruder_map {'0': 1, '1': 0, '2': 0}. BambuStudio, dispatching a plate
that used all three of those AMS units, sent the filament from the unit on
extruder 1 to physical nozzle 1 and the ones on extruder 0 to rack positions
16 and 18, and that print completed. And the two constants could not both
have been right: _FIXED_NOZZLE_ID is 1 while the fixed extruder was 0, in a
scheme where physical nozzle id N sits on extruder N.

The old value came from the #2800 hardware A/B, where [17, -1, -1, 1] printed
in mid-air and [1, -1, -1, 17] printed correctly. That result stands -- it
established which wire worked. The extruder indices were not measured by it;
they were inferred by pairing the working wire with a slot_extruders list
produced by the 3MF reader that has since turned out to mis-read exactly
these files. The reasoning is recorded at the constants so a future
regression report is not re-litigated from scratch.

Also withholds nozzle_mapping entirely when more than one filament group
needs the rack. Two groups on one extruder means that extruder is a rack and
the plate wants a different hotend per group -- which physical slot each
takes is the slicer's choice against the live rack and is stated nowhere in
the file, since both groups can carry identical diameter and nozzle type.
Studio dispatched such a plate to 16 and 18; nothing here can reproduce that,
and answering anyway is what printed in mid-air, so the firmware picks.

Restores the fixed 32-entry padding that dfeac792f replaced with the plate's
slot count. That was derived from a single 3-entry capture which turned out
to be a calibration job; Studio's dispatch of a real project print on the
same machine is 32 entries.

Both constants are read in one function, on the nozzle-rack path, so no other
model is affected. Verified on hardware: the plate that printed in mid-air
now prints.
maziggy пре 3 недеља
родитељ
комит
45dc139c41

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 35 - 14
backend/app/services/bambu_mqtt.py

@@ -286,20 +286,42 @@ def apply_tray_exist_bits(
 # doing exactly that is what #2800 was.
 _RACK_NOZZLE_IDS = frozenset(range(16, 22))
 
-# Ceiling on the nozzle_mapping we will build, not the length we send. The wire
-# carries one physical nozzle ID per filament slot the plate declares, and -1
-# for a slot it does not print: BambuStudio's own dispatch of a three-filament
-# H2C plate is [1, 16, 16], and the hardware A/B in #2800 ran four-slot plates
-# as four entries. Padding to a fixed 32 was an over-generalisation of that.
+# 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.
+#
+# Briefly changed to the plate's own slot count on the strength of a single
+# 3-entry capture, then changed back: Studio's dispatch of a real 3-filament
+# project print on the maintainer's H2C is 32 entries ([16, 1, 18, -1 x29],
+# captured 2026-08-13 17:20, and that print completed). The 3-entry capture was
+# a calibration job, so the length varies with whatever Studio is doing rather
+# than with the filament count -- which makes it the wrong thing to derive.
 _RACK_WIRE_SLOTS = 32
 
 # 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
+# translated through the file's physical_extruder_map).
+#
+# Measured on the maintainer's H2C 2026-08-14, from three sources that agree:
+#
+#   - telemetry: ``ams_extruder_map {'0': 1, '1': 0, '2': 0}`` -- AMS 0 feeds
+#     extruder 1, AMS 1 and 2 feed extruder 0;
+#   - BambuStudio's own dispatch of a plate using all three units sent AMS 0's
+#     filament to physical nozzle 1 and AMS 1's to rack positions 16 and 18,
+#     and that print completed. So extruder 1 is the fixed hotend and extruder
+#     0 is the rack;
+#   - our own constants were internally inconsistent about it: physical nozzle
+#     id N sits on extruder N (see the L/R split in PrintersPage), and
+#     ``_FIXED_NOZZLE_ID`` is 1, which cannot be reconciled with a fixed
+#     extruder index of 0.
+#
+# These were the other way round until then, which is what dispatched a plate
+# to the carriage that had not been levelled and printed its first layer in
+# mid-air. That value came from #2800, where dispatching [17, -1, -1, 1] printed
+# in mid-air and [1, -1, -1, 17] printed correctly -- but that A/B measured
+# which *wire* worked, and the extruder indices were only inferred from it by
+# pairing with a slot_extruders list the then-buggy 3MF reader had produced. The
+# wire result stands; the inference from it did not.
+_FIXED_EXTRUDER_ID = 1
+_RACK_EXTRUDER_ID = 0
 
 # 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
@@ -320,8 +342,7 @@ def resolve_rack_nozzle_mapping(
     plate does not print. ``rack_nozzle_id`` is the rack position the printer
     reports as live.
 
-    Returns one physical nozzle ID per slot given, matching BambuStudio's own
-    dispatch length, or None
+    Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None
     when the mapping cannot be resolved with confidence -- in which case the
     caller omits the field entirely and the firmware falls back to its own
     nozzle pick, exactly as it did before this translation existed. Omitting
@@ -371,7 +392,7 @@ def resolve_rack_nozzle_mapping(
     if _RACK_EXTRUDER_ID not in normalised:
         return None
 
-    wire = [-1] * len(normalised)
+    wire = [-1] * _RACK_WIRE_SLOTS
     for index, extruder in enumerate(normalised):
         if extruder < 0:
             continue

+ 20 - 0
backend/app/utils/threemf_tools.py

@@ -510,6 +510,26 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile, plate_id: int | None =
                     except (ValueError, TypeError):
                         pass
 
+            # Two groups on one extruder means that extruder is a nozzle rack,
+            # and the plate wants a *different* hotend from it per group. Which
+            # physical rack slot each group takes is the slicer's own choice
+            # against the rack's live contents and is stated nowhere in the
+            # file -- on the plate that prompted this, both rack groups carry
+            # identical nozzle_diameter and volume_type, and BambuStudio still
+            # dispatched them to positions 16 and 18 (captured 2026-08-13
+            # 17:20; that print completed). Nothing here can reproduce that
+            # choice, and answering anyway is what printed in mid-air, so the
+            # whole mapping is withheld and the firmware picks for itself.
+            if group_extruders and len(set(group_extruders.values())) < len(group_extruders):
+                logger.warning(
+                    "Ignoring nozzle mapping: groups %s share extruders %s, so the plate "
+                    "needs more than one nozzle from a rack and the physical positions "
+                    "are not derivable from the file",
+                    sorted(group_extruders),
+                    sorted(set(group_extruders.values())),
+                )
+                return None
+
         # Single-active shortcut: only safe when the slice actually uses one
         # group. extruder_nozzle_stats can under-report a second installed
         # nozzle when its volume-type differs from the profile's enumerated

+ 78 - 49
backend/tests/unit/test_nozzle_rack_mapping_2800.py

@@ -37,31 +37,36 @@ class TestIsNozzleRackModel:
 
 class TestResolveRackNozzleMapping:
     def test_rack_slot_takes_the_live_rack_position(self):
-        mapping = resolve_rack_nozzle_mapping([1], rack_nozzle_id=17)
-        assert mapping == [17]
-
-    def test_the_wire_is_as_long_as_the_plate_has_slots(self):
-        """One entry per filament slot, not a fixed-length padded array.
-
-        BambuStudio's own dispatch of a three-filament H2C plate is
-        [1, 16, 16] -- three entries, captured from the maintainer's machine.
-        The earlier fixed 32-length padding was a generalisation from nothing.
+        # Extruder index 0 is the rack carriage: measured 2026-08-14 from
+        # ams_extruder_map plus a BambuStudio dispatch that completed.
+        mapping = resolve_rack_nozzle_mapping([0], 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_the_wire_is_padded_to_a_fixed_length(self):
+        """Studio's own dispatch is 32 entries whatever the plate's slot count.
+
+        This was briefly changed to the plate's slot count on the strength of
+        one 3-entry capture, which turned out to be a calibration job; the real
+        project print on the same machine dispatched 32 ([16, 1, 18, -1 x29])
+        and completed. The length is Studio's business, not the file's.
         """
-        assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) == [17]
-        assert resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16) == [1, 16, 1]
-        assert len(resolve_rack_nozzle_mapping([1, 0, 0, -1], rack_nozzle_id=16)) == 4
+        for slots in ([0], [1, 0], [0, 1, 1, -1]):
+            assert len(resolve_rack_nozzle_mapping(slots, rack_nozzle_id=16)) == _RACK_WIRE_SLOTS
 
     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.
+        Sending an extruder index for the fixed side 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)
+        mapping = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=21)
         assert mapping[:2] == [1, 21]
 
     def test_unprinted_slots_stay_unset(self):
-        mapping = resolve_rack_nozzle_mapping([1, -1, 1], rack_nozzle_id=16)
+        mapping = resolve_rack_nozzle_mapping([0, -1, 0], rack_nozzle_id=16)
         assert mapping[:3] == [16, -1, 16]
 
     @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255])
@@ -71,7 +76,7 @@ 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([1], rack_nozzle_id=rack_id) is None
+        assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=rack_id) is None
 
     def test_job_that_never_uses_the_rack_is_left_alone(self):
         """BambuStudio omits nozzle_mapping for a fixed-hotend-only plate.
@@ -80,7 +85,7 @@ class TestResolveRackNozzleMapping:
         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
+        assert resolve_rack_nozzle_mapping([1, 1], 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):
@@ -89,16 +94,16 @@ class TestResolveRackNozzleMapping:
         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
+        assert resolve_rack_nozzle_mapping([unknown, 0], rack_nozzle_id=17) is None
 
     @pytest.mark.parametrize(
         "bad_slots",
         [
-            ["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`
+            ["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`
             "1",  # not a list at all
         ],
     )
@@ -111,10 +116,10 @@ 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([1], rack_nozzle_id=bad_rack) is None
+        assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=bad_rack) is None
 
     def test_none_entries_read_as_unprinted(self):
-        assert resolve_rack_nozzle_mapping([None, 1], rack_nozzle_id=17)[:2] == [-1, 17]
+        assert resolve_rack_nozzle_mapping([None, 0], rack_nozzle_id=17)[:2] == [-1, 17]
 
     def test_hardware_confirmed_mixed_nozzle_plate(self):
         """The exact job the reporter ran on an H2C, both ways round.
@@ -125,10 +130,11 @@ class TestResolveRackNozzleMapping:
         BambuStudio captures of mixed plates on the same machine carry
         [1, 17, ...] and [17, 1, ...] depending on filament slot order.
         """
-        wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17)
-        assert wire == [1, -1, -1, 17]
+        wire = resolve_rack_nozzle_mapping([1, -1, -1, 0], 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)
+        swapped = resolve_rack_nozzle_mapping([0, 1], rack_nozzle_id=17)
         assert swapped[:2] == [17, 1]
 
     def test_more_slots_than_the_wire_carries(self):
@@ -192,7 +198,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([1, -1, 1]))
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, -1, 0]))
         cmd = self._print_cmd(client)
         assert cmd["nozzle_mapping"][:3] == [18, -1, 18]
 
@@ -201,7 +207,7 @@ 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([1]))
+        client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0]))
         assert self._print_cmd(client)["nozzle_mapping"][0] == 20
 
     def test_unknown_rack_position_omits_the_field(self):
@@ -247,8 +253,8 @@ def _write_dual_nozzle_3mf(path, group_by_slot):
 
     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.
+    the fixed hotend and index 0 the rack carriage — measured 2026-08-14, which
+    is why the rack-side fixtures below slice their filaments into group 1.
     """
     filaments = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in group_by_slot.items())
     with zipfile.ZipFile(path, "w") as zf:
@@ -272,9 +278,13 @@ class TestSlotExtrudersFromFile:
         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: 0, 3: 0})
-        assert extract_slot_extruders_from_3mf(source) == [1, -1, 1]
+        """The reported failure: a two-slot job that must print from the rack.
+
+        `physical_extruder_map` is [1, 0], so it is the file's group 1 that
+        lands on extruder 0 -- the rack carriage.
+        """
+        source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1})
+        assert extract_slot_extruders_from_3mf(source) == [0, -1, 0]
         wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17)
         assert wire[:3] == [17, -1, 17]
 
@@ -283,7 +293,7 @@ class TestSlotExtrudersFromFile:
         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]
+        assert wire[:2] == [1, 17]
 
     def test_single_nozzle_file_yields_nothing(self, tmp_path):
         path = tmp_path / "single.3mf"
@@ -348,23 +358,42 @@ class TestGroupsBeyondTheExtruderCount:
     value that means "this plate does not print the slot".
     """
 
-    def test_the_dropped_filament_from_the_hms_0500_4047_report(self, tmp_path):
-        """The maintainer's own plate, first print on a new H2C.
-
-        Three filaments in groups 2, 0 and 1 against a two-entry
-        physical_extruder_map. Slot 1 fell out of the mapping and dispatched as
-        [-1, 16, 1, ...] while ams_mapping named tray 6 for that same slot; the
-        printer stopped with "the available hotend quantity or model does not
-        match the sliced file". The file's own nozzle table says group 2 prints
-        on extruder 2, the rack side, same as group 1.
+    def test_a_plate_wanting_two_rack_nozzles_is_left_to_the_firmware(self, tmp_path):
+        """The maintainer's own plate, first prints on a new H2C.
+
+        Three filaments in groups 2, 0 and 1, where the file's nozzle table
+        puts groups 1 AND 2 on extruder 2. Two groups on one extruder is a
+        rack: the plate wants a different hotend from it per group, which is
+        the whole point of the six-nozzle carriage. Which physical slot each
+        group takes is the slicer's choice against the live rack -- both rack
+        groups here carry identical nozzle_diameter and volume_type, and
+        BambuStudio still dispatched them to 16 and 18 (captured 17:20 on
+        2026-08-13; that print completed).
+
+        Nothing derivable from the file reproduces that, and the two attempts
+        that answered anyway both failed on hardware: dropping the unplaceable
+        filament dispatched [-1, 16, 1, ...] and the printer refused to start
+        (HMS 0500-4047), and placing it dispatched [1, 16, 1] which printed in
+        mid-air. So the mapping is withheld entirely.
         """
         source = _write_h2c_3mf(
             tmp_path / "benchy.3mf",
             [(1, {1: 2, 2: 0, 3: 1}, {0: 1, 1: 2, 2: 2})],
         )
-        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [0, 1, 0]
-        wire = resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16)
-        assert wire == [1, 16, 1]
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) is None
+
+    def test_one_rack_group_is_still_answered(self, tmp_path):
+        """The refusal is about naming *several* rack positions, not the rack.
+
+        A plate with one group per carriage needs only the position the printer
+        reports as live, which is knowable -- that is the #2800 case and it
+        must keep working.
+        """
+        source = _write_h2c_3mf(
+            tmp_path / "one-each.3mf",
+            [(1, {1: 0, 2: 1}, {0: 1, 1: 2})],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0]
 
     def test_a_group_the_file_never_places_omits_the_whole_mapping(self, tmp_path):
         """Refusing beats answering for the slots that did resolve.

Неке датотеке нису приказане због велике количине промена