Преглед на файлове

feat(spoolbuddy): send the Bambu tag blocks with tag scans (#2685) (#3389)

Victor Manuel преди 1 ден
родител
ревизия
ae4a712d7c

+ 2 - 0
spoolbuddy/daemon/api_client.py

@@ -168,6 +168,7 @@ class APIClient:
         tray_uuid: str | None = None,
         sak: int | None = None,
         tag_type: str | None = None,
+        raw_blocks: dict[str, str] | None = None,
     ) -> dict | None:
         return await self._post(
             "/nfc/tag-scanned",
@@ -177,6 +178,7 @@ class APIClient:
                 "tray_uuid": tray_uuid,
                 "sak": sak,
                 "tag_type": tag_type,
+                "raw_blocks": raw_blocks,
             },
         )
 

+ 1 - 0
spoolbuddy/daemon/main.py

@@ -132,6 +132,7 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
                     tray_uuid=event_data.get("tray_uuid"),
                     sak=event_data.get("sak"),
                     tag_type=event_data.get("tag_type"),
+                    raw_blocks=event_data.get("raw_blocks"),
                 )
             elif event_type == "tag_removed":
                 await api.tag_removed(

+ 8 - 0
spoolbuddy/daemon/nfc_reader.py

@@ -205,12 +205,14 @@ class NFCReader:
 
                 # Try reading Bambu tag data
                 tray_uuid = None
+                raw_blocks = None
                 tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak in (0x00, 0x04) else "unknown"
 
                 if sak in (0x08, 0x18):
                     blocks = self._nfc.read_bambu_tag(uid_bytes)
                     if blocks:
                         tray_uuid = _extract_tray_uuid(blocks)
+                        raw_blocks = _raw_blocks(blocks)
 
                 logger.info("Tag detected: %s (SAK=0x%02X, type=%s)", uid_hex, sak, tag_type)
                 return "tag_detected", {
@@ -218,6 +220,7 @@ class NFCReader:
                     "sak": sak,
                     "tag_type": tag_type,
                     "tray_uuid": tray_uuid,
+                    "raw_blocks": raw_blocks,
                 }
 
             # Tag still present — no event
@@ -253,3 +256,8 @@ def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
     if uuid_str == "0" * 32:
         return None
     return uuid_str
+
+
+def _raw_blocks(blocks: dict[int, bytes]) -> dict[str, str]:
+    """Bambu tag blocks as {"block number": "32 uppercase hex chars"}, for the backend to decode (#2685)."""
+    return {str(block): data.hex().upper() for block, data in sorted(blocks.items())}

+ 3 - 1
spoolbuddy/daemon/pn5180.py

@@ -63,7 +63,9 @@ BAMBU_CONTEXT = b"RFID-A\x00"  # 7 bytes including null terminator
 
 # 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]
+# Block 6 holds the drying and nozzle temperatures; with 1, 2, 4 and 5
+# (material, type, colour, weight) it is sent as raw_blocks (#2685).
+BAMBU_BLOCKS = [1, 2, 4, 5, 6, 9]
 
 # ISO 14443-3 anticollision: SEL codes of cascade levels 1-3, the cascade tag
 # that opens a level's four bytes when the UID goes on at the next level, and

+ 3 - 2
spoolbuddy/scripts/read_tag.py

@@ -60,8 +60,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, 9]
+# Blocks to read for Bambu tag data, as daemon/pn5180.py reads them: block 6
+# holds the temperatures, block 9 the tray UUID (#2685)
+BAMBU_BLOCKS = [1, 2, 4, 5, 6, 9]
 
 # ISO 14443-3 anticollision: SEL codes of cascade levels 1-3, the cascade tag
 # that opens a level's four bytes when the UID goes on at the next level, and

+ 23 - 0
spoolbuddy/tests/test_api_client.py

@@ -203,6 +203,29 @@ def _status_error(code: int, detail: str | None = None) -> httpx.HTTPStatusError
     return httpx.HTTPStatusError("rejected", request=request, response=httpx.Response(code, request=request, **body))
 
 
+class TestTagScanned:
+    @pytest.mark.asyncio
+    async def test_sends_raw_blocks(self, api):
+        api._post = AsyncMock(return_value={"status": "ok"})
+        raw = {"1": "4130302D423038004746413030000000", "9": "36B6C12DB3DA4A39A50C275F510BD399"}
+
+        await api.tag_scanned("sb-1", "865947E2", tray_uuid="36B6C12DB3DA4A39A50C275F510BD399", raw_blocks=raw)
+
+        path, payload = api._post.await_args.args
+        assert path == "/nfc/tag-scanned"
+        assert payload["raw_blocks"] == raw
+        assert payload["tray_uuid"] == "36B6C12DB3DA4A39A50C275F510BD399"
+
+    @pytest.mark.asyncio
+    async def test_raw_blocks_default_to_none(self, api):
+        api._post = AsyncMock(return_value={"status": "ok"})
+
+        await api.tag_scanned("sb-1", "04A1B2C3D4E5F6")
+
+        _, payload = api._post.await_args.args
+        assert payload["raw_blocks"] is None
+
+
 class TestRejectedRequests:
     """A request the backend refuses is not a connection problem: buffering it
     replays it forever, and the flush stops at the first item that fails."""

+ 51 - 1
spoolbuddy/tests/test_main.py

@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 from daemon.config import Config
-from daemon.main import heartbeat_loop, scale_poll_loop
+from daemon.main import heartbeat_loop, nfc_poll_loop, scale_poll_loop
 
 
 def _make_config(**overrides):
