瀏覽代碼

fix(spoolman): AMS syncs write only the slots whose spool they changed and keep a spool assigned meanwhile; the Sync buttons announce slot changes and update the slot preset; a new Spoolman spool is not seeded from leftover built-in assignments

maziggy 3 天之前
父節點
當前提交
fcc20cb62c

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ 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 AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:
+  - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.
+  - **The Sync buttons in Spoolman settings** changed slots without telling other open pages, and without updating the slot's filament preset, so the printer card could keep the previous spool's preset name. They now do both, as the automatic sync does.
+  - **The Sync buttons** gave a newly created Spoolman spool the remaining weight of a built-in inventory spool last assigned to that slot before the switch to Spoolman, when the printer reported no weight of its own. That leftover weight is no longer used, as in the automatic sync.
 - **Spoolman inventory: five requests refused or reported correctly** —
   - **Deleting a storage location while Spoolman was unreachable** counted its spools from the built-in inventory, found none, and deleted a location Spoolman spools still used. The delete is now refused until Spoolman answers.
   - **Printing labels for an archived Spoolman spool** failed the whole batch with "not found", as Spoolman leaves archived spools out of its plain spool list.

+ 68 - 65
backend/app/api/routes/spoolman.py

@@ -29,6 +29,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
 from backend.app.services import slot_unlink_grace
 from backend.app.services.ams_slot_presence import spool_present
+from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
@@ -41,6 +42,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.services.spoolman_slot_sync import write_slot_results
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -233,6 +235,24 @@ def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
     return spool_present(tray_data) is not True
 
 
+async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id: int, ams_id: int) -> None:
+    """The slot's preset row follows the spool synced into it, as in the
+    automatic pass: the slot card reads that row ahead of the live tray, so it
+    kept the previous spool's preset name (SSA-2)."""
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spoolman_spool
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db,
+        spoolman_spool=spool,
+        tray_info_idx=tray.tray_info_idx or "",
+        tray_sub_brands=tray.tray_sub_brands or "",
+        tray_type=tray.tray_type or "",
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray.tray_id,
+    )
+
+
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
     printer_id: int,
@@ -327,30 +347,36 @@ async def sync_printer_ams(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
 
-    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+    # Load inventory weights as fallback (when AMS MQTT data lacks remain values).
+    # Inert in Spoolman mode, as in the automatic pass (main.on_ams_change): the
+    # built-in slot rows are left over from before the switch, and a new Spoolman
+    # spool would be seeded with an unrelated weight (SM-C3).
     inv_weights: dict[tuple[int, int], float] = {}
-    try:
-        assign_result = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == printer_id)
-        )
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(SpoolAssignment.printer_id == printer_id)
+            )
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
     # Load existing Spoolman slot assignments for the no-RFID fallback path
     spoolman_slot_map: dict[tuple[int, int], int] = {}
+    slot_map_read = False
     try:
         slot_result = await db.execute(
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
         )
         for slot in slot_result.scalars().all():
             spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
