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

SpoolBuddy: written tags start with the NDEF message (#3500)

- the OpenTag3D encoder put a copy of the Capability Container in
  front of the NDEF TLV; written from page 4 it landed in the data
  area, where Type 2 readers took it for an unknown TLV, skipped the
  record and reported an empty tag
- the data now starts with the NDEF TLV; the tag's own CC in page 3
  is left as it is
maziggy 1 день назад
Родитель
Сommit
a2079371ad

+ 9 - 6
backend/app/services/opentag3d.py

@@ -1,11 +1,15 @@
 """OpenTag3D NDEF encoder for NTAG tags.
 
 Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
-starting at page 4 (after the manufacturer pages).
+starting at page 4, the first page of the data area.
+
+The Capability Container is not part of it: on NTAG21x it is page 3, set at
+the factory. A copy written from page 4 lands in the data area, where a Type 2
+reader takes it for an unknown TLV, skips the NDEF message and reports an
+empty tag (#3500).
 
 NDEF structure:
-  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)
-  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)
+  [TLV: 03 len]                  - NDEF Message TLV (2 bytes, page 4)
   [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
   [Type: "application/opentag3d"] - 21 bytes
   [Payload: OpenTag3D fields]    - 102 bytes
@@ -100,7 +104,7 @@ def _build_payload(spool: Spool) -> bytes:
 
 
 def _encode_ndef(payload: bytes) -> bytes:
-    """Wrap a 102-byte payload in CC + TLV + NDEF record + terminator."""
+    """Wrap a 102-byte payload in TLV + NDEF record + terminator."""
     mime_type = OPENTAG3D_MIME_TYPE
 
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
@@ -114,9 +118,8 @@ def _encode_ndef(payload: bytes) -> bytes:
     else:
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
 
-    cc = bytes([0xE1, 0x10, 0x12, 0x00])
     terminator = bytes([0xFE])
-    return cc + tlv + ndef_record + terminator
+    return tlv + ndef_record + terminator
 
 
 def encode_opentag3d(spool: Spool) -> bytes:

+ 4 - 6
backend/tests/integration/test_spoolbuddy.py

@@ -1081,13 +1081,11 @@ class TestWriteTagEndpoints:
         payload = hb.json()["pending_write_payload"]
         ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
 
-        # CC bytes
-        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
-        # TLV type
-        assert ndef_bytes[4] == 0x03
+        # The data area starts with the NDEF TLV, no CC copy (#3500)
+        assert ndef_bytes[0] == 0x03
         # NDEF record: TNF=MIME, type=application/opentag3d
-        assert ndef_bytes[6] == 0xD2
-        assert ndef_bytes[9:30] == b"application/opentag3d"
+        assert ndef_bytes[2] == 0xD2
+        assert ndef_bytes[5:26] == b"application/opentag3d"
         # Terminator
         assert ndef_bytes[-1] == 0xFE
         # Total size fits NTAG213

+ 52 - 19
backend/tests/unit/test_opentag3d.py

@@ -13,6 +13,33 @@ from backend.app.services.opentag3d import (
 )
 
 
+def _first_ndef_message(data: bytes) -> bytes | None:
+    """Walk the data area from page 4 the way an NFC Forum Type 2 reader does.
+
+    Returns the value of the first NDEF Message TLV (0x03), or None when the
+    walk reaches the terminator (0xFE) or the end without one.
+    """
+    i = 0
+    while i < len(data):
+        tlv_type = data[i]
+        if tlv_type == 0x00:  # NULL TLV, no length
+            i += 1
+            continue
+        if tlv_type == 0xFE:  # Terminator
+            return None
+        if i + 1 >= len(data):
+            return None
+        length = data[i + 1]
+        start = i + 2
+        if length == 0xFF:
+            length = int.from_bytes(data[i + 2 : i + 4], "big")
+            start = i + 4
+        if tlv_type == 0x03:
+            return data[start : start + length]
+        i = start + length
+    return None
+
+
 def _make_spool(**kwargs):
     """Create a mock Spool with default values."""
     defaults = {
@@ -105,27 +132,33 @@ class TestBuildPayload:
 
 
 class TestEncodeOpentag3d:
-    def test_starts_with_cc(self):
-        data = encode_opentag3d(_make_spool())
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
-
-    def test_tlv_header(self):
+    def test_starts_with_the_ndef_tlv(self):
+        # #3500: page 4 is data on NTAG21x. A CC copy there hid the message.
         data = encode_opentag3d(_make_spool())
         # TLV type = 0x03
-        assert data[4] == 0x03
+        assert data[0] == 0x03
         # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126
-        assert data[5] == 126
+        assert data[1] == 126
 
     def test_ndef_record_header(self):
         data = encode_opentag3d(_make_spool())
-        # Record starts after CC(4) + TLV(2) = offset 6
-        assert data[6] == 0xD2  # MB|ME|SR + TNF=MIME
-        assert data[7] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
-        assert data[8] == PAYLOAD_SIZE  # payload length = 102
+        # Record starts after TLV(2) = offset 2
+        assert data[2] == 0xD2  # MB|ME|SR + TNF=MIME
+        assert data[3] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
+        assert data[4] == PAYLOAD_SIZE  # payload length = 102
+
+    def test_a_type_2_reader_finds_the_record(self):
+        data = encode_opentag3d(_make_spool(material="PLA", brand="Polymaker"))
+
+        record = _first_ndef_message(data)
+
+        assert record is not None
+        assert record[3 : 3 + 21] == OPENTAG3D_MIME_TYPE
+        assert record[3 + 21 :][0x02:0x05] == b"PLA"
 
     def test_mime_type(self):
         data = encode_opentag3d(_make_spool())
-        mime = data[9:30]
+        mime = data[5:26]
         assert mime == b"application/opentag3d"
 
     def test_ends_with_terminator(self):
@@ -134,8 +167,8 @@ class TestEncodeOpentag3d:
 
     def test_total_size(self):
         data = encode_opentag3d(_make_spool())
-        # CC(4) + TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 133
-        assert len(data) == 133
+        # TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 129
+        assert len(data) == 129
 
     def test_fits_ntag213(self):
         """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
@@ -255,11 +288,11 @@ class TestBuildPayloadFromDict:
 class TestEncodeOpentag3dFromMapped:
     def test_total_size(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert len(data) == 133
+        assert len(data) == 129
 
-    def test_starts_with_cc(self):
+    def test_starts_with_the_ndef_tlv(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        assert data[:2] == bytes([0x03, 126])
 
     def test_ends_with_terminator(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
@@ -317,5 +350,5 @@ class TestEncodeOpentag3dFromMapped:
         }
         mapped = _map_spoolman_spool(raw)
         data = encode_opentag3d_from_mapped(mapped)
-        assert len(data) == 133
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        assert len(data) == 129
+        assert data[:2] == bytes([0x03, 126])