diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index cc7c85599..621a11d45 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -274,6 +274,99 @@ def apply_tray_exist_bits( return cleared +# --- 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. +_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 + + +def resolve_rack_nozzle_mapping( + slot_extruders: list[int], + rack_nozzle_id: int | None, +) -> list[int] | None: + """Expand a per-slot extruder mapping into an H2C physical nozzle_mapping. + + ``slot_extruders`` is the compact form stored on the queue item: MQTT + extruder index per filament slot (index 0 = slot 1), -1 for a slot the + 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 + 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 + is deliberately the failure mode: a *wrong* physical ID makes the printer + level with one nozzle and print with another several millimetres off the + bed, which is far worse than letting the firmware choose. + + Returns None specifically when: + + - 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. + - the plate needs more slots than the wire format carries; + - the input is not a list of whole numbers. + + Total by construction: it raises nothing, because the only caller is + building an MQTT print command with no exception handler above it and the + queue item has already been committed as `printing` by then. An + unparseable input has to degrade to "let the firmware pick", not to a job + wedged in a state no print will ever leave. + """ + if not isinstance(slot_extruders, list) or not slot_extruders: + return None + if len(slot_extruders) > _RACK_WIRE_SLOTS: + return None + if not isinstance(rack_nozzle_id, int) or isinstance(rack_nozzle_id, bool): + return None + if rack_nozzle_id not in _RACK_NOZZLE_IDS: + return None + + # Normalise first so the checks below, and the values that reach the wire, + # are known ints. bool is an int subclass and would otherwise serialise as + # a JSON `true`; None means "slot not printed" and is folded into -1. + normalised: list[int] = [] + for extruder in slot_extruders: + if extruder is None: + normalised.append(-1) + elif isinstance(extruder, int) and not isinstance(extruder, bool): + normalised.append(extruder) + else: + return None + + if _RACK_EXTRUDER_ID not in normalised: + return None + + wire = [-1] * _RACK_WIRE_SLOTS + for index, extruder in enumerate(normalised): + if extruder < 0: + continue + wire[index] = rack_nozzle_id if extruder == _RACK_EXTRUDER_ID else extruder + return wire + + @dataclass class MQTTLogEntry: """Log entry for MQTT message debugging.""" @@ -490,6 +583,14 @@ class PrinterState: h2d_extruder_snow: dict = field(default_factory=dict) # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles) nozzle_rack: list = field(default_factory=list) + # H2C rack position currently mounted / being moved to, from + # device.nozzle.src_id / tar_id. These are PHYSICAL nozzle IDs (16-21 for + # the six rack slots), not extruder indices, and they are what the + # dispatch `nozzle_mapping` array has to carry (#2800). Only the printer + # can tell us which hotend is in the carriage right now, so this is read + # live rather than derived from the queued job. + nozzle_rack_src_id: int | None = None + nozzle_rack_tar_id: int | None = None # Timestamp of last AMS data update (for RFID refresh detection) last_ams_update: float = 0.0 # Printable objects for skip object functionality: {identify_id: object_name} @@ -4285,6 +4386,36 @@ class BambuMQTTClient: if "device" in data and isinstance(data["device"], dict): device = data["device"] nozzle_data = device.get("nozzle", {}) + + # H2C rack position (#2800). `tar_id` is where the carriage is + # headed, `src_id` where it came from; mid-swap they differ, so + # dispatch prefers tar_id and falls back to src_id. Both are + # sticky — the field is only pushed when it changes, so an + # absent key must leave the last known value alone rather than + # reset it to None. + if isinstance(nozzle_data, dict): + for key, attr in (("src_id", "nozzle_rack_src_id"), ("tar_id", "nozzle_rack_tar_id")): + if key not in nozzle_data: + continue + try: + parsed_id = int(nozzle_data[key]) + except (TypeError, ValueError): + continue + if getattr(self.state, attr) != parsed_id: + setattr(self.state, attr, parsed_id) + # DEBUG, not INFO: these move on every tool change, so + # a long multi-material print would otherwise write + # thousands of lines. The dispatch log records both + # values once per print, which is where triage needs + # them. Same reasoning as the one-shot `nozzle_info` + # log below. + logger.debug( + "[%s] Nozzle rack %s -> %s", + self.serial_number, + key, + parsed_id, + ) + nozzle_info = nozzle_data.get("info", []) if isinstance(nozzle_info, list): # H2 series: nozzle_info contains extended nozzle data (wear, serial, @@ -4946,6 +5077,7 @@ class BambuMQTTClient: use_ams: bool = True, nozzle_offset_cali: str = "auto", nozzle_mapping: str | None = None, + nozzle_slot_extruders: str | None = None, ): """Start a print job on the printer. @@ -4972,6 +5104,14 @@ class BambuMQTTClient: firmware honours the user's slicer pick instead of falling back to "last matching nozzle" auto-pick. Silently ignored on single-nozzle printers. + nozzle_slot_extruders: Opaque JSON string of per-filament-slot + MQTT extruder indices, derived from the 3MF when no + BambuStudio capture exists (#2800). Consulted only on + nozzle-rack models (H2C) and only when `nozzle_mapping` did + not already supply one; resolved here into physical rack + positions using the live `device.nozzle` state. When it + cannot be resolved the field is omitted and the firmware + picks, as it did before this existed. Returns True when the start command was published, False otherwise (not connected, or the printer is already busy — see the run-state @@ -5017,7 +5157,7 @@ class BambuMQTTClient: # model name for the brief window after connect before push data # arrives. _is_dual_nozzle only ever flips False→True, so it's safe # as the primary signal. - from backend.app.utils.printer_models import is_dual_nozzle_model + from backend.app.utils.printer_models import is_dual_nozzle_model, is_nozzle_rack_model is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model) @@ -5227,6 +5367,52 @@ class BambuMQTTClient: nozzle_mapping, ) + # Nozzle-rack fallback (#2800). Only consulted when BambuStudio + # never saw the job, so it can never override a real capture. The + # queue stores extruder indices per filament slot; the physical + # rack position they resolve to is only knowable here, because the + # mounted hotend can change between queueing and dispatch. + if is_nozzle_rack_model(self.model) and nozzle_slot_extruders and "nozzle_mapping" not in command["print"]: + try: + slot_extruders = json.loads(nozzle_slot_extruders) + except (json.JSONDecodeError, TypeError): + # TypeError covers a caller handing us the list itself + # rather than its JSON — the field is opaque by contract, + # and a print must not die over the difference. + slot_extruders = None + logger.warning( + "[%s] Invalid nozzle_slot_extruders JSON on dispatch, " + "omitting nozzle_mapping (firmware will auto-pick): %r", + self.serial_number, + nozzle_slot_extruders, + ) + + if isinstance(slot_extruders, list): + rack_nozzle_id = ( + self.state.nozzle_rack_tar_id + if self.state.nozzle_rack_tar_id in _RACK_NOZZLE_IDS + else self.state.nozzle_rack_src_id + ) + resolved = resolve_rack_nozzle_mapping(slot_extruders, rack_nozzle_id) + if resolved is None: + logger.info( + "[%s] Nozzle rack slots %s not resolvable (tar_id=%s src_id=%s); " + "omitting nozzle_mapping so the firmware picks", + self.serial_number, + slot_extruders, + self.state.nozzle_rack_tar_id, + self.state.nozzle_rack_src_id, + ) + else: + logger.info( + "[%s] Nozzle rack mapping: slots=%s rack_id=%s -> %s", + self.serial_number, + slot_extruders, + rack_nozzle_id, + resolved, + ) + command["print"]["nozzle_mapping"] = resolved + logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command)) self._client.publish(self.topic_publish, json.dumps(command), qos=1) # Record what we dispatched so /cover can pick the right plate diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 502947d33..fc720d09f 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -55,7 +55,12 @@ from backend.app.services.printer_manager import ( ) from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.utils.filename import derive_remote_filename -from backend.app.utils.printer_models import is_gcode_compatible, normalize_printer_model +from backend.app.utils.printer_models import ( + is_gcode_compatible, + is_nozzle_rack_model, + normalize_printer_model, +) +from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf logger = logging.getLogger(__name__) @@ -4697,11 +4702,34 @@ class PrintScheduler: # FINISH-state fallback — no need to force a video. effective_timelapse = bool(item.timelapse) + # Nozzle-rack fallback (#2800). A job that never passed through the + # Virtual Printer carries no Bambu Studio nozzle pick, and an H2C then + # dispatches with no nozzle field at all and chooses for itself — which + # is how a print levelled on one hotend and then printed on another, + # millimetres above the plate. Derive the per-slot extruder assignment + # from the file being dispatched. + # + # Done here rather than at queue time because this is the first point + # that knows both the actual printer and the actual file: an item can + # be created without a printer (model-based assignment), reassigned + # afterwards, or have its file swapped for a G-code-injected copy just + # above. Every queue-creation path — the print dialog, a bulk library + # add, the webhook, a pipeline run — is covered by the one call. + # Skipped when the item already carries a Bambu Studio capture: that + # one wins downstream anyway, so reading the 3MF again would be work + # 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) + if slot_extruders: + nozzle_slot_extruders = json.dumps(slot_extruders) + # Start the print with AMS mapping, plate_id and print options. # nozzle_mapping rides through verbatim — JSON string captured from # Bambu Studio's project_file on VP intake (#1780); the MQTT layer # parses + injects it only for dual-nozzle models so a null on every - # other model is a transparent pass-through. + # other model is a transparent pass-through. The rack fallback is + # resolved down there too, where the live rack position is known. started = printer_manager.start_print( item.printer_id, remote_filename, @@ -4715,6 +4743,7 @@ class PrintScheduler: use_ams=item.use_ams, nozzle_offset_cali=item.nozzle_offset_cali, nozzle_mapping=item.nozzle_mapping, + nozzle_slot_extruders=nozzle_slot_extruders, ) if started: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 654a8878a..f770edd6d 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -871,6 +871,7 @@ class PrinterManager: use_ams: bool = True, nozzle_offset_cali: str = "auto", nozzle_mapping: str | None = None, + nozzle_slot_extruders: str | None = None, ) -> bool: """Start a print on a connected printer. @@ -878,6 +879,10 @@ class PrinterManager: project_file MQTT command (H2C rack-swap slicer pick preservation, #1780). It rides through to the MQTT client untouched; the dispatch builder there parses + injects it only on dual-nozzle models. + + ``nozzle_slot_extruders`` is the fallback for a job that never passed + through BambuStudio (#2800): per-slot extruder indices the MQTT layer + resolves into physical rack positions, and only on rack models. """ caller = traceback.extract_stack(limit=3)[0] logger.info( @@ -901,6 +906,7 @@ class PrinterManager: use_ams=use_ams, nozzle_offset_cali=nozzle_offset_cali, nozzle_mapping=nozzle_mapping, + nozzle_slot_extruders=nozzle_slot_extruders, ) return False diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 363e1c214..7d80b309d 100644 --- a/backend/app/utils/printer_models.py +++ b/backend/app/utils/printer_models.py @@ -234,6 +234,28 @@ DUAL_NOZZLE_MODELS = frozenset( ) +# Printers with a swappable nozzle rack ("Vortek"): the H2C carries six +# hotends in a rack and mounts one of them on its right extruder at a time. +# +# 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 +# printer clean and level with one nozzle and then print with another, at the +# wrong Z (#2800). +NOZZLE_RACK_MODELS = frozenset( + [ + # Display names (uppercase, no spaces) + "H2C", + # Internal codes + "O1C", # H2C + "O1C2", # H2C (dual nozzle variant) + ] +) + + # Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 / # airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's # touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it @@ -322,6 +344,19 @@ def is_dual_nozzle_model(model: str | None) -> bool: return normalized in DUAL_NOZZLE_MODELS +def is_nozzle_rack_model(model: str | None) -> bool: + """Return True if the model mounts its nozzles from a swappable rack (H2C). + + Accepts both the display name and the internal SSDP code, because + ``BambuMQTTClient.model`` carries whichever the printer row happens to + hold — the same reason the P2S dispatch tweak checks ``("P2S", "N7")``. + """ + if not model: + return False + normalized = model.strip().upper().replace(" ", "").replace("-", "") + return normalized in NOZZLE_RACK_MODELS + + def supports_nozzle_flow_type(model: str | None) -> bool: """Return True if the model offers a Standard / High Flow nozzle choice. diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index 029b8d3d4..9709d024f 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -310,6 +310,53 @@ def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | No return result +# Ceiling on the dense per-slot form below. Deliberately larger than the 32 +# entries a print command carries, so a legitimate file is never silently +# truncated at the limit -- it is either usable or rejected outright. +_MAX_DENSE_FILAMENT_SLOTS = 64 + + +def extract_slot_extruders_from_3mf(file_path: Path) -> 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 + dispatcher: index 0 is filament slot 1, and a slot this file does not + print is ``-1``. Nozzle-rack printers (H2C) need it to build the physical + ``nozzle_mapping`` the firmware expects — without one they fall back to + picking a nozzle themselves, which can level with one hotend and print + with another, several millimetres off the bed. + + 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 + dispatches exactly as it did before this existed. + """ + try: + with zipfile.ZipFile(file_path) as zf: + by_slot = extract_nozzle_mapping_from_3mf(zf) + except (zipfile.BadZipFile, OSError) as exc: + logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc) + return None + + if not by_slot: + return None + + # The slot IDs are whatever the file says, so the dense form has to be + # bounded before it is built: a corrupt or hostile 3MF declaring + # `filament id="50000000"` would otherwise allocate a fifty-million-entry + # list here, on the dispatch path. Nothing above 32 is usable anyway -- + # that is the length of the array the printer is sent. + highest_slot = max(by_slot) + if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS: + logger.warning( + "Ignoring nozzle mapping from %s: highest filament slot %s is out of range", + file_path, + highest_slot, + ) + return 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: """Extract per-slot nozzle/extruder mapping from a 3MF file. diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 6ef65cf02..66527ef54 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -395,6 +395,7 @@ class TestPrinterManager: use_ams=True, nozzle_offset_cali="auto", nozzle_mapping=None, + nozzle_slot_extruders=None, ) assert result is True diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py new file mode 100644 index 000000000..9e953722f --- /dev/null +++ b/backend/tests/unit/test_nozzle_rack_mapping_2800.py @@ -0,0 +1,272 @@ +"""Nozzle-rack (H2C) dispatch mapping — #2800. + +The H2C mounts one of six rack hotends on its right carriage. Dispatch has to +name the *physical* rack position, not the extruder index every other +dual-nozzle printer uses; get it wrong and the printer cleans and levels with +one nozzle, then prints with another several millimetres off the bed. + +Nothing in the queue knew the rack position, so these jobs shipped with no +`nozzle_mapping` at all and the firmware picked for itself. +""" + +import json +import zipfile + +import pytest + +from backend.app.services.bambu_mqtt import ( + _RACK_WIRE_SLOTS, + BambuMQTTClient, + resolve_rack_nozzle_mapping, +) +from backend.app.utils.printer_models import is_nozzle_rack_model +from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf + + +class TestIsNozzleRackModel: + @pytest.mark.parametrize("model", ["H2C", "h2c", " H2C ", "O1C", "O1C2"]) + def test_h2c_spellings_and_codes(self, model): + """The printer row may hold either the display name or the SSDP code.""" + assert is_nozzle_rack_model(model) is True + + @pytest.mark.parametrize("model", ["H2D", "H2D Pro", "H2S", "X2D", "P1S", "O1D", "N6", "", None]) + def test_everything_else_is_not_a_rack_model(self, model): + """Other dual-nozzle printers must keep the plain extruder-index wire.""" + assert is_nozzle_rack_model(model) is False + + +class TestResolveRackNozzleMapping: + def test_rack_slot_takes_the_live_rack_position(self): + 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_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) + assert mapping[:2] == [1, 21] + + def test_unprinted_slots_stay_unset(self): + 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]) + def test_no_usable_rack_position_omits_the_field(self, rack_id): + """Mid-swap or stale state must fall back to the firmware's own pick. + + 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 + + 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 + + @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 + ], + ) + def test_junk_input_returns_none_and_never_raises(self, bad_slots): + """Nothing above this raises: `start_print` builds the MQTT command + with no exception handler, and by then the queue item is already + committed as `printing`. A bad value has to degrade to "firmware + picks", not wedge the item in a state no print will leave.""" + assert resolve_rack_nozzle_mapping(bad_slots, rack_nozzle_id=17) is None + + @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 + + def test_none_entries_read_as_unprinted(self): + assert resolve_rack_nozzle_mapping([None, 0], 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. + + 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. + """ + assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None + + 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 + + def test_empty_mapping(self): + assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None + + +class TestRackPositionFromMqtt: + @pytest.fixture + def client(self): + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST-H2C", + access_code="12345678", + model="H2C", + ) + + def test_src_and_tar_are_captured(self, client): + client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}}) + assert client.state.nozzle_rack_src_id == 16 + assert client.state.nozzle_rack_tar_id == 19 + + def test_absent_key_does_not_clear_the_last_known_value(self, client): + """The firmware only pushes these when they change.""" + client._update_state({"device": {"nozzle": {"src_id": 16, "tar_id": 19}}}) + client._update_state({"device": {"nozzle": {"info": []}}}) + assert client.state.nozzle_rack_tar_id == 19 + + def test_unparseable_value_is_ignored(self, client): + client._update_state({"device": {"nozzle": {"tar_id": 19}}}) + client._update_state({"device": {"nozzle": {"tar_id": "nonsense"}}}) + assert client.state.nozzle_rack_tar_id == 19 + + def test_starts_unknown(self, client): + assert client.state.nozzle_rack_src_id is None + assert client.state.nozzle_rack_tar_id is None + + +class TestDispatch: + """What actually reaches the wire.""" + + def _client(self, model): + from unittest.mock import MagicMock + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST-DISPATCH", + access_code="12345678", + model=model, + ) + client._client = MagicMock() + client.state.connected = True + client._is_dual_nozzle = True + return client + + def _print_cmd(self, client): + return json.loads(client._client.publish.call_args[0][1])["print"] + + 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])) + cmd = self._print_cmd(client) + assert cmd["nozzle_mapping"][:3] == [18, -1, 18] + + def test_src_id_used_when_tar_id_is_not_a_rack_position(self): + """Between swaps the printer can report a settled src_id and nothing else.""" + 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])) + 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])) + assert "nozzle_mapping" not in self._print_cmd(client) + + def test_studio_capture_is_never_overridden(self): + """A real capture is authoritative; the derived fallback must stand down.""" + client = self._client("H2C") + client.state.nozzle_rack_tar_id = 18 + client.start_print( + "job.3mf", + nozzle_mapping=json.dumps([16, -1, -1, 1]), + nozzle_slot_extruders=json.dumps([0, -1, 0]), + ) + assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1] + + def test_other_dual_nozzle_models_are_untouched(self): + """H2D has no rack: its extruder indices are already the wire values.""" + client = self._client("H2D") + client.state.nozzle_rack_tar_id = 18 + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, 1])) + assert "nozzle_mapping" not in self._print_cmd(client) + + def test_malformed_slot_extruders_is_logged_and_omitted(self, caplog): + client = self._client("H2C") + client.state.nozzle_rack_tar_id = 18 + with caplog.at_level("WARNING"): + client.start_print("job.3mf", nozzle_slot_extruders="not json {") + assert "nozzle_mapping" not in self._print_cmd(client) + assert any("Invalid nozzle_slot_extruders" in rec.message for rec in caplog.records) + + def test_absent_slot_extruders_changes_nothing(self): + client = self._client("H2C") + client.state.nozzle_rack_tar_id = 18 + client.start_print("job.3mf") + assert "nozzle_mapping" not in self._print_cmd(client) + + +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. + """ + filaments = "".join(f'' for slot, group in group_by_slot.items()) + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "Metadata/project_settings.config", + json.dumps( + { + "physical_extruder_map": [1, 0], + "extruder_nozzle_stats": ["Standard#1", "Standard#1"], + } + ), + ) + zf.writestr("Metadata/slice_info.config", f"{filaments}") + return path + + +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.""" + 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}) + 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): + source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1}) + assert extract_slot_extruders_from_3mf(source) == [1, 0] + + def test_single_nozzle_file_yields_nothing(self, tmp_path): + path = tmp_path / "single.3mf" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("Metadata/project_settings.config", json.dumps({"physical_extruder_map": [0]})) + assert extract_slot_extruders_from_3mf(path) is None + + def test_unreadable_file_is_not_fatal(self, tmp_path): + path = tmp_path / "broken.3mf" + path.write_bytes(b"not a zip") + assert extract_slot_extruders_from_3mf(path) is None + + @pytest.mark.parametrize("slot_id", [50000000, 65, 0, -3]) + def test_out_of_range_slot_ids_are_rejected(self, tmp_path, slot_id): + """Slot IDs are whatever the file claims, and this builds a dense list. + + Without a ceiling a corrupt or hostile 3MF declaring + `filament id="50000000"` allocates a fifty-million-entry list on the + dispatch path. + """ + 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