Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Argos notifications ↗︎
|
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
force-pushed
the
addon/tigertag-db-lookup
branch
from
November 10, 2026 08:17
7e47bc0 to
511a4b4
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Optional, off-by-default add-on that resolves TigerTag's on-tag
id_material/id_brandcatalog 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 andmergeable 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 localcache on a schedule (same
filecache/scheduler.asynciopattern as the existingSpoolmanDB sync in
spoolman/externaldb.py), plus a live per-UID+product-id lookup for anexact match ahead of the cache.
spoolman/tags/decode.py:decode_async(), a new sibling to the existing synchronousdecode()-- 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 stayssynchronous 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_ENABLEDis 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_INTERVALenv vars(off/60s-default-interval by default).
spoolman/api/v1/tag.py/main.py: wiresdecode_asyncinto/tag/scanand schedules thesync task, both gated behind the same flag.
color_hex/diameter/weight) always come straight off the tagitself, 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
materialand weight under afilamentsub-object; the bulk catalog sync (product/get/all) still returns them at thetop level. The add-on's model only handled the bulk shape -- a successful live lookup would
resolve
materialtoNoneinstead of the real value, silently falling back to no betterthan 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.ioon 2026-11-09).brand/get/all/material/get/all/measure_unit/get/allwere also re-checked live --field shapes (
id/name,id/label/density) still match what the add-on assumes.Known limitation
A fixed
density=1.24is used for every result from the live single-tag lookup(
_to_external_filament) when the material-specific figure from the materials cache isn'tseparately known yet --
decode.py's_decode_tigertag_asyncalready overrides this withthe 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
pytest tests), 647 integration tests pass(
python tests_integration/run.py sqlite),ruff check/ruff format --checkclean,uv lock --checkclean (no dependency change).tests/test_tigertagdb.pycovers 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.pycoversdecode_asyncwith the add-on disabled (matchesdecode()exactly), resolving from the cache, preferring the live lookup, and fallingback to the cache when the live lookup 404s, errors, has no product id, or has no uid.
🤖 Generated with Claude Code