Skip to content

feat(nfc): decode TigerTag Maker/+ tags in /tag/scan - #1324

Open
goeland86 wants to merge 5 commits into
Donkie:masterfrom
goeland86:codec/tigertag
Open

goeland86 wants to merge 5 commits into
Donkie:masterfrom
goeland86:codec/tigertag

Conversation

@goeland86

@goeland86 goeland86 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Decodes the TigerTag Maker/+ binary format (NTAG213, 144 bytes user memory) and wires it
into /tag/scan, reusing the same decoded/create: true contract #1112 (OpenPrintTag)
established. Same split as that PR: a format-specific codec (spoolman/tags/tigertag.py)
mapping onto the shared DecodedTag, with every write going through the already-validated,
already-transactional create_spool_from_decoded_tag path in spoolman/database/tag.py.

What's in this PR

  • spoolman/tags/tigertag.py: decodes the fixed-width binary header (id_tigertag/
    id_product/id_material/id_aspect/id_type/id_diameter/id_brand, color RGBA, weight +
    measure unit, nozzle/bed/drying temperatures, timestamp, a 28-byte custom message).
  • spoolman/tags/decode.py: a tigertag branch mapping TigerTagData onto the shared
    DecodedTag -- no changes needed in api/v1/tag.py, models.py, or database/tag.py,
    since those are already format-agnostic.
  • TigerTag's id_material/id_brand are the format's own catalog IDs, not names --
    resolving them to real names needs an external DB lookup, which is deliberately a
    separate PR, feat(nfc): optional TigerTag external-DB add-on for name/density resolution #1325 (same reasoning as OpenPrintTag's gtin). material/name/
    vendor_name stay unset here rather than surfacing a bare catalog number.

Validated against the real spec, not guesses

Before opening this, I went through every category of problem raised on #1112's three
review rounds and checked whether it still needed addressing here, plus cross-checked this
codec against the official TigerTag spec (TigerTag-Project/TigerTag-RFID-Guide on
GitHub) the same way #1112 was checked against OpenPrintTag's own spec repo.

Inherited for free, since this branch is rebased on top of #1112's fixes: one
transaction for the whole auto-create (vendor/filament/spool/tag-link flush together,
commit once), range-checked density/weight/diameter before any write, tolerant
diameter/weight matching (plus color) when reusing an existing filament, the decoded tag
never being echoed back when create refuses it, non-ASCII payload_b64 handling, an
odd-length UID answering 400, and the TagDecodedInfo/instance_uuid field naming.

Structurally doesn't apply to this format: TigerTag is a fixed-width, big-endian
struct-packed binary layout, not a self-describing one like OpenPrintTag's CBOR. A
density/weight that's a list, a bignum, NaN, or Infinity can't arrive here -- every field
is a plain unsigned int of a fixed byte width, and there's no way to encode a different
Python type into any of them. No new dependency, no NDEF wrapping either.

Found and fixed by checking the real spec (not assumed from a guess at some point --
database/id_diameter.json, database/id_measure_unit.json, and the README's three fully
worked examples, cross-confirmed against the offsets its own Sample code/ parse_tigertag.py reference parser reads):

  • Diameter IDs were invented (1/2, plus an "alt" 57 with no spec backing) instead of
    the real 56 (0x38, 1.75mm) / 221 (0xDD, 2.85mm) -- a real 2.85mm spool would've
    silently defaulted to 1.75mm.
  • The weight field's unit byte was packed but never checked on decode. The spec's own
    Polymaker worked example reports its weight in kilograms, not grams -- trusting the raw
    value as always-grams would read 1000kg as 1000g. weight_grams is now None unless the
    tag says grams.
  • bed_temp/bed_temp_max were read from the wrong offset (fixed); user_message was
    read from the wrong offset (fixed); emoji wasn't a real field at all (removed -- the
    offset it was read from falls inside the real Custom Message region).
  • encode_ntag213 had no caller outside tests -- same situation as OpenPrintTag's
    encode_nfcv_memory, dropped from production there in response to review. Same fix here.

Color 2/3, tag index/count, HueForge TD, Measure Available, and the ECDSA signature pages
are real spec fields this PR still doesn't decode -- deliberately out of scope, same
discipline as OpenPrintTag's own PR1 (decode what's used, land more fields later).

Known limitation

Without the external-DB add-on (#1325, not part of this PR and mergeable independently of
it), a bare TigerTag tag has no product name -- create: true still makes a usable spool
(diameter/weight/color all come straight off the tag), just with an unnamed filament and no
vendor. Intentional, and covered by
test_create_true_on_an_unmatched_tigertag_creates_and_links_a_spool_without_names.

Test plan

  • 647 integration tests pass (python tests_integration/run.py sqlite), 413 unit tests
    pass (pytest tests), ruff check/ruff format --check clean, uv lock --check clean
    (no dependency change).
  • Three real-reference-payload tests built from the spec repo's own worked examples
    (tests/test_tigertag_codec.py), including the kilograms case.
  • New tests_integration/tests/tag/test_scan_tigertag_hostile.py: the hostile cases that
    actually apply to a fixed-width format (over-range weight, a non-gram unit, a too-short
    payload, an unmapped magic number, the blank Init tag), each with before/after row-count
    checks, plus the positive-path case (an unmapped diameter id still creates, using the
    default).

🤖 Generated with Claude Code

@argos-ci

argos-ci Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Nov 10, 2026, 8:21 AM

goeland86 and others added 5 commits November 10, 2026 09:16
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 (Donkie#880's PR1): codec and
tests only, no wiring into the tag scan/link endpoints from Donkie#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 Donkie#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).
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.
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 Donkie#1112, which was dropped from production in
response. Moved into tests/test_tigertag_codec.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant