Browse Source

fix(inventory): archived or deleted spools release their AMS slots, safely in both modes

maziggy 6 days ago
parent
commit
23d04c8645
76 changed files with 905 additions and 24 deletions
  1. 7 0
      CHANGELOG.md
  2. 28 0
      backend/app/api/routes/inventory.py
  3. 17 3
      backend/app/api/routes/spoolman.py
  4. 43 1
      backend/app/api/routes/spoolman_inventory.py
  5. 14 0
      backend/app/core/database.py
  6. 12 2
      backend/app/main.py
  7. 20 0
      backend/app/services/spool_tag_matcher.py
  8. 67 0
      backend/app/services/spoolman_tracking.py
  9. 354 0
      backend/tests/integration/test_spool_archive_unassigns.py
  10. 84 0
      backend/tests/integration/test_spoolman_manual_sync_empty_slots.py
  11. 66 0
      backend/tests/unit/test_archived_spool_slots_migration.py
  12. 22 0
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  13. 151 0
      frontend/src/__tests__/pages/InventoryPageGroupRestore.test.tsx
  14. 2 3
      frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx
  15. 4 1
      frontend/src/components/AssignSpoolModal.tsx
  16. 2 2
      frontend/src/components/SpoolFormModal.tsx
  17. 1 1
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  18. 5 3
      frontend/src/pages/InventoryPage.tsx
  19. 3 3
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  20. 0 0
      static/assets/AdditionalSection-BX_9eEfo.js
  21. 0 0
      static/assets/AmsUnitCard-6ccENZX6.js
  22. 0 0
      static/assets/ArchivesPage-BlZjYmpF.js
  23. 0 0
      static/assets/AssignToAmsModal-BxpwPkwW.js
  24. 0 0
      static/assets/AssignToAmsModal-bYR8gZFx.js
  25. 0 0
      static/assets/CamWallPage-D8u4JrDN.js
  26. 0 0
      static/assets/CameraDiagnoseModal-CF_3H9kv.js
  27. 0 0
      static/assets/CameraPage-De35Xmbr.js
  28. 0 0
      static/assets/CameraWall-BkkYN17L.js
  29. 0 0
      static/assets/ColumnConfigModal-qt5W3ay1.js
  30. 0 0
      static/assets/ConfigureAmsSlotModal-f8OX-oKe.js
  31. 0 0
      static/assets/ConfigureAmsSlotModal-rs7Y8Skg.js
  32. 0 0
      static/assets/ConnectAuthorizePage-Dn2SO3F3.js
  33. 0 0
      static/assets/ContextMenu-D6f8moyC.js
  34. 0 0
      static/assets/ExternalLinkPage-GeVMbDzR.js
  35. 0 1
      static/assets/FileManagerPage-Btvhq7_l.js
  36. 0 0
      static/assets/FileUploadModal-Dzbs0PEH.js
  37. 0 0
      static/assets/FinancePage-BQtwtBhb.js
  38. 0 0
      static/assets/GCodeViewerPage-Cy12g--n.js
  39. 0 0
      static/assets/GroupEditPage-COvCEH5S.js
  40. 0 0
      static/assets/ImagePreviewModal-BZx-swKk.js
  41. 0 0
      static/assets/InventoryPage-5oNzlDxv.js
  42. 0 0
      static/assets/InventoryPage-CUHoyuIl.js
  43. 0 0
      static/assets/LibraryTrashPage-DXYSRW50.js
  44. 0 0
      static/assets/LoginPage-CVt_zIST.js
  45. 0 0
      static/assets/MaintenancePage-C3tVMLBZ.js
  46. 0 0
      static/assets/ModelSourcesPage-D3S3tr8C.js
  47. 0 0
      static/assets/NotificationsPage-COybtLWi.js
  48. 0 1
      static/assets/PdfPreviewModal-KRaIVJSa.js
  49. 0 0
      static/assets/PhotoGalleryModal-DZyC_fln.js
  50. 0 0
      static/assets/PrinterLocationsPage-BFo-HCfF.js
  51. 0 0
      static/assets/PrintersPage-CBVbiVxI.js
  52. 0 0
      static/assets/ProfilesPage-Cmwnf0q7.js
  53. 0 0
      static/assets/ProjectDetailPage-C6UXRtJv.js
  54. 0 0
      static/assets/ProjectsPage-CL0cj_MO.js
  55. 0 0
      static/assets/QueuePage-DW5hJfPK.js
  56. 0 0
      static/assets/RichTextEditor-Bvzf58ww.js
  57. 0 0
      static/assets/SettingsPage-DcPvbyBd.js
  58. 0 0
      static/assets/SetupPage-Dktx568m.js
  59. 0 0
      static/assets/SliceModal-ByDncSCa.js
  60. 0 0
      static/assets/SpoolBuddyAmsPage-B2_AtoaS.js
  61. 0 0
      static/assets/SpoolBuddyAmsPage-BuEPYNjx.js
  62. 0 0
      static/assets/SpoolBuddyCalibrationPage-BMjY85sH.js
  63. 0 0
      static/assets/SpoolBuddyDashboard-CzeVV1nH.js
  64. 0 0
      static/assets/SpoolBuddyInventoryPage-C5T4e-ah.js
  65. 1 1
      static/assets/SpoolBuddySettingsPage-D5TAqoBQ.js
  66. 0 0
      static/assets/SpoolBuddyWriteTagPage-BVMPSGAL.js
  67. 0 0
      static/assets/SpreadsheetPreviewModal-DNdW1efE.js
  68. 0 0
      static/assets/StatsPage-BRicIk0i.js
  69. 0 0
      static/assets/StreamOverlayPage-5o8XNrF0.js
  70. 0 0
      static/assets/SystemInfoPage-DVB4k_Xr.js
  71. 0 0
      static/assets/index-CFLW6HNX.js
  72. 1 1
      static/assets/inventorySearch-C4akseUN.js
  73. 0 0
      static/assets/locationSensorDefaults-B7KP4Vx0.js
  74. 0 0
      static/assets/pdf-sdfy98wJ.js
  75. 0 0
      static/assets/utils-C4DxcP2p.js
  76. 1 1
      static/index.html

+ 7 - 0
CHANGELOG.md

@@ -164,6 +164,13 @@ 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.
 - **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
 ### Fixed
