Explorar o código

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 hai 1 día
pai
achega
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.
     """
     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