Choose which rack nozzle each filament prints from on an H2C (#1784)

The Vortek rack holds six hotends, and a multi-colour plate is sliced to
use a different one per colour so it can skip the purge. Which of the six
each colour takes is not in the 3MF. The same plate, sliced and sent twice
from Bambu Studio with a different choice each time, produces two files
that differ only in rounding in the last digit of a few extrusion figures
-- the filament grouping, the toolchange stream, the 120 nozzle-change
markers and project_settings.config are all identical. The choice travels
only in the dispatched nozzle_mapping.

Bambuddy had no way to state it, so those plates went out with no nozzle
assignment at all and the printer chose for itself. That is what levelled
on one hotend and printed with another, millimetres above the plate.

Every rack-bound filament now carries a position picker beside its AMS
slot dropdown, listing all six with the nozzle each holds. An empty
position, or one holding the wrong diameter or flow type, is shown greyed
out with the reason rather than hidden, so someone looking for position 4
finds it. The choice is per filament *group* rather than per slot, because
a group is one hotend: two filaments the slicer grouped together share it
and cannot point at different positions.

Nothing has to be picked. Positions are assigned automatically, preferring
one already loaded with that colour, which on the plate this was built
against reproduces Bambu Studio's own pick exactly.

A nozzle currently picked up onto the carriage is offered too. The
firmware drops its rack position from the report entirely rather than
sending a placeholder (#943), and refusing it would rule out the position
most likely to be wanted -- the one the last print left mounted. Only
recoverable when exactly one position is missing; two gaps are genuinely
ambiguous and stay unavailable.

Positions are re-checked at dispatch, not just when queued, because the
rack can be re-loaded in between. The two failure modes differ on purpose:
an explicitly chosen position that no longer fits stops the print, names
what the position now holds, and deletes the uploaded file from the SD
card so it cannot be started by hand either -- an operator who named a
hotend must not silently get a different one. An automatic assignment that
cannot be made instead falls back to letting the firmware choose, which is
what happened before any of this existed.

The pick is stored as {group: position} rather than as the expanded
nozzle_mapping, though that is what goes on the wire. That column means
"Bambu Studio decided, forward verbatim", and only the group-and-position
form can be re-checked against what is actually mounted at dispatch.

The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
It still guards the #2800 fallback, which can only ever name one rack id.

Measured on the maintainer's H2C: rack position n is physical nozzle id
15 + n, confirmed by cross-referencing two captured dispatches against
Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
is the rack straight from the file, and is read rather than assumed -- a
fourth independent confirmation of the carriage indices fixed in 45dc139.

The print dialog is also wider, on every printer. Its filament rows carry
the most horizontal content in it and adding a picker truncated names to
"Bamb...". The column widths themselves only change on a rack machine.

Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
recovery and every refusal; 9 dispatch integration asserting the two real
captures end to end; 7 API round-trip; 33 frontend. The API ones exist
because two integration bugs got through a green suite that tested the
pieces and not the seams -- the group data reached only one of the three
filament-requirements routes, and the field was declared on every schema
except the create one, where Pydantic dropped it in silence.
This commit is contained in:
maziggy
2026-08-14 11:27:50 +02:00
parent 4d458a5202
commit 3954d3a7e6
39 changed files with 2428 additions and 115 deletions
@@ -0,0 +1,172 @@
"""The rack-position pick has to survive the round trip (#1784).
It did not, first time out: the field was declared on ``PrintQueueItemUpdate``
and the response model but not on ``PrintQueueItemCreate``, so Pydantic dropped
it from every POST without complaint. The queued item then carried no pick, the
dispatcher assigned positions itself, and the print ran from hotends the
operator had not chosen -- with nothing in the logs but ``chosen auto``.
A silently-dropped field is invisible at every layer above it, so it is pinned
here at the layer it crosses: HTTP in, database out.
"""
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.integration
@pytest.fixture
async def printer(db_session):
from backend.app.models.printer import Printer
printer = Printer(
name="H2C-1",
ip_address="192.168.1.210",
serial_number="RACKCHOICE0001",
access_code="12345678",
model="H2C",
)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
@pytest.fixture
async def archive(db_session, printer):
from backend.app.models.archive import PrintArchive
archive = PrintArchive(
printer_id=printer.id,
filename="benchy.gcode.3mf",
file_path="archives/benchy.gcode.3mf",
file_size=1024,
status="completed",
)
db_session.add(archive)
await db_session.commit()
await db_session.refresh(archive)
return archive
async def _stored_choice(db_session, item_id):
"""What actually landed in the column, not what the response echoed."""
from backend.app.models.print_queue import PrintQueueItem
db_session.expire_all()
item = await db_session.get(PrintQueueItem, item_id)
return item.nozzle_rack_choice
@pytest.mark.asyncio
class TestCreate:
async def test_a_pick_posted_on_create_reaches_the_column(
self, async_client: AsyncClient, printer, archive, db_session
):
response = await async_client.post(
"/api/v1/queue/",
json={
"printer_id": printer.id,
"archive_id": archive.id,
# Group 2 to rack position 1, group 1 to position 3 -- the pick
# BambuStudio dispatched as [16, 1, 18] on 2026-08-13.
"nozzle_rack_choice": {"2": 1, "1": 3},
},
)
assert response.status_code == 200
result = response.json()
assert result["nozzle_rack_choice"] == {"2": 1, "1": 3}
assert await _stored_choice(db_session, result["id"]) is not None
async def test_creating_without_one_leaves_the_column_null(
self, async_client: AsyncClient, printer, archive, db_session
):
"""Null is the signal to assign positions at dispatch."""
response = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
assert response.status_code == 200
assert response.json()["nozzle_rack_choice"] is None
assert await _stored_choice(db_session, response.json()["id"]) is None
@pytest.mark.asyncio
class TestUpdate:
async def test_editing_an_item_replaces_its_pick(self, async_client: AsyncClient, printer, archive, db_session):
created = await async_client.post(
"/api/v1/queue/",
json={
"printer_id": printer.id,
"archive_id": archive.id,
"nozzle_rack_choice": {"2": 1, "1": 3},
},
)
item_id = created.json()["id"]
response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"nozzle_rack_choice": {"2": 1, "1": 2}})
assert response.status_code == 200
assert response.json()["nozzle_rack_choice"] == {"2": 1, "1": 2}
async def test_clearing_the_pick_hands_the_choice_back_to_the_dispatcher(
self, async_client: AsyncClient, printer, archive, db_session
):
created = await async_client.post(
"/api/v1/queue/",
json={
"printer_id": printer.id,
"archive_id": archive.id,
"nozzle_rack_choice": {"2": 1, "1": 3},
},
)
item_id = created.json()["id"]
response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"nozzle_rack_choice": None})
assert response.status_code == 200
assert response.json()["nozzle_rack_choice"] is None
assert await _stored_choice(db_session, item_id) is None
async def test_an_unrelated_edit_does_not_disturb_the_pick(
self, async_client: AsyncClient, printer, archive, db_session
):
created = await async_client.post(
"/api/v1/queue/",
json={
"printer_id": printer.id,
"archive_id": archive.id,
"nozzle_rack_choice": {"2": 1, "1": 3},
},
)
item_id = created.json()["id"]
response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"manual_start": True})
assert response.status_code == 200
assert response.json()["nozzle_rack_choice"] == {"2": 1, "1": 3}
class TestSchemaCoverage:
def test_create_update_and_response_all_declare_the_field(self):
"""The original bug in one assertion: it was on two of the three.
A field missing from a request schema is dropped in silence, so there is
no error anywhere to catch it -- only a print that runs from the wrong
hotend.
"""
from backend.app.schemas.print_queue import (
PrintQueueItemCreate,
PrintQueueItemResponse,
PrintQueueItemUpdate,
QueueVariantCreate,
)
for schema in (PrintQueueItemCreate, PrintQueueItemUpdate, PrintQueueItemResponse, QueueVariantCreate):
assert "nozzle_rack_choice" in schema.model_fields, schema.__name__
def test_the_create_schema_actually_keeps_a_posted_pick(self):
from backend.app.schemas.print_queue import PrintQueueItemCreate
parsed = PrintQueueItemCreate(printer_id=1, archive_id=1, nozzle_rack_choice={"2": 1, "1": 3})
assert parsed.nozzle_rack_choice == {2: 1, 1: 3}
@@ -0,0 +1,253 @@
"""What the dispatcher does with a rack-position pick (#1784).
The resolution itself is covered in
``backend/tests/unit/test_nozzle_rack_positions_1784.py``. This covers the glue
around it, where the two failure modes deliberately differ:
- an **explicit** pick that no longer fits the rack stops the print, because the
operator named a hotend and printing from a different one is how a plate gets
levelled on one nozzle and drawn with another, millimetres above the bed;
- an **assignment** that cannot be made falls through to the pre-existing #2800
path, which is strictly not worse than the behaviour before any of this.
And a non-rack printer must be untouched by all of it.
"""
from __future__ import annotations
import json
import zipfile
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings # noqa: F401 - registers the table
from backend.app.services.print_scheduler import PrintScheduler
pytestmark = pytest.mark.integration
# Three filaments in groups 2/0/1, groups 1 and 2 both on the rack carriage --
# the maintainer's own plate, the one that printed in mid-air.
_FILAMENTS = (
'<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
'<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
'<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
)
_NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
def _write_3mf(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as zf:
zf.writestr(
"Metadata/project_settings.config",
json.dumps(
{
"physical_extruder_map": ["1", "0"],
"extruder_max_nozzle_count": ["1", "6"],
"extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
}
),
)
zf.writestr(
"Metadata/slice_info.config",
f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
)
def _rack(present=(1, 2, 3, 4, 5, 6)):
"""Live rack telemetry, plus the always-reported fixed carriage."""
return [{"id": 15 + p, "diameter": "0.4", "type": "HH01", "filament_color": ""} for p in present] + [
{"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""}
]
@pytest.fixture
async def rack_case(tmp_path):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
base_dir = tmp_path / "rack-dispatch"
archive_rel = Path("archives") / "benchy.gcode.3mf"
_write_3mf(base_dir / archive_rel)
async def _build(model: str, choice: dict | None):
async with session_maker() as db:
printer = Printer(
name="H2C-1",
serial_number="RACK-SERIAL",
ip_address="127.0.0.1",
access_code="access-code",
model=model,
)
db.add(printer)
await db.flush()
archive = PrintArchive(
printer_id=printer.id,
filename="benchy.gcode.3mf",
file_path=str(archive_rel),
file_size=(base_dir / archive_rel).stat().st_size,
status="completed",
)
db.add(archive)
await db.flush()
item = PrintQueueItem(
printer_id=printer.id,
archive_id=archive.id,
plate_id=1,
status="pending",
nozzle_rack_choice=json.dumps(choice) if choice else None,
)
db.add(item)
await db.commit()
return SimpleNamespace(item_id=item.id, printer_id=printer.id)
try:
yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
finally:
await engine.dispose()
async def _dispatch(ctx, ids, rack_slots):
"""Run one dispatch, returning the mocked ``start_print`` and the delete."""
scheduler = PrintScheduler()
start_print = MagicMock(return_value=True)
delete_file = AsyncMock(return_value=True)
status = SimpleNamespace(state="IDLE", nozzle_rack=rack_slots)
with ExitStack() as stack:
for patcher in (
patch.object(scheduler_module, "async_session", ctx.session_maker),
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
patch("backend.app.services.print_scheduler.delete_file_async", delete_file),
patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
# Reads settings through its own session on the real app database,
# which the in-memory engine here does not have.
patch(
"backend.app.services.print_scheduler.get_ftp_retry_settings",
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
),
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
):
stack.enter_context(patcher)
await scheduler._dispatch_one(ids.item_id)
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ids.item_id)
return start_print, delete_file, item
def _sent_mapping(start_print):
assert start_print.call_count == 1, "the print command was never sent"
return json.loads(start_print.call_args.kwargs["nozzle_mapping"])
class TestAPickThatStillFits:
async def test_the_chosen_positions_reach_the_printer(self, rack_case):
"""Picking R1 for group 2 and R2 for group 1 is BambuStudio's own
dispatch of this plate on 2026-08-14: nozzle_mapping [16, 1, 17].
"""
ids = await rack_case.build("H2C", {"2": 1, "1": 2})
start_print, _, item = await _dispatch(rack_case, ids, _rack())
assert _sent_mapping(start_print)[:3] == [16, 1, 17]
assert item.status == "printing"
async def test_a_different_pick_of_the_same_plate_sends_a_different_mapping(self, rack_case):
"""The 2026-08-13 dispatch of the identical file: [16, 1, 18]."""
ids = await rack_case.build("H2C", {"2": 1, "1": 3})
start_print, _, _ = await _dispatch(rack_case, ids, _rack())
assert _sent_mapping(start_print)[:3] == [16, 1, 18]
class TestNoPickAtAll:
async def test_positions_are_assigned_rather_than_left_to_the_firmware(self, rack_case):
"""The plate that used to dispatch with no mapping now gets one."""
ids = await rack_case.build("H2C", None)
start_print, _, item = await _dispatch(rack_case, ids, _rack())
assert _sent_mapping(start_print)[:3] == [17, 1, 16]
assert item.status == "printing"
async def test_an_unassignable_plate_falls_back_instead_of_failing(self, rack_case):
"""Nothing was promised, so nothing is broken by letting the firmware
pick -- exactly what happened before this feature existed.
"""
ids = await rack_case.build("H2C", None)
start_print, _, item = await _dispatch(rack_case, ids, _rack(present=()))
assert start_print.call_count == 1
assert start_print.call_args.kwargs["nozzle_mapping"] is None
assert item.status == "printing"
class TestAPickThatNoLongerFits:
"""Someone re-loaded the rack between queueing and dispatch."""
async def test_the_print_is_refused_rather_than_sent_to_another_hotend(self, rack_case):
ids = await rack_case.build("H2C", {"2": 1, "1": 3})
start_print, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
start_print.assert_not_called()
assert item.status == "failed"
async def test_the_error_names_the_position_and_says_how_to_fix_it(self, rack_case):
ids = await rack_case.build("H2C", {"2": 1, "1": 3})
_, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
assert "rack position 3" in item.error_message
assert "Edit the item" in item.error_message
async def test_the_uploaded_file_is_removed_from_the_sd_card(self, rack_case):
"""It is already uploaded by this point, and a 3MF left there is a
phantom print waiting to be started from the touchscreen.
"""
ids = await rack_case.build("H2C", {"2": 1, "1": 3})
_, delete_file, _ = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
delete_file.assert_awaited()
class TestOtherModels:
async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
"""No rack means no resolution, no refusal, and no mapping invented."""
ids = await rack_case.build("X1C", None)
start_print, _, item = await _dispatch(rack_case, ids, [])
assert start_print.call_count == 1
assert start_print.call_args.kwargs["nozzle_mapping"] is None
assert item.status == "printing"
async def test_a_stale_pick_on_a_non_rack_printer_does_not_stop_the_print(self, rack_case):
"""The column can survive a reassignment to another model; it must not
then block a printer the pick never applied to.
"""
ids = await rack_case.build("X1C", {"2": 1, "1": 3})
start_print, _, item = await _dispatch(rack_case, ids, [])
assert start_print.call_count == 1
assert item.status == "printing"