Browse Source

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 day ago
parent
commit
b52cc399a2

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **Tags written by SpoolBuddy can be read by phones and other readers (#3500, reported by @pleasantone; likely #3076)** — SpoolBuddy's **Write Tag** wrote a copy of the tag's Capability Container in front of the OpenTag3D data. On an NTAG tag that copy landed in the data area, where a phone, NFC Tools or another OpenTag3D reader took it for unknown data, skipped over the spool record and reported an empty tag, although the write had succeeded. Bambuddy itself was unaffected, since it links spools by the tag's ID. The data now starts with the spool record, and the tag's own Capability Container is left as it is. Tags written before need writing again for other readers to see them.
 - **An A2L with an AMS and an AMS Lite shows both (#3499, reported by @auberonkai)** — With an AMS 2 Pro and an AMS Lite attached to an A2L at the same time, the printer card showed only the external spool, until one of them was unplugged and the printer reconnected. Bambuddy failed to sort the two units, because the printer names a regular AMS with text and the AMS Lite with a number, and from then on it could not read the AMS data of any later update. Both units now show, in order, and an AMS update that can't be read no longer blocks the ones after it. The connection diagnostic also no longer fails an A2L on "Store sent files on external storage": neither the printer nor the slicers offer that option for it, so the check is skipped, as on the P1 series, and the explanation no longer names the P1 series only.
 - **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with a cloud or local preset, and those that use the file's own settings, are unchanged.
 - **A restart no longer sends printer errors again, and their titles name the right part (#3486, reported by @SaadShaya7)** — Printers keep some faults in their error list long after they happened, without showing them on the screen, for example "There was an unfinished print job when the printer lost power" after a power cut. Bambuddy remembered which faults it had already notified only until it restarted, so every restart sent each of those faults again. The notified faults are now stored, so a restart only notifies faults that are new. The title of a printer error notification also named the wrong part for several kinds of fault: USB, network and file errors came as "Nozzle/Extruder Error", camera faults as "Motion Controller Error", and an A1 mini's AMS Lite runouts as "Chamber Error". They now read "System Error", "Camera Error" and "AMS Lite Error"; AMS-HT, AMS 3H, toolhead, tool changer and air filter faults are named too, and a fault of any other kind is titled "Printer Error".

+ 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])