Sfoglia il codice sorgente

Let a print start on a nozzle the printer can fetch (issue #2885)

The nozzle-diameter guard compared the sliced diameter against the
mounted hotends alone. On an H2C both hotends commonly read the same
size, so a job sliced for anything else was failed before upload with
"install the matching nozzle before printing" -- even with that nozzle
sitting in the tool-changer rack, which the printer fetches by itself
as part of starting a print. The reporter saw it as an asymmetry:
going to 0.4mm always worked, going to 0.2mm never did, and only
fetching the nozzle by hand on the printer's own screen let the print
run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
hotends, a 0.6mm slice was refused identically.

Placement is what made it fatal rather than merely wrong. The guard
runs near the top of _start_print and the rack picker near the bottom,
so the item was failed before the code that would have chosen the dock
ever ran.

Bambuddy had the rack contents the whole time. nozzle_info carries an
entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
each with its diameter, so the guard now tests the slice against both
sets. It keys off the ids rather than the printer model: only a rack
machine reports 16-21, which leaves no registry to keep in sync. An
empty dock is absent from the payload entirely, so an id appearing
there already means a nozzle is in it. stat is left uninterpreted; it
read 0 on every entry, occupied and empty alike.

A diameter in neither a hotend nor a dock still stops the print before
it uploads, and the message now names both sets so the machine's real
stock is visible.

The same telemetry showed a second fault, pointing the other way. A
hotend with nothing mounted is still reported, keeping the diameter of
the nozzle it last held -- measured at idle, where the rack-side hotend
read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
back in the dock. That stale value counted as installed. Presence now
comes from the serial and the temperature rating, and emptiness has to
be stated rather than merely unstated: the serial must be the
firmware's explicit "N/A" and the rating absent. A firmware that
reports neither field normalises to exactly that, and reading it as
empty would switch the guard off on that machine.

Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
left to the firmware.
maziggy 2 settimane fa
parent
commit
4961990a7a

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
 
 ### Fixed
+- **A print could not start on the nozzle sitting in the H2C's rack (#2885, reported by @apizz)** — A job sliced for a 0.2mm nozzle failed in the queue with "install the matching nozzle before printing", even though a 0.2mm nozzle was in the tool-changer rack and the printer would have fetched it. Only picking the nozzle up by hand on the printer's own screen first let the print run, and because the item failed rather than waited, the rest of the queue went with it. The reporter noticed the giveaway: going *to* 0.4mm always worked, going *to* 0.2mm never did. The nozzle-diameter guard that catches a genuinely wrong slice before upload was measuring the wrong thing — it compared the sliced diameter against the two mounted hotends only, so on a machine whose hotends both read 0.4mm nothing but 0.4mm could ever pass, and the rack picker that would have fetched the right one runs further down the same dispatch and never got the chance. The rack now counts as reachable: a diameter parked in any dock satisfies the guard the same way a mounted one does. This was never only about 0.2mm — a 0.6mm slice was blocked identically. A diameter that is in neither a hotend nor a dock still stops the print before it uploads, and the message now lists both sets so it is clear what the machine actually has. Also fixed alongside it: an empty carriage keeps reporting the diameter of the nozzle it last held, so a hotend that had parked its nozzle back in the rack was counted as a mounted 0.4mm that was not there — presence is now read from the nozzle's own temperature rating and serial number, and a hotend is only discarded when both agree it is empty. Printers that report no rack at all are unaffected.
 - **An AMS slot could name the wrong white** — A hex is not one colour in Bambu's range: `#FFFFFF` is Jade White in PLA Basic, Ivory White in PLA Matte and plain White in six other materials, and `#000000` is Charcoal in PLA Matte where it is Black everywhere else. The slot popover looked the colour up by hex alone, and the lookup table can only keep one name per hex — so an ivory Matte spool was titled "Jade White" even while the profile line beside it correctly read Matte Ivory. The colour map now also carries the names that collapsing loses, keyed by material, and every slot resolves its colour with the material the printer reports for it (`tray_sub_brands`). Slots with a spool assigned from Inventory are titled with that spool's own colour name, which is the roll the user actually put in. Only a name the same brand's own range lost is carried — one manufacturer's name must not displace another's — so the added map is 11 entries against the 608 in the shipped catalog.
 - **A queued job switched on printers that could never have printed it** — When no printer of the target model was available, the queue powered one on via its smart plug, but chose it on model alone: it walked the farm in printer-ID order, woke the first machine with an Auto On plug, and only then discovered the loaded filament was the wrong colour. A job for a colour loaded at the far end of the farm therefore woke every earlier printer in turn and left each one running until its own auto-power-off timer expired. The colours were known the whole time — a printer keeps its last reported AMS and external-spool trays after the power goes — so the wake step now asks the same three questions the matcher asks a live printer (required types, forced colours, preferred colours) and passes over a printer whose last known filament cannot satisfy the job. A printer Bambuddy has never heard from is still woken: no reading is not the same as no filament. Reconnecting a printer also no longer discards that reading, so a power-on attempt that times out stops erasing what the next attempt needs.
 - **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before.

+ 140 - 13
backend/app/services/print_scheduler.py

@@ -37,7 +37,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
-from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED, resolve_rack_plan_mapping
+from backend.app.services.bambu_mqtt import _RACK_NOZZLE_IDS, HMS_MQTT_VERIFY_FAILED, resolve_rack_plan_mapping
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.finance_budget import (
     create_budget_reservation,
@@ -510,30 +510,138 @@ def _drying_ams_ids(status) -> list[int]:
     return ids
 
 
+def _parse_diameter(raw) -> float | None:
+    """``"0.4"`` → ``0.4``; anything unparseable or non-positive → None."""
+    try:
+        value = float(raw)
+    except (TypeError, ValueError):
+        return None
+    return value if value > 0 else None
+
+
+def _nozzle_info_by_id(status) -> dict[int, dict]:
+    """Index ``PrinterState.nozzle_rack`` by nozzle id.
+
+    The field name is historical: on the H2 series ``nozzle_info`` carries an
+    entry for *every* nozzle the printer knows about — the L/R hotends under
+    ids 0/1 and, on a rack model, the dock positions under ids 16-21. Only the
+    latter are the rack proper; :func:`_rack_nozzle_diameters` and
+    :func:`_installed_nozzle_diameters` each take the half they need.
+    """
+    by_id: dict[int, dict] = {}
+    for entry in getattr(status, "nozzle_rack", None) or []:
+        if not isinstance(entry, dict):
+            continue
+        try:
+            by_id[int(entry.get("id"))] = entry
+        except (TypeError, ValueError):
+            continue
+    return by_id
+
+
+# What the firmware puts in a nozzle's serial number when the carriage or dock
+# is empty. Measured on an H2C at idle, where the rack-side hotend had parked
+# its nozzle back in the rack (#2885).
+_EMPTY_NOZZLE_SERIAL = "N/A"
+
+
+def _nozzle_is_mounted(entry: dict | None) -> bool:
+    """Whether a ``nozzle_info`` entry describes hardware that is actually there.
+
+    A hotend entry is reported whether or not a nozzle is mounted, and an empty
+    carriage keeps the *last* nozzle's diameter — measured on an H2C at idle,
+    where id 1 read ``diameter "0.4"`` with ``max_temp 0``, ``serial_number
+    "N/A"`` and ``wear 0`` because that hotend had parked its nozzle back in
+    the dock. So the diameter is not a presence signal (#2885).
+
+    Emptiness has to be stated, not merely unstated. The serial must be the
+    firmware's explicit ``"N/A"`` marker *and* the temperature rating must be
+    absent — an empty serial only means the printer didn't say, and a firmware
+    that reports neither field normalises to exactly that. Treating "didn't
+    say" as "empty" would silently switch the #1899 guard off on any such
+    machine, so everything we cannot positively call empty counts as mounted.
+    """
+    if entry is None:
+        return True
+    serial = str(entry.get("serial_number") or "").strip().upper()
+    if serial != _EMPTY_NOZZLE_SERIAL:
+        return True
+    try:
+        max_temp = float(entry.get("max_temp") or 0)
+    except (TypeError, ValueError):
+        return True
+    return max_temp > 0
+
+
 def _installed_nozzle_diameters(status) -> list[float]:
-    """Parse the installed nozzle diameters from a PrinterState (#1899).
+    """Parse the mounted nozzle diameters from a PrinterState (#1899).
 
     Returns the diameters the printer actually reports (e.g. [0.4] single-nozzle,
     [0.4, 0.6] dual-nozzle), skipping the empty-string defaults that populate a
     NozzleInfo before MQTT fills it in. An empty list means "the printer hasn't
     told us its nozzle hardware" — callers must treat that as unknown, not as a
     mismatch, so we never block a print on missing data.
+
+    A hotend whose ``nozzle_info`` entry says nothing is mounted is skipped even
+    though ``nozzles`` still carries a diameter for it: that value is stale, and
+    counting it would let a slice match a nozzle the machine does not have
+    (#2885). Printers that report no ``nozzle_info`` at all are unaffected.
     """
+    info = _nozzle_info_by_id(status)
     diameters: list[float] = []
-    for nozzle in getattr(status, "nozzles", None) or []:
+    for index, nozzle in enumerate(getattr(status, "nozzles", None) or []):
         raw = getattr(nozzle, "nozzle_diameter", "") or ""
         try:
             value = float(raw)
         except (TypeError, ValueError):
             continue
-        if value > 0:
+        if value > 0 and _nozzle_is_mounted(info.get(index)):
+            diameters.append(value)
+    return diameters
+
+
+def _rack_nozzle_diameters(status) -> list[float]:
+    """Diameters sitting in the tool-changer rack, nearest dock first (#2885).
+
+    Keyed off the nozzle ids themselves rather than the printer model: only a
+    rack machine ever reports ids 16-21, so there is no model registry to keep
+    in sync. An empty dock is simply absent from the payload — measured on an
+    H2C whose R2 (id 17) was empty and unlisted while R1/R3/R4/R5/R6 were all
+    present — so appearing here already means "a nozzle is in that dock".
+
+    ``stat`` is deliberately not interpreted: its values aren't known, and
+    reading it wrongly could hide a nozzle the printer would happily fetch.
+    """
+    diameters: list[float] = []
+    for nozzle_id, entry in sorted(_nozzle_info_by_id(status).items()):
+        if nozzle_id not in _RACK_NOZZLE_IDS:
+            continue
+        # PrinterState spells it "diameter"; the REST schema renames it to
+        # "nozzle_diameter". Accept either so a caller holding the serialised
+        # shape gets the same answer.
+        value = _parse_diameter(entry.get("diameter") or entry.get("nozzle_diameter"))
+        if value is not None:
             diameters.append(value)
     return diameters
 
 
-def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]) -> str | None:
+def _format_diameters(diameters: list[float]) -> str:
+    """``[0.4, 0.4, 0.6]`` → ``"0.4mm / 0.6mm"``, in first-seen order.
+
+    Deduplicated because a loaded rack holds several nozzles of the same size,
+    and "0.4mm / 0.4mm / 0.4mm / 0.6mm / 0.2mm" tells the reader nothing the
+    short form doesn't. Matching still runs over the full list.
+    """
+    return " / ".join(f"{d:g}mm" for d in dict.fromkeys(diameters))
+
+
+def _nozzle_mismatch_message(
+    sliced_nozzle: float | None,
+    installed: list[float],
+    rack: list[float] | None = None,
+) -> str | None:
     """Return an actionable error message when the sliced nozzle can't be
-    printed on any installed nozzle, else None (#1899).
+    printed on any nozzle the machine can reach, else None (#1899).
 
     Fail-safe: returns None whenever we lack the data to judge — no sliced
     diameter, or the printer reported no nozzles — so a print is only ever
@@ -541,16 +649,27 @@ def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]
     EITHER installed nozzle passes (a 0.6 slice is fine if one hotend is 0.6).
     The 0.05 tolerance absorbs float noise while staying well inside the 0.2
     gap between adjacent nozzle sizes (0.2/0.4/0.6/0.8).
+
+    *rack* holds the diameters parked in a tool-changer dock (H2C). Those count
+    as reachable: the printer fetches one as part of starting the print, so a
+    slice that matches a docked nozzle is not a mismatch. Without this the
+    guard blocked every job whose nozzle happened not to be on a hotend at
+    dispatch time — on a rack loaded with 0.2/0.4/0.6 that meant only the
+    diameter already mounted could ever print, and the user had to fetch the
+    nozzle by hand on the printer's own UI first (#2885).
     """
-    if not sliced_nozzle or not installed:
+    reachable = [*installed, *(rack or [])]
+    if not sliced_nozzle or not reachable:
         return None
-    if any(abs(d - sliced_nozzle) < 0.05 for d in installed):
+    if any(abs(d - sliced_nozzle) < 0.05 for d in reachable):
         return None
-    installed_str = " / ".join(f"{d:g}mm" for d in installed)
+    where = f"{_format_diameters(installed)} installed" if installed else "no nozzle mounted"
+    if rack:
+        where += f" and {_format_diameters(rack)} in the nozzle rack"
     return (
         f"File sliced for a {sliced_nozzle:g}mm nozzle, but the printer has "
-        f"{installed_str} installed. Re-slice for the installed nozzle, or "
-        f"install the matching nozzle before printing."
+        f"{where}. Re-slice for an available nozzle, or fit the matching "
+        f"nozzle before printing."
     )
 
 
@@ -5859,10 +5978,18 @@ class PrintScheduler:
         # print proceed exactly as before. On dual-nozzle printers (H2D) a match
         # against EITHER installed nozzle passes, so a 0.6 slice is fine as long
         # as one of the two hotends is a 0.6.
+        #
+        # On a tool-changer model (H2C) the nozzles parked in the rack count too
+        # (#2885): the printer fetches one as part of starting the print, so the
+        # set to test against is "reachable", not "currently mounted". This runs
+        # well before the rack picker at the bottom of this method, so without
+        # the rack in scope here that picker never got the chance to run.
         sliced_nozzle = archive.nozzle_diameter if archive else None
         if sliced_nozzle:
-            installed = _installed_nozzle_diameters(printer_manager.get_status(item.printer_id))
-            mismatch_msg = _nozzle_mismatch_message(sliced_nozzle, installed)
+            nozzle_status = printer_manager.get_status(item.printer_id)
+            installed = _installed_nozzle_diameters(nozzle_status)
+            rack = _rack_nozzle_diameters(nozzle_status)
+            mismatch_msg = _nozzle_mismatch_message(sliced_nozzle, installed, rack)
             if mismatch_msg:
                 item.status = "failed"
                 item.error_message = mismatch_msg

+ 186 - 2
backend/tests/unit/test_scheduler_nozzle_mismatch.py

@@ -27,6 +27,7 @@ from backend.app.services.print_scheduler import (
     PrintScheduler,
     _installed_nozzle_diameters,
     _nozzle_mismatch_message,
+    _rack_nozzle_diameters,
 )
 from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
@@ -114,6 +115,156 @@ def test_adjacent_sizes_are_distinguished():
     assert _nozzle_mismatch_message(0.6, [0.8]) is not None
 
 
+# ---------------------------------------------------------------------------
+# Tool-changer rack (#2885)
+#
+# H2C-1's live telemetry, captured while it was sitting in the state that
+# reproduces the bug: both hotends read 0.4, R2 (id 17) is an empty dock and so
+# absent from the payload, and id 1 is a hotend whose nozzle is parked back in
+# the rack -- it still reports diameter "0.4", but max_temp 0 / serial "N/A"
+# say nothing is mounted.
+# ---------------------------------------------------------------------------
+
+H2C_NOZZLE_INFO = [
+    {"id": 0, "diameter": "0.4", "wear": 128, "max_temp": 350, "serial_number": "20D06A611826266"},
+    {"id": 1, "diameter": "0.4", "wear": 0, "max_temp": 0, "serial_number": "N/A"},
+    {"id": 16, "diameter": "0.4", "wear": 128, "max_temp": 350, "serial_number": "20D06A630222856"},
+    {"id": 18, "diameter": "0.4", "wear": 128, "max_temp": 350, "serial_number": "20D06A630227749"},
+    {"id": 19, "diameter": "0.4", "wear": 128, "max_temp": 350, "serial_number": "20D06A630222810"},
+    {"id": 20, "diameter": "0.6", "wear": 128, "max_temp": 350, "serial_number": "20D06A610707022"},
+    {"id": 21, "diameter": "0.2", "wear": 128, "max_temp": 350, "serial_number": "20D06A5C2913952"},
+]
+
+
+def _h2c_state():
+    return SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
+        nozzle_rack=H2C_NOZZLE_INFO,
+    )
+
+
+def test_rack_reads_dock_positions_only():
+    # ids 16-21 are the docks; 0/1 are the hotends and must not leak in.
+    assert _rack_nozzle_diameters(_h2c_state()) == [0.4, 0.4, 0.4, 0.6, 0.2]
+
+
+def test_rack_empty_dock_is_absent_not_zero():
+    # R2 (id 17) held no nozzle and the printer simply omitted it, so five
+    # entries come back for a six-position rack.
+    assert len(_rack_nozzle_diameters(_h2c_state())) == 5
+
+
+def test_rack_empty_on_printers_without_one():
+    assert _rack_nozzle_diameters(_state("0.4")) == []
+    assert _rack_nozzle_diameters(None) == []
+    assert _rack_nozzle_diameters(SimpleNamespace(nozzle_rack=[])) == []
+
+
+def test_rack_ignores_unparseable_entries():
+    status = SimpleNamespace(
+        nozzle_rack=[
+            {"id": 16, "diameter": ""},
+            {"id": 17, "diameter": "0"},
+            {"id": 18, "diameter": "abc"},
+            {"id": 19, "diameter": "0.4"},
+            "not-a-dict",
+            {"id": "x", "diameter": "0.6"},
+        ]
+    )
+    assert _rack_nozzle_diameters(status) == [0.4]
+
+
+def test_rack_accepts_the_serialised_key_name():
+    # PrinterState says "diameter"; the REST schema says "nozzle_diameter".
+    status = SimpleNamespace(nozzle_rack=[{"id": 21, "nozzle_diameter": "0.2"}])
+    assert _rack_nozzle_diameters(status) == [0.2]
+
+
+def test_installed_drops_a_hotend_with_no_nozzle_mounted():
+    # id 1's "0.4" is stale -- the carriage is empty (max_temp 0, serial N/A).
+    assert _installed_nozzle_diameters(_h2c_state()) == [0.4]
+
+
+def test_installed_keeps_hotends_when_no_nozzle_info_is_reported():
+    # Printers that never send nozzle_info behave exactly as before.
+    assert _installed_nozzle_diameters(_state("0.4", "0.6")) == [0.4, 0.6]
+
+
+def test_installed_keeps_a_hotend_when_only_one_signal_says_empty():
+    # Conservative: the explicit "N/A" serial and a missing temperature rating
+    # must BOTH be present before we discard a reported diameter, so a partial
+    # payload can never invent a mismatch.
+    only_serial_says_empty = SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter="0.4")],
+        nozzle_rack=[{"id": 0, "diameter": "0.4", "max_temp": 350, "serial_number": "N/A"}],
+    )
+    only_temp_says_empty = SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter="0.4")],
+        nozzle_rack=[{"id": 0, "diameter": "0.4", "max_temp": 0, "serial_number": "SN123"}],
+    )
+    assert _installed_nozzle_diameters(only_serial_says_empty) == [0.4]
+    assert _installed_nozzle_diameters(only_temp_says_empty) == [0.4]
+
+
+def test_installed_keeps_a_hotend_when_the_firmware_reports_neither_field():
+    # A firmware that sends nozzle_info without max_temp/serial normalises to
+    # max_temp 0 + serial "". That is "didn't say", not "empty" -- reading it as
+    # empty would silently switch the #1899 guard off on that machine.
+    quiet = SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.6")],
+        nozzle_rack=[
+            {"id": 0, "diameter": "0.4", "max_temp": 0, "serial_number": ""},
+            {"id": 1, "diameter": "0.6", "max_temp": 0, "serial_number": ""},
+        ],
+    )
+    assert _installed_nozzle_diameters(quiet) == [0.4, 0.6]
+    # ...and the guard therefore still blocks a genuinely wrong slice.
+    assert _nozzle_mismatch_message(0.2, _installed_nozzle_diameters(quiet), []) is not None
+
+
+def test_installed_tolerates_a_non_numeric_max_temp():
+    odd = SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter="0.4")],
+        nozzle_rack=[{"id": 0, "diameter": "0.4", "max_temp": "hot", "serial_number": "N/A"}],
+    )
+    assert _installed_nozzle_diameters(odd) == [0.4]
+
+
+def test_docked_nozzle_counts_as_reachable():
+    # The bug: a 0.2 slice was blocked while a 0.2 sat in R6.
+    assert _nozzle_mismatch_message(0.2, [0.4], [0.4, 0.6, 0.2]) is None
+    # And it was never only about 0.2 -- a 0.6 in R5 was blocked the same way.
+    assert _nozzle_mismatch_message(0.6, [0.4], [0.4, 0.6, 0.2]) is None
+
+
+def test_h2c_live_state_no_longer_blocks_any_stocked_diameter():
+    status = _h2c_state()
+    installed = _installed_nozzle_diameters(status)
+    rack = _rack_nozzle_diameters(status)
+    for sliced in (0.2, 0.4, 0.6):
+        assert _nozzle_mismatch_message(sliced, installed, rack) is None, sliced
+
+
+def test_diameter_in_neither_hotend_nor_rack_still_blocks():
+    msg = _nozzle_mismatch_message(0.8, [0.4], [0.4, 0.6, 0.2])
+    assert msg is not None
+    assert "0.8mm" in msg
+    assert "0.4mm installed" in msg
+    # Deduplicated and rack-labelled, so the user can see what is actually there.
+    assert "0.4mm / 0.6mm / 0.2mm in the nozzle rack" in msg
+
+
+def test_message_names_an_empty_carriage_rather_than_claiming_a_size():
+    msg = _nozzle_mismatch_message(0.8, [], [0.4])
+    assert msg is not None
+    assert "no nozzle mounted" in msg
+
+
+def test_rack_only_still_fail_safe_when_nothing_is_known():
+    # No hotend and no rack -> unknown, never block.
+    assert _nozzle_mismatch_message(0.8, [], []) is None
+
+
 # ---------------------------------------------------------------------------
 # End-to-end: the guard fires inside _start_print BEFORE upload
 # ---------------------------------------------------------------------------
@@ -185,9 +336,12 @@ async def archive_case(tmp_path):
         await engine.dispose()
 
 
-async def _run_start_print(ctx, *, installed_nozzles):
+async def _run_start_print(ctx, *, installed_nozzles, nozzle_rack=None):
     scheduler = PrintScheduler()
-    status = SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in installed_nozzles])
+    status = SimpleNamespace(
+        nozzles=[SimpleNamespace(nozzle_diameter=d) for d in installed_nozzles],
+        nozzle_rack=nozzle_rack or [],
+    )
     # The mismatch case returns before the upload path; the match case drives it
     # to start_print, so mirror the post-guard dependency patches the
     # cleanup-library harness uses (get_ftp_retry_settings et al. open their own
@@ -247,3 +401,33 @@ async def test_start_print_proceeds_when_nozzle_matches(archive_case):
         item = await db.get(PrintQueueItem, ctx.queue_item_id)
     assert item.status != "failed"
     ctx.start_print.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_start_print_proceeds_when_the_nozzle_is_in_the_rack(archive_case):
+    """#2885: a 0.2 slice dispatched to an H2C whose hotends both read 0.4 but
+    whose rack holds a 0.2 must reach start_print. Before the fix the guard
+    failed the item here, so the rack picker further down never ran and the
+    user had to fetch the nozzle by hand on the printer's own UI."""
+    ctx = await archive_case(sliced_nozzle=0.2)
+    await _run_start_print(ctx, installed_nozzles=["0.4", "0.4"], nozzle_rack=H2C_NOZZLE_INFO)
+
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+    assert item.status != "failed"
+    ctx.start_print.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_start_print_still_blocks_a_diameter_the_rack_lacks(archive_case):
+    """The rack widens the guard, it does not disable it: 0.8 is in neither a
+    hotend nor a dock, so the item still fails before upload."""
+    ctx = await archive_case(sliced_nozzle=0.8)
+    await _run_start_print(ctx, installed_nozzles=["0.4", "0.4"], nozzle_rack=H2C_NOZZLE_INFO)
+
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+    assert item.status == "failed"
+    assert "nozzle rack" in item.error_message
+    ctx.upload.assert_not_called()
+    ctx.start_print.assert_not_called()