Explorar el Código

Spoolman: external-spool usage on two-holder printers (X2D, H2D)

- A print command without ams_mapping2 leaves the external spool as -1
  in the flat mapping (#3166). Spoolman mode charged the left holder
  (254), which the X2D always lists, empty or not, and since fb69a665d
  nothing at all; a spool on the right holder (255) was never charged
- Spoolman now drops a mapping that names no tray for any used slot,
  as the built-in inventory does, and reads the tray the printer fed
  from, for finished and aborted prints. It goes straight to the tray
  readings: the colour match knows no external holder
- tray_now 255 is the right-hand holder on a printer that lists one,
  ahead of last_loaded_tray in both modes: a print from that holder
  never updates it. A tray the printer loaded during the print still
  wins
- Spoolman's last_loaded_tray step takes 0-253 only, as the built-in
  inventory always has
- Both rules shared from usage_tracker
maziggy hace 1 día
padre
commit
991d2caca9

+ 1 - 0
CHANGELOG.md

@@ -199,6 +199,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman: an external-spool print on an X2D or H2D is charged to the holder it ran from (#3405, reported by @juandelarossa)** — A print command that carries no `ams_mapping2` (#3166) writes the external spool as -1. In Spoolman mode that charged the left holder (254) whenever the printer listed it, which the X2D always does, empty or not, and in the daily builds no spool at all, so a spool on the right holder (255) was never charged. The built-in inventory treats such a mapping as no mapping and lets the printer's `tray_now` decide, and Spoolman mode now does the same, for finished and for aborted prints. In both modes `tray_now` 255 now also counts as the right-hand holder ahead of the last tray loaded before the print, which a print from that holder never updates, so the AMS tray or left holder an earlier print used was charged instead.
 - **A printer that does not answer the K-profile read is no longer a printer without K-profiles (#3404, reported by @Spegeli)** — When the printer did not answer any of the three requests, the read returned an empty table, the same as a printer with no profiles for that nozzle. The K-Profiles page then said "No K-Profiles" and offered to create the first one, and the Configure AMS Slot dialog offered only the default K. Worse, a Git restore matched the backed-up profiles against that empty table and sent every one of them as a new profile, next to the originals still on the printer. The read is now an error: the API answers HTTP 504 "The printer did not answer the K-profile request", the K-Profiles page says the profiles could not be read and offers Refresh, and the slot dialog says so above the default. A restore skips that nozzle, counts its profiles failed and says the printer did not answer. The read-back after an edit no longer has to treat an empty table as a possible timeout.
 - **The Slice dialog picks the right material for H2C filaments with OrcaSlicer preferred (#3403, reported by @pleasantone)** — Bambu Cloud lists its filament profiles without their material, which Bambuddy takes from the bundled profile of the same name. The OrcaSlicer bundle has no H2C profiles, so every "@BBL H2C" filament stayed without one, and a PETG-CF slot could be preselected as Generic PETG, also with "Only loaded spools" on. Such a profile now takes the material of the same filament's bundled profiles for other printers ("Generic PETG-CF @BBL X1C" for "Generic PETG-CF @BBL H2C 0.4 nozzle"), when they all agree. An OrcaSlicer Cloud filament saved without a material of its own takes it from the profile it was saved from.
 - **SpoolBuddy (PN5180): NTAG tags with 7-byte UIDs are read through every cascade level (#3258, contributed by @Thomansky in #3346)** - They are no longer reported as 88 + three bytes. Spools linked with the short form are still found, in the built-in inventory and in Spoolman, and their link is upgraded on the next scan.

+ 50 - 8
backend/app/services/spoolman_tracking.py

@@ -233,20 +233,35 @@ def _single_slot_tray_from_state(
     # nothing is loaded, which is why _MAX_REAL_TRAY_ID excludes it; A1
     # firmware parks there the moment a print ends, leaving last_loaded_tray
     # as the only survivor.
-    for candidate in (
-        tray_now_at_start,
-        getattr(state, "tray_now", None),
-        getattr(state, "last_loaded_tray", None),
-    ):
+    for candidate in (tray_now_at_start, getattr(state, "tray_now", None)):
         if _is_real_tray_id(candidate):
             return slot_id, candidate
+    # On a printer with two external holders (H2D, X2D) tray_now 255 is the
+    # right-hand one. Ahead of last_loaded_tray: a print from that holder never
+    # updates it, so it still names the tray an earlier print used (#3405).
+    from backend.app.services.usage_tracker import has_second_external_holder
+
+    if getattr(state, "tray_now", None) == 255 and has_second_external_holder(getattr(state, "raw_data", None)):
+        return slot_id, 255
+    # 254 is no survivor of an unload: the same cap as the built-in inventory.
+    last_loaded = getattr(state, "last_loaded_tray", None)
+    if isinstance(last_loaded, int) and _is_real_tray_id(last_loaded) and last_loaded < 254:
+        return slot_id, last_loaded
     return None
 
 
+def _mapping_names_no_used_tray(slot_to_tray: list | None, filament_usage: list[dict] | None) -> bool:
+    """The built-in inventory's rule for a mapping that is no evidence (#3405)."""
+    from backend.app.services.usage_tracker import mapping_names_no_used_tray
+
+    return mapping_names_no_used_tray(slot_to_tray, filament_usage)
+
+
 def _resolve_slot_to_tray_fallback(
     printer_id: int,
     filament_usage: list[dict],
     tray_now_at_start: int | None = None,
+    tray_state_only: bool = False,
 ) -> tuple[list[int] | None, str]:
     """Recover a slot-to-tray mapping at completion when print start captured none.
 
@@ -291,6 +306,11 @@ def _resolve_slot_to_tray_fallback(
         tray_now_at_start: The tray the printer was feeding from when the print
             began, as captured by ``store_print_data``. Only consulted by the
             tray-state fallback.
+        tray_state_only: Skip the ``mapping`` field and the colour match. For a
+            stored mapping that named no tray: the built-in inventory goes
+            straight to the printer's tray readings for one (#3405), and the
+            colour match, which knows no external holder, would hand an
+            external-spool print to a same-coloured AMS tray.
 
     Returns:
         ``(mapping, source)``, or ``(None, "none")`` when no fallback produced
@@ -305,7 +325,7 @@ def _resolve_slot_to_tray_fallback(
     # Both of the first two fallbacks read the status payload; the third reads
     # fields ``bambu_mqtt`` maintains on the state object itself, so an empty
     # payload must not short-circuit past it.
-    if raw_data:
+    if raw_data and not tray_state_only:
         decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
         if decoded:
             return decoded, "mqtt"
@@ -1407,12 +1427,19 @@ async def _report_partial_usage(
     # dispatched from Studio over the cloud left print start with no mapping to
     # store, and both paths below feed ``slot_to_tray`` to
     # ``_resolve_global_tray_id`` (#2768). An aborted print charges the wrong
-    # spool just as readily as a finished one.
+    # spool just as readily as a finished one. A mapping that names no tray
+    # for any used slot is no mapping either, as in the built-in inventory
+    # (#3405).
+    unmapped = _mapping_names_no_used_tray(slot_to_tray, filament_usage)
+    if unmapped:
+        logger.info("[SPOOLMAN] Partial usage: mapping %s names no tray for the used slots, ignoring it", slot_to_tray)
+        slot_to_tray = None
     if not slot_to_tray:
         slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(
             printer_id,
             filament_usage,
             getattr(tracking, "tray_now_at_start", None),
+            tray_state_only=unmapped,
         )
         logger.info(
             "[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
@@ -1649,8 +1676,23 @@ async def report_usage(printer_id: int, archive_id: int):
         # ``slot_to_tray``; the remain-delta path below resolves spools from the
         # AMS slot directly, so there is nothing to recover for it.
         mapping_source = "stored" if slot_to_tray else "none"
+        # A flat ams_mapping whose only used slot is an external spool's -1,
+        # sent without ams_mapping2 (#3166), names no tray at all: the
+        # fallback below reads the tray the printer fed from, as the built-in
+        # inventory does. Kept, it charged that spool nothing (#3405).
+        unmapped = _mapping_names_no_used_tray(slot_to_tray, filament_usage)
+        if unmapped:
+            logger.info(
+                "[SPOOLMAN] Archive %s: mapping %s names no tray for the used slots, ignoring it",
+                archive_id,
+                slot_to_tray,
+            )
+            slot_to_tray = None
+            mapping_source = "none"
         if filament_usage and not slot_to_tray:
-            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage, tray_now_at_start)
+            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(
+                printer_id, filament_usage, tray_now_at_start, tray_state_only=unmapped
+            )
         logger.info(
             "[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
             archive_id,

+ 59 - 22
backend/app/services/usage_tracker.py

@@ -25,6 +25,43 @@ from backend.app.services import spool_swaps
 logger = logging.getLogger(__name__)
 
 
+def has_second_external_holder(raw_data: dict | None) -> bool:
+    """Whether the printer has a second external holder (vt_tray 255).
+
+    ``tray_now`` 255 means "nothing loaded" on a printer with one external
+    holder, and the right-hand external spool on one with two (H2D, X2D),
+    which lists it in ``vt_tray``. Both inventory modes read it this way
+    (#3405).
+    """
+    for vt in (raw_data or {}).get("vt_tray") or []:
+        if not isinstance(vt, dict):
+            continue
+        try:
+            if int(vt.get("id", 0)) == 255:
+                return True
+        except (TypeError, ValueError):
+            continue
+    return False
+
+
+def mapping_names_no_used_tray(slot_to_tray: list | None, filament_usage: list[dict] | None) -> bool:
+    """Whether a slot-to-tray mapping names no tray for any slot the print used.
+
+    Such a mapping carries no evidence at all: typically a flat ams_mapping
+    whose external spool is -1 and came without an ams_mapping2 to resolve it
+    (#3166). Both inventory modes drop it, so the printer's own readings
+    (``tray_now``, the tray-change log) decide which tray fed the print
+    instead of every slot reading as unfed (#3405).
+    """
+    if not slot_to_tray:
+        return False
+    used_slots = [u.get("slot_id", 0) for u in filament_usage or [] if u.get("used_g", 0) > 0]
+    return bool(used_slots) and all(
+        not (0 < s <= len(slot_to_tray) and isinstance(slot_to_tray[s - 1], int) and slot_to_tray[s - 1] >= 0)
+        for s in used_slots
+    )
+
+
 def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
     """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
 
@@ -1635,22 +1672,15 @@ async def _track_from_3mf(
     )
 
     # A mapping that names no tray for any slot the print used carries no
-    # evidence at all -- typically a flat ams_mapping whose external spool is
-    # -1 and came without an ams_mapping2 to resolve it (#3166). Drop it so the
-    # tray_now evidence below decides, rather than leaving every slot unfed.
-    if slot_to_tray:
-        used_slots = [u.get("slot_id", 0) for u in filament_usage if u.get("used_g", 0) > 0]
-        if used_slots and all(
-            not (0 < s <= len(slot_to_tray) and isinstance(slot_to_tray[s - 1], int) and slot_to_tray[s - 1] >= 0)
-            for s in used_slots
-        ):
-            logger.info(
-                "[UsageTracker] 3MF: mapping %s names no tray for used slots %s — ignoring it",
-                slot_to_tray,
-                used_slots,
-            )
-            slot_to_tray = None
-            mapping_source = None
+    # evidence at all; see mapping_names_no_used_tray. Drop it so the tray_now
+    # evidence below decides, rather than leaving every slot unfed.
+    if mapping_names_no_used_tray(slot_to_tray, filament_usage):
+        logger.info(
+            "[UsageTracker] 3MF: mapping %s names no tray for the used slots — ignoring it",
+            slot_to_tray,
+        )
+        slot_to_tray = None
+        mapping_source = None
 
     # 5. For single-filament non-queue prints, use tray_now from printer state
     #    Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now
@@ -1693,15 +1723,22 @@ async def _track_from_3mf(
         elif state and 0 <= state.tray_now <= 254:
             tray_now_override = state.tray_now
             logger.info("[UsageTracker] 3MF: using current tray_now=%d", state.tray_now)
+        elif (
+            state
+            and state.tray_now == 255
+            and has_second_external_holder(getattr(state, "raw_data", None))
+            and not any(isinstance(c, (tuple, list)) and c and 0 <= c[0] <= 254 for c in tray_changes)
+        ):
+            # 255 = "no filament" on legacy printers, but valid 2nd external
+            # spool on H2-series. Ahead of last_loaded_tray: a print from that
+            # holder never updates it, so it still names the tray an earlier
+            # print used (#3405). A tray the printer switched to during this
+            # print says the 255 is an unload after it, not the feed.
+            tray_now_override = state.tray_now
+            logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
         elif state and 0 <= state.last_loaded_tray <= 253:
             tray_now_override = state.last_loaded_tray
             logger.info("[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)", state.last_loaded_tray)
-        elif state and state.tray_now == 255:
-            # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
-            vt_tray = state.raw_data.get("vt_tray") or []
-            if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
-                tray_now_override = state.tray_now
-                logger.info("[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)")
         if tray_now_override is None:
             logger.info(
                 "[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)",

+ 207 - 0
backend/tests/unit/services/test_spoolman_unmapped_external_3405.py

@@ -0,0 +1,207 @@
+"""An X2D print from the right-hand external spool, sent without ams_mapping2 (#3405).
+
+The X2D has two external holders and lists both in ``vt_tray``, loaded or not.
+A print command that carries no ``ams_mapping2`` leaves the external spool as -1
+in the flat mapping (#3166 resolves it only from ams_mapping2). Until #3166's
+follow-up, Spoolman mode read that -1 as the first external holder present,
+254, and charged the empty left holder; since then it charged nothing. The
+built-in inventory drops a mapping that names no tray for any used slot and
+lets the printer's ``tray_now`` decide. Spoolman mode now does the same, and
+reads ``tray_now`` 255 as the right-hand holder on a printer that has one, as
+the built-in inventory does.
+
+Values are the reporter's ``/status``: 254 empty, 255 loaded with red PLA,
+``tray_now`` 255.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import spoolman_tracking
+from backend.app.services.spoolman_tracking import _single_slot_tray_from_state
+from backend.app.services.usage_tracker import has_second_external_holder, mapping_names_no_used_tray
+
+X2D_VT_TRAY = [
+    {"id": 254, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""},
+    {"id": 255, "tray_type": "PLA", "tray_color": "F72323FF", "tray_info_idx": "GFA01"},
+]
+USAGE = [{"slot_id": 1, "used_g": 12.5, "type": "PLA", "color": "#F72323"}]
+RIGHT_HOLDER_SPOOL = 77
+
+
+def _x2d_state(tray_now=255, vt_tray=X2D_VT_TRAY, last_loaded_tray=-1, tray_change_log=(), **raw):
+    return SimpleNamespace(
+        raw_data={"vt_tray": vt_tray, **raw},
+        layer_num=0,
+        total_layers=0,
+        tray_change_log=list(tray_change_log),
+        tray_now=tray_now,
+        last_loaded_tray=last_loaded_tray,
+    )
+
+
+class TestTheRules:
+    def test_a_mapping_of_only_minus_one_names_no_used_tray(self):
+        assert mapping_names_no_used_tray([-1], USAGE)
+        assert not mapping_names_no_used_tray([255], USAGE)
+        # Another slot is mapped: the -1 is a slot the print does not feed.
+        two = [*USAGE, {"slot_id": 2, "used_g": 3.0}]
+        assert not mapping_names_no_used_tray([-1, 5], two)
+        assert not mapping_names_no_used_tray(None, USAGE)
+
+    def test_255_is_a_tray_only_on_a_printer_with_two_holders(self):
+        assert has_second_external_holder({"vt_tray": X2D_VT_TRAY})
+        assert not has_second_external_holder({"vt_tray": [X2D_VT_TRAY[0]]})
+        assert not has_second_external_holder(None)
+
+    def test_tray_now_255_is_the_right_holder(self):
+        assert _single_slot_tray_from_state(_x2d_state(), USAGE, 255) == (1, 255)
+
+    @pytest.mark.parametrize("stale", [3, 254])
+    def test_the_right_holder_beats_a_tray_an_earlier_print_loaded(self, stale):
+        """A print from 255 never updates last_loaded_tray: it still names the
+        AMS tray, or the left holder, an earlier print used."""
+        state = _x2d_state(last_loaded_tray=stale)
+        assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 255)
+
+    def test_a_tray_loaded_during_the_print_still_wins_over_255(self):
+        state = _x2d_state(last_loaded_tray=3, tray_change_log=[(3, 2)])
+        assert _single_slot_tray_from_state(state, USAGE, 255) == (1, 3)
+
+    def test_last_loaded_254_is_no_survivor(self):
+        """Same cap as the built-in inventory: 0-253 only."""
+        state = _x2d_state(vt_tray=[X2D_VT_TRAY[0]], last_loaded_tray=254)
+        assert _single_slot_tray_from_state(state, USAGE, 255) is None
+
+    def test_tray_now_255_on_a_one_holder_printer_is_nothing(self):
+        assert _single_slot_tray_from_state(_x2d_state(vt_tray=[X2D_VT_TRAY[0]]), USAGE, 255) is None
+
+
+class _AsyncCtx:
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+def _slot_assignment(_printer_id, ams_id, tray_id):
+    # The reporter's spool, assigned to the holder it sits on: 255 is (255, 1).
+    return RIGHT_HOLDER_SPOOL if (ams_id, tray_id) == (255, 1) else None
+
+
+def _client():
+    client = AsyncMock()
+    client.find_spool_by_tag = AsyncMock(return_value=None)
+    client.get_spool = AsyncMock(
+        return_value={"id": RIGHT_HOLDER_SPOOL, "filament": {"color_hex": "F72323", "material": "PLA"}}
+    )
+    client.use_spool = AsyncMock()
+    return client
+
+
+def _patches(client, state, db):
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    return (
+        patch.object(spoolman_tracking, "async_session", lambda: _AsyncCtx(db)),
+        patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+        patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=client)),
+        patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SER")),
+        patch.object(
+            spoolman_tracking, "_resolve_spool_id_via_slot_assignment", AsyncMock(side_effect=_slot_assignment)
+        ),
+        patch("backend.app.services.printer_manager.printer_manager", pm),
+    )
+
+
+def _tracking(slot_to_tray):
+    return SimpleNamespace(
+        filament_usage=list(USAGE),
+        ams_trays={
+            "254": {"tray_uuid": "", "tag_uid": "", "tray_type": ""},
+            "255": {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"},
+        },
+        slot_to_tray=slot_to_tray,
+        tray_remain_start=None,
+        layer_usage=None,
+        filament_properties=None,
+        tray_now_at_start=255,
+        archive_id=12,
+    )
+
+
+class TestReportUsage:
+    @staticmethod
+    async def _run(slot_to_tray, state=None):
+        archive = SimpleNamespace(filament_color="#F72323", filament_type="PLA")
+        rows = iter([_tracking(slot_to_tray)])
+
+        def _next_row(*_args, **_kwargs):
+            result = MagicMock()
+            result.scalar_one_or_none.return_value = next(rows, archive)
+            return result
+
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=_next_row)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+        client = _client()
+        p = _patches(client, state or _x2d_state(), db)
+        with p[0], p[1], p[2], p[3], p[4], p[5]:
+            await spoolman_tracking.report_usage(printer_id=1, archive_id=12)
+        return client
+
+    @pytest.mark.asyncio
+    async def test_the_loaded_right_holder_is_charged(self):
+        client = await self._run([-1])
+
+        client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
+
+    @pytest.mark.asyncio
+    async def test_a_same_coloured_ams_tray_is_not_charged_instead(self):
+        """The colour match knows no external holder; the built-in inventory
+        never runs it for a mapping that named no tray."""
+        ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
+        client = await self._run([-1], state=_x2d_state(ams=ams))
+
+        client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
+
+    @pytest.mark.asyncio
+    async def test_a_resolved_mapping_is_unchanged(self):
+        """With ams_mapping2 the captured mapping already says 255 (#3166)."""
+        client = await self._run([255])
+
+        client.use_spool.assert_awaited_once_with(RIGHT_HOLDER_SPOOL, 12.5)
+
+
+class TestPartialUsage:
+    @pytest.mark.asyncio
+    async def test_an_aborted_print_charges_the_right_holder_too(self):
+        tracking = _tracking([-1])
+        tracking.layer_usage = {}
+        # A same-coloured AMS tray must not take it either, as on completion.
+        ams = [{"id": 0, "tray": [{"id": 0, "tray_color": "F72323FF", "tray_type": "PLA"}]}]
+        state = _x2d_state(ams=ams)
+        state.layer_num, state.total_layers = 50, 100
+        report = AsyncMock(return_value=1)
+        p = _patches(_client(), state, AsyncMock())
+        with (
+            p[0],
+            p[1],
+            p[2],
+            p[3],
+            p[4],
+            p[5],
+            patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
+        ):
+            await spoolman_tracking._report_partial_usage(1, tracking, last_progress=50)
+
+        report.assert_awaited()
+        slot_to_tray = report.await_args.args[3]
+        assert spoolman_tracking._resolve_global_tray_id(1, slot_to_tray, {254: {}, 255: {}}) == 255

+ 23 - 3
backend/tests/unit/test_external_spool_usage_3166.py

@@ -159,7 +159,9 @@ RAW_DATA = {
 }
 
 
-async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255):
+async def _track(
+    ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255, last_loaded_tray=-1, tray_change_log=()
+):
     charged: list[tuple[int, int]] = []
 
     async def resolve(printer_id, ams_id, tray_id, **kwargs):
@@ -171,8 +173,8 @@ async def _track(ams_mapping, filament_usage, tray_now_at_start=-1, tray_now=255
         progress=100,
         layer_num=50,
         tray_now=tray_now,
-        last_loaded_tray=-1,
-        tray_change_log=[],
+        last_loaded_tray=last_loaded_tray,
+        tray_change_log=list(tray_change_log),
         raw_data=RAW_DATA,
     )
     with (
@@ -228,3 +230,21 @@ class TestTheTrackerCharges:
         results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255)
         assert (0, 0) not in charged
         assert [r["spool_id"] for r in results] == [4]
+
+    @pytest.mark.asyncio
+    async def test_the_right_holder_beats_a_tray_an_earlier_print_loaded(self):
+        # #3405: a print from the right holder never updates last_loaded_tray,
+        # so it still names AMS slot 1 from the print before.
+        results, charged = await _track([-1], ABS_ONLY, tray_now_at_start=255, last_loaded_tray=0)
+        assert (0, 0) not in charged
+        assert [r["spool_id"] for r in results] == [4]
+
+    @pytest.mark.asyncio
+    async def test_a_tray_loaded_during_the_print_still_wins_over_255(self):
+        # An H2D reads 255 at start and end of an AMS print; the tray it
+        # loaded in between is in the tray-change log.
+        usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}]
+        results, charged = await _track(
+            [-1], usage, tray_now_at_start=255, last_loaded_tray=0, tray_change_log=[(0, 2)]
+        )
+        assert [r["spool_id"] for r in results] == [20]