@@ -352,3 +352,53 @@ class TestReRegistration:
         api.register_device.assert_awaited()
         assert api.register_device.await_args.kwargs["device_id"] == "dev-1"
         assert config.tare_offset == 5
+
+
+class TestNfcPollLoopTagScanned:
+    """nfc_poll_loop forwards everything a tag_detected event carries to the backend (#2685)."""
+
+    @pytest.mark.asyncio
+    async def test_raw_blocks_reach_tag_scanned(self):
+        raw_blocks = {"1": "41" * 16, "9": "36B6C12DB3DA4A39A50C275F510BD399"}
+        events = [
+            (
+                "tag_detected",
+                {
+                    "tag_uid": "865947E2",
+                    "sak": 0x08,
+                    "tag_type": "mifare_classic",
+                    "tray_uuid": "36B6C12DB3DA4A39A50C275F510BD399",
+                    "raw_blocks": raw_blocks,
+                },
+            )
+        ]
+        nfc = MagicMock()
+        nfc.ok = True
+        nfc.poll = lambda: events.pop(0) if events else ("none", None)
+        api = AsyncMock()
+        config = _make_config(nfc_poll_interval=0.0)
+        shared = {"nfc": nfc, "display": MagicMock(), "pending_write": None}
+
+        async def _inline_to_thread(fn, *args, **kwargs):
+            return fn(*args, **kwargs)
+
+        with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
+            task = asyncio.create_task(nfc_poll_loop(config, api, shared))
+            for _ in range(20):
+                await asyncio.sleep(0)
+                if api.tag_scanned.await_count:
+                    break
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+        api.tag_scanned.assert_awaited_once_with(
+            device_id="dev-1",
+            tag_uid="865947E2",
+            tray_uuid="36B6C12DB3DA4A39A50C275F510BD399",
+            sak=0x08,
+            tag_type="mifare_classic",
+            raw_blocks=raw_blocks,
+        )

+ 13 - 2
spoolbuddy/tests/test_nfc_reader.py

@@ -1,6 +1,6 @@
-"""Tests for daemon.nfc_reader — tray UUID extraction from Bambu tag blocks."""
+"""Tests for daemon.nfc_reader — tray UUID extraction and raw blocks from Bambu tag blocks."""
 
-from daemon.nfc_reader import _extract_tray_uuid
+from daemon.nfc_reader import _extract_tray_uuid, _raw_blocks
 
 # Measured on a real spool: block 9 equals the tray_uuid the AMS reports (#984).
 BLOCK9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
@@ -26,3 +26,14 @@ class TestExtractTrayUuid:
 
     def test_empty_blocks(self):
         assert _extract_tray_uuid({}) is None
+
+
+class TestRawBlocks:
+    def test_blocks_as_uppercase_hex_keyed_by_block_number(self):
+        assert _raw_blocks({9: BLOCK9, 4: BLOCK4_PLA_MATTE}) == {
+            "4": "504C41204D6174746500000000000000",
+            "9": "9E0B0717BEE94D7887EB1D8DFD1A14F3",
+        }
+
+    def test_empty_blocks(self):
+        assert _raw_blocks({}) == {}

+ 40 - 0
spoolbuddy/tests/test_pn5180.py

@@ -220,6 +220,46 @@ class TestNfcReaderPoll:
         assert data["tag_uid"] == "04ECE673D32A81"
         assert data["sak"] == 0x00
         assert data["tag_type"] == "ntag"
+        # NTAGs are not Bambu tags: no block read, nothing for the backend to decode (#2685)
+        assert data["raw_blocks"] is None
+
+    def test_bambu_tag_is_reported_with_its_data_blocks(self, pn5180_mod):
+        """A MIFARE Classic poll sends the blocks read_bambu_tag() read, as hex, for the backend to decode (#2685)."""
+        from daemon.nfc_reader import NFCReader
+
+        reader = NFCReader()
+        reader._nfc = _reader(pn5180_mod, FakeChip(UID_4, sak=0x08))
+        # Whatever blocks the driver is told to read; block 9 holds the tray UUID
+        tray_uuid = bytes.fromhex("36B6C12DB3DA4A39A50C275F510BD399")
+        reader._nfc.read_bambu_tag = MagicMock(
+            side_effect=lambda uid: {
+                block: tray_uuid if block == 9 else bytes([block]) * 16 for block in pn5180_mod.BAMBU_BLOCKS
+            }
+        )
+
+        event, data = reader.poll()
+
+        assert event == "tag_detected"
+        assert data["tag_type"] == "mifare_classic"
+        reader._nfc.read_bambu_tag.assert_called_once_with(UID_4)
+        # Block 6 (temperatures) included, not just the blocks the tray UUID needs
+        assert sorted(data["raw_blocks"], key=int) == ["1", "2", "4", "5", "6", "9"]
+        assert data["raw_blocks"]["6"] == "06" * 16
+        assert data["raw_blocks"]["9"] == "36B6C12DB3DA4A39A50C275F510BD399"
+        assert data["tray_uuid"] == "36B6C12DB3DA4A39A50C275F510BD399"
+
+    def test_failed_bambu_read_sends_no_blocks(self, pn5180_mod):
+        from daemon.nfc_reader import NFCReader
+
+        reader = NFCReader()
+        reader._nfc = _reader(pn5180_mod, FakeChip(UID_4, sak=0x08))
+        reader._nfc.read_bambu_tag = MagicMock(return_value=None)
+
+        event, data = reader.poll()
+
+        assert event == "tag_detected"
+        assert data["raw_blocks"] is None
+        assert data["tray_uuid"] is None
 
 
 class TestReadTagDiagnostic: