Преглед на файлове

fix(queue): print a plate whose filaments are all on the external spool (issue #3087)

The reporter's P1S heated up, sat at Heatbed preheating for ten and a half
minutes, then paused with 07FF_8012, "Failed to get AMS mapping table".
Resuming only reheated it. Prints that fed from the AMS were fine.

The plate was one filament of a seven-filament MakerWorld project, mapped by
hand to the external spool. slice_info.config numbers filaments across the
whole project, so the mapping for that plate is [-1,-1,-1,-1,-1,-1,254]: six
placeholders and the spool holder. The command builder decides whether a print
needs the AMS by asking whether the mapping is entirely external, and six -1s
answer no. So the print went out as use_ams=true carrying a flat mapping of
nothing but -1 -- 254 is deliberately never sent raw, the firmware reads it as
AMS tray 0 -- which is exactly the mapping table the firmware then could not
find.

The builder cannot fix this itself. Down there a -1 is either padding for a
filament this plate does not print, which is BambuStudio's own convention, or a
slot that never resolved to a tray, and sending the second one to the spool
holder is what #2589 exists to prevent. They are the same byte.

The scheduler knows. extract_filament_requirements drops every filament with
used_g <= 0, so it names precisely the slots the plate prints. When all of those
are an explicit 254/255, dispatch now sends use_ams=false and the print runs.
When one of them resolved to nothing, the flag is left alone and the firmware
rejects the print as it does today -- deliberately, because that is the case
where guessing would print a filament in the wrong material without saying so.

Single-nozzle only, mirroring the reconcile in the command builder: on a
two-extruder printer use_ams selects which nozzle to feed rather than whether to
use the AMS, so an H2D with a spool on each side must keep the flag it was
given. Judged generously from the model name and from live telemetry -- a second
nozzle reporting a diameter, an extruder map, or more than one external feed --
because a wrong yes only preserves existing behaviour while a wrong no would
reroute the print. H2S stays single-nozzle (#1386).

Nothing in the command builder changed. Its own reconcile keeps the exact
semantics #2589, #2595 and #797 gave it, and now usually agrees with a decision
that was already made one layer up. Where the file has no parseable filament
list the mapping is left exactly as before, the same evidence-only convention
as #2771, and the parse itself sits behind a check for anything external at all
so an AMS-only print never opens the file.

Covered end to end at the dispatcher, including the reporter's seven-filament
shape, a plate mixing the spool holder with an AMS tray, a consumed slot that
never resolved, both external feeds on a dual-nozzle machine, and a 3MF with no
filament list at all.
maziggy преди 1 ден
родител
ревизия
5be18ff57a

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS.
 - **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it.
 - **Auto-drying skipped every composite spool (#3067, reported by @TheUltimateC0der)** — the reporter's AMS-HT would not dry PA6-CF, and drying the same spool by hand worked. The scheduler reduced a tray to a preset key by splitting on spaces only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset table has, and the tray was read as holding nothing worth drying. It was never only nylon: of the 41 types a printer can report, 33 had no row under that rule, and 20 of those — every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA — have a base material sitting right there. The drying popover has resolved these correctly since 1.2.5, which is exactly why doing it by hand worked; the scheduler now shares that rule, including the alias map that reads PA6, PA11, PA12, PAHT, PPA and Nylon as PA. A row you have added for the exact type still wins over the base material's, and a material whose base is not in the table either is still left alone rather than dried at a temperature nobody chose. The same lookup decides per-filament humidity thresholds and the chamber preheat target, so a composite now gets the override and the preheat that were set for its material too.
 - **An archive gave up on its 3MF for good after one slow transfer (#3063, reported by @dfrysinger)** — the reporter's P1S had the file on its card and was serving it; the 19 MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the job upload. Bambuddy wrote an empty archive and never looked again — and then downloaded that same file successfully three times in the next two minutes, throwing each copy away, because the only code that would have attached one had already run. Recovery for a fallback archive existed but was armed for exactly one give-up, the FTPS cool-off. It now also covers a transfer that ran out of time: Bambuddy comes back after one, four and ten minutes and fills the row in where an attempt lands. The two are told apart by what the printer said — a file that genuinely is not on the card is answered with 550, and that answer does not improve with waiting, so nothing is scheduled for it. Nor for a 3MF that downloaded fine but turned out to be another plate's, where retrying would put back exactly what was just discarded. The archives banner has wording for this case too, because the old text sent an owner whose card was working to switch on a setting that was already on; it names the Connection Timeout setting instead.

+ 121 - 1
backend/app/services/print_scheduler.py

@@ -64,6 +64,7 @@ from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.printer_models import (
+    is_dual_nozzle_model,
     is_gcode_compatible,
     is_nozzle_rack_model,
     normalize_printer_model,
@@ -472,6 +473,77 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
 _EXTERNAL_TRAY_ID_MIN = 254
 
 
+def _consumed_mapping_entries(mapping: list | None, required: list[dict] | None) -> list | None:
+    """The ``mapping`` entries for the slots this plate actually prints.
+
+    ``required`` comes from ``extract_filament_requirements``, which drops any
+    filament with ``used_g <= 0`` — so a slot_id present there is one the plate
+    consumes, and one absent from it is padding. That distinction is why this
+    decision lives here and not in the MQTT command builder: a ``-1`` in the
+    mapping means either "this plate does not print filament N" or "we never
+    worked out which tray", and only the plate's own filament list separates
+    them. The builder sees both as the same byte, which is how a plate whose one
+    printed filament sat on the external spool went out as `use_ams=true` with a
+    mapping of nothing but -1 and stalled at preheat until the firmware gave up
+    with 07FF_8012 (#3087).
+
+    Returns None whenever the two cannot be lined up — no mapping, no parsed
+    requirements, or a requirement the mapping is too short to cover — so every
+    caller falls back to existing behaviour rather than acting on a guess.
+    """
+    if not isinstance(mapping, list) or not mapping or not required:
+        return None
+    entries = []
+    for filament in required:
+        slot_id = filament.get("slot_id")
+        if not isinstance(slot_id, int) or not 1 <= slot_id <= len(mapping):
+            # The mapping and the requirements disagree about how many filaments
+            # the file has. They came from different reads, so judge nothing.
+            return None
+        entries.append(mapping[slot_id - 1])
+    return entries or None
+
+
+def _is_external_tray(tray_id) -> bool:
+    """True for an explicit external-spool selection (254/255), not for an
+    unresolved slot and not for an AMS tray."""
+    if tray_id is None:
+        return False
+    try:
+        return int(tray_id) >= _EXTERNAL_TRAY_ID_MIN
+    except (TypeError, ValueError):
+        return False
+
+
+def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
+    """Whether this printer could have two extruders, judged generously.
+
+    On a dual-nozzle printer ``use_ams`` is nozzle routing rather than an
+    AMS on/off flag — H2D Pro firmware reads it as an extruder index — which is
+    why the MQTT command builder skips its own use_ams reconcile there. Anything
+    that might be dual-nozzle therefore keeps whatever ``use_ams`` it arrived
+    with, external spools or not.
+
+    Deliberately over-eager: a wrong "yes" only means this printer keeps the
+    behaviour it has always had, while a wrong "no" would rewrite a field that
+    steers which nozzle prints. The model name is the first answer (it is what
+    the command builder falls back to as well), then the same live evidence the
+    dispatcher's extruder annotation uses — a second nozzle reporting a
+    diameter, a populated ``ams_extruder_map``, or more than one external feed,
+    since a single-nozzle printer has exactly one.
+    """
+    if is_dual_nozzle_model(printer_model):
+        return True
+    nozzles = getattr(status, "nozzles", None) or []
+    if len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""):
+        return True
+    raw = getattr(status, "raw_data", None) or {}
+    if raw.get("ams_extruder_map"):
+        return True
+    vt_trays = raw.get("vt_tray") or []
+    return isinstance(vt_trays, list) and len(vt_trays) > 1
+
+
 def _int_or(value, default: int) -> int:
     """``int(value)``, or ``default`` when the field is missing or junk.
 
@@ -6808,6 +6880,54 @@ class PrintScheduler:
             if slot_extruders:
                 nozzle_slot_extruders = json.dumps(slot_extruders)
 
+        # Every filament this plate prints is on the external spool -> the print
+        # must go out with use_ams=False. The firmware answers use_ams=true plus
+        # a mapping it cannot resolve with 07FF_8012 "Failed to get AMS mapping
+        # table", which is what held the reporter's P1S at Heatbed preheating
+        # for ten minutes before it gave up (#3087). The MQTT command builder
+        # already downgrades a mapping that is *only* external ([254]), but a
+        # multi-filament project pads the slots this plate does not print with
+        # -1 — BambuStudio's own convention — and down there a -1 is
+        # indistinguishable from a slot that never resolved, which must never be
+        # sent to the spool holder (#2589). Here the plate's filament list says
+        # which is which, so the answer is exact rather than a guess.
+        #
+        # Deliberately narrow: this fires only when every consumed slot is an
+        # explicit 254/255. A consumed slot that did not resolve leaves use_ams
+        # alone and the firmware still rejects the print, exactly as today. And
+        # only for single-nozzle printers, mirroring the builder's own reconcile
+        # — on a dual-nozzle machine use_ams is which extruder to feed, not
+        # whether to use the AMS, so it is not ours to rewrite.
+        effective_use_ams = item.use_ams
+        if (
+            effective_use_ams
+            and ams_mapping
+            and file_path is not None
+            # Cheap gate before opening the file: with nothing on the spool
+            # holder anywhere in the mapping, no subset of it can be all
+            # external, so most dispatches never pay for the parse. The
+            # isinstance also keeps a malformed stored mapping (a bare number
+            # from a hand-edited row) failing where it always failed, in the
+            # command builder, rather than here.
+            and isinstance(ams_mapping, list)
+            and any(_is_external_tray(t) for t in ams_mapping)
+            and not _might_be_dual_nozzle(printer.model, pre_status)
+        ):
+            from backend.app.services.filament_requirements import extract_filament_requirements
+
+            consumed = _consumed_mapping_entries(
+                ams_mapping, extract_filament_requirements(file_path, plate_id=item.plate_id or 1)
+            )
+            if consumed and all(_is_external_tray(t) for t in consumed):
+                effective_use_ams = False
+                logger.info(
+                    "Queue item %s: every filament plate %s prints is on the external spool "
+                    "(mapping %s) — dispatching with use_ams=False (#3087)",
+                    item.id,
+                    item.plate_id or 1,
+                    ams_mapping,
+                )
+
         # 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
@@ -6824,7 +6944,7 @@ class PrintScheduler:
             vibration_cali=item.vibration_cali,
             layer_inspect=item.layer_inspect,
             timelapse=effective_timelapse,
-            use_ams=item.use_ams,
+            use_ams=effective_use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping
             or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),

+ 303 - 0
backend/tests/integration/test_external_spool_use_ams_3087.py

@@ -0,0 +1,303 @@
+"""A plate printed entirely from the external spool must dispatch use_ams=False (#3087).
+
+The reporter's P1S sat at "Heatbed preheating" for ten and a half minutes and
+then paused with 07FF_8012, "Failed to get AMS mapping table". The plate was one
+filament, mapped by hand to the external spool, out of a seven-filament
+MakerWorld project -- so the mapping was ``[-1, -1, -1, -1, -1, -1, 254]`` and
+the command went out as ``use_ams: true`` with a flat mapping of nothing but
+-1 (254 is deliberately not sent raw: the firmware reads it as AMS tray 0).
+
+The decision belongs here rather than in the MQTT command builder. Down there a
+-1 is either padding for a filament this plate does not print -- BambuStudio's
+own convention, and what the other six entries are -- or a slot that never
+resolved, which must never be redirected to the spool holder (#2589). The two
+are the same byte. Only the plate's own filament list tells them apart, and
+``extract_filament_requirements`` already drops anything with ``used_g <= 0``,
+so it names exactly the slots that are printed.
+"""
+
+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
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
+
+pytestmark = pytest.mark.integration
+
+# The reporter's plate: filament 7 of a seven-filament project, and it is the
+# only one this plate consumes. slice_info.config lists a plate's filaments by
+# their project-wide id, which is why the mapping is seven long.
+_PLATE_4_ONE_FILAMENT = '<filament id="7" used_g="12.4" type="PLA" color="#F98C36"/>'
+
+
+def _write_3mf(path: Path, plate_index: int = 4, filaments: str = _PLATE_4_ONE_FILAMENT) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            f'<config><plate><metadata key="index" value="{plate_index}"/>{filaments}</plate></config>',
+        )
+
+
+def _write_3mf_without_slice_info(path: Path) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+
+
+@pytest.fixture
+async def dispatch_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 / "external-spool"
+
+    async def _build(
+        mapping, *, use_ams=True, plate_id=4, filaments=_PLATE_4_ONE_FILAMENT, slice_info=True, model="P1S"
+    ):
+        archive_rel = Path("archives") / f"plate-{plate_id}-{abs(hash(str(mapping))) % 10**6}.gcode.3mf"
+        if slice_info:
+            _write_3mf(base_dir / archive_rel, plate_index=plate_id, filaments=filaments)
+        else:
+            _write_3mf_without_slice_info(base_dir / archive_rel)
+
+        async with session_maker() as db:
+            printer = Printer(
+                name="P1S",
+                serial_number=f"01P{abs(hash(str(mapping))) % 10**9}",
+                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=archive_rel.name,
+                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=plate_id,
+                status="pending",
+                use_ams=use_ams,
+                ams_mapping=json.dumps(mapping) if mapping is not None 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, status=None):
+    scheduler = PrintScheduler()
+    start_print = MagicMock(return_value=True)
+    status = status or SimpleNamespace(state="IDLE", nozzle_rack=None, raw_data={}, nozzles=[])
+
+    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", AsyncMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+            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()),
+            discarding_spawn_patch(),
+            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)
+
+    assert start_print.call_count == 1, "the print command was never sent"
+    return start_print.call_args
+
+
+class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
+    async def test_the_reporters_mapping_dispatches_without_the_ams(self, dispatch_case):
+        """[-1]*6 + [254] on a plate whose only printed filament is #7."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+        # The mapping itself still goes out untouched — the builder is what
+        # turns 254 into -1 plus ams_mapping2, and none of that changes.
+        assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
+
+    async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+    async def test_an_unpadded_single_filament_plate_is_unaffected(self, dispatch_case):
+        """[254] already worked: the MQTT command builder downgrades an
+        all-external mapping by itself. The scheduler now reaches the same
+        answer one layer earlier, so the two agree rather than one undoing the
+        other — this pins that they do."""
+        ids = await dispatch_case.build([254], filaments='<filament id="1" used_g="9.0" type="PLA"/>', plate_id=1)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+
+class TestWhatMustNotChange:
+    async def test_a_consumed_slot_that_never_resolved_still_goes_out_with_the_ams(self, dispatch_case):
+        """The #2589 contract, and the reason this lives in the scheduler.
+
+        Filaments 1 and 7 are both printed; 7 is on the spool holder and 1
+        resolved to nothing. Redirecting the plate to the external spool would
+        print filament 1 in the wrong material without saying so. use_ams stays
+        true and the firmware rejects the print, exactly as before.
+        """
+        ids = await dispatch_case.build(
+            [-1, -1, -1, -1, -1, -1, 254],
+            filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_mixing_an_ams_tray_with_the_spool_holder_keeps_the_ams(self, dispatch_case):
+        ids = await dispatch_case.build(
+            [5, -1, -1, -1, -1, -1, 254],
+            filaments='<filament id="1" used_g="8.0" type="PETG"/>' + _PLATE_4_ONE_FILAMENT,
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_printed_from_ams_trays_is_untouched(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5])
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
+        """Promotion is the builder's job (#2595) and stays there."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 5], use_ams=False)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+
+    async def test_a_3mf_with_no_filament_list_falls_back_to_the_stored_flag(self, dispatch_case):
+        """No evidence, no decision — the same convention as #2771."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], slice_info=False)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_plate_the_file_does_not_describe_falls_back(self, dispatch_case):
+        """The item says plate 4; the file only describes plate 1."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], plate_id=4)
+        # Rewrite the archive's 3MF so its only plate is index 1.
+        async with dispatch_case.session_maker() as db:
+            archive = (await db.get(PrintQueueItem, ids.item_id)).archive_id
+            path = dispatch_case.base_dir / (await db.get(PrintArchive, archive)).file_path
+        _write_3mf(path, plate_index=1)
+
+        call = await _dispatch(dispatch_case, ids)
+        assert call.kwargs["use_ams"] is True
+
+    async def test_an_item_with_no_mapping_at_all_is_untouched(self, dispatch_case):
+        ids = await dispatch_case.build(None)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+
+class TestDualNozzleIsNotOursToRewrite:
+    """On a two-extruder printer use_ams is which nozzle to feed, not whether to
+    use the AMS — H2D Pro firmware reads it as an extruder index. The MQTT
+    command builder skips its own reconcile for exactly that reason, and this
+    must skip it too, or a perfectly normal dual external-spool print gets its
+    routing rewritten."""
+
+    async def test_a_dual_nozzle_model_keeps_its_flag(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2D")
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_both_external_feeds_on_a_dual_nozzle_are_left_alone(self, dispatch_case):
+        """254 is the deputy feed and 255 the main one — an ordinary H2D print
+        with a spool on each side, and the one this would have broken."""
+        ids = await dispatch_case.build(
+            [254, -1, -1, -1, -1, -1, 255],
+            filaments='<filament id="1" used_g="8.0" type="PLA"/>' + _PLATE_4_ONE_FILAMENT,
+            model="H2D",
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_live_telemetry_can_veto_a_single_nozzle_model_name(self, dispatch_case):
+        """A model string we do not recognise as dual is not the last word: two
+        external feeds is something only a two-extruder printer reports."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
+        status = SimpleNamespace(
+            state="IDLE",
+            nozzle_rack=None,
+            nozzles=[],
+            raw_data={"vt_tray": [{"id": "254"}, {"id": "255"}]},
+        )
+        call = await _dispatch(dispatch_case, ids, status=status)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_a_second_nozzle_reporting_a_diameter_vetoes_it_too(self, dispatch_case):
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="Something New")
+        status = SimpleNamespace(
+            state="IDLE",
+            nozzle_rack=None,
+            nozzles=[SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")],
+            raw_data={},
+        )
+        call = await _dispatch(dispatch_case, ids, status=status)
+
+        assert call.kwargs["use_ams"] is True
+
+    async def test_h2s_is_single_nozzle_and_still_gets_the_fix(self, dispatch_case):
+        """H2S shares the H2 serial prefix and firmware quirks but has one
+        extruder — the #1386 distinction, which must survive here."""
+        ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 254], model="H2S")
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False

+ 156 - 0
backend/tests/unit/test_external_spool_mapping_3087.py

@@ -0,0 +1,156 @@
+"""Which mapping entries a plate actually prints, and what the builder does
+with the answer (#3087).
+
+The dispatch-level behaviour is covered in
+``backend/tests/integration/test_external_spool_use_ams_3087.py``. This is the
+pure part: separating a padding ``-1`` from a slot that never resolved, which
+is the distinction the MQTT command builder cannot make and the reason the
+decision was put in the scheduler.
+"""
+
+import json
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+from backend.app.services.print_scheduler import (
+    _consumed_mapping_entries,
+    _is_external_tray,
+    _might_be_dual_nozzle,
+)
+
+
+def _required(*slot_ids):
+    """What `extract_filament_requirements` returns: one entry per filament the
+    plate consumes, keyed by its project-wide slot_id. Anything with
+    `used_g <= 0` is already dropped there, so everything here is printed."""
+    return [{"slot_id": s, "type": "PLA", "used_grams": 10.0} for s in slot_ids]
+
+
+class TestConsumedMappingEntries:
+    def test_it_picks_out_the_slot_the_plate_prints(self):
+        # The reporter's plate: seven project filaments, only #7 printed.
+        assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(7)) == [254]
+
+    def test_padding_is_not_reported_as_unresolved(self):
+        assert _consumed_mapping_entries([-1, 5, -1], _required(2)) == [5]
+
+    def test_a_slot_the_plate_prints_reports_its_own_unresolved_entry(self):
+        assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(1, 7)) == [-1, 254]
+
+    def test_several_printed_slots_come_back_in_slot_order(self):
+        assert _consumed_mapping_entries([4, -1, 254], _required(1, 3)) == [4, 254]
+
+    @pytest.mark.parametrize(
+        "mapping,required",
+        [
+            (None, _required(1)),
+            ([], _required(1)),
+            ([254], None),
+            ([254], []),
+        ],
+    )
+    def test_it_declines_to_answer_without_both_halves(self, mapping, required):
+        # No evidence, no decision — the caller then leaves use_ams alone.
+        assert _consumed_mapping_entries(mapping, required) is None
+
+    def test_a_requirement_the_mapping_is_too_short_for_declines(self):
+        # The two were read at different times and disagree about the file.
+        assert _consumed_mapping_entries([254], _required(7)) is None
+
+    def test_a_junk_slot_id_declines(self):
+        assert _consumed_mapping_entries([254], [{"slot_id": "7"}]) is None
+        assert _consumed_mapping_entries([254], [{"slot_id": 0}]) is None
+        assert _consumed_mapping_entries([254], [{}]) is None
+
+
+class TestIsExternalTray:
+    @pytest.mark.parametrize("tray_id", [254, 255, "254"])
+    def test_the_spool_holder(self, tray_id):
+        assert _is_external_tray(tray_id) is True
+
+    @pytest.mark.parametrize("tray_id", [None, -1, 0, 5, 253, 128, "", "x", 1.5])
+    def test_everything_else(self, tray_id):
+        # 128-253 are AMS-HT units, -1/None unresolved, and junk is not a
+        # licence to redirect a print to the spool holder.
+        assert _is_external_tray(tray_id) is False
+
+
+class TestMightBeDualNozzle:
+    """Over-eager on purpose: a wrong yes only leaves a printer with the
+    behaviour it already had, a wrong no rewrites which nozzle prints."""
+
+    def _status(self, *, nozzles=(), **raw):
+        return SimpleNamespace(nozzles=list(nozzles), raw_data=dict(raw))
+
+    @pytest.mark.parametrize("model", ["H2D", "H2C", "X2D", "H2D Pro"])
+    def test_the_model_name_is_enough(self, model):
+        assert _might_be_dual_nozzle(model, self._status()) is True
+
+    @pytest.mark.parametrize("model", ["P1S", "X1C", "A1", "P2S", "H2S", None, ""])
+    def test_single_nozzle_models_pass(self, model):
+        # H2S is the #1386 case: H2 family, one extruder.
+        assert _might_be_dual_nozzle(model, self._status()) is False
+
+    def test_two_external_feeds_give_it_away(self):
+        # Only a two-extruder printer reports more than one vt_tray.
+        assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}, {"id": "255"}])) is True
+
+    def test_one_external_feed_does_not(self):
+        assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}])) is False
+
+    def test_a_second_nozzle_with_a_diameter_gives_it_away(self):
+        nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")]
+        assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is True
+
+    def test_a_stub_second_nozzle_does_not(self):
+        # The status model can carry placeholder NozzleInfo entries; only a
+        # populated diameter means real hardware.
+        nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="")]
+        assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is False
+
+    def test_an_extruder_map_gives_it_away(self):
+        assert _might_be_dual_nozzle("P1S", self._status(ams_extruder_map={"0": 1})) is True
+
+    def test_no_status_at_all_is_not_evidence_of_two(self):
+        assert _might_be_dual_nozzle("P1S", None) is False
+
+    def test_a_vt_tray_dict_is_not_counted_as_many_trays(self):
+        # bambu_mqtt normalises vt_tray to a list, but a dict here would
+        # otherwise count its keys and read as dual-nozzle.
+        assert _might_be_dual_nozzle("P1S", self._status(vt_tray={"id": "254", "tray_type": "PLA"})) is False
+
+
+class TestTheBuilderHonoursTheDecision:
+    """The scheduler's answer has to survive the command builder, which has its
+    own use_ams reconcile (#2589/#2595). It must not promote the flag back."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="01P00A452600691", access_code="x")
+        client.model = "P1S"
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _sent(self, mqtt_client):
+        return json.loads(mqtt_client._client.publish.call_args.args[1])["print"]
+
+    def test_the_reporters_command_now_goes_out_printable(self, mqtt_client):
+        mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=False)
+        cmd = self._sent(mqtt_client)
+
+        assert cmd["use_ams"] is False
+        # 254 is still never sent raw in the flat array — the firmware reads it
+        # as AMS tray 0 — and ams_mapping2 still carries the spool holder.
+        assert cmd["ams_mapping"] == [-1] * 7
+        assert cmd["ams_mapping2"][6] == {"ams_id": 255, "slot_id": 0}
+        assert cmd["ams_mapping2"][0] == {"ams_id": 255, "slot_id": 255}
+
+    def test_the_builder_still_rejects_an_unresolved_mapping_as_external(self, mqtt_client):
+        """The #2589 contract, unchanged: nothing here treats -1 as the spool."""
+        mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=True)
+
+        assert self._sent(mqtt_client)["use_ams"] is True