@@ -405,6 +431,7 @@ async def sync_printer_ams(
                         if not spool_exists:
                             cached_spools.append(sync_result)
                             logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                        await _refresh_slot_preset(db, sync_result, tray, printer_id, ams_id)
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
@@ -433,29 +460,18 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 errors.append(error_msg)
 
-    # Persist slot assignment changes to the local table
+    # Persist slot assignment changes to the local table: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if slot_changes or empty_slots:
         try:
-            for ams_id, tray_id, spool_id in slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for ams_id, tray_id in empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(
+                db,
+                {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                if slot_map_read
+                else None,
+                [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+            )
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
@@ -522,24 +538,28 @@ async def sync_all_printers(
 
     # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
     # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
+    # Inert in Spoolman mode, as in sync_printer_ams above (SM-C3).
     inventory_weights: dict[tuple[int, int, int], float] = {}
-    try:
-        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory assignments for weight fallback: %s", e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
     # Load all Spoolman slot assignments for the no-RFID fallback
     # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
     all_slot_map: dict[tuple[int, int, int], int] = {}
+    all_slot_map_read = False
     try:
         slot_result = await db.execute(select(SpoolmanSlotAssignment))
         for slot in slot_result.scalars().all():
             all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        all_slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments: %s", e)
 
@@ -625,6 +645,7 @@ async def sync_all_printers(
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                            await _refresh_slot_preset(db, sync_result, tray, printer.id, ams_id)
                     elif spool_tag and not auto_add_unknown_rfid:
                         all_skipped.append(
                             SkippedSpool(
@@ -648,29 +669,11 @@ async def sync_all_printers(
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
-    # Persist slot assignment changes across all printers
+    # Persist slot assignment changes across all printers: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if all_slot_changes or all_empty_slots:
         try:
-            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for p_id, ams_id, tray_id in all_empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == p_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(db, all_slot_map if all_slot_map_read else None, all_slot_changes, all_empty_slots)
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments: %s", e)

+ 18 - 46
backend/app/main.py

@@ -3242,12 +3242,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
 
             # Load existing Spoolman slot assignments for the no-RFID fallback path
             spoolman_slot_map: dict[tuple[int, int], int] = {}
+            slot_map_read = False
             try:
                 slot_result = await db.execute(
                     select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
                 )
                 for slot in slot_result.scalars().all():
                     spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+                slot_map_read = True
             except Exception as e:
                 logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
@@ -3392,57 +3394,27 @@ async def on_ams_change(printer_id: int, ams_data: list):
             if synced > 0:
                 logger.info("Auto-synced %s AMS trays to Spoolman for printer %s", synced, printer_id)
 
-            # Persist slot assignment changes to the local table
+            # Persist slot assignment changes to the local table: only slots
+            # whose spool this pass changed, and only while they still hold what
+            # it read before awaiting Spoolman, so a spool assigned by hand in
+            # between stays (SSA-3). The changed slots are announced: the slot
+            # card reads slot_preset_mappings (rewritten above) ahead of the live
+            # tray, so with no event it kept the previous spool's preset name.
             if slot_changes or empty_slots:
+                from backend.app.services.spoolman_slot_sync import write_slot_results
+
                 try:
-                    for ams_id, tray_id, spool_id in slot_changes:
-                        await db.execute(
-                            text(
-                                "INSERT INTO spoolman_slot_assignments"
-                                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                                " ON CONFLICT(printer_id, ams_id, tray_id)"
-                                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                            ),
-                            {
-                                "printer_id": printer_id,
-                                "ams_id": ams_id,
-                                "tray_id": tray_id,
-                                "spool_id": spool_id,
-                            },
-                        )
-                    for ams_id, tray_id in empty_slots:
-                        await db.execute(
-                            delete(SpoolmanSlotAssignment).where(
-                                SpoolmanSlotAssignment.printer_id == printer_id,
-                                SpoolmanSlotAssignment.ams_id == ams_id,
-                                SpoolmanSlotAssignment.tray_id == tray_id,
-                            )
-                        )
-                    await db.commit()
+                    await write_slot_results(
+                        db,
+                        {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                        if slot_map_read
+                        else None,
+                        [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                        [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+                    )
                 except Exception as e:
                     await db.rollback()
                     logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
-                else:
-                    # Tell open browsers the slot changed. This loop rewrites
-                    # slot_preset_mappings via upsert_slot_preset_for_spoolman_spool
-                    # above, and the AMS slot card reads that row ahead of the
-                    # live tray_info_idx -- so with no event the card keeps
-                    # showing the previous spool's preset name. Internal mode
-                    # raises spool_auto_assigned for the same reason; this loop
-                    # broadcast nothing at all, which made Spoolman mode the
-                    # worse half of the same bug. On the else branch so a
-                    # failed commit stays silent and a broadcast failure cannot
-                    # roll back rows that are already committed.
-                    for ams_id, tray_id, *_ in (*slot_changes, *empty_slots):
-                        await ws_manager.broadcast(
-                            {
-                                "type": "spool_assignment_changed",
-                                "printer_id": printer_id,
-                                "ams_id": ams_id,
-                                "tray_id": tray_id,
-                            }
-                        )
 
     except Exception as e:
         logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)

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

@@ -0,0 +1,141 @@
+"""Writing a Spoolman AMS sync's results to the local slot table.
+
+Shared by the automatic pass in ``on_ams_change`` and the Sync buttons in
+Settings. A sync reads the slot table, then awaits Spoolman for every tray, so
+by the time it writes, a slot may hold a spool the user assigned meanwhile.
+Writing every synced slot back, the unchanged ones included, overwrote that
+assignment with what the sync had read (SSA-3). Only a slot whose spool the
+sync changed is written, and only while it still holds what the sync read.
+
+What it did change is announced, its grace hold dropped, as a hand assignment
+does; the Sync buttons did neither (SSA-2).
+"""
+
+import logging
+from collections.abc import Iterable, Mapping
+from typing import cast
+
+from sqlalchemy import delete, text, update
+from sqlalchemy.engine import CursorResult
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.websocket import ws_manager
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services import slot_unlink_grace
+
+logger = logging.getLogger(__name__)
+
+Slot = tuple[int, int, int]  # (printer_id, ams_id, tray_id)
+
+
+async def write_slot_results(
+    db: AsyncSession,
+    read: Mapping[Slot, int] | None,
+    synced: Iterable[tuple[int, int, int, int]],
+    emptied: Iterable[Slot],
+) -> list[Slot]:
+    """Write the slots the sync changed and commit; returns them.
+
+    ``read`` is the slot table as the sync read it, ``synced`` the
+    (printer_id, ams_id, tray_id, spoolman_spool_id) it found in the slots, and
+    ``emptied`` the slots it found empty. Raises on a database error, as the
+    callers report it.
+
+    ``read`` None: the table could not be read, so there is nothing to compare
+    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.
+    """
+    if read is None:
+        return await _write_unconditionally(db, synced, emptied)
+    changed: list[Slot] = []
+    assigned: list[Slot] = []
+    for printer_id, ams_id, tray_id, spool_id in synced:
+        slot = (printer_id, ams_id, tray_id)
+        before = read.get(slot)
+        if before == spool_id:
+            continue
+        where = (
+            SpoolmanSlotAssignment.printer_id == printer_id,
+            SpoolmanSlotAssignment.ams_id == ams_id,
+            SpoolmanSlotAssignment.tray_id == tray_id,
+        )
+        if before is None:
+            statement = text(
+                "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                " ON CONFLICT(printer_id, ams_id, tray_id) DO NOTHING"
+            ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
+        else:
+            statement = (
+                update(SpoolmanSlotAssignment)
+                .where(*where, SpoolmanSlotAssignment.spoolman_spool_id == before)
+                .values(spoolman_spool_id=spool_id)
+                .execution_options(synchronize_session=False)
+            )
+        if cast(CursorResult, await db.execute(statement)).rowcount:
+            changed.append(slot)
+            assigned.append(slot)
+    for slot in emptied:
+        # A slot the sync read without a spool matches no row below: there is
+        # nothing of the sync's own to remove.
+        before = read.get(slot)
+        printer_id, ams_id, tray_id = slot
+        result = cast(
+            CursorResult,
+            await db.execute(
+                delete(SpoolmanSlotAssignment)
+                .where(
+                    SpoolmanSlotAssignment.printer_id == printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                    SpoolmanSlotAssignment.spoolman_spool_id == before,
+                )
+                .execution_options(synchronize_session=False)
+            ),
+        )
+        if result.rowcount:
+            changed.append(slot)
+    await db.commit()
+    await _announce(assigned, changed)
+    return changed
+
+
+async def _write_unconditionally(
+    db: AsyncSession, synced: Iterable[tuple[int, int, int, int]], emptied: Iterable[Slot]
+) -> list[Slot]:
+    assigned: list[Slot] = []
+    for printer_id, ams_id, tray_id, spool_id in synced:
+        await db.execute(
+            text(
+                "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                " ON CONFLICT(printer_id, ams_id, tray_id)"
+                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+            ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
+        )
+        assigned.append((printer_id, ams_id, tray_id))
+    removed: list[Slot] = []
+    for printer_id, ams_id, tray_id in emptied:
+        await db.execute(
+            delete(SpoolmanSlotAssignment)
+            .where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == ams_id,
+                SpoolmanSlotAssignment.tray_id == tray_id,
+            )
+            .execution_options(synchronize_session=False)
+        )
+        removed.append((printer_id, ams_id, tray_id))
+    await db.commit()
+    changed = assigned + removed
+    await _announce(assigned, changed)
+    return changed
+
+
+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)
+    for printer_id, ams_id, tray_id in changed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )

+ 7 - 4
backend/tests/integration/test_spoolman_ams_sync_broadcast.py

@@ -48,12 +48,12 @@ def _tray(ams_id: int, tray_id: int):
     return tray
 
 
-async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
+async def _run_ams_change(printer_id: int, ams_data: list, *, parsed, sync=None):
     """Drive ``on_ams_change`` with Spoolman standing in for the real server.
 
     ``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
     which is the only thing that decides whether the sync treats the slot as
-    occupied or cleared.
+    occupied or cleared. ``sync`` stands in for ``sync_ams_tray``.
     """
     from backend.app.main import on_ams_change
 
@@ -63,14 +63,17 @@ async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
     spoolman.parse_ams_tray = MagicMock(
         side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
     )
-    spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
+    spoolman.sync_ams_tray = sync or AsyncMock(return_value={"id": 4242})
 
     status = _status(ams_data)
+    ws = MagicMock()
     with (
         patch("backend.app.main.printer_manager") as pm_main,
         patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
         patch("backend.app.main.mqtt_relay") as relay,
-        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.ws_manager", ws),
+        # The slot writes are announced by the shared writer.
+        patch("backend.app.services.spoolman_slot_sync.ws_manager", ws),
         patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
         patch(
             "backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",

+ 248 - 0
backend/tests/integration/test_spoolman_slot_sync_writes.py

@@ -0,0 +1,248 @@
+"""What the Spoolman AMS syncs write to the slot table, and announce (SM-C3, SSA-2, SSA-3).
+
+- The automatic pass read the slot table, then awaited Spoolman for every tray
+  and wrote every synced slot back, unchanged ones included. A spool assigned
+  by hand in between was overwritten with what the pass had read. It now writes
+  only slots whose spool it changed, and only while they still hold what it read.
+- The Sync buttons in Settings wrote slots without announcing them, refreshing
+  the slot's preset or clearing its grace hold, as the automatic pass does.
+- Both seeded a newly created spool's weight from the built-in inventory's
+  slot assignments, which in Spoolman mode are left over from before the switch.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, update
+
+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.spoolman import AMSTray
+from backend.tests.integration.test_spoolman_ams_sync_broadcast import (
+    _enable_spoolman,
+    _run_ams_change,
+    _slot_events,
+    _tray,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _slots(db_session, printer_id) -> dict[tuple[int, int], int]:
+    rows = (
+        await db_session.execute(
+            select(SpoolmanSlotAssignment)
+            .where(SpoolmanSlotAssignment.printer_id == printer_id)
+            .execution_options(populate_existing=True)
+        )
+    ).scalars()
+    return {(row.ams_id, row.tray_id): row.spoolman_spool_id for row in rows}
+
+
+async def _printer_with_slot(db_session, printer_factory, spool_id: int = 7) -> int:
+    await _enable_spoolman(db_session)
+    printer = await printer_factory()
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=spool_id))
+    await db_session.commit()
+    return printer.id
+
+
+def _assigning_by_hand_meanwhile(db_session, printer_id, slot, spool_id, returns):
+    """sync_ams_tray, during which the user assigns *spool_id* to *slot*."""
+
+    async def sync(tray, *args, **kwargs):
+        await db_session.execute(
+            update(SpoolmanSlotAssignment)
+            .where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == slot[0],
+                SpoolmanSlotAssignment.tray_id == slot[1],
+            )
+            .values(spoolman_spool_id=spool_id)
+        )
+        await db_session.commit()
+        return returns
+
+    return AsyncMock(side_effect=sync)
+
+
+AMS = [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}]
+
+
+@pytest.mark.parametrize("synced", [7, 8], ids=["unchanged", "changed"])
+async def test_the_automatic_pass_keeps_a_spool_assigned_meanwhile(
+    async_client: AsyncClient, db_session, printer_factory, synced
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": synced})
+
+    broadcast, _client = await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=sync)
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9}
+    assert (0, 0) not in _slot_events(broadcast), "nothing the pass wrote changed"
+
+
+async def test_the_automatic_pass_announces_no_slot_it_left_as_it_was(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    broadcast, _client = await _run_ams_change(
+        printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 7})
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 7}
+    assert (0, 0) not in _slot_events(broadcast)
+
+
+async def test_the_automatic_pass_still_writes_and_announces_what_it_changed(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    broadcast, _client = await _run_ams_change(
+        printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 8})
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 8}
+    assert _slot_events(broadcast) == {(0, 0)}
+
+
+# ── The Sync buttons ─────────────────────────────────────────────────────────
+
+PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
+
+
+def _parsed_tray(ams_id, tray_data):
+    return AMSTray(
+        ams_id=ams_id,
+        tray_id=int(tray_data.get("id", 0)),
+        tray_type="PLA",
+        tray_sub_brands="PLA Matte",
+        tray_color="042F56FF",
+        remain=-1,
+        tag_uid="7757EF0100000100",
+        tray_uuid="EB335968299543078925C71D83DA3864",
+        tray_info_idx="GFA01",
+        tray_weight=1000,
+    )
+
+
+async def _manual_sync(async_client, printer_id, path, synced: int, *, trays=None, sync=None):
+    trays = trays or [{"id": 0, "tray_type": "PLA"}]
+    state = SimpleNamespace(state="IDLE", raw_data={"ams": [{"id": 0, "tray": trays}]})
+    client = MagicMock()
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spools = AsyncMock(return_value=[])
+    # A tray without a type reads empty.
+    client.parse_ams_tray = MagicMock(
+        side_effect=lambda ams_id, tray: _parsed_tray(ams_id, tray) if tray.get("tray_type") else None
+    )
+    client.sync_ams_tray = sync or AsyncMock(return_value={"id": synced})
+    preset = AsyncMock()
+    with (
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman.printer_manager") as pm,
+        patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws,
+        patch("backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool", preset),
+        patch("backend.app.services.slot_unlink_grace.forget_slot") as forget,
+    ):
+        ws.broadcast = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_all_statuses = MagicMock(return_value={printer_id: state})
+        response = await async_client.post(path.format(printer_id=printer_id))
+    assert response.status_code == 200, response.text
+    return client, ws.broadcast, preset, forget
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_announces_a_changed_slot(async_client: AsyncClient, db_session, printer_factory, path):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    _client, broadcast, preset, forget = await _manual_sync(async_client, printer_id, path, synced=8)
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 8}
+    assert _slot_events(broadcast) == {(0, 0)}
+    assert preset.await_args.kwargs["spoolman_spool"] == {"id": 8}
+    forget.assert_called_once_with(printer_id, 0, 0)
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_does_not_seed_a_spool_from_the_built_in_inventory(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    """A slot assignment left from before the switch to Spoolman: 880 g of
+    1000 used, nothing to do with the spool now in the slot."""
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    stale = Spool(material="PLA", label_weight=1000, weight_used=880)
+    db_session.add(stale)
+    await db_session.flush()
+    db_session.add(SpoolAssignment(spool_id=stale.id, printer_id=printer_id, ams_id=0, tray_id=0))
+    await db_session.commit()
+
+    client, *_ = await _manual_sync(async_client, printer_id, path, synced=8)
+
+    assert client.sync_ams_tray.await_args.kwargs.get("inventory_remaining") is None
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_keeps_a_slot_that_was_filled_meanwhile(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    """Slot 0 read empty, so the sync would unlink spool 7; by then it holds 9."""
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": 5})
+
+    await _manual_sync(
+        async_client, printer_id, path, synced=5, trays=[{"id": 0}, {"id": 1, "tray_type": "PLA"}], sync=sync
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9, (0, 1): 5}
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_still_unlinks_a_slot_that_reads_empty(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    _client, broadcast, *_ = await _manual_sync(async_client, printer_id, path, synced=5, trays=[{"id": 0}])
+
+    assert await _slots(db_session, printer_id) == {}
+    assert _slot_events(broadcast) == {(0, 0)}
+
+
+async def test_the_automatic_pass_keeps_a_spool_assigned_to_a_slot_it_read_empty(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    await _enable_spoolman(db_session)
+    printer_id = (await printer_factory()).id
+
+    async def sync(tray, *args, **kwargs):
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=0, spoolman_spool_id=9))
+        await db_session.commit()
+        return {"id": 8}
+
+    await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(side_effect=sync))
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9}
+
+
+async def test_without_the_slot_table_as_read_the_syncs_write_as_before(db_session, printer_factory):
+    """Its load failed: nothing to compare against, so a swap and a removal are
+    written as before rather than dropped."""
+    from backend.app.services.spoolman_slot_sync import write_slot_results
+
+    printer_id = await _printer_with_slot(db_session, printer_factory, spool_id=10)
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=1, spoolman_spool_id=11))
+    await db_session.commit()
+
+    with patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        changed = await write_slot_results(db_session, None, [(printer_id, 0, 0, 99)], [(printer_id, 0, 1)])
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 99}
+    assert sorted(changed) == [(printer_id, 0, 0), (printer_id, 0, 1)]