Sfoglia il codice sorgente

Spoolman: slot and tag changes made elsewhere reach open pages

- Unassign, link, unlink and create-from-slot announce the slots they
  write or clear, plus inventory_changed for a changed spool tag
- The assign announces after taking the slot's fallback tag off the
  previous spool, so a page can't read that spool back
- SpoolBuddy tag write-back in Spoolman mode announces the moved tag
- inventory_changed also refreshes the tag -> spool map
maziggy 2 giorni fa
parent
commit
6c2731a6d9

+ 1 - 0
CHANGELOG.md

@@ -205,6 +205,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A Spoolman slot change made elsewhere now reaches open pages without a reload (#3402, reported by @Eifel-Joe)** — In Spoolman mode, an AMS slot's hover card kept showing the spool assigned before when the change came from another tab or client, here a filament scale using the API. The page only learns about slot changes that the server announces, and unassigning a slot, linking or unlinking a spool's tag, adding a spool from a slot, and writing a tag on SpoolBuddy did not announce theirs. They do now, as built-in inventory always has. Assigning a slot has announced since the move-on-scan change (#1606), but did so before taking the slot's tag off the previous spool, so a page could read that spool back and keep its fill level on the slot. It now announces after.
 - **A timelapse baseline the printer's card could not give no longer attaches an old video to the print (#3412, reported by @ojimpo)** — When the card could not be read at print start (here during the FTPS handshake cool-off), Bambuddy recorded the empty listing as a card holding no videos. If the card was readable again by the end, every video still on the printer counted as new: the scan attached the first one to the print, deleted it from the printer, and took the archive's finish photo from its last frame. A baseline now records whether the card was actually read, including when a listing failed for another reason. With an unread one, several unclaimed videos are left on the printer for **Scan for Timelapse**, and a single one is still attached. The card is also read again every two minutes while the print runs, because the printer writes the video only after the print ends. Wiki updated.
 - **SpoolBuddy reads Bambu tags reliably on the first scan (#3377, reported and contributed by @hibikipr in #3378)** — A Bambu tag's sector reads sometimes failed on the first try (authentication not answered, or a block read that came back empty), and the scan then went out without its tray UUID. Each step is now retried before giving up, a block is accepted only when two reads agree and the reader flags no error, and a reader fault during the read no longer stops SpoolBuddy from polling: the tag is still reported, matched by its UID.
 - **A large tag catalog no longer pushes the File Manager's file list off the page (#3411, reported by @iammenasco)** — The tag filter showed every tag in the catalog as a chip, so 160 tags wrapped to about a dozen rows and left the file list a strip at the bottom. Active tags now always show first. The others take up to two rows, with **Show all (N)** opening them in a box that scrolls on its own. From 12 tags on, **Search tags** narrows them by name. Filtering works as before. Wiki updated.

+ 4 - 0
backend/app/api/routes/spoolbuddy.py

@@ -1058,6 +1058,10 @@ async def nfc_write_result(
                 # fall through to broadcast + raise 502 below
 
             await db.commit()
+            # The tag moved between Spoolman spools, or the stale holders were
+            # cleared before a link that then failed: either way pages open
+            # elsewhere hold a tag -> spool map that changed (#3402).
+            await ws_manager.broadcast({"type": "inventory_changed"})
             if _tag_link_ok:
                 await ws_manager.broadcast(
                     {

+ 18 - 2
backend/app/api/routes/spoolman.py

@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
-from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
+from backend.app.api.routes.spoolman_inventory import _announce_freed_slots, _clear_stale_tag_links
 from backend.app.core.auth import (
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
@@ -20,6 +20,7 @@ from backend.app.core.auth import (
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
+from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -1003,6 +1004,11 @@ async def link_spool(
             else "via /spools/{id}/link"
         ),
     )
+    # Pages open elsewhere find a slot's spool by its tag, and by the slot row
+    # when one was written: both changed (#3402).
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    if printer_context:
+        await _announce_freed_slots([printer_context])
 
     # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
     if printer_context:
@@ -1292,7 +1298,12 @@ async def unlink_spool(
 
     # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
     try:
-        await db.execute(delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id))
+        result = await db.execute(
+            delete(SpoolmanSlotAssignment)
+            .where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
+            .returning(SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id)
+        )
+        freed = list(result.tuples())
         await db.commit()
     except Exception:
         await db.rollback()
@@ -1300,6 +1311,8 @@ async def unlink_spool(
         raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
 
     logger.info("Unlinked Spoolman spool %s", spool_id)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
 
 
@@ -1423,4 +1436,7 @@ async def create_spool_from_slot(
                 detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
             ) from exc
 
+    if sync_result.get("id"):
+        await ws_manager.broadcast({"type": "inventory_changed"})
+        await _announce_freed_slots([(req.printer_id, req.ams_id, req.tray_id)])
     return {"success": True, "spool_id": sync_result.get("id")}

+ 15 - 6
backend/app/api/routes/spoolman_inventory.py

@@ -1969,11 +1969,6 @@ async def assign_spoolman_slot(
             body.tray_id,
             ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
         )
-    # Other tabs and the SpoolBuddy kiosk follow slot changes over the
-    # websocket, as the built-in assign announces them: the slots the spool
-    # left, and the one it went to.
-    await _announce_freed_slots([*vacated, target])
-
     # #1457: clear stale fallback-tag links on OTHER spools still bound to this
     # slot. Without this, a non-RFID slot's deterministic fallback tag stays
     # attached to the previous spool in Spoolman's extra.tag and re-surfaces in
@@ -1987,6 +1982,12 @@ async def assign_spoolman_slot(
             keep_spool_id=body.spoolman_spool_id,
         )
 
+    # Other tabs and the SpoolBuddy kiosk follow slot changes over the
+    # websocket, as the built-in assign announces them: the slots the spool
+    # left, and the one it went to. After the tag clean-up above, which the
+    # pages read back with the slot rows (#3402).
+    await _announce_freed_slots([*vacated, target])
+
     mapped = _map_spoolman_spool(spool)
 
     # Fetch K-profiles before the MQTT try block so we can use async DB access.
@@ -2288,12 +2289,20 @@ async def unassign_spoolman_slot(
         unassign = delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
         if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
             unassign = unassign.where(clause)
-        await db.execute(unassign)
+        result = await db.execute(
+            unassign.returning(
+                SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id
+            )
+        )
+        freed = list(result.tuples())
         await db.commit()
     except Exception as exc:
         await db.rollback()
         logger.error("Failed to delete slot assignment: %s", exc)
         raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
+    # Other tabs, other clients' pages and the SpoolBuddy kiosk follow slot
+    # changes over the websocket (#3402).
+    await _announce_freed_slots(freed)
 
     # Fetch the spool from Spoolman to return in InventorySpool format.
     # If the spool no longer exists in Spoolman, the local unassignment still succeeded.

+ 8 - 1
backend/tests/integration/test_spoolbuddy_write_clears_previous_tag_3312.py

@@ -114,7 +114,7 @@ async def _write(async_client: AsyncClient, db_session, fake: FakeSpoolman):
             json={"device_id": "sb-3312", "spool_id": TARGET, "tag_uid": UID.lower(), "success": True},
         )
     assert resp.status_code == 200, resp.text
-    return resp
+    return ws
 
 
 async def test_the_reported_case_leaves_one_holder(async_client: AsyncClient, db_session):
@@ -154,3 +154,10 @@ async def test_rewriting_the_targets_own_tag_does_not_clear_it_first(async_clien
     await _write(async_client, db_session, fake)
     assert fake.holders() == [TARGET]
     assert [w for w in fake.writes if w[0] == TARGET] == [(TARGET, {"tag": json.dumps(UID)})]
+
+
+async def test_pages_open_elsewhere_are_told_the_tag_moved(async_client: AsyncClient, db_session):
+    """Their tag -> spool map still has the previous holder (#3402)."""
+    fake = FakeSpoolman([_spool(1, tag=UID), _spool(TARGET)])
+    ws = await _write(async_client, db_session, fake)
+    assert {"type": "inventory_changed"} in [c.args[0] for c in ws.broadcast.await_args_list]

+ 201 - 0
backend/tests/integration/test_spoolman_slot_announcements_3402.py

@@ -0,0 +1,201 @@
+"""Spoolman slot changes are announced to pages open elsewhere (#3402).
+
+A page refetches its slot rows, and the tag -> spool map the slot cards fall
+back on, when the server announces a change over the websocket. A scale that
+assigned a spool through the API left the printer page's hover card on the
+previous spool until a reload. The assign route announces its slots since
+#1606; these are the other Spoolman routes that write or clear a slot row or a
+spool's tag, which built-in inventory already announces.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+TAG = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    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"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def client():
+    c = MagicMock()
+    c.base_url = "http://localhost:7912"
+    c.health_check = AsyncMock(return_value=True)
+    c.has_tag_api = AsyncMock(return_value=False)
+    c.add_native_tags = AsyncMock(return_value=0)
+    c.unlink_all_native_tags = AsyncMock()
+    c.ensure_tag_extra_field = AsyncMock(return_value=True)
+    c.ensure_extra_field = AsyncMock(return_value=True)
+    c.get_spools = AsyncMock(return_value=[])
+    c.get_spool = AsyncMock(return_value={"id": 5, "archived": False, "extra": {}})
+    c.merge_spool_extra = AsyncMock(return_value={"id": 5, "extra": {"tag": f'"{TAG}"'}})
+    with (
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=c)),
+        patch("backend.app.api.routes.spoolman.init_spoolman_client", AsyncMock(return_value=c)),
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=c)),
+        patch("backend.app.api.routes.spoolman.printer_manager.get_client", MagicMock(return_value=None)),
+    ):
+        yield c
+
+
+@pytest.fixture
+def ws():
+    """One websocket for both route modules: the announcement helper lives in
+    spoolman_inventory, the link routes in spoolman."""
+    w = MagicMock()
+    w.broadcast = AsyncMock()
+    with (
+        patch("backend.app.api.routes.spoolman_inventory.ws_manager", w),
+        patch("backend.app.api.routes.spoolman.ws_manager", w),
+    ):
+        yield w
+
+
+def _slots(ws) -> set[tuple[int, int, int]]:
+    return {
+        (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
+        for c in ws.broadcast.await_args_list
+        if c.args[0].get("type") == "spool_assignment_changed"
+    }
+
+
+def _inventory_changed(ws) -> bool:
+    return any(c.args[0].get("type") == "inventory_changed" for c in ws.broadcast.await_args_list)
+
+
+async def _row(db, printer_id: int, ams_id: int, tray_id: int, spool_id: int = 5) -> None:
+    db.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spoolman_spool_id=spool_id))
+    await db.commit()
+
+
+async def test_unassign_announces_every_slot_it_cleared(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    here = await printer_factory(name="Here")
+    there = await printer_factory(name="There")
+    await _row(db_session, here.id, 0, 0)
+    await _row(db_session, there.id, 1, 2)
+    await _row(db_session, there.id, 0, 3, spool_id=6)  # another spool's slot
+
+    response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/5")
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == {(here.id, 0, 0), (there.id, 1, 2)}
+
+
+async def test_unassign_of_a_spool_in_no_slot_announces_nothing(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/5")
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == set()
+
+
+async def test_a_link_onto_a_slot_announces_the_slot_and_the_tag(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    printer = await printer_factory()
+
+    response = await async_client.post(
+        "/api/v1/spoolman/spools/5/link",
+        json={"tray_uuid": TAG, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+    )
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == {(printer.id, 0, 1)}
+    assert _inventory_changed(ws)
+
+
+async def test_a_tag_only_link_announces_the_tag(async_client: AsyncClient, db_session, spoolman_settings, client, ws):
+    """No slot row, but the slot cards find a spool by its tag."""
+    response = await async_client.post("/api/v1/spoolman/spools/5/link", json={"tray_uuid": TAG})
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == set()
+    assert _inventory_changed(ws)
+
+
+async def test_unlink_announces_the_slots_it_cleared(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    printer = await printer_factory()
+    await _row(db_session, printer.id, 0, 2)
+    client.merge_spool_extra = AsyncMock(return_value={"id": 5, "extra": {"tag": '""'}})
+
+    response = await async_client.post("/api/v1/spoolman/spools/5/unlink")
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == {(printer.id, 0, 2)}
+    assert _inventory_changed(ws)
+
+
+async def test_a_spool_created_from_a_slot_is_announced(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    printer = await printer_factory()
+    tray = {"id": 3, "tag_uid": "0102030405060708", "tray_uuid": TAG, "tray_type": "PLA"}
+    status = SimpleNamespace(raw_data={"ams": [{"id": 0, "tray": [tray]}]})
+    client.parse_ams_tray = MagicMock(return_value=SimpleNamespace(tag_uid=tray["tag_uid"], tray_uuid=TAG))
+    client.sync_ams_tray = AsyncMock(return_value={"id": 9})
+
+    with patch("backend.app.api.routes.spoolman.printer_manager.get_status", MagicMock(return_value=status)):
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/from-slot", json={"printer_id": printer.id, "ams_id": 0, "tray_id": 3}
+        )
+
+    assert response.status_code == 200, response.text
+    assert _slots(ws) == {(printer.id, 0, 3)}
+    assert _inventory_changed(ws)
+
+
+async def test_the_assign_announces_after_the_previous_spool_lost_the_slots_tag(
+    async_client: AsyncClient, db_session, printer_factory, spoolman_settings, client, ws
+):
+    """The reported path: a page that refetches on the announcement must not
+    read the previous spool still holding the slot's fallback tag."""
+    import json
+
+    from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+
+    printer = await printer_factory(serial_number="01S00C123456789")
+    fallback = get_fallback_spool_tag_for_slot(printer.serial_number, 0, 0)
+    previous = {"id": 32, "archived": False, "extra": {"tag": json.dumps(fallback)}}
+    client.get_spools = AsyncMock(return_value=[previous])
+
+    async def _merge(spool_id, fields):
+        if spool_id == 32:
+            previous["extra"] = {**previous["extra"], **fields}
+        return {"id": spool_id, "extra": fields}
+
+    client.merge_spool_extra = AsyncMock(side_effect=_merge)
+    tag_when_announced: list[str] = []
+
+    async def _broadcast(message):
+        if message.get("type") == "spool_assignment_changed":
+            tag_when_announced.append(json.loads(previous["extra"]["tag"]))
+
+    ws.broadcast = AsyncMock(side_effect=_broadcast)
+
+    with patch("backend.app.api.routes.spoolman_inventory.printer_manager.get_client", MagicMock(return_value=None)):
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={"spoolman_spool_id": 5, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+        )
+
+    assert response.status_code == 200, response.text
+    assert tag_when_announced == [""], "announced while spool #32 still held the slot's tag"

+ 2 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -506,6 +506,8 @@ describe('useWebSocket hook', () => {
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
       // A weight synced or weighed elsewhere reaches printer cards through the slot rows (WU-9).
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+      // A Spoolman spool linked to a tag elsewhere: the slot cards find it by tag (#3402).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['linked-spools'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

+ 3 - 0
frontend/src/hooks/useWebSocket.ts

@@ -488,6 +488,9 @@ export function useWebSocket() {
         debouncedInvalidate('material-number-stats');
         // Printer cards and slot views read a spool's weight from the slot rows.
         debouncedInvalidate('spool-assignments');
+        // A Spoolman spool's tag is spool data too, and the slot cards find a
+        // spool by its tag (#3402).
+        debouncedInvalidate('linked-spools');
         break;
 
       case 'spool_assignment_changed':

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


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


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


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


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


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


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


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-397PQCdQ.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C1VA8N0f.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bi1NLJDY.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Wixdakxn.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-9hW1swN1.js">

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