+- **Spool slots** —
+  - Archiving a spool, one or in bulk, unassigns it from its AMS slots, with the built-in inventory and with Spoolman, and slots still held by spools archived earlier are released on update. The slot kept showing the archived spool and charged it the next print's usage. A Bambu Lab spool's tag still in the AMS after archiving is not given to another spool or turned into a new one. With Spoolman, a spool feeding a running print keeps its slot until the print's usage is reported.
+  - Deleting a Spoolman spool removes its slot assignments too, and a spool archived or deleted in Spoolman itself releases its slots after the next print on that printer. The slot kept pointing at a spool Spoolman no longer had.
+  - Printer cards and slot lists update right away when a Spoolman spool is archived, deleted or unassigned.
+  - **Sync** and **Sync all** in Settings → Spoolman no longer unlink a slot that reads empty while its printer is printing (a runout), or one the AMS still reports as holding a spool.
+  - The spool list in the Assign dialog updates right after a Spoolman assignment, so the spool just assigned is no longer offered for a second slot.
+  - **Restore** on an archived spool inside an expanded group row of the inventory table works. It did nothing.
 - **Spool weights from the AMS** —
 - **Spool weights from the AMS** —
   - **Sync Weights from AMS** and **Sync Spoolman Weights from AMS** leave the spools of a printer that is printing alone, and say how many. Synced halfway through a print, that print's usage was counted twice at completion.
   - **Sync Weights from AMS** and **Sync Spoolman Weights from AMS** leave the spools of a printer that is printing alone, and say how many. Synced halfway through a print, that print's usage was counted twice at completion.
   - A spool weighed on the SpoolBuddy scale is locked like one whose weight was set by hand, so the automatic AMS remain% sync no longer overwrites the measured weight.
   - A spool weighed on the SpoolBuddy scale is locked like one whose weight was set by hand, so the automatic AMS remain% sync no longer overwrites the measured weight.

+ 28 - 0
backend/app/api/routes/inventory.py

