diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7d948b3..3ddd67edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ All notable changes to Bambuddy will be documented in this file. ### Fixed - **A print stage Bambuddy has no name for now says so in the log** — The printer reports its current activity as a number, and Bambuddy keeps a table of what those numbers mean. The table is hand-maintained and every new model adds to it, so a printer occasionally reports one that is not in it and the card reads "Unknown stage (72)" — which happened on an H2C, where the table runs to 66 and then jumps to 74. Stage changes were logged, but at debug level, which is off in normal running: the only record that it had happened at all was the card, and by the time anyone looked the printer had moved on. An unnamed stage is now recorded at the normal log level, once per stage number, along with the model, the stage it came from and what the printer was doing at the time — which is what identifying it afterwards actually needs. Stages that do have names stay at debug as before, so a normal print logs nothing new. Fixing this turned up a latent crash beside it: the stage-change log line builds its text before the log level is consulted, so a printer reporting a stage that was not a number at all — malformed telemetry rather than an unknown stage — would abort processing of that update entirely. Labelling a value can no longer do that. Covered by backend tests. -- **An H2C refused to start a multi-colour print, reporting that its hotends did not match the sliced file** — The print uploaded, the printer took the command, and then stopped immediately with "the print has stopped because the available hotend quantity or model does not match the sliced file" (HMS 0500-4047). Bambuddy was telling the printer that one of the filaments the plate prints goes to no hotend at all, while the AMS mapping alongside it named the exact tray that filament comes from — a contradiction the firmware will not start a job on. The mistake was in reading the sliced file. Each filament in a 3MF names the *group* it belongs to, and on every other dual-nozzle Bambu that group number happens to also be the extruder it prints on, so Bambuddy read it as one. On a nozzle-rack machine it is not: the rack carriage can hold six hotends against the fixed carriage's one, so the slicer writes one group per nozzle it wants rather than one per carriage, and a three-colour plate can carry groups 0, 1 and 2 on a printer with two extruders. The filament in the group that ran past the end was quietly dropped, and a dropped filament is indistinguishable further down from a slot the plate genuinely does not print. Bambuddy now reads the group-to-extruder table the file states for itself, which is the only place the real answer is written. Where a filament still cannot be placed, no mapping is sent at all rather than a partial one, and the reason is logged: the printer then chooses its own nozzle, which is what it did before any of this existed and is much better than being handed an answer that contradicts itself. Two things were corrected alongside it. The mapping is now read from the plate being printed rather than from every plate in the file at once — a project with several plates can assign the same slot to different extruders on each, and the wrong plate's answer was as likely as the right one. And the mapping sent to the printer is now as long as the plate has filament slots, matching what Bambu Studio itself sends, instead of being padded to a fixed 32 entries. Covered by backend tests, and verified against the file that failed. +- **An H2C sent two-nozzle prints to the wrong carriage, laying the first layer down in mid-air** — Bambuddy had the printer's two carriages the wrong way round: it treated extruder index 0 as the fixed hotend and index 1 as the swappable rack, and they are the other way about. A print that used both would be levelled with one nozzle and then printed with the other, several millimetres off the plate. Three things now say so and agree. The printer's own telemetry reports which AMS unit feeds which extruder; Bambu Studio, dispatching a plate that used all three of those units, sent the filament from the unit on extruder 1 to the fixed hotend and the ones on extruder 0 to the rack, and that print completed; and Bambuddy's own two constants could not both have been right, since it recorded the fixed hotend's nozzle as number 1 while placing it on extruder 0, in a scheme where nozzle number and extruder index are the same thing everywhere else. The previous value came from #2800, where one dispatch printed in mid-air and the other printed correctly — that result stands, but it established which *mapping* worked, and the extruder indexes were then read back out of it using the 3MF reader that has since turned out to mis-read exactly these files. **If you have an H2C and a two-nozzle print came out in mid-air, this is why.** Nothing changes for any other printer: the two values are read in one place, on the nozzle-rack path, which no other model reaches. Covered by backend tests. +- **An H2C refused to start a multi-colour print, reporting that its hotends did not match the sliced file** — The print uploaded, the printer took the command, and then stopped immediately with "the print has stopped because the available hotend quantity or model does not match the sliced file" (HMS 0500-4047). Bambuddy was telling the printer that one of the filaments the plate prints goes to no hotend at all, while the AMS mapping alongside it named the exact tray that filament comes from — a contradiction the firmware will not start a job on. The mistake was in reading the sliced file. Each filament in a 3MF names the *group* it belongs to, and on every other dual-nozzle Bambu that group number happens to also be the extruder it prints on, so Bambuddy read it as one. On a nozzle-rack machine it is not: the rack carriage can hold six hotends against the fixed carriage's one, so the slicer writes one group per nozzle it wants rather than one per carriage, and a three-colour plate can carry groups 0, 1 and 2 on a printer with two extruders. The filament in the group that ran past the end was quietly dropped, and a dropped filament is indistinguishable further down from a slot the plate genuinely does not print. Bambuddy now reads the group-to-extruder table the file states for itself, which is the only place the real answer is written. Where a filament still cannot be placed, no mapping is sent at all rather than a partial one, and the reason is logged: the printer then chooses its own nozzle, which is what it did before any of this existed and is much better than being handed an answer that contradicts itself. Reading the file correctly turned out not to be enough on its own, because a rack plate can want *several* different hotends from the rack rather than one. On the plate that prompted this, Bambu Studio sends two of the three filaments to rack positions 16 and 18 — and the two are indistinguishable in the file, same diameter and same nozzle type, so which physical slot each one takes is the slicer's own choice against what is currently in the rack and is written down nowhere. Bambuddy cannot reproduce that choice, and answering anyway laid the first layer down in mid-air. A plate that needs more than one nozzle from the rack now gets no mapping at all, with the reason logged, and the printer chooses for itself; the same plate still prints correctly when sent from Bambu Studio, so nothing is lost that was ever working. Alongside, the mapping is now read from the plate being printed rather than from every plate in the file at once — a project with several plates can assign the same slot to different extruders on each, and the wrong plate's answer was as likely as the right one. Covered by backend tests, and verified against the files that failed. - **Interchangeable filaments were reported as the wrong material, and the pre-flight check disagreed with what would actually print** — Bambu's firmware treats PA-CF, PA12-CF and PAHT-CF as the same material, and the scheduler has always matched them accordingly, so a PA12-CF spool would happily print a job asking for PA-CF. The interface compared the raw type names instead, so it labelled that same pairing a type mismatch — the badge contradicted what the printer was about to do, and the manual override picker made it worse by *offering* the spool the badge then rejected. Every place that judges whether a spool suits a requirement now reads one shared table. The pipeline pre-flight check had drifted the other way: it carried its own copy of that table, and the copy had come to disagree in both directions — it treated **PLA Basic** as interchangeable with **PLA** where the matcher never has, so a run could clear the check and then fail to map its slots, and it lacked the nylon grouping, so it flagged runs the matcher handles without complaint. It now answers with the matcher's rules, which is the only answer worth giving: a check that predicts dispatch is wrong whenever it disagrees with dispatch, whichever way it leans. **This makes the pre-flight check stricter in one case** — a printer reporting a product name such as "PLA Basic" where the generic material is expected is now flagged rather than passed. That is the honest answer, and it is rare in practice, because the printer reports the material and the product name in separate fields. Nothing about which spool a print actually uses has changed. Covered by backend and frontend tests. - **A process preset that turns supports on had them switched back off by the file being sliced (#2820, reported by @zevulos)** — The server slicer is handed the picked process preset as the authoritative settings for the slice, so anything the preset does not name comes from the file's own embedded settings. Since #1881 four support fields travel the other way as well — supports on/off, the support and interface filament slots, and tree versus normal — because Bambu's shipped process presets all set supports off (supports are a decision per print, not per quality level), and without carrying them a project exported with supports configured came out of the slicer as a single-material print with a PVA slot loaded and never used. That carry ran in both directions, which is wrong in the direction nobody asked for: a file that ships with supports off, which is very nearly every published model, stripped supports back out of a preset that deliberately turned them on. The reporter's own preset turns supports on with normal(auto) and snug, and the slice came back with supports disabled and set to tree(auto) — the only setting of the three that survived was the style, and only because it is not one of the four fields carried and they had re-entered it in the slice dialog. The file can now switch supports on but never off. Nothing is lost by that: since every shipped preset has supports off, a preset that has them on is a deliberate choice by whoever wrote it, and a file that wants supports still gets them along with its slot assignments. A file that never states whether it wants supports at all is treated the same as one that says no. The carry is also written to the log now, naming the fields it took, because the slice dialog shows the picked preset's values and a carried field quietly disagrees with what was on screen — with nothing in the log to say so, this bug reads as the preset being ignored, and the log line the report understandably keyed on was an unrelated one about the source file's own settings. - **Printing several copies of a file uploaded straight to a printer left every copy after the first unable to run (#2819, reported by @ooniiik)** — Dropping a file onto a printer card uploads it to the library, prints it, and deletes it again: the file is a courier, not something you asked to keep. Ask for more than one copy, though, and each copy is its own queue entry pointing at that same temporary file — so the first one to dispatch deleted the file the others were still waiting for. What happened next depended on which database Bambuddy is running on, and neither answer was right. On SQLite the remaining copies were left pointing at a file that no longer existed: starting one failed with "Library file not found", and a copy set to start manually simply sat in the queue forever, giving no reason. On PostgreSQL, which enforces the same rule the schema has always described, the remaining copies were deleted outright — the jobs vanished from the queue with no error and no history, which is harder to notice and harder still to explain. On a farm running batches across twenty printers, both leave work that looks queued and is not. The copy that dispatches first now hands the rest the archive it just created, before the file is deleted: they print the same bytes from Print History, and the flag that consumes a library file is cleared on them so they cannot delete anything themselves. A copy already printing from its own archive keeps it, and a finished one keeps its outcome — but both stop referring to the deleted file, because on PostgreSQL that reference is the only thing tying them to the deletion, and the finished rows are what a batch order counts its progress from. Cross-model jobs (#671) that had this file as one of several alternatives keep their other alternatives and simply lose this one; one left with no alternative at all is handed the archive like any other copy. The same fix covers batch orders, which inherit the file from the item they were cloned from, and copies held back by a printer's previous-print gate, which come back the moment that gate is cleared. Reprints from Print History and any file kept in the library are untouched — nothing is deleted there in the first place. Covered by backend tests, and verified row for row against PostgreSQL as well as SQLite. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index a02cce12e..abb09338d 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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 diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 2265a9445..e49f0aa95 100644 --- a/backend/app/utils/threemf_tools.py +++ b/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 diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py index 9b5ba6ea5..ce1bffa88 100644 --- a/backend/tests/unit/test_nozzle_rack_mapping_2800.py +++ b/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] + # 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_as_long_as_the_plate_has_slots(self): - """One entry per filament slot, not a fixed-length padded array. + def test_the_wire_is_padded_to_a_fixed_length(self): + """Studio's own dispatch is 32 entries whatever the plate's slot count. - 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. + 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'' 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. + 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 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. + 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.