diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 2350a754f..e52622829 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -286,8 +286,11 @@ def apply_tray_exist_bits( # 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. +# 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. _RACK_WIRE_SLOTS = 32 # The two carriages, as extruder indices in the form the queue stores (already @@ -317,7 +320,8 @@ def resolve_rack_nozzle_mapping( plate does not print. ``rack_nozzle_id`` is the rack position the printer reports as live. - Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None + Returns one physical nozzle ID per slot given, matching BambuStudio's own + dispatch length, 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 @@ -367,7 +371,7 @@ def resolve_rack_nozzle_mapping( if _RACK_EXTRUDER_ID not in normalised: return None - wire = [-1] * _RACK_WIRE_SLOTS + wire = [-1] * len(normalised) for index, extruder in enumerate(normalised): if extruder < 0: continue diff --git a/backend/app/services/filament_requirements.py b/backend/app/services/filament_requirements.py index 193fc1894..97d00462f 100644 --- a/backend/app/services/filament_requirements.py +++ b/backend/app/services/filament_requirements.py @@ -91,7 +91,11 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None) # Dual-nozzle printers (H2D / X2D) — annotate which extruder each # slot is fed into. Empty mapping for single-nozzle printers, in # which case we just don't add the key. - nozzle_mapping = extract_nozzle_mapping_from_3mf(zf) + # Same plate the filaments above were collected from: a multi-plate + # file can assign one slot to different extruders per plate, and + # annotating slot 2 with plate 3's nozzle is worse than not + # annotating it. + nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id) if nozzle_mapping: for filament in filaments: filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"]) diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 0764fdefe..c11113dfe 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -5761,7 +5761,7 @@ class PrintScheduler: # thrown away on every dispatch. nozzle_slot_extruders = None if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model): - slot_extruders = extract_slot_extruders_from_3mf(file_path) + slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1) if slot_extruders: nozzle_slot_extruders = json.dumps(slot_extruders) diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 937d2c5be..2265a9445 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -16,6 +16,10 @@ from dataclasses import dataclass, field from pathlib import Path from threading import Lock +# Parsing goes through defusedxml; the element type it hands back is the stdlib +# one, and defusedxml does not re-export it, so annotations name it directly. +from xml.etree.ElementTree import Element as XmlElement + import defusedxml.ElementTree as ET logger = logging.getLogger(__name__) @@ -330,7 +334,7 @@ def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | No _MAX_DENSE_FILAMENT_SLOTS = 64 -def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: +def extract_slot_extruders_from_3mf(file_path: Path, plate_id: int | None = None) -> list[int] | None: """Per-slot extruder assignment as a dense list, or None (#2800). Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the @@ -340,6 +344,11 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: picking a nozzle themselves, which can level with one hotend and print with another, several millimetres off the bed. + ``plate_id`` scopes the answer to the plate actually being dispatched. A + multi-plate 3MF carries one filament list per plate and they need not + agree, so without it a slot can take its extruder from a plate this print + is not going to run. + Takes a path rather than an open archive because the dispatcher is handling the file, not the zip, and a broken file there must not take the print down: an unreadable or non-3MF path returns None, and the caller @@ -347,7 +356,7 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: """ try: with zipfile.ZipFile(file_path) as zf: - by_slot = extract_nozzle_mapping_from_3mf(zf) + by_slot = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id) except (zipfile.BadZipFile, OSError) as exc: logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc) return None @@ -371,7 +380,63 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None: return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)] -def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None: +def _plates_in_scope(si_root: XmlElement, plate_id: int | None) -> list[XmlElement]: + """The ```` elements a lookup should read, narrowed to one if asked. + + A 3MF holds every plate in the project, each with its own filament list, and + two plates may assign the same slot to different extruders. Falls back to + every plate when no id is given or none matches, which is what this module + did before plates were distinguished at all. + """ + plates = si_root.findall(".//plate") + if not plates: + return [si_root] + if plate_id is None: + return plates + for plate in plates: + for metadata in plate.findall("metadata"): + if metadata.get("key") != "index": + continue + try: + if int(metadata.get("value") or "") == plate_id: + return [plate] + except (TypeError, ValueError): + pass + return plates + + +def _group_extruder_indices(plates: list[XmlElement]) -> dict[int, int] | None: + """Map each filament group to the slicer extruder index it prints on. + + ``slice_info.config`` states this directly, as ````. Reading it matters on nozzle-rack + printers, where the group id is *not* an extruder index: the H2C's rack + carriage can host six hotends (``extruder_max_nozzle_count`` is ``['1', + '6']``), so the slicer emits more groups than the machine has extruders and + several groups share one carriage. A plate of the reporter's carried groups + 0, 1 and 2 against a two-entry ``physical_extruder_map``. + + Returns None when the file states no table, or when two plates in scope + disagree about a group — in which case the caller keeps treating the group + id as the extruder index, which is what every H2D file in practice wants + and what this module has always done. + """ + table: dict[int, int] = {} + for plate in plates: + for nozzle in plate.findall(".//nozzle"): + try: + group_id = int(nozzle.get("id") or "") + extruder_index = int(nozzle.get("extruder_id") or "") - 1 + except (TypeError, ValueError): + return None + if extruder_index < 0: + return None + if table.setdefault(group_id, extruder_index) != extruder_index: + return None + return table or None + + +def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile, plate_id: int | None = None) -> dict[int, int] | None: """Extract per-slot nozzle/extruder mapping from a 3MF file. On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a @@ -380,17 +445,27 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non attributes, not from the user's filament_nozzle_map preference. Priority: - 1. group_id on elements in slice_info.config (actual assignment) + 1. group_id on elements in slice_info.config (actual assignment), + resolved through the file's own group-to-extruder table 2. filament_nozzle_map in project_settings.config (user preference fallback) Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left). + Returns None rather than a partial answer whenever a filament the plate + prints cannot be placed. The gap does not stay a gap downstream: the dense + form fills it with -1, which already means "slot not printed", and + dispatching that against an ams_mapping that *does* name a tray for the slot + is a contradiction the firmware rejects outright with HMS 0500-4047, "the + available hotend quantity or model does not match the sliced file". Giving + no mapping at all costs only the firmware's own nozzle pick. + Args: zf: An open ZipFile of the 3MF archive + plate_id: 1-based plate to read, or None for every plate in the file Returns: Dictionary mapping {slot_id: extruder_id} for dual-nozzle files, - or None if single-nozzle, missing data, or parse error. + or None if single-nozzle, missing data, unplaceable, or parse error. """ try: if "Metadata/project_settings.config" not in zf.namelist(): @@ -417,12 +492,17 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non # Parse slice_info once: needed by both the single-active shortcut # (to verify the slice is actually single-group, #1825) and Priority 1. - si_root: ET.Element | None = None + si_root: XmlElement | None = None + filament_elems: list[XmlElement] = [] + group_extruders: dict[int, int] | None = None distinct_group_ids: set[int] = set() if "Metadata/slice_info.config" in zf.namelist(): si_content = zf.read("Metadata/slice_info.config").decode() si_root = ET.fromstring(si_content) - for filament_elem in si_root.findall(".//filament"): + plates = _plates_in_scope(si_root, plate_id) + group_extruders = _group_extruder_indices(plates) + filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")] + for filament_elem in filament_elems: gid = filament_elem.get("group_id") if gid is not None: try: @@ -440,29 +520,61 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non nozzle_mapping: dict[int, int] = {} active_idx = active_extruders.index(1) target_extruder = int(physical_extruder_map[active_idx]) - if si_root is not None: - for filament_elem in si_root.findall(".//filament"): - try: - nozzle_mapping[int(filament_elem.get("id"))] = target_extruder - except (ValueError, TypeError): - pass + for filament_elem in filament_elems: + try: + nozzle_mapping[int(filament_elem.get("id"))] = target_extruder + except (ValueError, TypeError): + pass return nozzle_mapping or None # Priority 1: Use group_id from slice_info filament elements. # This reflects the actual slicer assignment (respects "Auto For Flush"). nozzle_mapping: dict[int, int] = {} - if si_root is not None: - for filament_elem in si_root.findall(".//filament"): - group_id_str = filament_elem.get("group_id") - filament_id_str = filament_elem.get("id") - if group_id_str is not None and filament_id_str: - try: - group_id = int(group_id_str) - slot_id = int(filament_id_str) - if group_id < len(physical_extruder_map): - nozzle_mapping[slot_id] = int(physical_extruder_map[group_id]) - except (ValueError, TypeError, IndexError): - pass + ungrouped = 0 + for filament_elem in filament_elems: + group_id_str = filament_elem.get("group_id") + filament_id_str = filament_elem.get("id") + if not filament_id_str: + continue + if group_id_str is None: + # Counted rather than returned on: a file where *no* filament + # carries a group falls through to Priority 2 as it always has. + # Only a file that groups some and not others is unplaceable. + ungrouped += 1 + continue + try: + group_id = int(group_id_str) + slot_id = int(filament_id_str) + except (ValueError, TypeError): + logger.warning( + "Ignoring nozzle mapping: unreadable filament id=%r group_id=%r", + filament_id_str, + group_id_str, + ) + return None + # The group id is an extruder index only where the file states no + # table of its own — true of every H2D slice, not of an H2C one. + extruder_index = group_id if group_extruders is None else group_extruders.get(group_id) + if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map): + logger.warning( + "Ignoring nozzle mapping: filament slot %s is in group %s, which " + "resolves to extruder %r outside physical_extruder_map %r", + slot_id, + group_id, + extruder_index, + physical_extruder_map, + ) + return None + nozzle_mapping[slot_id] = int(physical_extruder_map[extruder_index]) + + if nozzle_mapping and ungrouped: + logger.warning( + "Ignoring nozzle mapping: %d filament(s) carry no group_id while %d do, " + "so the ungrouped slots would dispatch as unprinted", + ungrouped, + len(nozzle_mapping), + ) + return None if nozzle_mapping: return nozzle_mapping diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py index f1ebe0d5c..9b5ba6ea5 100644 --- a/backend/tests/unit/test_nozzle_rack_mapping_2800.py +++ b/backend/tests/unit/test_nozzle_rack_mapping_2800.py @@ -38,10 +38,18 @@ 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 is not None - assert len(mapping) == _RACK_WIRE_SLOTS - assert mapping[0] == 17 - assert set(mapping[1:]) == {-1} + 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. + """ + 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 def test_the_fixed_hotend_takes_its_own_physical_id(self): """Both carriages are translated; neither extruder index reaches the wire. @@ -118,8 +126,7 @@ class TestResolveRackNozzleMapping: [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[:4] == [1, -1, -1, 17] - assert set(wire[4:]) == {-1} + assert wire == [1, -1, -1, 17] swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17) assert swapped[:2] == [17, 1] @@ -299,3 +306,145 @@ class TestSlotExtrudersFromFile: """ source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1}) assert extract_slot_extruders_from_3mf(source) is None + + +def _write_h2c_3mf(path, plates): + """3MF in the shape BambuStudio writes for a nozzle-rack machine. + + ``plates`` is a list of ``(index, {slot: group}, {group: 1-based extruder})``. + The per-plate ```` elements are the group-to-extruder table; the + fixture above deliberately omits them, because H2D files carry group ids + that already are extruder indices and both shapes have to keep working. + """ + body = "" + for index, filaments, nozzles in plates: + elems = "".join(f'' for slot, group in filaments.items()) + elems += "".join(f'' for group, ext in nozzles.items()) + body += f'{elems}' + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "Metadata/project_settings.config", + json.dumps( + { + "physical_extruder_map": [1, 0], + # One nozzle on the fixed carriage, four racked, as the + # reporter's H2C reports itself. + "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"], + } + ), + ) + zf.writestr("Metadata/slice_info.config", f"{body}") + return path + + +class TestGroupsBeyondTheExtruderCount: + """A rack carriage hosts six hotends, so groups outnumber extruders. + + The H2C's `extruder_max_nozzle_count` is ['1', '6'], and the slicer emits + one filament group per nozzle it wants rather than one per carriage. Group + ids therefore run past the end of `physical_extruder_map`, which has an + entry per *extruder*. Treating the group id as an index into it silently + dropped those filaments, and a dropped filament densifies to -1 -- the same + 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. + """ + 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] + + def test_a_group_the_file_never_places_omits_the_whole_mapping(self, tmp_path): + """Refusing beats answering for the slots that did resolve. + + A partial mapping is not a smaller answer, it is a wrong one: the gap + reaches the printer as -1, contradicting the ams_mapping entry for the + same slot. Omitting costs only the firmware's own nozzle pick. + """ + source = _write_h2c_3mf( + tmp_path / "orphan.3mf", + [(1, {1: 5, 2: 0}, {0: 1, 1: 2})], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) is None + + def test_a_slot_the_plate_genuinely_skips_still_reads_minus_one(self, tmp_path): + """-1 keeps its meaning where it is earned rather than inferred.""" + source = _write_h2c_3mf( + tmp_path / "gap.3mf", + [(1, {1: 0, 3: 1}, {0: 1, 1: 2})], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, -1, 0] + + def test_filaments_grouped_and_ungrouped_in_one_plate_omit_the_mapping(self, tmp_path): + """Half an answer has the same failure mode as a dropped group.""" + path = tmp_path / "mixed.3mf" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "Metadata/project_settings.config", + json.dumps( + { + "physical_extruder_map": [1, 0], + "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"], + } + ), + ) + zf.writestr( + "Metadata/slice_info.config", + '' + '' + '', + ) + assert extract_slot_extruders_from_3mf(path, plate_id=1) is None + + def test_files_without_a_nozzle_table_keep_the_group_as_the_index(self, tmp_path): + """Every H2D slice in the wild carries groups 0 and 1 and no table.""" + source = _write_dual_nozzle_3mf(tmp_path / "h2d.3mf", {1: 0, 2: 1}) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0] + + +class TestPlateScoping: + """A 3MF holds every plate in the project, not just the one being printed.""" + + def test_each_plate_answers_for_itself(self, tmp_path): + source = _write_h2c_3mf( + tmp_path / "two.3mf", + [ + (1, {1: 0, 2: 1}, {0: 1, 1: 2}), + (2, {1: 1, 2: 0}, {0: 1, 1: 2}), + ], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0] + assert extract_slot_extruders_from_3mf(source, plate_id=2) == [0, 1] + + def test_an_unknown_plate_falls_back_to_the_whole_file(self, tmp_path): + """Not every file indexes its plates; this must not become a hard stop.""" + source = _write_h2c_3mf(tmp_path / "one.3mf", [(1, {1: 0, 2: 1}, {0: 1, 1: 2})]) + assert extract_slot_extruders_from_3mf(source, plate_id=7) == [1, 0] + + def test_plates_that_disagree_about_a_group_fall_back_to_the_index(self, tmp_path): + """Reading every plate at once can only be done on the old terms. + + With no plate asked for, two plates naming different extruders for one + group leave no table worth trusting, so the group id is read as the + extruder index exactly as it was before the table existed. + """ + source = _write_h2c_3mf( + tmp_path / "conflict.3mf", + [ + (1, {1: 0}, {0: 1}), + (2, {2: 0}, {0: 2}), + ], + ) + assert extract_slot_extruders_from_3mf(source, plate_id=None) == [1, 1]