Browse Source

RFID: a tag read takes the spool out of its other slots (issue #3427)

- When an AMS read a spool's tag, the auto-assign wrote that slot and
  left any other slot the spool was assigned to: a spool assigned by
  hand to an empty slot of printer A and then loaded into printer B
  stayed in both, so A's slot was configured from it and its prints
  were charged to a spool sitting in B.
- New services/spool_relocation.release_other_slots does what a
  manual move does to the other slots, with the same locks in the
  same order (spool move lock, then printer locks by id), after the
  caller let go of its own printer lock. Released slots get no MQTT
  command and are announced.
- Internal: only for a spool already known by the tag, not one found
  by a material/colour match. Spoolman: from write_slot_results for
  slots it wrote that report a tag; the old slot's fallback tag is
  cleared from the spool, as in a Spoolman move.
- Kept: a slot whose printer reports the same tag right now, and
  everything when the read slot holds another spool by then.
maziggy 13 hours ago
parent
commit
6a6158c94c

+ 1 - 0
CHANGELOG.md

@@ -216,6 +216,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
+- **A spool whose tag is read in a slot leaves its other slots (#3427, reported by @Spegeli)** — When an AMS read a spool's tag, Bambuddy assigned the spool to that slot and left any other slot it was assigned to alone. A spool assigned by hand to an empty slot of one printer and then loaded into another was assigned to both: the first printer's slot was configured from it once a spool went in there, and its prints were charged to a spool sitting in the other printer. A tag read now takes the spool out of its other slots, as moving it by hand does; those slots keep what the printer has set, as after unassigning. A slot whose printer reports the same tag keeps its assignment. Internal inventory and Spoolman alike.
 - **AMS Filament Backup pools spools the printer itself treats as backups (#1824, reported by @lightmaster)** — With Filament Backup on, a print short on its assigned spool is only held when all spools of that filament together are short. Which spools count as the same filament was decided from the spools in the inventory, by their preset as stored: a spool created from its RFID tag stores `GFA10`, one set up from the preset picker `GFSA10_02` or a user preset of its own, so two rolls of the same PLA Tough+ never pooled, and Start reported "Insufficient filament for the assigned spool" for a 194 g print with 142 g in one slot and 1000 g in the other, while the printer listed both for Auto Refill. The pool now follows what the printer reports for each tray, its filament preset and colour, which is what it switches on; the remaining grams still come from the inventory. Internal inventory and Spoolman alike, for the queue's Start, the dispatcher and the print dialog's warning.
 - **One plate of a multi-plate file is measured against that plate (#3375, reported by @sargonas)** — The slicer's estimate on an archive adds all plates of the file up, so printing one plate of a three-plate file showed +200 % on the card, an archive printed more than once showed no badge at all, and statistics only kept time accuracies between 50 % and 200 %. An archive now keeps each plate's estimate from its 3MF (archives made before get theirs in the background after the update), and each print is measured against the plate it printed: the card's badge compares the latest print with its plate's estimate, the archive window shows each print's duration and filament next to its plate's estimate, the statistics' time accuracy goes by each print's plate, and the print start and queue job started notifications give the plate's time (the printer's own remaining time when the plate of a multi-plate file is not known). Moving a queued job to another plate now also updates the time shortest-job-first orders it by. A print of a multi-plate file whose plate is not known gets no estimate rather than the whole file's. Wiki updated.
 - **Git backup: prints come back with their archives** — A Git backup held archives only, so restoring onto a rebuilt instance gave archives without their prints: statistics and the Print Log came back empty, and each print's printer, plate, outcome, cost and energy was lost. The backup now also writes each print (`archives/print_runs.json`) and which archive was merged into which (`archives/aliases.json`), and **Print archives** restores both: each print is attached to its restored archive, which then shows its latest print's outcome. An archive printed again since the backup is recognised by when it was archived and its prints' start times instead of being added a second time, and an archive merged into another, before or after the backup, is not restored as one of its own, with its spool usage going to the archive it went into. A print running when the backup was made comes back cancelled, like its archive. The one-tap confirm link, photos, timelapses and wallet links are not in the backup. Older backups restore as before. Wiki updated.

+ 19 - 0
backend/app/main.py

@@ -3273,6 +3273,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
 
     await _unlink_stale_assignments(printer_id, ams_data, printing_now)
 
+    # Spools whose tag this pass assigned to a slot; their other slots are
+    # released below, once this printer's lock is let go (#3427).
+    rfid_assigned: list[tuple[int, tuple[int, int, int]]] = []
+
     # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS).
     # Serialised per-printer via services/ams_assignment_lock: MQTT bursts can deliver
     # two AMS pushes ~30 ms apart, and without the lock both callbacks read
@@ -3385,6 +3389,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                             # BL spool with RFID tag: auto-match → inventory match → auto-create
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
+                            # Only a spool already known by this tag is proven to be
+                            # here; a material/colour match or a new spool is not
+                            # grounds to take it out of other slots (#3427).
+                            known_by_tag = spool is not None
                             if not spool and await tag_belongs_to_archived_spool(db, tag_uid, tray_uuid):
                                 # An archived spool still in the AMS: the slot
                                 # stays free rather than adopting its tag.
@@ -3425,6 +3433,8 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 tray_info_idx=tray_info_idx,
                             )
                             await db.commit()
+                            if known_by_tag:
+                                rfid_assigned.append((spool.id, (printer_id, ams_id, tray_id)))
                             await ws_manager.broadcast(
                                 {
                                     "type": "spool_auto_assigned",
@@ -3463,6 +3473,15 @@ async def on_ams_change(printer_id: int, ams_data: list):
     except Exception as e:
         logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
 
+    if rfid_assigned:
+        from backend.app.services.spool_relocation import INTERNAL, release_other_slots
+
+        for spool_id, slot in rfid_assigned:
+            try:
+                await release_other_slots(INTERNAL, spool_id, slot)
+            except Exception as e:
+                logger.warning("Releasing spool %s from its other slots failed: %s", spool_id, e, exc_info=True)
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting

+ 4 - 1
backend/app/services/filament_deficit.py

@@ -293,7 +293,10 @@ def _live_tray_keys(printer_id: int) -> dict[int, str]:
 
     # Free-form MQTT payload on the dispatch path: a malformed unit or tray
     # costs that slot its live key, never the check.
-    for unit in raw.get("ams") or []:
+    units = raw.get("ams") or []
+    if isinstance(units, dict):  # also stored as the push's {"ams": [...]}
+        units = units.get("ams") or []
+    for unit in units:
         if not isinstance(unit, dict):
             continue
         for tray in unit.get("tray") or []:

+ 161 - 0
backend/app/services/spool_relocation.py

@@ -0,0 +1,161 @@
+"""Taking a spool out of its other slots once an AMS has read its tag (#3427).
+
+A tag read in a slot says where the physical spool is, so any other slot the
+spool is still assigned to is stale. The RFID auto-assign and the Spoolman AMS
+sync wrote the slot they read and left those rows alone: a spool assigned by
+hand to printer A's empty slot and then loaded into printer B stayed assigned
+to both, and A's slot was configured from, and charged to, a spool in B.
+
+This does what a manual move (#1574) does to the other slots, with the same
+locks in the same order: the spool's move lock, then, for the internal
+inventory, each holding printer's assignment lock by id. It must run after the
+caller has let go of its own printer's lock; taking another printer's lock
+while holding one is the reverse of a move's order.
+
+The removed slots get no MQTT command and keep what the printer has set, as a
+manual unassign does. A Spoolman spool also loses a vacated slot's fallback
+tag, as in a Spoolman move. Callers only pass spools already known by the tag
+read: a material/colour match proves nothing about where a spool is.
+"""
+
+from __future__ import annotations
+
+import logging
+from contextlib import AsyncExitStack
+
+from sqlalchemy import select
+
+from backend.app.core.websocket import ws_manager
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
+from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
+
+logger = logging.getLogger(__name__)
+
+Slot = tuple[int, int, int]  # (printer_id, ams_id, tray_id)
+
+INTERNAL = "inventory"
+SPOOLMAN = "spoolman"
+
+
+def _live_tray(printer_id: int, ams_id: int, tray_id: int) -> dict | None:
+    """The tray the printer reports for a slot, or None."""
+    from backend.app.services.printer_manager import printer_manager
+
+    state = printer_manager.get_status(printer_id)
+    raw = getattr(state, "raw_data", None) if state is not None else None
+    if not isinstance(raw, dict):
+        return None
+    if ams_id == 255:
+        vt_trays = raw.get("vt_tray") or []
+        for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
+            if isinstance(tray, dict) and str(tray.get("id", 254)) == str(254 + tray_id):
+                return tray
+        return None
+    units = raw.get("ams") or []
+    if isinstance(units, dict):  # also stored as the push's {"ams": [...]}
+        units = units.get("ams") or []
+    for unit in units:
+        if not isinstance(unit, dict) or str(unit.get("id")) != str(ams_id):
+            continue
+        for tray in unit.get("tray") or []:
+            if isinstance(tray, dict) and str(tray.get("id")) == str(tray_id):
+                return tray
+    return None
+
+
+def _tag_ids(tray: dict | None) -> set[str]:
+    """The tag's usable identifiers (tag_uid, tray_uuid), normalised; empty when untagged."""
+    if not tray:
+        return set()
+    ids = set()
+    uid = normalize_tag_uid(tray.get("tag_uid") or "")
+    if uid and uid != "0" * len(uid):
+        ids.add(f"uid:{uid}")
+    uuid = normalize_tray_uuid(tray.get("tray_uuid") or "")
+    if uuid and uuid != "0" * len(uuid):
+        ids.add(f"uuid:{uuid}")
+    return ids
+
+
+async def _clear_fallback_tag(db, spool_id: int, vacated: list[Slot]) -> None:
+    """Clear the Spoolman spool's extra.tag when it is a vacated slot's fallback tag.
+
+    As a Spoolman move does (#1574): the tag lookup, which usage tracking asks
+    before the slot table, would otherwise keep finding the spool for the old
+    slot. Best effort: the rows are already gone, and a failure is logged.
+    """
+    from backend.app.api.routes.spoolman_inventory import _clear_vacated_slot_fallback_tag
+    from backend.app.services.spoolman import get_spoolman_client
+
+    try:
+        client = await get_spoolman_client()
+        spool = await client.get_spool(spool_id) if client else None
+        if client and spool:
+            await _clear_vacated_slot_fallback_tag(client, db, spool_id=spool_id, spool=spool, vacated=vacated)
+    except Exception as e:
+        logger.warning("Could not clear the old slot's fallback tag from Spoolman spool %s: %s", spool_id, e)
+
+
+async def release_other_slots(inventory: str, spool_id: int, keep: Slot) -> list[Slot]:
+    """Unassign ``spool_id`` from every slot but ``keep``, where a tag was just read.
+
+    Only acts while ``keep`` reports a tag and still holds the spool. A slot
+    whose printer reports the same tag right now keeps its assignment: one tag
+    in two slots is a misread, and taking turns removing each other would be
+    worse than leaving both. Returns the slots it removed, already announced.
+    """
+    from backend.app.core.database import async_session
+
+    tag = _tag_ids(_live_tray(*keep))
+    if not tag:
+        return []
+
+    model = SpoolAssignment if inventory == INTERNAL else SpoolmanSlotAssignment
+    spool_col = SpoolAssignment.spool_id if inventory == INTERNAL else SpoolmanSlotAssignment.spoolman_spool_id
+
+    async def _holders() -> set[int]:
+        async with async_session() as db:
+            rows = await db.execute(select(model.printer_id).where(spool_col == spool_id).distinct())
+            return set(rows.scalars())
+
+    # Spoolman slots have no printer lock to take, as a Spoolman move takes none.
+    locked = ({keep[0]} | await _holders()) if inventory == INTERNAL else set()
+    vacated: list[Slot] = []
+    while True:
+        async with AsyncExitStack() as locks:
+            await locks.enter_async_context(get_spool_move_lock(inventory, spool_id))
+            for pid in sorted(locked):
+                await locks.enter_async_context(get_ams_assignment_lock(pid))
+            async with async_session() as db:
+                rows = (await db.execute(select(model).where(spool_col == spool_id))).scalars().all()
+                if inventory == INTERNAL and not {row.printer_id for row in rows} <= locked:
+                    locked |= {row.printer_id for row in rows}
+                    continue
+                if keep not in {(row.printer_id, row.ams_id, row.tray_id) for row in rows}:
+                    # Reassigned since the read: whatever holds the slot now decides.
+                    return []
+                for row in rows:
+                    slot = (row.printer_id, row.ams_id, row.tray_id)
+                    if slot == keep or _tag_ids(_live_tray(*slot)) & tag:
+                        continue
+                    vacated.append(slot)
+                    await db.delete(row)
+                await db.commit()
+                if inventory == SPOOLMAN and vacated:
+                    await _clear_fallback_tag(db, spool_id, vacated)
+        break
+
+    if vacated:
+        logger.info(
+            "Tag of spool %s read on printer %d AMS%d-T%d: unassigned it from %s",
+            spool_id,
+            *keep,
+            ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
+        )
+    for printer_id, ams_id, tray_id in vacated:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )
+    return vacated

+ 23 - 0
backend/app/services/spoolman_slot_sync.py

@@ -45,6 +45,7 @@ async def write_slot_results(
     against, and every slot is written as it was before the compare existed. A
     swap and a removal must not be dropped for want of a snapshot.
     """
+    synced = list(synced)  # read again by _release_elsewhere
     if read is None:
         return await _write_unconditionally(db, synced, emptied)
     changed: list[Slot] = []
@@ -98,6 +99,7 @@ async def write_slot_results(
             changed.append(slot)
     await db.commit()
     await _announce(assigned, changed)
+    await _release_elsewhere(synced, assigned)
     return changed
 
 
@@ -131,9 +133,30 @@ async def _write_unconditionally(
     await db.commit()
     changed = assigned + removed
     await _announce(assigned, changed)
+    await _release_elsewhere(synced, assigned)
     return changed
 
 
+async def _release_elsewhere(synced: Iterable[tuple[int, int, int, int]], assigned: list[Slot]) -> None:
+    """Take each spool this write put in a slot out of its other slots (#3427).
+
+    Only where the slot reports a tag, which ``release_other_slots`` checks:
+    the sync also fills slots from what they held before, which proves
+    nothing about where the spool is. A failure leaves the assignment written.
+    """
+    from backend.app.services.spool_relocation import SPOOLMAN, release_other_slots
+
+    written = set(assigned)
+    for printer_id, ams_id, tray_id, spool_id in synced:
+        slot = (printer_id, ams_id, tray_id)
+        if slot not in written:
+            continue
+        try:
+            await release_other_slots(SPOOLMAN, spool_id, slot)
+        except Exception as e:
+            logger.warning("Releasing Spoolman spool %s from its other slots failed: %s", spool_id, e, exc_info=True)
+
+
 async def _announce(assigned: list[Slot], changed: list[Slot]) -> None:
     for printer_id, ams_id, tray_id in assigned:
         slot_unlink_grace.forget_slot(printer_id, ams_id, tray_id)

+ 334 - 0
backend/tests/integration/test_rfid_releases_other_slots_3427.py

@@ -0,0 +1,334 @@
+"""A tag read in a slot takes the spool out of its other slots (#3427).
+
+A spool assigned by hand to printer A's empty slot, then loaded into printer B,
+stayed assigned to both once B read its tag: A's slot was configured from, and
+charged to, a spool sitting in B. A manual move already took a spool out of its
+other slots (#1574); the RFID auto-assign and the Spoolman AMS sync did not.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services import spool_relocation
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+UUID = "2E5476BB23AA431296203D76D312DA6F"
+TAG = "0D19333900000100"
+
+
+def _tray(tray_id: int, *, tagged: bool = True) -> dict:
+    return {
+        "id": tray_id,
+        "tray_type": "PLA",
+        "tray_color": "AFB1AEFF",
+        "tray_info_idx": "GFA10",
+        "tray_uuid": UUID if tagged else "0" * 32,
+        "tag_uid": TAG if tagged else "0" * 16,
+        "remain": 100,
+    }
+
+
+def _status(by_printer: dict[int, list], *, wrapped: bool = False):
+    """``get_status`` answering each printer with its own AMS report: the list
+    the MQTT client stores, or with ``wrapped`` the push's ``{"ams": [...]}``."""
+    return lambda pid: SimpleNamespace(
+        raw_data={"ams": {"ams": by_printer.get(pid, [])} if wrapped else by_printer.get(pid, [])},
+        state="IDLE",
+        connected=True,
+    )
+
+
+async def _rows(db_session, model=SpoolAssignment) -> set[tuple[int, int, int]]:
+    db_session.expire_all()
+    rows = (await db_session.execute(select(model))).scalars().all()
+    return {(r.printer_id, r.ams_id, r.tray_id) for r in rows}
+
+
+async def test_a_tag_read_on_printer_b_releases_printer_as_slot(test_engine, db_session, printer_factory):
+    """The reporter's case, through the real AMS-update handler."""
+    from backend.app.main import on_ams_change
+
+    a_id = (await printer_factory()).id
+    b_id = (await printer_factory()).id
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0, rgba="AFB1AEFF", tray_uuid=UUID, tag_uid=TAG)
+    db_session.add(spool)
+    await db_session.flush()
+    spool_id = spool.id
+    # Assigned by hand to A's empty slot, waiting for a spool.
+    db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
+    await db_session.commit()
+
+    b_ams = [{"id": 0, "tray": [_tray(0)]}]
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    status = _status({b_id: b_ams}, wrapped=True)
+    with (
+        patch("backend.app.main.async_session", real_db),
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.services.printer_manager.printer_manager.get_status", status),
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
+        pm.get_status.side_effect = status
+        pm.get_client.return_value = MagicMock()
+        pm.get_model.return_value = "X1C"
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        relocation_ws.broadcast = AsyncMock()
+
+        await on_ams_change(b_id, b_ams)
+
+    assert await _rows(db_session) == {(b_id, 0, 0)}
+    relocation_ws.broadcast.assert_awaited_once_with(
+        {"type": "spool_assignment_changed", "printer_id": a_id, "ams_id": 0, "tray_id": 1}
+    )
+
+
+async def test_a_spool_matched_by_material_is_not_taken_from_its_slot(test_engine, db_session, printer_factory):
+    """An unknown tag linked to an untagged spool of the same filament proves
+    nothing about where that spool is: two identical rolls, one in each printer."""
+    from backend.app.main import on_ams_change
+
+    a_id = (await printer_factory()).id
+    b_id = (await printer_factory()).id
+    spool = Spool(material="PLA", brand="Bambu Lab", label_weight=1000, weight_used=0, rgba="AFB1AEFF")
+    db_session.add(spool)
+    await db_session.flush()
+    spool_id = spool.id
+    db_session.add(SpoolAssignment(spool_id=spool_id, printer_id=a_id, ams_id=0, tray_id=1))
+    await db_session.commit()
+
+    b_ams = [{"id": 0, "tray": [_tray(0)]}]
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    status = _status({b_id: b_ams})
+    with (
+        patch("backend.app.main.async_session", real_db),
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.services.printer_manager.printer_manager.get_status", status),
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.services.spool_relocation.ws_manager") as relocation_ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="X1C", serial_number="B")
+        pm.get_status.side_effect = status
+        pm.get_client.return_value = MagicMock()
+        pm.get_model.return_value = "X1C"
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        relocation_ws.broadcast = AsyncMock()
+
+        await on_ams_change(b_id, b_ams)
+
+    db_session.expire_all()
+    linked = (await db_session.execute(select(Spool).where(Spool.id == spool_id))).scalar_one()
+    assert linked.tray_uuid == UUID  # the match did happen...
+    assert (a_id, 0, 1) in await _rows(db_session)  # ...and A keeps its spool
+
+
+class TestReleaseOtherSlots:
+    async def _setup(self, db_session, printer_factory, slots):
+        printers = [(await printer_factory()).id for _ in range(2)]
+        spool = Spool(material="PLA", label_weight=1000, weight_used=0)
+        db_session.add(spool)
+        await db_session.flush()
+        spool_id = spool.id
+        for printer_idx, ams_id, tray_id in slots:
+            db_session.add(
+                SpoolAssignment(spool_id=spool_id, printer_id=printers[printer_idx], ams_id=ams_id, tray_id=tray_id)
+            )
+        await db_session.commit()
+        return printers, spool_id
+
+    async def _release(self, test_engine, reports, spool_id, keep, inventory=spool_relocation.INTERNAL):
+        real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        with (
+            patch("backend.app.core.database.async_session", real_db),
+            patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
+            patch("backend.app.services.spool_relocation.ws_manager") as ws,
+        ):
+            ws.broadcast = AsyncMock()
+            return await spool_relocation.release_other_slots(inventory, spool_id, keep)
+
+    async def test_releases_every_other_slot(self, test_engine, db_session, printer_factory):
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (0, 1, 2), (1, 0, 0)])
+        vacated = await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
+        assert sorted(vacated) == [(a, 0, 1), (a, 1, 2)]
+        assert await _rows(db_session) == {(b, 0, 0)}
+
+    async def test_a_slot_reporting_the_same_tag_keeps_its_assignment(self, test_engine, db_session, printer_factory):
+        """One tag in two slots is a misread: neither takes the other's row."""
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
+        reports = {a: [{"id": 0, "tray": [_tray(0), _tray(1)]}], b: [{"id": 0, "tray": [_tray(0)]}]}
+        assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
+        assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
+
+    async def test_nothing_without_a_tag_in_the_kept_slot(self, test_engine, db_session, printer_factory):
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
+        reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
+        assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
+        assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
+
+    async def test_nothing_once_the_kept_slot_holds_another_spool(self, test_engine, db_session, printer_factory):
+        """Reassigned since the read: the old read decides nothing."""
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1)])
+        other = Spool(material="PLA", label_weight=1000, weight_used=0)
+        db_session.add(other)
+        await db_session.flush()
+        db_session.add(SpoolAssignment(spool_id=other.id, printer_id=b, ams_id=0, tray_id=0))
+        await db_session.commit()
+        reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
+        assert await self._release(test_engine, reports, spool_id, (b, 0, 0)) == []
+        assert await _rows(db_session) == {(a, 0, 1), (b, 0, 0)}
+
+    async def test_external_slot_is_read_from_vt_tray(self, test_engine, db_session, printer_factory):
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 255, 0)])
+        real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        status = lambda pid: SimpleNamespace(raw_data={"vt_tray": [{**_tray(0), "id": "254"}]} if pid == b else {})  # noqa: E731
+        with (
+            patch("backend.app.core.database.async_session", real_db),
+            patch("backend.app.services.printer_manager.printer_manager.get_status", status),
+            patch("backend.app.services.spool_relocation.ws_manager") as ws,
+        ):
+            ws.broadcast = AsyncMock()
+            vacated = await spool_relocation.release_other_slots(spool_relocation.INTERNAL, spool_id, (b, 255, 0))
+        assert vacated == [(a, 0, 1)]
+
+    async def test_takes_the_move_lock_then_printer_locks_in_id_order(self, test_engine, db_session, printer_factory):
+        """A move's order (#1574): any other order can deadlock against one."""
+        (a, b), spool_id = await self._setup(db_session, printer_factory, [(0, 0, 1), (1, 0, 0)])
+        taken = []
+        real_move, real_printer = spool_relocation.get_spool_move_lock, spool_relocation.get_ams_assignment_lock
+
+        def move_lock(inventory, spool_id):
+            taken.append(("spool", spool_id))
+            return real_move(inventory, spool_id)
+
+        def printer_lock(pid):
+            taken.append(("printer", pid))
+            return real_printer(pid)
+
+        with (
+            patch("backend.app.services.spool_relocation.get_spool_move_lock", move_lock),
+            patch("backend.app.services.spool_relocation.get_ams_assignment_lock", printer_lock),
+        ):
+            await self._release(test_engine, {b: [{"id": 0, "tray": [_tray(0)]}]}, spool_id, (b, 0, 0))
+        assert taken == [("spool", spool_id), ("printer", min(a, b)), ("printer", max(a, b))]
+
+
+async def test_spoolman_sync_releases_the_spools_other_slots(test_engine, db_session, printer_factory):
+    """Parity: the Spoolman AMS sync writes the slot it read the tag in and
+    takes the spool out of its other slots."""
+    from backend.app.services.spoolman_slot_sync import write_slot_results
+
+    a = (await printer_factory()).id
+    b = (await printer_factory()).id
+    db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
+    await db_session.commit()
+
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
+    with (
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
+        patch("backend.app.services.spool_relocation.ws_manager") as ws,
+        patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
+    ):
+        ws.broadcast = AsyncMock()
+        sync_ws.broadcast = AsyncMock()
+        async with real_db() as db:
+            await write_slot_results(db, {(a, 0, 1): 42}, [(b, 0, 0, 42)], [])
+
+    assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
+
+
+async def test_spoolman_sync_without_a_slot_snapshot_releases_too(test_engine, db_session, printer_factory):
+    """The write that runs when the slot table could not be read first."""
+    from backend.app.services.spoolman_slot_sync import write_slot_results
+
+    a = (await printer_factory()).id
+    b = (await printer_factory()).id
+    db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
+    await db_session.commit()
+
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    reports = {b: [{"id": 0, "tray": [_tray(0)]}]}
+    with (
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
+        patch("backend.app.services.spool_relocation.ws_manager") as ws,
+        patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
+    ):
+        ws.broadcast = AsyncMock()
+        sync_ws.broadcast = AsyncMock()
+        async with real_db() as db:
+            await write_slot_results(db, None, [(b, 0, 0, 42)], [])
+
+    assert await _rows(db_session, SpoolmanSlotAssignment) == {(b, 0, 0)}
+
+
+async def test_spoolman_sync_of_an_untagged_slot_releases_nothing(test_engine, db_session, printer_factory):
+    from backend.app.services.spoolman_slot_sync import write_slot_results
+
+    a = (await printer_factory()).id
+    b = (await printer_factory()).id
+    db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
+    await db_session.commit()
+
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    reports = {b: [{"id": 0, "tray": [_tray(0, tagged=False)]}]}
+    with (
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.services.printer_manager.printer_manager.get_status", _status(reports)),
+        patch("backend.app.services.spoolman_slot_sync.ws_manager") as sync_ws,
+    ):
+        sync_ws.broadcast = AsyncMock()
+        async with real_db() as db:
+            await write_slot_results(db, None, [(b, 0, 0, 42)], [])
+
+    assert await _rows(db_session, SpoolmanSlotAssignment) == {(a, 0, 1), (b, 0, 0)}
+
+
+async def test_spoolman_release_clears_the_old_slots_fallback_tag(test_engine, db_session, printer_factory):
+    """As a Spoolman move does: a spool linked to an untagged slot carries that
+    slot's fallback tag, and usage tracking would keep finding it there."""
+    from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+
+    printer_a = await printer_factory()
+    a, serial_a = printer_a.id, printer_a.serial_number
+    b = (await printer_factory()).id
+    db_session.add(SpoolmanSlotAssignment(printer_id=a, ams_id=0, tray_id=1, spoolman_spool_id=42))
+    db_session.add(SpoolmanSlotAssignment(printer_id=b, ams_id=0, tray_id=0, spoolman_spool_id=42))
+    await db_session.commit()
+
+    fallback = get_fallback_spool_tag_for_slot(serial_a, 0, 1)
+    client = MagicMock()
+    client.get_spool = AsyncMock(return_value={"id": 42, "extra": {"tag": f'"{fallback}"'}})
+    client.merge_spool_extra = AsyncMock()
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.core.database.async_session", real_db),
+        patch(
+            "backend.app.services.printer_manager.printer_manager.get_status",
+            _status({b: [{"id": 0, "tray": [_tray(0)]}]}),
+        ),
+        patch("backend.app.services.spool_relocation.ws_manager") as ws,
+        patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+    ):
+        ws.broadcast = AsyncMock()
+        vacated = await spool_relocation.release_other_slots(spool_relocation.SPOOLMAN, 42, (b, 0, 0))
+
+    assert vacated == [(a, 0, 1)]
+    client.merge_spool_extra.assert_awaited_once_with(42, {"tag": '""'})

+ 9 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -1532,3 +1532,12 @@ class TestBackupPoolKeys:
         reads = iter([{6: "first", 3: "first"}, {6: "second", 3: "second"}])
         with patch("backend.app.services.filament_deficit._live_tray_keys", lambda pid: next(reads)):
             assert await self._deficit(db_session, printer, item, None) == []
+
+    @pytest.mark.asyncio
+    async def test_report_stored_as_the_push_wrapper(self, db_session, printer_factory, tmp_path):
+        """``raw_data["ams"]`` may also hold the push's ``{"ams": [...]}``."""
+        printer, item = await self._reporter_setup(
+            db_session, printer_factory, tmp_path, b3_preset="PFUSaae244ee3ae94c"
+        )
+        wrapped = {"ams": {"ams": self.REPORTER_RAW["ams"]}}
+        assert await self._deficit(db_session, printer, item, wrapped) == []