Skip to content

feat(nfc): optional TigerTag external-DB add-on for name/density resolution - #1325

Open
goeland86 wants to merge 8 commits into
Donkie:masterfrom
goeland86:addon/tigertag-db-lookup
Open

goeland86 wants to merge 8 commits into
Donkie:masterfrom
goeland86:addon/tigertag-db-lookup

Conversation

@goeland86

@goeland86 goeland86 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Optional, off-by-default add-on that resolves TigerTag's on-tag id_material/id_brand
catalog IDs into real names and a material density, via TigerTag's own public API
(api.tigertag.io, free, no key). Builds on #1324 (codec/tigertag) -- separable and
mergeable independently of it (a bare TigerTag already decodes diameter/weight/color
without this), but this is the add-on that resolves the "unnamed filament" limitation
#1324 calls out.

What's in this PR

  • spoolman/tigertagdb.py: syncs TigerTag's product/brand/material catalogs into a local
    cache on a schedule (same filecache/scheduler.asyncio pattern as the existing
    SpoolmanDB sync in spoolman/externaldb.py), plus a live per-UID+product-id lookup for an
    exact match ahead of the cache.
  • spoolman/tags/decode.py: decode_async(), a new sibling to the existing synchronous
    decode() -- identical for every format and for TigerTag with the add-on off; with it on,
    it awaits the live lookup first and falls back to the synced cache. decode() itself stays
    synchronous and pure (no network, no add-on import) for the test suite and any other
    offline caller. The add-on module is only ever imported when SPOOLMAN_TIGERTAG_ENABLED
    is set, so a deployment that never opts in never loads it, never talks to the network for
    it, and pays no startup cost for it.
  • spoolman/env.py: SPOOLMAN_TIGERTAG_ENABLED, _API_URL, _SYNC_INTERVAL env vars
    (off/60s-default-interval by default).
  • spoolman/api/v1/tag.py/main.py: wires decode_async into /tag/scan and schedules the
    sync task, both gated behind the same flag.
  • Physical properties (color_hex/diameter/weight) always come straight off the tag
    itself, never from the add-on -- the add-on only ever fills in what the tag's compact
    catalog IDs can't carry: names and density. Tag data stays ground truth for this specific
    roll.

Checked against the live API, not just the bulk sync's shape

