From b1c3c8a3ce7cdef2173798bf108ef751a6c46062 Mon Sep 17 00:00:00 2001 From: Jon C Date: Sun, 23 Aug 2026 21:42:31 +0200 Subject: [PATCH 1/5] feat(nfc): add TigerTag NTAG213 binary codec with unit tests Decodes/encodes the TigerTag Maker/Pro binary format on NTAG213 tags (144 bytes user memory, pages 4-39): product/material/brand IDs, color, weight, temperatures, timestamp, emoji, and a 28-byte user message. Same scope discipline as codec/openprinttag (#880's PR1): codec and tests only, no wiring into the tag scan/link endpoints from #1096, no hardware dependency in core, no external dependency at all here (pure stdlib: struct/dataclasses/logging). Split out as its own PR per Donkie's request to keep #880 small -- TigerTag already ships on retail spools from several brands, so there's a case for it following the OpenPrintTag PR reasonably soon rather than much later. 21 tests covering the binary round trip, color/diameter helpers, and edge cases (truncated user message, 24-bit weight masking, header-only data, invalid UTF-8 in the message field). --- spoolman/tigertag_codec.py | 268 +++++++++++++++++++++++++++++++++++ tests/test_tigertag_codec.py | 189 ++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 spoolman/tigertag_codec.py create mode 100644 tests/test_tigertag_codec.py diff --git a/spoolman/tigertag_codec.py b/spoolman/tigertag_codec.py new file mode 100644 index 000000000..ea43d282f --- /dev/null +++ b/spoolman/tigertag_codec.py @@ -0,0 +1,268 @@ +"""TigerTag NTAG213 binary encoder/decoder. + +Implements the TigerTag Maker format for encoding/decoding filament data +to/from NTAG213 NFC chips (144 bytes user memory, pages 4-39). + +Based on the TigerTag RFID Guide specification. +""" + +import logging +import struct +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# NTAG213 has 144 bytes of user memory (pages 4-39, 36 pages x 4 bytes) +NTAG213_USER_BYTES = 144 + +# TigerTag magic numbers (from id_version.json API database — the README hex values are incorrect) +TIGERTAG_MAKER_V1 = 0x5BF59264 # TigerTag Maker v1.0 — offline only (decimal 1542820452) +TIGERTAG_PRO_V1 = 0xBC0FCB97 # TigerTag+ v1.0 — offline + cloud sync (decimal 3155151767) +TIGERTAG_INIT = 0x6C41A2E1 # TigerTag Init — blank/uninitialized (decimal 1816240865) + +# All valid TigerTag magic numbers (for detection) +TIGERTAG_MAGIC_NUMBERS = {TIGERTAG_MAKER_V1, TIGERTAG_PRO_V1} + +# Hex color string lengths: RGB (no alpha) vs RGBA. +_RGB_HEX_LEN = 6 +_RGBA_HEX_LEN = 8 + +# TigerTag diameter IDs. 1/2 are the common Maker values; 56/57 are equivalent +# IDs seen from some product/API sources for the same two diameters. +_DIAMETER_ID_175 = 1 +_DIAMETER_ID_285 = 2 +_DIAMETER_ID_175_ALT = 56 +_DIAMETER_ID_285_ALT = 57 + + +def is_tigertag(magic: int) -> bool: + """Check if a magic number identifies a valid TigerTag (Maker or Pro/+).""" + return magic in TIGERTAG_MAGIC_NUMBERS + + +@dataclass +class TigerTagData: + """Represents all data fields stored on a TigerTag NFC chip.""" + + # Core identifiers + id_tigertag: int = 0 # 4 bytes - magic number / format identifier + id_product: int = 0 # 4 bytes - product/filament ID (0xFFFFFFFF = Factory) + id_material: int = 0 # 2 bytes - material type ID + id_diameter: int = 0 # 1 byte - diameter ID (TigerTag numbering) + id_aspect: int = 0 # 1 byte - aspect/finish ID + id_type: int = 0 # 1 byte - type ID (142=filament) + id_brand: int = 0 # 2 bytes - brand/manufacturer ID + + # Color as RGBA + color_r: int = 0 # 1 byte + color_g: int = 0 # 1 byte + color_b: int = 0 # 1 byte + color_a: int = 255 # 1 byte + + # Spool properties + weight: int = 0 # net weight in grams + + # Temperature settings + nozzle_temp: int = 0 # nozzle temp min in °C + nozzle_temp_max: int = 0 # nozzle temp max in °C + bed_temp: int = 0 # bed temp min in °C + bed_temp_max: int = 0 # bed temp max in °C + drying_temp: int = 0 # drying temp in °C + drying_duration: int = 0 # drying time in hours + + # Metadata + timestamp: int = 0 # 4 bytes - seconds since 2000-01-01 GMT + emoji: int = 0 # 4 bytes - emoji codepoint + + # User message - 28 bytes UTF-8 + user_message: str = "" + + @property + def color_hex(self) -> str: + """Get color as hex string (without alpha).""" + return f"{self.color_r:02x}{self.color_g:02x}{self.color_b:02x}" + + @color_hex.setter + def color_hex(self, value: str) -> None: + """Set color from hex string.""" + value = value.lstrip("#") + if len(value) == _RGB_HEX_LEN: + self.color_r = int(value[0:2], 16) + self.color_g = int(value[2:4], 16) + self.color_b = int(value[4:6], 16) + elif len(value) == _RGBA_HEX_LEN: + self.color_r = int(value[0:2], 16) + self.color_g = int(value[2:4], 16) + self.color_b = int(value[4:6], 16) + self.color_a = int(value[6:8], 16) + + @property + def diameter_mm(self) -> float: + """Get diameter in mm from the diameter ID.""" + if self.id_diameter in (_DIAMETER_ID_175, _DIAMETER_ID_175_ALT): + return 1.75 + if self.id_diameter in (_DIAMETER_ID_285, _DIAMETER_ID_285_ALT): + return 2.85 + return 0.0 + + +# TigerTag binary format (big-endian, 36-byte header): +# +# Offset Size Format Field +# 0 4 >I id_tigertag +# 4 4 >I id_product +# 8 2 >H id_material +# 10 1 B id_aspect_1 +# 11 1 B id_aspect_2 +# 12 1 B id_type +# 13 1 B id_diameter +# 14 2 >H id_brand +# 16 4 >I color_rgba (R<<24 | G<<16 | B<<8 | A) +# 20 4 >I weight_unit (weight<<8 | unit_id) +# 24 2 >H nozzle_temp_min +# 26 2 >H nozzle_temp_max +# 28 1 B drying_temp +# 29 1 B drying_time (hours) +# 30 2 >H reserved (0) +# 32 4 >I timestamp +# +# After header: +# 36 1 B bed_temp_min +# 37 1 B bed_temp_max +# 38-53 16 - reserved +# 54 4 >I emoji +# 58 28 UTF-8 user_message +# 86-143 58 - signature / reserved + +_HEADER_FMT = ">II HBB BBH I I HH BBH I" +_HEADER_SIZE = struct.calcsize(_HEADER_FMT) # 36 bytes +_USER_MESSAGE_SIZE = 28 +_USER_MESSAGE_OFFSET = 58 +_EMOJI_OFFSET = 54 +_BED_TEMP_OFFSET = 36 + + +def decode_ntag213(raw_bytes: bytes) -> TigerTagData: + """Decode raw NTAG213 user memory bytes into TigerTagData. + + Args: + raw_bytes: The raw bytes from NTAG213 pages 4-39 (up to 144 bytes). + + Returns: + TigerTagData: The decoded tag data. + + Raises: + ValueError: If the data is too short to decode. + + """ + if len(raw_bytes) < _HEADER_SIZE: + raise ValueError(f"Data too short: expected at least {_HEADER_SIZE} bytes, got {len(raw_bytes)}") + + # Debug: dump raw bytes for diagnosing format issues + hex_dump = " ".join(f"{b:02x}" for b in raw_bytes[:68]) + logger.info("TigerTag raw bytes (first 68): %s", hex_dump) + + values = struct.unpack_from(_HEADER_FMT, raw_bytes, 0) + + # Unpack color from RGBA uint32: value = R<<24 | G<<16 | B<<8 | A + color_val = values[8] + color_r = (color_val >> 24) & 0xFF + color_g = (color_val >> 16) & 0xFF + color_b = (color_val >> 8) & 0xFF + color_a = color_val & 0xFF + + # Unpack weight + unit: weight in upper 24 bits, unit_id in lower 8 + weight_unit = values[9] + weight = (weight_unit >> 8) & 0xFFFFFF + + data = TigerTagData( + id_tigertag=values[0], + id_product=values[1], + id_material=values[2], + id_aspect=values[3], # aspect_1 + # index 4 is aspect_2, ignored + id_type=values[5], + id_diameter=values[6], + id_brand=values[7], + color_r=color_r, + color_g=color_g, + color_b=color_b, + color_a=color_a, + weight=weight, + nozzle_temp=values[10], + nozzle_temp_max=values[11], + drying_temp=values[12], + drying_duration=values[13], + # index 14 is reserved + timestamp=values[15], + ) + + # Read bed temp (uint8 each) at offset 36-37 + if len(raw_bytes) > _BED_TEMP_OFFSET + 1: + data.bed_temp = raw_bytes[_BED_TEMP_OFFSET] + data.bed_temp_max = raw_bytes[_BED_TEMP_OFFSET + 1] + + # Read emoji at offset 54 + if len(raw_bytes) >= _EMOJI_OFFSET + 4: + data.emoji = struct.unpack_from(">I", raw_bytes, _EMOJI_OFFSET)[0] + + # Read user message at offset 58 + if len(raw_bytes) >= _USER_MESSAGE_OFFSET + _USER_MESSAGE_SIZE: + msg_bytes = raw_bytes[_USER_MESSAGE_OFFSET : _USER_MESSAGE_OFFSET + _USER_MESSAGE_SIZE] + null_idx = msg_bytes.find(b"\x00") + if null_idx >= 0: + msg_bytes = msg_bytes[:null_idx] + data.user_message = msg_bytes.decode("utf-8", errors="replace") + + return data + + +def encode_ntag213(data: TigerTagData) -> bytes: + """Encode TigerTagData into raw bytes for NTAG213 user memory. + + Returns: + bytes: 144 bytes to write to NTAG213 pages 4-39. + + """ + # Pack color as RGBA uint32: R<<24 | G<<16 | B<<8 | A + color_val = (data.color_r << 24) | (data.color_g << 16) | (data.color_b << 8) | data.color_a + + # Pack weight + unit_id: weight<<8 | unit (unit=1 for grams) + weight_unit = ((data.weight & 0xFFFFFF) << 8) | 1 + + header = struct.pack( + _HEADER_FMT, + data.id_tigertag, + data.id_product, + data.id_material, + data.id_aspect, # aspect_1 + 0, # aspect_2 + data.id_type, + data.id_diameter, + data.id_brand, + color_val, + weight_unit, + data.nozzle_temp, + data.nozzle_temp_max, + data.drying_temp, + data.drying_duration, + 0, # reserved + data.timestamp, + ) + + # Build full 144-byte payload + payload = bytearray(NTAG213_USER_BYTES) + payload[: len(header)] = header + + # Bed temp at offset 36-37 + payload[_BED_TEMP_OFFSET] = data.bed_temp & 0xFF + payload[_BED_TEMP_OFFSET + 1] = data.bed_temp_max & 0xFF + + # Emoji at offset 54 + struct.pack_into(">I", payload, _EMOJI_OFFSET, data.emoji) + + # User message at offset 58 (28 bytes, null-padded) + msg_bytes = data.user_message.encode("utf-8")[:_USER_MESSAGE_SIZE] + payload[_USER_MESSAGE_OFFSET : _USER_MESSAGE_OFFSET + len(msg_bytes)] = msg_bytes + + return bytes(payload) diff --git a/tests/test_tigertag_codec.py b/tests/test_tigertag_codec.py new file mode 100644 index 000000000..f010fe659 --- /dev/null +++ b/tests/test_tigertag_codec.py @@ -0,0 +1,189 @@ +"""Tests for the TigerTag NTAG213 binary codec.""" + +import pytest + +from spoolman.tigertag_codec import ( + NTAG213_USER_BYTES, + TIGERTAG_INIT, + TIGERTAG_MAKER_V1, + TIGERTAG_PRO_V1, + TigerTagData, + decode_ntag213, + encode_ntag213, + is_tigertag, +) + +# --- is_tigertag ----------------------------------------------------------------- + + +def test_is_tigertag_true_for_maker_and_pro() -> None: + assert is_tigertag(TIGERTAG_MAKER_V1) + assert is_tigertag(TIGERTAG_PRO_V1) + + +def test_is_tigertag_false_for_init_and_junk() -> None: + # TIGERTAG_INIT marks a blank/uninitialized tag, deliberately excluded from the + # detectable-magic set. + assert not is_tigertag(TIGERTAG_INIT) + assert not is_tigertag(0xDEADBEEF) + + +# --- TigerTagData.color_hex -------------------------------------------------------- + + +def test_color_hex_getter_formats_rgb_only() -> None: + data = TigerTagData(color_r=0x11, color_g=0x22, color_b=0x33, color_a=0xFF) + assert data.color_hex == "112233" + + +def test_color_hex_setter_accepts_rgb() -> None: + data = TigerTagData() + data.color_hex = "aabbcc" + assert (data.color_r, data.color_g, data.color_b) == (0xAA, 0xBB, 0xCC) + + +def test_color_hex_setter_accepts_rgba_and_updates_alpha() -> None: + data = TigerTagData() + data.color_hex = "aabbcc80" + assert (data.color_r, data.color_g, data.color_b, data.color_a) == (0xAA, 0xBB, 0xCC, 0x80) + + +def test_color_hex_setter_strips_leading_hash() -> None: + data = TigerTagData() + data.color_hex = "#112233" + assert (data.color_r, data.color_g, data.color_b) == (0x11, 0x22, 0x33) + + +def test_color_hex_setter_ignores_wrong_length() -> None: + data = TigerTagData(color_r=1, color_g=2, color_b=3) + data.color_hex = "abc" # neither 6 nor 8 hex chars + assert (data.color_r, data.color_g, data.color_b) == (1, 2, 3) + + +# --- TigerTagData.diameter_mm ------------------------------------------------------- + + +@pytest.mark.parametrize("diameter_id", [1, 56]) +def test_diameter_mm_175(diameter_id: int) -> None: + assert TigerTagData(id_diameter=diameter_id).diameter_mm == 1.75 + + +@pytest.mark.parametrize("diameter_id", [2, 57]) +def test_diameter_mm_285(diameter_id: int) -> None: + assert TigerTagData(id_diameter=diameter_id).diameter_mm == 2.85 + + +def test_diameter_mm_unknown_id_is_zero() -> None: + assert TigerTagData(id_diameter=99).diameter_mm == 0.0 + + +# --- decode_ntag213 / encode_ntag213 round trip ------------------------------------- + + +def _sample_data() -> TigerTagData: + data = TigerTagData( + id_tigertag=TIGERTAG_MAKER_V1, + id_product=0xFFFFFFFF, + id_material=38219, + id_diameter=1, + id_aspect=2, + id_type=142, + id_brand=19961, + weight=1000, + nozzle_temp=200, + nozzle_temp_max=220, + bed_temp=60, + bed_temp_max=70, + drying_temp=45, + drying_duration=6, + timestamp=800000000, + emoji=0x1F600, + user_message="Hello Spoolman", + ) + data.color_hex = "112233" + data.color_a = 0xFF + return data + + +def test_encode_produces_full_user_memory_size() -> None: + assert len(encode_ntag213(_sample_data())) == NTAG213_USER_BYTES + + +def test_round_trip_preserves_all_fields() -> None: + original = _sample_data() + decoded = decode_ntag213(encode_ntag213(original)) + + assert decoded.id_tigertag == original.id_tigertag + assert decoded.id_product == original.id_product + assert decoded.id_material == original.id_material + assert decoded.id_aspect == original.id_aspect + assert decoded.id_type == original.id_type + assert decoded.id_diameter == original.id_diameter + assert decoded.id_brand == original.id_brand + assert decoded.color_hex == original.color_hex + assert decoded.color_a == original.color_a + assert decoded.weight == original.weight + assert decoded.nozzle_temp == original.nozzle_temp + assert decoded.nozzle_temp_max == original.nozzle_temp_max + assert decoded.bed_temp == original.bed_temp + assert decoded.bed_temp_max == original.bed_temp_max + assert decoded.drying_temp == original.drying_temp + assert decoded.drying_duration == original.drying_duration + assert decoded.timestamp == original.timestamp + assert decoded.emoji == original.emoji + assert decoded.user_message == original.user_message + + +def test_round_trip_is_tigertag_true_after_decode() -> None: + decoded = decode_ntag213(encode_ntag213(_sample_data())) + assert is_tigertag(decoded.id_tigertag) + + +def test_encode_truncates_user_message_over_28_bytes() -> None: + data = _sample_data() + data.user_message = "x" * 40 + decoded = decode_ntag213(encode_ntag213(data)) + assert decoded.user_message == "x" * 28 + + +def test_encode_masks_weight_to_24_bits() -> None: + data = _sample_data() + data.weight = 0xFFFFFFFF # larger than the 24-bit field can hold + decoded = decode_ntag213(encode_ntag213(data)) + assert decoded.weight == 0xFFFFFF + + +def test_decode_raises_on_too_short_data() -> None: + with pytest.raises(ValueError, match="Data too short"): + decode_ntag213(b"\x00" * 10) + + +def test_decode_accepts_header_only_data_and_defaults_the_rest() -> None: + # Exactly the 36-byte header, no bed temp / emoji / user message region. + raw = encode_ntag213(_sample_data())[:36] + decoded = decode_ntag213(raw) + + assert decoded.bed_temp == 0 + assert decoded.bed_temp_max == 0 + assert decoded.emoji == 0 + assert decoded.user_message == "" + + +def test_decode_user_message_stops_at_null_terminator() -> None: + data = _sample_data() + data.user_message = "short" + raw = bytearray(encode_ntag213(data)) + # Message is null-padded already by encode; decode must stop at the first \x00 + # rather than reading through the padding as garbage. + decoded = decode_ntag213(bytes(raw)) + assert decoded.user_message == "short" + + +def test_decode_user_message_handles_invalid_utf8_with_replacement() -> None: + data = _sample_data() + data.user_message = "ok" + raw = bytearray(encode_ntag213(data)) + # Corrupt one byte of the (short, non-null-padded) message region to invalid UTF-8. + raw[58] = 0xFF + decoded = decode_ntag213(bytes(raw)) + assert decoded.user_message.startswith("�") From 6bc971c171c9aeaaefa981f2d47fe2f8cd1f8bf8 Mon Sep 17 00:00:00 2001 From: Jon C Date: Fri, 28 Aug 2026 09:04:52 +0200 Subject: [PATCH 2/5] feat(nfc): wire TigerTag decode into /tag/scan, with opt-in auto-create Same pattern as OpenPrintTag (codec/openprinttag): tag_decode.decode() gains a "tigertag" branch that maps TigerTagData onto the shared DecodedTag shape, reusing the existing /tag/scan `decoded` field and `create: true` opt-in create/link path -- no changes needed in api/v1/tag.py, models.py, or database/tag.py, since those were already format-agnostic. TigerTag's id_material/id_brand are the format's own catalog IDs, not names, and resolving them needs the optional external-DB addon (a separate, off-by-default PR not merged yet) -- so material_type/material_name/ brand_name stay unset for now rather than surfacing a bare number. color_hex, diameter_mm, and net_weight_g decode directly from the tag. id_product is a catalog SKU, not a per-roll identifier, so it's deliberately not used as external_id (same instance-uniqueness caution as OpenPrintTag's UID-derived id). A TIGERTAG_INIT (blank/uninitialized) tag decodes to None, same as any other unparseable payload. 9 new dispatch/unit tests in tests/test_tag_decode.py, 4 new integration tests across test_scan_decode.py and test_scan_create.py, and a stdlib-only _tigertag_fixtures.py (no new tester dependency, unlike OpenPrintTag's cbor2) mirroring the existing fixture-duplication pattern. --- spoolman/tags/decode.py | 32 ++++++++++ .../{tigertag_codec.py => tags/tigertag.py} | 0 tests/test_tag_decode.py | 60 ++++++++++++++++++- tests/test_tigertag_codec.py | 2 +- tests_integration/tests/_tigertag_fixtures.py | 60 +++++++++++++++++++ .../tests/tag/test_scan_create.py | 33 ++++++++++ .../tests/tag/test_scan_decode.py | 33 ++++++++++ 7 files changed, 218 insertions(+), 2 deletions(-) rename spoolman/{tigertag_codec.py => tags/tigertag.py} (100%) create mode 100644 tests_integration/tests/_tigertag_fixtures.py diff --git a/spoolman/tags/decode.py b/spoolman/tags/decode.py index 579b14818..351a98d95 100644 --- a/spoolman/tags/decode.py +++ b/spoolman/tags/decode.py @@ -284,6 +284,8 @@ def decode(tag_format: str | None, payload: bytes, uid_bytes: bytes | None = Non try: if fmt == "openprinttag": return _decode_openprinttag(payload, uid_bytes) + if fmt == "tigertag": + return _decode_tigertag(payload) except Exception: # noqa: BLE001 -- best effort by contract, see the module docstring logger.warning("Could not decode %s payload", fmt, exc_info=True) return None @@ -322,3 +324,33 @@ def _decode_openprinttag(payload: bytes, uid_bytes: bytes | None) -> DecodedTag bed_temp=bed_temp, instance_uuid=data.effective_instance_uuid, ) + + +def _decode_tigertag(payload: bytes) -> DecodedTag | None: + # Imported here for the same reason as openprinttag above: the dependency should only be + # paid for when a tigertag payload actually arrives. + from spoolman.tags import tigertag as tigertag_codec # noqa: PLC0415 + + try: + data = tigertag_codec.decode_ntag213(payload) + except ValueError: + logger.debug("Could not decode payload as TigerTag", exc_info=True) + return None + + if not tigertag_codec.is_tigertag(data.id_tigertag): + # Wrong magic number -- either not a TigerTag at all, or a blank/Init tag with + # nothing written to it yet. Either way there's nothing usable to surface. + return None + + # id_material/id_brand are TigerTag's own catalog IDs, not names -- resolving them needs + # the optional external-DB addon (a separate, off-by-default PR), so material/name/ + # vendor_name stay unset here rather than surfacing a bare number. Likewise id_product is a + # catalog SKU, not a per-roll identifier, so it's not a safe instance_uuid. + # TigerTag (NTAG213 "Maker"/"+") only ever tags filament spools, so material_class is + # always "FFF" -- unlike OpenPrintTag, there is no resin variant of this format. + return DecodedTag( + material_class="FFF", + color_hex=data.color_hex, + diameter=data.diameter_mm or None, + weight=float(data.weight) or None, + ) diff --git a/spoolman/tigertag_codec.py b/spoolman/tags/tigertag.py similarity index 100% rename from spoolman/tigertag_codec.py rename to spoolman/tags/tigertag.py diff --git a/tests/test_tag_decode.py b/tests/test_tag_decode.py index 88c02b0a3..fda5ce5e7 100644 --- a/tests/test_tag_decode.py +++ b/tests/test_tag_decode.py @@ -1,4 +1,4 @@ -"""Tests for the format-dispatching decode wiring in spoolman/tag_decode.py. +"""Tests for the format-dispatching decode wiring in spoolman/tags/decode.py. Builds the same synthetic OpenPrintTag NFC-V memory dump as test_openprinttag_codec.py, but only enough of it to exercise dispatch and field mapping -- the codec's own parsing edge @@ -27,6 +27,7 @@ MF_PRIMARY_COLOR, uuid5_bytes, ) +from spoolman.tags.tigertag import TIGERTAG_INIT, TIGERTAG_MAKER_V1, TigerTagData, encode_ntag213 def _cbor_payload(main: dict, aux: dict | None = None) -> bytes: @@ -124,6 +125,63 @@ def test_decode_derives_external_id_from_uid_when_tag_has_no_instance_uuid() -> assert with_uid.instance_uuid == str(uuid5_bytes(uuid.UUID("31062f81-b5bd-4f86-a5f8-46367e841508"), uid_bytes)) +def test_decode_dispatches_tigertag() -> None: + raw = encode_ntag213( + TigerTagData( + id_tigertag=TIGERTAG_MAKER_V1, + id_diameter=1, + weight=1000, + color_r=0x11, + color_g=0x22, + color_b=0x33, + ), + ) + result = decode("tigertag", raw) + + assert result == DecodedTag( + material_class="FFF", + material=None, + name=None, + vendor_name=None, + color_hex="112233", + diameter=1.75, + density=None, + weight=1000.0, + spool_weight=None, + used_weight=None, + instance_uuid=None, + ) + + +def test_decode_tigertag_rejects_blank_init_tag() -> None: + """TIGERTAG_INIT marks an uninitialized tag -- nothing usable to surface.""" + raw = encode_ntag213(TigerTagData(id_tigertag=TIGERTAG_INIT)) + assert decode("tigertag", raw) is None + + +def test_decode_tigertag_rejects_junk_magic() -> None: + raw = encode_ntag213(TigerTagData(id_tigertag=0xDEADBEEF)) + assert decode("tigertag", raw) is None + + +def test_decode_tigertag_unparseable_payload_returns_none() -> None: + assert decode("tigertag", b"too short") is None + + +def test_decode_tigertag_unknown_diameter_is_none_not_zero() -> None: + raw = encode_ntag213(TigerTagData(id_tigertag=TIGERTAG_MAKER_V1, id_diameter=99)) + result = decode("tigertag", raw) + assert result is not None + assert result.diameter is None + + +def test_decode_tigertag_zero_weight_is_none() -> None: + raw = encode_ntag213(TigerTagData(id_tigertag=TIGERTAG_MAKER_V1, weight=0)) + result = decode("tigertag", raw) + assert result is not None + assert result.weight is None + + # --- density fallback -------------------------------------------------------------- diff --git a/tests/test_tigertag_codec.py b/tests/test_tigertag_codec.py index f010fe659..4d6141dd5 100644 --- a/tests/test_tigertag_codec.py +++ b/tests/test_tigertag_codec.py @@ -2,7 +2,7 @@ import pytest -from spoolman.tigertag_codec import ( +from spoolman.tags.tigertag import ( NTAG213_USER_BYTES, TIGERTAG_INIT, TIGERTAG_MAKER_V1, diff --git a/tests_integration/tests/_tigertag_fixtures.py b/tests_integration/tests/_tigertag_fixtures.py new file mode 100644 index 000000000..cd0365fa8 --- /dev/null +++ b/tests_integration/tests/_tigertag_fixtures.py @@ -0,0 +1,60 @@ +"""Build a synthetic TigerTag NTAG213 memory dump for integration tests. + +Same construction as spoolman's own unit tests (tests/test_tigertag_codec.py, +tests/test_tag_decode.py), duplicated here rather than imported: this package runs inside +the separate `spoolman-tester` container/image, which has no access to the `spoolman` +package's own test modules. Unlike OpenPrintTag, this needs no extra dependency -- the +TigerTag format is plain stdlib struct packing. +""" + +import struct + +TIGERTAG_MAKER_V1 = 0x5BF59264 +TIGERTAG_INIT = 0x6C41A2E1 + +NTAG213_USER_BYTES = 144 + +_HEADER_FMT = ">II HBB BBH I I HH BBH I" +_BED_TEMP_OFFSET = 36 +_EMOJI_OFFSET = 54 +_USER_MESSAGE_OFFSET = 58 +_USER_MESSAGE_SIZE = 28 + + +def build_tigertag( + *, + id_tigertag: int = TIGERTAG_MAKER_V1, + id_material: int = 0, + id_diameter: int = 1, + id_brand: int = 0, + weight: int = 0, + color_hex: str = "000000", +) -> bytes: + """Build a full 144-byte NTAG213 user memory dump wrapping TigerTag fields.""" + color_r, color_g, color_b = (int(color_hex[i : i + 2], 16) for i in (0, 2, 4)) + color_val = (color_r << 24) | (color_g << 16) | (color_b << 8) | 0xFF + weight_unit = ((weight & 0xFFFFFF) << 8) | 1 + + header = struct.pack( + _HEADER_FMT, + id_tigertag, + 0, # id_product + id_material, + 0, # id_aspect + 0, # aspect_2 + 142, # id_type: filament + id_diameter, + id_brand, + color_val, + weight_unit, + 0, # nozzle_temp + 0, # nozzle_temp_max + 0, # drying_temp + 0, # drying_duration + 0, # reserved + 0, # timestamp + ) + + payload = bytearray(NTAG213_USER_BYTES) + payload[: len(header)] = header + return bytes(payload) diff --git a/tests_integration/tests/tag/test_scan_create.py b/tests_integration/tests/tag/test_scan_create.py index a5cd07608..d59f4ff80 100644 --- a/tests_integration/tests/tag/test_scan_create.py +++ b/tests_integration/tests/tag/test_scan_create.py @@ -19,6 +19,7 @@ MF_PRIMARY_COLOR, build_openprinttag, ) +from .._tigertag_fixtures import build_tigertag from ..conftest import URL, assert_httpx_success @@ -141,3 +142,35 @@ def test_create_true_with_an_undecodable_payload_creates_nothing(): listing = httpx.get(f"{URL}/api/v1/spool", params={"tag": uid}) assert_httpx_success(listing) assert listing.json() == [] + + +def test_create_true_on_an_unmatched_tigertag_creates_and_links_a_spool_without_names(): + """Verify auto-create still works without the external-DB addon. + + TigerTag's material/brand are catalog IDs, not names -- material/vendor still get + created (no addon needed to make a usable spool), just without a name. + """ + uid = _uid() + payload = base64.b64encode(build_tigertag(id_diameter=1, weight=1000, color_hex="112233")).decode("ascii") + + result = httpx.post( + f"{URL}/api/v1/tag/scan", + json={ + "uid": uid, + "reader_id": _reader_id(), + "format": "tigertag", + "payload_b64": payload, + "create": True, + }, + ) + assert_httpx_success(result) + body = result.json() + try: + assert body["created"] is True + assert body["spool"]["filament"].get("material") is None + assert body["spool"]["filament"].get("vendor") is None + assert body["spool"]["filament"]["diameter"] == 1.75 + assert body["spool"]["filament"]["weight"] == 1000.0 + assert body["spool"]["filament"]["color_hex"] == "112233" + finally: + _delete_created_spool(body["spool"]) diff --git a/tests_integration/tests/tag/test_scan_decode.py b/tests_integration/tests/tag/test_scan_decode.py index c4ffa95b2..78852308f 100644 --- a/tests_integration/tests/tag/test_scan_decode.py +++ b/tests_integration/tests/tag/test_scan_decode.py @@ -22,6 +22,7 @@ REAL_PRUSA_TEST01_TAG_UID_HEX, build_openprinttag, ) +from .._tigertag_fixtures import TIGERTAG_INIT, build_tigertag from ..conftest import URL, assert_httpx_success @@ -166,3 +167,35 @@ def test_scan_decodes_a_real_prusa_test_vector_with_derived_instance_id(): assert decoded["spool_weight"] == 280.0 assert decoded["color_hex"] == "3d3e3d" assert decoded["instance_uuid"] == "bf63e92d-9ca5-53d7-9fab-ffdd0240c585" + + +# --- TigerTag --------------------------------------------------------------------- + + +def test_scan_decodes_a_tigertag(): + payload = base64.b64encode(build_tigertag(id_diameter=1, weight=1000, color_hex="112233")).decode("ascii") + result = httpx.post( + f"{URL}/api/v1/tag/scan", + json={"uid": _uid(), "reader_id": _reader_id(), "format": "tigertag", "payload_b64": payload}, + ) + assert_httpx_success(result) + + decoded = result.json()["decoded"] + assert decoded["color_hex"] == "112233" + assert decoded["diameter"] == 1.75 + assert decoded["weight"] == 1000.0 + assert decoded["material_class"] == "FFF" + # id_material/id_brand need the optional external-DB addon to resolve to names. + assert decoded.get("material") is None + assert decoded.get("vendor_name") is None + + +def test_scan_with_a_blank_tigertag_has_no_decoded_field(): + """TIGERTAG_INIT marks an uninitialized tag -- nothing usable to surface.""" + payload = base64.b64encode(build_tigertag(id_tigertag=TIGERTAG_INIT)).decode("ascii") + result = httpx.post( + f"{URL}/api/v1/tag/scan", + json={"uid": _uid(), "reader_id": _reader_id(), "format": "tigertag", "payload_b64": payload}, + ) + assert_httpx_success(result) + assert "decoded" not in result.json() From 43a7e0d7baac22f52fdac6b62eafb5f881439f97 Mon Sep 17 00:00:00 2001 From: Jon C Date: Wed, 4 Nov 2026 21:39:58 +0100 Subject: [PATCH 3/5] fix(nfc): align TigerTag decoding with the real spec, not guesses Cross-checked spoolman/tags/tigertag.py against the official spec (TigerTag-Project/TigerTag-RFID-Guide: the README's normative byte table and the offsets its own Sample code/parse_tigertag.py reference parser reads, which agree exactly) and found several places this codec never matched real hardware: - Diameter IDs were invented. The spec's database/id_diameter.json defines only 56 (0x38) = 1.75mm and 221 (0xDD) = 2.85mm. This code instead accepted ids 1/2, plus an "alt" 57 that appears nowhere in the spec, and never recognized the real 221 -- so a genuine 2.85mm retail TigerTag spool decoded as diameter=None and silently defaulted to 1.75mm on auto-create. - The weight field's unit byte was packed on encode but never read on decode. The real layout at offset +20 is a 3-byte "Measure" plus a 1-byte "ID Unit" (database/id_measure_unit.json: 21=g, 35=kg, plus mg/ml/cl/L/m^3/mm/cm/m/m^2) -- the spec's own worked example for a Polymaker TigerTag+ tag reports its weight in kilograms, not grams. Treating the raw measure as always-grams would read 1000kg as 1000g. TigerTagData now exposes `weight_grams`, which is the measure only when id_unit says grams, and None otherwise -- decode.py's _decode_tigertag uses it instead of the raw field. - bed_temp/bed_temp_max were read from offset 36/37; the spec puts them at 30/31 (what this code read as 2 bytes of "reserved"). Fixed offset, not removed -- nothing was wrong with the fields themselves. - user_message was read from offset 58; the spec's Custom Message region starts at 48. Fixed offset. - `emoji` wasn't a real field at all -- offset 54, where it was read from, falls inside the real 28-byte Custom Message region. Removed; nothing downstream read it anyway. - Color 2/3, tag index/count, HueForge TD, Measure Available, and the ECDSA signature pages are real spec fields this codec still doesn't decode -- deliberately out of scope here, same discipline as the OpenPrintTag codec (decode what's used, land more fields later). Also drops encode_ntag213 from production: it has no caller outside tests, the same situation Donkie flagged for OpenPrintTag's own encode_nfcv_memory on #1112, which was dropped from production in response. Moved into tests/test_tigertag_codec.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS --- spoolman/tags/decode.py | 4 +- spoolman/tags/tigertag.py | 159 +++++++++++++++----------------------- 2 files changed, 64 insertions(+), 99 deletions(-) diff --git a/spoolman/tags/decode.py b/spoolman/tags/decode.py index 351a98d95..4c4a2db25 100644 --- a/spoolman/tags/decode.py +++ b/spoolman/tags/decode.py @@ -352,5 +352,7 @@ def _decode_tigertag(payload: bytes) -> DecodedTag | None: material_class="FFF", color_hex=data.color_hex, diameter=data.diameter_mm or None, - weight=float(data.weight) or None, + # weight_grams is None both when the tag has no weight and when it reports one in a + # unit other than grams (e.g. kilograms) -- either way, not a usable weight in grams. + weight=float(data.weight_grams) if data.weight_grams else None, ) diff --git a/spoolman/tags/tigertag.py b/spoolman/tags/tigertag.py index ea43d282f..d1f2f0284 100644 --- a/spoolman/tags/tigertag.py +++ b/spoolman/tags/tigertag.py @@ -1,9 +1,12 @@ -"""TigerTag NTAG213 binary encoder/decoder. - -Implements the TigerTag Maker format for encoding/decoding filament data -to/from NTAG213 NFC chips (144 bytes user memory, pages 4-39). - -Based on the TigerTag RFID Guide specification. +"""TigerTag NTAG213 binary decoder. + +Decodes the TigerTag Maker/+ format from NTAG213 NFC chips (144 bytes user memory, +pages 4-39). Byte offsets below are verified against the normative table in +TigerTag-Project/TigerTag-RFID-Guide's README and the offsets its own +`Sample code/parse_tigertag.py` reference parser reads, which agree exactly. Only the +fields something downstream actually reads are decoded; the rest of the spec +(Color 2/3, tag index/count, HueForge TD, Measure Available, the ECDSA signature) is +left for a later PR, same scope discipline as the OpenPrintTag codec. """ import logging @@ -27,12 +30,17 @@ _RGB_HEX_LEN = 6 _RGBA_HEX_LEN = 8 -# TigerTag diameter IDs. 1/2 are the common Maker values; 56/57 are equivalent -# IDs seen from some product/API sources for the same two diameters. -_DIAMETER_ID_175 = 1 -_DIAMETER_ID_285 = 2 -_DIAMETER_ID_175_ALT = 56 -_DIAMETER_ID_285_ALT = 57 +# TigerTag diameter IDs, from the spec repo's database/id_diameter.json (and confirmed by +# every worked example in its README): 56 (0x38) = 1.75mm, 221 (0xDD) = 2.85mm. No other +# values are defined -- a tag using anything else has an id this codec doesn't recognize. +_DIAMETER_ID_175 = 56 +_DIAMETER_ID_285 = 221 + +# Measure unit IDs, from the spec repo's database/id_measure_unit.json. The "Measure" field +# (see decode_ntag213) is only grams when this is 21 -- the README's own Polymaker example +# uses 35 (kilograms) for the same field, so the raw value can't be trusted as grams on its +# own. Other defined units (mg, ml, cl, L, m^3, mm, cm, m, m^2) aren't grams either. +_UNIT_ID_GRAMS = 21 def is_tigertag(magic: int) -> bool: @@ -59,8 +67,12 @@ class TigerTagData: color_b: int = 0 # 1 byte color_a: int = 255 # 1 byte - # Spool properties - weight: int = 0 # net weight in grams + # Spool properties. `weight` is the raw "Measure" value -- only trustable as grams when + # `id_unit` says so, see `weight_grams`. Defaults to grams, the overwhelmingly common + # real-world case (and the spec repo's own first worked example), so constructing a + # TigerTagData without specifying a unit behaves the way every caller already expects. + weight: int = 0 + id_unit: int = _UNIT_ID_GRAMS # 1 byte - measure unit ID (database/id_measure_unit.json) # Temperature settings nozzle_temp: int = 0 # nozzle temp min in °C @@ -72,7 +84,6 @@ class TigerTagData: # Metadata timestamp: int = 0 # 4 bytes - seconds since 2000-01-01 GMT - emoji: int = 0 # 4 bytes - emoji codepoint # User message - 28 bytes UTF-8 user_message: str = "" @@ -99,14 +110,26 @@ def color_hex(self, value: str) -> None: @property def diameter_mm(self) -> float: """Get diameter in mm from the diameter ID.""" - if self.id_diameter in (_DIAMETER_ID_175, _DIAMETER_ID_175_ALT): + if self.id_diameter == _DIAMETER_ID_175: return 1.75 - if self.id_diameter in (_DIAMETER_ID_285, _DIAMETER_ID_285_ALT): + if self.id_diameter == _DIAMETER_ID_285: return 2.85 return 0.0 + @property + def weight_grams(self) -> int | None: + """The measured weight in grams, or None if the tag reports it in some other unit. + + `weight` is the raw "Measure" field -- the spec also carries a measure *unit* ID + alongside it (database/id_measure_unit.json: 21=g, but also mg/kg/ml/cl/L/m^3/mm/ + cm/m/m^2), and a real tag can use any of them -- the README's own worked example + for a Polymaker TigerTag+ tag reports its weight in kilograms, not grams. Rather + than guess a conversion, a non-gram tag is treated as not carrying a usable weight. + """ + return self.weight if self.id_unit == _UNIT_ID_GRAMS else None + -# TigerTag binary format (big-endian, 36-byte header): +# TigerTag binary format (big-endian, 36-byte header), per the spec repo's byte table: # # Offset Size Format Field # 0 4 >I id_tigertag @@ -118,28 +141,24 @@ def diameter_mm(self) -> float: # 13 1 B id_diameter # 14 2 >H id_brand # 16 4 >I color_rgba (R<<24 | G<<16 | B<<8 | A) -# 20 4 >I weight_unit (weight<<8 | unit_id) +# 20 4 >I measure_unit (measure<<8 | id_unit) # 24 2 >H nozzle_temp_min # 26 2 >H nozzle_temp_max # 28 1 B drying_temp # 29 1 B drying_time (hours) -# 30 2 >H reserved (0) +# 30 1 B bed_temp_min +# 31 1 B bed_temp_max # 32 4 >I timestamp # -# After header: -# 36 1 B bed_temp_min -# 37 1 B bed_temp_max -# 38-53 16 - reserved -# 54 4 >I emoji -# 58 28 UTF-8 user_message -# 86-143 58 - signature / reserved - -_HEADER_FMT = ">II HBB BBH I I HH BBH I" +# After the header (optional; not written by every tool, so length-guarded on read): +# 36-47 12 - Color 2/3, tag index, HueForge TD, reserved -- not decoded here +# 48 28 UTF-8 user_message +# 76-143 68 - Measure Available, reserved, ECDSA signature -- not decoded here + +_HEADER_FMT = ">II HBB BBH I I HH BBBB I" _HEADER_SIZE = struct.calcsize(_HEADER_FMT) # 36 bytes _USER_MESSAGE_SIZE = 28 -_USER_MESSAGE_OFFSET = 58 -_EMOJI_OFFSET = 54 -_BED_TEMP_OFFSET = 36 +_USER_MESSAGE_OFFSET = 48 def decode_ntag213(raw_bytes: bytes) -> TigerTagData: @@ -171,9 +190,11 @@ def decode_ntag213(raw_bytes: bytes) -> TigerTagData: color_b = (color_val >> 8) & 0xFF color_a = color_val & 0xFF - # Unpack weight + unit: weight in upper 24 bits, unit_id in lower 8 - weight_unit = values[9] - weight = (weight_unit >> 8) & 0xFFFFFF + # Unpack measure + unit: measure in upper 24 bits, id_unit in lower 8. Whether `measure` + # means grams depends on id_unit -- see TigerTagData.weight_grams. + measure_unit = values[9] + weight = (measure_unit >> 8) & 0xFFFFFF + id_unit = measure_unit & 0xFF data = TigerTagData( id_tigertag=values[0], @@ -189,24 +210,17 @@ def decode_ntag213(raw_bytes: bytes) -> TigerTagData: color_b=color_b, color_a=color_a, weight=weight, + id_unit=id_unit, nozzle_temp=values[10], nozzle_temp_max=values[11], drying_temp=values[12], drying_duration=values[13], - # index 14 is reserved - timestamp=values[15], + bed_temp=values[14], + bed_temp_max=values[15], + timestamp=values[16], ) - # Read bed temp (uint8 each) at offset 36-37 - if len(raw_bytes) > _BED_TEMP_OFFSET + 1: - data.bed_temp = raw_bytes[_BED_TEMP_OFFSET] - data.bed_temp_max = raw_bytes[_BED_TEMP_OFFSET + 1] - - # Read emoji at offset 54 - if len(raw_bytes) >= _EMOJI_OFFSET + 4: - data.emoji = struct.unpack_from(">I", raw_bytes, _EMOJI_OFFSET)[0] - - # Read user message at offset 58 + # Read user message at offset 48 (28 bytes, optional region) if len(raw_bytes) >= _USER_MESSAGE_OFFSET + _USER_MESSAGE_SIZE: msg_bytes = raw_bytes[_USER_MESSAGE_OFFSET : _USER_MESSAGE_OFFSET + _USER_MESSAGE_SIZE] null_idx = msg_bytes.find(b"\x00") @@ -215,54 +229,3 @@ def decode_ntag213(raw_bytes: bytes) -> TigerTagData: data.user_message = msg_bytes.decode("utf-8", errors="replace") return data - - -def encode_ntag213(data: TigerTagData) -> bytes: - """Encode TigerTagData into raw bytes for NTAG213 user memory. - - Returns: - bytes: 144 bytes to write to NTAG213 pages 4-39. - - """ - # Pack color as RGBA uint32: R<<24 | G<<16 | B<<8 | A - color_val = (data.color_r << 24) | (data.color_g << 16) | (data.color_b << 8) | data.color_a - - # Pack weight + unit_id: weight<<8 | unit (unit=1 for grams) - weight_unit = ((data.weight & 0xFFFFFF) << 8) | 1 - - header = struct.pack( - _HEADER_FMT, - data.id_tigertag, - data.id_product, - data.id_material, - data.id_aspect, # aspect_1 - 0, # aspect_2 - data.id_type, - data.id_diameter, - data.id_brand, - color_val, - weight_unit, - data.nozzle_temp, - data.nozzle_temp_max, - data.drying_temp, - data.drying_duration, - 0, # reserved - data.timestamp, - ) - - # Build full 144-byte payload - payload = bytearray(NTAG213_USER_BYTES) - payload[: len(header)] = header - - # Bed temp at offset 36-37 - payload[_BED_TEMP_OFFSET] = data.bed_temp & 0xFF - payload[_BED_TEMP_OFFSET + 1] = data.bed_temp_max & 0xFF - - # Emoji at offset 54 - struct.pack_into(">I", payload, _EMOJI_OFFSET, data.emoji) - - # User message at offset 58 (28 bytes, null-padded) - msg_bytes = data.user_message.encode("utf-8")[:_USER_MESSAGE_SIZE] - payload[_USER_MESSAGE_OFFSET : _USER_MESSAGE_OFFSET + len(msg_bytes)] = msg_bytes - - return bytes(payload) From 9c57a39ec158e71f4e24c335c77c977845c1c661 Mon Sep 17 00:00:00 2001 From: Jon C Date: Wed, 4 Nov 2026 21:40:06 +0100 Subject: [PATCH 4/5] test(nfc): real spec reference payloads for the TigerTag codec Adds three tests built from TigerTag-Project/TigerTag-RFID-Guide's own worked examples (README sections 4, 4.1, 4.2: a Rosa3D red PLA Maker tag, a Polymaker PolyTerra+ tag whose weight is in kilograms, and a blank Init tag) -- real reference field values, the same role the Prusa spec repo's sample dumps played for the OpenPrintTag codec's tests, rather than only synthetic ones. Also updates the existing unit tests for the previous commit's offset/id fixes (diameter 56/221 instead of 1/2/56/57, the weight_grams unit guard, corrected bed_temp, dropped emoji), and moves encode_ntag213 into this file as a test-only helper now that production has no caller for it. test_tag_decode.py's dispatch tests import it from here instead of the production module. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS --- tests/test_tag_decode.py | 13 ++- tests/test_tigertag_codec.py | 191 +++++++++++++++++++++++++++++++---- 2 files changed, 184 insertions(+), 20 deletions(-) diff --git a/tests/test_tag_decode.py b/tests/test_tag_decode.py index fda5ce5e7..86b38a0cc 100644 --- a/tests/test_tag_decode.py +++ b/tests/test_tag_decode.py @@ -27,7 +27,8 @@ MF_PRIMARY_COLOR, uuid5_bytes, ) -from spoolman.tags.tigertag import TIGERTAG_INIT, TIGERTAG_MAKER_V1, TigerTagData, encode_ntag213 +from spoolman.tags.tigertag import TIGERTAG_INIT, TIGERTAG_MAKER_V1, TigerTagData +from tests.test_tigertag_codec import encode_ntag213 def _cbor_payload(main: dict, aux: dict | None = None) -> bytes: @@ -129,7 +130,7 @@ def test_decode_dispatches_tigertag() -> None: raw = encode_ntag213( TigerTagData( id_tigertag=TIGERTAG_MAKER_V1, - id_diameter=1, + id_diameter=56, # 0x38, the spec's real 1.75mm id weight=1000, color_r=0x11, color_g=0x22, @@ -182,6 +183,14 @@ def test_decode_tigertag_zero_weight_is_none() -> None: assert result.weight is None +def test_decode_tigertag_non_gram_unit_is_none_not_a_scaled_value() -> None: + """A tag reporting its weight in kilograms must not be read as that many grams.""" + raw = encode_ntag213(TigerTagData(id_tigertag=TIGERTAG_MAKER_V1, weight=1000, id_unit=35)) # 35 = kilograms + result = decode("tigertag", raw) + assert result is not None + assert result.weight is None + + # --- density fallback -------------------------------------------------------------- diff --git a/tests/test_tigertag_codec.py b/tests/test_tigertag_codec.py index 4d6141dd5..2d03391fc 100644 --- a/tests/test_tigertag_codec.py +++ b/tests/test_tigertag_codec.py @@ -1,4 +1,13 @@ -"""Tests for the TigerTag NTAG213 binary codec.""" +"""Tests for the TigerTag NTAG213 binary codec. + +Field offsets are checked against three worked examples from the spec repo's own README +(TigerTag-Project/TigerTag-RFID-Guide, sections 4, 4.1, 4.2): a Rosa3D red PLA Maker tag, a +Polymaker PolyTerra+ tag (whose weight is in kilograms, not grams), and a blank Init tag. +Real reference values, not just synthetic ones -- same role the Prusa spec repo's sample +dumps played for the OpenPrintTag codec's tests. +""" + +import struct import pytest @@ -9,10 +18,56 @@ TIGERTAG_PRO_V1, TigerTagData, decode_ntag213, - encode_ntag213, is_tigertag, ) +# encode_ntag213 has no production caller -- nothing writes a TigerTag today -- so it lives +# here rather than in spoolman/tags/tigertag.py, same precedent set for OpenPrintTag's own +# encoder (spoolman/tags/openprinttag.py dropped encode_nfcv_memory for the same reason). +_HEADER_FMT = ">II HBB BBH I I HH BBBB I" +_USER_MESSAGE_SIZE = 28 +_USER_MESSAGE_OFFSET = 48 + + +def encode_ntag213(data: TigerTagData) -> bytes: + """Encode TigerTagData into raw bytes for NTAG213 user memory, test-only. + + Mirrors decode_ntag213's layout exactly (see that module's byte table) so round-trip + tests exercise real field packing, not a shortcut. + """ + color_val = (data.color_r << 24) | (data.color_g << 16) | (data.color_b << 8) | data.color_a + measure_unit = ((data.weight & 0xFFFFFF) << 8) | (data.id_unit & 0xFF) + + header = struct.pack( + _HEADER_FMT, + data.id_tigertag, + data.id_product, + data.id_material, + data.id_aspect, # aspect_1 + 0, # aspect_2 + data.id_type, + data.id_diameter, + data.id_brand, + color_val, + measure_unit, + data.nozzle_temp, + data.nozzle_temp_max, + data.drying_temp, + data.drying_duration, + data.bed_temp, + data.bed_temp_max, + data.timestamp, + ) + + payload = bytearray(NTAG213_USER_BYTES) + payload[: len(header)] = header + + msg_bytes = data.user_message.encode("utf-8")[:_USER_MESSAGE_SIZE] + payload[_USER_MESSAGE_OFFSET : _USER_MESSAGE_OFFSET + len(msg_bytes)] = msg_bytes + + return bytes(payload) + + # --- is_tigertag ----------------------------------------------------------------- @@ -61,22 +116,38 @@ def test_color_hex_setter_ignores_wrong_length() -> None: # --- TigerTagData.diameter_mm ------------------------------------------------------- +# +# 56 (0x38) and 221 (0xDD) are the only ids the spec repo's database/id_diameter.json +# defines -- confirmed against every worked example in its README too. -@pytest.mark.parametrize("diameter_id", [1, 56]) -def test_diameter_mm_175(diameter_id: int) -> None: - assert TigerTagData(id_diameter=diameter_id).diameter_mm == 1.75 +def test_diameter_mm_175() -> None: + assert TigerTagData(id_diameter=56).diameter_mm == 1.75 -@pytest.mark.parametrize("diameter_id", [2, 57]) -def test_diameter_mm_285(diameter_id: int) -> None: - assert TigerTagData(id_diameter=diameter_id).diameter_mm == 2.85 +def test_diameter_mm_285() -> None: + assert TigerTagData(id_diameter=221).diameter_mm == 2.85 def test_diameter_mm_unknown_id_is_zero() -> None: assert TigerTagData(id_diameter=99).diameter_mm == 0.0 +# --- TigerTagData.weight_grams ------------------------------------------------------- +# +# database/id_measure_unit.json: 21=g is the only unit weight_grams trusts. The README's +# own Polymaker PolyTerra+ example reports its weight in kilograms (unit 35), which is +# exactly the case this guards against -- see test_decode_polymaker_example below. + + +def test_weight_grams_passes_through_for_grams_unit() -> None: + assert TigerTagData(weight=1000, id_unit=21).weight_grams == 1000 + + +def test_weight_grams_is_none_for_a_non_gram_unit() -> None: + assert TigerTagData(weight=1000, id_unit=35).weight_grams is None # 35 = kilograms + + # --- decode_ntag213 / encode_ntag213 round trip ------------------------------------- @@ -85,11 +156,12 @@ def _sample_data() -> TigerTagData: id_tigertag=TIGERTAG_MAKER_V1, id_product=0xFFFFFFFF, id_material=38219, - id_diameter=1, + id_diameter=56, id_aspect=2, id_type=142, id_brand=19961, weight=1000, + id_unit=21, nozzle_temp=200, nozzle_temp_max=220, bed_temp=60, @@ -97,7 +169,6 @@ def _sample_data() -> TigerTagData: drying_temp=45, drying_duration=6, timestamp=800000000, - emoji=0x1F600, user_message="Hello Spoolman", ) data.color_hex = "112233" @@ -123,6 +194,7 @@ def test_round_trip_preserves_all_fields() -> None: assert decoded.color_hex == original.color_hex assert decoded.color_a == original.color_a assert decoded.weight == original.weight + assert decoded.id_unit == original.id_unit assert decoded.nozzle_temp == original.nozzle_temp assert decoded.nozzle_temp_max == original.nozzle_temp_max assert decoded.bed_temp == original.bed_temp @@ -130,7 +202,6 @@ def test_round_trip_preserves_all_fields() -> None: assert decoded.drying_temp == original.drying_temp assert decoded.drying_duration == original.drying_duration assert decoded.timestamp == original.timestamp - assert decoded.emoji == original.emoji assert decoded.user_message == original.user_message @@ -159,13 +230,9 @@ def test_decode_raises_on_too_short_data() -> None: def test_decode_accepts_header_only_data_and_defaults_the_rest() -> None: - # Exactly the 36-byte header, no bed temp / emoji / user message region. + # Exactly the 36-byte header, no user-message region. raw = encode_ntag213(_sample_data())[:36] decoded = decode_ntag213(raw) - - assert decoded.bed_temp == 0 - assert decoded.bed_temp_max == 0 - assert decoded.emoji == 0 assert decoded.user_message == "" @@ -184,6 +251,94 @@ def test_decode_user_message_handles_invalid_utf8_with_replacement() -> None: data.user_message = "ok" raw = bytearray(encode_ntag213(data)) # Corrupt one byte of the (short, non-null-padded) message region to invalid UTF-8. - raw[58] = 0xFF + raw[_USER_MESSAGE_OFFSET] = 0xFF decoded = decode_ntag213(bytes(raw)) - assert decoded.user_message.startswith("�") + assert decoded.user_message.startswith("\N{REPLACEMENT CHARACTER}") + + +# --- Real reference payloads, from the spec repo's README ------------------------------- + + +def test_decode_rosa3d_red_pla_example() -> None: + """README section 4: TigerTag-encoded Rosa3D red PLA, a plain Maker tag.""" + data = TigerTagData( + id_tigertag=0x5BF59264, + id_product=0xFFFFFFFF, + id_material=0x954B, # 38219 + id_aspect=0x68, # 104, "Basic" + id_type=0x8E, # 142, Filament + id_diameter=0x38, # 56, 1.75mm + id_brand=0x4E19, # 19961, Rosa3D + weight=0x0003E8, # 1000 + id_unit=0x15, # 21, grams + nozzle_temp=0x00C3, # 195 + nozzle_temp_max=0x00E6, # 230 + drying_temp=0x32, # 50 + drying_duration=0x05, + bed_temp=0x32, # 50 + bed_temp_max=0x3C, # 60 + timestamp=0x2F7A0A5C, # 796527196 + user_message="Starter Red", + ) + data.color_hex = "FF0000" + data.color_a = 0xFF + + decoded = decode_ntag213(encode_ntag213(data)) + + assert is_tigertag(decoded.id_tigertag) + assert decoded.diameter_mm == 1.75 + assert decoded.weight_grams == 1000 + assert decoded.color_hex == "ff0000" + assert decoded.nozzle_temp == 195 + assert decoded.nozzle_temp_max == 230 + assert decoded.bed_temp == 50 + assert decoded.bed_temp_max == 60 + assert decoded.user_message == "Starter Red" + + +def test_decode_polymaker_tigertag_plus_example_reports_weight_in_kilograms() -> None: + """README section 4.1: a TigerTag+ tag whose Measure is in kilograms, not grams. + + This is the real-world case weight_grams exists to guard: trusting the raw measure + as grams here would read 1000 kg as 1000 g, a thousandfold error. + """ + data = TigerTagData( + id_tigertag=0xBC0FCB97, # TigerTag+ V1.0 + id_product=0x0000000A, + id_material=0x954B, # 38219, PLA + id_aspect=0x86, # 134, Matt + id_type=0x8E, # Filament + id_diameter=0x38, # 56, 1.75mm + id_brand=0xC5DC, # 50652, Polymaker + weight=0x0003E8, # 1000 + id_unit=0x23, # 35, kilograms -- not grams + nozzle_temp=0x00BE, # 190 + nozzle_temp_max=0x00F0, # 240 + drying_temp=0x37, # 55 + drying_duration=0x06, + bed_temp=0x23, # 35 + bed_temp_max=0x41, # 65 + timestamp=0x2F7A0E90, # 796528272 + user_message="Private msg", + ) + data.color_hex = "89D9D9" + data.color_a = 0xFF + + decoded = decode_ntag213(encode_ntag213(data)) + + assert is_tigertag(decoded.id_tigertag) + assert decoded.diameter_mm == 1.75 + assert decoded.weight == 1000 # the raw measure round-trips... + assert decoded.weight_grams is None # ...but it is not usable as grams. + assert decoded.color_hex == "89d9d9" + + +def test_decode_init_tag_example() -> None: + """README section 4.2: a blank/uninitialized Init tag, every field at its default.""" + decoded = decode_ntag213(encode_ntag213(TigerTagData(id_tigertag=0x6C41A2E1, id_unit=0x00))) + + assert not is_tigertag(decoded.id_tigertag) + assert decoded.id_tigertag == TIGERTAG_INIT + assert decoded.diameter_mm == 0.0 + # Unit ID 0x00 is "Not defined" on a blank tag -- not grams, so not a usable weight. + assert decoded.weight_grams is None From 1866708d8ca5436329e375bd330e71709438dd98 Mon Sep 17 00:00:00 2001 From: Jon C Date: Wed, 4 Nov 2026 21:40:14 +0100 Subject: [PATCH 5/5] test(nfc): hostile/malformed TigerTag coverage for /tag/scan Fixes the integration fixture/tests for the previous commits' id and unit corrections (id_diameter defaults to the real 56/1.75mm id, not the invented 1; build_tigertag takes an id_unit knob). New tests_integration/tests/tag/test_scan_tigertag_hostile.py covers what's actually hostile for a fixed-width binary format: TigerTag has no self-describing types, so the CBOR-style cases test_scan_hostile.py covers (a number field holding a list, a bignum, NaN, Infinity) cannot arrive here at all, and the file's docstring says so instead of silently omitting them. What it does cover, each with the row-count-before/after pattern from test_scan_hostile.py: weight at the 24-bit max (over the shared range check), a non-gram unit (must come through as no weight, not a scaled one), a too-short payload, an unmapped magic number, the blank Init tag (now also checked for create:true + row counts, not just decoded absence), and an unmapped diameter id as the positive-path case (still creates, using the 1.75mm default). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS --- tests_integration/tests/_tigertag_fixtures.py | 20 +-- .../tests/tag/test_scan_create.py | 2 +- .../tests/tag/test_scan_decode.py | 2 +- .../tests/tag/test_scan_tigertag_hostile.py | 131 ++++++++++++++++++ 4 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 tests_integration/tests/tag/test_scan_tigertag_hostile.py diff --git a/tests_integration/tests/_tigertag_fixtures.py b/tests_integration/tests/_tigertag_fixtures.py index cd0365fa8..62561f390 100644 --- a/tests_integration/tests/_tigertag_fixtures.py +++ b/tests_integration/tests/_tigertag_fixtures.py @@ -14,26 +14,27 @@ NTAG213_USER_BYTES = 144 -_HEADER_FMT = ">II HBB BBH I I HH BBH I" -_BED_TEMP_OFFSET = 36 -_EMOJI_OFFSET = 54 -_USER_MESSAGE_OFFSET = 58 -_USER_MESSAGE_SIZE = 28 +# Field order/offsets per spoolman/tags/tigertag.py's byte table, checked against the real +# spec repo (TigerTag-Project/TigerTag-RFID-Guide). 56 (0x38) and 221 (0xDD) are the only +# diameter ids the spec defines; 21 is the "grams" measure-unit id. +_HEADER_FMT = ">II HBB BBH I I HH BBBB I" +_UNIT_ID_GRAMS = 21 def build_tigertag( *, id_tigertag: int = TIGERTAG_MAKER_V1, id_material: int = 0, - id_diameter: int = 1, + id_diameter: int = 56, id_brand: int = 0, weight: int = 0, + id_unit: int = _UNIT_ID_GRAMS, color_hex: str = "000000", ) -> bytes: """Build a full 144-byte NTAG213 user memory dump wrapping TigerTag fields.""" color_r, color_g, color_b = (int(color_hex[i : i + 2], 16) for i in (0, 2, 4)) color_val = (color_r << 24) | (color_g << 16) | (color_b << 8) | 0xFF - weight_unit = ((weight & 0xFFFFFF) << 8) | 1 + measure_unit = ((weight & 0xFFFFFF) << 8) | (id_unit & 0xFF) header = struct.pack( _HEADER_FMT, @@ -46,12 +47,13 @@ def build_tigertag( id_diameter, id_brand, color_val, - weight_unit, + measure_unit, 0, # nozzle_temp 0, # nozzle_temp_max 0, # drying_temp 0, # drying_duration - 0, # reserved + 0, # bed_temp_min + 0, # bed_temp_max 0, # timestamp ) diff --git a/tests_integration/tests/tag/test_scan_create.py b/tests_integration/tests/tag/test_scan_create.py index d59f4ff80..9d0d8fbb7 100644 --- a/tests_integration/tests/tag/test_scan_create.py +++ b/tests_integration/tests/tag/test_scan_create.py @@ -151,7 +151,7 @@ def test_create_true_on_an_unmatched_tigertag_creates_and_links_a_spool_without_ created (no addon needed to make a usable spool), just without a name. """ uid = _uid() - payload = base64.b64encode(build_tigertag(id_diameter=1, weight=1000, color_hex="112233")).decode("ascii") + payload = base64.b64encode(build_tigertag(id_diameter=56, weight=1000, color_hex="112233")).decode("ascii") result = httpx.post( f"{URL}/api/v1/tag/scan", diff --git a/tests_integration/tests/tag/test_scan_decode.py b/tests_integration/tests/tag/test_scan_decode.py index 78852308f..daa85d523 100644 --- a/tests_integration/tests/tag/test_scan_decode.py +++ b/tests_integration/tests/tag/test_scan_decode.py @@ -173,7 +173,7 @@ def test_scan_decodes_a_real_prusa_test_vector_with_derived_instance_id(): def test_scan_decodes_a_tigertag(): - payload = base64.b64encode(build_tigertag(id_diameter=1, weight=1000, color_hex="112233")).decode("ascii") + payload = base64.b64encode(build_tigertag(id_diameter=56, weight=1000, color_hex="112233")).decode("ascii") result = httpx.post( f"{URL}/api/v1/tag/scan", json={"uid": _uid(), "reader_id": _reader_id(), "format": "tigertag", "payload_b64": payload}, diff --git a/tests_integration/tests/tag/test_scan_tigertag_hostile.py b/tests_integration/tests/tag/test_scan_tigertag_hostile.py new file mode 100644 index 000000000..9216e7559 --- /dev/null +++ b/tests_integration/tests/tag/test_scan_tigertag_hostile.py @@ -0,0 +1,131 @@ +"""Integration tests for POST /tag/scan with hostile or malformed TigerTag payloads. + +TigerTag is a fixed-width, big-endian `struct`-packed binary format, not a self-describing +one like OpenPrintTag's CBOR -- so the type-confusion attacks test_scan_hostile.py covers +(a number field holding a list, a bignum, NaN, Infinity) cannot arrive here: every field is +a plain unsigned int of a fixed byte width, and there is no way to encode a float, a +container, or an out-of-range Python type into any of them. What TigerTag *can* carry that +still needs guarding against is a value that's technically a valid unsigned int but wrong +in a way that matters (a weight over any real spool's size, a unit that silently rescales +the weight, truncated or garbage bytes) -- this file covers those instead. +""" + +import base64 +import uuid +from typing import Any + +import httpx + +from .._tigertag_fixtures import TIGERTAG_INIT, build_tigertag +from ..conftest import URL, assert_httpx_success +from .test_scan_create import _delete_created_spool +from .test_scan_hostile import _assert_list_endpoints_still_work, _row_counts + + +def _uid() -> str: + return uuid.uuid4().hex[:14].upper() + + +def _reader_id() -> str: + return f"reader-{uuid.uuid4().hex[:8]}" + + +def _scan(raw: bytes, *, create: bool | None = None) -> httpx.Response: + body: dict[str, Any] = { + "uid": _uid(), + "reader_id": _reader_id(), + "format": "tigertag", + "payload_b64": base64.b64encode(raw).decode("ascii"), + } + if create is not None: + body["create"] = create + return httpx.post(f"{URL}/api/v1/tag/scan", json=body) + + +def test_weight_over_the_supported_range_creates_nothing(): + """The 24-bit measure field's max is still accepted by the codec, but far too heavy. + + 16,777,215g is well outside any real spool -- the shared range check in + database/tag.py must refuse it. + """ + before = _row_counts() + raw = build_tigertag(weight=0xFFFFFF, color_hex="112233") # grams unit (the default) + result = _scan(raw, create=True) + assert_httpx_success(result) + body = result.json() + + assert body.get("created") is not True + assert "spool" not in body + assert _row_counts() == before + _assert_list_endpoints_still_work() + + +def test_a_non_gram_unit_creates_a_spool_with_no_weight_instead_of_a_scaled_one(): + """A tag reporting its weight in kilograms must not silently become a wrong gram value. + + It must come through as no weight at all, same as OpenPrintTag's never-defined fields. + """ + raw = build_tigertag(weight=1000, id_unit=35, color_hex="112233") # 35 = kilograms + result = _scan(raw, create=True) + assert_httpx_success(result) + body = result.json() + try: + assert body["created"] is True + assert body["spool"]["filament"].get("weight") is None + finally: + _delete_created_spool(body["spool"]) + + +def test_too_short_payload_creates_nothing(): + before = _row_counts() + result = _scan(b"\x00" * 10, create=True) + assert_httpx_success(result) + body = result.json() + + assert body.get("created") is not True + assert "decoded" not in body + assert _row_counts() == before + _assert_list_endpoints_still_work() + + +def test_unmapped_magic_number_creates_nothing(): + before = _row_counts() + raw = build_tigertag(id_tigertag=0xDEADBEEF, color_hex="112233") + result = _scan(raw, create=True) + assert_httpx_success(result) + body = result.json() + + assert body.get("created") is not True + assert "decoded" not in body + assert _row_counts() == before + _assert_list_endpoints_still_work() + + +def test_blank_init_tag_creates_nothing(): + before = _row_counts() + raw = build_tigertag(id_tigertag=TIGERTAG_INIT) + result = _scan(raw, create=True) + assert_httpx_success(result) + body = result.json() + + assert body.get("created") is not True + assert "decoded" not in body + assert _row_counts() == before + _assert_list_endpoints_still_work() + + +def test_unmapped_diameter_id_still_creates_using_the_default(): + """An id_diameter the codec doesn't recognize decodes as 'not set', same as OpenPrintTag. + + Create still succeeds, falling back to the 1.75mm default rather than refusing the + whole tag. + """ + raw = build_tigertag(id_diameter=99, weight=1000, color_hex="112233") + result = _scan(raw, create=True) + assert_httpx_success(result) + body = result.json() + try: + assert body["created"] is True + assert body["spool"]["filament"]["diameter"] == 1.75 + finally: + _delete_created_spool(body["spool"])