diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 59ca05c..f3abb77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,6 +56,14 @@ jobs: - name: Install dependencies (skip optional native modules) run: npm ci --omit=optional + - name: Refresh bundled reference data + # Every release ships the day's reference tables and product catalogue + # (database/*.json + id_catalog.json.gz), even if the daily sync commit lags. + run: node scripts/sync_databases.js + + - name: Check the package after the refresh + run: npm test + - name: Publish with provenance (OIDC Trusted Publisher — no token needed) # npm exchanges the GitHub OIDC JWT for a short-lived npm token automatically. # Trusted Publisher must be configured on npmjs.com for diff --git a/.github/workflows/sync-databases.yml b/.github/workflows/sync-databases.yml index d3ee600..df107ee 100644 --- a/.github/workflows/sync-databases.yml +++ b/.github/workflows/sync-databases.yml @@ -26,7 +26,9 @@ jobs: - name: Install dependencies (skip optional native modules) run: npm ci --omit=optional - - name: Sync databases from TigerTag API + - name: Sync reference tables and product catalogue + # Tables from the TigerTag API (GitHub mirror as fallback), catalogue as + # database/id_catalog.json.gz — rewritten only when its content changed. run: node scripts/sync_databases.js - name: Commit and push if databases changed diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d4c9d..df7b47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,177 @@ All notable changes to this project will be documented in this file. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.2.0] — 2026-09-30 + +### Added +- **Unified playground**: `tools/playground.html` is now one page, byte-for-byte identical in the + JavaScript and Python SDKs. Each `tools/server.*` implements the same server contract + (`docs/playground-api.md`): `GET /api/version` (now `{ version, sdk, label, repo, install, + server, style }` — the page takes its SDK names, links and the `create()` code style from it), + `POST /api/parse` / `/api/build` (snake_case `create()` fields, `measure_available`) / + `/api/diff`, `GET /api/catalog/info` / ``, `POST /api/catalog/refresh`, `GET /api/db/info`, + `POST /api/db/update`, `GET /api/db/table/`, `GET /api/nfc/events` (Server-Sent Events, + chips already present replayed on connect), `POST /api/nfc/read` / `burn` / `plan`. The page + talks to the readers through SSE + HTTP on both servers (the JS server keeps its WebSocket transport for older pages; `TIGERTAG_PLAYGROUND_NO_NFC=1` starts it without readers). + `scripts/check_playground_sync.js` fails when the two copies differ (the local checkout next to + this one, else GitHub `main`); the test suite runs it and skips it when neither is reachable. + From the other SDK's page: gradient colour preview, read-only manufacturing date, refusal of an available quantity above the initial one, Studio Manager download buttons, error toasts, tables loaded from the server (`/api/db/table`). +- **Tag index / tag count** (protocol v2.2). Page 0x0D byte 3 (payload offset +39), + previously reserved padding, is now parsed and written as `tagInfo` (u8): + high nibble = tag index, which of the item's TigerTags this one is, from 1 (0 = unknown); + low nibble = tag count, how many TigerTags the item carries — a filament spool, a resin + bottle…, as given by idType (0 = unknown, 1 = single tag, + 2 = twin tag, … 15). The hex reads "index/count": `0x11` single tag, `0x12` / `0x22` + twin tag, tag 1 / 2 of 2, `0x02` twin tag with index unknown, `0x00` unknown. + - `tag.tagInfo`, and read-only getters `tag.tagCount` / `tag.tagIndex`. + - `TigerTag.create({ tagCount, tagIndex })` (default `0` = unknown); `asInit()` writes `0x00`. + Values outside 0–15 throw `RangeError`. + - `patch()` accepts `tagInfo`, `tagCount` and `tagIndex`; `patchFromRawDict()` / + `fromRawDict()` accept `tag_info`. + - `validate()` warns when tag index / count are outside 0–15, when index > count (count > 0), + and when count is 1 with index > 1. + - `toRawDict()` gains `tag_info`; `toDict()` gains `tag_count` / `tag_index` + (`null` when unknown) next to `twin_tag_pairing_id`; `pretty()` prints a `Tag` line + (`Tag 1 of 2`) after `Twin tag ID`; `describe()` adds "Tag 1 of 2 on this filament." + when known (the idType label, lowercased; "item" when the type is unknown). + - `tagInfo` is not covered by the ECDSA signature — setting it never invalidates a signed tag. + +- Playground: "Tag index" / "Tag count" inputs (0–15, 0 = unknown) passed to + `TigerTag.create()` via `/api/build`; the client-side encoder / decoder handle byte +39; + the decoded view shows a `Tag` row after `Twin tag ID` ("1 of 2", "? of 2", "unknown"); + the raw hex view labels page 13 byte 3 as the tag index / count (e.g. `(0x12) tag 1 of 2`); + imported `.bin` files and scanned chips fill the two inputs; presets and API loads reset them to 0. +- Playground server: `/api/parse` and the `card:detected` WebSocket event include + `validate` (the `validate()` warnings), shown in a new "Validation" card. +- Playground: an info bubble on "Tag index" and "Tag count" explains both values; + the repository list points to Tiger-Scale-V3 and adds TigerSpool-RFID, TigerPOD, + TigerSystem-Docs and TigerTag_Firebase_Integration. +- Playground ecosystem panel: photo cards for Tiger Scale V3, TigerSpool, TigerPOD Mini + and the mobile app. +- Playground: TigerTag favicon (`assets/favicon.svg`, with `assets/apple-touch-icon.png`). +- Playground SDK Input panel: `create()` | `HEX` | `Pages` tabs. `HEX` shows exactly what + Burn writes (144 bytes, pages 0x04–0x27, uppercase); `Pages` lists them page by page in + write order with the Raw Read annotations (page 0x0D byte 3 → `tag 1 of 2`). Copy copies + the active tab (`Pages` → one TSV line per page). The "→ Burn result" section is kept. +- Playground twin tag mode: when two or more readers hold a chip (sorted by name → #1, #2, …), + Tag count is locked to the number of chips and Tag index to "auto", with a note naming + each reader's tag ("#1 = tag 1 of 2"). Generate builds one payload per reader — same + data, same Timestamp (computed once in the page and passed to `create()`), tag index 1…n + (`0x12`, `0x22`) — and SDK Input shows + one section per reader. SDK Output gets `#1` / `#2` buttons to switch between the chips. + Burn asks for confirmation listing each reader, UID and tag, then writes each reader's own + payload; the "x/y written" counter and the burn result aggregate across the writes. + Loading a `.bin` or a scanned chip clears the twin plan. +- Playground server: `burn:write` accepts an optional `reader` field (a reader id / name, or an + array of them) to write to those readers only; without it every reader holding a chip is + written, as before (`tools/burn_targets.js`). +- Playground "Chips on the readers" card: for the chips currently on the readers, checks they + belong to the same item (same Timestamp), carry identical data apart from byte +39, and that + the tag numbering is complete (e.g. "Numbering complete: 1/2 + 2/2", "tag 2/2 not on a reader", + or "Tag index / count unknown" for tags written before protocol v2.2). The server replays the + last chip read on each reader to a newly connected page. + +- Playground Read / Burn modes: a header switch "Read" (default) | "Burn". + Read shows only what the readers read (or an imported `.bin`), under a blue "Read result" + banner naming the reader and UID (or the file), with the "Chips on the readers" card; Burn + is disabled and twin tag locking is off. Burn shows only what Burn will write, under an + orange "Burn preview — not written yet" banner listing the target readers (and each one's + tag i of n); a chip placed on a reader never replaces the preview, and Burn writes the + stored preview, never the last payload read. Generate switches to Burn, Import .bin to Read; + each mode keeps its last view. In Read mode, removing the chip whose result is shown switches + to another chip still on a reader, or clears the view (an imported `.bin` stays). Reader + events start only after the reference database has loaded. + +- Playground burn never writes a signature and never leaves a stale one: `burn:write` always + writes pages 0x04–0x27 (36 pages) — the tag data on 0x04–0x17, then `00 00 00 00` on every + signature page 0x18–0x27, whatever the payload (TigerTag, TigerTag+, or data read back from a + signed chip). Only a certified manufacturer can issue a signature, and a copied one would be + invalid since it covers the chip UID; the playgrounds only read signatures to verify them. + Pages 0–3 and 0x28+ are never touched. The page plan lives in `tools/burn_plan.js` + (unit-tested; accepts 80 or 144 bytes); `burn:result` reports `pagesWritten: 36` and + `signatureDropped` when the payload carried a signature. The playground sends the full + 144-byte image, the HEX / Pages tabs show all 36 pages with the zeroed signature, and the + caption and confirmation say so ("the signature read from the chip is not copied" when the + data came from a signed chip). + +- No emoji anywhere: `SignatureResult` labels are plain words (`VALID`, `INVALID`, `NOT SIGNED`, + `NO PUBLIC KEY — …`, `NO UID — …`, `NO CRYPTO — …`), `pretty()` prints `signed (not verified)` + instead of a check mark, and the scripts print `OK` / `FAILED`. The playground uses small inline + SVG icons (read, burn, raw read, upload, download, play, check, x, warning, cloud, hourglass, + close, refresh, external link…) in buttons, banners, tabs, badges and cards, and plain words in + tooltips, confirmation dialogs and console logs. The README, llms.txt and the SVG badges no + longer use emoji either. + +- **TigerTag+ from the official catalogue** (`src/catalog.js`): `TigerTag.fromCatalog(productId, + { uid, tagCount, tagIndex, timestamp, db, catalog })` builds a ready-to-burn TigerTag+ from + `id_catalog.json` (14 000+ products); `TigerTag.fromCatalogEntry(entry, options)` does it from an + entry; `catalogEntry(productId)` returns the display metadata (title, brand, SKU, barcode, image). + RFID_Data mapping: `data1` = diameter, `data2`/`data3` = nozzle min/max, `data4`/`data5` = dry + temp/time, `data6`/`data7` = bed min/max, `id_aspect2` null → 0, colours 2/3 from `color_r2…b3` + or `color_info.colors`. The catalogue (~12 MB) is not bundled: `loadCatalog({ url, cacheDir, + maxAge, force })` downloads it on first use and caches it in the user cache folder (override: + `TIGERTAG_CACHE_DIR`), checks for a new version once the copy is older than 1 day (`maxAge`), falls back to the cached copy when offline and fails with a clear error + when there is none. It changes every day: `refreshCatalog()` checks for a new version now + (conditional download with ETag / Last-Modified — an unchanged catalogue answers 304) and + `catalogInfo()` reports `downloaded`, `count`, `fetchedAt`, `checkedAt`, `url` and `etag`. +- Playground: one "Load a TigerTag+ product" block — a single Product ID field, a source toggle + "Offline · Catalogue" (bundled / cached catalogue, no internet) | "Online · API" (live + api.tigertag.io data), remembered in the browser, and one Load button; both sources fill the + form, switch to Burn and build the preview (twin tag with two readers), share one message area + and suggest the other source when a product is not found / the API is unreachable. +- Playground: decluttered — on screen only short labels, values, buttons and status words + ("Catalogue · 14 164 · 1 oct.", "Tables · 25 sept.", "Burn preview · not written · twin tag · + 2 chips"); every explanation moved into info bubbles (TigerTag+ intro, data sources, catalogue + and tables details, Read / Burn banners, HEX / Pages captions, "Chips on the readers" checks, + field hints, twin tag readers, Init, demo presets). Bubbles are placed to stay inside their + panel and the viewport. +- Playground: "Load from catalogue" in the TigerTag+ tab — a product ID fills the whole form from + the catalogue, shows title, brand, SKU and image, switches to Burn and builds the preview (twin + tag with two readers); "Update catalogue" downloads the latest version; a status line shows + "Catalogue: 14 164 products · updated " or "not downloaded yet". Server endpoints + `GET /api/catalog/`, `GET /api/catalog/info`, `POST /api/catalog/refresh`. + +### Fixed +- Playground: Generate now passes an explicit Timestamp to `create()`, so the decoded view + shows the real Twin tag ID and manufacturing date instead of `null` / 2000-01-01. +- Playground: the `create()` call shown in SDK Input is the exact call sent to `/api/build` + (inactive color 2 / 3 slots are no longer sent while hidden from the displayed call), so + the text shown always reproduces the bytes. + +- **Reference data always available offline, kept fresh automatically**: the product catalogue + ships in the package as `database/id_catalog.json.gz` (~1 MB, gunzipped at load; package + 1.1 MB) next to the 7 tables. `TigerTagDB` picks, per table, the newest of the downloaded copy + in the data dir (`dataDir`, `TIGERTAG_DATA_DIR`, default: the per-user cache folder shared + with the catalogue) and the bundled copy. `await TigerTagDB.open()` / the constructor's + background check (`db.ready`) look for new tables at most once a day (one request to the + TigerTag API, GitHub mirror as fallback, only the changed tables downloaded, ~5 s timeout, + never throws). `offline: true` / `TIGERTAG_OFFLINE=1` / CLI `--offline` mean zero network + calls; `autoUpdate: false` disables only the automatic check (`autoSync` is a deprecated + alias). `await db.update({ force, catalog })` forces it and returns the changed files; + `db.info()` reports where every table comes from (custom / downloaded / bundled), the last + check, the data dir, the offline flag and the catalogue. CLI: `tigertag update [--force] + [--catalog] [--data-dir PATH | --db PATH]`. The catalogue loader shares the data dir, the + offline switch and the bundled `.gz` fallback, so `TigerTag.fromCatalog()` works offline. +- Playground: "Update reference tables" button with a status line ("Reference tables: 7 (n + downloaded, n bundled) · data · checked "), server endpoints `GET /api/db/info` + and `POST /api/db/update`; the catalogue status line names the bundled copy. +- Playground: Read mode shows no SDK Input panel and Burn mode no SDK Output panel at all (not + even the fold rail) — three columns instead of four; switching mode opens the visible panel. +- Release pipeline: `scripts/sync_databases.js` also refreshes `database/id_catalog.json.gz` + (only when its content changed); the daily `sync-databases.yml` commits it and `publish.yml` + runs the sync (then the tests) before `npm publish`, so every release ships the day's data. + +### Changed +- Protocol version is now **TigerTag Open Source v2.2** (backward compatible: tags written + before v2.2 read `0x00` = unknown). +- `toBytes()` writes `tagInfo` at +39 instead of a hard-coded `0x00`. +- **Behaviour change**: a custom `dbPath` is used exclusively — a missing table file now throws + a clear error instead of silently falling back to the bundled copy; `new TigerTagDB()` now + checks for updates once a day in the background (into the data dir — never into the package + folder; disable with `autoUpdate: false` or `offline: true`); `db.sync()` is an alias of + `db.update()`, and `tag.syncDb()` / `tigertag --sync-only` without a folder update the data dir + instead of rewriting the bundled `database/` folder. + ## [1.1.0] — 2026-07-10 ### Changed @@ -73,7 +244,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Playground: **Available qty auto-link** — the Available Qty field automatically mirrors Initial Qty until the user manually edits it. Link is restored on preset load, API fetch, or NFC scan. Removes the old "(0 = same as initial)" convention. -- Playground: **Raw Hex Reader** (`🔬 Raw Read` button) — reads all 144 bytes (pages 4–39) from +- Playground: **Raw Hex Reader** (`Raw Read` button) — reads all 144 bytes (pages 4–39) from every connected reader that holds a card and displays them in a structured table: page number, byte offset, four individual hex bytes (B0–B3), big-endian u32 decimal, and field label. The signature pages (24–39) are visually dimmed and preceded by a separator row. @@ -82,7 +253,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). displayed side-by-side (flex row). Panels use the same rail UX as SDK Input / Output. - **Copy hex** button per panel — copies one line per page (`0x04 B0 B1 B2 B3`) to the clipboard. Includes page hex prefix on each line for direct cross-reference with NFC documentation. - Button shows `✓ Copied!` (green, 1.5 s) after a successful copy so the user gets clear feedback. + Button shows `Copied` (green, 1.5 s) after a successful copy so the user gets clear feedback. - **Annotated Field column** — each field cell now shows decoded values inline: `(value) field_name · (value) field_name · …`. Values are read directly from the raw bytes (no extra server round-trip). customMessage pages show the decoded ASCII chars `("azer")`. @@ -121,7 +292,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Works for TigerTag+ chips only (filament / resin types). Cache-busted with `v=` on each call. `toRawDict()` and `toDict()` now include an `img` field exposing all URLs. -- Playground: **Burn** button (`🔥 Burn`) — writes the generated payload to every +- Playground: **Burn** button (`Burn`) — writes the generated payload to every connected ACR122U / PC-SC reader that currently holds a card. Writes 20 pages (pages 4–23, 80 bytes) sequentially via `reader.write()`. Result reported per reader via WS (`burn:result`) with success/error detail; `burn:done` signals diff --git a/README.md b/README.md index 2f772dd..3cb7e8f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Tests](https://github.com/TigerTag-Project/TigerTag-SDK-JS/actions/workflows/test.yml/badge.svg)](https://github.com/TigerTag-Project/TigerTag-SDK-JS/actions/workflows/test.yml) [![Node](https://img.shields.io/badge/node-18%2B-blue?logo=node.js&logoColor=white)](https://nodejs.org/) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE) -[![Protocol](https://img.shields.io/badge/protocol-TigerTag%20v2.1-orange)](https://github.com/TigerTag-Project/TigerTag-RFID-Guide) +[![Protocol](https://img.shields.io/badge/protocol-TigerTag%20v2.2-orange)](https://github.com/TigerTag-Project/TigerTag-RFID-Guide) [![Offline first](https://img.shields.io/badge/offline-first-teal)](database/) **Offline JavaScript / Node.js SDK for TigerTag RFID material identification.** @@ -48,9 +48,9 @@ Each signed chip carries an ECDSA-P256 signature that binds the chip UID to the Any reader — including this SDK — can verify the signature fully offline, with no server call: ```js -const result = tag.verify(); // ✅ VALID — chip is genuine and untampered - // ❌ INVALID — data has been modified or chip is cloned - // ⬜ NOT SIGNED — unsigned Maker tag (verification not required) +const result = tag.verify(); // VALID — chip is genuine and untampered + // INVALID — data has been modified or chip is cloned + // NOT SIGNED — unsigned Maker tag (verification not required) ``` No other RFID material protocol provides on-chip cryptographic authentication at this level. @@ -105,7 +105,7 @@ the mobile apps, and all community tools. No subscription, no lock-in. --- -## ▶ Try the Playground +## Try the Playground No NFC hardware required — explore the full SDK output directly in your browser. @@ -119,6 +119,14 @@ node tools/server.js 7432 open http://localhost:7432/tools/playground.html ``` +**One page, two servers.** `tools/playground.html` is the same file, byte for byte, in the +JavaScript SDK and the Python SDK; each repository's `tools/server.*` implements the same +server contract ([docs/playground-api.md](docs/playground-api.md)) with its own SDK, and the page +adapts its names and code (`create()` shown in camelCase or snake_case, `toRawDict()` / +`to_raw_dict()`…) to `GET /api/version`. `node scripts/check_playground_sync.js` checks that the copy here is identical to +the other repository's (the local checkout next to this one, or GitHub `main`); the test suite +runs it and skips it when neither is reachable. Change the page in both repositories together. + Or via npm: ```bash @@ -131,9 +139,9 @@ The playground has five panels: |-------|---------| | **Sidebar** (left) | Build a TigerTag / TigerTag+ / Init tag: choose version, brand, material, colors, print settings. Generate button pinned at the bottom — always visible. | | **Center** | Protocol preview cards: Protocol, Material, Colors, Print Settings, Quantity, Traceability, Cloud API | -| **SDK Input** (collapsible) | Shows the exact `TigerTag.create({...})` call for the current tag — the **write** side. Opens automatically when you click 🔥 Burn. Payload is generated server-side via `POST /api/build` (SDK is always the authoritative serializer — browser never computes chip bytes). | +| **SDK Input** (collapsible) | Shows the exact `TigerTag.create({...})` call for the current tag — the **write** side. Opens automatically when you click Burn. Payload is generated server-side via `POST /api/build` (SDK is always the authoritative serializer — browser never computes chip bytes). | | **SDK Output** (collapsible) | Shows `pretty()`, `describe()`, `verify()`, `toRawDict()`, `toDict()`, `rawApi()`, `diffApi()` — the **read** side. Opens automatically on Generate / NFC scan / Import. | -| **Raw Hex** (modal) | `🔬 Raw Read` — reads all 144 bytes (pages 4–39) from every connected reader and shows a structured hex table: page (decimal), offset (bytes), page (hex: 0x04–0x27), B0–B3, u32 BE, annotated field label `(value) field_name · …`. Signature pages dimmed. Multiple readers shown side-by-side in collapsible panels. Copy hex button outputs one `0x04 B0 B1 B2 B3` line per page with `✓ Copied!` feedback. | +| **Raw Hex** (modal) | `Raw Read` — reads all 144 bytes (pages 4–39) from every connected reader and shows a structured hex table: page (decimal), offset (bytes), page (hex: 0x04–0x27), B0–B3, u32 BE, annotated field label `(value) field_name · …`. Signature pages dimmed. Multiple readers shown side-by-side in collapsible panels. Copy hex button outputs one `0x04 B0 B1 B2 B3` line per page with `Copied` feedback. | SDK Input / Output and Raw Hex reader panels are all collapsible via their adjacent rails. @@ -153,13 +161,16 @@ npm run playground ``` **Multiple simultaneous USB readers** supported. Each reader gets its own status badge in the -header (`● green` = connected, `● orange pulse` = reading card) and its own Raw Hex panel. +header (green dot = connected, orange pulsing dot = reading card) and its own Raw Hex panel. -**🔥 Burn** — once a chip is on a reader, click Burn to write the current payload to all -connected readers that hold a card. Writes pages 4–23 (80 bytes) sequentially. +**Burn** — once a chip is on a reader, click Burn to write the current payload to all +connected readers that hold a card. Writes pages 0x04–0x27 (36 pages) sequentially: the tag +data, then `00` on every signature page 0x18–0x27. The playground never writes a signature — +only a certified manufacturer can issue one; the playground only reads signatures to verify +them — and a burn never leaves a stale one (pages 0–3 and 0x28+ are never touched). The SDK Input panel opens automatically so you can see exactly what was written. -**🔬 Raw Read** — reads all 144 bytes from every card-holding reader and displays the raw chip +**Raw Read** — reads all 144 bytes from every card-holding reader and displays the raw chip memory as a structured hex table with field annotations. Useful for debugging and verifying burns. Server endpoints: @@ -191,7 +202,7 @@ const { TigerTag } = require('tigertag'); const tag = TigerTag.fromPages(uid, payload); // from your NFC SDK console.log(tag.pretty()); // human-readable summary -console.log(String(tag.verify())); // ✅ VALID / ⬜ NOT SIGNED / ❌ INVALID +console.log(String(tag.verify())); // VALID / NOT SIGNED / INVALID console.log(tag.toDict()); // JSON-ready object ``` @@ -209,9 +220,9 @@ for reading — all data lives on the chip. | Tag type | idProduct | Offline | Cloud | |---|---|---|---| -| **TigerTag** (Maker) | `0xFFFFFFFF` | ✅ full data on chip | — | -| **TigerTag Init** | `0x00000000` | ✅ blank template | — | -| **TigerTag+** | numeric ID | ✅ full data on chip | ✅ API for live updates | +| **TigerTag** (Maker) | `0xFFFFFFFF` | Yes — full data on chip | — | +| **TigerTag Init** | `0x00000000` | Yes — blank template | — | +| **TigerTag+** | numeric ID | Yes — full data on chip | Yes — API for live updates | **Protocol spec:** [github.com/TigerTag-Project/TigerTag-RFID-Guide](https://github.com/TigerTag-Project/TigerTag-RFID-Guide) @@ -243,15 +254,15 @@ capability container) are never part of the user data payload. | Payload | Pages | UID | Verifiable | |---|---|---|---| -| **144 bytes** | 0x04–0x27 (user data + signature) | Required (7 bytes) | ✅ Yes | +| **144 bytes** | 0x04–0x27 (user data + signature) | Required (7 bytes) | Yes | | **80 bytes** | 0x04–0x17 (user data, no signature) | Required (7 bytes) | N/A | ### `fromDump(data)` — binary dump workflow | Dump | Content | UID | Verifiable | |---|---|---|---| -| **180 bytes** | Full chip (pages 0–44, includes system pages) | Auto-extracted | ✅ Yes | -| **144 bytes** | Partial dump (user data + signature, no system pages) | Not available | ❌ No | +| **180 bytes** | Full chip (pages 0–44, includes system pages) | Auto-extracted | Yes | +| **144 bytes** | Partial dump (user data + signature, no system pages) | Not available | No | | **80 bytes** | User data only | Not available | N/A | --- @@ -271,8 +282,10 @@ tag.toRawDict() // → object raw protocol fields, no r // color_r2/g2/b2 and color_r3/g3/b3 are zeroed for inactive slots // num_colors — active color slot count from aspect DB (1/2/3) // color_list — string[] of #RRGGBB for active slots only + // tag_info — raw u8 at +39 (index << 4 | count) tag.toBytes(includeSignature = false) // → Buffer re-serialize to chip bytes tag.validate() // → string[] sanity check — list of warnings + // (includes tag index > tag count checks) tag.verify(db) // → SignatureResult // Write (immutable — all return a new TigerTag) @@ -301,6 +314,9 @@ tag.isSigned // true if signature bytes are non-zero tag.uidHex // "04AABBCCDDEE11" or null tag.color1Hex // "#FF3232" tag.tdValue // 12.5 (HueForge Transmission Distance) +tag.tagInfo // 0x12 raw u8 at +39, reads "index/count" (0x00 = unknown, tags written before v2.2) +tag.tagIndex // 1 which tag this one is, from 1 (high nibble) — 0 unknown +tag.tagCount // 2 TigerTags on the item (low nibble) — 0 unknown, 1 single tag, 2 twin tag tag.manufacturingDate // Date (UTC) tag.stockPercent // 75.0 or null tag.productPageUrl // "https://tigertag.io/products/..." or null @@ -326,6 +342,7 @@ const tag = TigerTag.create({ color1R: 255, color1G: 0, color1B: 0, color1A: 255, measure: 1000, idUnit: 21, // measureAvailable: 750, // optional — partial spool; defaults to measure (full) + // tagCount: 2, tagIndex: 1, // optional — twin tag, tag 1 of 2 → byte +39 = 0x12 (default 0 = unknown) }); // Blank TigerTag Init chip (ready for programming) @@ -337,6 +354,12 @@ const blankBytes = TigerTag.erase(); // Immutable surgical update — returns a new TigerTag, original unchanged const patched = tag.patch({ nozzleTempMin: 200, dryTemp: 55 }); +// Tag index / tag count (protocol v2.2) — write tag 2 of a twin tag. +// Both tags of an item (a filament spool, a resin bottle…) share the same tagCount and timestamp. +// describe() then says "Tag 2 of 2 on this filament." (the idType label; "item" when unknown). +// tagInfo is not covered by the ECDSA signature: changing it never invalidates a signed tag. +const second = tag.patch({ tagCount: 2, tagIndex: 2 }); // byte +39 = 0x22 + // TigerTag+ cloud sync const apiData = await tag.rawApi(); // fetch live product data const diffs = await tag.diffApi(apiData); // what differs chip vs cloud? @@ -381,6 +404,49 @@ const patched2 = tag2.patchFromRawDict({ measure_available: 650 }); | `TD` | `tdRaw` (float × 10 → integer, e.g. `1.5` → `15`) | | `weight_available` / `measure_gr` | `measureAvailable` | +### TigerTag+ from the official catalogue + +Give only a TigerTag+ product ID and get a complete tag, ready to burn. The official +catalogue ([`id_catalog.json`](https://github.com/TigerTag-Project/TigerTag-RFID-Guide/blob/main/database/id_catalog.json), +14 000+ products, ~12 MB) is **not bundled**: the SDK downloads it on first use (internet needed +once) and caches it in a per-user cache folder (`~/Library/Caches/tigertag`, `%LOCALAPPDATA%\tigertag`, +`$XDG_CACHE_HOME/tigertag` or `~/.cache/tigertag`; override with `TIGERTAG_CACHE_DIR`). + +```js +const { TigerTag, catalogEntry, refreshCatalog, catalogInfo } = require('tigertag'); + +const tag = await TigerTag.fromCatalog(3527039449); // Elegoo Rapid TPU 95A - Black +const bytes = tag.toBytes(); // 80 bytes, ready to write + +// Twin tag: same timestamp on both tags +const ts = Math.floor((Date.now() - Date.UTC(2000, 0, 1)) / 1000); +const tag1 = await TigerTag.fromCatalog(3527039449, { tagCount: 2, tagIndex: 1, timestamp: ts }); +const tag2 = await TigerTag.fromCatalog(3527039449, { tagCount: 2, tagIndex: 2, timestamp: ts }); + +// Display metadata (title, brand, sku, barcode, img_src, material, measure…) +const entry = await catalogEntry(3527039449); + +// The catalogue changes every day: check for a new version now (ETag — unchanged = 304, no download) +await refreshCatalog(); +catalogInfo(); // { downloaded, count, fetchedAt, checkedAt, url, etag, lastModified, cacheFile } +``` + +| Catalogue `RFID_Data` | TigerTag field | +|---|---| +| `id_material`, `id_aspect1`, `id_aspect2` (`null` → `0`, none), `id_type`, `id_brand`, `id_unit`, `measure` | same names (camelCase) | +| `color_r/g/b/a` | colour 1 (RGBA) | +| `color_r2…b2`, `color_r3…b3` (when present), otherwise `color_info.colors[1]` / `[2]` | colour 2 / colour 3 | +| `data1` | `idDiameter` | +| `data2` / `data3` | `nozzleTempMin` / `nozzleTempMax` | +| `data4` / `data5` | `dryTemp` / `dryTime` | +| `data6` / `data7` | `bedTempMin` / `bedTempMax` | + +`null` values become `0`. A product without `RFID_Data` (a few resins) throws a clear error, as does +an unknown ID or an offline first use with no cached copy. `loadCatalog({ url, cacheDir, maxAge, force })` +returns the whole catalogue as a `Map` (id → entry) and checks for a new version once the cached copy +is older than `maxAge` (default 1 day, since the catalogue changes every day); `TigerTag.fromCatalogEntry(entry, options)` builds +the tag from an entry you already have. + ### ApiDiff `ApiDiff` is a plain object `{ field, chipValue, apiValue }`: @@ -409,7 +475,7 @@ const result = tag.verify(); // fully autonomous — finds the public key from result.ok // true only for VALID result.status // "valid" | "invalid" | "unsigned" | "no_key" | "no_uid" -String(result) // "✅ VALID" | "❌ INVALID" | "⬜ NOT SIGNED" | "🔑 NO KEY" | … +String(result) // "VALID" | "INVALID" | "NOT SIGNED" | "NO PUBLIC KEY — …" | … result.toDict() // { status: "valid", ok: true, detail: "…" } ``` @@ -431,9 +497,10 @@ fully offline, no external dependencies (Node.js built-in `crypto` module). ```js const { TigerTagDB } = require('tigertag'); -const db = new TigerTagDB(); // bundled database (offline, no network) -const db = new TigerTagDB({ autoSync: true }); // check for updates on init -const db = new TigerTagDB({ dbPath: '/path' }); // custom database path +const db = new TigerTagDB(); // freshest local data, daily check in the background +const db = await TigerTagDB.open(); // waits for the daily check (5 s max, never throws) +const db = new TigerTagDB({ offline: true }); // zero network calls +const db = new TigerTagDB({ dbPath: '/my/tables' }); // your own files, used exclusively db.material(38219) // { id: 38219, label: "PLA", density: 1.24, ... } db.brand(1) // { id: 1, label: "Generic", ... } @@ -441,19 +508,68 @@ db.version(0x01000001) // { id: ..., label: ..., public_key: "-----BEGIN..." } TigerTagDB.label(entry) // safe label extraction helper ``` -### Auto-update behavior +## Reference data: offline, automatic and manual updates -The SDK ships with bundled reference databases — works fully offline after `npm install tigertag`. +The reference data is the 7 tables (`id_version`, `id_material`, `id_aspect`, `id_type`, +`id_diameter`, `id_brand`, `id_measure_unit` + `last_update.json`) and the product catalogue +(`id_catalog.json`). It is **always available offline**: a copy ships in the package +(`database/`, the catalogue as `id_catalog.json.gz`, refreshed at every release), and +**kept fresh automatically**. -| Mode | Behavior | -|------|----------| -| Default | Uses bundled JSONs — no network, always works | -| `new TigerTagDB({ autoSync: true })` | Checks timestamps on init, downloads only changed files | -| `tag.syncDb(null, true)` | Forces full re-download | -| `tigertag --sync-only` | CLI sync, updates bundled database in place | -| Network failure | Caught silently — bundled databases used as fallback | +**Where each table comes from** -Sources: TigerTag API → GitHub mirror (automatic fallback). +| Priority | Source | When | +|---|---|---| +| 1 | `dbPath` (your own folder) | Used **exclusively**: a missing file is an error, there is no fallback and no automatic network call | +| 2 | Downloaded copy in the **data dir** | When its `last_update.json` timestamp is newer than the bundled one | +| 3 | Bundled copy (`database/` in the package) | Always present — the fallback | + +The **data dir** holds the downloaded copies: `dataDir` option, else `TIGERTAG_DATA_DIR`, else +`TIGERTAG_CACHE_DIR`, else the per-user cache folder (`~/Library/Caches/tigertag`, +`%LOCALAPPDATA%\tigertag`, `$XDG_CACHE_HOME/tigertag` or `~/.cache/tigertag`). Point it at a +project folder to keep the data with your project. + +**Automatic update** (`autoUpdate`, default on): `new TigerTagDB()` is synchronous and never +waits for the network — it loads the freshest local copy and starts ONE background check per +process (`await db.ready` resolves when it is done; the instance is then reloaded). +`await TigerTagDB.open()` runs the same check before returning. The check runs at most once per +`maxAge` (default 1 day, tracked in `db_state.json` in the data dir; retried after 1 h when it +failed), makes ONE request to `https://api.tigertag.io/api:tigertag/all/last_update` (GitHub +mirror as fallback), downloads only the changed tables, uses a ~5 s timeout and never throws +(`verbose: true` logs it). `autoUpdate: false` disables only this check (`autoSync` is a +deprecated alias). The catalogue shares the data dir: `TigerTag.fromCatalog()` checks for a new +catalogue once its copy is older than 1 day and works offline from the bundled `.gz`. + +**Offline**: `offline: true` on `TigerTagDB`, `loadCatalog`, `fromCatalog`, the CLI `--offline` +flag, or `TIGERTAG_OFFLINE=1` → zero network calls. + +**Manual update** + +```js +const db = new TigerTagDB(); +await db.update(); // → ['id_brand.json', 'last_update.json'] (only what changed) +await db.update({ force: true }); // re-download every table +await db.update({ catalog: true }); // tables + product catalogue +db.info(); // { offline, autoUpdate, dataDir, customDir, lastCheck, lastError, + // tables: { brands: { file, source: 'custom'|'downloaded'|'bundled', path, timestamp }, … }, + // catalog: { source, count, fetchedAt, checkedAt, … } } +``` + +```bash +tigertag update # tables, into the data dir +tigertag update --force --catalog # everything, re-downloaded +tigertag update --data-dir ./refdata # into a project folder +tigertag update --db ./my-tables # into your own (exclusive) folder +tigertag dump.bin --offline # parse with no network call +``` + +`db.sync(force)` and `syncDatabases(folder)` still work (`sync()` is now an alias of `update()`). + +> **Behaviour change in 1.2.0**: a custom `dbPath` is used exclusively — a missing file now +> throws instead of silently falling back to the bundled copy; `new TigerTagDB()` checks for +> updates once a day in the background (into the data dir, never into the package folder); +> `syncDb()` / `tigertag --sync-only` without a folder update the data dir instead of the +> bundled `database/` folder. --- @@ -468,7 +584,7 @@ reader.on('card', async (card) => { const payload = await reader.read(4, 144, 4); // pages 4–39, 144 bytes const tag = TigerTag.fromPages(uid, payload); console.log(tag.pretty()); - console.log(String(tag.verify())); // ✅ VALID / ⬜ NOT SIGNED + console.log(String(tag.verify())); // VALID / NOT SIGNED }); ``` @@ -493,7 +609,7 @@ nfc.on('reader', (reader) => { const payload = await reader.read(4, 144, 4); // pages 4–39, 144 bytes const tag = TigerTag.fromPages(uid, payload); console.log(tag.pretty()); - console.log(String(tag.verify())); // ✅ VALID / ⬜ NOT SIGNED / ❌ INVALID + console.log(String(tag.verify())); // VALID / NOT SIGNED / INVALID } catch (err) { console.error(err); } @@ -537,7 +653,8 @@ Offset Size Field 0x1E 1 bedTempMin u8 0x1F 1 bedTempMax u8 0x20 4 timestamp u32 BE — seconds since 2000-01-01 UTC -0x24 3 color2 RGB u8×3 + 0x00 padding +0x24 3 color2 RGB u8×3 +0x27 1 tagInfo u8 — high nibble tag index, low nibble tag count (0x12 = tag 1 of 2, 0 = unknown) 0x28 3 color3 RGB u8×3 + 0x00 padding 0x2C 2 tdRaw u16 BE — HueForge TD × 10 0x2E 2 (padding) @@ -584,11 +701,15 @@ tigertag dump.bin --json # Raw protocol fields (no DB lookup) tigertag dump.bin --raw -# Use a custom database folder +# Use a custom database folder (exclusively) tigertag dump.bin --db /path/to/db -# Update reference databases and exit -tigertag --sync-only +# Parse with no network call at all +tigertag dump.bin --offline + +# Update the reference tables now (into the data dir); --catalog adds the product catalogue +tigertag update +tigertag update --force --catalog --data-dir ./refdata # Show version tigertag --version @@ -628,6 +749,12 @@ const { SignatureResult, ApiDiff, syncDatabases, + loadCatalog, + refreshCatalog, + catalogInfo, + catalogEntry, + catalogCacheDir, + CATALOG_URL, ID_TIGERTAG, ID_TIGERTAG_PLUS, ID_TIGERTAG_INIT, @@ -714,7 +841,7 @@ Protocol spec: [github.com/TigerTag-Project/TigerTag-RFID-Guide](https://github. --- - diff --git a/SECURITY.md b/SECURITY.md index 602a11c..309ba35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | |---------|-----------| -| 1.x | ✅ Yes | +| 1.x | Yes | ## Reporting a Vulnerability diff --git a/assets/TigerPOD_Mini.jpg b/assets/TigerPOD_Mini.jpg new file mode 100644 index 0000000..f5c16c7 Binary files /dev/null and b/assets/TigerPOD_Mini.jpg differ diff --git a/assets/TigerScale_Photo.png b/assets/TigerScale_Photo.png deleted file mode 100644 index 47c0f0f..0000000 Binary files a/assets/TigerScale_Photo.png and /dev/null differ diff --git a/assets/TigerScale_V3.png b/assets/TigerScale_V3.png new file mode 100644 index 0000000..3c3fa16 Binary files /dev/null and b/assets/TigerScale_V3.png differ diff --git a/assets/TigerSpool.png b/assets/TigerSpool.png new file mode 100644 index 0000000..794571c Binary files /dev/null and b/assets/TigerSpool.png differ diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000..aa0bd8f Binary files /dev/null and b/assets/apple-touch-icon.png differ diff --git a/assets/badge_playground.svg b/assets/badge_playground.svg index dd4a492..9ca3465 100644 --- a/assets/badge_playground.svg +++ b/assets/badge_playground.svg @@ -28,14 +28,16 @@ fill="#94a3b8" letter-spacing="1">TIGERTAG SDK - ▶ Interactive + + Interactive Playground localhost:7432 - No hardware needed ✓ + No hardware needed + diff --git a/assets/banner.svg b/assets/banner.svg index f3d387e..52ac0a9 100644 --- a/assets/banner.svg +++ b/assets/banner.svg @@ -91,20 +91,24 @@ - ⬡ 2 000 000+ chips + 2 000 000+ chips + - ✓ 100% offline + 100% offline + ⊕ GPLv3 free - ⬡ ECDSA-P256 auth + ECDSA-P256 auth + - ⬡ Node.js 18+ + Node.js 18+ + Adopted by diff --git a/assets/chip_layout.svg b/assets/chip_layout.svg index 75b5e09..a75cde2 100644 --- a/assets/chip_layout.svg +++ b/assets/chip_layout.svg @@ -27,7 +27,7 @@ NTAG Chip Memory Layout - Pages 0x04–0x27 · 144 bytes user data · TigerTag Open Source v2.1 · NTAG213 / 215 / 216 compatible + Pages 0x04–0x27 · 144 bytes user data · TigerTag Open Source v2.2 · NTAG213 / 215 / 216 compatible @@ -140,7 +140,7 @@ 0x0D +36 4 B - Color 2 — RGB (3 bytes) · padding + Color 2 — RGB (3 bytes) · tag index / count (u8, 0x12 = 1 of 2) Colors diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..7a921cb --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/playground/brands/elegoo.png b/assets/playground/brands/elegoo.png new file mode 100644 index 0000000..1570217 Binary files /dev/null and b/assets/playground/brands/elegoo.png differ diff --git a/assets/playground/brands/esun.png b/assets/playground/brands/esun.png new file mode 100644 index 0000000..c2a347d Binary files /dev/null and b/assets/playground/brands/esun.png differ diff --git a/assets/playground/brands/polymaker.png b/assets/playground/brands/polymaker.png new file mode 100644 index 0000000..3c8bae4 Binary files /dev/null and b/assets/playground/brands/polymaker.png differ diff --git a/assets/playground/brands/r3d.png b/assets/playground/brands/r3d.png new file mode 100644 index 0000000..a24a7e9 Binary files /dev/null and b/assets/playground/brands/r3d.png differ diff --git a/assets/playground/brands/rosa3d.png b/assets/playground/brands/rosa3d.png new file mode 100644 index 0000000..9f9fa04 Binary files /dev/null and b/assets/playground/brands/rosa3d.png differ diff --git a/assets/playground/brands/sunlu.svg b/assets/playground/brands/sunlu.svg new file mode 100644 index 0000000..2eca05e --- /dev/null +++ b/assets/playground/brands/sunlu.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/playground/products/10.jpg b/assets/playground/products/10.jpg new file mode 100644 index 0000000..ed7a83f Binary files /dev/null and b/assets/playground/products/10.jpg differ diff --git a/assets/playground/products/20.jpg b/assets/playground/products/20.jpg new file mode 100644 index 0000000..c3cd7ee Binary files /dev/null and b/assets/playground/products/20.jpg differ diff --git a/assets/playground/products/30.jpg b/assets/playground/products/30.jpg new file mode 100644 index 0000000..bd2fd0c Binary files /dev/null and b/assets/playground/products/30.jpg differ diff --git a/assets/playground/products/40.jpg b/assets/playground/products/40.jpg new file mode 100644 index 0000000..90505d6 Binary files /dev/null and b/assets/playground/products/40.jpg differ diff --git a/assets/playground/products/50.jpg b/assets/playground/products/50.jpg new file mode 100644 index 0000000..e2dee61 Binary files /dev/null and b/assets/playground/products/50.jpg differ diff --git a/assets/playground/products/60.jpg b/assets/playground/products/60.jpg new file mode 100644 index 0000000..30e4633 Binary files /dev/null and b/assets/playground/products/60.jpg differ diff --git a/bin/tigertag.js b/bin/tigertag.js index 5552627..9903577 100644 --- a/bin/tigertag.js +++ b/bin/tigertag.js @@ -25,24 +25,33 @@ const fs = require('fs'); const path = require('path'); const { TigerTag, TigerTagDB, SignatureResult, syncDatabases } = require('../src/index'); -const { _BUNDLED_DB_PATH } = require('../src/db'); const VERSION = require('../package.json').version; const HELP = ` Usage: tigertag [dump.bin] [options] + tigertag update [--force] [--catalog] [--data-dir PATH | --db PATH] Parse, verify, and export TigerTag RFID chip dumps. Arguments: dump.bin Binary .bin file to parse +Commands: + update Check for new reference tables now and download the changed ones + (into the data dir, or into --db PATH) + Options: - --db Database folder (default: bundled database) + --db Custom database folder, used exclusively (no fallback) + --data-dir Folder for downloaded reference data (default: TIGERTAG_DATA_DIR + or the per-user cache folder) + --offline No network call at all (same as TIGERTAG_OFFLINE=1) + --force update: re-download every table + --catalog update: also check for a new product catalogue --json Output as JSON --raw Raw protocol fields, no DB lookup - --no-sync Do not auto-download databases - --sync-only Update databases and exit + --no-sync Do not run the automatic daily check + --sync-only Same as "update" --version Show version -h, --help Show this help @@ -54,39 +63,56 @@ Dump formats: Examples: tigertag dump.bin Parse + human-readable output tigertag dump.bin --json Output as JSON - tigertag dump.bin --raw Raw protocol fields, no DB lookup - tigertag --sync-only Update reference databases and exit + tigertag dump.bin --offline Parse without any network call + tigertag update Update the reference tables now + tigertag update --catalog Same, plus the product catalogue Spec: https://github.com/TigerTag-Project/TigerTag-RFID-Guide `.trim(); function parseArgs(argv) { - const args = { dump: null, db: null, json: false, raw: false, noSync: false, syncOnly: false }; + const args = { + dump: null, db: null, dataDir: null, json: false, raw: false, noSync: false, + update: false, force: false, catalog: false, offline: false, + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--json') args.json = true; - else if (a === '--raw') args.raw = true; - else if (a === '--no-sync') args.noSync = true; - else if (a === '--sync-only') args.syncOnly = true; + if (a === '--json') args.json = true; + else if (a === '--raw') args.raw = true; + else if (a === '--no-sync') args.noSync = true; + else if (a === '--sync-only' || (a === 'update' && !args.dump && !args.update)) args.update = true; + else if (a === '--force') args.force = true; + else if (a === '--catalog') args.catalog = true; + else if (a === '--offline') args.offline = true; else if (a === '--version') { console.log(`tigertag ${VERSION}`); process.exit(0); } else if (a === '-h' || a === '--help') { console.log(HELP); process.exit(0); } - else if (a === '--db') args.db = argv[++i]; - else if (!a.startsWith('-')) args.dump = a; + else if (a === '--db') args.db = argv[++i]; + else if (a === '--data-dir') args.dataDir = argv[++i]; + else if (!a.startsWith('-')) args.dump = a; } return args; } async function main() { const args = parseArgs(process.argv.slice(2)); - const dbPath = args.db ? path.resolve(args.db) : _BUNDLED_DB_PATH; + if (args.offline) process.env.TIGERTAG_OFFLINE = '1'; // no network call anywhere in this process + const dbPath = args.db ? path.resolve(args.db) : undefined; + const dataDir = args.dataDir ? path.resolve(args.dataDir) : undefined; - if (args.syncOnly) { + if (args.update) { try { - const updated = await syncDatabases(dbPath, { verbose: true }); + let updated; + if (dbPath) { + updated = await syncDatabases(dbPath, { force: args.force, verbose: true }); + } else { + const db = new TigerTagDB({ dataDir, offline: args.offline || undefined, autoUpdate: false, verbose: true }); + updated = await db.update({ force: args.force, catalog: args.catalog }); + console.log(`Data dir: ${db.info().dataDir}`); + } if (updated.length > 0) { console.log(`\nUpdated ${updated.length} file(s): ${updated.join(', ')}`); } else { - console.log('\nAll databases already up to date.'); + console.log('\nAll reference data already up to date.'); } } catch (err) { process.stderr.write(`Error: ${err.message}\n`); @@ -120,12 +146,24 @@ async function main() { process.stderr.write(`Warning: ${w}\n`); } + let db; + try { + db = new TigerTagDB({ + dbPath, dataDir, + offline: args.offline || undefined, + autoUpdate: !args.noSync && !args.raw, + }); + } catch (err) { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(1); + } + tag._db = db; // every lookup (toRawDict / toDict / pretty) uses this database + if (args.raw) { console.log(JSON.stringify(tag.toRawDict(), null, 2)); return; } - const db = new TigerTagDB({ dbPath }); const sigResult = tag.isSigned ? tag.verify(db) : new SignatureResult(SignatureResult.UNSIGNED); diff --git a/database/id_catalog.json.gz b/database/id_catalog.json.gz new file mode 100644 index 0000000..0828197 Binary files /dev/null and b/database/id_catalog.json.gz differ diff --git a/database/id_catalog.meta.json b/database/id_catalog.meta.json new file mode 100644 index 0000000..b2f7c7c --- /dev/null +++ b/database/id_catalog.meta.json @@ -0,0 +1,9 @@ +{ + "url": "https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/refs/heads/main/database/id_catalog.json", + "count": 14164, + "fetchedAt": "2026-10-01T00:21:04.647Z", + "etag": "W/\"7449ae9629b94cbaeec7846e392006cab8a5ba061d256ec13bb1b944a0e73157\"", + "lastModified": null, + "bytes": 11827953, + "sha256": "19db0fc3101a5e30449393d81df7b6da990eb8d88aa251d0100b5943dde466a1" +} diff --git a/docs/playground-api.md b/docs/playground-api.md new file mode 100644 index 0000000..bd573d9 --- /dev/null +++ b/docs/playground-api.md @@ -0,0 +1,161 @@ +# TigerTag playground — server contract + +The TigerTag playground is **one page**, `tools/playground.html`, byte-for-byte identical in +the JavaScript SDK ([TigerTag-SDK-JS](https://github.com/TigerTag-Project/TigerTag-SDK-JS)) and the +Python SDK ([TigerTag-SDK-Python](https://github.com/TigerTag-Project/TigerTag-SDK-Python)). +Each repository ships a small server that implements the contract below with its own SDK: + +| SDK | Start | Reader support | +|---|---|---| +| JavaScript | `node tools/server.js [port]` | `npm install ws nfc-pcsc` | +| Python | `python3 tools/server.py [port]` | `pip install "tigertag[nfc]"` (pyscard) | + +Then open `http://localhost:/tools/playground.html` (default port 7432). +`TIGERTAG_PLAYGROUND_NO_NFC=1` starts either server without touching the readers. + +All request and response bodies are JSON with **snake_case** keys, except the NFC message +fields `reqId` and `pagesWritten` (kept for compatibility). Hex strings are lowercase in +responses and accepted in any case in requests. Servers may add extra keys; the page only +relies on the keys listed here. Errors are `{ "error": "" }` with a 4xx / 5xx status. + +The page must never contain SDK-specific code: everything that differs comes from +`GET /api/version`. + +## SDK identity + +### `GET /api/version` + +```json +{ "version": "1.2.0", "sdk": "javascript", "label": "JS SDK", + "repo": "https://github.com/TigerTag-Project/TigerTag-SDK-JS", + "install": "npm install tigertag", "server": "node tools/server.js", "style": "camel" } +``` + +| Key | JavaScript | Python | +|---|---|---| +| `sdk` | `javascript` | `python` | +| `label` | `JS SDK` | `Python SDK` | +| `repo` | `…/TigerTag-SDK-JS` | `…/TigerTag-SDK-Python` | +| `install` | `npm install tigertag` | `pip install tigertag` | +| `server` | `node tools/server.js` | `python3 tools/server.py` | +| `style` | `camel` (`toRawDict()`, `create({ tagCount })`) | `snake` (`to_raw_dict()`, `create(tag_count=…)`) | + +The page uses it for the header link and badge, the SDK highlighted in the GitHub list, the +`create()` code shown in SDK Input, the method names in the SDK Output tabs and every hint +that names the SDK or its server. + +## Tags + +### `POST /api/parse` + +Request: `{ "uid": "<14 hex>"?, "payload": "" }` — without a UID the +payload is parsed as a dump (`fromDump()` / `from_dump()`). + +Response: + +| Key | Content | +|---|---| +| `pretty`, `describe` | `pretty()` / `describe()` text | +| `verify` | signature result `{ ok, status, detail }` | +| `raw_dict`, `dict` | `toRawDict()` / `to_raw_dict()`, `toDict()` / `to_dict()` | +| `validate` | list of warnings | +| `tag_info`, `tag_count`, `tag_index` | byte +39 and its two nibbles (`null` when 0) | + +### `POST /api/build` + +Request: the keyword arguments of `TigerTag.create()` in snake_case — `product_id`, +`id_material`, `id_aspect_1`, `id_aspect_2`, `id_type`, `id_diameter`, `id_brand`, +`color1_r/g/b/a`, `color2_r/g/b`, `color3_r/g/b`, `measure`, `id_unit`, `nozzle_temp_min/max`, +`dry_temp`, `dry_time`, `bed_temp_min/max`, `timestamp`, `custom_message`, `td_raw`, +`tag_count`, `tag_index` — plus `uid` (hex) and `measure_available` (when it differs from +`measure`). + +Response: `{ "payload": "<80-byte hex>", "validate": [...], "tag_info", "tag_count", "tag_index" }`. +Both SDKs produce the same bytes for the same fields. + +### `POST /api/diff` + +Request: `{ "uid"?, "payload" }`. Response: +`{ "api_data": , "diffs": [{ "field", "chip_value", "api_value" }], "in_sync": bool, "error": "" }`. + +## TigerTag+ catalogue + +### `GET /api/catalog/info` + +The catalogue copy in use: `{ "downloaded", "source": "bundled" | "downloaded", "count", +"fetched_at", "checked_at", "url", "etag", "last_modified", "path", "bundled": { "path", "date" } }`. + +### `GET /api/catalog/` + +A ready-to-burn TigerTag+: +`{ "entry": { id, title, brand, sku, barcode, img_src, material, measure, product_type, color, color_info }, +"rfid_data", "fields": , "raw_dict", "payload": "<80-byte hex>", +"pretty", "describe", "catalog": }`. +Errors: 400 (not a number), 404 (not in the catalogue), 422 (no RFID data), 503 (no copy at all). + +### `POST /api/catalog/refresh` + +Checks for a new catalogue now (conditional download). Response: the catalog info; +503 `{ error, ...info }` when offline. + +## Reference tables + +### `GET /api/db/info` + +`{ "custom", "db_path", "data_dir", "offline", "auto_update", "max_age" (seconds), "last_check", +"last_error", "tables": { "id_material.json": { "source": "bundled" | "downloaded" | "custom", +"path", "timestamp" }, … }, "catalog": }`. + +### `POST /api/db/update` + +Request: `{ "force"?: bool, "catalog"?: bool }`. Response: `{ "changed": [files], "info": }`; +503 `{ error, info }` when offline or unreachable. + +### `GET /api/db/table/` + +The table in use (e.g. `id_material.json`), as served to the page; header +`X-TigerTag-Source: bundled | downloaded | custom`. 404 for an unknown file. + +## NFC readers + +### `GET /api/nfc/events` — Server-Sent Events + +Each event is `data: \n\n`; `: keep-alive` comments every 15 s. On connect the server +sends `readers:status`, then one `card:detected` per chip already on a reader. + +| `type` | Fields | +|---|---| +| `readers:status` | `readers: [{ id, name, connected, hasCard, uid }]`, `nfc` (reader support installed), `hint` (how to install it, or null), `error` | +| `reader:connected` / `reader:disconnected` | `reader: { id, name }` | +| `card:detected` | `reader`, `uid`, `payload` (hex, 144 bytes, 80 on small chips) and the `/api/parse` keys (`pretty`, `describe`, `verify`, `raw_dict`, `dict`, `validate`, `tag_info`, `tag_count`, `tag_index`) | +| `card:removed` | `reader` | +| `error` | `reader`, `message` | + +### `POST /api/nfc/read` + +Request: `{ "reqId"?, "reader"? }` (reader id / name; all readers holding a chip when omitted). +Response: `{ "messages": [ { "type": "read:result", reqId, reader, uid, payload, bytes, ok, error? }…, +{ "type": "read:done", reqId } ] }`. + +### `POST /api/nfc/burn` + +Request: `{ "reqId"?, "reader"?, "payload": "" }`. Writes pages 0x04–0x27 +(36 pages, one UPDATE BINARY per page) of every reader holding a chip, or only `reader`. +**The signature pages 0x18–0x27 are always written as `00`** — a playground never writes a +signature (only a certified manufacturer can issue one, and a copied signature is invalid since +it covers the chip UID). Pages 0–3 and 0x28+ are never touched. +Response: `{ "messages": [ { "type": "burn:result", reqId, reader, uid, ok, pagesWritten: 36, +signature_dropped, error? }…, { "type": "burn:done", reqId } ] }`; 400 for a bad length. + +### `POST /api/nfc/plan` + +Dry run, no reader touched. Request: `{ "payload" }`. Response: +`{ "signature_dropped": bool, "pages": 36, "apdus": ["FF D6 00 04 04 BC 0F CB 97", …] }`. + +## Keeping the two copies in sync + +`tools/playground.html` must stay identical in both repositories. +`scripts/check_playground_sync.js` (JavaScript) and `scripts/check_playground_sync.py` +(Python) compare it with the sibling repository's copy — the local checkout next to this one +when present, otherwise the other repository's `main` on GitHub — and fail on any difference. +Both test suites run the check and skip it when neither copy is reachable. diff --git a/llms.txt b/llms.txt index e2599b6..a524bb6 100644 --- a/llms.txt +++ b/llms.txt @@ -1,5 +1,5 @@ # TigerTag JavaScript SDK — Reference for AI Agents -# Version: 1.0.4 | Protocol: TigerTag Open Source v2.1 +# Version: 1.2.0 | Protocol: TigerTag Open Source v2.2 # npm: https://www.npmjs.com/package/tigertag # SDK: https://github.com/TigerTag-Project/TigerTag-SDK-JS # Spec: https://github.com/TigerTag-Project/TigerTag-RFID-Guide @@ -88,7 +88,7 @@ Language: JavaScript (Node.js 18+, CommonJS) Package: tigertag (npm) — published and available Install: npm install tigertag npm URL: https://www.npmjs.com/package/tigertag -Version: 1.0.4 +Version: 1.2.0 Main entry: src/index.js CLI: bin/tigertag.js (command: tigertag) License: Apache-2.0 @@ -96,7 +96,7 @@ No external runtime dependencies — Node.js built-in crypto and fetch only. Optional devDependencies for playground NFC reader support: npm install ws nfc-pcsc - Enables: ACR122U / PC-SC readers connected live to the playground via WebSocket + Enables: ACR122U / PC-SC readers connected live to the playground (Server-Sent Events + HTTP) ## PROTOCOL CONSTANTS @@ -141,7 +141,7 @@ Offset Size JS property Type Description 0x24 1 color2R u8 0x25 1 color2G u8 0x26 1 color2B u8 -0x27 1 (padding) 0x00 +0x27 1 tagInfo u8 high nibble = tag index, low nibble = tag count (0x12 = tag 1 of 2, 0x00 = unknown) 0x28 1 color3R u8 0x29 1 color3G u8 0x2A 1 color3B u8 @@ -176,6 +176,8 @@ TigerTag.create({ ...fields }) Returns a new TigerTag with ID_TIGERTAG and MAKER_PRODUCT_ID. Optional `measureAvailable` parameter now accepted — pass to encode partial spools correctly (defaults to `measure` when omitted, i.e. full spool). + Optional `tagCount` / `tagIndex` (0–15, default 0 = unknown) are encoded into tagInfo + (byte +39): tagInfo = tagIndex << 4 | tagCount, so the hex reads "index/count". Out-of-range values throw RangeError. TigerTag.asInit(uid?) Returns a blank TigerTag Init chip ready for programming. @@ -198,6 +200,22 @@ TigerTag.fromCloudDoc(doc, db?) transparently to the chip integer format: tdRaw = Math.round(doc.TD * 10). Reading from chip is also transparent: tag.tdValue = tag.tdRaw / 10. +await TigerTag.fromCatalog(productId, { catalog?, uid?, tagCount?, tagIndex?, timestamp?, db?, + url?, cacheDir?, maxAge?, force? }) → Promise + TigerTag+ (idTigertag = ID_TIGERTAG_PLUS, idProduct = productId) from the official catalogue + id_catalog.json (raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/refs/heads/main/database/id_catalog.json, + 14 000+ products, ~12 MB, NOT bundled: downloaded on first use, cached in the user cache dir + ~/Library/Caches/tigertag | %LOCALAPPDATA%\tigertag | $XDG_CACHE_HOME/tigertag | ~/.cache/tigertag, + override TIGERTAG_CACHE_DIR). Errors: unknown ID, product without RFID_Data, offline with no cache. +TigerTag.fromCatalogEntry(entry, { uid?, tagCount?, tagIndex?, timestamp?, db? }) → TigerTag (sync) + RFID_Data mapping: data1=idDiameter, data2/data3=nozzleTempMin/Max, data4/data5=dryTemp/dryTime, + data6/data7=bedTempMin/Max; id_aspect2 null → 0 (none); null → 0; colour 2/3 from + color_r2…b3 when present, else color_info.colors[1]/[2] ("#RRGGBBAA"). +await loadCatalog({ url?, cacheDir?, maxAge? (1 day = 24 h), force?, timeout?, fetch? }) → Map +await refreshCatalog(options?) → Map (force check; ETag/Last-Modified → 304 keeps the cache) +catalogInfo({ cacheDir? }) → { downloaded, count, fetchedAt, checkedAt, url, etag, lastModified, cacheFile } +await catalogEntry(productId, options?) → entry | null (title, brand, sku, barcode, img_src, material, measure…) + ## INSTANCE METHODS @@ -219,9 +237,15 @@ tag.toRawDict() → raw protocol fields (flat snake_case (num_colors derived from aspect DB: Basic→1, Bicolor→2, Tricolor→3). num_colors: int — number of active color slots. color_list: string[] — #RRGGBB hex for active slots only. + tag_info: int — raw u8 at +39 (tag index << 4 | tag count). tag.patch({ ...fields }) → new TigerTag (immutable update, camelCase keys) + tagCount / tagIndex (or tagInfo) are patchable; the nibble not + supplied is kept. tagInfo is not signed — patching it never + invalidates the ECDSA signature. tag.patchFromRawDict(raw) → new TigerTag (immutable update, snake_case keys from toRawDict()) tag.validate() → string[] warnings + Tag checks: tag index/count outside 0–15, index > count (count > 0), + count == 1 with index > 1. Count 0 with index > 0 is accepted. tag.verify(db?) → SignatureResult tag.pretty(db?, sigResult?) → human-readable string Quantity section: "1 kg (= 1000 g)" / "0.75 kg (= 750 g) (75% remaining)" @@ -246,6 +270,15 @@ tag.isSigned boolean — signatureR or signatureS non-zero tag.uidHex string | null — "04A1B2C3D4E5F6" tag.color1Hex string — "#RRGGBB" tag.tdValue number — tdRaw / 10 +tag.tagInfo number — raw u8 at +39 (protocol v2.2). 0x00 = unknown (all tags written before v2.2) +tag.tagIndex number — tagInfo >> 4 (high nibble): which tag this one is, from 1. 0 unknown +tag.tagCount number — tagInfo & 0x0F (low nibble): TigerTags on the item (what it is comes from idType). 0 unknown, 1 single tag, 2 twin tag, … 15 + The hex reads "index/count": 0x11 single tag (1/1), 0x12 / 0x22 twin tag, tag 1 / 2 of 2, + 0x02 twin tag with index unknown. + All tags of an item share the same tagCount and the same timestamp (twin tag pairing id). + describe() adds "Tag 1 of 2 on this filament." (idType label lowercased; "item" when unknown). + toDict() exposes tag_count / tag_index (null when 0) next to twin_tag_pairing_id. + pretty() prints "Tag 1 of 2" ("?" for unknown parts, " (unknown)" when tagInfo is 0). tag.manufacturingDate Date — from timestamp field (seconds since 2000-01-01 UTC) tag.stockPercent number | null — measureAvailable/measure × 100 tag.productPageUrl string | null — TigerTag+ product page URL @@ -259,9 +292,23 @@ idTigertag, idProduct, uid, signatureR, signatureS ## TigerTagDB -new TigerTagDB({ dbPath?, autoSync?, verbose? }) - dbPath: path to database folder (default: bundled) - autoSync: (ignored in JS — constructor is sync; call db.sync() explicitly) +new TigerTagDB({ dbPath?, dataDir?, offline?, autoUpdate? (true), autoSync? (deprecated alias), + maxAge? (1 day), verbose? (false), fetch? }) + Synchronous, never waits for the network. Per table: dbPath (custom folder) is used EXCLUSIVELY + (missing file → Error, no fallback, no automatic network call); otherwise the newest of the + downloaded copy in the data dir and the bundled copy (by last_update.json timestamps). + Data dir: dataDir | TIGERTAG_DATA_DIR | TIGERTAG_CACHE_DIR | per-user cache folder/tigertag. + autoUpdate: ONE background check per process, at most once per maxAge (state in db_state.json, + retried after 1 h on failure): one request to api.tigertag.io/api:tigertag/all/last_update + (GitHub mirror fallback), only changed tables downloaded into the data dir, ~5 s timeout, never + throws; the instance reloads when done (await db.ready). + offline: true or TIGERTAG_OFFLINE=1 → zero network calls. +await TigerTagDB.open(options?) → TigerTagDB (same check, awaited before returning) +await db.update({ force?, catalog? }) → string[] changed files (throws when offline / unreachable) +db.info() → { offline, autoUpdate, maxAge, dataDir, customDir, lastCheck, lastAttempt, lastError, + tables: { : { file, source: 'custom'|'downloaded'|'bundled', path, timestamp } }, + catalog: catalogInfo() } +db.ready → Promise (background check finished) db.version(id) → object | null db.material(id) → object | null @@ -270,7 +317,7 @@ db.type(id) → object | null db.diameter(id) → object | null db.brand(id) → object | null db.unit(id) → object | null -db.sync(force?) → Promise +db.sync(force?) → Promise (alias of update) TigerTagDB.label(entry) → string (entry.label || entry.name || 'Unknown') @@ -287,7 +334,7 @@ result.ok boolean result.status string — one of the constants above result.detail string — human-readable detail result.toDict() → { ok, status, detail } -String(result) → "✅ VALID" | "❌ INVALID" | "⬜ NOT SIGNED" | ... +String(result) → "VALID" | "INVALID" | "NOT SIGNED" | "NO PUBLIC KEY — …" | "NO UID — …" | "NO CRYPTO — …" ## ApiDiff @@ -322,6 +369,7 @@ const { SignatureResult, ApiDiff, syncDatabases, + loadCatalog, refreshCatalog, catalogInfo, catalogEntry, catalogCacheDir, CATALOG_URL, ID_TIGERTAG, ID_TIGERTAG_PLUS, ID_TIGERTAG_INIT, @@ -343,15 +391,24 @@ Five panels: SDK Input (collapsible, right of center) — shows TigerTag.create({...}) call for the current tag (write flow). Opens automatically when Burn is clicked. Rail on left, expands rightward. SDK Output (collapsible, rightmost) — shows pretty(), describe(), verify(), toRawDict(), toDict(), rawApi(), diffApi() (read flow). + Tag index / Tag count inputs (0–15, 0 = unknown) write byte +39; the decoded view shows a "Tag" row + and a "Validation" card lists validate() warnings (returned by /api/parse as `validate`). + SDK Input tabs: create() | HEX (the 144 bytes Burn writes, pages 0x04–0x27) | Pages (page-by-page, write order). + Twin tag mode: 2+ readers holding a chip → one payload per reader (same Timestamp, tagCount n, tagIndex 1…n); + burn:write accepts an optional `reader` filter so each reader receives its own payload. + TigerTag+ tab: "Load from catalogue" (Product ID → GET /api/catalog/ → { entry, rfid_data, payload, + fields, pretty }) fills the whole form, shows title / brand / SKU / image, switches to Burn and builds + the preview (twin tag with 2 readers). "Update catalogue" → POST /api/catalog/refresh; status line from + GET /api/catalog/info ("Catalogue: 14 164 products · updated " or "not downloaded yet"). Opens automatically on Generate / NFC scan / Import. Rail on left, expands rightward. Both SDK rails are adjacent; click either to expand or collapse. - Raw Hex (modal, opened via 🔬 Raw Read button) — reads 144 bytes (pages 4–39) from all + Raw Hex (modal, opened via the Raw Read button) — reads 144 bytes (pages 4–39) from all card-holding readers. Displays a structured hex table: page (decimal), offset (byte), page (hex: 0x04–0x27), B0–B3, u32 BE, annotated field label "(value) field_name · …" decoded live from raw bytes. customMessage pages show decoded ASCII chars. Signature pages (24–39) dimmed, static labels only. Multiple readers shown side-by-side, each in its own collapsible panel. Copy hex button per panel: outputs one line per page as - "0x04 B0 B1 B2 B3\n…" with ✓ Copied! animation feedback (1.5 s green state). + "0x04 B0 B1 B2 B3\n…" with "Copied" feedback (1.5 s green state). Panel state is context-aware: Generate / NFC scan / Import → opens SDK Output, closes SDK Input @@ -359,36 +416,38 @@ Panel state is context-aware: Raw Read → opens Raw Hex modal (multiple reader panels, side-by-side) ACR122U / PC-SC reader integration (requires: npm install ws nfc-pcsc): - - WebSocket server on same port as HTTP (ws://localhost:7432) - - Multiple simultaneous USB readers supported - - Card events: card:detected, card:removed, reader:connected, reader:disconnected - - On card placement: reads 144 bytes (pages 4–39), broadcasts to all playground clients - - Playground auto-populates form and displays full SDK output — no manual action needed - - Reader status shown in header: green dot = connected, orange pulse = card reading - -Burn (write) via WS: - - Client sends: { type: "burn:write", payload: "", reqId: "" } - - Server writes pages 4–23 (80 bytes, 20 pages × 4 bytes) to every reader with a card - - Server replies: { type: "burn:result", reqId, reader, uid, ok, pagesWritten|error } per reader - { type: "burn:done", reqId } when all readers processed - - Payload is always generated via POST /api/build before burn — SDK is the authoritative serializer - -Raw Read via WS: - - Client sends: { type: "read:request", reqId: "" } - - Server replies: { type: "read:result", reqId, reader, uid, payload: "<288-char hex>", bytes, ok } per reader - { type: "read:done", reqId } when all readers processed + The page is shared with the Python SDK (identical tools/playground.html) and talks to either + server through the contract in docs/playground-api.md: + - GET /api/version → { version, sdk: "javascript", label: "JS SDK", repo, install, server, style: "camel" } + - GET /api/nfc/events (Server-Sent Events): readers:status, reader:connected / reader:disconnected, + card:detected (reads 144 bytes, pages 4–39, with pretty / describe / verify / raw_dict / dict / + validate / tag_*), card:removed, error; chips already on the readers are replayed on connect + - POST /api/nfc/read { reqId?, reader? } → { messages: [read:result…, read:done] } + - POST /api/nfc/burn { reqId?, reader?, payload } → { messages: [burn:result { pagesWritten: 36, + signature_dropped }…, burn:done] }. Pages 0x04–0x27; the tag data goes to 0x04–0x17 and the + signature pages 0x18–0x27 are ALWAYS written as 00 00 00 00 — playgrounds never write a signature + (only a certified manufacturer can issue one). Pages 0–3 and 0x28+ are never touched. + Page plan: tools/burn_plan.js. + - POST /api/nfc/plan { payload } → { signature_dropped, pages: 36, apdus: ["FF D6 00 04 04 …", …] } + - POST /api/build takes the create() fields in snake_case (product_id, id_aspect_1, color1_r, + tag_count, measure_available…) — the SDK is always the authoritative serializer + - Legacy WebSocket on the same port (read:request / burn:write) is kept for older pages + - TIGERTAG_PLAYGROUND_NO_NFC=1 starts the server without touching the readers + - scripts/check_playground_sync.js fails when the page differs from the Python SDK's copy - Server tries 144 bytes first, falls back to 80 bytes for smaller chips - Each reader panel is created dynamically on first read:result for that reader Form behaviour: - - `timestamp: null` is always passed to TigerTag.create() — SDK sets its own write-time timestamp + - Generate computes one timestamp (shown read-only as "Manufacturing date") and passes it to create(); + every tag of a twin tag gets the same value. Available above Initial qty is refused. - Available qty auto-links to Initial Qty: changes to Initial Qty propagate to Available automatically, until the user manually edits Available. Link is restored on preset load, API fetch, or NFC scan. Server endpoints: - GET /api/version → { version: string } (from package.json, used by playground badge) - POST /api/parse → { pretty, describe, verify, raw_dict, dict } full SDK parse of { uid, payload } hex - POST /api/build → { payload: hex } TigerTag.create(kwargs).toBytes() — SDK-authoritative; playground always calls this before burn + Full contract (same on the Python server): docs/playground-api.md + GET /api/version → { version, sdk, label, repo, install, server, style } (header, names, create() code style) + POST /api/parse → { pretty, describe, verify, raw_dict, dict, validate, tag_info, tag_count, tag_index } of { uid?, payload } hex + POST /api/build → { payload: hex, validate, tag_* } TigerTag.create(snake_case fields) — SDK-authoritative; playground always calls this before burn POST /api/diff → { api_data, diffs, in_sync, error } (server-side diffApi) @@ -397,8 +456,10 @@ Server endpoints: tigertag dump.bin # parse + human-readable output tigertag dump.bin --json # output as JSON tigertag dump.bin --raw # raw protocol fields, no DB lookup -tigertag dump.bin --db /path # use custom database folder -tigertag --sync-only # update reference databases and exit +tigertag dump.bin --db /path # use custom database folder (exclusively) +tigertag dump.bin --offline # no network call at all +tigertag update [--force] [--catalog] [--data-dir PATH | --db PATH] # update reference data now +tigertag --sync-only # same as "tigertag update" tigertag --version # show version @@ -412,9 +473,14 @@ database/id_diameter.json — diameter IDs (1.75mm, 2.85mm…) database/id_brand.json — brand IDs (eSun, Rosa3D, Bambu Lab, Polymaker…) database/id_measure_unit.json — unit IDs (grams, kg, ml, L…) database/last_update.json — sync timestamps +database/id_catalog.json.gz — product catalogue (gzip, ~1 MB; gunzipped at load) + id_catalog.meta.json -All files are arrays of objects with an "id" field. Lookup is by linear scan. -Bundled versions work 100% offline. Call syncDatabases() or db.sync() to update. +All table files are arrays of objects with an "id" field. Lookup is by linear scan. +Bundled versions work 100% offline and are refreshed at every release (publish.yml runs +scripts/sync_databases.js before npm publish; sync-databases.yml refreshes them daily). +Fresher copies are downloaded into the data dir automatically (daily) or with +db.update() / `tigertag update [--force] [--catalog] [--data-dir PATH | --db PATH]`. +Playground server: GET /api/db/info, POST /api/db/update ({ force, catalog }). ## NOTES FOR AI CODE GENERATION @@ -442,6 +508,9 @@ Bundled versions work 100% offline. Call syncDatabases() or db.sync() to update. uid_hex → uidHex color1_hex → color1Hex td_value → tdValue + tag_info → tagInfo + tag_count → tagCount + tag_index → tagIndex manufacturing_date → manufacturingDate stock_percent → stockPercent 9. `TigerTag.create()` accepts `measureAvailable` as an optional parameter to encode partial spool diff --git a/package-lock.json b/package-lock.json index 3781621..afe0bac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tigertag", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tigertag", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "bin": { "tigertag": "bin/tigertag.js" diff --git a/package.json b/package.json index db121cf..10c4dbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tigertag", - "version": "1.1.0", + "version": "1.2.0", "description": "JavaScript SDK for TigerTag RFID material identification", "main": "src/index.js", "bin": { @@ -53,5 +53,14 @@ "optionalDependencies": { "nfc-pcsc": "^0.8.1", "ws": "^8.20.1" + }, + "jest": { + "setupFiles": [ + "/test/setup-env.js" + ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/test/setup-env.js" + ] } } diff --git a/scripts/check_playground_sync.js b/scripts/check_playground_sync.js new file mode 100644 index 0000000..3da1dba --- /dev/null +++ b/scripts/check_playground_sync.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +'use strict'; + +// tools/playground.html must be byte-for-byte identical in the JavaScript and Python SDKs +// (docs/playground-api.md). Compares this copy with the sibling repository's copy: the local +// checkout next to this one when present (or TIGERTAG_SIBLING_REPO), otherwise the other +// repository's main branch on GitHub. +// +// node scripts/check_playground_sync.js → exit 0 identical, 1 different, 2 sibling unreachable + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const LOCAL = path.join(ROOT, 'tools', 'playground.html'); +const SIBLING_DIRS = ['tigertag-sdk-python', 'TigerTag-SDK-Python']; +const SIBLING_RAW = 'https://raw.githubusercontent.com/TigerTag-Project/TigerTag-SDK-Python/main/tools/playground.html'; + +async function siblingCopy() { + const dirs = process.env.TIGERTAG_SIBLING_REPO + ? [process.env.TIGERTAG_SIBLING_REPO] + : SIBLING_DIRS.map((d) => path.join(ROOT, '..', d)); + for (const dir of dirs) { + const file = path.join(dir, 'tools', 'playground.html'); + if (fs.existsSync(file)) return { source: file, data: fs.readFileSync(file) }; + } + if (/^(1|true|yes|on)$/i.test(process.env.TIGERTAG_OFFLINE || '')) return null; // no network call + try { + const resp = await fetch(SIBLING_RAW, { signal: AbortSignal.timeout(10000) }); + if (resp.ok) return { source: SIBLING_RAW, data: Buffer.from(await resp.arrayBuffer()) }; + } catch { /* offline */ } + return null; +} + +async function check() { + const mine = fs.readFileSync(LOCAL); + const other = await siblingCopy(); + if (!other) return { status: 'unreachable' }; + if (mine.equals(other.data)) return { status: 'identical', source: other.source }; + let line = 1; + const n = Math.min(mine.length, other.data.length); + let i = 0; + for (; i < n && mine[i] === other.data[i]; i++) if (mine[i] === 10) line++; + return { status: 'different', source: other.source, line }; +} + +module.exports = { check }; + +if (require.main === module) { + check().then((r) => { + if (r.status === 'identical') { console.log(`OK — tools/playground.html identical to ${r.source}`); process.exit(0); } + if (r.status === 'unreachable') { console.log('SKIP — sibling playground.html not reachable (no local checkout, offline)'); process.exit(2); } + console.error(`FAILED — tools/playground.html differs from ${r.source} (first difference at line ${r.line}).` + + '\nCopy the unified page to both repositories (see docs/playground-api.md).'); + process.exit(1); + }); +} diff --git a/scripts/generate_fixtures.js b/scripts/generate_fixtures.js index 12b02b5..bca615c 100644 --- a/scripts/generate_fixtures.js +++ b/scripts/generate_fixtures.js @@ -330,11 +330,11 @@ function main() { material = d.material ? TigerTagDB.label(d.material) : '—'; brand = d.brand ? TigerTagDB.label(d.brand) : '—'; status = String(sig); - ok = '✓'; + ok = 'OK'; } catch (err) { material = brand = '—'; status = `ERROR: ${err.message}`; - ok = '✗'; + ok = 'FAILED'; } const size = payload.length; @@ -343,7 +343,7 @@ function main() { const brandPad = brand.substring(0, 14).padEnd(14); console.log(` ${ok} ${namePad} ${String(size).padStart(3)}B ${matPad} ${brandPad} ${status}`); - if (ok === '✗') process.exit(1); + if (ok === 'FAILED') process.exit(1); } console.log('\nDone. Load any fixture with:'); diff --git a/scripts/sync_databases.js b/scripts/sync_databases.js index 16bc107..4544767 100644 --- a/scripts/sync_databases.js +++ b/scripts/sync_databases.js @@ -1,20 +1,49 @@ #!/usr/bin/env node 'use strict'; -const path = require('path'); -const { syncDatabases } = require('../src/index'); +// Refresh the copies bundled in the npm package (database/): +// - the 7 reference tables + last_update.json (only the changed ones), +// - the product catalogue as database/id_catalog.json.gz + id_catalog.meta.json +// (rewritten only when its content changed, so an unchanged day makes no diff). +// Run by .github/workflows/sync-databases.yml every day and by publish.yml before +// every release. -const dbPath = path.join(__dirname, '..', 'database'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { syncDatabases, CATALOG_URL } = require('../src/index'); -syncDatabases(dbPath, { force: false, verbose: true }) - .then((updated) => { - if (updated.length > 0) { - console.log(`\n✓ Updated: ${updated.join(', ')}`); - } else { - console.log('\n✓ All databases are up to date.'); - } - }) - .catch((err) => { - console.error(`Error: ${err.message}`); - process.exit(1); - }); +const dbPath = path.join(__dirname, '..', 'database'); +const gzFile = path.join(dbPath, 'id_catalog.json.gz'); +const metaFile = path.join(dbPath, 'id_catalog.meta.json'); + +async function syncCatalog() { + let meta = {}; + try { meta = JSON.parse(fs.readFileSync(metaFile, 'utf8')); } catch { /* first run */ } + const headers = fs.existsSync(gzFile) && meta.etag ? { 'If-None-Match': meta.etag } : {}; + const resp = await fetch(CATALOG_URL, { headers, signal: AbortSignal.timeout(120000) }); + if (resp.status === 304) return false; + if (!resp.ok) throw new Error(`catalogue: HTTP ${resp.status}`); + const text = await resp.text(); + const list = JSON.parse(text); + if (!Array.isArray(list) || list.length === 0) throw new Error('catalogue: expected a non-empty JSON list'); + const sha256 = crypto.createHash('sha256').update(text).digest('hex'); + if (sha256 === meta.sha256 && fs.existsSync(gzFile)) return false; + fs.writeFileSync(gzFile, zlib.gzipSync(Buffer.from(text), { level: 9 })); + fs.writeFileSync(metaFile, JSON.stringify({ + url: CATALOG_URL, count: list.length, fetchedAt: new Date().toISOString(), + etag: resp.headers.get('etag'), lastModified: resp.headers.get('last-modified'), + bytes: Buffer.byteLength(text), sha256, + }, null, 2) + '\n'); + return true; +} + +(async () => { + const updated = await syncDatabases(dbPath, { force: false, verbose: true }); + if (await syncCatalog()) updated.push('id_catalog.json.gz', 'id_catalog.meta.json'); + console.log(updated.length ? `\nOK — updated: ${updated.join(', ')}` : '\nOK — all databases are up to date.'); +})().catch((err) => { + console.error(`Error: ${err.message}`); + process.exit(1); +}); diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 0000000..0737d41 --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,285 @@ +'use strict'; + +// SPDX-License-Identifier: Apache-2.0 +// +// TigerTag SDK +// Copyright (c) 2025-2026 TigerTag Corp. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Implementing the TigerTag protocol requires no licence and no payment. +// https://github.com/TigerTag-Project/TigerTag-RFID-Guide/blob/main/LICENSING.md + +/** + * TigerTag product catalogue (id_catalog.json) — loader, cache and lookup. + * + * The official catalogue lists every TigerTag+ product with the exact data to + * write on the chip (`RFID_Data`) plus display metadata (title, brand, SKU, + * barcode, image). A gzip copy ships in the npm package + * (database/id_catalog.json.gz, refreshed at every release) so it works offline; + * a fresher copy is downloaded into the data dir (shared with the reference + * tables) once the local copy is older than `maxAge` (1 day). + * + * @example + * const { loadCatalog, catalogEntry, TigerTag } = require('tigertag'); + * const tag = await TigerTag.fromCatalog(3527039449); // ready to burn + * const entry = await catalogEntry(3527039449); // title, brand, sku, img_src… + */ + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { defaultDataDir, resolveOffline, BUNDLED_DIR } = require('./datadir'); + +/** Official catalogue URL (GitHub raw, CORS *). */ +const CATALOG_URL = + 'https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/refs/heads/main/database/id_catalog.json'; + +/** Default cache lifetime: 1 day (24 h) — the catalogue changes every day. */ +const DEFAULT_MAX_AGE_MS = 24 * 3600 * 1000; + +const _CACHE_FILE = 'id_catalog.json'; +const _META_FILE = 'id_catalog.meta.json'; +const _BUNDLED_FILE = 'id_catalog.json.gz'; +const _memo = new Map(); // key → Promise (one parse per process) + +/** + * Default folder for the downloaded catalogue: the SDK data dir + * (TIGERTAG_DATA_DIR, TIGERTAG_CACHE_DIR, or the per-user cache folder). + * + * @returns {string} + */ +function defaultCacheDir() { return defaultDataDir(); } + +function _toMap(list) { + if (!Array.isArray(list)) throw new Error('TigerTag catalogue: expected a JSON list of products'); + const map = new Map(); + for (const entry of list) { + if (entry && entry.id != null) map.set(Number(entry.id), entry); + } + return map; +} + +function _readMeta(dir) { + try { return JSON.parse(fs.readFileSync(path.join(dir, _META_FILE), 'utf8')); } catch { return null; } +} + +function _writeMeta(dir, meta) { + try { fs.writeFileSync(path.join(dir, _META_FILE), JSON.stringify(meta, null, 2)); } catch { /* read-only */ } +} + +// The local copies: downloaded (data dir) and bundled (.gz in the package), with their dates +function _localCopies(dataDir, bundledDir) { + const out = { downloaded: null, bundled: null }; + const file = path.join(dataDir, _CACHE_FILE); + try { + const st = fs.statSync(file); + const meta = _readMeta(dataDir) || {}; + out.downloaded = { + source: 'downloaded', file, meta, + fetchedAt: meta.fetchedAt || st.mtime.toISOString(), + checkedMs: st.mtimeMs, + read: () => fs.readFileSync(file, 'utf8'), + }; + } catch { /* none */ } + if (bundledDir) { + const gz = path.join(bundledDir, _BUNDLED_FILE); + try { + const st = fs.statSync(gz); + const meta = _readMeta(bundledDir) || {}; + const fetchedAt = meta.fetchedAt || st.mtime.toISOString(); + out.bundled = { + source: 'bundled', file: gz, meta, fetchedAt, + checkedMs: Date.parse(fetchedAt), + read: () => zlib.gunzipSync(fs.readFileSync(gz)).toString('utf8'), + }; + } catch { /* none */ } + } + return out; +} + +function _newest(copies) { + const list = [copies.downloaded, copies.bundled].filter(Boolean); + if (!list.length) return null; + return list.reduce((a, b) => (Date.parse(b.fetchedAt) > Date.parse(a.fetchedAt) ? b : a)); +} + +// Conditional GET: ETag / Last-Modified from the previous download → 304 skips the 12 MB body +async function _download(url, fetchImpl, timeoutMs, prev) { + const f = fetchImpl || globalThis.fetch; + if (typeof f !== 'function') throw new Error('fetch() is not available (Node.js 18+ required)'); + const headers = {}; + if (prev && prev.etag) headers['If-None-Match'] = prev.etag; + if (prev && prev.lastModified) headers['If-Modified-Since'] = prev.lastModified; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await f(url, { signal: controller.signal, headers }); + const h = (name) => (resp.headers && typeof resp.headers.get === 'function' ? resp.headers.get(name) : null) || null; + if (resp.status === 304) return { notModified: true, etag: h('etag') || prev.etag, lastModified: h('last-modified') || prev.lastModified }; + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return { text: await resp.text(), etag: h('etag'), lastModified: h('last-modified') }; + } finally { + clearTimeout(timer); + } +} + +/** + * Load the TigerTag catalogue as a Map productId → entry. + * + * Copies: the downloaded one in the data dir and the gzip one bundled in the + * package; the newest is used. When it is older than `maxAge` (or `force`), a + * conditional download checks for a new version (an unchanged catalogue answers + * 304 — nothing is re-downloaded). Offline (`offline: true` or + * TIGERTAG_OFFLINE=1), or when the download fails, the newest local copy is used + * with zero / no further network calls. Throws only when there is no copy at all. + * + * @param {object} [options] + * @param {string} [options.url=CATALOG_URL] - Catalogue URL. + * @param {string} [options.dataDir] - Folder for the downloaded copy (default: the SDK data dir). + * @param {string} [options.cacheDir] - Alias of dataDir. + * @param {boolean} [options.offline] - true = zero network calls (default: TIGERTAG_OFFLINE env). + * @param {number} [options.maxAge=1 day] - Max age in ms of the local copy before checking for a new version. + * @param {boolean} [options.force=false] - Check for a new version now (ignore memory and age). + * @param {number} [options.timeout=60000] - Download timeout in ms. + * @param {Function} [options.fetch] - fetch implementation (default: global fetch). + * @param {string|null} [options.bundledDir] - Folder of the bundled .gz (default: the package database/; + * null = ignore the bundled copy). + * @returns {Promise>} + */ +function loadCatalog({ + url = CATALOG_URL, dataDir, cacheDir, offline, maxAge = DEFAULT_MAX_AGE_MS, + force = false, timeout = 60000, fetch: fetchImpl = null, bundledDir = BUNDLED_DIR, +} = {}) { + const dir = path.resolve(dataDir || cacheDir || defaultDataDir()); + const isOffline = resolveOffline(offline); + const key = `${url}|${dir}|${bundledDir}|${isOffline}`; + if (!force && _memo.has(key)) return _memo.get(key); + const p = (async () => { + const copies = _localCopies(dir, bundledDir); + const newest = _newest(copies); + const readNewest = () => _toMap(JSON.parse(newest.read())); + if (isOffline) { + if (newest) return readNewest(); + throw new Error(`TigerTag catalogue unavailable offline: no local copy in ${dir} and no bundled copy.`); + } + const lastChecked = Math.max(copies.downloaded ? copies.downloaded.checkedMs : 0, copies.bundled ? copies.bundled.checkedMs : 0); + if (!force && newest && maxAge > 0 && Date.now() - lastChecked < maxAge) return readNewest(); + + const prevMeta = copies.downloaded && copies.downloaded.meta.url === url ? copies.downloaded.meta + : copies.bundled && (!copies.downloaded || newest === copies.bundled) ? copies.bundled.meta : null; + let res; + try { + res = await _download(url, fetchImpl, timeout, prevMeta); + } catch (err) { + if (newest) return readNewest(); // stale but usable + throw new Error( + `TigerTag catalogue unavailable: could not download ${url} (${err.message}) ` + + `and there is no local copy in ${dir}. An internet connection is needed the first time.`, + ); + } + const now = new Date().toISOString(); + const file = path.join(dir, _CACHE_FILE); + let text = res.text; + let fetchedAt = now; + if (res.notModified) { + // Unchanged: keep (or materialise) the copy the ETag came from, record the check + const from = prevMeta === (copies.downloaded && copies.downloaded.meta) ? copies.downloaded : copies.bundled; + text = from.read(); + fetchedAt = from.fetchedAt; + } + const map = _toMap(JSON.parse(text)); + try { + fs.mkdirSync(dir, { recursive: true }); + if (!(res.notModified && copies.downloaded && prevMeta === copies.downloaded.meta)) fs.writeFileSync(file, text); + else fs.utimesSync(file, new Date(), new Date()); + _writeMeta(dir, { + url, count: map.size, fetchedAt, checkedAt: now, + etag: res.etag || null, lastModified: res.lastModified || null, bytes: Buffer.byteLength(text), + }); + } catch { /* read-only data dir: keep the in-memory copy */ } + return map; + })(); + _memo.set(key, p); + p.catch(() => _memo.delete(key)); // a failed load is retried next time + return p; +} + +/** + * Check for a new catalogue version now (conditional download) and reload it. + * Same options as loadCatalog(), with `force: true`. + * + * @param {object} [options] + * @returns {Promise>} + */ +function refreshCatalog(options = {}) { + return loadCatalog({ ...options, force: true }); +} + +/** + * Which catalogue copy is used, without loading it. + * + * @param {object} [options] + * @param {string} [options.dataDir] - Data dir (default: the SDK data dir). `cacheDir` is an alias. + * @param {string|null} [options.bundledDir] - Bundled folder (default: package database/; null = ignore). + * @returns {{ downloaded: boolean, source: 'downloaded'|'bundled'|null, count: number|null, + * fetchedAt: string|null, checkedAt: string|null, url: string|null, etag: string|null, + * lastModified: string|null, cacheFile: string, bundled: object|null }} + * `source` = the copy in use (the newest), `fetchedAt` = when that copy was downloaded, + * `checkedAt` = last check for a new version, `downloaded` = a copy exists in the data dir. + */ +function catalogInfo({ dataDir, cacheDir, bundledDir = BUNDLED_DIR } = {}) { + const dir = path.resolve(dataDir || cacheDir || defaultDataDir()); + const copies = _localCopies(dir, bundledDir); + const newest = _newest(copies); + const cacheFile = path.join(dir, _CACHE_FILE); + const b = copies.bundled ? { file: copies.bundled.file, fetchedAt: copies.bundled.fetchedAt, count: copies.bundled.meta.count ?? null } : null; + if (!newest) { + return { downloaded: false, source: null, count: null, fetchedAt: null, checkedAt: null, url: null, etag: null, lastModified: null, cacheFile, bundled: b }; + } + const m = newest.meta || {}; + const dm = copies.downloaded ? copies.downloaded.meta : {}; + return { + downloaded: !!copies.downloaded, + source: newest.source, + count: m.count ?? null, + fetchedAt: newest.fetchedAt, + checkedAt: dm.checkedAt || (copies.downloaded ? new Date(copies.downloaded.checkedMs).toISOString() : null), + url: m.url || null, + etag: m.etag || null, + lastModified: m.lastModified || null, + cacheFile, + bundled: b, + }; +} + +/** + * Look up one catalogue product. + * + * @param {number} productId - TigerTag+ product ID. + * @param {object} [options] + * @param {Map} [options.catalog] - Already loaded catalogue; otherwise loadCatalog(options). + * @returns {Promise} The catalogue entry (title, brand, sku, barcode, img_src, RFID_Data…) or null. + */ +async function catalogEntry(productId, options = {}) { + const catalog = options.catalog || await loadCatalog(options); + return catalog.get(Number(productId)) || null; +} + +/** Clear the in-memory catalogue copies (the disk cache is kept). */ +function clearCatalogMemo() { _memo.clear(); } + +module.exports = { + CATALOG_URL, DEFAULT_MAX_AGE_MS, defaultCacheDir, + loadCatalog, refreshCatalog, catalogInfo, catalogEntry, clearCatalogMemo, +}; diff --git a/src/datadir.js b/src/datadir.js new file mode 100644 index 0000000..1a46463 --- /dev/null +++ b/src/datadir.js @@ -0,0 +1,65 @@ +'use strict'; + +// SPDX-License-Identifier: Apache-2.0 +// +// TigerTag SDK +// Copyright (c) 2025-2026 TigerTag Corp. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Implementing the TigerTag protocol requires no licence and no payment. +// https://github.com/TigerTag-Project/TigerTag-RFID-Guide/blob/main/LICENSING.md + +/** + * Where the SDK keeps downloaded reference data (tables + catalogue), and the + * global offline switch. + */ + +const os = require('os'); +const path = require('path'); + +/** + * Folder holding the downloaded fresh copies of the reference tables and the + * catalogue. Precedence: `TIGERTAG_DATA_DIR`, then `TIGERTAG_CACHE_DIR`, then the + * platform cache folder (`~/Library/Caches`, `%LOCALAPPDATA%`, `$XDG_CACHE_HOME` + * or `~/.cache`) + `/tigertag`. + * + * @returns {string} + */ +function defaultDataDir() { + if (process.env.TIGERTAG_DATA_DIR) return process.env.TIGERTAG_DATA_DIR; + if (process.env.TIGERTAG_CACHE_DIR) return process.env.TIGERTAG_CACHE_DIR; + const home = os.homedir(); + let base; + if (process.platform === 'darwin') base = path.join(home, 'Library', 'Caches'); + else if (process.platform === 'win32') base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + else base = process.env.XDG_CACHE_HOME || path.join(home, '.cache'); + return path.join(base, 'tigertag'); +} + +/** + * Resolve the offline switch: an explicit option wins, otherwise the + * `TIGERTAG_OFFLINE` environment variable (`1`, `true`, `yes`, `on`). + * + * @param {boolean} [option] + * @returns {boolean} + */ +function resolveOffline(option) { + if (option === true || option === false) return option; + return /^(1|true|yes|on)$/i.test(String(process.env.TIGERTAG_OFFLINE || '')); +} + +/** Folder of the copies shipped inside the npm package. */ +const BUNDLED_DIR = path.join(__dirname, '..', 'database'); + +module.exports = { defaultDataDir, resolveOffline, BUNDLED_DIR }; diff --git a/src/db.js b/src/db.js index 66e02f3..df65a91 100644 --- a/src/db.js +++ b/src/db.js @@ -21,15 +21,42 @@ // https://github.com/TigerTag-Project/TigerTag-RFID-Guide/blob/main/LICENSING.md /** - * TigerTag reference database loader and sync utilities. + * TigerTag reference tables (id_*.json): loader, precedence, automatic and + * manual updates. + * + * Where each table comes from (per file): + * 1. a custom folder (`dbPath`) — used EXCLUSIVELY: a missing file is an error, + * there is no fallback, and no network call happens unless update() is called; + * 2. otherwise the newest of + * - the downloaded copy in the data dir (`dataDir`, `TIGERTAG_DATA_DIR`, + * default: the per-user cache folder shared with the catalogue), and + * - the copy bundled in the npm package (always present), + * compared by their last_update.json timestamps. + * + * Updates: `new TigerTagDB()` never waits for the network. With autoUpdate on + * (default) it starts ONE background check per process, at most once per + * `maxAge` (default 1 day): one request to the TigerTag API (GitHub mirror as + * fallback), only the changed tables are downloaded into the data dir, ~5 s + * timeout, errors are swallowed. `await TigerTagDB.open()` does the same check + * before returning. `await db.update()` forces it. `offline: true` (or + * `TIGERTAG_OFFLINE=1`) means zero network calls. */ const fs = require('fs'); const path = require('path'); +const { defaultDataDir, resolveOffline, BUNDLED_DIR } = require('./datadir'); const _API_BASE = 'https://api.tigertag.io/api:tigertag'; const _GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/main/database'; -const _HTTP_TIMEOUT = 30000; +const _MANUAL_TIMEOUT = 30000; +const _AUTO_TIMEOUT = 5000; +const _RETRY_AFTER_FAILURE_MS = 3600 * 1000; // a failed automatic check is retried after 1 h + +/** Default interval between two automatic checks: 1 day. */ +const DEFAULT_UPDATE_MAX_AGE_MS = 24 * 3600 * 1000; + +const _LAST_UPDATE = 'last_update.json'; +const _STATE_FILE = 'db_state.json'; // Maps last_update key → [API endpoint, local filename] const _DATASETS = { @@ -42,202 +69,351 @@ const _DATASETS = { measure_units: ['measure_unit/get/all', 'id_measure_unit.json'], }; -const _BUNDLED_DB_PATH = path.join(__dirname, '..', 'database'); +// Instance property holding each table +const _PROPS = { + versions: '_versions', types: '_types', brands: '_brands', filament_diameters: '_diameters', + filament_materials: '_materials', aspects: '_aspects', measure_units: '_units', +}; -/** - * Download or update TigerTag reference JSON databases. - * Tries the live TigerTag API first; falls back to the GitHub mirror. - * Only downloads files whose timestamp has changed. - * - * @param {string} [dbPath] - Folder where JSON files are stored (created if missing). - * @param {object} [options] - * @param {boolean} [options.force=false] - Re-download all files even if up to date. - * @param {boolean} [options.verbose=true] - Print progress to stdout. - * @returns {Promise} List of filenames that were downloaded/updated. - */ -async function syncDatabases(dbPath, { force = false, verbose = true } = {}) { - const resolvedPath = path.resolve(dbPath || _BUNDLED_DB_PATH); - fs.mkdirSync(resolvedPath, { recursive: true }); +const _BUNDLED_DB_PATH = BUNDLED_DIR; +const _backgroundChecks = new Map(); // dataDir → Promise (one automatic check per process) - const lastUpdatePath = path.join(resolvedPath, 'last_update.json'); +function _readJson(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } +} - const _log = verbose ? (msg) => console.log(msg) : () => {}; +function _writeJson(file, data) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8'); +} - async function _get(url) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), _HTTP_TIMEOUT); - try { - const resp = await fetch(url, { signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const text = await resp.text(); - return { data: JSON.parse(text), text }; - } finally { - clearTimeout(timer); - } +async function _getJson(url, fetchImpl, timeout) { + const f = fetchImpl || globalThis.fetch; + if (typeof f !== 'function') throw new Error('fetch() is not available (Node.js 18+ required)'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const resp = await f(url, { signal: controller.signal }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return JSON.parse(await resp.text()); + } finally { + clearTimeout(timer); } +} - let remoteData, remoteText, datasetUrlFn; - +// One request for every table timestamp: TigerTag API first, GitHub mirror as fallback +async function _remoteLastUpdate(fetchImpl, timeout, log) { try { - const result = await _get(`${_API_BASE}/all/last_update`); - remoteData = result.data; - remoteText = result.text; - datasetUrlFn = (endpoint, _filename) => `${_API_BASE}/${endpoint}`; - _log('[info] source: api'); + const data = await _getJson(`${_API_BASE}/all/last_update`, fetchImpl, timeout); + log('[info] source: api'); + return { data, source: 'api', url: (endpoint) => `${_API_BASE}/${endpoint}` }; } catch (exc) { - _log(`[warn] TigerTag API unreachable (${exc.message}), falling back to GitHub mirror`); + log(`[warn] TigerTag API unreachable (${exc.message}), falling back to GitHub mirror`); try { - const result = await _get(`${_GITHUB_RAW_BASE}/last_update.json`); - remoteData = result.data; - remoteText = result.text; - datasetUrlFn = (_endpoint, filename) => `${_GITHUB_RAW_BASE}/${filename}`; - _log('[info] source: github'); + const data = await _getJson(`${_GITHUB_RAW_BASE}/${_LAST_UPDATE}`, fetchImpl, timeout); + log('[info] source: github'); + return { data, source: 'github', url: (_endpoint, filename) => `${_GITHUB_RAW_BASE}/${filename}` }; } catch (exc2) { throw new Error( - `Both API and GitHub mirror are unreachable.\nAPI error: ${exc.message}\nGitHub error: ${exc2.message}\nCheck your internet connection.`, + `Both API and GitHub mirror are unreachable.\nAPI error: ${exc.message}\n` + + `GitHub error: ${exc2.message}\nCheck your internet connection.`, ); } } +} - let localData = {}; - if (fs.existsSync(lastUpdatePath)) { - try { - localData = JSON.parse(fs.readFileSync(lastUpdatePath, 'utf8')); - } catch (_) {} - } - +/** + * Download the changed tables into `dir`. + * `current(key, localLastUpdate)` gives the timestamp already available locally + * (null = nothing), a table is downloaded when the remote one is newer (or `force`). + * Returns the list of written files (last_update.json included when rewritten). + */ +async function _updateTables(dir, { force = false, fetchImpl = null, timeout = _MANUAL_TIMEOUT, log = () => {}, current }) { + const remote = await _remoteLastUpdate(fetchImpl, timeout, log); + const lastUpdateFile = path.join(dir, _LAST_UPDATE); + const local = _readJson(lastUpdateFile) || {}; const updated = []; - for (const [key, [endpoint, filename]] of Object.entries(_DATASETS)) { - const remoteTs = remoteData[key]; - const localTs = localData[key]; - const localFile = path.join(resolvedPath, filename); - - if (remoteTs == null) { - _log(`[skip] ${key}: not in last_update payload`); - continue; - } - - if (!force && remoteTs === localTs && fs.existsSync(localFile)) { - _log(`[ok] ${filename}: up to date`); - continue; - } - - _log(`[sync] ${filename}: ${localTs} → ${remoteTs}`); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), _HTTP_TIMEOUT); - try { - const resp = await fetch(datasetUrlFn(endpoint, filename), { signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - fs.writeFileSync(localFile, JSON.stringify(data, null, 2), 'utf8'); - updated.push(filename); - } finally { - clearTimeout(timer); - } + const remoteTs = remote.data[key]; + if (remoteTs == null) { log(`[skip] ${key}: not in last_update payload`); continue; } + const have = current(key, local); + if (!force && have != null && have >= remoteTs) { log(`[ok] ${filename}: up to date`); continue; } + log(`[sync] ${filename}: ${have} → ${remoteTs}`); + const data = await _getJson(remote.url(endpoint, filename), fetchImpl, timeout); + if (!Array.isArray(data)) throw new Error(`${filename}: expected a JSON list`); + _writeJson(path.join(dir, filename), data); + local[key] = remoteTs; + updated.push(filename); } - - if (updated.length > 0 || JSON.stringify(localData) !== JSON.stringify(remoteData)) { - fs.writeFileSync(lastUpdatePath, remoteText, 'utf8'); - if (!updated.includes('last_update.json')) updated.push('last_update.json'); + if (updated.length) { + _writeJson(lastUpdateFile, local); + updated.push(_LAST_UPDATE); } + return { updated, source: remote.source }; +} +/** + * Download or update the reference tables in one folder (e.g. a custom dbPath). + * Tries the live TigerTag API first; falls back to the GitHub mirror. + * Only downloads files whose timestamp has changed. + * + * @param {string} [dbPath] - Folder where JSON files are stored (created if missing). + * Default: the data dir (see TigerTagDB). + * @param {object} [options] + * @param {boolean} [options.force=false] - Re-download all files even if up to date. + * @param {boolean} [options.verbose=true] - Print progress to stdout. + * @param {number} [options.timeout=30000] - Per-request timeout in ms. + * @param {Function} [options.fetch] - fetch implementation (default: global fetch). + * @returns {Promise} List of filenames that were downloaded/updated. + * @throws {Error} When both the API and the GitHub mirror are unreachable. + */ +async function syncDatabases(dbPath, { force = false, verbose = true, timeout = _MANUAL_TIMEOUT, fetch = null } = {}) { + const dir = path.resolve(dbPath || defaultDataDir()); + fs.mkdirSync(dir, { recursive: true }); + const log = verbose ? (msg) => console.log(msg) : () => {}; + const { updated } = await _updateTables(dir, { + force, fetchImpl: fetch, timeout, log, + current: (key, local) => (fs.existsSync(path.join(dir, _DATASETS[key][1])) ? (local[key] ?? null) : null), + }); return updated; } /** * Loads and exposes TigerTag JSON reference databases. * - * Ships with bundled JSON files so the SDK works offline immediately after install. - * Optionally syncs updates from the TigerTag API or GitHub mirror via sync(). + * Ships with bundled JSON files so the SDK works offline immediately after install, + * keeps a fresher downloaded copy in the data dir, and checks for updates once a day. * * All ID lookups return the full JSON entry object (or null if not found). * The JSON files are the single source of truth — no hardcoded ID mappings. * * @example - * const db = new TigerTagDB(); + * const db = new TigerTagDB(); // local data, background daily check + * const db = await TigerTagDB.open(); // waits for the daily check (5 s max) + * const db = new TigerTagDB({ offline: true }); // never touches the network * const mat = db.material(38219); * console.log(mat.label); // "PLA" - * console.log(mat.density); // 1.24 */ class TigerTagDB { /** + * Synchronous and network-free: loads the freshest LOCAL copy of every table. + * With autoUpdate on, a background daily check is started (see `ready`). + * * @param {object} [options] - * @param {string} [options.dbPath] - Path to database directory. Defaults to bundled DB. - * @param {boolean} [options.autoSync] - Flag; call db.sync() manually to download files. - * @param {boolean} [options.verbose] - Print sync progress (default true). + * @param {string} [options.dbPath] - Custom folder, used exclusively (missing file → Error, + * no fallback, no automatic network call). + * @param {string} [options.dataDir] - Folder for downloaded copies (default: TIGERTAG_DATA_DIR, + * TIGERTAG_CACHE_DIR, or the per-user cache folder). + * @param {boolean} [options.offline] - true = zero network calls (default: TIGERTAG_OFFLINE env). + * @param {boolean} [options.autoUpdate=true] - Automatic daily check (background in the constructor). + * @param {boolean} [options.autoSync] - Deprecated alias of autoUpdate. + * @param {number} [options.maxAge=1 day] - Minimum interval between two automatic checks, in ms. + * @param {boolean} [options.verbose=false] - Log update progress / errors. + * @param {Function} [options.fetch] - fetch implementation (default: global fetch). + * @throws {Error} When a custom dbPath is missing a table or a table cannot be parsed. */ - constructor({ dbPath, autoSync = false, verbose = true } = {}) { - this._path = dbPath ? path.resolve(dbPath) : _BUNDLED_DB_PATH; - this._autoSync = autoSync; - this._verbose = verbose; - this._ensureDb(); - this._versions = this._load('id_version.json'); - this._materials = this._load('id_material.json'); - this._aspects = this._load('id_aspect.json'); - this._types = this._load('id_type.json'); - this._diameters = this._load('id_diameter.json'); - this._brands = this._load('id_brand.json'); - this._units = this._load('id_measure_unit.json'); + constructor({ + dbPath, dataDir, offline, autoUpdate, autoSync, maxAge = DEFAULT_UPDATE_MAX_AGE_MS, + verbose = false, fetch = null, + } = {}) { + this._custom = dbPath ? path.resolve(dbPath) : null; + this._path = this._custom || _BUNDLED_DB_PATH; // kept for backward compatibility + this._dataDir = path.resolve(dataDir || defaultDataDir()); + this._offline = resolveOffline(offline); + this._autoUpdate = autoUpdate ?? autoSync ?? true; + this._maxAge = maxAge; + this._verbose = verbose; + this._fetch = fetch; + this._reloadAll(); + /** Resolves (to this instance) when the background check started by the constructor is done. */ + this.ready = Promise.resolve(this); + if (this._autoUpdate && this._canAutoUpdate()) { + let p = _backgroundChecks.get(this._dataDir); + if (!p) { + p = this._autoCheck(); + _backgroundChecks.set(this._dataDir, p); + } + this.ready = p.then(() => { this._reloadAll(); return this; }, () => this); + } } - _ensureDb() { - const missing = TigerTagDB.REQUIRED_FILES.filter( - (fn) => !fs.existsSync(path.join(this._path, fn)), - ); - if (missing.length === 0) return; - - // Fallback to bundled database if user provided a custom path - if (this._path !== _BUNDLED_DB_PATH && fs.existsSync(_BUNDLED_DB_PATH)) { - const stillMissing = TigerTagDB.REQUIRED_FILES.filter( - (fn) => !fs.existsSync(path.join(_BUNDLED_DB_PATH, fn)), - ); - if (stillMissing.length === 0) { - this._path = _BUNDLED_DB_PATH; - return; - } + /** + * Create a TigerTagDB and wait for the automatic daily check (at most once per + * maxAge, ~5 s timeout, never throws) before returning it. + * + * @param {object} [options] - Same options as the constructor. + * @returns {Promise} + */ + static async open(options = {}) { + const db = new TigerTagDB({ ...options, autoUpdate: false }); + db._autoUpdate = options.autoUpdate ?? options.autoSync ?? true; + if (db._autoUpdate && db._canAutoUpdate()) { + await db._autoCheck(); + db._reloadAll(); } + return db; + } + + _canAutoUpdate() { return !this._offline && !this._custom; } + + _log(msg) { if (this._verbose) console.log(msg); } + + // Timestamp already available locally for a table (newest of data dir and bundled copy) + _currentTs(key, dataLastUpdate) { + const bundled = (_readJson(path.join(_BUNDLED_DB_PATH, _LAST_UPDATE)) || {})[key] ?? null; + const file = path.join(this._dataDir, _DATASETS[key][1]); + const dl = fs.existsSync(file) ? (dataLastUpdate[key] ?? null) : null; + if (dl == null) return bundled; + if (bundled == null) return dl; + return Math.max(dl, bundled); + } + + _state() { return _readJson(path.join(this._dataDir, _STATE_FILE)) || {}; } - process.stderr.write( - `\nTigerTag database files not found.\n Expected: ${path.resolve(this._path)}\n\n Missing:\n` - + missing.map((fn) => ` - ${fn}`).join('\n') - + '\n\n Call await db.sync() to download them.\n\n', - ); + _saveState(patch) { + try { _writeJson(path.join(this._dataDir, _STATE_FILE), { ...this._state(), ...patch }); } catch { /* read-only */ } } - _load(filename) { - const fp = path.join(this._path, filename); - if (!fs.existsSync(fp)) return []; + // Automatic check: throttled by maxAge (1 h after a failure), never throws + async _autoCheck() { + const st = _readJson(path.join(this._dataDir, _STATE_FILE)) || {}; + const now = Date.now(); + if (st.lastCheck && now - Date.parse(st.lastCheck) < this._maxAge) return []; + if (st.lastAttempt && st.lastError && now - Date.parse(st.lastAttempt) < Math.min(this._maxAge, _RETRY_AFTER_FAILURE_MS)) return []; + const at = new Date().toISOString(); try { - return JSON.parse(fs.readFileSync(fp, 'utf8')); - } catch (_) { + const { updated, source } = await _updateTables(this._dataDir, { + fetchImpl: this._fetch, timeout: _AUTO_TIMEOUT, log: (m) => this._log(m), + current: (key, local) => this._currentTs(key, local), + }); + this._saveState({ lastCheck: at, lastAttempt: at, lastError: null, source, lastUpdated: updated }); + return updated; + } catch (err) { + this._log(`[warn] automatic reference data check failed: ${err.message}`); + this._saveState({ lastAttempt: at, lastError: err.message }); return []; } } - _reloadAll() { - this._versions = this._load('id_version.json'); - this._materials = this._load('id_material.json'); - this._aspects = this._load('id_aspect.json'); - this._types = this._load('id_type.json'); - this._diameters = this._load('id_diameter.json'); - this._brands = this._load('id_brand.json'); - this._units = this._load('id_measure_unit.json'); - } - - static _find(table, idValue) { - return table.find((e) => e.id === idValue) || null; + /** + * Check for new reference tables now and download the changed ones. + * Targets the custom dbPath when one is set, otherwise the data dir. + * + * @param {object} [options] + * @param {boolean} [options.force=false] - Re-download every table even if up to date. + * @param {boolean} [options.catalog=false] - Also check for a new product catalogue. + * @param {number} [options.timeout=30000] - Per-request timeout in ms. + * @returns {Promise} Files written (tables, last_update.json, id_catalog.json). + * @throws {Error} When offline, or when the API and the GitHub mirror are both unreachable. + */ + async update({ force = false, catalog = false, timeout = _MANUAL_TIMEOUT } = {}) { + if (this._offline) { + throw new Error('TigerTagDB is offline (offline: true or TIGERTAG_OFFLINE=1): update() makes no network call.'); + } + const dir = this._custom || this._dataDir; + const at = new Date().toISOString(); + const { updated, source } = await _updateTables(dir, { + force, fetchImpl: this._fetch, timeout, log: (m) => this._log(m), + current: this._custom + ? (key, local) => (fs.existsSync(path.join(dir, _DATASETS[key][1])) ? (local[key] ?? null) : null) + : (key, local) => (force ? null : this._currentTs(key, local)), + }); + if (!this._custom) this._saveState({ lastCheck: at, lastAttempt: at, lastError: null, source, lastUpdated: updated }); + if (catalog) { + const { refreshCatalog, catalogInfo } = require('./catalog'); + const before = catalogInfo({ dataDir: this._dataDir }).fetchedAt; + await refreshCatalog({ dataDir: this._dataDir, fetch: this._fetch, offline: false }); + if (catalogInfo({ dataDir: this._dataDir }).fetchedAt !== before) updated.push('id_catalog.json'); + } + this._reloadAll(); + return updated; } /** - * Manually trigger a database update. + * Deprecated alias of update(): check for new tables now. * @param {boolean} [force=false] - Re-download all files even if up to date. * @returns {Promise} List of filenames that were downloaded/updated. */ async sync(force = false) { - const updated = await syncDatabases(this._path, { force, verbose: this._verbose }); - this._reloadAll(); - return updated; + return this.update({ force }); + } + + // Resolve every table: custom folder exclusively, else newest of data dir / bundled + _resolve() { + const out = {}; + if (this._custom) { + const lu = _readJson(path.join(this._custom, _LAST_UPDATE)) || {}; + const missing = Object.values(_DATASETS).map(([, fn]) => fn) + .filter((fn) => !fs.existsSync(path.join(this._custom, fn))); + if (missing.length) { + throw new Error( + `TigerTag database folder ${this._custom} is missing: ${missing.join(', ')}. A custom dbPath is ` + + 'used exclusively (no fallback to the bundled copy). Copy the files there, or download them with ' + + `syncDatabases('${this._custom}') / tigertag update --db "${this._custom}".`, + ); + } + for (const [key, [, fn]] of Object.entries(_DATASETS)) { + out[key] = { file: fn, source: 'custom', path: path.join(this._custom, fn), timestamp: lu[key] ?? null }; + } + return out; + } + const bundledLu = _readJson(path.join(_BUNDLED_DB_PATH, _LAST_UPDATE)) || {}; + const dataLu = _readJson(path.join(this._dataDir, _LAST_UPDATE)) || {}; + for (const [key, [, fn]] of Object.entries(_DATASETS)) { + const dlFile = path.join(this._dataDir, fn); + const dlTs = fs.existsSync(dlFile) ? (dataLu[key] ?? null) : null; + const bTs = bundledLu[key] ?? null; + out[key] = dlTs != null && (bTs == null || dlTs > bTs) + ? { file: fn, source: 'downloaded', path: dlFile, timestamp: dlTs } + : { file: fn, source: 'bundled', path: path.join(_BUNDLED_DB_PATH, fn), timestamp: bTs }; + } + return out; + } + + _reloadAll() { + this._sources = this._resolve(); + for (const [key, src] of Object.entries(this._sources)) { + let data = _readJson(src.path); + if (!Array.isArray(data) && src.source === 'downloaded') { // broken download → bundled copy + const fb = path.join(_BUNDLED_DB_PATH, src.file); + data = _readJson(fb); + this._sources[key] = { ...src, source: 'bundled', path: fb }; + } + if (!Array.isArray(data)) { + if (src.source === 'custom') throw new Error(`TigerTag database file ${src.path} is not a valid JSON list.`); + data = []; + } + this[_PROPS[key]] = data; + } + } + + /** + * Where every table comes from, and the update / catalogue status. + * + * @returns {{ offline: boolean, autoUpdate: boolean, maxAge: number, dataDir: string, customDir: string|null, + * lastCheck: string|null, lastAttempt: string|null, lastError: string|null, + * tables: Object, catalog: object }} + */ + info() { + const st = this._custom ? {} : this._state(); + const { catalogInfo } = require('./catalog'); + return { + offline: this._offline, + autoUpdate: this._autoUpdate, + maxAge: this._maxAge, + dataDir: this._dataDir, + customDir: this._custom, + lastCheck: st.lastCheck || null, + lastAttempt: st.lastAttempt || null, + lastError: st.lastError || null, + tables: JSON.parse(JSON.stringify(this._sources)), + catalog: catalogInfo({ dataDir: this._dataDir }), + }; + } + + static _find(table, idValue) { + return table.find((e) => e.id === idValue) || null; } /** @@ -302,4 +478,4 @@ class TigerTagDB { TigerTagDB.REQUIRED_FILES = Object.values(_DATASETS).map(([, fn]) => fn); -module.exports = { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH }; +module.exports = { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH, DEFAULT_UPDATE_MAX_AGE_MS }; diff --git a/src/index.js b/src/index.js index eea0f39..f8f683d 100644 --- a/src/index.js +++ b/src/index.js @@ -24,7 +24,7 @@ * tigertag — JavaScript SDK for TigerTag RFID material identification. * * Spec : https://github.com/TigerTag-Project/TigerTag-RFID-Guide - * Protocol: TigerTag Open Source v2.1 + * Protocol: TigerTag Open Source v2.2 * * Quick start: * const { TigerTag } = require('tigertag'); @@ -37,6 +37,8 @@ * console.log(tag.toRawDict()); // raw protocol fields * console.log(tag.toDict()); // enriched with labels, hex colors, dates * console.log(tag.verify()); // ECDSA signature result + * + * const plus = await TigerTag.fromCatalog(3527039449); // TigerTag+ from the catalogue */ const { @@ -54,7 +56,11 @@ const { } = require('./tag'); const { TigerTagDB, syncDatabases } = require('./db'); +const { defaultDataDir } = require('./datadir'); const { SignatureResult, ecdsaRawToDer } = require('./signature'); +const { + CATALOG_URL, loadCatalog, refreshCatalog, catalogInfo, catalogEntry, defaultCacheDir: catalogCacheDir, +} = require('./catalog'); module.exports = { TigerTag, @@ -62,7 +68,14 @@ module.exports = { SignatureResult, ApiDiff, syncDatabases, + defaultDataDir, ecdsaRawToDer, + loadCatalog, + refreshCatalog, + catalogInfo, + catalogEntry, + catalogCacheDir, + CATALOG_URL, CHIP_DUMP_LEN, FULL_DATA_LEN, MIN_DATA_LEN, diff --git a/src/signature.js b/src/signature.js index c5bdf5d..4a125fb 100644 --- a/src/signature.js +++ b/src/signature.js @@ -65,7 +65,7 @@ class SignatureResult { } toString() { - const base = SignatureResult._ICONS[this.status] || `? ${this.status}`; + const base = SignatureResult._LABELS[this.status] || `? ${this.status}`; return this.detail ? `${base} ${this.detail}` : base; } @@ -85,13 +85,13 @@ SignatureResult.NO_CRYPTO = 'no_crypto'; SignatureResult.NO_KEY = 'no_key'; SignatureResult.NO_UID = 'no_uid'; -SignatureResult._ICONS = { - valid: '✅ VALID', - invalid: '❌ INVALID', - unsigned: '⬜ NOT SIGNED', - no_crypto: '⚠️ crypto not available', - no_key: '⚠️ public key not found in id_version.json', - no_uid: '⚠️ UID unavailable — provide a full 180-byte chip dump', +SignatureResult._LABELS = { + valid: 'VALID', + invalid: 'INVALID', + unsigned: 'NOT SIGNED', + no_crypto: 'NO CRYPTO — crypto not available', + no_key: 'NO PUBLIC KEY — public key not found in id_version.json', + no_uid: 'NO UID — UID unavailable, provide a full 180-byte chip dump', }; /** diff --git a/src/tag.js b/src/tag.js index 1c7c6fa..46e5123 100644 --- a/src/tag.js +++ b/src/tag.js @@ -24,7 +24,7 @@ * TigerTag class — binary parsing, serialization, CRUD, and cloud sync. */ -const { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH } = require('./db'); +const { TigerTagDB, syncDatabases } = require('./db'); const { SignatureResult, verifySignature } = require('./signature'); // ── Protocol constants ────────────────────────────────────────────────────── @@ -61,6 +61,7 @@ const _PATCHABLE_FIELDS = new Set([ 'measure', 'idUnit', 'measureAvailable', 'nozzleTempMin', 'nozzleTempMax', 'dryTemp', 'dryTime', 'bedTempMin', 'bedTempMax', 'timestamp', 'customMessage', 'tdRaw', + 'tagInfo', 'tagCount', 'tagIndex', ]); // ── ApiDiff ───────────────────────────────────────────────────────────────── @@ -155,6 +156,11 @@ class TigerTag { // HueForge this.tdRaw = fields.tdRaw || 0; + // Tag index / tag count (protocol v2.2) — u8 at payload offset +39. + // High nibble = which tag this one is, low nibble = tags on the item (0 = unknown), + // so the hex reads "index/count": 0x12 = tag 1 of 2. + this.tagInfo = fields.tagInfo || 0; + // Signature (optional) this.signatureR = fields.signatureR instanceof Buffer ? fields.signatureR : Buffer.alloc(32); this.signatureS = fields.signatureS instanceof Buffer ? fields.signatureS : Buffer.alloc(32); @@ -191,6 +197,21 @@ class TigerTag { /** HueForge TD as float. 0.0 = undefined, valid range 0.1–100.0. */ get tdValue() { return this.tdRaw / 10.0; } + /** + * Number of TigerTags on the tagged item — a filament spool, a resin bottle… as given + * by idType (low nibble of tagInfo). + * 0 = unknown, 1 = single tag, 2 = twin tag, … up to 15. + * @returns {number} + */ + get tagCount() { return this.tagInfo & 0x0F; } + + /** + * Which of those tags this one is, from 1 (high nibble of tagInfo). + * 0 = unknown. + * @returns {number} + */ + get tagIndex() { return this.tagInfo >> 4; } + /** Manufacturing timestamp as UTC Date. */ get manufacturingDate() { return new Date(_TIGERTAG_EPOCH_MS + this.timestamp * 1000); @@ -379,6 +400,7 @@ class TigerTag { color2R: u8(36), color2G: u8(37), color2B: u8(38), + tagInfo: u8(39), color3R: u8(40), color3G: u8(41), color3B: u8(42), @@ -431,6 +453,8 @@ class TigerTag { * @param {number} [options.timestamp] - Seconds since 2000-01-01 UTC. Defaults to now. * @param {string} [options.customMessage=''] * @param {number} [options.tdRaw=0] + * @param {number} [options.tagCount=0] - TigerTags on the item (0 = unknown, 0–15). + * @param {number} [options.tagIndex=0] - Which tag this one is, from 1 (0 = unknown, 0–15). * @param {TigerTagDB} [options.db] * @returns {TigerTag} */ @@ -457,6 +481,8 @@ class TigerTag { timestamp = null, customMessage = '', tdRaw = 0, + tagCount = 0, + tagIndex = 0, measureAvailable = null, db = null, } = {}) { @@ -505,6 +531,7 @@ class TigerTag { timestamp, customMessage, tdRaw, + tagInfo: TigerTag._packTagInfo(tagCount, tagIndex), uid, _db: db, }); @@ -534,6 +561,7 @@ class TigerTag { timestamp: ts, customMessage: '', tdRaw: 0, + tagInfo: 0, uid, }); } @@ -590,7 +618,7 @@ class TigerTag { buf[o++] = this.color2R & 0xFF; buf[o++] = this.color2G & 0xFF; buf[o++] = this.color2B & 0xFF; - buf[o++] = 0x00; + buf[o++] = this.tagInfo & 0xFF; buf[o++] = this.color3R & 0xFF; buf[o++] = this.color3G & 0xFF; @@ -631,8 +659,14 @@ class TigerTag { * Protected fields (idTigertag, idProduct, uid, signatureR, signatureS) * cannot be modified — they are covered by the ECDSA signature. * + * tagCount / tagIndex are accepted as shortcuts and re-encoded into + * tagInfo; the nibble that is not supplied keeps its current value. + * tagInfo is not covered by the signature, so patching it never + * invalidates a signed tag. + * * @param {object} kwargs - Field names (camelCase) and their new values. * @returns {TigerTag} + * @throws {RangeError} When tagCount / tagIndex is not an integer in 0–15. */ patch(kwargs) { const protected_ = Object.keys(kwargs).filter((k) => _PROTECTED_FIELDS.has(k)); @@ -649,7 +683,38 @@ class TigerTag { + `Valid patchable fields: ${[..._PATCHABLE_FIELDS].sort().join(', ')}`, ); } - return new TigerTag(Object.assign({}, this, kwargs)); + const updates = Object.assign({}, kwargs); + if ('tagCount' in updates || 'tagIndex' in updates) { + const base = 'tagInfo' in updates ? (updates.tagInfo & 0xFF) : this.tagInfo; + const count = 'tagCount' in updates ? updates.tagCount : base & 0x0F; + const index = 'tagIndex' in updates ? updates.tagIndex : base >> 4; + updates.tagInfo = TigerTag._packTagInfo(count, index); + delete updates.tagCount; + delete updates.tagIndex; + } + return new TigerTag(Object.assign({}, this, updates)); + } + + /** + * Pack a tag count and tag index into the tagInfo byte: + * (tagIndex << 4) | tagCount — the hex reads "index/count" (0x12 = tag 1 of 2). + * + * @param {number} tagCount - TigerTags on the item (0 = unknown, 0–15). + * @param {number} tagIndex - Which tag this one is, from 1 (0 = unknown, 0–15). + * @returns {number} u8 tagInfo value. + * @throws {RangeError} When either value is not an integer in 0–15 + * (it would not fit in its 4-bit nibble). + * @private + */ + static _packTagInfo(tagCount, tagIndex) { + const count = tagCount || 0; + const index = tagIndex || 0; + for (const [name, v] of [['tagCount', count], ['tagIndex', index]]) { + if (!Number.isInteger(v) || v < 0 || v > 15) { + throw new RangeError(`${name} must be an integer in 0–15, got ${v}`); + } + } + return (index << 4) | count; } // ── Validation ────────────────────────────────────────────────────────── @@ -675,6 +740,25 @@ class TigerTag { `TD HueForge out of range: ${this.tdRaw} (valid: 10–1000 or 0=undefined)`, ); } + const tagCount = this.tagCount; + const tagIndex = this.tagIndex; + if (!Number.isInteger(this.tagInfo) || this.tagInfo < 0 || this.tagInfo > 0xFF + || tagCount > 15 || tagIndex > 15) { + warnings.push( + `Tag index/count out of range: tag_info=${this.tagInfo} ` + + `(count and index must each be 0–15, tag_info must be 0x00–0xFF)`, + ); + } + if (tagCount > 0 && tagIndex > tagCount) { + warnings.push( + `Tag index (${tagIndex}) > tag count (${tagCount}) (valid index: 1–count or 0=unknown)`, + ); + } + if (tagCount === 1 && tagIndex > 1) { + warnings.push( + `Single-tag item (count=1) but tag index is ${tagIndex} (expected 1 or 0=unknown)`, + ); + } if (this.measure > 0 && this.measureAvailable > this.measure) { warnings.push( `measure_available (${this.measureAvailable}) > initial measure (${this.measure})`, @@ -949,16 +1033,24 @@ class TigerTag { } /** - * Download or update reference databases. + * Check for new reference tables now and reload this tag's database. * - * @param {string} [dbPath] - Target folder. Defaults to the bundled database directory. + * Without dbPath: updates the data dir (see TigerTagDB.update()). With dbPath: + * downloads into that custom folder, which is then used exclusively. + * + * @param {string} [dbPath] - Custom target folder. Default: the data dir. * @param {boolean} [force=false] - Re-download all files even if up to date. * @returns {Promise} List of filenames that were downloaded/updated. */ async syncDb(dbPath = null, force = false) { - const targetPath = dbPath || _BUNDLED_DB_PATH; - const updated = await syncDatabases(targetPath, { force, verbose: true }); - this._db = new TigerTagDB({ dbPath: targetPath }); + if (dbPath) { + const updated = await syncDatabases(dbPath, { force, verbose: true }); + this._db = new TigerTagDB({ dbPath }); + return updated; + } + const db = new TigerTagDB({ autoUpdate: false }); + const updated = await db.update({ force }); + this._db = db; return updated; } @@ -1086,6 +1178,7 @@ class TigerTag { color_g3: numColors >= 3 ? this.color3G : 0, color_b3: numColors >= 3 ? this.color3B : 0, td_raw: this.tdRaw, + tag_info: this.tagInfo, message: this.customMessage, measure_available: this.measureAvailable, ...TigerTag._baseUnitFields(this.measure, this.measureAvailable, this.idUnit), @@ -1139,6 +1232,7 @@ class TigerTag { bed_max: 'bedTempMax', timestamp: 'timestamp', td_raw: 'tdRaw', + tag_info: 'tagInfo', message: 'customMessage', }; const kwargs = {}; @@ -1193,6 +1287,8 @@ class TigerTag { timestamp: raw.timestamp ?? null, customMessage: raw.message ?? '', tdRaw: raw.td_raw ?? 0, + tagCount: (raw.tag_info ?? 0) & 0x0F, + tagIndex: (raw.tag_info ?? 0) >> 4, db, }); } @@ -1243,6 +1339,101 @@ class TigerTag { }); } + /** + * Build a ready-to-burn TigerTag+ from one entry of the official catalogue + * (id_catalog.json). Pure and synchronous — see fromCatalog() to look the + * product up by ID. + * + * RFID_Data mapping (same as the cloud document, see fromCloudDoc()): + * data1 = idDiameter, data2/data3 = nozzle min/max, data4/data5 = dry temp/time, + * data6/data7 = bed min/max; id_aspect2 null → 0 (none); missing values → 0. + * Colours 2 and 3 come from RFID_Data color_r2…b3 when present, otherwise from + * color_info.colors[1] / [2] ("#RRGGBBAA"). + * + * @param {object} entry - Catalogue entry ({ id, RFID_Data, title, brand, color_info, … }). + * @param {object} [options] + * @param {Buffer} [options.uid] - 7-byte chip UID. + * @param {number} [options.tagCount=0] - TigerTags on the item (twin tag: 2). + * @param {number} [options.tagIndex=0] - Which tag this one is, from 1. + * @param {number} [options.timestamp] - Seconds since 2000-01-01 UTC (default: now). Use the + * same value on every tag of a twin tag. + * @param {TigerTagDB} [options.db] + * @returns {TigerTag} A TigerTag+ (idTigertag = ID_TIGERTAG_PLUS, idProduct = entry.id). + * @throws {Error} When the entry has no RFID_Data (it cannot be written to a tag). + */ + static fromCatalogEntry(entry, { + uid = null, tagCount = 0, tagIndex = 0, timestamp = null, db = null, + } = {}) { + const r = entry && entry.RFID_Data; + if (!r) { + throw new Error( + `Catalogue product ${entry && entry.id} ("${(entry && entry.title) || '?'}") has no RFID_Data ` + + '— it cannot be written to a tag.', + ); + } + const n = (v) => (v == null ? 0 : Number(v)); + const hex = (h) => { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(h || ''); + return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 0, 0]; + }; + const colors = (entry.color_info && entry.color_info.colors) || []; + const c2 = r.color_r2 != null ? [n(r.color_r2), n(r.color_g2), n(r.color_b2)] : colors[1] ? hex(colors[1]) : [0, 0, 0]; + const c3 = r.color_r3 != null ? [n(r.color_r3), n(r.color_g3), n(r.color_b3)] : colors[2] ? hex(colors[2]) : [0, 0, 0]; + return TigerTag.create({ + productId: Number(entry.id), + uid, + idMaterial: n(r.id_material), + idAspect1: n(r.id_aspect1), + idAspect2: n(r.id_aspect2), + idType: n(r.id_type), + idDiameter: n(r.data1), + idBrand: n(r.id_brand), + color1R: n(r.color_r), + color1G: n(r.color_g), + color1B: n(r.color_b), + color1A: r.color_a == null ? 255 : n(r.color_a), + color2R: c2[0], color2G: c2[1], color2B: c2[2], + color3R: c3[0], color3G: c3[1], color3B: c3[2], + measure: n(r.measure), + idUnit: n(r.id_unit), + nozzleTempMin: n(r.data2), + nozzleTempMax: n(r.data3), + dryTemp: n(r.data4), + dryTime: n(r.data5), + bedTempMin: n(r.data6), + bedTempMax: n(r.data7), + timestamp, + tagCount, + tagIndex, + db, + }); + } + + /** + * Build a ready-to-burn TigerTag+ from a product ID of the official catalogue. + * The catalogue is downloaded on first use and cached on disk (see loadCatalog()). + * Use catalogEntry(productId) for the display metadata (title, brand, sku, + * barcode, img_src). + * + * @param {number} productId - TigerTag+ product ID. + * @param {object} [options] - fromCatalogEntry() options, plus: + * @param {Map} [options.catalog] - Already loaded catalogue (otherwise loaded with + * the loadCatalog() options url / cacheDir / maxAge / force). + * @returns {Promise} + * @throws {Error} When the ID is not in the catalogue, the product has no RFID_Data, or the + * catalogue cannot be loaded (offline with no cache). + */ + static async fromCatalog(productId, options = {}) { + const { loadCatalog } = require('./catalog'); + const { catalog: given, uid, tagCount, tagIndex, timestamp, db, ...loadOpts } = options; + const catalog = given || await loadCatalog(loadOpts); + const entry = catalog.get(Number(productId)); + if (!entry) { + throw new Error(`Product ID ${productId} is not in the TigerTag catalogue (${catalog.size} products).`); + } + return TigerTag.fromCatalogEntry(entry, { uid, tagCount, tagIndex, timestamp, db }); + } + /** * Apply a surgical patch using snake_case keys (toRawDict format). * Only the supplied keys are changed — all other fields are preserved. @@ -1276,7 +1467,7 @@ class TigerTag { return { sdk: 'tigertag-sdk-js', sdk_mode: 'offline', - protocol: 'TigerTag Open Source v2.1', + protocol: 'TigerTag Open Source v2.2', chip: 'NTAG213/215/216', uid: this.uidHex, version: { @@ -1369,6 +1560,8 @@ class TigerTag { }, manufacturing_date: this.manufacturingDate.toISOString(), twin_tag_pairing_id: this.timestamp, + tag_count: this.tagCount || null, + tag_index: this.tagIndex || null, custom_message: this.customMessage, authentication: { signed: this.isSigned, @@ -1465,6 +1658,11 @@ class TigerTag { } if (this.tdRaw !== 0) parts.push(`HueForge TD: ${this.tdValue.toFixed(1)}.`); + if (this.tagInfo !== 0) { + // What the item is comes from idType (Filament, Resin, …); unknown → "item" + const typeLabel = ((_db.type(this.idType) || {}).label || '').trim().toLowerCase(); + parts.push(`Tag ${this.tagIndex || '?'} of ${this.tagCount || '?'} on this ${typeLabel || 'item'}.`); + } const dateStr = this.manufacturingDate.toISOString().slice(0, 10); parts.push(`Manufactured: ${dateStr}.`); @@ -1509,7 +1707,7 @@ class TigerTag { const ul = TigerTagDB.label(_db.unit(this.idUnit)); const sig = sigResult ? String(sigResult) - : (this.isSigned ? 'signed ✓' : 'not signed'); + : (this.isSigned ? 'signed (not verified)' : 'not signed'); const recNote = (kMin, kMax, suffix = '°C') => rec[kMin] != null ? ` (DB: ${rec[kMin]}–${rec[kMax]}${suffix})` : ''; @@ -1554,6 +1752,7 @@ class TigerTag { + `├─ Traceability ────────────────────────────────────────\n` + `│ Manufactured ${this.manufacturingDate.toISOString().replace('T', ' ').slice(0, 16)} UTC\n` + `│ Twin tag ID ${this.timestamp}\n` + + `│ Tag ${this.tagIndex || '?'} of ${this.tagCount || '?'}${this.tagInfo === 0 ? ' (unknown)' : ''}\n` + `│ Message ${JSON.stringify(this.customMessage)}\n` + `├─ Signature ───────────────────────────────────────────\n` + `│ ECDSA ${sig}\n` diff --git a/test/burn_plan.test.js b/test/burn_plan.test.js new file mode 100644 index 0000000..a4d49c3 --- /dev/null +++ b/test/burn_plan.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const { buildBurnPlan, SIG_FIRST_PAGE } = require('../tools/burn_plan'); + +const user = () => Buffer.from(Array.from({ length: 80 }, (_, i) => (i + 1) & 0xFF)); + +describe('playground burn page plan', () => { + test('144-byte payload without signature → 36 pages, 0x04–0x27, signature pages zeroed', () => { + const plan = buildBurnPlan(Buffer.concat([user(), Buffer.alloc(64)])); + expect(plan.signatureDropped).toBe(false); + expect(plan.pages).toHaveLength(36); + expect(plan.pages[0].page).toBe(0x04); + expect(plan.pages[35].page).toBe(0x27); + expect([...plan.pages[0].data]).toEqual([1, 2, 3, 4]); + expect([...plan.pages[19].data]).toEqual([77, 78, 79, 80]); // page 0x17 + for (const p of plan.pages.filter((x) => x.page >= SIG_FIRST_PAGE)) { + expect([...p.data]).toEqual([0, 0, 0, 0]); + } + }); + + test('80-byte payload is padded: signature pages written as 00', () => { + const plan = buildBurnPlan(user()); + expect(plan.signatureDropped).toBe(false); + expect(plan.pages).toHaveLength(36); + expect(plan.pages.filter((p) => p.page >= SIG_FIRST_PAGE).every((p) => p.data.equals(Buffer.alloc(4)))).toBe(true); + }); + + test('144-byte payload WITH a signature → the signature is never written (00 on 0x18–0x27)', () => { + const sig = Buffer.from(Array.from({ length: 64 }, (_, i) => 0xA0 + (i % 16))); + const plan = buildBurnPlan(Buffer.concat([user(), sig])); + expect(plan.signatureDropped).toBe(true); + expect(plan.pages).toHaveLength(36); + expect(Buffer.concat(plan.pages.slice(0, 20).map((p) => p.data)).equals(user())).toBe(true); + expect(Buffer.concat(plan.pages.slice(20).map((p) => p.data)).equals(Buffer.alloc(64))).toBe(true); + }); + + test('never plans pages 0–3 or 0x28+', () => { + for (const payload of [user(), Buffer.concat([user(), Buffer.alloc(64, 0xFF)])]) { + const plan = buildBurnPlan(payload); + expect(plan.pages.every((p) => p.page >= 0x04 && p.page <= 0x27)).toBe(true); + } + }); + + test('rejects any other length', () => { + for (const n of [0, 4, 79, 81, 143, 145, 180]) { + expect(() => buildBurnPlan(Buffer.alloc(n))).toThrow(RangeError); + } + expect(() => buildBurnPlan(null)).toThrow(RangeError); + }); +}); diff --git a/test/burn_targets.test.js b/test/burn_targets.test.js new file mode 100644 index 0000000..a32319b --- /dev/null +++ b/test/burn_targets.test.js @@ -0,0 +1,34 @@ +'use strict'; + +const { selectBurnTargets } = require('../tools/burn_targets'); + +describe('playground burn:write reader filter', () => { + const readers = new Map([ + ['ACS ACR122U PICC Interface', { id: 'ACS ACR122U PICC Interface', hasCard: true }], + ['ACS ACR122U PICC Interface 01', { id: 'ACS ACR122U PICC Interface 01', hasCard: true }], + ['Empty reader', { id: 'Empty reader', hasCard: false }], + ]); + const names = (pairs) => pairs.map(([n]) => n); + + test('no filter targets every reader holding a card (backward compatible)', () => { + expect(names(selectBurnTargets(readers))).toEqual([ + 'ACS ACR122U PICC Interface', 'ACS ACR122U PICC Interface 01', + ]); + expect(names(selectBurnTargets(readers, ''))).toHaveLength(2); + }); + + test('a reader filter targets only that reader', () => { + expect(names(selectBurnTargets(readers, 'ACS ACR122U PICC Interface 01'))) + .toEqual(['ACS ACR122U PICC Interface 01']); + }); + + test('an array filter targets each listed reader', () => { + expect(names(selectBurnTargets(readers, ['ACS ACR122U PICC Interface', 'Empty reader']))) + .toEqual(['ACS ACR122U PICC Interface']); + }); + + test('a filter never targets a reader without a card, or an unknown reader', () => { + expect(selectBurnTargets(readers, 'Empty reader')).toEqual([]); + expect(selectBurnTargets(readers, 'nope')).toEqual([]); + }); +}); diff --git a/test/catalog.test.js b/test/catalog.test.js new file mode 100644 index 0000000..1bdcb38 --- /dev/null +++ b/test/catalog.test.js @@ -0,0 +1,217 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { TigerTag, ID_TIGERTAG_PLUS } = require('../src/index'); +const { + loadCatalog, refreshCatalog, catalogInfo, catalogEntry, clearCatalogMemo, +} = require('../src/catalog'); + +// Real entry from id_catalog.json (Elegoo Rapid TPU 95A - Black) +const ELEGOO = { + RFID_Data: { + color_a: 255, color_b: 0, color_g: 0, color_r: 0, + data1: 56, data2: 200, data3: 250, data4: 60, data5: 6, data6: 25, data7: 45, + id_aspect1: 104, id_aspect2: null, id_brand: 57632, id_material: 43518, + id_type: 142, id_unit: 21, measure: 1000, + }, + barcode: '40553085', brand: 'ELEGOO', color: '#000000FF', + color_info: { colors: ['#000000FF'], type: 'mono' }, + id: 3527039449, img_src: 'https://example.invalid/tpu.jpg', material: 'TPU', + measure: '1000 g', product_type: 'Filament', sku: '50.203.0631', title: 'Rapid TPU 95A - Black', +}; +// Bicolour entry: colours only in color_info +const DUAL = { + RFID_Data: { ...ELEGOO.RFID_Data, id_aspect1: 247, id_aspect2: 252, data4: null, data5: null, + color_r: 0xEA, color_g: 0xAD, color_b: 0xBD }, + color_info: { colors: ['#EAADBDFF', '#ED2F2EFF', '#102030FF'], type: 'multi' }, + id: 19278539, title: 'Dual Matte Flamingo', +}; +// Colours 2 / 3 given in RFID_Data take precedence over color_info +const RGB3 = { + RFID_Data: { ...ELEGOO.RFID_Data, color_r2: 1, color_g2: 2, color_b2: 3, color_r3: 4, color_g3: 5, color_b3: 6 }, + color_info: { colors: ['#000000FF', '#FFFFFFFF', '#FFFFFFFF'], type: 'multi' }, id: 42, title: 'Tri', +}; +// Resin entries of the catalogue carry no RFID_Data +const RESIN = { id: 1298476103, title: 'Castable - X-Filigree V2', product_type: 'Resin', RFID_Data: null }; + +const LIST = [ELEGOO, DUAL, RGB3, RESIN]; + +function mockFetch(body = JSON.stringify(LIST)) { + const fn = jest.fn(async () => ({ ok: true, status: 200, text: async () => body })); + return fn; +} + +// Online (tests set TIGERTAG_OFFLINE=1 globally), mocked fetch, no bundled copy +const ISOLATED = { offline: false, bundledDir: null }; +let cacheDir; +beforeEach(() => { + clearCatalogMemo(); + cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tigertag-catalog-')); +}); +afterEach(() => fs.rmSync(cacheDir, { recursive: true, force: true })); + +describe('TigerTag.fromCatalogEntry / fromCatalog', () => { + test('Elegoo Rapid TPU 95A Black → expected TigerTag+ bytes', () => { + const tag = TigerTag.fromCatalogEntry(ELEGOO, { timestamp: 1000 }); + expect(tag.idTigertag).toBe(ID_TIGERTAG_PLUS); + expect(tag.idProduct).toBe(3527039449); + expect(tag.toBytes().toString('hex')).toBe( + 'bc0fcb97' + 'd23a59d9' + 'a9fe' + '68' + '00' + '8e' + '38' + 'e120' // version, product, material, aspects, type, diameter, brand + + '000000ff' + '0003e8' + '15' + '00c8' + '00fa' + '3c' + '06' + '19' + '2d' // colour 1, measure, unit, nozzle, dry, bed + + '000003e8' + '00000000' + '00000000' + '00000000' // timestamp, colour 2 + tag info, colour 3, TD + + '00'.repeat(28) + '0003e8' + '00', // message, measure available + ); + expect([tag.nozzleTempMin, tag.nozzleTempMax, tag.dryTemp, tag.dryTime, tag.bedTempMin, tag.bedTempMax]) + .toEqual([200, 250, 60, 6, 25, 45]); + expect([tag.idDiameter, tag.idAspect2, tag.measure, tag.measureAvailable, tag.idUnit]).toEqual([56, 0, 1000, 1000, 21]); + }); + + test('multi-colour: color_info.colors[1..2] → colour 2 / 3; null data → 0', () => { + const tag = TigerTag.fromCatalogEntry(DUAL, { timestamp: 1 }); + expect(tag.color1Hex).toBe('#EAADBD'); + expect(tag.color2Hex).toBe('#ED2F2E'); + expect(tag.color3Hex).toBe('#102030'); + expect([tag.idAspect1, tag.idAspect2, tag.dryTemp, tag.dryTime]).toEqual([247, 252, 0, 0]); + }); + + test('RFID_Data colour 2 / 3 take precedence over color_info', () => { + const tag = TigerTag.fromCatalogEntry(RGB3, { timestamp: 1 }); + expect([tag.color2R, tag.color2G, tag.color2B, tag.color3R, tag.color3G, tag.color3B]).toEqual([1, 2, 3, 4, 5, 6]); + }); + + test('twin tag options and uid pass through', () => { + const uid = Buffer.from('04AABBCCDDEEFF', 'hex'); + const tag = TigerTag.fromCatalogEntry(ELEGOO, { uid, timestamp: 77, tagCount: 2, tagIndex: 2 }); + expect(tag.tagInfo).toBe(0x22); + expect(tag.timestamp).toBe(77); + expect(tag.uidHex).toBe('04AABBCCDDEEFF'); + }); + + test('a product without RFID_Data (catalogue resins) → clear error', () => { + expect(() => TigerTag.fromCatalogEntry(RESIN)).toThrow(/no RFID_Data/); + }); + + test('fromCatalog() looks the ID up; unknown ID → clear error', async () => { + const catalog = new Map(LIST.map((e) => [e.id, e])); + const tag = await TigerTag.fromCatalog('3527039449', { catalog, timestamp: 1000 }); + expect(tag.idProduct).toBe(3527039449); + await expect(TigerTag.fromCatalog(123, { catalog })).rejects.toThrow(/not in the TigerTag catalogue/); + }); +}); + +describe('loadCatalog / catalogEntry', () => { + test('downloads once, writes the disk cache, reuses it', async () => { + const fetch = mockFetch(); + const map = await loadCatalog({ ...ISOLATED, cacheDir, fetch }); + expect(map.size).toBe(4); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(cacheDir, 'id_catalog.json'))).toBe(true); + + clearCatalogMemo(); + const fetch2 = mockFetch('[]'); + const again = await loadCatalog({ ...ISOLATED, cacheDir, fetch: fetch2 }); // fresh disk cache → no download + expect(again.size).toBe(4); + expect(fetch2).not.toHaveBeenCalled(); + }); + + test('force / expired cache re-downloads', async () => { + await loadCatalog({ ...ISOLATED, cacheDir, fetch: mockFetch() }); + clearCatalogMemo(); + const fetch = mockFetch(JSON.stringify([ELEGOO])); + const map = await loadCatalog({ ...ISOLATED, cacheDir, fetch, maxAge: 0 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(map.size).toBe(1); + }); + + test('offline: stale cache is used; no cache → clear error', async () => { + await loadCatalog({ ...ISOLATED, cacheDir, fetch: mockFetch() }); + clearCatalogMemo(); + const offline = jest.fn(async () => { throw new Error('getaddrinfo ENOTFOUND'); }); + const stale = await loadCatalog({ ...ISOLATED, cacheDir, fetch: offline, maxAge: 0 }); + expect(stale.size).toBe(4); + + clearCatalogMemo(); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'tigertag-catalog-')); + await expect(loadCatalog({ ...ISOLATED, cacheDir: empty, fetch: offline })) + .rejects.toThrow(/catalogue unavailable.*no local copy/); + fs.rmSync(empty, { recursive: true, force: true }); + }); + + test('catalogEntry() returns the metadata, null when unknown', async () => { + const entry = await catalogEntry(3527039449, { ...ISOLATED, cacheDir, fetch: mockFetch() }); + expect(entry).toMatchObject({ title: 'Rapid TPU 95A - Black', brand: 'ELEGOO', sku: '50.203.0631', barcode: '40553085' }); + expect(await catalogEntry(1, { ...ISOLATED, cacheDir, fetch: mockFetch() })).toBeNull(); + }); +}); + +describe('default cache lifetime', () => { + test('DEFAULT_MAX_AGE_MS is 1 day; a cache older than 1 day is re-checked, a younger one is reused', async () => { + const { DEFAULT_MAX_AGE_MS } = require('../src/catalog'); + expect(DEFAULT_MAX_AGE_MS).toBe(24 * 3600 * 1000); + await loadCatalog({ ...ISOLATED, cacheDir, fetch: mockFetch() }); + const file = path.join(cacheDir, 'id_catalog.json'); + + clearCatalogMemo(); + const hoursAgo = (h) => new Date(Date.now() - h * 3600 * 1000); + fs.utimesSync(file, hoursAgo(23), hoursAgo(23)); + const young = mockFetch(); + await loadCatalog({ ...ISOLATED, cacheDir, fetch: young }); + expect(young).not.toHaveBeenCalled(); + + clearCatalogMemo(); + fs.utimesSync(file, hoursAgo(25), hoursAgo(25)); + const old = mockFetch(); + await loadCatalog({ ...ISOLATED, cacheDir, fetch: old }); + expect(old).toHaveBeenCalledTimes(1); + }); +}); + +describe('refreshCatalog / catalogInfo', () => { + const headers = (h) => ({ get: (n) => h[n.toLowerCase()] ?? null }); + + test('catalogInfo() before the first download → not downloaded', () => { + expect(catalogInfo({ ...ISOLATED, cacheDir })).toMatchObject({ downloaded: false, count: null, fetchedAt: null }); + }); + + test('info after a download: count, fetchedAt, source url, ETag', async () => { + const fetch = jest.fn(async () => ({ + ok: true, status: 200, headers: headers({ etag: '"v1"', 'last-modified': 'Wed, 30 Sep 2026 10:00:00 GMT' }), + text: async () => JSON.stringify(LIST), + })); + await loadCatalog({ ...ISOLATED, cacheDir, fetch, url: 'https://example.invalid/id_catalog.json' }); + const info = catalogInfo({ ...ISOLATED, cacheDir }); + expect(info).toMatchObject({ downloaded: true, count: 4, url: 'https://example.invalid/id_catalog.json', etag: '"v1"' }); + expect(Date.parse(info.fetchedAt)).not.toBeNaN(); + }); + + test('refreshCatalog() forces a check, sends the ETag, keeps the cache on 304', async () => { + const first = jest.fn(async () => ({ + ok: true, status: 200, headers: headers({ etag: '"v1"' }), text: async () => JSON.stringify(LIST), + })); + await loadCatalog({ ...ISOLATED, cacheDir, fetch: first }); + const fetchedAt = catalogInfo({ ...ISOLATED, cacheDir }).fetchedAt; + + const notModified = jest.fn(async () => ({ ok: false, status: 304, headers: headers({}), text: async () => '' })); + const map = await refreshCatalog({ ...ISOLATED, cacheDir, fetch: notModified }); // memo + fresh cache ignored + expect(notModified).toHaveBeenCalledTimes(1); + expect(notModified.mock.calls[0][1].headers['If-None-Match']).toBe('"v1"'); + expect(map.size).toBe(4); + const info = catalogInfo({ ...ISOLATED, cacheDir }); + expect(info.fetchedAt).toBe(fetchedAt); // same copy + expect(Date.parse(info.checkedAt)).toBeGreaterThanOrEqual(Date.parse(fetchedAt)); + }); + + test('refreshCatalog() with a new version replaces the cache', async () => { + await loadCatalog({ ...ISOLATED, cacheDir, fetch: mockFetch() }); + const updated = jest.fn(async () => ({ + ok: true, status: 200, headers: headers({ etag: '"v2"' }), text: async () => JSON.stringify([ELEGOO, DUAL]), + })); + const map = await refreshCatalog({ ...ISOLATED, cacheDir, fetch: updated }); + expect(map.size).toBe(2); + expect(catalogInfo({ ...ISOLATED, cacheDir })).toMatchObject({ count: 2, etag: '"v2"' }); + clearCatalogMemo(); + expect((await loadCatalog({ ...ISOLATED, cacheDir, fetch: mockFetch('[]') })).size).toBe(2); // read from the new cache + }); +}); diff --git a/test/helpers/cli_mock_fetch.js b/test/helpers/cli_mock_fetch.js new file mode 100644 index 0000000..455148e --- /dev/null +++ b/test/helpers/cli_mock_fetch.js @@ -0,0 +1,7 @@ +'use strict'; +// Preloaded with `node -r` by the CLI test: replaces global fetch by the mock. +// TIGERTAG_MOCK_BRANDS_TS bumps the brands timestamp so exactly one table changes. +const { makeFetch, bundledLastUpdate } = require('./mock_fetch'); +const lu = bundledLastUpdate(); +if (process.env.TIGERTAG_MOCK_BRANDS_TS) lu.brands = Number(process.env.TIGERTAG_MOCK_BRANDS_TS); +globalThis.fetch = makeFetch({ lastUpdate: lu, tables: { 'id_brand.json': [{ id: 1, name: 'Mock brand' }] } }); diff --git a/test/helpers/mock_fetch.js b/test/helpers/mock_fetch.js new file mode 100644 index 0000000..6afbfe5 --- /dev/null +++ b/test/helpers/mock_fetch.js @@ -0,0 +1,34 @@ +'use strict'; + +// Mocked TigerTag API / GitHub mirror for reference-table tests. +// makeFetch({ lastUpdate, tables, failApi, failAll }) → jest-compatible fetch(url) +const fs = require('fs'); +const path = require('path'); + +const BUNDLED = path.join(__dirname, '..', '..', 'database'); +const bundledLastUpdate = () => JSON.parse(fs.readFileSync(path.join(BUNDLED, 'last_update.json'), 'utf8')); + +const ENDPOINT_FILE = { + 'version/get/all': 'id_version.json', 'type/get/all': 'id_type.json', 'brand/get/all': 'id_brand.json', + 'diameter/filament/get/all': 'id_diameter.json', 'material/get/all': 'id_material.json', + 'aspect/get/all': 'id_aspect.json', 'measure_unit/get/all': 'id_measure_unit.json', +}; + +function makeFetch({ lastUpdate = bundledLastUpdate(), tables = {}, failApi = false, failAll = false } = {}) { + const calls = []; + const fetch = async (url) => { + calls.push(url); + if (failAll || (failApi && url.includes('api.tigertag.io'))) throw new Error('getaddrinfo ENOTFOUND'); + const ok = (body) => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => JSON.stringify(body), json: async () => body }); + if (url.endsWith('/all/last_update') || url.endsWith('/last_update.json')) return ok(lastUpdate); + let file = null; + for (const [ep, fn] of Object.entries(ENDPOINT_FILE)) if (url.endsWith('/' + ep)) file = fn; + if (!file) file = path.basename(url); + if (tables[file]) return ok(tables[file]); + return ok(JSON.parse(fs.readFileSync(path.join(BUNDLED, file), 'utf8'))); + }; + fetch.calls = calls; + return fetch; +} + +module.exports = { makeFetch, bundledLastUpdate }; diff --git a/test/playground.test.js b/test/playground.test.js new file mode 100644 index 0000000..9ea167e --- /dev/null +++ b/test/playground.test.js @@ -0,0 +1,111 @@ +'use strict'; + +// Playground: the shared page stays identical to the Python SDK's copy, and tools/server.js +// implements the server contract (docs/playground-api.md). The server runs without readers. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { check } = require('../scripts/check_playground_sync'); + +describe('tools/playground.html sync', () => { + test('identical to the Python SDK copy (skipped when unreachable)', async () => { + const r = await check(); + if (r.status === 'unreachable') return; // no sibling checkout, offline + expect(r).toMatchObject({ status: 'identical' }); + }); +}); + +describe('playground server contract', () => { + let proc; + let base; + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tigertag-pg-')); + + beforeAll(async () => { + const port = 20000 + Math.floor(Math.random() * 20000); + base = `http://127.0.0.1:${port}`; + proc = spawn(process.execPath, [path.join(__dirname, '..', 'tools', 'server.js'), String(port)], { + env: { ...process.env, TIGERTAG_PLAYGROUND_NO_NFC: '1', TIGERTAG_OFFLINE: '1', TIGERTAG_DATA_DIR: dataDir }, + stdio: 'ignore', + }); + for (let i = 0; i < 50; i++) { + try { await fetch(`${base}/api/version`); return; } catch { await new Promise((r) => setTimeout(r, 100)); } + } + throw new Error('server did not start'); + }); + afterAll(() => { proc && proc.kill(); fs.rmSync(dataDir, { recursive: true, force: true }); }); + + const post = (url, body) => fetch(base + url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + + test('GET /api/version names the SDK', async () => { + const v = await (await fetch(`${base}/api/version`)).json(); + expect(v).toMatchObject({ sdk: 'javascript', label: 'JS SDK', style: 'camel', install: 'npm install tigertag' }); + expect(v.version).toBe(require('../package.json').version); + }); + + test('POST /api/build (snake_case fields) → POST /api/parse round trip', async () => { + const built = await (await post('/api/build', { + id_material: 38219, id_aspect_1: 104, id_type: 142, id_brand: 57632, measure: 1000, id_unit: 21, + color1_r: 1, color1_g: 2, color1_b: 3, color1_a: 255, tag_count: 2, tag_index: 1, timestamp: 5, measure_available: 800, + })).json(); + expect(built.payload).toMatch(/^[0-9a-f]{160}$/); + expect(built).toMatchObject({ tag_info: 0x12, tag_count: 2, tag_index: 1 }); + const parsed = await (await post('/api/parse', { uid: '04aabbccddeeff', payload: built.payload })).json(); + for (const k of ['pretty', 'describe', 'verify', 'raw_dict', 'dict', 'validate', 'tag_info', 'tag_count', 'tag_index']) { + expect(parsed).toHaveProperty(k); + } + expect(parsed.raw_dict.measure_available).toBe(800); + }); + + test('GET /api/nfc/events: readers:status first (SSE)', async () => { + const ctrl = new AbortController(); + const resp = await fetch(`${base}/api/nfc/events`, { signal: ctrl.signal }); + expect(resp.headers.get('content-type')).toMatch(/text\/event-stream/); + const reader = resp.body.getReader(); + const { value } = await reader.read(); + ctrl.abort(); + const text = Buffer.from(value).toString('utf8'); + expect(text.startsWith('data: ')).toBe(true); + const msg = JSON.parse(text.slice(6).split('\n\n')[0]); + expect(msg).toMatchObject({ type: 'readers:status', readers: [], nfc: false, hint: 'npm install ws nfc-pcsc' }); + }); + + test('POST /api/nfc/plan: 36 UPDATE BINARY APDUs, signature pages always 00', async () => { + const payload = 'ab'.repeat(80) + 'cd'.repeat(64); + const plan = await (await post('/api/nfc/plan', { payload })).json(); + expect(plan.pages).toBe(36); + expect(plan.signature_dropped).toBe(true); + expect(plan.apdus[0]).toBe('FF D6 00 04 04 AB AB AB AB'); + expect(plan.apdus[20]).toBe('FF D6 00 18 04 00 00 00 00'); + expect(plan.apdus[35]).toBe('FF D6 00 27 04 00 00 00 00'); + expect((await post('/api/nfc/plan', { payload: 'ab'.repeat(10) })).status).toBe(400); + }); + + test('POST /api/nfc/burn: bad length → 400; no reader → just burn:done', async () => { + expect((await post('/api/nfc/burn', { payload: 'ab' })).status).toBe(400); + const out = await (await post('/api/nfc/burn', { reqId: 7, payload: '00'.repeat(80) })).json(); + expect(out.messages).toEqual([{ type: 'burn:done', reqId: 7 }]); + const read = await (await post('/api/nfc/read', { reqId: 8 })).json(); + expect(read.messages).toEqual([{ type: 'read:done', reqId: 8 }]); + }); + + test('GET /api/db/table/ serves the table in use; /api/db/info lists them by file', async () => { + const r = await fetch(`${base}/api/db/table/id_material.json`); + expect(r.status).toBe(200); + expect(r.headers.get('x-tigertag-source')).toBe('bundled'); + expect(Array.isArray(await r.json())).toBe(true); + expect((await fetch(`${base}/api/db/table/nope.json`)).status).toBe(404); + const info = await (await fetch(`${base}/api/db/info`)).json(); + expect(Object.keys(info.tables)).toContain('id_material.json'); + expect(info).toMatchObject({ offline: true, custom: false }); + }); + + test('GET /api/catalog/ works offline from the bundled copy', async () => { + const out = await (await fetch(`${base}/api/catalog/3527039449`)).json(); + expect(out.entry.title).toBe('Rapid TPU 95A - Black'); + expect(out.fields).toMatchObject({ product_id: 3527039449, nozzle_temp_min: 200, id_aspect_2: 0 }); + expect(out.catalog.source).toBe('bundled'); + expect((await fetch(`${base}/api/catalog/123`)).status).toBe(404); + }); +}); diff --git a/test/refdata.test.js b/test/refdata.test.js new file mode 100644 index 0000000..ad33385 --- /dev/null +++ b/test/refdata.test.js @@ -0,0 +1,213 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); +const { TigerTag, TigerTagDB, loadCatalog } = require('../src/index'); +const { clearCatalogMemo } = require('../src/catalog'); +const { makeFetch, bundledLastUpdate } = require('./helpers/mock_fetch'); + +const BUNDLED = path.join(__dirname, '..', 'database'); +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'tigertag-ref-')); +const ONLINE = { offline: false }; + +let dataDir; +beforeEach(() => { dataDir = tmp(); clearCatalogMemo(); }); +afterEach(() => fs.rmSync(dataDir, { recursive: true, force: true })); + +function writeDownloaded(dir, file, data, key, ts) { + fs.writeFileSync(path.join(dir, file), JSON.stringify(data)); + const luFile = path.join(dir, 'last_update.json'); + const lu = fs.existsSync(luFile) ? JSON.parse(fs.readFileSync(luFile, 'utf8')) : {}; + lu[key] = ts; + fs.writeFileSync(luFile, JSON.stringify(lu)); +} + +describe('reference tables: precedence', () => { + test('bundled copy by default, no network with offline', () => { + const fetch = makeFetch(); + const db = new TigerTagDB({ dataDir, offline: true, fetch }); + expect(db.material(38219).label).toBe('PLA'); + expect(db.info().tables.filament_materials.source).toBe('bundled'); + expect(fetch.calls).toEqual([]); + }); + + test('a downloaded table newer than the bundled one wins; an older one is ignored', () => { + const lu = bundledLastUpdate(); + writeDownloaded(dataDir, 'id_brand.json', [{ id: 1, name: 'Newer' }], 'brands', lu.brands + 1); + writeDownloaded(dataDir, 'id_type.json', [{ id: 142, label: 'Older' }], 'types', lu.types - 1); + const db = new TigerTagDB({ dataDir, offline: true }); + expect(db.brand(1).name).toBe('Newer'); + expect(db.info().tables.brands.source).toBe('downloaded'); + expect(db.type(142).label).toBe('Filament'); + expect(db.info().tables.types.source).toBe('bundled'); + }); + + test('custom dbPath is used exclusively: missing file → clear error, no fallback', () => { + const custom = tmp(); + for (const f of TigerTagDB.REQUIRED_FILES) fs.copyFileSync(path.join(BUNDLED, f), path.join(custom, f)); + fs.writeFileSync(path.join(custom, 'id_brand.json'), JSON.stringify([{ id: 7, name: 'Mine' }])); + const db = new TigerTagDB({ dbPath: custom }); + expect(db.brand(7).name).toBe('Mine'); + expect(db.brand(19961)).toBeNull(); // not silently taken from the bundled copy + expect(db.info().tables.brands.source).toBe('custom'); + + fs.rmSync(path.join(custom, 'id_type.json')); + expect(() => new TigerTagDB({ dbPath: custom })).toThrow(/missing: id_type\.json.*used exclusively/s); + fs.rmSync(custom, { recursive: true, force: true }); + }); + + test('custom dbPath never triggers the automatic check', async () => { + const custom = tmp(); + for (const f of TigerTagDB.REQUIRED_FILES) fs.copyFileSync(path.join(BUNDLED, f), path.join(custom, f)); + const fetch = makeFetch(); + const db = await TigerTagDB.open({ dbPath: custom, ...ONLINE, fetch }); + await db.ready; + expect(fetch.calls).toEqual([]); + fs.rmSync(custom, { recursive: true, force: true }); + }); +}); + +describe('reference tables: automatic daily check', () => { + test('open(): one last_update request, only the changed table is downloaded', async () => { + const lu = { ...bundledLastUpdate(), brands: bundledLastUpdate().brands + 10 }; + const fetch = makeFetch({ lastUpdate: lu, tables: { 'id_brand.json': [{ id: 1, name: 'Fresh' }] } }); + const db = await TigerTagDB.open({ dataDir, ...ONLINE, fetch }); + expect(fetch.calls.filter((u) => u.includes('last_update'))).toHaveLength(1); + expect(fetch.calls.filter((u) => !u.includes('last_update'))).toEqual([ + 'https://api.tigertag.io/api:tigertag/brand/get/all', + ]); + expect(db.brand(1).name).toBe('Fresh'); + expect(db.info().tables.brands.source).toBe('downloaded'); + expect(db.info().lastCheck).not.toBeNull(); + }); + + test('throttled: at most once per maxAge (default 1 day)', async () => { + const fetch = makeFetch(); + await TigerTagDB.open({ dataDir, ...ONLINE, fetch }); + const n = fetch.calls.length; + await TigerTagDB.open({ dataDir, ...ONLINE, fetch }); + expect(fetch.calls.length).toBe(n); // checked less than a day ago + const st = path.join(dataDir, 'db_state.json'); + const s = JSON.parse(fs.readFileSync(st, 'utf8')); + s.lastCheck = new Date(Date.now() - 25 * 3600 * 1000).toISOString(); + fs.writeFileSync(st, JSON.stringify(s)); + await TigerTagDB.open({ dataDir, ...ONLINE, fetch }); + expect(fetch.calls.length).toBeGreaterThan(n); + }); + + test('API down → GitHub mirror; everything down → no throw, bundled data kept', async () => { + const lu = { ...bundledLastUpdate(), aspects: bundledLastUpdate().aspects + 1 }; + const mirror = makeFetch({ lastUpdate: lu, failApi: true }); + await TigerTagDB.open({ dataDir, ...ONLINE, fetch: mirror }); + expect(mirror.calls.some((u) => u.startsWith('https://raw.githubusercontent.com') && u.endsWith('id_aspect.json'))).toBe(true); + + const other = tmp(); + const down = makeFetch({ failAll: true }); + const db = await TigerTagDB.open({ dataDir: other, ...ONLINE, fetch: down }); + expect(db.material(38219).label).toBe('PLA'); + expect(db.info().lastError).toMatch(/unreachable/); + fs.rmSync(other, { recursive: true, force: true }); + }); + + test('constructor: background check, non-blocking, db.ready resolves', async () => { + const other = tmp(); + const lu = { ...bundledLastUpdate(), brands: bundledLastUpdate().brands + 5 }; + const fetch = makeFetch({ lastUpdate: lu, tables: { 'id_brand.json': [{ id: 2, name: 'Bg' }] } }); + const db = new TigerTagDB({ dataDir: other, ...ONLINE, fetch }); + expect(db.brand(2)).toBeNull(); // constructor did not wait + await db.ready; + expect(db.brand(2).name).toBe('Bg'); // reloaded when the check finished + fs.rmSync(other, { recursive: true, force: true }); + }); + + test('autoUpdate: false (and the deprecated autoSync: false) disable the automatic check', async () => { + const fetch = makeFetch(); + await TigerTagDB.open({ dataDir, ...ONLINE, fetch, autoUpdate: false }); + new TigerTagDB({ dataDir: tmp(), ...ONLINE, fetch, autoSync: false }); + expect(fetch.calls).toEqual([]); + }); +}); + +describe('offline switch', () => { + test('offline: true and TIGERTAG_OFFLINE=1 → fetch is never called', async () => { + const fetch = makeFetch(); + const db = await TigerTagDB.open({ dataDir, offline: true, fetch }); + await db.ready; + await expect(db.update()).rejects.toThrow(/offline/); + const envDb = await TigerTagDB.open({ dataDir, fetch }); // env set by test/setup-env.js + await envDb.ready; + await loadCatalog({ dataDir, fetch, force: true }); + await TigerTag.fromCatalog(3527039449, { dataDir, fetch, force: true }); + expect(fetch.calls).toEqual([]); + }); +}); + +describe('manual update', () => { + test('update() downloads changed tables; force re-downloads all; returns the files', async () => { + const lu = { ...bundledLastUpdate(), measure_units: bundledLastUpdate().measure_units + 1 }; + const fetch = makeFetch({ lastUpdate: lu }); + const db = new TigerTagDB({ dataDir, ...ONLINE, fetch, autoUpdate: false }); + expect(await db.update()).toEqual(['id_measure_unit.json', 'last_update.json']); + expect(await db.update()).toEqual([]); + const all = await db.update({ force: true }); + expect(all).toHaveLength(8); + expect(await db.sync()).toEqual([]); // deprecated alias + }); + + test('update({ catalog: true }) also refreshes the catalogue', async () => { + const tables = makeFetch(); + const fetch = async (url, opts) => (url.includes('id_catalog.json') + ? { ok: true, status: 200, headers: { get: () => null }, text: async () => JSON.stringify([{ id: 5, title: 'X', RFID_Data: null }]) } + : tables(url, opts)); + const db = new TigerTagDB({ dataDir, ...ONLINE, fetch, autoUpdate: false }); + const files = await db.update({ catalog: true }); + expect(files).toContain('id_catalog.json'); + expect(db.info().catalog).toMatchObject({ downloaded: true, source: 'downloaded', count: 1 }); + }); +}); + +describe('info()', () => { + test('per-table source / path / timestamp, data dir, offline, catalogue', () => { + const info = new TigerTagDB({ dataDir, offline: true }).info(); + expect(info).toMatchObject({ offline: true, dataDir: path.resolve(dataDir), customDir: null, lastCheck: null }); + expect(Object.keys(info.tables)).toHaveLength(7); + expect(info.tables.versions).toMatchObject({ file: 'id_version.json', source: 'bundled' }); + expect(info.tables.versions.timestamp).toBe(bundledLastUpdate().versions); + expect(info.catalog).toMatchObject({ source: 'bundled', downloaded: false }); + expect(info.catalog.count).toBeGreaterThan(10000); + }); +}); + +describe('catalogue: bundled gzip copy', () => { + test('fromCatalog works offline from database/id_catalog.json.gz', async () => { + const tag = await TigerTag.fromCatalog(3527039449, { dataDir, offline: true, timestamp: 1 }); + expect(tag.idProduct).toBe(3527039449); + expect([tag.nozzleTempMin, tag.nozzleTempMax, tag.measure]).toEqual([200, 250, 1000]); + }); +}); + +describe('CLI', () => { + const cli = path.join(__dirname, '..', 'bin', 'tigertag.js'); + const preload = path.join(__dirname, 'helpers', 'cli_mock_fetch.js'); + + test('tigertag update --data-dir PATH downloads the changed tables', () => { + const env = { ...process.env, TIGERTAG_OFFLINE: '', TIGERTAG_MOCK_BRANDS_TS: String(bundledLastUpdate().brands + 1) }; + const out = execFileSync(process.execPath, ['-r', preload, cli, 'update', '--data-dir', dataDir], { env, encoding: 'utf8' }); + expect(out).toMatch(/Updated 2 file\(s\): id_brand\.json, last_update\.json/); + expect(JSON.parse(fs.readFileSync(path.join(dataDir, 'id_brand.json'), 'utf8'))[0].name).toBe('Mock brand'); + const again = execFileSync(process.execPath, ['-r', preload, cli, 'update', '--data-dir', dataDir], { env, encoding: 'utf8' }); + expect(again).toMatch(/already up to date/); + }); + + test('tigertag update --offline fails; parsing with --offline works', () => { + const r = spawnSync(process.execPath, [cli, 'update', '--offline', '--data-dir', dataDir], { encoding: 'utf8' }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/offline/); + const fixture = path.join(__dirname, 'fixtures', 'tigertag_pla_rosa3d.bin'); + const p = spawnSync(process.execPath, [cli, fixture, '--offline', '--data-dir', dataDir], { encoding: 'utf8' }); + expect(p.status).toBe(0); + expect(p.stdout).toMatch(/TigerTag/); + }); +}); diff --git a/test/setup-env.js b/test/setup-env.js new file mode 100644 index 0000000..b8c428e --- /dev/null +++ b/test/setup-env.js @@ -0,0 +1,11 @@ +'use strict'; + +// Every test runs offline and with a throw-away data dir: no network call and +// nothing written to the user's real cache folder unless a test opts in +// explicitly (offline: false + a mocked fetch + its own temporary folder). +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +process.env.TIGERTAG_OFFLINE = '1'; +process.env.TIGERTAG_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'tigertag-test-data-')); diff --git a/test/tag.test.js b/test/tag.test.js index 7b2fcfc..ec36033 100644 --- a/test/tag.test.js +++ b/test/tag.test.js @@ -48,6 +48,7 @@ function makePayload({ color2R = 0, color2G = 255, color2B = 0, color3R = 0, color3G = 0, color3B = 255, tdRaw = 0, + tagInfo = 0, customMessage = Buffer.alloc(0), measureAvail = 800, includeSig = false, @@ -71,7 +72,7 @@ function makePayload({ buf.writeUInt16BE(nozzleMax, o); o += 2; buf[o++] = dryTemp; buf[o++] = dryTime; buf[o++] = bedMin; buf[o++] = bedMax; buf.writeUInt32BE(timestamp >>> 0, o); o += 4; - buf[o++] = color2R; buf[o++] = color2G; buf[o++] = color2B; buf[o++] = 0; + buf[o++] = color2R; buf[o++] = color2G; buf[o++] = color2B; buf[o++] = tagInfo; buf[o++] = color3R; buf[o++] = color3G; buf[o++] = color3B; buf[o++] = 0; buf.writeUInt16BE(tdRaw, o); o += 2; buf[o++] = 0; buf[o++] = 0; @@ -545,3 +546,177 @@ describe('Fixture smoke tests', () => { }); } }); + +// ── Tag index / tag count (protocol v2.2) ──────────────────────────────────── + +describe('Tag index / tag count', () => { + const REQUIRED = { + idMaterial: 38219, idAspect1: 1, idType: 0x8E, idBrand: 1, + measure: 1000, idUnit: 1, + color1R: 255, color1G: 0, color1B: 0, color1A: 255, + }; + + test.each([ + ['0x00', 0x00, 0, 0], + ['0x11', 0x11, 1, 1], + ['0x12', 0x12, 2, 1], + ['0x22', 0x22, 2, 2], + ])('parse byte %s → count %i, index %i', (_label, byte, count, index) => { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: byte })); + expect(tag.tagInfo).toBe(byte); + expect(tag.tagCount).toBe(count); + expect(tag.tagIndex).toBe(index); + }); + + test('byte +39 round-trips through toBytes()', () => { + const payload = makePayload({ tagInfo: 0x22 }); + const tag = TigerTag.fromPages(TEST_UID, payload); + const out = tag.toBytes(); + expect(out[39]).toBe(0x22); + expect(out.equals(payload)).toBe(true); + }); + + test('create({ tagCount: 2, tagIndex: 2 }) writes 0x22', () => { + const tag = TigerTag.create({ ...REQUIRED, tagCount: 2, tagIndex: 2 }); + expect(tag.tagInfo).toBe(0x22); + expect(tag.toBytes()[39]).toBe(0x22); + expect(tag.validate()).toEqual([]); + }); + + test('twin tag: create() with one explicit timestamp → identical tags except byte +39', () => { + const ts = 812345678; + const chip1 = TigerTag.create({ ...REQUIRED, timestamp: ts, tagCount: 2, tagIndex: 1 }).toBytes(); + const chip2 = TigerTag.create({ ...REQUIRED, timestamp: ts, tagCount: 2, tagIndex: 2 }).toBytes(); + expect(chip1.readUInt32BE(32)).toBe(ts); + expect(chip2.readUInt32BE(32)).toBe(ts); + expect([chip1[39], chip2[39]]).toEqual([0x12, 0x22]); + const diff = [...chip1].map((b, i) => (b !== chip2[i] ? i : -1)).filter((i) => i >= 0); + expect(diff).toEqual([39]); + }); + + test('create() defaults to 0x00 (unknown), asInit() is 0x00', () => { + expect(TigerTag.create(REQUIRED).tagInfo).toBe(0); + expect(TigerTag.asInit().toBytes()[39]).toBe(0); + }); + + test('create() rejects values that do not fit in a nibble', () => { + expect(() => TigerTag.create({ ...REQUIRED, tagCount: 16 })).toThrow(RangeError); + expect(() => TigerTag.create({ ...REQUIRED, tagIndex: -1 })).toThrow(RangeError); + }); + + test('validate() warns when index > count', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x32 })); + expect(tag.validate().some((w) => w.includes('Tag index (3) > tag count (2)'))).toBe(true); + }); + + test('validate() warns when count is 1 and index > 1', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x21 })); + expect(tag.validate().some((w) => w.includes('Single-tag item'))).toBe(true); + }); + + test('validate() accepts count known / index unknown and count unknown / index set', () => { + for (const byte of [0x00, 0x02, 0x20, 0x11, 0x12]) { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: byte })); + expect(tag.validate().filter((w) => /tag (index|count)|single-tag/i.test(w))).toEqual([]); + } + }); + + test('validate() warns when tagInfo is not a u8', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload()).patch({ tagInfo: 0x1FF }); + expect(tag.validate().some((w) => w.includes('out of range'))).toBe(true); + }); + + test('patch() updates count and index independently', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x11 })); + const twin = tag.patch({ tagCount: 2 }); + expect(twin.tagInfo).toBe(0x12); + const second = twin.patch({ tagIndex: 2 }); + expect(second.tagInfo).toBe(0x22); + expect(tag.tagInfo).toBe(0x11); // immutable + expect(tag.patch({ tagInfo: 0x12 }).tagIndex).toBe(1); + expect(() => tag.patch({ tagIndex: 16 })).toThrow(RangeError); + }); + + test('patchFromRawDict() / fromRawDict() accept tag_info', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload()); + expect(tag.patchFromRawDict({ tag_info: 0x22 }).tagInfo).toBe(0x22); + expect(TigerTag.fromRawDict({ ...tag.toRawDict(), tag_info: 0x12 }).tagInfo).toBe(0x12); + }); + + test('toRawDict() exposes tag_info', () => { + const tag = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x12 })); + expect(tag.toRawDict().tag_info).toBe(0x12); + }); + + test('toDict() exposes tag_count / tag_index, null when unknown', () => { + const d = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x12 })).toDict(); + expect(d.tag_count).toBe(2); + expect(d.tag_index).toBe(1); + expect(d.protocol).toBe('TigerTag Open Source v2.2'); + const u = TigerTag.fromPages(TEST_UID, makePayload()).toDict(); + expect(u.tag_count).toBeNull(); + expect(u.tag_index).toBeNull(); + const partial = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x02 })).toDict(); + expect(partial.tag_count).toBe(2); + expect(partial.tag_index).toBeNull(); + }); + + test('describe() names the item from idType: filament, or "item" when unknown', () => { + const filament = TigerTag.fromPages(TEST_UID, makePayload({ idType: 142, tagInfo: 0x12 })); + expect(filament.describe()).toContain('Tag 1 of 2 on this filament.'); + const resin = TigerTag.fromPages(TEST_UID, makePayload({ idType: 173, tagInfo: 0x22 })); + expect(resin.describe()).toContain('Tag 2 of 2 on this resin.'); + const unknown = TigerTag.fromPages(TEST_UID, makePayload({ idType: 0xFE, tagInfo: 0x02 })); + expect(unknown.describe()).toContain('Tag ? of 2 on this item.'); + expect(TigerTag.fromPages(TEST_UID, makePayload()).describe()).not.toMatch(/Tag \S+ of/); + }); + + test('pretty() prints the tag line', () => { + const twin = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x12 })).pretty(); + expect(twin).toContain('Tag 1 of 2\n'); + const unknown = TigerTag.fromPages(TEST_UID, makePayload()).pretty(); + expect(unknown).toContain('Tag ? of ? (unknown)\n'); + const partial = TigerTag.fromPages(TEST_UID, makePayload({ tagInfo: 0x02 })).pretty(); + expect(partial).toContain('Tag ? of 2\n'); + }); + + test('patching only tagCount / tagIndex changes page 0x0D byte 3 and nothing else', () => { + const payload = makePayload({ color2R: 0x12, color2G: 0x34, color2B: 0x56, includeSig: true }); + const tag = TigerTag.fromPages(TEST_UID, payload); + const out = tag.patch({ tagCount: 2, tagIndex: 1 }).toBytes(true); + // Page 0x0D = payload bytes 36–39: color2 RGB + tagInfo + expect([...out.subarray(36, 40)]).toEqual([0x12, 0x34, 0x56, 0x12]); + const changed = [...out].map((b, i) => (b !== payload[i] ? i : -1)).filter((i) => i >= 0); + expect(changed).toEqual([39]); + // Patching color 2 keeps tagInfo + const recolored = TigerTag.fromPages(TEST_UID, out).patch({ color2R: 0xAA }); + expect(recolored.toBytes()[39]).toBe(0x12); + }); + + test('signature still verifies after changing tagInfo', () => { + const { createSign, generateKeyPairSync } = require('crypto'); + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }); + + const idTigertag = 0x01000001; + const idProduct = 0xFFFFFFFF; + const block4 = Buffer.alloc(4); block4.writeUInt32BE(idTigertag, 0); + const block5 = Buffer.alloc(4); block5.writeUInt32BE(idProduct >>> 0, 0); + + const sign = createSign('SHA256'); + sign.update(Buffer.concat([TEST_UID, block4, block5])); + const raw = sign.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }); + + const payload = Buffer.concat([makePayload({ idTigertag, idProduct }), raw]); + const tag = TigerTag.fromPages(TEST_UID, payload); + + const db = new TigerTagDB(); + db._versions = [{ id: idTigertag, label: 'test', public_key: publicKeyPem }]; + expect(tag.verify(db).status).toBe(SignatureResult.VALID); + + const twin = tag.patch({ tagCount: 2, tagIndex: 1 }); + const reread = TigerTag.fromPages(TEST_UID, twin.toBytes(true)); + expect(reread.tagInfo).toBe(0x12); + expect(reread.verify(db).status).toBe(SignatureResult.VALID); + }); +}); diff --git a/tools/burn_plan.js b/tools/burn_plan.js new file mode 100644 index 0000000..633b423 --- /dev/null +++ b/tools/burn_plan.js @@ -0,0 +1,42 @@ +'use strict'; + +const FIRST_PAGE = 0x04; // first user page +const SIG_FIRST_PAGE = 0x18; // signature R starts here +const LAST_PAGE = 0x27; // signature S ends here +const USER_LEN = 80; // pages 0x04–0x17 +const FULL_LEN = 144; // pages 0x04–0x27 + +/** + * Build the page-by-page write plan for a burn. + * + * The plan always covers pages 0x04–0x27 (36 pages): + * - pages 0x04–0x17 get the payload's first 80 bytes (the tag data); + * - pages 0x18–0x27 (the ECDSA signature) are ALWAYS written as 00 00 00 00. + * A playground never writes a signature: only a certified manufacturer can + * issue one, and a signature copied from another chip is invalid anyway since + * it covers that chip's UID. Clearing the pages means a burn never leaves a + * stale signature behind. Pages 0–3 (UID, lock, capability container) and + * 0x28+ (configuration) are never part of the plan. + * + * @param {Buffer|Uint8Array} payload - 80 or 144 bytes starting at page 0x04. + * @returns {{ signatureDropped: boolean, pages: Array<{ page: number, data: Buffer }> }} + * signatureDropped is true when the input carried a non-zero signature that was not written. + * @throws {RangeError} When the payload is not 80 or 144 bytes long. + */ +function buildBurnPlan(payload) { + const buf = Buffer.from(payload || []); + if (buf.length !== USER_LEN && buf.length !== FULL_LEN) { + throw new RangeError(`burn payload must be ${USER_LEN} or ${FULL_LEN} bytes, got ${buf.length}`); + } + const signatureDropped = buf.subarray(USER_LEN).some((b) => b !== 0); + const image = Buffer.alloc(FULL_LEN); // signature area stays 00 + buf.copy(image, 0, 0, USER_LEN); + const pages = []; + for (let page = FIRST_PAGE; page <= LAST_PAGE; page++) { + const o = (page - FIRST_PAGE) * 4; + pages.push({ page, data: Buffer.from(image.subarray(o, o + 4)) }); + } + return { signatureDropped, pages }; +} + +module.exports = { buildBurnPlan, FIRST_PAGE, SIG_FIRST_PAGE, LAST_PAGE }; diff --git a/tools/burn_targets.js b/tools/burn_targets.js new file mode 100644 index 0000000..9a06778 --- /dev/null +++ b/tools/burn_targets.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * Select the readers a `burn:write` request targets. + * + * Without a filter every reader holding a card is targeted (historical + * behaviour). With a filter only the named reader(s) are targeted — the + * playground uses this for twin tags, where each reader gets its own payload. + * + * @param {Map} readers - readerName → reader state. + * @param {string|string[]|null|undefined} [filter] - Reader id(s) / name(s) to keep. + * @returns {Array<[string, object]>} [readerName, state] pairs, in map order. + */ +function selectBurnTargets(readers, filter) { + const wanted = filter == null || filter === '' + ? null + : new Set(Array.isArray(filter) ? filter : [filter]); + return [...readers.entries()].filter(([name, info]) => + info && info.hasCard && (!wanted || wanted.has(name) || wanted.has(info.id))); +} + +module.exports = { selectBurnTargets }; diff --git a/tools/playground.html b/tools/playground.html index 0dc5d1f..cf43da5 100644 --- a/tools/playground.html +++ b/tools/playground.html @@ -4,6 +4,8 @@ TigerTag · Open Source RFID Protocol Playground + + + +/* Info bubble next to a field label */ +.info{position:relative;display:inline-flex;vertical-align:-2px;margin-left:4px;color:var(--muted);cursor:help;outline:none} +.info svg{width:13px;height:13px;display:block} +.info:hover,.info:focus-visible{color:var(--accent)} +.info .bubble{position:absolute;top:calc(100% + 6px);left:-8px;z-index:50;width:230px;padding:9px 11px;border-radius:8px;background:#1b1d22;color:#f3f4f6;font-size:11px;font-weight:400;line-height:1.45;text-transform:none;letter-spacing:0;white-space:normal;box-shadow:0 6px 20px rgba(0,0,0,.25);visibility:hidden} +.info.right .bubble{left:auto;right:-8px} +.info:hover .bubble,.info:focus .bubble{visibility:visible} +.info .bubble b{color:#fff;font-size:inherit;font-weight:700} +
@@ -327,20 +376,26 @@

TigerTag

Open Source RFID Protocol - · JS SDK + · SDK · Playground
SDK v…
+
+ + +
- - - - + + + + +
+