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-10 08:54:44 +02:00
parent 08df660f6c
commit ec26cba927
8 changed files with 580 additions and 3 deletions
+1
View File
@@ -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.
+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