Просмотр исходного кода

SpoolBuddy: OpenTag3D 2.004 on NTAG215/216 (#3500)

- the encoder wrote the OpenTag3D 1.000 layout; 2.000 moved every
  field, so a reader of the current spec misread brand, colour,
  weight and temperatures
- a v2 encoder writes the fields Bambuddy has (material, brand,
  colour name, colours 1-4, print temperatures, net and empty spool
  weight); density, bed and chamber temperature stay unset
- the write job carries both; the daemon reads the tag's CC and
  writes v2 when its data area holds it, v1 on an NTAG213 (v2 does
  not fit) or when the CC can't be read
- a daemon that has not been updated ignores v2 and writes v1
maziggy 1 день назад
Родитель
Сommit
7f24f58e76

+ 13 - 1
backend/app/api/routes/spoolbuddy.py

@@ -836,7 +836,12 @@ async def nfc_write_tag(
     Spoolman, so one with the same id must not stand in for the Spoolman spool.
     Spoolman, so one with the same id must not stand in for the Spoolman spool.
     """
     """
     from backend.app.models.spool import Spool
     from backend.app.models.spool import Spool
-    from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
+    from backend.app.services.opentag3d import (
+        encode_opentag3d,
+        encode_opentag3d_from_mapped,
+        encode_opentag3d_v2,
+        encode_opentag3d_v2_from_mapped,
+    )
 
 
     # Find the device first (required regardless of spool source)
     # Find the device first (required regardless of spool source)
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
@@ -854,6 +859,7 @@ async def nfc_write_tag(
         if spool is None:
         if spool is None:
             raise HTTPException(status_code=404, detail="Spool not found")
             raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
         ndef_data = encode_opentag3d(spool)
+        ndef_v2 = encode_opentag3d_v2(spool)
         data_origin = "local"
         data_origin = "local"
     else:
     else:
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
@@ -890,6 +896,7 @@ async def nfc_write_tag(
             )
             )
 
 
         ndef_data = encode_opentag3d_from_mapped(mapped)
         ndef_data = encode_opentag3d_from_mapped(mapped)
+        ndef_v2 = encode_opentag3d_v2_from_mapped(mapped)
         data_origin = "spoolman"
         data_origin = "spoolman"
 
 
         # Warn when fields that drive NFC content are absent in Spoolman.
         # Warn when fields that drive NFC content are absent in Spoolman.
@@ -924,7 +931,12 @@ async def nfc_write_tag(
     device.pending_write_payload = json.dumps(
     device.pending_write_payload = json.dumps(
         {
         {
             "spool_id": req.spool_id,
             "spool_id": req.spool_id,
+            # OpenTag3D v1, which fits every NTAG; what a reader that knows
+            # nothing else writes.
             "ndef_data_hex": ndef_data.hex(),
             "ndef_data_hex": ndef_data.hex(),
+            # OpenTag3D 2.004, for a tag with room for it (NTAG215/216). The
+            # daemon picks by the tag's size (#3500).
+            "ndef_v2_hex": ndef_v2.hex(),
             "data_origin": data_origin,
             "data_origin": data_origin,
         }
         }
     )
     )

+ 91 - 1
backend/app/services/opentag3d.py

@@ -103,8 +103,98 @@ def _build_payload(spool: Spool) -> bytes:
     )
     )
 
 
 
 
+# OpenTag3D 2.004 (https://opentag3d.info/spec). 2.000 moved every field and
+# dropped NTAG213: this layout does not fit one, so v1 stays for those tags and
+# the SpoolBuddy daemon picks by the tag's size (#3500).
+V2_TAG_VERSION = 2004
+# Up to the end of the last field written (empty spool weight, 0xA0-0xA1). The
+# spec reads missing bytes as 0 (2.001), so the rest need not be written.
+V2_PAYLOAD_SIZE = 0xA2
+
+
+def _rgba_bytes(value: str | None) -> bytes:
+    """4 RGBA bytes from 6- or 8-char hex; 6 chars means opaque."""
+    text = (value or "").strip().lstrip("#")
+    if len(text) == 6:
+        text += "FF"
+    try:
+        return bytes.fromhex(text[:8].ljust(8, "0"))
+    except ValueError:
+        logger.warning("OpenTag3D encoder: invalid colour %r, encoding as transparent black", value)
+        return b"\x00\x00\x00\x00"
+
+
+def _put_text(buf: bytearray, offset: int, size: int, value: str | None) -> None:
+    """UTF-8, cut to *size* bytes without splitting a character, space-padded."""
+    raw = (value or "").encode("utf-8")[:size].decode("utf-8", "ignore").encode("utf-8")
+    buf[offset : offset + size] = raw.ljust(size, b" ")
+
+
+def _temp(celsius: int | None) -> int:
+    """A temperature as the spec stores it: degrees C divided by 5, one byte."""
+    try:
+        return max(0, min(int(celsius or 0) // 5, 255))
+    except (TypeError, ValueError):
+        return 0
+
+
+def _build_v2_payload_from_dict(data: dict) -> bytes:
+    """OpenTag3D 2.004 payload from the same field dict as the v1 one.
+
+    Written: material, modifiers, manufacturer, colour name, colours 1-4
+    (2-4 from ``extra_colors``), diameter, print temperatures (target = the
+    minimum, as v1 wrote it), target weight and empty spool weight. Bambuddy
+    tracks no density or bed temperature, so those stay 0 (not set), and
+    chamber temperature 0 means no heating.
+    """
+    buf = bytearray(V2_PAYLOAD_SIZE)
+    struct.pack_into(">H", buf, 0x00, V2_TAG_VERSION)
+    _put_text(buf, 0x02, 5, data.get("material"))
+    _put_text(buf, 0x07, 5, data.get("subtype"))
+    _put_text(buf, 0x0C, 16, data.get("brand"))
+    _put_text(buf, 0x1C, 32, data.get("color_name"))
+    buf[0x3C:0x40] = _rgba_bytes(data.get("rgba") or "00000000")
+    extra = [tok for tok in (data.get("extra_colors") or "").split(",") if tok.strip()]
+    for i, colour in enumerate(extra[:3]):
+        offset = 0x40 + 4 * i
+        buf[offset : offset + 4] = _rgba_bytes(colour)
+    # 0x8C: diameter in micrometres
+    struct.pack_into(">H", buf, 0x8C, 1750)
+    buf[0x90] = _temp(data.get("nozzle_temp_min"))  # target print
+    buf[0x91] = _temp(data.get("nozzle_temp_min"))  # minimum print
+    buf[0x92] = _temp(data.get("nozzle_temp_max"))  # maximum print
+    struct.pack_into(">H", buf, 0x9E, max(0, min(int(data.get("label_weight") or 0), 65535)))
+    struct.pack_into(">H", buf, 0xA0, max(0, min(int(data.get("core_weight") or 0), 65535)))
+    return bytes(buf)
+
+
+def _spool_fields(spool: Spool) -> dict:
+    return {
+        "material": spool.material,
+        "subtype": spool.subtype,
+        "brand": spool.brand,
+        "color_name": spool.color_name,
+        "rgba": spool.rgba,
+        "extra_colors": spool.extra_colors,
+        "label_weight": spool.label_weight,
+        "core_weight": spool.core_weight,
+        "nozzle_temp_min": spool.nozzle_temp_min,
+        "nozzle_temp_max": spool.nozzle_temp_max,
+    }
+
+
+def encode_opentag3d_v2(spool: Spool) -> bytes:
+    """Encode a spool as an OpenTag3D 2.004 NDEF message, for NTAG215/216."""
+    return _encode_ndef(_build_v2_payload_from_dict(_spool_fields(spool)))
+
+
+def encode_opentag3d_v2_from_mapped(mapped: MappedSpoolFields) -> bytes:
+    """:func:`encode_opentag3d_v2` for a Spoolman-mapped spool dict."""
+    return _encode_ndef(_build_v2_payload_from_dict(dict(mapped)))
+
+
 def _encode_ndef(payload: bytes) -> bytes:
 def _encode_ndef(payload: bytes) -> bytes:
-    """Wrap a 102-byte payload in TLV + NDEF record + terminator."""
+    """Wrap a payload in TLV + NDEF record + terminator."""
     mime_type = OPENTAG3D_MIME_TYPE
     mime_type = OPENTAG3D_MIME_TYPE
 
 
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2

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

@@ -1091,6 +1091,12 @@ class TestWriteTagEndpoints:
         # Total size fits NTAG213
         # Total size fits NTAG213
         assert len(ndef_bytes) <= 144
         assert len(ndef_bytes) <= 144
 
 
+        # OpenTag3D 2.004 beside it, for a tag with room for it (#3500)
+        v2 = bytes.fromhex(payload["ndef_v2_hex"])
+        assert v2[5:26] == b"application/opentag3d"
+        assert int.from_bytes(v2[26:28], "big") == 2004
+        assert v2[26 + 0x0C : 26 + 0x15] == b"Polymaker"
+
 
 
 # ============================================================================
 # ============================================================================
 # Scale endpoints
 # Scale endpoints
@@ -2178,6 +2184,7 @@ class TestNfcWriteTagSpoolman:
         assert payload["data_origin"] == "spoolman"
         assert payload["data_origin"] == "spoolman"
         assert payload["spool_id"] == 88
         assert payload["spool_id"] == 88
         assert "ndef_data_hex" in payload
         assert "ndef_data_hex" in payload
+        assert int.from_bytes(bytes.fromhex(payload["ndef_v2_hex"])[26:28], "big") == 2004
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration

+ 115 - 0
backend/tests/unit/test_opentag3d_v2_3500.py

@@ -0,0 +1,115 @@
+"""OpenTag3D 2.004 encoding for tags with room for it (#3500).
+
+The spec moved every field in 2.000 and dropped NTAG213, so SpoolBuddy writes
+v2 on an NTAG215/216 and keeps v1 for an NTAG213. Offsets are from
+https://opentag3d.info/spec (2.004).
+"""
+
+import struct
+from types import SimpleNamespace
+
+from backend.app.services.opentag3d import (
+    OPENTAG3D_MIME_TYPE,
+    V2_PAYLOAD_SIZE,
+    encode_opentag3d,
+    encode_opentag3d_v2,
+    encode_opentag3d_v2_from_mapped,
+)
+
+NTAG213_DATA_AREA = 0x12 * 8  # 144
+NTAG215_DATA_AREA = 0x3E * 8  # 496
+
+
+def _spool(**overrides):
+    fields = {
+        "material": "PETG",
+        "subtype": "HF",
+        "brand": "Polymaker",
+        "color_name": "Jade White",
+        "rgba": "00AE42FF",
+        "extra_colors": "ff0000,00ff0080",
+        "label_weight": 1000,
+        "core_weight": 250,
+        "nozzle_temp_min": 230,
+        "nozzle_temp_max": 260,
+    }
+    fields.update(overrides)
+    return SimpleNamespace(**fields)
+
+
+def _payload(message: bytes) -> bytes:
+    """The record's payload, after TLV(2) + header(3) + type(21)."""
+    assert message[0] == 0x03
+    assert message[2] == 0xD2
+    assert message[5:26] == OPENTAG3D_MIME_TYPE
+    length = message[4]
+    return message[26 : 26 + length]
+
+
+def test_the_fields_sit_at_the_2004_offsets():
+    p = _payload(encode_opentag3d_v2(_spool()))
+
+    assert struct.unpack_from(">H", p, 0x00)[0] == 2004
+    assert p[0x02:0x07] == b"PETG "
+    assert p[0x07:0x0C] == b"HF   "
+    assert p[0x0C:0x1C] == b"Polymaker".ljust(16)
+    assert p[0x1C:0x3C] == b"Jade White".ljust(32)
+    assert p[0x3C:0x40] == bytes.fromhex("00AE42FF")
+    assert struct.unpack_from(">H", p, 0x8C)[0] == 1750
+    assert (p[0x90], p[0x91], p[0x92]) == (46, 46, 52)  # 230, 230, 260 C / 5
+    assert struct.unpack_from(">H", p, 0x9E)[0] == 1000
+    assert struct.unpack_from(">H", p, 0xA0)[0] == 250
+
+
+def test_extra_colours_fill_colours_2_to_4():
+    p = _payload(encode_opentag3d_v2(_spool(extra_colors="ff0000,00ff0080,0000ff,123456")))
+
+    assert p[0x40:0x44] == bytes.fromhex("FF0000FF")  # 6 hex chars = opaque
+    assert p[0x44:0x48] == bytes.fromhex("00FF0080")
+    assert p[0x48:0x4C] == bytes.fromhex("0000FFFF")
+    # Only three more colours fit; the fourth extra one is dropped.
+    assert p[0x4C:0x50] == bytes(4)
+
+
+def test_fields_bambuddy_does_not_track_stay_unset():
+    p = _payload(encode_opentag3d_v2(_spool()))
+
+    assert p[0x93] == 0  # chamber: no heating
+    assert p[0x94] == 0  # bed
+    assert struct.unpack_from(">H", p, 0x9C)[0] == 0  # density
+
+
+def test_missing_values_encode_as_zero():
+    p = _payload(
+        encode_opentag3d_v2(
+            _spool(extra_colors=None, nozzle_temp_min=None, nozzle_temp_max=None, core_weight=None, brand=None)
+        )
+    )
+
+    assert p[0x0C:0x1C] == b" " * 16
+    assert p[0x40:0x4C] == bytes(12)
+    assert (p[0x90], p[0x91], p[0x92]) == (0, 0, 0)
+    assert struct.unpack_from(">H", p, 0xA0)[0] == 0
+
+
+def test_a_long_name_is_cut_without_splitting_a_character():
+    p = _payload(encode_opentag3d_v2(_spool(color_name="Grün" * 10)))
+
+    name = p[0x1C:0x3C]
+    assert len(name) == 32
+    name.decode("utf-8")  # no half character at the cut
+
+
+def test_v2_fits_an_ntag215_but_not_an_ntag213():
+    v2 = encode_opentag3d_v2(_spool())
+
+    assert len(v2) == 2 + 3 + 21 + V2_PAYLOAD_SIZE + 1
+    assert NTAG213_DATA_AREA < len(v2) <= NTAG215_DATA_AREA
+    # v1 still fits the NTAG213 it is kept for.
+    assert len(encode_opentag3d(_spool())) <= NTAG213_DATA_AREA
+
+
+def test_the_spoolman_path_encodes_the_same():
+    spool = _spool()
+
+    assert encode_opentag3d_v2_from_mapped(vars(spool)) == encode_opentag3d_v2(spool)

+ 6 - 1
spoolbuddy/daemon/main.py

@@ -145,7 +145,7 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
             if pending and nfc.state == NFCState.TAG_PRESENT:
             if pending and nfc.state == NFCState.TAG_PRESENT:
                 if nfc.current_sak in (0x00, 0x04):
                 if nfc.current_sak in (0x00, 0x04):
                     logger.info("Executing pending tag write for spool %d", pending["spool_id"])
                     logger.info("Executing pending tag write for spool %d", pending["spool_id"])
-                    success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
+                    success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"], pending.get("ndef_v2"))
                     await api.write_tag_result(
                     await api.write_tag_result(
                         device_id=config.device_id,
                         device_id=config.device_id,
                         spool_id=pending["spool_id"],
                         spool_id=pending["spool_id"],
@@ -419,6 +419,11 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                     shared["pending_write"] = {
                     shared["pending_write"] = {
                         "spool_id": write_payload["spool_id"],
                         "spool_id": write_payload["spool_id"],
                         "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
                         "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
+                        # OpenTag3D v2 for a tag with room for it (#3500);
+                        # absent from an older Bambuddy.
+                        "ndef_v2": bytes.fromhex(write_payload["ndef_v2_hex"])
+                        if write_payload.get("ndef_v2_hex")
+                        else None,
                     }
                     }
                     logger.info("Write tag command received for spool %d", write_payload["spool_id"])
                     logger.info("Write tag command received for spool %d", write_payload["spool_id"])
             elif cmd in ("reboot", "shutdown", "restart_daemon", "restart_browser"):
             elif cmd in ("reboot", "shutdown", "restart_daemon", "restart_browser"):

+ 36 - 3
spoolbuddy/daemon/nfc_reader.py

@@ -90,9 +90,13 @@ class NFCReader:
     def current_sak(self) -> int | None:
     def current_sak(self) -> int | None:
         return self._current_sak
         return self._current_sak
 
 
-    def write_ntag(self, data: bytes) -> tuple[bool, str]:
+    def write_ntag(self, data: bytes, data_v2: bytes | None = None) -> tuple[bool, str]:
         """Write raw NDEF bytes to currently present NTAG tag.
         """Write raw NDEF bytes to currently present NTAG tag.
 
 
+        *data* is OpenTag3D v1, which fits every NTAG. *data_v2* (OpenTag3D
+        2.004, #3500) is written instead when the tag's Capability Container
+        says it has room for it; v2 does not fit an NTAG213.
+
         Requires tag in TAG_PRESENT state with SAK=0x00.
         Requires tag in TAG_PRESENT state with SAK=0x00.
         Returns (success, message).
         Returns (success, message).
         """
         """
@@ -113,11 +117,26 @@ class NFCReader:
             if uid_bytes.hex().upper() != self._current_uid:
             if uid_bytes.hex().upper() != self._current_uid:
                 return False, "Tag UID changed during write"
                 return False, "Tag UID changed during write"
 
 
+            version = "v1"
+            if data_v2 is not None:
+                # Page 3 is the Capability Container; READ returns pages 3-6.
+                try:
+                    cc_pages = self._nfc.ntag_read_pages(start_page=3, num_pages=1)
+                except Exception as e:
+                    logger.warning("Could not read the tag's Capability Container: %s", e)
+                    cc_pages = None
+                data, version = choose_ndef(cc_pages, data, data_v2)
+                # A fresh select before writing, as the write path always had.
+                if self._nfc.reactivate_card() is None:
+                    return False, "Failed to reactivate card for write"
+
             # Write starting at page 4
             # Write starting at page 4
             success = self._nfc.ntag_write_pages(start_page=4, data=data)
             success = self._nfc.ntag_write_pages(start_page=4, data=data)
             if success:
             if success:
-                logger.info("NTAG write successful: %d bytes to tag %s", len(data), self._current_uid)
-                return True, "Write successful"
+                logger.info(
+                    "NTAG write successful: %d bytes (OpenTag3D %s) to tag %s", len(data), version, self._current_uid
+                )
+                return True, f"Write successful (OpenTag3D {version})"
             else:
             else:
                 return False, "Write or verification failed"
                 return False, "Write or verification failed"
         except Exception as e:
         except Exception as e:
@@ -265,6 +284,20 @@ def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
     return uuid_str
     return uuid_str
 
 
 
 
+def choose_ndef(cc_pages: bytes | None, data_v1: bytes, data_v2: bytes) -> tuple[bytes, str]:
+    """Pick the OpenTag3D encoding the tag has room for (#3500).
+
+    *cc_pages* starts with page 3, the NDEF Capability Container
+    (E1, version, data area size / 8, access). v2 is chosen only when the CC
+    reads as NDEF and its data area holds the whole v2 message: an NTAG213
+    (size byte 0x12, 144 bytes) gets v1, an NTAG215/216 (0x3E/0x6D) gets v2.
+    Anything unreadable gets v1, which fits every NTAG.
+    """
+    if cc_pages and len(cc_pages) >= 4 and cc_pages[0] == 0xE1 and len(data_v2) <= cc_pages[2] * 8:
+        return data_v2, "v2"
+    return data_v1, "v1"
+
+
 def _raw_blocks(blocks: dict[int, bytes]) -> dict[str, 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)."""
     """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())}
     return {str(block): data.hex().upper() for block, data in sorted(blocks.items())}

+ 71 - 0
spoolbuddy/tests/test_main.py

@@ -138,6 +138,38 @@ class TestHeartbeatLoopCommands:
         assert "pending_write" in shared
         assert "pending_write" in shared
         assert shared["pending_write"]["spool_id"] == 42
         assert shared["pending_write"]["spool_id"] == 42
         assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
         assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
+        # An older Bambuddy sends no v2 encoding: v1 only.
+        assert shared["pending_write"]["ndef_v2"] is None
+
+    @pytest.mark.asyncio
+    async def test_write_tag_command_keeps_the_v2_encoding(self):
+        # #3500: Bambuddy sends OpenTag3D v2 beside v1 for a tag with room for it.
+        config = _make_config()
+        api = _make_api()
+        sent = False
+
+        async def mock_heartbeat(*args, **kwargs):
+            nonlocal sent
+            if sent:
+                return None
+            sent = True
+            return {
+                "pending_command": "write_tag",
+                "pending_write_payload": {"spool_id": 42, "ndef_data_hex": "DEADBEEF", "ndef_v2_hex": "CAFE"},
+            }
+
+        api.heartbeat = mock_heartbeat
+        shared = {"nfc": None, "scale": None, "display": MagicMock()}
+
+        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
+        await asyncio.sleep(0.1)
+        task.cancel()
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+
+        assert shared["pending_write"]["ndef_v2"] == bytes.fromhex("CAFE")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_display_settings_applied_from_heartbeat(self):
     async def test_display_settings_applied_from_heartbeat(self):
@@ -402,3 +434,42 @@ class TestNfcPollLoopTagScanned:
             tag_type="mifare_classic",
             tag_type="mifare_classic",
             raw_blocks=raw_blocks,
             raw_blocks=raw_blocks,
         )
         )
+
+
+class TestNfcPollLoopWrite:
+    @pytest.mark.asyncio
+    async def test_the_v2_encoding_reaches_the_write(self):
+        # #3500: the reader picks v1 or v2 by the tag's size, so it needs both.
+        from daemon.nfc_reader import NFCState
+
+        nfc = MagicMock()
+        nfc.ok = True
+        nfc.poll = lambda: ("none", None)
+        nfc.state = NFCState.TAG_PRESENT
+        nfc.current_sak = 0x00
+        nfc.current_uid = "04A1B2C3"
+        nfc.write_ntag = MagicMock(return_value=(True, "Write successful (OpenTag3D v2)"))
+        api = AsyncMock()
+        config = _make_config(nfc_poll_interval=0.0)
+        shared = {
+            "nfc": nfc,
+            "display": MagicMock(),
+            "pending_write": {"spool_id": 7, "ndef_data": b"v1", "ndef_v2": b"v2"},
+        }
+
+        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.write_tag_result.await_count:
+                    break
+            task.cancel()
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+
+        nfc.write_ntag.assert_called_once_with(b"v1", b"v2")

+ 85 - 1
spoolbuddy/tests/test_nfc_reader.py

@@ -1,6 +1,8 @@
 """Tests for daemon.nfc_reader — tray UUID extraction and raw blocks 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, _raw_blocks
+from unittest.mock import MagicMock
+
+from daemon.nfc_reader import NFCReader, NFCState, _extract_tray_uuid, _raw_blocks, choose_ndef
 
 
 # Measured on a real spool: block 9 equals the tray_uuid the AMS reports (#984).
 # Measured on a real spool: block 9 equals the tray_uuid the AMS reports (#984).
 BLOCK9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
 BLOCK9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
@@ -37,3 +39,85 @@ class TestRawBlocks:
 
 
     def test_empty_blocks(self):
     def test_empty_blocks(self):
         assert _raw_blocks({}) == {}
         assert _raw_blocks({}) == {}
+
+
+class TestChooseNdef:
+    """#3500: OpenTag3D v2 only where the tag has room for it."""
+
+    V1 = b"\x03" + bytes(128)  # 129 bytes, fits every NTAG
+    V2 = b"\x03" + bytes(188)  # 189 bytes, more than an NTAG213 holds
+
+    def test_ntag215_gets_v2(self):
+        assert choose_ndef(bytes([0xE1, 0x10, 0x3E, 0x00]), self.V1, self.V2) == (self.V2, "v2")
+
+    def test_ntag216_gets_v2(self):
+        assert choose_ndef(bytes([0xE1, 0x10, 0x6D, 0x00]), self.V1, self.V2) == (self.V2, "v2")
+
+    def test_ntag213_gets_v1(self):
+        assert choose_ndef(bytes([0xE1, 0x10, 0x12, 0x00]), self.V1, self.V2) == (self.V1, "v1")
+
+    def test_a_cc_that_is_not_ndef_gets_v1(self):
+        assert choose_ndef(bytes([0x00, 0x00, 0x3E, 0x00]), self.V1, self.V2) == (self.V1, "v1")
+
+    def test_an_unread_cc_gets_v1(self):
+        assert choose_ndef(None, self.V1, self.V2) == (self.V1, "v1")
+        assert choose_ndef(b"\xe1", self.V1, self.V2) == (self.V1, "v1")
+
+
+class TestWriteNtag:
+    UID = bytes.fromhex("04A1B2C3D4E5F6")
+
+    def _reader(self, cc: bytes | None = None, read_error: Exception | None = None):
+        reader = NFCReader.__new__(NFCReader)
+        reader._state = NFCState.TAG_PRESENT
+        reader._current_uid = self.UID.hex().upper()
+        reader._current_sak = 0x00
+        nfc = MagicMock()
+        nfc.reactivate_card.return_value = (self.UID, 0x00)
+        if read_error is not None:
+            nfc.ntag_read_pages.side_effect = read_error
+        else:
+            nfc.ntag_read_pages.return_value = cc
+        nfc.ntag_write_pages.return_value = True
+        reader._nfc = nfc
+        return reader, nfc
+
+    def _written(self, nfc) -> bytes:
+        return nfc.ntag_write_pages.call_args.kwargs["data"]
+
+    def test_an_ntag215_is_written_v2(self):
+        reader, nfc = self._reader(cc=bytes([0xE1, 0x10, 0x3E, 0x00]))
+
+        ok, msg = reader.write_ntag(TestChooseNdef.V1, TestChooseNdef.V2)
+
+        assert ok
+        assert "v2" in msg
+        nfc.ntag_read_pages.assert_called_once_with(start_page=3, num_pages=1)
+        assert self._written(nfc) == TestChooseNdef.V2
+        assert nfc.ntag_write_pages.call_args.kwargs["start_page"] == 4
+
+    def test_an_ntag213_is_written_v1(self):
+        reader, nfc = self._reader(cc=bytes([0xE1, 0x10, 0x12, 0x00]))
+
+        ok, msg = reader.write_ntag(TestChooseNdef.V1, TestChooseNdef.V2)
+
+        assert ok
+        assert "v1" in msg
+        assert self._written(nfc) == TestChooseNdef.V1
+
+    def test_a_failed_cc_read_writes_v1(self):
+        reader, nfc = self._reader(read_error=RuntimeError("BUSY timeout"))
+
+        ok, _ = reader.write_ntag(TestChooseNdef.V1, TestChooseNdef.V2)
+
+        assert ok
+        assert self._written(nfc) == TestChooseNdef.V1
+
+    def test_without_v2_the_tag_is_not_read_first(self):
+        reader, nfc = self._reader()
+
+        ok, _ = reader.write_ntag(TestChooseNdef.V1)
+
+        assert ok
+        nfc.ntag_read_pages.assert_not_called()
+        assert self._written(nfc) == TestChooseNdef.V1