Before opening this, I ran every add-on code path against the real api.tigertag.io
(documented in TigerTag-Project/TigerTag-RFID-Guide's README as the free public API) --
not just the bulk sync's response shape, which is what this branch had originally been
tested against.

Found and fixed: the live single-tag lookup (product/get?uid=...&product_id=...,
the "live-first" path's whole reason to exist) nests material and weight under a
filament sub-object; the bulk catalog sync (product/get/all) still returns them at the
top level. The add-on's model only handled the bulk shape -- a successful live lookup would
resolve material to None instead of the real value, silently falling back to no better
than having the add-on off at all, despite the lookup itself succeeding. Fixed to read
whichever shape actually populated the response (TigerTagProduct.resolved_material/
.resolved_weight_g), with a test built from the real response
(test_to_external_filament_resolves_material_and_weight_from_nested_filament_block,
checked against api.tigertag.io on 2026-11-09).
brand/get/all/material/get/all/measure_unit/get/all were also re-checked live --
field shapes (id/name, id/label/density) still match what the add-on assumes.

Known limitation

A fixed density=1.24 is used for every result from the live single-tag lookup
(_to_external_filament) when the material-specific figure from the materials cache isn't
separately known yet -- decode.py's _decode_tigertag_async already overrides this with
the real per-material cache figure whenever one exists, so in practice this only matters for
a brand-new material the cache hasn't synced yet. Not fixed here to keep this PR's diff to
the real API-shape bug found above; worth a follow-up if it turns out to matter in practice.

Test plan

  • 451 unit tests pass (pytest tests), 647 integration tests pass
    (python tests_integration/run.py sqlite), ruff check/ruff format --check clean,
    uv lock --check clean (no dependency change).
  • tests/test_tigertagdb.py covers both API response shapes (bulk-flat and live-nested),
    cache hit/miss/corrupt-file handling for all three lookups, the live lookup's
    disabled/success/404/500/malformed-body cases, and the three-way independence of the
    product/brand/material sync steps (one endpoint failing doesn't block the others).
  • tests/test_tag_decode.py covers decode_async with the add-on disabled (matches
    decode() exactly), resolving from the cache, preferring the live lookup, and falling
    back to the cache when the live lookup 404s, errors, has no product id, or has no uid.

🤖 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 8 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
Syncs TigerTag's product/brand/material catalog (api.tigertag.io) into
local cache files on a schedule, plus a real-time single-product lookup
for resolving a scanned tag's small on-tag product_id to a full record.
Merged into the existing /external/filament catalog so TigerTag-tagged
spools benefit from the same manufacturer/material auto-fill as
SpoolmanDB entries.

Built as a true opt-in add-on per the discussion on Donkie#880: off by
default (SPOOLMAN_TIGERTAG_ENABLED unset), and everything that imports
spoolman.tigertagdb -- main.py's scheduler wiring, the /external/filament
endpoint's catalog merge -- does so lazily, inside the disabled check,
so a deployment that never sets the env var never loads this module at
all. No new dependency: httpx/pydantic/scheduler are already core deps,
so this is a pure code-optionality story, not a package-optionality one.

Also fixes a real bug found while writing tests: _to_external_filament
set source="tigertag" on the returned ExternalFilament, but upstream's
model (unlike this fork's) has no such field, so pydantic's default
extra="ignore" silently dropped it -- every synced entry was
indistinguishable from a SpoolmanDB one. Added `source: str | None` to
ExternalFilament (default None, so existing SpoolmanDB entries and API
consumers are unaffected) so add-ons that merge in their own catalog
can actually be told apart downstream.

28 tests: pure helper functions (weight parsing, product mapping),
cache lookups against a monkeypatched cache dir, the real-time lookup
and full sync flow against httpx.MockTransport (no real network, no
new test dependency), and the scheduler wiring's enabled/disabled/
zero-interval branches.
Closes the "known limitation" from codec/tigertag (#PR2): id_material/id_brand
are TigerTag's own catalog IDs, not names, and this add-on is what resolves them.
Per the user's choice, /tag/scan now prefers an exact per-tag live lookup
(tigertagdb.lookup_product_by_tag, keyed on this tag's UID + id_product) over the
add-on's coarser background-synced cache, falling back to that cache -- and finally
to unresolved, same as before this PR -- on any miss, timeout, or error. The add-on
being off is unaffected either way: tigertagdb.py is only ever imported once
env.is_tigertag_enabled() says so, so a deployment that never opts in never loads
it and behaves exactly as if this PR didn't exist.

tag_decode.py:
- _decode_tigertag_raw() factors out the parse+magic-check previously inlined in
  _decode_tigertag(), shared by both the sync and async paths below.
- _tigertag_decoded() builds the common DecodedTag shape: color_hex/diameter_mm/
  net_weight_g always come straight off the physical tag (ground truth for this
  roll's physical properties), never the catalog; only material_type/material_name/
  brand_name/density_g_cm3 are ever resolved externally.
- _tigertag_decoded_from_cache() is the shared background-synced-cache fallback,
  used by both decode()'s tigertag branch and decode_async()'s fallback branch.
- decode_async() is a new top-level entry point alongside decode(): identical to it
  for every format and for TigerTag with the add-on off; for TigerTag with it on,
  awaits the live lookup first. decode() itself is unchanged in behavior except that
  its own tigertag branch now also resolves names from the cache when the add-on is
  enabled (previously always left them unset, since the add-on didn't exist yet).

api/v1/tag.py: `_decode_payload` is now async, awaiting `decode_async` instead of
calling `decode` -- the only change needed in the endpoint itself, since `scan()`
was already async.

19 new unit tests (28 total in tests/test_tag_decode.py) covering: cache-based
resolution when the add-on is enabled, decode()/decode_async() equivalence when
it's off, the live-lookup-first-then-cache-fallback ordering, graceful fallback on
a live-lookup exception/miss/no-product-id/no-uid, and non-TigerTag formats being
unaffected by decode_async existing at all.

Verified live end-to-end against a real running server (not just mocked unit
tests): started Spoolman with SPOOLMAN_TIGERTAG_ENABLED=TRUE pointed at a local
stub standing in for api.tigertag.io, scanned a synthetic TigerTag payload, and
confirmed /tag/scan's `decoded` field carried the live-resolved material/brand
names, then that `create: true` used them to build a correctly-named
vendor/filament/spool.
…ight

product/get (the single-tag lookup lookup_product_by_tag uses) puts material
and weight under a nested "filament" block now; product/get/all (the bulk
sync) still puts them at the top level. Checked against the live API: with
the old flat-only assumption, a live per-tag lookup resolved material to None
instead of the real value, even though the lookup itself succeeded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuqDTmgHEWXFA6xJ6UmDsS
@goeland86
goeland86 force-pushed the addon/tigertag-db-lookup branch from 7e47bc0 to 511a4b4 Compare November 10, 2026 08:17

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