@@ -1674,6 +1674,29 @@ async def delete_spool(
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
 
 
+async def _release_slots(db: AsyncSession, spool_ids: list[int]) -> list[tuple[int, int, int]]:
+    """Unassign archived spools from their AMS slots; returns the freed slots.
+
+    Assigning refuses an archived spool and nothing downstream checks the flag,
+    so a slot left pointing at one would keep showing it and charge it the next
+    print's usage. The caller commits, then announces the slots.
+    """
+    if not spool_ids:
+        return []
+    rows = (await db.execute(select(SpoolAssignment).where(SpoolAssignment.spool_id.in_(spool_ids)))).scalars().all()
+    freed = [(a.printer_id, a.ams_id, a.tray_id) for a in rows]
+    for assignment in rows:
+        await db.delete(assignment)
+    return freed
+
+
+async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
+    for printer_id, ams_id, tray_id in freed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )
+
+
 @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
 @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
 async def archive_spool(
 async def archive_spool(
     spool_id: int,
     spool_id: int,
@@ -1689,9 +1712,11 @@ async def archive_spool(
         raise HTTPException(404, "Spool not found")
         raise HTTPException(404, "Spool not found")
 
 
     spool.archived_at = datetime.now(timezone.utc)
     spool.archived_at = datetime.now(timezone.utc)
+    freed = await _release_slots(db, [spool.id])
     await db.commit()
     await db.commit()
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return result.scalar_one()
     return result.scalar_one()
 
 
 
 
@@ -1868,9 +1893,12 @@ async def bulk_archive_spools(
             continue
             continue
         spool.archived_at = now
         spool.archived_at = now
         archived.append(spool.id)
         archived.append(spool.id)
+    # Already-archived ones too: archived before archiving released slots.
+    freed = await _release_slots(db, archived + already)
     await db.commit()
     await db.commit()
     if archived:
     if archived:
         await ws_manager.broadcast({"type": "inventory_changed"})
         await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"archived": len(archived), "already_archived": already, "not_found": not_found}
     return {"archived": len(archived), "already_archived": already, "not_found": not_found}
 
 
 
 

+ 17 - 3
backend/app/api/routes/spoolman.py

@@ -27,7 +27,8 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services import slot_unlink_grace
 from backend.app.services import slot_unlink_grace
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.ams_slot_presence import spool_present
+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.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spool_filament_preset import resolve_spoolman_preset
@@ -220,6 +221,17 @@ async def disconnect_spoolman(
     return {"success": True, "message": "Disconnected from Spoolman"}
     return {"success": True, "message": "Disconnected from Spoolman"}
 
 
 
 
+def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
+    """The automatic pass's guards for a tray that reads empty (#3186): not while
+    the printer prints, where a runout reads empty, and not in a slot whose
+    presence bit says a spool is there. Its grace period is left out: the sync
+    was asked for.
+    """
+    if str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES:
+        return False
+    return spool_present(tray_data) is not True
+
+
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
 async def sync_printer_ams(
     printer_id: int,
     printer_id: int,
@@ -358,7 +370,8 @@ async def sync_printer_ams(
             tray_id_raw = int(tray_data.get("id", 0))
             tray_id_raw = int(tray_data.get("id", 0))
             tray = client.parse_ams_tray(ams_id, tray_data)
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
             if not tray:
-                empty_slots.append((ams_id, tray_id_raw))
+                if _empty_slot_may_be_unlinked(state, tray_data):
+                    empty_slots.append((ams_id, tray_id_raw))
                 continue
                 continue
 
 
             spool_tag = (
             spool_tag = (
@@ -577,7 +590,8 @@ async def sync_all_printers(
                 tray_id_raw = int(tray_data.get("id", 0))
                 tray_id_raw = int(tray_data.get("id", 0))
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 if not tray:
                 if not tray:
-                    all_empty_slots.append((printer.id, ams_id, tray_id_raw))
+                    if _empty_slot_may_be_unlinked(state, tray_data):
+                        all_empty_slots.append((printer.id, ams_id, tray_id_raw))
                     continue
                     continue
 
 
                 spool_tag = (
                 spool_tag = (

+ 43 - 1
backend/app/api/routes/spoolman_inventory.py

@@ -1004,6 +1004,33 @@ async def update_spool(
     return _map_spoolman_spool(updated)
     return _map_spoolman_spool(updated)
 
 
 
 
+async def _release_spool_slots(db: AsyncSession, spool_id: int, *, keep_tracked: bool) -> list[tuple[int, int, int]]:
+    """Delete a spool's slot rows; returns the freed (printer, ams, tray) slots.
+
+    ``keep_tracked`` keeps the rows of a printer with a print being tracked:
+    its usage is charged at completion by the slot's spool, which only the row
+    knows for a spool without a tag. ``release_archived_slot_rows`` frees them
+    once that print is reported. The caller commits, then announces.
+    """
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+
+    query = select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
+    if keep_tracked:
+        query = query.where(SpoolmanSlotAssignment.printer_id.not_in(select(ActivePrintSpoolman.printer_id)))
+    rows = (await db.execute(query)).scalars().all()
+    freed = [(r.printer_id, r.ams_id, r.tray_id) for r in rows]
+    for row in rows:
+        await db.delete(row)
+    return freed
+
+
+async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
+    for printer_id, ams_id, tray_id in freed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )
+
+
 async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
 async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
     """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
     """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
 
 
@@ -1031,8 +1058,12 @@ async def delete_spool(
     async with _translate_spoolman_errors():
     async with _translate_spoolman_errors():
         await client.delete_spool(spool_id)
         await client.delete_spool(spool_id)
     await _purge_local_rows_for_spool(db, spool_id)
     await _purge_local_rows_for_spool(db, spool_id)
+    # Its slots go too: a row left behind points tracking and the scheduler
+    # at an id Spoolman no longer has.
+    freed = await _release_spool_slots(db, spool_id, keep_tracked=False)
     await db.commit()
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
 
 
@@ -1046,12 +1077,16 @@ async def archive_spool(
     client = await _get_client(db)
     client = await _get_client(db)
     async with _translate_spoolman_errors():
     async with _translate_spoolman_errors():
         spool = await client.set_spool_archived(spool_id, archived=True)
         spool = await client.set_spool_archived(spool_id, archived=True)
+    # An archived spool gives its slots up, as in the built-in inventory.
+    freed = await _release_spool_slots(db, spool_id, keep_tracked=True)
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     try:
     try:
         mapped = _map_spoolman_spool(spool)
         mapped = _map_spoolman_spool(spool)
     except ValueError as exc:
     except ValueError as exc:
         logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
         logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
-    await ws_manager.broadcast({"type": "inventory_changed"})
     return mapped
     return mapped
 
 
 
 
@@ -1157,12 +1192,14 @@ async def bulk_delete_spools(
     """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
     """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
     client = await _get_client(db)
     client = await _get_client(db)
     deleted = 0
     deleted = 0
+    freed: list[tuple[int, int, int]] = []
     errors: list[dict] = []
     errors: list[dict] = []
     for sid in payload.ids:
     for sid in payload.ids:
         try:
         try:
             async with _translate_spoolman_errors():
             async with _translate_spoolman_errors():
                 await client.delete_spool(sid)
                 await client.delete_spool(sid)
             await _purge_local_rows_for_spool(db, sid)
             await _purge_local_rows_for_spool(db, sid)
+            freed += await _release_spool_slots(db, sid, keep_tracked=False)
             deleted += 1
             deleted += 1
         except HTTPException as exc:
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1172,6 +1209,7 @@ async def bulk_delete_spools(
     if deleted:
     if deleted:
         await db.commit()
         await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
         await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots(freed)
     return {"deleted": deleted, "errors": errors}
     return {"deleted": deleted, "errors": errors}
 
 
 
 
@@ -1184,11 +1222,13 @@ async def bulk_archive_spools(
     """Archive every listed Spoolman spool. Per-spool failures are collected."""
     """Archive every listed Spoolman spool. Per-spool failures are collected."""
     client = await _get_client(db)
     client = await _get_client(db)
     archived = 0
     archived = 0
+    freed: list[tuple[int, int, int]] = []
     errors: list[dict] = []
     errors: list[dict] = []
     for sid in payload.ids:
     for sid in payload.ids:
         try:
         try:
             async with _translate_spoolman_errors():
             async with _translate_spoolman_errors():
                 await client.set_spool_archived(sid, archived=True)
                 await client.set_spool_archived(sid, archived=True)
+            freed += await _release_spool_slots(db, sid, keep_tracked=True)
             archived += 1
             archived += 1
         except HTTPException as exc:
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1196,7 +1236,9 @@ async def bulk_archive_spools(
             logger.exception("Spoolman bulk-archive failed for spool %s", sid)
             logger.exception("Spoolman bulk-archive failed for spool %s", sid)
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
     if archived:
     if archived:
+        await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
         await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots(freed)
     return {"archived": archived, "errors": errors}
     return {"archived": archived, "errors": errors}
 
 
 
 

+ 14 - 0
backend/app/core/database.py

@@ -5807,10 +5807,24 @@ async def _run_migrations(conn):
     await _migrate_spool_usage_outlives_its_spool(conn)
     await _migrate_spool_usage_outlives_its_spool(conn)
     await _backfill_pipeline_job_final_status(conn)
     await _backfill_pipeline_job_final_status(conn)
     await _release_stuck_budget_reservations(conn)
     await _release_stuck_budget_reservations(conn)
+    await _release_slots_of_archived_spools(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_columns_to_model(conn)
     await _migrate_pg_columns_to_model(conn)
 
 
 
 
+async def _release_slots_of_archived_spools(conn) -> None:
+    """An archived spool gives its AMS slots up: archiving does so now, and the
+    slots still held by one archived earlier are released here. Idempotent."""
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        released = await conn.execute(
+            text("DELETE FROM spool_assignment WHERE spool_id IN (SELECT id FROM spool WHERE archived_at IS NOT NULL)")
+        )
+    if released.rowcount:
+        logger.info("Released %d AMS slot(s) still assigned to archived spools", released.rowcount)
+
+
 # Columns that migrations added nullable or with a server default the model
 # Columns that migrations added nullable or with a server default the model
 # didn't have, so on a PostgreSQL database older than them they differ from a
 # didn't have, so on a PostgreSQL database older than them they differ from a
 # fresh install. Default and nullability are taken from the model itself.
 # fresh install. Default and nullability are taken from the model itself.

+ 12 - 2
backend/app/main.py

@@ -161,6 +161,7 @@ from backend.app.services.spool_filament_preset import printer_safe_filament_id
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman_tracking import (
 from backend.app.services.spoolman_tracking import (
     cleanup_tracking as _cleanup_spoolman_tracking,
     cleanup_tracking as _cleanup_spoolman_tracking,
+    release_archived_slot_rows as _release_archived_spoolman_slots,
     report_usage as _report_spoolman_usage,
     report_usage as _report_spoolman_usage,
     store_print_data as _store_spoolman_print_data,
     store_print_data as _store_spoolman_print_data,
 )
 )
@@ -2859,6 +2860,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                 is_bambu_tag,
                 is_bambu_tag,
                 is_valid_tag,
                 is_valid_tag,
                 link_tag_to_inventory_spool,
                 link_tag_to_inventory_spool,
+                tag_belongs_to_archived_spool,
             )
             )
 
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
             _spoolman_on = await get_setting(db, "spoolman_enabled")
@@ -3009,6 +3011,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                             # BL spool with RFID tag: auto-match → inventory match → auto-create
                             # BL spool with RFID tag: auto-match → inventory match → auto-create
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
+                            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.
+                                continue
                             if not spool:
                             if not spool:
                                 # Try matching an untagged inventory spool (same material/color)
                                 # Try matching an untagged inventory spool (same material/color)
                                 spool = await find_matching_untagged_spool(db, tray)
                                 spool = await find_matching_untagged_spool(db, tray)
@@ -3061,8 +3067,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 ams_id,
                                 ams_id,
                                 tray_id,
                                 tray_id,
                             )
                             )
-                        elif is_valid_tag(tag_uid, tray_uuid):
-                            # Non-BL spool with some tag — let user choose
+                        elif is_valid_tag(tag_uid, tray_uuid) and not await tag_belongs_to_archived_spool(
+                            db, tag_uid, tray_uuid
+                        ):
+                            # Non-BL spool with some tag — let user choose (not
+                            # an archived one's, whose slot stays free)
                             await _broadcast_unknown_tag(
                             await _broadcast_unknown_tag(
                                 printer_id=printer_id,
                                 printer_id=printer_id,
                                 ams_id=ams_id,
                                 ams_id=ams_id,
@@ -8177,6 +8186,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     )
                     )
             except Exception as e:
             except Exception as e:
                 logger.debug("[SPOOLMAN] Cleanup failed: %s", e)
                 logger.debug("[SPOOLMAN] Cleanup failed: %s", e)
+        await _release_archived_spoolman_slots(printer_id)
 
 
     log_timing("Filament usage tracking")
     log_timing("Filament usage tracking")
 
 

+ 20 - 0
backend/app/services/spool_tag_matcher.py

@@ -402,6 +402,26 @@ async def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data:
     )
     )
 
 
 
 
+async def tag_belongs_to_archived_spool(db: AsyncSession, tag_uid: str, tray_uuid: str) -> bool:
+    """True when the tag is an archived spool's.
+
+    Archiving releases the spool's slot, but its tag can stay in the AMS (an
+    empty core waiting to be swapped). Treated as unknown, it would be linked
+    to a matching unopened spool or become a new one.
+    """
+    tray_uuid_norm = _normalize_tray_uuid(tray_uuid)
+    tag_uid_norm = _normalize_tag_uid(tag_uid)
+    conditions = []
+    if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm):
+        conditions.append(func.upper(Spool.tray_uuid) == tray_uuid_norm)
+    if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm):
+        conditions.append(func.upper(Spool.tag_uid) == tag_uid_norm)
+    if not conditions:
+        return False
+    found = await db.scalar(select(Spool.id).where(Spool.archived_at.is_not(None), or_(*conditions)).limit(1))
+    return found is not None
+
+
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
 
 

+ 67 - 0
backend/app/services/spoolman_tracking.py

@@ -9,8 +9,10 @@ import json
 import logging
 import logging
 import math
 import math
 from dataclasses import dataclass
 from dataclasses import dataclass
+from typing import cast
 
 
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
+from sqlalchemy.engine import CursorResult
 
 
 from backend.app.api.routes._spoolman_helpers import spoolman_net_weight
 from backend.app.api.routes._spoolman_helpers import spoolman_net_weight
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
@@ -629,6 +631,71 @@ async def cleanup_tracking(
     logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
     logger.debug("[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s", printer_id, archive_id)
 
 
 
 
+async def release_archived_slot_rows(printer_id: int) -> None:
+    """Unassign the printer's slots whose Spoolman spool is archived or gone.
+
+    Archiving a spool through Bambuddy keeps the slot of a printer whose print
+    it may be feeding: usage is charged at completion by the slot's spool, and
+    only the slot row knows it for a spool without a tag. Run once that print's
+    usage is reported. Also catches spools archived or deleted in Spoolman.
+    Best-effort: a failure leaves the rows for the next print to try again.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.core.websocket import ws_manager
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    try:
+        async with async_session() as db:
+            enabled = await get_setting(db, "spoolman_enabled")
+            if not enabled or enabled.lower() != "true":
+                return
+            rows = (
+                (
+                    await db.execute(
+                        select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
+                    )
+                )
+                .scalars()
+                .all()
+            )
+            if not rows:
+                return
+            client = await _get_spoolman_client_with_fallback()
+            if not client:
+                return
+            live = {
+                spool["id"]
+                for spool in await client.get_all_spools(allow_archived=True)
+                if spool.get("id") is not None and not spool.get("archived")
+            }
+            freed: list[tuple[int, int]] = []
+            for row in rows:
+                if row.spoolman_spool_id in live:
+                    continue
+                # Keyed on the spool too: the slot may have been given another
+                # spool since the rows were read.
+                result = cast(
+                    CursorResult,
+                    await db.execute(
+                        delete(SpoolmanSlotAssignment).where(
+                            SpoolmanSlotAssignment.id == row.id,
+                            SpoolmanSlotAssignment.spoolman_spool_id == row.spoolman_spool_id,
+                        )
+                    ),
+                )
+                if result.rowcount:
+                    freed.append((row.ams_id, row.tray_id))
+            if not freed:
+                return
+            await db.commit()
+        for ams_id, tray_id in freed:
+            await ws_manager.broadcast(
+                {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+            )
+    except Exception as e:  # noqa: BLE001 — best-effort cleanup after completion
+        logger.warning("[SPOOLMAN] Could not release archived spools' slots on printer %s: %s", printer_id, e)
+
+
 async def _get_spoolman_client_with_fallback():
 async def _get_spoolman_client_with_fallback():
     """Get Spoolman client, initializing from settings if needed.
     """Get Spoolman client, initializing from settings if needed.
 
 

+ 354 - 0
backend/tests/integration/test_spool_archive_unassigns.py

@@ -0,0 +1,354 @@
+"""An archived or deleted spool gives its AMS slots up.
+
+Assigning refuses an archived spool, and nothing else looks at the archive
+flag: a slot left pointing at one kept showing it, and the next print charged
+its usage to it. A deleted Spoolman spool left its slot row pointing at an id
+Spoolman no longer has.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+SPOOLMAN_SPOOL = {
+    "id": 42,
+    "filament": {"id": 1, "name": "PLA", "material": "PLA", "weight": 1000, "color_hex": "FF0000"},
+    "remaining_weight": 800.0,
+    "used_weight": 200.0,
+    "archived": False,
+    "extra": {},
+}
+
+
+async def _assigned_built_in_spools(db_session, printer_factory, count=1) -> list[int]:
+    printer = await printer_factory()
+    ids = []
+    for tray in range(count):
+        spool = Spool(material="PLA", label_weight=1000, weight_used=0)
+        db_session.add(spool)
+        await db_session.flush()
+        db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=tray))
+        ids.append(spool.id)
+    await db_session.commit()
+    return ids
+
+
+async def _built_in_slots(db_session) -> list:
+    return list((await db_session.execute(select(SpoolAssignment))).scalars().all())
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archiving_a_spool_unassigns_it(async_client: AsyncClient, db_session, printer_factory):
+    (spool_id,) = await _assigned_built_in_spools(db_session, printer_factory)
+
+    with patch("backend.app.api.routes.inventory.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool_id}/archive")
+
+    assert response.status_code == 200
+    assert await _built_in_slots(db_session) == []
+    sent = [c.args[0]["type"] for c in ws.broadcast.call_args_list]
+    assert "spool_assignment_changed" in sent
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_bulk_archiving_unassigns_every_spool(async_client: AsyncClient, db_session, printer_factory):
+    ids = await _assigned_built_in_spools(db_session, printer_factory, count=2)
+
+    response = await async_client.post("/api/v1/inventory/spools/bulk-archive", json={"ids": ids})
+
+    assert response.status_code == 200
+    assert await _built_in_slots(db_session) == []
+
+
+@pytest.fixture
+async def spoolman_slot(db_session, printer_factory):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    printer = await printer_factory()
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
+    await db_session.commit()
+
+
+def _client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.delete_spool = AsyncMock(return_value=True)
+    client.set_spool_archived = AsyncMock(
+        side_effect=lambda spool_id, archived: {**SPOOLMAN_SPOOL, "archived": archived}
+    )
+    return client
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    "method,path,body",
+    [
+        ("post", "/api/v1/spoolman/inventory/spools/42/archive", None),
+        ("post", "/api/v1/spoolman/inventory/spools/bulk-archive", {"ids": [42]}),
+        ("delete", "/api/v1/spoolman/inventory/spools/42", None),
+        ("post", "/api/v1/spoolman/inventory/spools/bulk-delete", {"ids": [42]}),
+    ],
+)
+async def test_a_spoolman_spool_archived_or_deleted_gives_its_slot_up(
+    async_client: AsyncClient, db_session, spoolman_slot, method, path, body
+):
+    with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())):
+        call = getattr(async_client, method)
+        response = await (call(path, json=body) if body is not None else call(path))
+
+    assert response.status_code == 200, response.text
+    rows = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()
+    assert rows == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_archived_spools_tag_still_in_the_ams_is_not_adopted(test_engine, db_session, printer_factory):
+    """The spool ran out and was archived; its empty core, tag intact, waits in
+    the AMS to be swapped. The freed slot stays free: no new spool is made
+    from the tag, and no unopened spool of the same filament takes it over."""
+    from datetime import datetime, timezone
+    from types import SimpleNamespace
+
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory()
+    uuid = "A1B2C3D4E5F60718293A4B5C6D7E8F90"
+    db_session.add(
+        Spool(
+            material="PLA",
+            label_weight=1000,
+            weight_used=1000,
+            tray_uuid=uuid,
+            tag_uid="0A1B2C3D4E5F6071",
+            archived_at=datetime.now(timezone.utc),
+        )
+    )
+    # An unopened spool of the same filament, which an unknown tag would be linked to.
+    db_session.add(Spool(material="PLA", label_weight=1000, weight_used=0, rgba="FF0000FF"))
+    await db_session.commit()
+
+    ams_data = [
+        {
+            "id": 0,
+            "tray": [
+                {
+                    "id": 0,
+                    "tray_type": "PLA",
+                    "tray_color": "FF0000FF",
+                    "tray_info_idx": "GFA00",
+                    "tray_uuid": uuid,
+                    "tag_uid": "0A1B2C3D4E5F6071",
+                    "remain": 0,
+                }
+            ],
+        }
+    ]
+    state = SimpleNamespace(raw_data={"ams": {"ams": ams_data}}, state="IDLE", connected=True)
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    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.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="X1C", serial_number="ARCHTAG")
+        pm.get_status.return_value = state
+        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()
+
+        await on_ams_change(printer.id, ams_data)
+
+    db_session.expire_all()
+    assert await _built_in_slots(db_session) == []
+    spools = (await db_session.execute(select(Spool))).scalars().all()
+    assert len(spools) == 2
+    unopened = next(s for s in spools if s.archived_at is None)
+    assert unopened.tray_uuid is None and unopened.tag_uid is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archiving_a_spoolman_spool_announces_the_freed_slot(
+    async_client: AsyncClient, db_session, spoolman_slot
+):
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())),
+        patch("backend.app.api.routes.spoolman_inventory.ws_manager") as ws,
+    ):
+        ws.broadcast = AsyncMock()
+        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
+
+    assert response.status_code == 200
+    sent = [c.args[0] for c in ws.broadcast.call_args_list]
+    assert any(m["type"] == "spool_assignment_changed" and m["tray_id"] == 0 for m in sent)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_spoolman_spool_feeding_a_print_keeps_its_slot_until_the_print_is_charged(
+    async_client: AsyncClient, test_engine, db_session, spoolman_slot, archive_factory
+):
+    """Its usage is charged at completion by the slot's spool, which only the
+    slot row knows for a spool without a tag."""
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+    from backend.app.services.spoolman_tracking import release_archived_slot_rows
+
+    printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
+    archive = await archive_factory(printer_id, status="printing", with_run=False)
+    db_session.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive.id, filament_usage=[], ams_trays={}))
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_client())):
+        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
+    assert response.status_code == 200
+    assert len((await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()) == 1
+
+    # The print completed and its usage was reported; Spoolman has the spool archived.
+    await db_session.execute(ActivePrintSpoolman.__table__.delete())
+    await db_session.commit()
+    client = _client()
+    client.get_all_spools = AsyncMock(return_value=[{**SPOOLMAN_SPOOL, "archived": True}])
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.services.spoolman_tracking.async_session", real_db),
+        patch(
+            "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
+        ),
+        patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()),
+    ):
+        await release_archived_slot_rows(printer_id)
+
+    db_session.expire_all()
+    assert (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all() == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_release_after_a_print_keeps_a_live_spools_slot(test_engine, db_session, spoolman_slot):
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.services.spoolman_tracking import release_archived_slot_rows
+
+    printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
+    client = _client()
+    client.get_all_spools = AsyncMock(return_value=[SPOOLMAN_SPOOL])
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.services.spoolman_tracking.async_session", real_db),
+        patch(
+            "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
+        ),
+    ):
+        await release_archived_slot_rows(printer_id)
+
+    db_session.expire_all()
+    assert len((await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_bulk_archive_releases_a_spool_archived_before_it_released_slots(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    from datetime import datetime, timezone
+
+    (spool_id,) = await _assigned_built_in_spools(db_session, printer_factory)
+    spool = await db_session.get(Spool, spool_id)
+    spool.archived_at = datetime.now(timezone.utc)
+    await db_session.commit()
+
+    response = await async_client.post("/api/v1/inventory/spools/bulk-archive", json={"ids": [spool_id]})
+
+    assert response.json()["already_archived"] == [spool_id]
+    assert await _built_in_slots(db_session) == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_release_after_a_print_frees_a_slot_of_a_spool_spoolman_no_longer_has(
+    test_engine, db_session, spoolman_slot
+):
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.services.spoolman_tracking import release_archived_slot_rows
+
+    printer_id = await db_session.scalar(select(SpoolmanSlotAssignment.printer_id))
+    client = _client()
+    client.get_all_spools = AsyncMock(return_value=[])
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.services.spoolman_tracking.async_session", real_db),
+        patch(
+            "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", AsyncMock(return_value=client)
+        ),
+        patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()),
+    ):
+        await release_archived_slot_rows(printer_id)
+
+    db_session.expire_all()
+    assert (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all() == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_archived_spools_third_party_tag_still_in_the_ams_is_not_offered_as_new(
+    test_engine, db_session, printer_factory
+):
+    from datetime import datetime, timezone
+    from types import SimpleNamespace
+
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory()
+    db_session.add(
+        Spool(material="PETG", label_weight=1000, tag_uid="04A1B2C3D4E5F6", archived_at=datetime.now(timezone.utc))
+    )
+    await db_session.commit()
+    ams_data = [
+        {"id": 0, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF", "tag_uid": "04A1B2C3D4E5F6"}]}
+    ]
+    state = SimpleNamespace(raw_data={"ams": {"ams": ams_data}}, state="IDLE", connected=True)
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    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.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main._broadcast_unknown_tag", new_callable=AsyncMock) as prompt,
+    ):
+        pm.get_printer.return_value = MagicMock(name="X1C", serial_number="ARCHTAG2")
+        pm.get_status.return_value = state
+        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()
+
+        await on_ams_change(printer.id, ams_data)
+
+    prompt.assert_not_awaited()

+ 84 - 0
backend/tests/integration/test_spoolman_manual_sync_empty_slots.py

@@ -0,0 +1,84 @@
+"""The manual Spoolman sync and slots that read empty.
+
+The automatic pass unlinks a Spoolman slot only when it reads empty while the
+printer is not printing and the presence bit does not say a spool is there.
+The Sync buttons in Settings deleted the row whenever the tray had no type or
+colour: mid-print (a runout reads empty), or a non-RFID spool whose tray type
+was never set.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+
+@pytest.fixture
+async def printer_id(db_session, printer_factory):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    printer = await printer_factory()
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
+    await db_session.commit()
+    return printer.id
+
+
+async def _sync(async_client, printer_id, path, gcode_state, exists):
+    tray = {"id": 0, "tray_type": "", "tray_color": ""}
+    if exists is not None:
+        tray["exists"] = exists
+    state = SimpleNamespace(state=gcode_state, raw_data={"ams": [{"id": 0, "tray": [tray]}]})
+    client = MagicMock()
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spools = AsyncMock(return_value=[])
+    client.parse_ams_tray = MagicMock(return_value=None)
+    with (
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman.printer_manager") as pm,
+    ):
+        pm.get_status = MagicMock(return_value=state)
+        response = await async_client.post(path.format(printer_id=printer_id))
+    assert response.status_code == 200, response.text
+
+
+PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("path", PATHS)
+@pytest.mark.parametrize("gcode_state", ["RUNNING", "PAUSE"])
+async def test_a_slot_reading_empty_mid_print_keeps_its_spool(
+    async_client: AsyncClient, db_session, printer_id, path, gcode_state
+):
+    await _sync(async_client, printer_id, path, gcode_state, exists=None)
+
+    rows = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()
+    assert len(rows) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_slot_the_presence_bit_calls_occupied_keeps_its_spool(
+    async_client: AsyncClient, db_session, printer_id, path
+):
+    await _sync(async_client, printer_id, path, "IDLE", exists=True)
+
+    rows = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()
+    assert len(rows) == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("path", PATHS)
+async def test_an_empty_slot_of_an_idle_printer_is_unlinked(async_client: AsyncClient, db_session, printer_id, path):
+    await _sync(async_client, printer_id, path, "IDLE", exists=False)
+
+    rows = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().all()
+    assert rows == []

+ 66 - 0
backend/tests/unit/test_archived_spool_slots_migration.py

@@ -0,0 +1,66 @@
+"""Slots still held by a spool archived before archiving released them are
+released by the startup migration. Idempotent."""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.mark.asyncio
+async def test_slots_of_archived_spools_are_released_and_others_kept():
+    from datetime import datetime, timezone
+
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+    import backend.app.models  # noqa: F401
+    from backend.app.core.database import Base
+    from backend.app.models import (  # noqa: F401
+        external_link,
+        location,
+        print_log,
+        print_queue,
+        project_bom,
+        slot_preset,
+        spoolman_k_profile,
+        spoolman_slot_assignment,
+        virtual_printer,
+    )
+    from backend.app.models.printer import Printer
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    async with AsyncSession(engine) as db:
+        printer = Printer(name="P", serial_number="S", ip_address="1.2.3.4", access_code="x")
+        archived = Spool(material="PLA", label_weight=1000, archived_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
+        active = Spool(material="PLA", label_weight=1000)
+        db.add_all([printer, archived, active])
+        await db.flush()
+        db.add(SpoolAssignment(spool_id=archived.id, printer_id=printer.id, ams_id=0, tray_id=0))
+        db.add(SpoolAssignment(spool_id=active.id, printer_id=printer.id, ams_id=0, tray_id=1))
+        active_id = active.id
+        await db.commit()
+
+    for _ in range(2):
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        rows = (await conn.execute(text("SELECT spool_id FROM spool_assignment"))).scalars().all()
+    await engine.dispose()
+    assert rows == [active_id]

+ 22 - 0
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -505,6 +505,28 @@ describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
     expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
     expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
   });
   });
 
 
+  it('refreshes the slots it filters "assigned elsewhere" by after an assignment', async () => {
+    const { default: userEvent } = await import('@testing-library/user-event');
+    const user = userEvent.setup();
+    (api.assignSpoolmanSlot as ReturnType<typeof vi.fn>).mockResolvedValue({});
+    const slotsCall = api.getSpoolmanSlotAssignments as ReturnType<typeof vi.fn>;
+    render(
+      <AssignSpoolModal
+        {...defaultProps}
+        spoolmanEnabled
+        trayInfo={{ type: 'PETG', material: 'PETG', profile: 'PETG HF', color: '0000FF', location: 'AMS 1 - Slot 1' }}
+      />
+    );
+
+    await waitFor(() => expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument());
+    const before = slotsCall.mock.calls.length;
+    await user.click(screen.getByText(/Bambu Lab/));
+    await user.click(screen.getByRole('button', { name: /assign spool/i }));
+
+    await waitFor(() => expect(api.assignSpoolmanSlot).toHaveBeenCalled());
+    await waitFor(() => expect(slotsCall.mock.calls.length).toBeGreaterThan(before));
+  });
+
   it('hides archived Spoolman spools', async () => {
   it('hides archived Spoolman spools', async () => {
     const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
     const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
     (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
     (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);

+ 151 - 0
frontend/src/__tests__/pages/InventoryPageGroupRestore.test.tsx

@@ -0,0 +1,151 @@
+/**
+ * Restore on an archived spool inside an expanded group row of the table.
+ * The group row passed a no-op to its members, so the button did nothing.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'archive',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+const archivedSpool = {
+    // Archived spool: 500 g consumed. Pre-fix this consumption disappeared
+    // from "Total Consumed" the moment the spool was archived (#1390 fb).
+    id: 2,
+    material: 'PETG',
+    subtype: null,
+    brand: 'eSun',
+    color_name: 'Blue',
+    rgba: '0000FFFF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 500,
+    weight_used_baseline: 0,
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: '2026-04-01T00:00:00Z',
+    created_at: '2025-01-02T00:00:00Z',
+    updated_at: '2025-01-02T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  };
+
+describe('InventoryPage — restore inside a group row', () => {
+  const restored: number[] = [];
+
+  beforeEach(() => {
+    restored.length = 0;
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () =>
+        // Only unused, unassigned spools are grouped.
+        HttpResponse.json([
+          { ...archivedSpool, weight_used: 0 },
+          { ...archivedSpool, id: 3, weight_used: 0 },
+        ]),
+      ),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
+      http.post('/api/v1/inventory/spools/:id/restore', ({ params }) => {
+        restored.push(Number(params.id));
+        return HttpResponse.json({ ...archivedSpool, id: Number(params.id), archived_at: null });
+      }),
+    );
+  });
+
+  afterEach(() => {
+    localStorage.removeItem('bambuddy-inventory-group');
+  });
+
+  it('restores the spool whose button was clicked', async () => {
+    render(<InventoryPageRouter />);
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Archived' }));
+    fireEvent.click(screen.getByRole('button', { name: 'Group' }));
+    const header = await screen.findByText('#2, #3');
+    fireEvent.click(header.closest('tr')!);
+
+    const buttons = await screen.findAllByTitle('Restore');
+    fireEvent.click(buttons[0]);
+
+    await waitFor(() => expect(restored).toHaveLength(1));
+    expect([2, 3]).toContain(restored[0]);
+  });
+});

+ 2 - 3
frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx

@@ -255,7 +255,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
   });
   });
 
 
   describe('P13-5 — unlinkSpoolMutation invalidates all 5 dependent query keys', () => {
   describe('P13-5 — unlinkSpoolMutation invalidates all 5 dependent query keys', () => {
-    it('invalidates linked-spools, unlinked-spools, spoolman-slot-assignments, spoolman-slot-assignments-all, spoolman-inventory-spools', async () => {
+    it('invalidates linked-spools, unlinked-spools, spoolman-slot-assignments (all printers too, by prefix), spoolman-inventory-spools', async () => {
       spoolmanStatusValue = { enabled: true, connected: true };
       spoolmanStatusValue = { enabled: true, connected: true };
       // BL slot 0 has tray_uuid that maps to a linked spool — so the unlink
       // BL slot 0 has tray_uuid that maps to a linked spool — so the unlink
       // button is reachable in the picker.
       // button is reachable in the picker.
@@ -293,7 +293,6 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
           'linked-spools',
           'linked-spools',
           'unlinked-spools',
           'unlinked-spools',
           'spoolman-slot-assignments',
           'spoolman-slot-assignments',
-          'spoolman-slot-assignments-all',
           'spoolman-inventory-spools',
           'spoolman-inventory-spools',
         ]));
         ]));
       });
       });
@@ -302,7 +301,7 @@ describe('SpoolBuddyAmsPage Phase 13', () => {
   });
   });
 
 
   describe('P13-6a — Spoolman queries fire when spoolmanEnabled', () => {
   describe('P13-6a — Spoolman queries fire when spoolmanEnabled', () => {
-    it('fetches spoolman-slot-assignments-all and spoolman-inventory-spools when spoolman is enabled', async () => {
+    it('fetches the all-printers spoolman-slot-assignments and spoolman-inventory-spools when spoolman is enabled', async () => {
       spoolmanStatusValue = { enabled: true, connected: true };
       spoolmanStatusValue = { enabled: true, connected: true };
       renderPage();
       renderPage();
 
 

+ 4 - 1
frontend/src/components/AssignSpoolModal.tsx

@@ -102,7 +102,8 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   // in the x1c-2 assign list), and assigning would silently steal it from
   // in the x1c-2 assign list), and assigning would silently steal it from
   // the other printer's slot.
   // the other printer's slot.
   const { data: allSpoolmanAssignments } = useQuery({
   const { data: allSpoolmanAssignments } = useQuery({
-    queryKey: ['spoolman-slot-assignments-all'],
+    // Under the per-slot key, so every slot invalidation refreshes it too.
+    queryKey: ['spoolman-slot-assignments', 'all'],
     queryFn: () => api.getSpoolmanSlotAssignments(),
     queryFn: () => api.getSpoolmanSlotAssignments(),
     enabled: isOpen && !!spoolmanEnabled,
     enabled: isOpen && !!spoolmanEnabled,
   });
   });
@@ -180,6 +181,8 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       }),
       }),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
+      // Prefix match: also the all-printers list this dialog filters
+      // "assigned elsewhere" by.
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       nudgePrinterRepublish();
       nudgePrinterRepublish();
       showToast(t('inventory.assignSuccess'), 'success');
       showToast(t('inventory.assignSuccess'), 'success');

+ 2 - 2
frontend/src/components/SpoolFormModal.tsx

@@ -753,7 +753,7 @@ export function SpoolFormModal({
     enabled: isOpen && isEditing && !spoolmanMode,
     enabled: isOpen && isEditing && !spoolmanMode,
   });
   });
   const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({
   const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({
-    queryKey: ['spoolman-slot-assignments-all'],
+    queryKey: ['spoolman-slot-assignments', 'all'],
     queryFn: () => api.getSpoolmanSlotAssignments(),
     queryFn: () => api.getSpoolmanSlotAssignments(),
     enabled: isOpen && isEditing && spoolmanMode,
     enabled: isOpen && isEditing && spoolmanMode,
   });
   });
@@ -818,7 +818,7 @@ export function SpoolFormModal({
     },
     },
     onSuccess: async () => {
     onSuccess: async () => {
       if (spoolmanMode) {
       if (spoolmanMode) {
-        await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
+        await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments', 'all'] });
         await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
         await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       } else {
       } else {
         await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
         await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });

+ 1 - 1
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -326,7 +326,7 @@ export function AssignToAmsModal({
       queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
       queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments', 'all'] });
       queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
       setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);

+ 5 - 3
frontend/src/pages/InventoryPage.tsx

@@ -872,7 +872,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     data: spoolmanSlotAssignments = [],
     data: spoolmanSlotAssignments = [],
     isError: spoolmanSlotAssignmentsError,
     isError: spoolmanSlotAssignmentsError,
   } = useQuery({
   } = useQuery({
-    queryKey: ['spoolman-slot-assignments-all'],
+    queryKey: ['spoolman-slot-assignments', 'all'],
     queryFn: () => api.getSpoolmanSlotAssignments(),
     queryFn: () => api.getSpoolmanSlotAssignments(),
     enabled: spoolmanMode,
     enabled: spoolmanMode,
     refetchInterval: 30000,
     refetchInterval: 30000,
@@ -2434,6 +2434,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
                           onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
                           onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
+                          onRestore={(id) => restoreMutation.mutate(id)}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
                           onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
                           onResetConsumedCounter={(id) => setConfirmAction({ type: 'reset-consumed-counter', spoolId: id })}
                           onResetConsumedCounter={(id) => setConfirmAction({ type: 'reset-consumed-counter', spoolId: id })}
@@ -3134,7 +3135,7 @@ function SpoolTableRow({
 /* Grouped spool rows for table view */
 /* Grouped spool rows for table view */
 function SpoolTableGroup({
 function SpoolTableGroup({
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
-  onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
+  onEdit, onCopy, onArchive, onRestore, onDelete, onPrintLabel, onResetConsumedCounter,
   visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
   colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
   colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
   selectedIds, onToggleSelected, onToggleGroupSelected,
   selectedIds, onToggleSelected, onToggleGroupSelected,
@@ -3150,6 +3151,7 @@ function SpoolTableGroup({
   onEdit: (spool: InventorySpool) => void;
   onEdit: (spool: InventorySpool) => void;
   onCopy?: (spool: InventorySpool) => void;
   onCopy?: (spool: InventorySpool) => void;
   onArchive: (id: number) => void;
   onArchive: (id: number) => void;
+  onRestore: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onPrintLabel?: (spoolId: number) => void;
   onPrintLabel?: (spoolId: number) => void;
   onResetConsumedCounter?: (id: number) => void;
   onResetConsumedCounter?: (id: number) => void;
@@ -3224,7 +3226,7 @@ function SpoolTableGroup({
             onToggleSelected={onToggleSelected ? () => onToggleSelected(spool.id) : undefined}
             onToggleSelected={onToggleSelected ? () => onToggleSelected(spool.id) : undefined}
             onEdit={() => onEdit(spool)}
             onEdit={() => onEdit(spool)}
             onCopy={onCopy ? () => onCopy(spool) : undefined}
             onCopy={onCopy ? () => onCopy(spool) : undefined}
-            onRestore={() => {}}
+            onRestore={() => onRestore(spool.id)}
             onArchive={() => onArchive(spool.id)}
             onArchive={() => onArchive(spool.id)}
             onDelete={() => onDelete(spool.id)}
             onDelete={() => onDelete(spool.id)}
             onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined}
             onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined}

+ 3 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -102,7 +102,7 @@ export function SpoolBuddyAmsPage() {
   // Without these, AmsUnitCard shows an empty fill bar and the Link button
   // Without these, AmsUnitCard shows an empty fill bar and the Link button
   // stays active even though the slot is occupied.
   // stays active even though the slot is occupied.
   const { data: spoolmanSlotAssignmentsAll = [] } = useQuery({
   const { data: spoolmanSlotAssignmentsAll = [] } = useQuery({
-    queryKey: ['spoolman-slot-assignments-all'],
+    queryKey: ['spoolman-slot-assignments', 'all'],
     queryFn: () => api.getSpoolmanSlotAssignments(),
     queryFn: () => api.getSpoolmanSlotAssignments(),
     enabled: !!spoolmanEnabled,
     enabled: !!spoolmanEnabled,
     staleTime: 30 * 1000,
     staleTime: 30 * 1000,
@@ -326,7 +326,7 @@ export function SpoolBuddyAmsPage() {
       // these PrintersPage / InventoryPage / SpoolBuddyDashboard keep showing
       // these PrintersPage / InventoryPage / SpoolBuddyDashboard keep showing
       // the spool as still assigned for ~30s until refetchInterval kicks in.
       // the spool as still assigned for ~30s until refetchInterval kicks in.
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments', 'all'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       setSlotActionPicker(null);
       setSlotActionPicker(null);
     },
     },
@@ -349,7 +349,7 @@ export function SpoolBuddyAmsPage() {
       // unlinkSpoolMutation does the same set — keep them aligned so both
       // unlinkSpoolMutation does the same set — keep them aligned so both
       // unbind paths refresh the same caches.
       // unbind paths refresh the same caches.
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments', 'all'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['linked-spools'] });

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-BX_9eEfo.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-6ccENZX6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-BlZjYmpF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-BxpwPkwW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-bYR8gZFx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-D8u4JrDN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-CF_3H9kv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-De35Xmbr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-BkkYN17L.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-qt5W3ay1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-f8OX-oKe.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-rs7Y8Skg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-Dn2SO3F3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-D6f8moyC.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-GeVMbDzR.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-Btvhq7_l.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-Dzbs0PEH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-BQtwtBhb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-Cy12g--n.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-COvCEH5S.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BZx-swKk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-5oNzlDxv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-CUHoyuIl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-DXYSRW50.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-CVt_zIST.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-C3tVMLBZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-D3S3tr8C.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-COybtLWi.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-KRaIVJSa.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-DZyC_fln.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-BFo-HCfF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-CBVbiVxI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-Cmwnf0q7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-C6UXRtJv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-CL0cj_MO.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-DW5hJfPK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-Bvzf58ww.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-DcPvbyBd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-Dktx568m.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-ByDncSCa.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-B2_AtoaS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BuEPYNjx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BMjY85sH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-CzeVV1nH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-C5T4e-ah.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-D5TAqoBQ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-BVMPSGAL.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DNdW1efE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-BRicIk0i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-5o8XNrF0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-DVB4k_Xr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CFLW6HNX.js


+ 1 - 1
static/assets/inventorySearch-p7JXaSAm.js → static/assets/inventorySearch-C4akseUN.js

@@ -1 +1 @@
-import{mt as e}from"./index-DO5B_8CV.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{mt as e}from"./index-CFLW6HNX.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-B7KP4Vx0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-sdfy98wJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/utils-C4DxcP2p.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DO5B_8CV.js"></script>
+    <script type="module" crossorigin src="/assets/index-CFLW6HNX.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C5b1pVUf.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C5b1pVUf.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-BqikUEEO.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-BqikUEEO.js">

Some files were not shown because too many files changed in this diff