design: NFC/RFID tag support, phase 1 and phase 2

Phase 1: format-agnostic tag identity (spool_tag table, indexed UID
lookup, link/unlink API) plus the scan relay and scanner-to-browser
pairing. Phase 2: OpenPrintTag codec, import and export.

Based on an investigation of 9 open issues, 3 discussions, PR #880 and
the surrounding tag-standard ecosystem.
This commit is contained in:
Donkie committed 2026-08-09 22:39:22 +02:00
1 parent 421ad925a7
commit f02ebbc93e
2 files changed
+636

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
# NFC Phase 1 — Tag identity, lookup, and the scan relay
Status: design, not yet implemented.
Companion document: [`nfc-phase-2-openprinttag.md`](./nfc-phase-2-openprinttag.md).
---
## Why
NFC/RFID is the longest-running unanswered feature request in the tracker — 9 open issues and 3
discussions going back to 2024-01 (#260), with no maintainer reply on any of them. In the absence of
a first-party answer an entire external ecosystem grew up around Spoolman's API, and every one of
those projects independently worked around the same gap: **there is no way to ask Spoolman "which
spool is this physical tag?"**
The workarounds are the evidence:
- **beikeland** relocated his `lot_nr` data into `comment` purely so he could stash the tag UID in
`lot_nr` and filter on it (#776).
- **kevinrmccoy** built a parallel Node-RED database mapping UID → spool id, and opened #716 asking
for exactly this (`/api/v1/spool?extra.tag_uid=<uid>`).
- **ryanch** gave up on UID lookup entirely and wrote `spoolman_id` *into* the tag instead (#799).
- **FilaMan** writes `sm_id` into the tag for the same reason, then does a full-list `GET /spool/`
and filters client-side.
`extra.<key>` filtering did ship in v0.26.0 (`spoolman/api/v1/spool.py:41`) and none of these
projects know it exists — worth announcing on its own. But it is an unindexed, non-unique,
case-insensitive substring match. It is a *filter*, not an *identity lookup*, and it cannot stop two
spools claiming the same tag.
### What Phase 1 unlocks
Phase 1 is deliberately **format-agnostic**: it keys only on the tag's hardware UID. That means it
works with blank NTAG215 stickers, Prusa NFC-V tags, TigerTag, and — importantly — Bambu, Creality
and Qidi MIFARE tags whose *contents* Spoolman can never decrypt or write. Every physical tag has a
UID; that is the one thing we can always rely on.
It is also a hard prerequisite for Phase 2, which needs somewhere to record a tag's UID and
`instance_uuid` so a rescan updates the existing spool instead of creating a duplicate.
What it does **not** cover is onboarding — buying a Prusament, tapping it, and having the vendor,
filament and spool created with correct material, color, weight, density and temperatures. That is
Phase 2 and needs the OpenPrintTag codec.
### Issues addressed
| Issue | Ask |
|---|---|
| #260 (2024-01) | RFID per-spool quick ID |
| #723 (9 👍) | Create NFC tags; read a tag to select the active spool |
| #716 (3 👍) | Look up a spool by UID through the API |
| #799 | Native RFID/NFC support, `nfc_id` matching |
| #884 | Put the NFC tag ID into the printed label's QR code |
| #783 | Per-spool action links so external tools live in Spoolman's UI |
| discussion #748 | ESP32 by the spool holder telling Spoolman what is loaded |
---
## Data model
One new table. Additive only — no existing column changes, so lossless by construction.
**Many tags per spool**, which is a real case, not a hypothetical: bofh69 described copying a Prusa
NFC-V tag's payload onto an NTAG215 so his PN532 could read it (#776), leaving one spool carrying
two physical tags with two different UIDs.
In `spoolman/database/models.py`, alongside `SpoolField` (`models.py:125`):
```python
class SpoolTag(Base):
__tablename__ = "spool_tag"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), index=True)
spool: Mapped["Spool"] = relationship(back_populates="tags")
uid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
format: Mapped[str | None] = mapped_column(String(32))
added: Mapped[datetime] = mapped_column()
```
- `uid` — **normalized on write**: uppercase hex, separators stripped. Readers report UIDs in wildly
different shapes (`04:A2:B3`, `04-a2-b3`, `04A2B3`). Normalize at the API boundary so lookups are
exact and the unique constraint actually means something. Web NFC's `serialNumber` is
colon-separated lowercase; nfcpy gives raw bytes.
- `format` — nullable free-ish string (`openprinttag`, `opentag3d`, `ntag`, `bambu`, `tigertag`,
`qidi`). Informational in Phase 1; Phase 2 uses it. Not an enum in the DB — new tag types appear
faster than migrations should.
- Unique index on `uid` is the point of the whole table: one tag, one spool, enforced.
On `Spool`, add the relationship following the `extra` convention at `models.py:94`:
```python
tags: Mapped[list["SpoolTag"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
```
`lazy="selectin"` is not optional — see the comment at `models.py:10-21` about joined loading
wrecking the spool list query.
### Migration
New revision in `migrations/versions/`. CREATE TABLE plus indexes only.
CockroachDB needs index creation split out of the table-creation transaction; follow the pattern
already established in the repo's earlier migrations. Must be proven on all four supported databases
(SQLite, PostgreSQL, MySQL/MariaDB, CockroachDB) against a populated database, not an empty one.
---
## API
All additive, so API v1 compatible.
### Lookup
```
GET /api/v1/spool?tag=<uid>
```
New filter param on the existing `find` handler (`spoolman/api/v1/spool.py:158`). Exact match on the
normalized UID, hitting the unique index. Returns the standard spool list shape so existing clients
need no special handling.
### Link / unlink
```
POST /api/v1/spool/{id}/tag {uid, format?} → 201
DELETE /api/v1/spool/{id}/tag/{uid} → 204
```
`POST` returns **409** if the UID is already linked to a different spool, with the conflicting spool
id in the body so a client can offer "move it to this spool instead". Re-posting the same UID to the
same spool is idempotent.
### Response model
Add `tags: list[SpoolTag]` to the `Spool` response model (`spoolman/api/v1/models.py:285`). Safe for
existing consumers: responses use `response_model_exclude_none=True`, and this is a new key rather
than a changed one.
### Events
Link and unlink emit the existing `SpoolEvent` through `spool_changed`
(`spoolman/database/spool.py:752`), after commit — the ordering there is deliberate. No new
websocket resource is needed for this part.
---
## How the reader connects to Spoolman
**Spoolman never talks to reader hardware. The reader talks to Spoolman.**
The reader is almost never on the Spoolman host. Spoolman typically runs in Docker on a NAS or a
Proxmox box, while the tag gets tapped at the printer, at a dryer, or at a desk. Three supported
topologies, none of which require Spoolman to know anything about NFC hardware:
| Topology | Reader location | Path to Spoolman | Reality check |
|---|---|---|---|
| **Phone** | in your hand | client_v2 Web NFC → REST | Android Chrome only, and requires a **secure context** — HTTPS or `localhost`. `http://192.168.x.x` will not work. Good for desk work; never the primary path. |
| **Reader-side agent** | printer / dryer / shelf | nfc2klipper, FilaMan, ESPHome, Node-RED, HA → REST | The dominant real topology. Already works today; it just needs `?tag=<uid>`. |
| **WebUSB / WebSerial** | desk, USB dongle | Chrome desktop → ACR122U or ESP32 | kquinsland's suggestion (#799). Secure context again, plus per-device quirks. Document as possible; do not build in-tree. |
**Explicitly rejected: a USB reader on the Spoolman host.** This is what PR #880 does
(`SPOOLMAN_NFC_ENABLED` + `nfcpy` + `libusb` in the image). It serves only bare-metal users who
handle spools at their server, and it buys that with USB passthrough, udev rules, and PUID/PGID
problems in a containerized app. bofh69's PN5180 work, nfc2klipper, FilaMan and ESPHome already own
this layer and do it better.
The HTTPS requirement on the phone path is not something we can engineer around — Web NFC grants no
private-IP exemption. Document it. Users behind a reverse proxy get it for free.
---
## The scan relay
This is the one genuinely new capability in the plan. Everything else exposes data that external
tools already want; this does something none of them can do alone.
**The idea.** A reader-side agent POSTs a scan. Spoolman broadcasts it over a websocket. A browser
that has been *paired* with that reader reacts — if the tag maps to a known spool, it navigates
straight to that spool.
**Why it matters.** It decouples *where the reader is* from *where the user is looking*. Click "Link
a tag" on your desktop, walk to the printer, tap the tag on the PN532 there, and the dialog fills
in. Or: a wall tablet by the printer sits on the spool list, and tapping any spool's tag pulls it up
instantly.
### Endpoint
```
POST /api/v1/tag/scan
{ uid, reader_id?, name?, format?, payload_b64? }
→ { matched_spool_id?, spool? }
```
It broadcasts **and** returns the match, so a dumb agent can use it as a one-shot lookup and ignore
websockets entirely. `payload_b64` is accepted here in Phase 1 but only carried in the broadcast
event; Phase 2 is what decodes it.
Scans are **ephemeral** — broadcast only, never persisted. No new table, no history.
### Separate websocket pool (important)
Scans must **not** go through the shared `websocket_manager`. `SubscriptionTree.send()`
(`spoolman/ws.py:46`) broadcasts to subscribers at *every level along the path*, and the root
endpoint `/api/v1/` subscribes with pool `()` — "listen to any changes" (`router.py:90`). Putting
scans in the shared tree would inject a novel `resource` into every existing root consumer's stream.
That is an API v1 compatibility problem for no benefit.
Instead: a **second `WebsocketManager` instance** dedicated to scans, reusing `SubscriptionTree`
as-is. Entity streams stay exactly as they are.
```
WS /api/v1/tag/scan → pool () — every reader
WS /api/v1/tag/scan/{reader_id} → pool (reader_id,) — one reader
```
The tree's propagate-along-the-path behaviour gives "follow all readers" for free at the root, which
is exactly the semantics we want.
Event model: add `SCANNED = "scanned"` to `EventType` and a `TagScanEvent(Event)` with
`resource: Literal["tag_scan"]` (`spoolman/api/v1/models.py:604`). Because it only ever travels on
the scan manager, no existing consumer can see it.
### Pairing: binding a scanner to one browser
The requirement is that a scan drives *one* browser, not every open tab.
**Chosen approach: reader identity, browser-side subscription.** The reader declares who it is; the
browser chooses which reader to follow. The server holds no pairing state at all.
- Every agent sends a `reader_id` — an operator-chosen string like `printer-voron` or `shelf-a`.
- A browser subscribes to `/api/v1/tag/scan/{reader_id}` for the reader it cares about, and stores
that choice in `localStorage` (a per-device UI preference, which is what localStorage is for in
this codebase).
- A browser that wants everything subscribes to `/api/v1/tag/scan` explicitly.
Why this one: the decision lives in the browser, so there is no server-side session registry to
build, expire, or keep consistent across restarts. It needs no auth. A wall tablet by printer A
follows reader A forever; your laptop follows nothing until you tell it to.
**Pairing UX — pair by tapping, not by typing.** Nobody should have to type `printer-voron` into a
settings box. The flow:
1. User clicks "Pair a scanner" in the browser.
2. That browser *temporarily* subscribes to the root scan pool — all readers.
3. User walks over and taps any tag on the reader they want to pair with.
4. The first scan that arrives carries its `reader_id`. The browser pins it, drops the root
subscription, and resubscribes to just that reader.
Physical pairing, zero configuration, and it is self-evidently correct to the user because they
performed the binding with their hands.
**Reader discovery.** The server keeps an in-memory registry of readers seen recently — `reader_id`,
optional friendly `name`, `last_seen` — exposed as `GET /api/v1/tag/reader`. Ephemeral, no
persistence; a reader reappears the moment it scans again. This backs a "choose a reader" dropdown
for people who prefer it to tap-to-pair.
**Agents that cannot send a `reader_id`.** Some setups are a dozen lines of ESPHome YAML (see
discussion #748) and will not be rewritten. If `reader_id` is absent, derive a stable one from the
client address — `ip-192-168-1-50`. Ugly but stable, and it makes tap-to-pair work with a completely
unmodified agent.
**Alternatives considered.** A server-issued pairing code (browser displays a 6-character code, the
operator configures it into the agent) — more work for the user and adds server state, with no
benefit over tap-to-pair. A browser-generated session token shown as a QR for the agent to scan —
solves a problem we do not have. A dedicated physical "pairing tag" — cute, too clever, another
thing to lose.
### Auto-navigate behaviour
On receiving a scan whose UID matches a spool, an enrolled browser navigates to that spool. Reuse
exactly what `QrScannerModal.svelte:26-30` already does:
```ts
goto(resolve(`/?sel=spool:${id}`));
```
Guard rails, all of which matter:
- **Off by default**, toggled per browser. A page that navigates itself without being asked is
hostile.
- **Only on a match.** An unknown UID must never navigate; it should offer "link this tag to a
spool" instead (and in Phase 2, "create a spool from this tag").
- **Never hijack.** Suppress navigation when a modal is open or an editor has unsaved changes.
Losing a half-typed spool because someone tapped a tag in the next room is unacceptable.
- **Debounce.** Readers poll continuously — nfc2klipper re-reads a tag for as long as it sits on the
reader, so the same UID will fire repeatedly. Ignore an identical `(uid, reader_id)` within a few
seconds. Cheapest correct place is server-side, before broadcast, so every subscriber benefits.
### Security note
Spoolman ships with no authentication. Any device on the LAN can POST a scan and make paired
browsers navigate. The blast radius is navigation only — no data is written by a scan — but it
should be stated plainly in the docs rather than discovered. An unguessable `reader_id` acts as a
weak shared secret for users who care; do not present it as a real one. This is consistent with the
existing threat model, where anyone on the LAN can already `DELETE` a spool.
---
## Client work (`client_v2`, SvelteKit / Svelte 5)
- **Spool inspector**: list linked tags, unlink, and add a tag.
- **Link a tag via Web NFC** where available. `NDEFReader` exposes `serialNumber` on read, which is
all Phase 1 needs — no NDEF parsing required. Progressive enhancement: hide the control entirely
when `'NDEFReader' in window` is false, which covers desktop, iOS, Firefox, and any plain-HTTP
install. Model it on `QrScannerModal.svelte`; put parsing and normalization in
`client_v2/src/lib/utils/spoolCode.ts`, which already owns the `WEB+SPOOLMAN:` scheme.
- **Link a tag via the relay**: the "add a tag" dialog subscribes to the paired reader and fills in
the next UID scanned. This is the flow that makes the relay worth building.
- **Scanner pairing UI**: pair-by-tap, current pairing, unpair, and the auto-navigate toggle.
- **Live connection**: `client_v2/src/lib/api/live.ts:10` hardcodes
`Resource = 'spool' | 'filament' | 'vendor'` and one socket per resource. The scan socket has a
different URL shape (`/tag/scan/{reader_id}`) and a different payload, so it is cleaner as a small
separate module reusing the reconnect/backoff approach than as a fourth `Resource`.
---
## Two cheap wins to land alongside
**#884 — QR code carrying the tag id.** `client_v2/src/lib/labels/qr.ts:20` substitutes only `{id}`
in a custom template. Route it through the resolver in `client_v2/src/lib/labels/template.ts:35`,
which already handles `{spool.id}`, `{spool.extra.<key>}`, `{filament.name}` and friends, so a label
QR can carry a tag UID. Small and self-contained.
**#783 — per-spool action links.** A settings-driven list of `{name, url_template}` rendered in the
spool overflow menu. bofh69 asked for exactly this so nfc2klipper's "write tag" button can live
inside Spoolman instead of on a second web page. New setting via the one-line `register_setting`
pattern (`spoolman/settings.py:64`). This is the cheapest possible unblock for the entire external
ecosystem and should probably ship regardless of the rest.
---
## Out of scope
- **Reader hardware in the container** — permanently. See above.
- **Decoding tag contents** — Phase 2.
- **Vendor tag contents** (Bambu / Creality / Qidi / Elegoo). UID identity already covers them for
identification. Decoding adds MIFARE key derivation for no writable benefit: Bambu tags are
RSA-signed and the printer rejects anything unsigned, so third parties can read but never forge.
---
## Verification
- **Migration** — 4-DB integration suite. Prove the upgrade on a *populated* database and confirm
the CockroachDB index split behaves.
- **API** — integration tests in `tests_integration/tests/spool/`: link, duplicate-UID 409, unlink,
idempotent re-link, `?tag=` exact match, UID normalization across input shapes, cascade on spool
delete, and that `tags` appearing in the spool response breaks no existing assertion. Precedent:
`tests_integration/tests/spool/test_related_extra_filter.py`.
- **Relay** — two websocket clients on different reader pools; assert a scan reaches only the right
one, that root subscribers get both, and that the **existing** `/api/v1/` root entity stream sees
no scan events at all. That last assertion is the API-compat guard.
- **Debounce** — repeated identical scans produce one broadcast.
- **Client** — Playwright in `client_v2/e2e/`. Manually confirm the Web NFC control is hidden on
desktop and over plain HTTP, and works on Android Chrome over HTTPS.
- **Lint/i18n** — repo lint, typecheck, i18n checks. `npm run build` rewrites every
`locales/*/common.json`; revert before staging.
---
## Communications
- Comment on #776, #723, #799 and #716 that `extra.<key>` filtering landed in v0.26.0. That alone
unblocks several downstream projects today and none of them know.
- **PR #880** (goeland86, TigerTag + OpenPrintTag + Qidi, +6512/−901, 36 files): decline as-is. It
targets the legacy React client, vendors `nfcpy` and `libusb` into the Docker image, ships
`.claude/settings.local.json` and session logs, and the author states the OpenPrintTag path is
untested for lack of NFC-V hardware. Reply explaining the direction, credit the work, and invite
the codec back on top of this tag model.
- bofh69 (nfc2klipper), beikeland, ryanch and the FilaMan successor maintainers are the natural
design reviewers. They have all built this twice already.
+260
View File
@@ -0,0 +1,260 @@
# NFC Phase 2 — OpenPrintTag codec, import, and export
Status: design, not yet implemented. Depends on Phase 1.
Companion document: [`nfc-phase-1-tag-identity.md`](./nfc-phase-1-tag-identity.md).
---
## Why
Phase 1 answers "which spool is this tag?" using nothing but the hardware UID. Phase 2 answers the
question that requires actually understanding the tag:
> I bought a Prusament. I tapped it. Why do I still have to type in the material, colour, weight,
> density, diameter and temperatures?
That is **#776 — the highest-signal NFC issue in the tracker at 29 👍**, and it goes back further
than that: #66 asked for it in 2023, imagining "producers of the filament wanted to support Spoolman
and had premade tags on their spools." That future arrived; it is just called OpenPrintTag.
The mirror-image request is #799 and #828 — write tags *from* Spoolman, so a DIY user with 200
spools can burn a tag per spool and have their printer configure itself.
### Why OpenPrintTag specifically
Two open standards compete, and it is worth being explicit about why we back one.
| | **OpenPrintTag** | **OpenTag3D** |
|---|---|---|
| Origin | Prusa Research | Gooborg Studios / consortium |
| Licence | **MIT** | GPL-3.0 |
| Air interface | NFC-V / ISO 15693 / Type 5 | NFC-A / ISO 14443-A / Type 2 |
| Reference chip | ICODE SLIX2, 320 B | NTAG213/215/216, 144 B core |
| Encoding | CBOR, integer keys, 3 sections | fixed byte layout |
| Cheap reader | **PN5180 required** | PN532 (~$3) works |
| Shipping on retail spools | **Yes — Prusament today** | No |
| Catalog | 13,403 materials / 125 brands, MIT | — |
OpenPrintTag wins on the only criterion that ultimately matters: it is on spools people are buying
right now, with a published physical spec (2026-03) and Positron and Voron involved. As
ThatDudeDelta put it in #776, OpenTag3D has "lots of corporate backers but nobody actually shipping
products with it."
The counter-argument in that thread is real and worth recording — beikeland argues NFC-V tags are
harder and pricier to source than ubiquitous 25 mm NTAG215 stickers, and that a DIY user only needs
a UID anyway. **Phase 1 already grants him everything he wants**, which is precisely why it ships
first. Phase 2 serves the person buying a tagged retail spool, which is a different and growing
population.
### The strongest objection, and the answer
bofh69 — who has implemented more of this than anyone, across nfc2klipper and pn5180-tagomatic —
argued in #776:
> it makes little sense for spoolman to have support for any rfid tags, except by having fields for
> the data in the tags. There are already good interfaces for other programs to read/write from/to
> spoolman.
He is right that Spoolman must not own reader hardware, and Phase 1 grants his UID-only workflow in
full. But the codec is a different thing from the hardware. It is a pure, testable data
transformation that **five separate projects have now each reimplemented**: nfc2klipper,
ryanch/openprinttag_scanner, goeland86's fork, CASAI77/spoolscan and Houzvicka/FlipperPrintTag.
Every one of them re-derives CBOR field numbers and UUIDv5 namespaces by hand, and each is a
separate opportunity to get it subtly wrong. Centralizing that in Spoolman — where the filament data
model already lives — removes duplicated work from the ecosystem rather than competing with it.
The line we hold: **Spoolman owns the codec. It never owns the reader.**
---
## Codec
New package `spoolman/tags/`, with `openprinttag.py` as the first format. Pure Python, no hardware,
no I/O.
```python
def decode(payload: bytes, hw_uid: bytes) -> OpenPrintTag: ...
def encode(spool: Spool) -> bytes: ...
```
### Format shape
- Transport is an **NDEF record with MIME type `application/vnd.openprinttag`**. Parse the NDEF TLV
inline — it is short and well-specified.
- Payload is **CBOR maps with integer keys**, in three sections:
- **meta** — always at the start; region offsets and sizes for the other two.
- **main** — static product data. Printers must not write here.
- **aux** — optional, printer-writable. Notably key 0, `consumed_weight`.
- **UUIDv5 derivation** against four fixed namespaces (brand / material / package / instance).
`instance_uuid` derives from `brand_uuid` plus the tag's hardware UID — which is why `decode()`
takes `hw_uid` and not just the payload.
> **Re-derive every field number, type code and namespace UUID from the spec repo at implementation
> time.** Source of truth is `OpenPrintTag/openprinttag-specification`, files `data/main_fields.yaml`
> and `data/aux_fields.yaml`. The spec has already deprecated keys once (12, 25, 26). Do not code
> from any summary, including this document.
### Dependencies
One new dependency: **`cbor2`**.
Explicitly not added: `ndeflib` (the TLV parse is small enough to own), `nfcpy`, `libusb`, or
anything else that implies hardware. The container gains no new system packages.
---
## API
```
POST /api/v1/tag/decode {payload_b64, uid}
→ {format, fields, matched_spool_id?, suggested_filament{...}, suggested_spool{...}}
POST /api/v1/tag/encode {spool_id, format}
→ {payload_b64}
```
`decode` auto-detects the format from the payload so additional formats slot in behind the same
endpoint later without a new route. It is a **pure read** — it never writes to the database. The
caller decides whether to act on the suggestion, which keeps a mis-scan from silently creating
records.
`encode` returns bytes for an external writer to burn. This alone satisfies #828's "just give me the
data and I'll write it with my own tool", and it is what makes #799's DIY tag-writing workflow
possible without Spoolman touching a reader.
Creation stays on the existing endpoints: the client takes `suggested_*` and POSTs to `/filament`
and `/spool` as it would for any other source. No new "create from tag" endpoint — that would
duplicate validation that already exists.
---
## Field mapping
Tag → Spoolman. Names on the left are OpenPrintTag main-section fields.
| Tag field | Spoolman |
|---|---|
| `brand_name` / `brand_uuid` | vendor (get-or-create) |
| `material_name`, `material_type`, `material_class` | `filament.material`, `filament.name` |
| `primary_color` | `filament.color_hex` |
| `secondary_color_0..4` | `filament.multi_color_hexes` |
| `nominal_netto_full_weight` | `filament.weight` |
| `empty_container_weight` | `filament.spool_weight` |
| `density` | `filament.density` |
| `filament_diameter` | `filament.diameter` |
| print / bed temp fields | `filament.extruder_temp`, `filament.bed_temp` |
| `nominal_full_length` | informational |
| `gtin`, `manufactured_date`, drying fields, `country_of_origin` | extra fields, if the user has defined them |
| `instance_uuid` | `spool_tag` row (Phase 1) |
| aux `consumed_weight` | see below |
Reuse the get-or-create and de-dupe logic already proven in
`client_v2/src/lib/api/spoolSource.ts:227` — but implement it **server-side** this time, so every
client and every external tool benefits rather than just the web UI.
### De-duplication on import
A tag will be scanned many times. Import must be idempotent:
1. **`instance_uuid` or hardware UID matches a `spool_tag` row** → this spool already exists. Update
nothing by default; just report the match.
2. **No tag match, but `external_id` matches a filament** → reuse that filament, create a new spool.
3. **Neither** → create vendor (if new), filament, and spool, then write the `spool_tag` row so step
1 catches the next scan.
### `consumed_weight` — deliberately not synced
The aux section carries a printer-written `consumed_weight`. **Spoolman must not treat it as
authoritative.** Spoolman is the system of record for consumption, with its own `use`/`measure`
endpoints and a considerably more careful model. Two writers to one number, with no ordering
guarantee between a printer and Spoolman, is a data-corruption bug waiting to happen — and this
project has a standing non-negotiable about not losing user data.
Decode it, surface it, do not act on it. bofh69 reached the same conclusion independently in #776:
"Nothing I'd want to do anyway as Spoolman keeps track of the usage."
---
## Writing tags
Producing the bytes is `encode`. Getting them onto a tag is someone else's job, and the options are
uneven:
- **Web NFC** can write plain NDEF records, which covers OpenTag3D and the JSON formats — but it is
a poor fit for OpenPrintTag, which needs region offsets aligned to physical tag blocks and SLIX2
`PROTECT PAGE` for write protection. Web NFC exposes no block-level control. Treat OpenPrintTag
writing from the browser as **unproven**, not merely unsupported.
- **External writers** (nfc2klipper with a PN5180, SimplyPrint's NFC Agent, a Prusa app) are the
realistic path. `encode` serves them.
Consequence: ship `encode` and let the ecosystem write. Do not build a browser tag-writer for
OpenPrintTag until someone has demonstrated it works on real NFC-V hardware.
---
## Validation constraint — read this before starting
**PR #880 stalled at exactly this point.** Its author shipped a complete-looking OpenPrintTag
implementation and then wrote: *"I unfortunately don't have OpenPrintTag tested because I lack the
physical hardware at present."* The spec is not simple enough to implement blind.
Phase 2 requires, before it ships:
- A physical Prusament tag or Prusa NFC sticker.
- A PN5180 reader, or an Android phone — **a PN532 cannot read these tags.** This is the single most
common failure in the community threads. Flipper Zero and iOS "NFC21 Tools" both *misreport*
OpenPrintTag as NTAG213; only "NFC Tools" on iOS identifies it correctly as Type 5. bofh69 burned
real time on this before ordering a PN5180.
- At least one real tag decoded end to end, not just fixtures.
Unit tests against spec fixtures are necessary but nowhere near sufficient. Do not ship this
claiming it works on the strength of a green test suite.
---
## Deferred, with reasons
**OpenTag3D** — fixed 144-byte layout, genuinely straightforward, and it slots in behind the same
`/tag/decode` dispatch. Not first only because nothing retail ships it. This is a good second format
and a natural first outside contribution.
**openprinttag-database as a catalog source** — 13,403 materials and 125 brands under MIT, raised by
anyasabo in #776 (2026-08-02), who already wrote an importer for their own fork and would rather not
maintain it. Arguably higher user value than tag reading itself. But it belongs in **SpoolmanDB**,
not in this repo, and should be tracked there.
**Vendor tag contents** (Bambu / Creality / Qidi / Elegoo) — Phase 1's UID identity already covers
these for identification, which is the part users actually need. Decoding contents means MIFARE key
derivation for no writable benefit: Bambu tags are RSA-signed and the printer rejects anything
unsigned, so third parties can read and clone but never forge. Elegoo's NTAG-based EPC-256 format is
open and would be easy, but is low-demand today.
**TigerTag** — implemented in PR #880 and in goeland86's fork. Reconsider if demand materializes;
discussion #710 is the only real signal so far.
---
## Files
New: `spoolman/tags/__init__.py`, `spoolman/tags/openprinttag.py`, `spoolman/api/v1/tag.py`.
Modified: `spoolman/api/v1/router.py` (mount the router), `pyproject.toml` (`cbor2`), and the
client_v2 spool-creation flow to offer a decoded tag as a spool source alongside the existing
external-database path.
---
## Verification
- **Codec unit tests** — fixtures built from the spec repo; round-trip `encode` → `decode`; every
field type exercised (uuid, enum, enum_array, colour, timestamp, half-float); malformed and
truncated payloads rejected cleanly rather than raising.
- **UUIDv5** — derived values checked against the spec's own examples. Getting a namespace wrong
produces confident, entirely wrong output, so this needs explicit coverage.
- **Real hardware** — at least one physical Prusament tag decoded end to end. Non-negotiable, see
above.
- **Import idempotency** — integration test in `tests_integration/`: decode-then-create, rescan,
assert no duplicate spool, filament or vendor.
- **No consumption writes** — assert that importing a tag carrying `consumed_weight` leaves
`used_weight` untouched.
- **API compat** — new endpoints only; confirm no existing response shape changed.