diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3d1ec56a..049ec5f24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
+- **An H2C could clean and level with one hotend and then print with another, several millimetres above the plate (#2800, reported by @tru3l3gend)** — The reporter's H2C ran its startup clean and bed levelling on the wrong nozzle, switched hotends, and then printed in mid-air; the same job sent from Bambu Studio was fine. The H2C is the only printer that mounts its nozzle from a rack of six, and a print command names that nozzle by its *physical* rack position — the firmware reports those as IDs 16 to 21 — rather than by the extruder index, 0 or 1, that 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 own pick and replays it untouched (#1780). Anything queued from the library, from an archive, through the webhook or from a slicer pipeline carried none, so the field was left off the command entirely and the firmware chose a nozzle for itself — and its choice does not have to agree with the one the file was sliced for. Bambuddy now reads the per-slot extruder assignment out of the file it is about to dispatch and resolves it against the rack position the printer is reporting at that moment, which is the only place it can be known: the mounted hotend can be swapped from the touchscreen between queueing a job and printing it. Nothing about this is guessed. When the rack position cannot be established — mid-swap, or a connection that has not yet reported one — the field is left off and the firmware picks exactly as it did before, because a wrong physical ID is what puts a print in the air and is far worse than no ID at all. For the same reason a job that prints only from the fixed hotend is still left to the firmware: that nozzle's own physical ID has not yet been confirmed against a known-good Bambu Studio capture, and it will not be invented. Confined to the H2C throughout — the dispatch for every other printer, including the H2D and X2D, is unchanged. Diagnosed on real hardware by the reporter, who compared Bambuddy's dispatch against a working Bambu Studio one, established the rack ID range, and supplied a patch.
- **Automatic drying no longer loops when the humidity threshold is set below what a warm AMS reports (#2770, reported by @tchavei)** — A reporter's H2D armed five separate 12-hour drying cycles inside four hours, one of them six seconds after the previous ended, and none of them ran for more than a couple of hours. Two things combine to produce that. The firmware ends a cycle whenever it decides the filament is dry, without reporting a fault: across this printer's history the run length tracks how wet the spools were, from nearly the full 12 hours when the AMS started at 32% down to minutes once it sat at 10-13%. That is the AMS doing its job. The loop is Bambuddy's. An AMS reports *higher* relative humidity while it is warm than once it has cooled — the same unit read 10-13% cold and 15-20% throughout every cycle — so with a threshold of 14% the reading at the moment a cycle ended was always still above it, and the next 30-second pass started another 12-hour cycle. Nothing counted, nothing waited, and it only stopped when the box finally cooled enough to read 13%. Auto-drying now waits half an hour after a cycle ends before it will arm another on the same unit, because the humidity reading means nothing until the AMS has cooled; and after two cycles in a row that bring the reading no lower it stops arming that unit altogether, says so in the log, and sends a notification — a new **Auto-drying suspended** event, on by default, since it reports that Bambuddy has *stopped* doing something and silence there reads as "still drying". Progress is judged against the lowest reading any cycle on that unit has ended at, so a genuinely wet spool in a humid room that is coming down slowly -- 40%, 37%, 35% -- keeps drying however far it still is from the threshold, and the suspension lifts by itself the moment the reading falls below it. Neither guard can ever stop a cycle that is running, and a cycle Bambuddy itself cut short for a print, or that you stopped by hand, is not counted against the unit -- so a farm that dries between queue jobs is unaffected. The threshold field in **Settings → Filament → AMS Display Thresholds** now warns when it is set below 20%, and every drying cycle end — early or normal — logs the unit's temperature and humidity, which is what made this diagnosable at all.
- **"database is locked" errors when a notification provider is unreachable (#2770)** — A reporter's log showed two unrelated background tasks -- printer sensor history, and the queue's orphaned-dispatch sweep -- failing with `sqlite3.OperationalError: database is locked`, each one landing inside a Discord connect timeout that took exactly 30.000 seconds. It was not contention from writing too much. Bambuddy raises an alarm from inside the loop that records sensor history, at a point where the new history rows have been added to the session but not yet committed; the first database read inside the notification path then flushed those rows to satisfy itself, which opens a write transaction, and the provider was contacted over the network with that transaction still open. SQLite allows exactly one writer, and 30 seconds of waiting for a host that is not answering comfortably outlives the 15-second busy timeout, so every other task that wanted to write during that window failed. The two reads that run before a provider is contacted no longer flush the caller's pending work, so nothing holds the writer while the network is in play, and the connect timeout is now 5 seconds rather than 30 -- reaching a host either works quickly or is not going to. Sending the body keeps the full 30 seconds, so snapshot images on a slow uplink are unaffected. Only SQLite installs were affected; Postgres has no single-writer limit.
- **A 3D preview that a proxy refuses to embed now says so, instead of leaving you with the browser's error page (#2787, reported by @trickfilm)** — A reporter uploaded an STL, sliced it in Bambuddy, and found that the sliced file's **3D Preview** showed a frowny icon and "*hostname* refused to connect" — while the STL's own preview worked. The split is exactly where the two previews part company: an STL or a source 3MF is drawn in the page itself, but a sliced file opens the embedded G-code viewer, which lives in an iframe. Bambuddy's own headers allow that frame — it is same-origin, and both the policy and the legacy header say so — which means a refusal comes from something between the browser and Bambuddy, typically a reverse proxy or security add-on sending its own framing header. None of that was visible: the browser drew its error page inside Bambuddy's layout, and nothing said what had been refused, by whom, or that the viewer opens perfectly well in a tab of its own. The page now asks for the viewer directly, reads the framing headers off the reply, and when they refuse the frame it replaces it with an explanation naming the exact header — so an operator can go and find the rule in their proxy configuration — plus a link that opens the viewer in its own tab, which no framing header applies to. A viewer that is missing from the installation is reported the same way rather than as raw JSON inside the frame. When the check cannot reach a verdict the frame is left exactly as it was, because a guess at a cause we cannot see would be worse than the browser's own page.
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