From f02ebbc93edd32d991edbe474ba42354331d48d2 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sun, 9 Aug 2026 22:39:22 +0200 Subject: [PATCH] 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. --- design/nfc-phase-1-tag-identity.md | 376 +++++++++++++++++++++++++++++ design/nfc-phase-2-openprinttag.md | 260 ++++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 design/nfc-phase-1-tag-identity.md create mode 100644 design/nfc-phase-2-openprinttag.md diff --git a/design/nfc-phase-1-tag-identity.md b/design/nfc-phase-1-tag-identity.md new file mode 100644 index 00000000..b9fb2a27 --- /dev/null +++ b/design/nfc-phase-1-tag-identity.md @@ -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=`). +- **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.` 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= +``` + +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=`. | +| **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.}`, `{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.` 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. diff --git a/design/nfc-phase-2-openprinttag.md b/design/nfc-phase-2-openprinttag.md new file mode 100644 index 00000000..712dd061 --- /dev/null +++ b/design/nfc-phase-2-openprinttag.md @@ -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.