Просмотр исходного кода

Resolve the H2C rack nozzle at dispatch instead of letting firmware pick (#2800)

An H2C ran its startup clean and bed levelling on one hotend, switched,
and then printed several millimetres above the plate. The same job from
Bambu Studio was fine.

The H2C is the only model that mounts its nozzle from a rack of six, and
a print command names that nozzle by physical rack position -- the
firmware reports those as 16 to 21 -- not by the extruder index, 0 or 1,
every other dual-nozzle printer uses. Bambuddy only ever had a rack
position when a job arrived through the Virtual Printer, which captures
Bambu Studio's pick and replays it (#1780). Anything queued from the
library, an archive, the webhook or a slicer pipeline carried none, so
the field was omitted and the firmware chose -- and its choice need not
match what the file was sliced for.

The scheduler now derives the per-slot extruder assignment from the file
it is about to send, and the MQTT layer resolves it against the rack
position the printer reports live. Both are needed: the file knows which
side a slot prints from, only the printer knows which hotend is in the
carriage, and it can be swapped from the touchscreen between queueing a
job and printing it.

Derived at dispatch rather than at creation because that is the first
point knowing both the real printer and the real file -- an item can be
created unassigned, reassigned later, or have its file swapped for a
G-code-injected copy. One call therefore covers the print dialog, bulk
library adds, the webhook and pipeline runs, and no column is needed.

extract_nozzle_mapping_from_3mf is deliberately untouched. Its output
feeds the AMS matcher, where nozzle_id is compared against a tray's
extruder_id as a hard filter, and physical_extruder_map is what makes
that comparison correct -- on an H2D it is [1, 0] and flips the two.
Dropping the translation to suit the rack would send every dual-nozzle
AMS match to the wrong extruder. The dense per-slot form is a separate
function reusing the same output.

Nothing here can fail a dispatch. The command is built and published with
no exception handler above it, and the queue item is already committed as
printing by then, so a bad input has to degrade to "firmware picks"
rather than wedge the item. resolve_rack_nozzle_mapping validates every
input and raises nothing; an unresolvable mapping, an unparseable value
or an unknown rack position all omit the field, which is the behaviour
that existed before. Slot IDs are bounded before the dense list is built:
they come from the file, and one declaring filament id="50000000" would
otherwise allocate a fifty-million-entry list on the dispatch path.

Two things are not guessed. A job printing only from the fixed hotend is
still left to the firmware, because that nozzle's physical ID is not
confirmed by a known-good capture. And the rack is taken to feed extruder
0 from a single hardware observation -- if that is flipped, a one-sided
job matches nothing and falls back to the old behaviour, so only a job
using both nozzles at once could be harmed, which is what a second
capture needs to confirm.

Confined to the H2C throughout. Building the print command for 21 model
spellings with and without the new argument changes exactly three of them
-- H2C, O1C and O1C2. The other 18, including H2D and X2D, are identical.

Reported by @tru3l3gend, who diagnosed it on real hardware against a
working Bambu Studio dispatch, established the rack ID range and supplied
a patch.
maziggy 3 недель назад
Родитель
Сommit
ec26cba927

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 187 - 1
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

+ 31 - 2
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:

+ 6 - 0
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
 

+ 35 - 0
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.
 

+ 47 - 0
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.
 

+ 1 - 0
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
 

+ 272 - 0
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'<filament id="{slot}" group_id="{group}"/>' 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"<config><plate>{filaments}</plate></config>")
+    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

Некоторые файлы не были показаны из-за большого количества измененных файлов