Sfoglia il codice sorgente

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 giorno fa
parent
commit
f7fb2f3719

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 ### Changed
+- **SpoolBuddy writes OpenTag3D 2.004 on NTAG215 and NTAG216 tags (#3500, suggested by @pleasantone)** — SpoolBuddy wrote the first version of the OpenTag3D layout. The current version moved every field, so a reader that only knows it read the brand, colour, weight and temperatures from the wrong places. Tags with room for it now get version 2.004, with material, brand, colour name, up to four colours, minimum and maximum print temperature, net weight and empty spool weight. Version 2 no longer fits on an NTAG213, so those keep the first version. SpoolBuddy picks by the size the tag reports, and falls back to the first version if it can't read it; a SpoolBuddy that hasn't been updated keeps writing the first version. Bambuddy does not track density or bed temperature, so a version 2 tag leaves them unset. Wiki updated.
 - **Virtual printer cards start collapsed** — Settings > Virtual Printer opened every virtual printer's card with all its settings, so a few of them filled the page. Each card now shows only its header (name, mode, model, status) until it is clicked.
 - **Virtual printers put uploads in the File Manager, not the archive** — An archive is a print that happened, but a virtual printer archived every file the slicer sent, in Archive, Review and Queue mode, before it ever printed. Uploads now go to the File Manager, and the archive gets the file when it prints, like any other File Manager print. **Archive** mode is now **File Manager** mode; virtual printers set to it switch over on upgrade, and archives they created before stay as they are. Each virtual printer card has a **File Manager folder** (by default a shared top-level folder named **Virtual Printer**; external folders can't be picked). The same file sent again under the same name into that folder doesn't make a second copy. **Review** mode stays: pending uploads now wait at the top of the File Manager page (the sidebar badge moved there too), and **Add to File Manager** stores them in the virtual printer's folder with their tags, notes and project (approving now needs the `library:upload` permission as well as `queue:create`, since it adds a file); the panel is translated now too. **Queue** mode stores the file and queues it from there, and a new switch, **Remove from File Manager once it prints** (off by default), takes it out of the File Manager when its print starts. With **Save AMS mapping** on, the slicer's tray pick still reaches the archive for reprints: the queue job keeps it until the print creates the archive, shows the saved-mapping badge meanwhile, and offers it under **Mapping** when edited. The virtual printer's archive-name setting (file name or 3MF title) applies to the archive made when the print starts. Wiki updated.
 - **A queue virtual printer shows where its jobs go, and its target can be cleared** — In Print Queue mode a virtual printer without a target printer sends each job to the next free printer of its model, but the card's target list only said "Select a printer...", and once a printer was picked there was no way back. The first entry now reads **Any available [model]** and switches the virtual printer back to that, and a line under the list says where the jobs go. The access code stays the same, so the slicer stays bound. In the other server modes the entry reads **None**; proxy mode still needs a printer. Wiki updated.

+ 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.
     """
     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)
     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:
             raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
+        ndef_v2 = encode_opentag3d_v2(spool)
         data_origin = "local"
     else:
         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_v2 = encode_opentag3d_v2_from_mapped(mapped)
         data_origin = "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(
         {
             "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(),
+            # 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,
         }
     )

+ 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:
-    """Wrap a 102-byte payload in TLV + NDEF record + terminator."""
+    """Wrap a payload in TLV + NDEF record + terminator."""
     mime_type = OPENTAG3D_MIME_TYPE
 
     # 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
         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
@@ -2178,6 +2184,7 @@ class TestNfcWriteTagSpoolman:
         assert payload["data_origin"] == "spoolman"
         assert payload["spool_id"] == 88
         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.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 nfc.current_sak in (0x00, 0x04):
                     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(
                         device_id=config.device_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"] = {
                         "spool_id": write_payload["spool_id"],
                         "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"])
             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:
         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.
 
+        *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.
         Returns (success, message).
         """
@@ -113,11 +117,26 @@ class NFCReader:
             if uid_bytes.hex().upper() != self._current_uid:
                 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
             success = self._nfc.ntag_write_pages(start_page=4, data=data)
             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:
                 return False, "Write or verification failed"
         except Exception as e:
@@ -265,6 +284,20 @@ def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
     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]:
     """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())}

+ 71 - 0
spoolbuddy/tests/test_main.py

@@ -138,6 +138,38 @@ class TestHeartbeatLoopCommands:
         assert "pending_write" in shared
         assert shared["pending_write"]["spool_id"] == 42
         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
     async def test_display_settings_applied_from_heartbeat(self):
@@ -402,3 +434,42 @@ class TestNfcPollLoopTagScanned:
             tag_type="mifare_classic",
             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."""
 
-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).
 BLOCK9 = bytes.fromhex("9E0B0717BEE94D7887EB1D8DFD1A14F3")
@@ -37,3 +39,85 @@ class TestRawBlocks:
 
     def test_empty_blocks(self):
         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