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