Browse Source

Fix SpoolBuddy reading the filament type as the Bambu spool ID (#984)

The daemon took tray_uuid from tag blocks 4-5, which hold the filament
type, so every spool of one type sent the same value and the kiosk never
saved it. Read block 9, which both tags of a spool share and the AMS
reports as tray_uuid. Save it on kiosk quick-add and link in local and
Spoolman mode, and give spools added before the fix their tray_uuid when
their exact tag UID is scanned again. Drop any tray_uuid that is not a v4
UUID, so daemons that are not updated yet keep matching by tag UID.

Block 9 read based on #1200 by @Keybored02.

Co-Authored-By: Keybored02 <Keybored02@users.noreply.github.com>
maziggy 1 day ago
parent
commit
12e543b1b4

+ 5 - 0
CHANGELOG.md

@@ -72,6 +72,11 @@ 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
+- **SpoolBuddy recognises a Bambu Lab spool from either of its two tags, and finds the spool the AMS already created (#984, block 9 read from @Keybored02's #1200, measured by @Thomansky and @Sawtaytoes)** — A Bambu Lab spool has a tag on each side. The two have different tag UIDs, but share one spool ID, the same one the AMS reports. SpoolBuddy read that ID from the wrong part of the tag: the bytes it used hold the filament type, so every "PLA Matte" spool sent the same "ID". The kiosk then didn't save it, so a spool was only known by the UID of the tag that was scanned. Scanning the other side offered to add the spool again, and a spool added on the kiosk was not matched when it went into the AMS, and the other way round.
+  - The SpoolBuddy daemon now reads the real spool ID (block 9). **Update the SpoolBuddy daemon** to get the fix: on the kiosk, Settings → Updates.
+  - Add to Inventory and Assign Spool on the kiosk save the spool ID with the tag, also when the spool was lifted off the reader while the dialog was open. In Spoolman mode the spool ID is stored as the spool's tag, as the AMS sync already does.
+  - Bambuddy ignores a spool ID that is not a real one, so a SpoolBuddy that has not been updated yet keeps matching by tag UID as before and never saves the filament type as an ID.
+  - Spools added on the kiosk before this fix get the spool ID the next time the tag they were added with is scanned. After that, the other tag and the AMS find them too. This only happens when the scanned tag UID matches the spool's exactly, the spool has no spool ID yet, and no other spool carries that ID. A near match on the tag UID, which a different reader can produce, never writes anything. In Spoolman mode the spool ID replaces the tag UID as the spool's tag.
 - **File names could vanish from the File Manager's list view (#3105, reported and contributed by @akshayy29 in #3119)** — With authentication on, the list has an extra Uploaded By column. When the folder sidebar left less room than the fixed columns needed, the Name column shrank to nothing, and the Uploaded By value sat right next to the thumbnail. The Name column now keeps at least 240px and the Tags column at least 96px. When there isn't room for both, the list scrolls sideways (it already could) instead of hiding them.
 - **PostgreSQL installs no longer warn at every start that the connection pool may exceed the server** — The built-in pool allowed up to 100 connections (`pool_size` 20 + `max_overflow` 80), but a stock PostgreSQL allows 97 (`max_connections` 100 minus 3 reserved for superusers). So every install that hadn't set `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` logged `DB pool may exceed what PostgreSQL allows` at startup, and under heavy load could hit `TooManyConnectionsError`. The default `max_overflow` is now 60, an 80-connection ceiling that fits. Installs that set their own values are unaffected, and large farms can still raise both, along with the server's `max_connections`.
 - **AMS slots that lost their K-profile after a printer restart stayed on the default K, and queued jobs printed with it (#3219)** — A printer that is power-cycled can come back with every slot's K-profile selection cleared (`cali_idx` -1) while the spools, tags and remaining amounts are unchanged. Bambuddy only looked at K-profiles when one of those changed, so it never noticed, and the next queued jobs started on the default K without a warning. Now, while the printer is idle, a loaded slot whose selection is gone gets its spool's stored K-profile back, for built-in and Spoolman inventory and for the external spool. The queue also checks the slots a job uses right before sending it. Only a lost selection is restored: a different profile picked in Bambu Studio is left alone, and so is **Default** picked in Bambuddy's Configure Slot dialog. A slot is retried at most three times, 30 seconds apart; if the profile still doesn't stick (for example because it was deleted on the printer), the log says so and Bambuddy stops until the slot changes. On dual-nozzle printers a slot whose nozzle isn't known yet is skipped rather than guessed.

+ 78 - 9
backend/app/api/routes/spoolbuddy.py

@@ -10,13 +10,14 @@ from urllib.parse import urlparse
 
 import httpx
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.websocket import ws_manager
+from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 from backend.app.schemas.spoolbuddy import (
@@ -42,6 +43,7 @@ from backend.app.schemas.spoolbuddy import (
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
+from backend.app.utils.tag_normalization import is_bambu_tray_uuid, normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
 
@@ -374,6 +376,55 @@ async def device_heartbeat(
 # --- NFC endpoints ---
 
 
+async def _backfill_local_tray_uuid(db: AsyncSession, spool: Spool, tag_uid: str, tray_uuid: str) -> None:
+    """Give a spool matched by its exact tag UID the tray UUID read from that tag.
+
+    Spools added on the kiosk before #984 carry only the UID of the tag that was
+    scanned then, so the spool's other tag and the AMS never found them. The scan
+    read block 9 of this very tag, so the UUID belongs to this spool. Only an
+    exact UID match counts: a fuzzy (suffix or first-byte) match may be another
+    spool's tag and must never write anything. A UUID any other spool carries,
+    archived ones included, is left alone.
+    """
+    if spool.tray_uuid or normalize_tag_uid(spool.tag_uid) != normalize_tag_uid(tag_uid):
+        return
+    try:
+        holder = await db.execute(
+            select(Spool.id).where(func.upper(Spool.tray_uuid) == tray_uuid, Spool.id != spool.id).limit(1)
+        )
+        if holder.scalar_one_or_none() is not None:
+            return
+        spool.tray_uuid = tray_uuid
+        await db.commit()
+        logger.info("SpoolBuddy: saved tray_uuid %s on spool %d, matched by tag %s", tray_uuid, spool.id, tag_uid)
+    except Exception:
+        await db.rollback()
+        logger.exception("SpoolBuddy: could not save tray_uuid %s on spool %d", tray_uuid, spool.id)
+
+
+async def _backfill_spoolman_tray_uuid(client, sm_spool: dict, tag_uid: str, tray_uuid: str) -> None:
+    """Store the tray UUID as the tag of a Spoolman spool matched by its exact tag UID.
+
+    Spoolman has one extra.tag per spool, and the AMS sync keys Bambu spools by
+    the tray UUID there, so the UUID replaces the tag UID. The caller already
+    looked the UUID up among the active spools and found none, the same check
+    the link route makes. See _backfill_local_tray_uuid for why only an exact
+    UID match counts.
+    """
+    extra = sm_spool.get("extra")
+    raw_tag = extra.get("tag") if isinstance(extra, dict) else None
+    stored = raw_tag.strip('"').upper() if isinstance(raw_tag, str) else ""
+    spool_id = sm_spool.get("id")
+    if not isinstance(spool_id, int) or not stored or stored != tag_uid.strip('"').upper():
+        return
+    try:
+        await client.merge_spool_extra(spool_id, {"tag": json.dumps(tray_uuid)})
+        logger.info("SpoolBuddy: stored tray_uuid %s as the tag of Spoolman spool %d", tray_uuid, spool_id)
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    except Exception:
+        logger.exception("SpoolBuddy: could not store tray_uuid %s on Spoolman spool %d", tray_uuid, spool_id)
+
+
 @router.post("/nfc/tag-scanned")
 async def nfc_tag_scanned(
     req: TagScannedRequest,
@@ -393,6 +444,20 @@ async def nfc_tag_scanned(
     """
     from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
+    # Daemons before #984 read the filament type from blocks 4-5 and sent it as
+    # tray_uuid. Drop anything that is not a real tray UUID, so an old daemon
+    # falls back to tag_uid matching and its value is never offered for saving.
+    tray_uuid: str | None = normalize_tray_uuid(req.tray_uuid) or None
+    if tray_uuid and not is_bambu_tray_uuid(tray_uuid):
+        logger.info(
+            "SpoolBuddy %s sent tray_uuid %s for tag %s, which is not a Bambu tray UUID; ignoring it. "
+            "Update the SpoolBuddy daemon.",
+            req.device_id,
+            tray_uuid,
+            req.tag_uid,
+        )
+        tray_uuid = None
+
     # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
     # is true (and the URL passes the SSRF guard), None otherwise — so its
     # return value doubles as the mode discriminator.
@@ -403,8 +468,8 @@ async def nfc_tag_scanned(
         try:
             cached_spools = await client.get_spools()
             sm_spool: dict | None = None
-            if req.tray_uuid:
-                sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
+            if tray_uuid:
+                sm_spool = await client.find_spool_by_tag(tray_uuid, cached_spools=cached_spools)
             if sm_spool is None and req.tag_uid:
                 sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
 
@@ -415,7 +480,7 @@ async def nfc_tag_scanned(
                         "type": "spoolbuddy_tag_matched",
                         "device_id": req.device_id,
                         "tag_uid": req.tag_uid,
-                        "tray_uuid": req.tray_uuid,
+                        "tray_uuid": tray_uuid,
                         "spool": {
                             "id": mapped["id"],
                             "material": mapped["material"],
@@ -439,6 +504,8 @@ async def nfc_tag_scanned(
                     }
                 )
                 logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
+                if tray_uuid:
+                    await _backfill_spoolman_tray_uuid(client, sm_spool, req.tag_uid, tray_uuid)
                 return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
         except ValueError as exc:
             logger.error(
@@ -481,14 +548,14 @@ async def nfc_tag_scanned(
             return {"status": "ok", "matched": False, "spool_id": None}
     else:
         # Local mode — exclusive lookup, no Spoolman fallback.
-        spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+        spool = await get_spool_by_tag(db, req.tag_uid, tray_uuid or "")
         if spool:
             await ws_manager.broadcast(
                 {
                     "type": "spoolbuddy_tag_matched",
                     "device_id": req.device_id,
                     "tag_uid": req.tag_uid,
-                    "tray_uuid": req.tray_uuid,
+                    "tray_uuid": tray_uuid,
                     "spool": {
                         "id": spool.id,
                         "material": spool.material,
@@ -508,6 +575,8 @@ async def nfc_tag_scanned(
                 }
             )
             logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
+            if tray_uuid:
+                await _backfill_local_tray_uuid(db, spool, req.tag_uid, tray_uuid)
             return {"status": "ok", "matched": True, "spool_id": spool.id}
 
     await ws_manager.broadcast(
@@ -515,7 +584,7 @@ async def nfc_tag_scanned(
             "type": "spoolbuddy_unknown_tag",
             "device_id": req.device_id,
             "tag_uid": req.tag_uid,
-            "tray_uuid": req.tray_uuid,
+            "tray_uuid": tray_uuid,
             "sak": req.sak,
             "tag_type": req.tag_type,
         }
@@ -524,8 +593,8 @@ async def nfc_tag_scanned(
         "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
         req.tag_uid,
         len(req.tag_uid or ""),
-        req.tray_uuid,
-        len(req.tray_uuid or ""),
+        tray_uuid,
+        len(tray_uuid or ""),
         req.tag_type,
         req.sak,
     )

+ 18 - 0
backend/app/utils/tag_normalization.py

@@ -1,5 +1,7 @@
 """Shared helpers for normalizing RFID tag and tray identifiers."""
 
+import uuid
+
 
 def normalize_hex(value: str | None) -> str:
     if not value:
@@ -22,3 +24,19 @@ def normalize_tray_uuid(value: str | None) -> str:
     if len(uuid) >= 32:
         uuid = uuid[:32]
     return uuid
+
+
+def is_bambu_tray_uuid(value: str | None) -> bool:
+    """True when ``value`` is a tray UUID a Bambu tag really carries.
+
+    Every tray_uuid seen from the AMS and from tag block 9 is an RFC 4122
+    version-4 UUID. SpoolBuddy daemons before #984 sent tag blocks 4-5 instead,
+    which hold the filament type ("PLA Matte" as hex) and fail this check, so
+    every spool of one type would share an id if such a value were matched or
+    stored.
+    """
+    normalized = normalize_tray_uuid(value)
+    if len(normalized) != 32:
+        return False
+    parsed = uuid.UUID(hex=normalized)
+    return parsed.version == 4 and parsed.variant == uuid.RFC_4122

+ 224 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -555,6 +555,230 @@ class TestNfcEndpoints:
         assert bind_call.args[0] == 22
         assert bind_call.args[1] == {"tag": '"DEADBEEF"'}
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_other_tag_matches_ams_spool_by_tray_uuid(self, async_client: AsyncClient, spool_factory):
+        """A Bambu spool has two tags with different UIDs but one block-9 tray UUID.
+
+        The AMS created the spool from one tag; scanning the other tag on
+        SpoolBuddy must find that spool through the tray UUID, not offer a
+        duplicate (#984).
+        """
+        spool = await spool_factory(tag_uid="1E783DA000000100", tray_uuid="5E5498918CBF4B94A25EF669C24DECC3")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "5E5498918CBF4B94A25EF669C24DECC3"},
+            )
+
+        assert resp.status_code == 200
+        assert resp.json()["spool_id"] == spool.id
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["tray_uuid"] == "5E5498918CBF4B94A25EF669C24DECC3"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_unknown_tag_carries_tray_uuid(self, async_client: AsyncClient):
+        """The kiosk saves the tray UUID from the unknown-tag event on quick-add and link."""
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "9e0b0717bee94d7887eb1d8dfd1a14f3"},
+            )
+
+        assert resp.json()["matched"] is False
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+        assert msg["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_drops_filament_type_sent_as_tray_uuid(self, async_client: AsyncClient, spool_factory):
+        """Daemons before #984 sent blocks 4-5 -- the filament type -- as tray_uuid.
+
+        That value is the same for every spool of one type, so it must neither
+        match a spool nor reach the kiosk, where quick-add would save it.
+        """
+        pla_matte = "504C41204D6174746500000000000000"  # "PLA Matte"
+        await spool_factory(tag_uid="11111111", tray_uuid=pla_matte)
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": pla_matte},
+            )
+
+        assert resp.json()["matched"] is False
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+        assert msg["tray_uuid"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_drops_invalid_tray_uuid_but_matches_tag_uid(
+        self, async_client: AsyncClient, spool_factory
+    ):
+        """An old daemon still matches by tag_uid, as before the fix."""
+        spool = await spool_factory(tag_uid="22222222")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": "504C41204D6174746500000000000000"},
+            )
+
+        assert resp.json()["spool_id"] == spool.id
+        assert mock_ws.broadcast.call_args[0][0]["tray_uuid"] is None
+
+    # Spools added on the kiosk before #984 carry only one tag's UID. A scan that
+    # matches one by that exact UID saves the tray UUID read from the same tag.
+    TRAY = "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+
+    async def _scan(self, async_client: AsyncClient, tag_uid: str, tray_uuid: str | None):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": tag_uid, "tray_uuid": tray_uuid},
+            )
+        assert resp.status_code == 200
+        return resp.json()
+
+    async def _tray_uuid_of(self, db_session: AsyncSession, spool_id: int) -> str | None:
+        from sqlalchemy import select
+
+        db_session.expire_all()
+        return (await db_session.execute(select(Spool.tray_uuid).where(Spool.id == spool_id))).scalar_one()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_exact_tag_match_saves_tray_uuid(self, async_client: AsyncClient, spool_factory, db_session):
+        spool_id = (await spool_factory(tag_uid="AABB1122")).id
+
+        data = await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert data["spool_id"] == spool_id
+        assert await self._tray_uuid_of(db_session, spool_id) == self.TRAY
+        # The spool's other tag now finds it.
+        assert (await self._scan(async_client, "8E3A00A2", self.TRAY))["spool_id"] == spool_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fuzzy_tag_match_saves_nothing(self, async_client: AsyncClient, spool_factory, db_session):
+        """A first-byte-variance match may be another spool's tag."""
+        spool = await spool_factory(tag_uid="BABB1122")
+
+        data = await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert data["spool_id"] == spool.id
+        assert await self._tray_uuid_of(db_session, spool.id) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_held_by_another_spool_is_not_copied(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        from datetime import datetime, timezone
+
+        await spool_factory(tag_uid="11111111", tray_uuid=self.TRAY, archived_at=datetime.now(timezone.utc))
+        spool = await spool_factory(tag_uid="AABB1122")
+
+        await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert await self._tray_uuid_of(db_session, spool.id) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_existing_tray_uuid_is_kept(self, async_client: AsyncClient, spool_factory, db_session):
+        other = "5E5498918CBF4B94A25EF669C24DECC3"
+        spool = await spool_factory(tag_uid="AABB1122", tray_uuid=other)
+
+        await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert await self._tray_uuid_of(db_session, spool.id) == other
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_exact_tag_match_stores_tray_uuid_as_tag(self, async_client: AsyncClient):
+        sm_spool = {
+            "id": 7,
+            "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
+            "extra": {"tag": '"AABB1122"'},
+            "used_weight": 0.0,
+        }
+        mock_client = MagicMock()
+        mock_client.get_spools = AsyncMock(return_value=[sm_spool])
+        mock_client.find_spool_by_tag = AsyncMock(
+            side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
+        )
+        mock_client.merge_spool_extra = AsyncMock(return_value={})
+
+        with patch(
+            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
+            new_callable=AsyncMock,
+            return_value=mock_client,
+        ):
+            data = await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert data["spool_id"] == 7
+        mock_client.merge_spool_extra.assert_awaited_once_with(7, {"tag": f'"{self.TRAY}"'})
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_match_by_tray_uuid_writes_nothing(self, async_client: AsyncClient):
+        sm_spool = {
+            "id": 7,
+            "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
+            "extra": {"tag": f'"{self.TRAY}"'},
+            "used_weight": 0.0,
+        }
+        mock_client = MagicMock()
+        mock_client.get_spools = AsyncMock(return_value=[sm_spool])
+        mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
+        mock_client.merge_spool_extra = AsyncMock(return_value={})
+
+        with patch(
+            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
+            new_callable=AsyncMock,
+            return_value=mock_client,
+        ):
+            data = await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert data["spool_id"] == 7
+        mock_client.merge_spool_extra.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_write_failure_still_reports_the_match(self, async_client: AsyncClient):
+        sm_spool = {
+            "id": 7,
+            "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
+            "extra": {"tag": '"AABB1122"'},
+            "used_weight": 0.0,
+        }
+        mock_client = MagicMock()
+        mock_client.get_spools = AsyncMock(return_value=[sm_spool])
+        mock_client.find_spool_by_tag = AsyncMock(
+            side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
+        )
+        mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanUnavailableError("down"))
+
+        with patch(
+            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
+            new_callable=AsyncMock,
+            return_value=mock_client,
+        ):
+            data = await self._scan(async_client, "AABB1122", self.TRAY)
+
+        assert data["matched"] is True
+        assert data["spool_id"] == 7
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_tag_removed(self, async_client: AsyncClient):

+ 12 - 8
backend/tests/integration/test_spoolbuddy_spoolman_nfc.py

@@ -83,7 +83,11 @@ def _mock_spoolman_client_local() -> MagicMock:
 
 
 class TestTagScannedBroadcastsTrayUuid:
-    """nfc/tag-scanned broadcasts include tray_uuid from the request payload."""
+    """nfc/tag-scanned broadcasts include tray_uuid from the request payload.
+
+    The values are real block-9 tray UUIDs: anything else is dropped as a
+    pre-#984 daemon value (see test_spoolbuddy.py).
+    """
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -116,7 +120,7 @@ class TestTagScannedBroadcastsTrayUuid:
                 json={
                     "device_id": "sb-test",
                     "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
+                    "tray_uuid": "9E0B0717BEE94D7887EB1D8DFD1A14F3",
                 },
             )
 
@@ -126,7 +130,7 @@ class TestTagScannedBroadcastsTrayUuid:
         msg = mock_ws.broadcast.call_args[0][0]
         assert msg["type"] == "spoolbuddy_tag_matched"
         assert msg["tag_uid"] == "AABB1122334455FF"
-        assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
+        assert msg["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -135,7 +139,7 @@ class TestTagScannedBroadcastsTrayUuid:
     ):
         """Spoolman fallback match broadcasts tray_uuid alongside tag_uid."""
         sm_spool = _spoolman_spool(5)
-        sm_spool["extra"] = {"tag": '"DEADBEEFDEADBEEFDEADBEEFDEADBEEF"'}
+        sm_spool["extra"] = {"tag": '"9E0B0717BEE94D7887EB1D8DFD1A14F3"'}
         mock_client = _mock_spoolman_client_local()
         mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
 
@@ -161,7 +165,7 @@ class TestTagScannedBroadcastsTrayUuid:
                 json={
                     "device_id": "sb-test",
                     "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
+                    "tray_uuid": "9E0B0717BEE94D7887EB1D8DFD1A14F3",
                 },
             )
 
@@ -170,7 +174,7 @@ class TestTagScannedBroadcastsTrayUuid:
         mock_ws.broadcast.assert_called_once()
         msg = mock_ws.broadcast.call_args[0][0]
         assert msg["type"] == "spoolbuddy_tag_matched"
-        assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
+        assert msg["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -201,7 +205,7 @@ class TestTagScannedBroadcastsTrayUuid:
                 json={
                     "device_id": "sb-test",
                     "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "CAFEBABECAFEBABECAFEBABECAFEBABE",
+                    "tray_uuid": "5E5498918CBF4B94A25EF669C24DECC3",
                 },
             )
 
@@ -210,7 +214,7 @@ class TestTagScannedBroadcastsTrayUuid:
         mock_ws.broadcast.assert_called_once()
         msg = mock_ws.broadcast.call_args[0][0]
         assert msg["type"] == "spoolbuddy_unknown_tag"
-        assert msg["tray_uuid"] == "CAFEBABECAFEBABECAFEBABECAFEBABE"
+        assert msg["tray_uuid"] == "5E5498918CBF4B94A25EF669C24DECC3"
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 35 - 0
backend/tests/unit/test_tag_normalization_tray_uuid.py

@@ -0,0 +1,35 @@
+"""Tests for is_bambu_tray_uuid -- telling a real tray UUID from the pre-#984 filament type."""
+
+import pytest
+
+from backend.app.utils.tag_normalization import is_bambu_tray_uuid
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        # Measured: AMS MQTT tray_uuid == tag block 9 (#984)
+        "9E0B0717BEE94D7887EB1D8DFD1A14F3",
+        "5E5498918CBF4B94A25EF669C24DECC3",
+        "9e0b0717bee94d7887eb1d8dfd1a14f3",
+        "9E0B0717-BEE9-4D78-87EB-1D8DFD1A14F3",
+    ],
+)
+def test_real_tray_uuids_pass(value):
+    assert is_bambu_tray_uuid(value) is True
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        "504C41204D6174746500000000000000",  # "PLA Matte", blocks 4-5 read as tray_uuid
+        "00000000000000000000000000000000",
+        "9E0B0717BEE91D7887EB1D8DFD1A14F3",  # version 1
+        "9E0B0717BEE94D78C7EB1D8DFD1A14F3",  # variant not RFC 4122
+        "9E0B0717BEE94D7887EB1D8DFD1A14",  # too short
+        "",
+        None,
+    ],
+)
+def test_other_values_fail(value):
+    assert is_bambu_tray_uuid(value) is False

+ 179 - 2
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -350,7 +350,7 @@ describe('SpoolBuddyDashboard', () => {
       });
     });
 
-    it('calls linkTagToSpoolmanSpool with tag_uid when linking in Spoolman mode', async () => {
+    it('sends the tray UUID when linking a Bambu tag in Spoolman mode (#984)', async () => {
       const { api } = await import('../../api/client');
       (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
         spoolman_enabled: 'true',
@@ -365,7 +365,7 @@ describe('SpoolBuddyDashboard', () => {
 
       renderPage({
         unknownTagUid: 'AABB1122334455FF',
-        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
+        unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
       });
 
       const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
@@ -377,6 +377,35 @@ describe('SpoolBuddyDashboard', () => {
       const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
       fireEvent.click(confirmBtn);
 
+      // The route stores tray_uuid over tag_uid -- the value the AMS keeps in
+      // extra.tag, and the same on both tags of the spool.
+      await waitFor(() => {
+        expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
+          tag_uid: 'AABB1122334455FF',
+          tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
+        });
+      });
+    });
+
+    it('links by tag_uid alone in Spoolman mode when the tag has no tray UUID', async () => {
+      const { api } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+      ]);
+
+      renderPage({ unknownTagUid: 'AABB1122334455FF' });
+
+      fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
+      fireEvent.click(await waitFor(() => screen.getByText('Orange')));
+      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
+
       await waitFor(() => {
         expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
           tag_uid: 'AABB1122334455FF',
@@ -385,6 +414,51 @@ describe('SpoolBuddyDashboard', () => {
       });
     });
 
+    it('shows the spool card for a Spoolman spool stored under the tag\'s tray UUID (#984)', async () => {
+      // After quick-add or link, Spoolman's extra.tag holds the tray UUID and the
+      // spool comes back with tray_uuid set and tag_uid null. The card must still
+      // find it, or the kiosk keeps offering "Add to Inventory" until a re-scan.
+      const { api } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { id: 40, material: 'PLA', brand: 'Bambu', tag_uid: null, tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3', archived_at: null, color_name: 'Teal', rgba: '008080FF', subtype: 'Matte', label_weight: 1000, core_weight: 250, weight_used: 0 },
+      ]);
+
+      renderPage({ unknownTagUid: 'AABB1122', unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3' });
+
+      await waitFor(() => expect(screen.getByText('Sync Weight')).toBeDefined());
+      expect(screen.queryByText('Add to Inventory')).toBeNull();
+    });
+
+    it('quick-adds a Spoolman spool linked by its tray UUID (#984)', async () => {
+      const { api } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+
+      renderPage({ unknownTagUid: 'AABB1122334455FF', unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3' });
+
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
+
+      await waitFor(() => {
+        expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(4, {
+          tag_uid: 'AABB1122334455FF',
+          tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
+        });
+      });
+    });
+
     it('quick-adds a Spoolman spool without a tare, so it inherits the filament type\'s (#2908)', async () => {
       // The quick-create has no tare input. The 250 it used to send was a
       // placeholder, and since #2908 a sent value lands on the spool's own
@@ -669,6 +743,109 @@ describe('SpoolBuddyDashboard', () => {
     });
   });
 
+  describe('Bambu tray UUID in local mode (#984)', () => {
+    const TRAY_UUID = '9E0B0717BEE94D7887EB1D8DFD1A14F3';
+
+    beforeEach(async () => {
+      const { api } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'false',
+        spoolman_url: '',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+      (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+      ]);
+    });
+
+    it('links the tag together with its tray UUID as a Bambu Lab tag', async () => {
+      const { api } = await import('../../api/client');
+      renderPage({ unknownTagUid: 'AABB9999', unknownTrayUuid: TRAY_UUID });
+
+      fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
+      fireEvent.click(await waitFor(() => screen.getByText('White')));
+      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
+
+      await waitFor(() => {
+        expect(api.linkTagToSpool).toHaveBeenCalledWith(3, {
+          tag_uid: 'AABB9999',
+          tray_uuid: TRAY_UUID,
+          tag_type: 'bambulab',
+          data_origin: 'nfc_link',
+        });
+      });
+    });
+
+    it('quick-adds the spool with its tray UUID, so the other tag and the AMS find it', async () => {
+      const { api } = await import('../../api/client');
+      renderPage({ unknownTagUid: 'AABB9999', unknownTrayUuid: TRAY_UUID });
+
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
+      expect(payload.tag_uid).toBe('AABB9999');
+      expect(payload.tray_uuid).toBe(TRAY_UUID);
+      expect(payload.tag_type).toBe('bambulab');
+    });
+
+    it('quick-adds a tag without a tray UUID as a generic tag, as before', async () => {
+      const { api } = await import('../../api/client');
+      renderPage({ unknownTagUid: 'AABB9999' });
+
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
+      expect(payload.tray_uuid).toBeNull();
+      expect(payload.tag_type).toBe('generic');
+    });
+
+    it('still saves the tray UUID when the spool is lifted off the reader while quick-add is open', async () => {
+      const { api } = await import('../../api/client');
+      const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
+      function DynWrapper() {
+        const [sbState, setSbState] = React.useState({
+          ...mockOutletContext.sbState,
+          unknownTagUid: 'AABB9999',
+          unknownTrayUuid: TRAY_UUID,
+        });
+        setterRef.current = setSbState;
+        return <Outlet context={{ ...mockOutletContext, sbState }} />;
+      }
+      const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+      render(
+        <ToastProvider>
+          <QueryClientProvider client={qc}>
+            <MemoryRouter initialEntries={['/spoolbuddy']}>
+              <Routes>
+                <Route element={<DynWrapper />}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                </Route>
+              </Routes>
+            </MemoryRouter>
+          </QueryClientProvider>
+        </ToastProvider>
+      );
+      fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
+      await waitFor(() => screen.getByText('Add Anyway'));
+
+      // Tag removed: the dialog stays open, the live tag state is gone.
+      act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: null, unknownTrayUuid: null })));
+
+      fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
+
+      await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
+      const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
+      expect(payload.tag_uid).toBe('AABB9999');
+      expect(payload.tray_uuid).toBe(TRAY_UUID);
+    });
+  });
+
   describe('Spoolman mode', () => {
     const SPOOLMAN_SPOOL = {
       id: 42, material: 'PLA', subtype: null, brand: 'Bambu',

+ 23 - 7
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -228,6 +228,10 @@ export function SpoolBuddyDashboard() {
 
   // Current Spool card state - persists until user closes or new tag detected
   const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
+  // Block-9 tray UUID of the displayed unknown tag. Kept with the card, like
+  // displayedTagId, so quick-add and link still save it after the spool is
+  // lifted off the reader (#984).
+  const [displayedTrayUuid, setDisplayedTrayUuid] = useState<string | null>(null);
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
   const [showLinkModal, setShowLinkModal] = useState(false);
@@ -238,6 +242,7 @@ export function SpoolBuddyDashboard() {
 
   // Track current tag from state
   const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
+  const currentTrayUuid = sbState.unknownTagUid ? sbState.unknownTrayUuid : null;
   const currentWeight = sbState.weight;
   const weightStable = sbState.weightStable;
 
@@ -260,6 +265,12 @@ export function SpoolBuddyDashboard() {
     if (!displayedTagId) return null;
     const byTag = spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId));
     if (byTag) return byTag;
+    // A Bambu spool quick-added or linked in Spoolman mode carries only the tray
+    // UUID: Spoolman has one extra.tag and the UUID wins over the tag UID (#984).
+    if (displayedTrayUuid) {
+      const byTrayUuid = spools.find((s) => s.tray_uuid?.toUpperCase() === displayedTrayUuid);
+      if (byTrayUuid) return byTrayUuid;
+    }
     // When a Bambu tray UUID (32-char) is linked, Spoolman stores it in extra.tag and
     // _map_spoolman_spool routes it to tray_uuid, not tag_uid. tagsEquivalent only
     // compares tag_uid, so it misses this spool until the device re-scans and
@@ -268,7 +279,7 @@ export function SpoolBuddyDashboard() {
     // present in MatchedSpool; AssignToAmsModal is guarded by !justLinkedSpool below.
     if (justLinkedSpool) return justLinkedSpool as unknown as InventorySpool;
     return null;
-  }, [displayedTagId, sbState.matchedSpool, spools, justLinkedSpool]);
+  }, [displayedTagId, displayedTrayUuid, sbState.matchedSpool, spools, justLinkedSpool]);
 
   // Effective spool for the Assign-to-AMS modal: prefer the fully-typed
   // InventorySpool from the local query cache, fall back to the
@@ -321,6 +332,7 @@ export function SpoolBuddyDashboard() {
 
       if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
         setDisplayedTagId(currentTagId);
+        setDisplayedTrayUuid(currentTrayUuid);
         setDisplayedWeight(null);
         setHiddenTagId(null);
         setJustLinkedSpool(null);
@@ -334,12 +346,13 @@ export function SpoolBuddyDashboard() {
       // Tag removed - clear hidden state so same tag can show when re-placed
       if (hiddenTagId) {
         setDisplayedTagId(null);
+        setDisplayedTrayUuid(null);
         setHiddenTagId(null);
         setDisplayedWeight(null);
         setJustLinkedSpool(null);
       }
     }
-  }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
+  }, [currentTagId, currentTrayUuid, currentWeight, weightStable, displayedTagId, hiddenTagId]);
 
   // Auto-sync weight once when known spool first detected
 
@@ -351,8 +364,10 @@ export function SpoolBuddyDashboard() {
     if (!displayedTagId) return;
     try {
       if (spoolmanMode) {
+        // The tray UUID wins when both are sent: it is what the AMS stores in
+        // extra.tag, and the same on both tags of a Bambu spool (#984).
         const tag_uid = sbState.unknownTagUid || undefined;
-        const tray_uuid = (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined;
+        const tray_uuid = displayedTrayUuid || undefined;
         if (!tag_uid && !tray_uuid) {
           showToast(t('spoolman.linkFailed'), 'error');
           return;
@@ -383,7 +398,8 @@ export function SpoolBuddyDashboard() {
       } else {
         await api.linkTagToSpool(spool.id, {
           tag_uid: displayedTagId,
-          tag_type: 'generic',
+          ...(displayedTrayUuid ? { tray_uuid: displayedTrayUuid } : {}),
+          tag_type: displayedTrayUuid ? 'bambulab' : 'generic',
           data_origin: 'nfc_link',
         });
       }
@@ -448,7 +464,7 @@ export function SpoolBuddyDashboard() {
         } as Parameters<typeof api.createSpoolmanInventorySpool>[0]);
         await api.linkTagToSpoolmanSpool(created.id, {
           tag_uid: sbState.unknownTagUid || undefined,
-          tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined,
+          tray_uuid: displayedTrayUuid || undefined,
         });
       } else {
         await api.createSpool({
@@ -472,9 +488,9 @@ export function SpoolBuddyDashboard() {
           last_used: null,
           encode_time: null,
           tag_uid: displayedTagId,
-          tray_uuid: null,
+          tray_uuid: displayedTrayUuid,
           data_origin: 'spoolbuddy',
-          tag_type: 'generic',
+          tag_type: displayedTrayUuid ? 'bambulab' : 'generic',
           cost_per_kg: null,
           last_scale_weight: weight !== null ? Math.round(weight) : null,
           last_weighed_at: weight !== null ? new Date().toISOString() : null,

+ 14 - 24
spoolbuddy/daemon/nfc_reader.py

@@ -239,27 +239,17 @@ class NFCReader:
 
 
 def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
-    """Extract tray_uuid from Bambu MIFARE Classic data blocks."""
-    # Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes.
-    if 4 in blocks and 5 in blocks:
-        raw = blocks[4] + blocks[5]
-        try:
-            # Preferred path: decode full ASCII payload, keep only hex chars.
-            ascii_candidate = raw.decode("ascii", errors="ignore")
-            hex_chars = "".join(ch for ch in ascii_candidate if ch in "0123456789abcdefABCDEF")
-            if len(hex_chars) >= 32:
-                uuid_str = hex_chars[:32].upper()
-                if uuid_str != "0" * 32:
-                    return uuid_str
-        except Exception:
-            pass
-
-        try:
-            # Fallback for partially decoded payloads: use first 16 raw bytes as hex.
-            # This preserves compatibility with older decoding behavior.
-            uuid_str = raw[:16].hex().upper()
-            if uuid_str and uuid_str != "0" * 32:
-                return uuid_str
-        except Exception:
-            pass
-    return None
+    """Extract tray_uuid from Bambu MIFARE Classic data blocks.
+
+    The tray UUID is the 16 raw bytes of block 9, as 32 uppercase hex chars.
+    Both tags of a spool carry the same value, and it is what the AMS reports
+    as tray_uuid. Blocks 4-5 hold the filament type ("PLA Matte"), not the UUID:
+    reading them made every spool of one type share an id (#984).
+    """
+    data = blocks.get(9)
+    if not data or len(data) < 16:
+        return None
+    uuid_str = data[:16].hex().upper()
+    if uuid_str == "0" * 32:
+        return None
+    return uuid_str

+ 3 - 2
spoolbuddy/daemon/pn5180.py

@@ -61,8 +61,9 @@ BAMBU_MASTER_KEY = bytes(
 )
 BAMBU_CONTEXT = b"RFID-A\x00"  # 7 bytes including null terminator
 
-# Blocks to read for Bambu tag data
-BAMBU_BLOCKS = [1, 2, 4, 5]
+# Blocks to read for Bambu tag data. Block 9 (sector 2) holds the tray UUID,
+# the same on both tags of a spool and equal to the AMS's tray_uuid (#984).
+BAMBU_BLOCKS = [1, 2, 4, 5, 9]
 
 
 def hkdf_derive_keys(uid: bytes) -> bytes:

+ 5 - 5
spoolbuddy/daemon/tag_parser.py

@@ -7,7 +7,8 @@ logger = logging.getLogger(__name__)
 # Bambu tag block layout (MIFARE Classic 1K):
 # Block 1: material type (bytes 0-7), color info (bytes 8-15)
 # Block 2: temperatures, weights
-# Block 4-5: tray UUID (32 hex chars across 2 blocks)
+# Block 4: detailed filament type ("PLA Matte")
+# Block 9: tray UUID (16 bytes), same on both tags, equals the AMS tray_uuid
 
 
 def parse_bambu_blocks(blocks: dict[int, bytes]) -> dict:
@@ -21,10 +22,9 @@ def parse_bambu_blocks(blocks: dict[int, bytes]) -> dict:
     """
     result = {}
 
-    # Extract tray UUID from blocks 4+5
-    if 4 in blocks and 5 in blocks:
-        uuid_raw = blocks[4] + blocks[5]
-        result["tray_uuid"] = uuid_raw[:16].hex().upper()
+    # Extract tray UUID from block 9
+    if 9 in blocks:
+        result["tray_uuid"] = blocks[9][:16].hex().upper()
 
     # Extract material info from block 1
     if 1 in blocks:

+ 2 - 1
spoolbuddy/scripts/read_tag.py

@@ -61,7 +61,7 @@ BAMBU_MASTER_KEY = bytes(
 BAMBU_CONTEXT = b"RFID-A\x00"  # 7 bytes including null terminator
 
 # Blocks to read for Bambu tag data
-BAMBU_BLOCKS = [1, 2, 4, 5]
+BAMBU_BLOCKS = [1, 2, 4, 5, 9]
 
 
 def hkdf_derive_keys(uid: bytes) -> bytes:
@@ -676,6 +676,7 @@ def main():
             for block_num in BAMBU_BLOCKS:
                 raw += blocks[block_num]
             print(f"\n    Raw payload ({len(raw)} bytes): {raw.hex().upper()}")
+            print(f"    tray_uuid (block 9): {blocks[9][:16].hex().upper()}")
 
         elif sak in (0x00, 0x04):
             # NTAG / MIFARE Ultralight family — SpoolEase / OpenPrintTag

+ 28 - 0
spoolbuddy/tests/test_nfc_reader.py

@@ -0,0 +1,28 @@
+"""Tests for daemon.nfc_reader — tray UUID extraction from Bambu tag blocks."""
+
+from daemon.nfc_reader import _extract_tray_uuid
+
+# Measured on a real spool: block 9 equals the tray_uuid the AMS reports (#984).
+BLOCK9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
+# Block 4 of a "PLA Matte" spool -- what the old blocks 4+5 read sent as tray_uuid.
+BLOCK4_PLA_MATTE = b"PLA Matte" + b"\x00" * 7
+
+
+class TestExtractTrayUuid:
+    def test_reads_block_9(self):
+        blocks = {1: b"\x01" * 16, 2: b"\x02" * 16, 4: BLOCK4_PLA_MATTE, 5: b"\x00" * 16, 9: BLOCK9}
+        assert _extract_tray_uuid(blocks) == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+
+    def test_ignores_blocks_4_and_5(self):
+        """Without block 9 there is no tray UUID, never the filament type from block 4."""
+        blocks = {4: BLOCK4_PLA_MATTE, 5: b"\x00" * 16}
+        assert _extract_tray_uuid(blocks) is None
+
+    def test_all_zero_block_9_is_no_uuid(self):
+        assert _extract_tray_uuid({9: b"\x00" * 16}) is None
+
+    def test_short_block_9_is_no_uuid(self):
+        assert _extract_tray_uuid({9: b"\x9e\x0b"}) is None
+
+    def test_empty_blocks(self):
+        assert _extract_tray_uuid({}) is None

+ 17 - 24
spoolbuddy/tests/test_tag_parser.py

@@ -10,26 +10,19 @@ class TestParseBambuBlocks:
         result = parse_bambu_blocks({})
         assert result == {}
 
-    def test_tray_uuid_from_blocks_4_and_5(self):
-        # 16 bytes per block, UUID is first 16 bytes of block4+block5
-        block4 = bytes(range(16))  # 00010203...0f
-        block5 = bytes(range(16, 32))  # 10111213...1f
-        blocks = {4: block4, 5: block5}
-
-        result = parse_bambu_blocks(blocks)
-
-        # UUID = first 16 bytes of (block4 + block5) = block4 itself
-        expected_uuid = block4.hex().upper()
-        assert result["tray_uuid"] == expected_uuid
-
-    def test_tray_uuid_missing_block_4(self):
-        blocks = {5: b"\x00" * 16}
-        result = parse_bambu_blocks(blocks)
+    def test_tray_uuid_from_block_9(self):
+        block9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
+        result = parse_bambu_blocks({9: block9})
+        assert result["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
+
+    def test_tray_uuid_not_taken_from_blocks_4_and_5(self):
+        """Blocks 4-5 hold the filament type, never the tray UUID (#984)."""
+        block4 = b"PLA Matte" + b"\x00" * 7
+        result = parse_bambu_blocks({4: block4, 5: b"\x00" * 16})
         assert "tray_uuid" not in result
 
-    def test_tray_uuid_missing_block_5(self):
-        blocks = {4: b"\x00" * 16}
-        result = parse_bambu_blocks(blocks)
+    def test_tray_uuid_missing_block_9(self):
+        result = parse_bambu_blocks({1: b"\x00" * 16, 2: b"\x00" * 16})
         assert "tray_uuid" not in result
 
     def test_material_raw_from_block_1(self):
@@ -53,7 +46,8 @@ class TestParseBambuBlocks:
         block2 = b"\x02" * 16
         block4 = b"\x04" * 16
         block5 = b"\x05" * 16
-        blocks = {1: block1, 2: block2, 4: block4, 5: block5}
+        block9 = b"\x09" * 16
+        blocks = {1: block1, 2: block2, 4: block4, 5: block5, 9: block9}
 
         result = parse_bambu_blocks(blocks)
 
@@ -62,15 +56,14 @@ class TestParseBambuBlocks:
         assert "block2_raw" in result
 
     def test_extra_blocks_ignored(self):
-        """Blocks not in {1, 2, 4, 5} don't affect output."""
-        blocks = {0: b"\x00" * 16, 3: b"\x03" * 16, 6: b"\x06" * 16}
+        """Blocks not in {1, 2, 9} don't affect output."""
+        blocks = {0: b"\x00" * 16, 3: b"\x03" * 16, 4: b"\x04" * 16, 5: b"\x05" * 16, 6: b"\x06" * 16}
         result = parse_bambu_blocks(blocks)
         assert result == {}
 
     def test_tray_uuid_hex_uppercase(self):
-        block4 = b"\xab\xcd\xef\x12\x34\x56\x78\x9a\xbc\xde\xf0\x11\x22\x33\x44\x55"
-        block5 = b"\x00" * 16
-        blocks = {4: block4, 5: block5}
+        block9 = b"\xab\xcd\xef\x12\x34\x56\x78\x9a\xbc\xde\xf0\x11\x22\x33\x44\x55"
+        blocks = {9: block9}
 
         result = parse_bambu_blocks(blocks)
 

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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-CP9PPK4v.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-Bd5WBTEQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-xVGFNSn1.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-MteGyJn2.css">
   </head>

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