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

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.
 """OpenTag3D NDEF encoder for NTAG tags.
 
 
 Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
 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:
 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)
   [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
   [Type: "application/opentag3d"] - 21 bytes
   [Type: "application/opentag3d"] - 21 bytes
   [Payload: OpenTag3D fields]    - 102 bytes
   [Payload: OpenTag3D fields]    - 102 bytes
@@ -100,7 +104,7 @@ def _build_payload(spool: Spool) -> bytes:
 
 
 
 
 def _encode_ndef(payload: bytes) -> 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
     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
@@ -114,9 +118,8 @@ def _encode_ndef(payload: bytes) -> bytes:
     else:
     else:
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
 
 
-    cc = bytes([0xE1, 0x10, 0x12, 0x00])
     terminator = bytes([0xFE])
     terminator = bytes([0xFE])
-    return cc + tlv + ndef_record + terminator
+    return tlv + ndef_record + terminator
 
 
 
 
 def encode_opentag3d(spool: Spool) -> bytes:
 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"]
         payload = hb.json()["pending_write_payload"]
         ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
         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
         # 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
         # Terminator
         assert ndef_bytes[-1] == 0xFE
         assert ndef_bytes[-1] == 0xFE
         # Total size fits NTAG213
         # 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):
 def _make_spool(**kwargs):
     """Create a mock Spool with default values."""
     """Create a mock Spool with default values."""
     defaults = {
     defaults = {
@@ -105,27 +132,33 @@ class TestBuildPayload:
 
 
 
 
 class TestEncodeOpentag3d:
 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())
         data = encode_opentag3d(_make_spool())
         # TLV type = 0x03
         # TLV type = 0x03
-        assert data[4] == 0x03
+        assert data[0] == 0x03
         # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126
         # 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):
     def test_ndef_record_header(self):
         data = encode_opentag3d(_make_spool())
         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):
     def test_mime_type(self):
         data = encode_opentag3d(_make_spool())
         data = encode_opentag3d(_make_spool())
-        mime = data[9:30]
+        mime = data[5:26]
         assert mime == b"application/opentag3d"
         assert mime == b"application/opentag3d"
 
 
     def test_ends_with_terminator(self):
     def test_ends_with_terminator(self):
@@ -134,8 +167,8 @@ class TestEncodeOpentag3d:
 
 
     def test_total_size(self):
     def test_total_size(self):
         data = encode_opentag3d(_make_spool())
         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):
     def test_fits_ntag213(self):
         """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
         """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
@@ -255,11 +288,11 @@ class TestBuildPayloadFromDict:
 class TestEncodeOpentag3dFromMapped:
 class TestEncodeOpentag3dFromMapped:
     def test_total_size(self):
     def test_total_size(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
         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)
         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):
     def test_ends_with_terminator(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
@@ -317,5 +350,5 @@ class TestEncodeOpentag3dFromMapped:
         }
         }
         mapped = _map_spoolman_spool(raw)
         mapped = _map_spoolman_spool(raw)
         data = encode_opentag3d_from_mapped(mapped)
         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])