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.
This commit is contained in:
maziggy
2026-08-15 14:49:43 +02:00
7 changed files with 579 additions and 3 deletions
+187 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -395,6 +395,7 @@ class TestPrinterManager:
use_ams=True,
nozzle_offset_cali="auto",
nozzle_mapping=None,
nozzle_slot_extruders=None,
)
assert result is True
@@ -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