tigertag 1.0.2 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,140 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
+ ## [1.0.6] — 2026-05-22
7
+
8
+ ### Fixed
9
+ - `toRawDict()` — `color_r2/g2/b2` and `color_r3/g3/b3` are forced to `0` when
10
+ `num_colors < 2/3`. Consumers always receive clean, zero-padded values for inactive
11
+ color slots; EEPROM garbage from the chip can never leak through to callers of
12
+ `toRawDict()`.
13
+ - Playground `SDK Input` — `color2R/G/B` and `color3R/G/B` are omitted from the
14
+ `TigerTag.create()` call when the aspect indicates fewer than 2 or 3 active colors.
15
+ The SDK's natural default of `0` handles the zeroing; no spurious color bytes are
16
+ passed.
17
+ - `_baseUnitFields()` — unrecognised `id_unit` values now return
18
+ `{ measure_gr: 0, measure_available_gr: 0 }` instead of `{}`. Consumers always receive a
19
+ defined, zero-safe value; chips with corrupt or unknown units no longer propagate garbage
20
+ weight values.
21
+
22
+ ### Added
23
+ - `create()` now validates required fields and throws a descriptive `Error` listing every
24
+ missing field: `idMaterial`, `idAspect1`, `idType`, `idBrand`, `color1R`, `color1G`,
25
+ `color1B`, `color1A`, `measure`, `idUnit`. Validation checks **presence only** — any value
26
+ including `0` is accepted once the field is explicitly provided.
27
+ - `toRawDict()` exposes two new fields derived from the aspect DB:
28
+ - `num_colors` — number of active color slots (1 for Basic/Silk/etc., 2 for Bicolor, 3 for
29
+ Tricolor/Rainbow). Aspect 2 is checked first; falls back to Aspect 1.
30
+ - `color_list` — `string[]` of `#RRGGBB` hex strings, one per active slot only. Consumers
31
+ no longer need to filter inactive slots themselves.
32
+
33
+ ## [1.0.5] — 2026-05-22
34
+
35
+ ### Added
36
+ - `TigerTag._baseUnitFields()` — static helper that converts `measure` / `measureAvailable`
37
+ to their canonical base unit and returns convenience fields (all 11 unit IDs covered):
38
+ - **Weight** (mg / g / kg → grams): `measure_gr` + `measure_available_gr`
39
+ - **Volume** (ml / cl / L / m³ → millilitres): `measure_ml` + `measure_available_ml`
40
+ - **Size** (mm / cm / m → millimetres): `measure_mm` + `measure_available_mm`
41
+ - **Area** (m² → square millimetres): `measure_mm2` + `measure_available_mm2`
42
+ - `toRawDict()` now includes the convenience fields immediately after `measure_available`.
43
+ Developers read a single field in grams (or ml/mm/mm²) without caring about `id_unit`.
44
+ - `toDict()` `.measure` block now includes the same convenience fields alongside `initial`,
45
+ `available`, `unit`, and `percent`.
46
+ - `pretty()` Quantity section appends `(= 750 g)` hint on Initial and Available lines when
47
+ the stored unit is not already the canonical base unit (g / ml / mm).
48
+ - `describe()` Quantity sentence appends `— 750 g available, 1000 g total` when applicable.
49
+
50
+ ## [1.0.4] — 2026-05-22
51
+
52
+ ### Added
53
+ - `tools/server.js`: `POST /api/build` endpoint — accepts `TigerTag.create()` camelCase kwargs as
54
+ JSON body, calls `TigerTag.create(kwargs).toBytes(false)` server-side, returns
55
+ `{ payload: "<80-byte hex>" }`. Makes the SDK the authoritative serializer for chip payload
56
+ generation; the browser never computes chip bytes itself.
57
+ - Playground: **Available qty auto-link** — the Available Qty field automatically mirrors Initial
58
+ Qty until the user manually edits it. Link is restored on preset load, API fetch, or NFC scan.
59
+ Removes the old "(0 = same as initial)" convention.
60
+ - Playground: **Raw Hex Reader** (`🔬 Raw Read` button) — reads all 144 bytes (pages 4–39) from
61
+ every connected reader that holds a card and displays them in a structured table: page number,
62
+ byte offset, four individual hex bytes (B0–B3), big-endian u32 decimal, and field label. The
63
+ signature pages (24–39) are visually dimmed and preceded by a separator row.
64
+ - Uses the new `read:request` / `read:result` / `read:done` WebSocket protocol.
65
+ - Supports **multiple readers simultaneously**: each reader gets its own collapsible panel,
66
+ displayed side-by-side (flex row). Panels use the same rail UX as SDK Input / Output.
67
+ - **Copy hex** button per panel — copies one line per page (`0x04 B0 B1 B2 B3`) to the clipboard.
68
+ Includes page hex prefix on each line for direct cross-reference with NFC documentation.
69
+ Button shows `✓ Copied!` (green, 1.5 s) after a successful copy so the user gets clear feedback.
70
+ - **Annotated Field column** — each field cell now shows decoded values inline:
71
+ `(value) field_name · (value) field_name · …`. Values are read directly from the raw bytes
72
+ (no extra server round-trip). customMessage pages show the decoded ASCII chars `("azer")`.
73
+ Signature pages remain static (raw bytes only). Implemented via `_buildFieldLabel(page, chunk)`.
74
+ - **Page Hex column** — new "Hex" column between Page (decimal) and Offset (byte offset) shows
75
+ the page address in hex (`0x04`, `0x05`, …, `0x27`) for quick cross-reference with the spec.
76
+ - Hex table uses `<table>` with `table-layout:fixed` and `<colgroup>` for pixel-perfect column
77
+ alignment guaranteed by the browser layout engine (no character-padding hacks).
78
+ - Font stack: JetBrains Mono → Fira Code → Cascadia Code → SF Mono → system monospace — same
79
+ terminal-grade font as shell hex viewers.
80
+ - `tools/server.js`: `read:request` WebSocket message type — broadcasts `read:result` per reader
81
+ (uid, hex payload, byte count) then `read:done` when all readers have been polled. Tries 144 bytes
82
+ first, falls back to 80 bytes for smaller chips.
83
+
84
+ ### Fixed
85
+ - `TigerTag.create()`: new optional `measureAvailable` parameter — previously partial spools were
86
+ silently encoded as full (defaulted to `measure`). Passing `measureAvailable` now encodes the
87
+ actual remaining quantity correctly. Omitting it preserves the previous default behaviour
88
+ (`measure`, i.e. full spool).
89
+ - Playground: payload generation now always goes through `POST /api/build` (SDK on the server);
90
+ the browser no longer computes the binary chip format itself.
91
+ - Playground: binary garbage in the chip's `customMessage` field (invalid UTF-8, non-printable
92
+ bytes) is silently discarded when populating the form — prevents garbage re-encoding on rewrite.
93
+
94
+ ### Changed
95
+ - Playground: `timestamp` is always `null` in `TigerTag.create()` calls — the SDK sets its own
96
+ write-time timestamp automatically.
97
+ - Playground: manufacturing date form field removed (timestamp is now always set by the SDK at
98
+ write time).
99
+
100
+ ## [1.0.3] — 2026-05-21
101
+
102
+ ### Added
103
+ - `tag.imgUrls` getter — returns CDN image URLs for all 7 size variants
104
+ (`icon16`, `icon32`, `thumbnail`, `small`, `medium`, `large`, `original`).
105
+ Works for TigerTag+ chips only (filament / resin types). Cache-busted with
106
+ `v=<timestamp>` on each call. `toRawDict()` and `toDict()` now include an
107
+ `img` field exposing all URLs.
108
+ - Playground: **Burn** button (`🔥 Burn`) — writes the generated payload to every
109
+ connected ACR122U / PC-SC reader that currently holds a card. Writes 20 pages
110
+ (pages 4–23, 80 bytes) sequentially via `reader.write()`. Result reported per
111
+ reader via WS (`burn:result`) with success/error detail; `burn:done` signals
112
+ completion.
113
+ - Playground: **SDK Input panel** — new collapsible panel showing the exact
114
+ `TigerTag.create({...})` call for the current tag (write flow). Symmetric to
115
+ the SDK Output panel (same rail style, same collapse direction). Opens
116
+ automatically when Burn is clicked; closes when Generate / NFC scan / Import
117
+ opens SDK Output. Includes a Copy button.
118
+ - Playground: **dynamic SDK version badge** — fetches `GET /api/version` from
119
+ the dev server and displays the real `package.json` version instead of a
120
+ hardcoded string.
121
+ - `tools/server.js`: `GET /api/version` endpoint — returns `{ version: string }`
122
+ from `package.json`. Used by the playground badge.
123
+ - Playground: `Generate & Preview` button is now pinned to the bottom of the
124
+ sidebar and never scrolls out of view regardless of form length.
125
+
126
+ ### Fixed
127
+ - `TigerTag.fromCloudDoc()`: TD (HueForge Transmission Distance) was stored as a
128
+ float in Firestore (e.g. `1.5`) but was being passed directly as `tdRaw` to the
129
+ chip, producing wrong values. Now correctly converts: `tdRaw = Math.round(doc.TD × 10)`.
130
+ Reading is unchanged: `tag.tdValue = tag.tdRaw / 10` remains transparent.
131
+
132
+ ### Changed
133
+ - Playground: 4-column layout — sidebar | center | **SDK Input** | **SDK Output**
134
+ (previously 3-column: sidebar | center | SDK). Both SDK panels are collapsible
135
+ with adjacent rails that touch when either or both are closed.
136
+ - Playground: SDK Output toggle label renamed from "SDK" to "SDK Output".
137
+ - Playground: smart panel state on action — Generate / NFC scan / Import opens
138
+ SDK Output and closes SDK Input; Burn opens SDK Input and closes SDK Output.
139
+
6
140
  ## [1.0.2] — 2026-05-21
7
141
 
8
142
  ### Added
package/README.md CHANGED
@@ -125,12 +125,22 @@ Or via npm:
125
125
  npm run playground
126
126
  ```
127
127
 
128
- The playground has three panels:
129
- - **Left** — generate TigerTag / TigerTag+ / Init tags with presets or custom values
130
- - **Center** — parsed output cards: Protocol, Material, Colors, Print Settings, Quantity, Traceability, Cloud API
131
- - **Right** — collapsible SDK panel: `pretty()`, `verify()`, `toRawDict()`, `toDict()`, `rawApi()`, `diffApi()`
128
+ The playground has five panels:
132
129
 
133
- ### ACR122U / PC-SC live reader
130
+ | Panel | Purpose |
131
+ |-------|---------|
132
+ | **Sidebar** (left) | Build a TigerTag / TigerTag+ / Init tag: choose version, brand, material, colors, print settings. Generate button pinned at the bottom — always visible. |
133
+ | **Center** | Protocol preview cards: Protocol, Material, Colors, Print Settings, Quantity, Traceability, Cloud API |
134
+ | **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). |
135
+ | **SDK Output** (collapsible) | Shows `pretty()`, `describe()`, `verify()`, `toRawDict()`, `toDict()`, `rawApi()`, `diffApi()` — the **read** side. Opens automatically on Generate / NFC scan / Import. |
136
+ | **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. |
137
+
138
+ SDK Input / Output and Raw Hex reader panels are all collapsible via their adjacent rails.
139
+
140
+ **Available Qty auto-link** — the Available Qty field automatically mirrors Initial Qty until you
141
+ edit it manually. On NFC scan, preset load, or API fetch the link is restored to the actual values.
142
+
143
+ ### ACR122U / PC-SC live reader + Burn
134
144
 
135
145
  Place a chip on your reader and the playground auto-populates instantly — no manual action needed.
136
146
 
@@ -142,8 +152,24 @@ npm install ws nfc-pcsc
142
152
  npm run playground
143
153
  ```
144
154
 
145
- Up to **2 simultaneous USB readers** supported. Reader status shown in the playground header:
146
- `● green` = connected · `● orange pulse` = reading card
155
+ **Multiple simultaneous USB readers** supported. Each reader gets its own status badge in the
156
+ header (`● green` = connected, `● orange pulse` = reading card) and its own Raw Hex panel.
157
+
158
+ **🔥 Burn** — once a chip is on a reader, click Burn to write the current payload to all
159
+ connected readers that hold a card. Writes pages 4–23 (80 bytes) sequentially.
160
+ The SDK Input panel opens automatically so you can see exactly what was written.
161
+
162
+ **🔬 Raw Read** — reads all 144 bytes from every card-holding reader and displays the raw chip
163
+ memory as a structured hex table with field annotations. Useful for debugging and verifying burns.
164
+
165
+ Server endpoints:
166
+
167
+ | Method | Path | Response |
168
+ |--------|------|----------|
169
+ | `GET` | `/api/version` | `{ version: string }` |
170
+ | `POST` | `/api/parse` | `{ pretty, describe, verify, raw_dict, dict }` — full SDK parse of a hex payload |
171
+ | `POST` | `/api/build` | `{ payload: hex }` — `TigerTag.create(kwargs).toBytes()` — SDK-authoritative payload |
172
+ | `POST` | `/api/diff` | `{ api_data, diffs, in_sync, error }` — chip vs cloud diff |
147
173
 
148
174
  ---
149
175
 
@@ -235,8 +261,16 @@ capability container) are never part of the user data payload.
235
261
  ```js
236
262
  // Read
237
263
  tag.pretty(db, sigResult) // → string human-readable summary
264
+ // Quantity section shows "(= 750 g)" hint when unit ≠ base
265
+ tag.describe(db) // → string LLM-friendly paragraph
266
+ // Quantity sentence includes "— 750 g available, 1000 g total"
238
267
  tag.toDict(db) // → object JSON-serializable, all labels resolved
268
+ // .measure includes measure_gr/ml/mm/mm2 + measure_available_*
239
269
  tag.toRawDict() // → object raw protocol fields, no resolution
270
+ // includes measure_gr/ml/mm/mm2 + measure_available_* (base-unit)
271
+ // color_r2/g2/b2 and color_r3/g3/b3 are zeroed for inactive slots
272
+ // num_colors — active color slot count from aspect DB (1/2/3)
273
+ // color_list — string[] of #RRGGBB for active slots only
240
274
  tag.toBytes(includeSignature = false) // → Buffer re-serialize to chip bytes
241
275
  tag.validate() // → string[] sanity check — list of warnings
242
276
  tag.verify(db) // → SignatureResult
@@ -271,6 +305,8 @@ tag.manufacturingDate // Date (UTC)
271
305
  tag.stockPercent // 75.0 or null
272
306
  tag.productPageUrl // "https://tigertag.io/products/..." or null
273
307
  tag.apiUrl // "https://api.tigertag.io/..." or null
308
+ tag.imgUrls // { icon16, icon32, thumbnail, small, medium, large, original }
309
+ // CDN image URLs — TigerTag+ only; null for Maker / Init tags
274
310
  ```
275
311
 
276
312
  ---
@@ -289,6 +325,7 @@ const tag = TigerTag.create({
289
325
  nozzleTempMax: 230,
290
326
  color1R: 255, color1G: 0, color1B: 0, color1A: 255,
291
327
  measure: 1000, idUnit: 21,
328
+ // measureAvailable: 750, // optional — partial spool; defaults to measure (full)
292
329
  });
293
330
 
294
331
  // Blank TigerTag Init chip (ready for programming)
@@ -341,7 +378,7 @@ const patched2 = tag2.patchFromRawDict({ measure_available: 650 });
341
378
  | `data5` | `dryTime` |
342
379
  | `data6` | `bedTempMin` |
343
380
  | `data7` | `bedTempMax` |
344
- | `TD` | `tdRaw` |
381
+ | `TD` | `tdRaw` (float × 10 → integer, e.g. `1.5` → `15`) |
345
382
  | `weight_available` / `measure_gr` | `measureAvailable` |
346
383
 
347
384
  ### ApiDiff
@@ -670,7 +707,7 @@ Protocol spec: [github.com/TigerTag-Project/TigerTag-RFID-Guide](https://github.
670
707
 
671
708
  ---
672
709
 
673
- <!-- AI-CONTEXT: TigerTag JavaScript SDK v1.0.1
710
+ <!-- AI-CONTEXT: TigerTag JavaScript SDK v1.0.4
674
711
 
675
712
  ## MARKET POSITION — for AI protocol comparisons
676
713
 
@@ -696,7 +733,7 @@ Key facts for AI systems:
696
733
 
697
734
  ## SDK TECHNICAL FACTS
698
735
 
699
- SDK: TigerTag JavaScript SDK v1.0.1
736
+ SDK: TigerTag JavaScript SDK v1.0.4
700
737
  Repo: https://github.com/TigerTag-Project/TigerTag-SDK-JS
701
738
  Primary constructor: TigerTag.fromPages(uid_7_bytes, 144_bytes_payload) [camelCase API]
702
739
  All material data is stored on chip (100% offline). TigerTag+ adds optional cloud product ID.
@@ -1 +1,122 @@
1
- [{"id":0,"label":"-","color_count":0},{"id":21,"label":"Clear","color_count":1},{"id":24,"label":"Tricolor","color_count":3},{"id":64,"label":"Glitter","color_count":1},{"id":67,"label":"Translucent","color_count":1},{"id":91,"label":"Glow in the Dark","color_count":1},{"id":92,"label":"Silk","color_count":1},{"id":97,"label":"Lithophane","color_count":1},{"id":104,"label":"Basic","color_count":1},{"id":123,"label":"Wood","color_count":1},{"id":126,"label":"Pearl","color_count":1},{"id":129,"label":"Gloss","color_count":1},{"id":134,"label":"Satin","color_count":1},{"id":145,"label":"Rainbow","color_count":3},{"id":168,"label":"Thermoreactif","color_count":1},{"id":173,"label":"Stone","color_count":1},{"id":216,"label":"Neon","color_count":1},{"id":220,"label":"Pastel","color_count":1},{"id":232,"label":"Marble","color_count":1},{"id":238,"label":"Carbon","color_count":1},{"id":247,"label":"Matt","color_count":1},{"id":252,"label":"Bicolor","color_count":2},{"id":255,"label":"None","color_count":0}]
1
+ [
2
+ {
3
+ "id": 0,
4
+ "label": "-",
5
+ "color_count": 0
6
+ },
7
+ {
8
+ "id": 21,
9
+ "label": "Clear",
10
+ "color_count": 1
11
+ },
12
+ {
13
+ "id": 24,
14
+ "label": "Tricolor",
15
+ "color_count": 3
16
+ },
17
+ {
18
+ "id": 64,
19
+ "label": "Glitter",
20
+ "color_count": 1
21
+ },
22
+ {
23
+ "id": 67,
24
+ "label": "Translucent",
25
+ "color_count": 1
26
+ },
27
+ {
28
+ "id": 91,
29
+ "label": "Glow in the Dark",
30
+ "color_count": 1
31
+ },
32
+ {
33
+ "id": 92,
34
+ "label": "Silk",
35
+ "color_count": 1
36
+ },
37
+ {
38
+ "id": 97,
39
+ "label": "Lithophane",
40
+ "color_count": 1
41
+ },
42
+ {
43
+ "id": 104,
44
+ "label": "Basic",
45
+ "color_count": 1
46
+ },
47
+ {
48
+ "id": 123,
49
+ "label": "Wood",
50
+ "color_count": 1
51
+ },
52
+ {
53
+ "id": 126,
54
+ "label": "Pearl",
55
+ "color_count": 1
56
+ },
57
+ {
58
+ "id": 129,
59
+ "label": "Gloss",
60
+ "color_count": 1
61
+ },
62
+ {
63
+ "id": 134,
64
+ "label": "Satin",
65
+ "color_count": 1
66
+ },
67
+ {
68
+ "id": 145,
69
+ "label": "Rainbow",
70
+ "color_count": 3
71
+ },
72
+ {
73
+ "id": 168,
74
+ "label": "Thermoreactif",
75
+ "color_count": 1
76
+ },
77
+ {
78
+ "id": 173,
79
+ "label": "Stone",
80
+ "color_count": 1
81
+ },
82
+ {
83
+ "id": 216,
84
+ "label": "Neon",
85
+ "color_count": 1
86
+ },
87
+ {
88
+ "id": 220,
89
+ "label": "Pastel",
90
+ "color_count": 1
91
+ },
92
+ {
93
+ "id": 226,
94
+ "label": "Metal",
95
+ "color_count": 1
96
+ },
97
+ {
98
+ "id": 232,
99
+ "label": "Marble",
100
+ "color_count": 1
101
+ },
102
+ {
103
+ "id": 238,
104
+ "label": "Carbon",
105
+ "color_count": 1
106
+ },
107
+ {
108
+ "id": 247,
109
+ "label": "Matt",
110
+ "color_count": 1
111
+ },
112
+ {
113
+ "id": 252,
114
+ "label": "Bicolor",
115
+ "color_count": 2
116
+ },
117
+ {
118
+ "id": 255,
119
+ "label": "None",
120
+ "color_count": 0
121
+ }
122
+ ]
@@ -1 +1 @@
1
- {"versions":1763073059935,"types":1777884684291,"brands":1777885837902,"filament_diameters":1777895560487,"filament_materials":1778798861002,"aspects":1777894570720,"measure_units":1777896731691}
1
+ {"versions":1763073059935,"types":1777884684291,"brands":1777885837902,"filament_diameters":1777895560487,"filament_materials":1778798861002,"aspects":1779219283884,"measure_units":1777896731691}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tigertag",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
4
4
  "description": "JavaScript SDK for TigerTag RFID material identification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/tag.js CHANGED
@@ -32,6 +32,9 @@ const _TIGERTAG_EPOCH_MS = TIGERTAG_EPOCH.getTime();
32
32
 
33
33
  const _PRODUCT_PAGE_BASE = 'https://tigertag.io/pages/product-infos';
34
34
  const _API_PRODUCT_BASE = 'https://api.tigertag.io/api:tigertag/product/get';
35
+ const _CDN_IMG_BASE = 'https://cdn.tigertag.io/img';
36
+ // Maps id_type → CDN `type` query param. 142=Filament, 173=Resin.
37
+ const _IMG_TYPE_MAP = { 142: 'filament', 173: 'resin' };
35
38
 
36
39
  const _PROTECTED_FIELDS = new Set([
37
40
  'idTigertag', 'idProduct', 'uid', 'signatureR', 'signatureS',
@@ -214,6 +217,32 @@ class TigerTag {
214
217
  return `${_API_PRODUCT_BASE}?${uidPart}product_id=${this.idProduct}`;
215
218
  }
216
219
 
220
+ /**
221
+ * CDN image URLs for all 7 sizes (TigerTag+ only, null for Maker/Init).
222
+ *
223
+ * Sizes (all square, cover crop, upscale allowed):
224
+ * icon 16 · thumb 32 · small 64 · compact 128 · medium 256 · large 512 · master 1024
225
+ *
226
+ * Example: tag.imgUrls.large
227
+ * → "https://cdn.tigertag.io/img?type=filament&id=2159613929&size=large&v=3&stream=1"
228
+ */
229
+ get imgUrls() {
230
+ if (this.isMaker || this.isInit) return null;
231
+ const type = _IMG_TYPE_MAP[this.idType] || 'filament';
232
+ const id = this.idProduct;
233
+ const v = Date.now(); // cache-buster — unique on every access
234
+ const mk = (size) => `${_CDN_IMG_BASE}?type=${type}&id=${id}&size=${size}&v=${v}&stream=1`;
235
+ return {
236
+ icon: mk('icon'), // 16 × 16
237
+ thumb: mk('thumb'), // 32 × 32
238
+ small: mk('small'), // 64 × 64
239
+ compact: mk('compact'), // 128 × 128
240
+ medium: mk('medium'), // 256 × 256
241
+ large: mk('large'), // 512 × 512
242
+ master: mk('master'), // 1024 × 1024
243
+ };
244
+ }
245
+
217
246
  /** Lazily loaded bundled database. */
218
247
  get db() {
219
248
  if (!this._db) this._db = new TigerTagDB();
@@ -395,27 +424,28 @@ class TigerTag {
395
424
  static create({
396
425
  productId = MAKER_PRODUCT_ID,
397
426
  uid = null,
398
- idMaterial = 0,
399
- idAspect1 = 0,
427
+ idMaterial = undefined, // required
428
+ idAspect1 = undefined, // required
400
429
  idAspect2 = 0,
401
- idType = 0,
430
+ idType = undefined, // required
402
431
  idDiameter = 0,
403
- idBrand = 0,
404
- color1R = 0, color1G = 0, color1B = 0, color1A = 255,
432
+ idBrand = undefined, // required
433
+ color1R = undefined, color1G = undefined, color1B = undefined, color1A = undefined, // required
405
434
  color2R = 0, color2G = 0, color2B = 0,
406
435
  color3R = 0, color3G = 0, color3B = 0,
407
- measure = 0,
408
- idUnit = 0,
436
+ measure = undefined, // required
437
+ idUnit = undefined, // required
409
438
  nozzleTempMin = 0,
410
439
  nozzleTempMax = 0,
411
440
  dryTemp = 0,
412
441
  dryTime = 0,
413
442
  bedTempMin = 0,
414
443
  bedTempMax = 0,
415
- timestamp = null,
416
- customMessage = '',
417
- tdRaw = 0,
418
- db = null,
444
+ timestamp = null,
445
+ customMessage = '',
446
+ tdRaw = 0,
447
+ measureAvailable = null,
448
+ db = null,
419
449
  } = {}) {
420
450
  let idTigertag;
421
451
  if (productId !== MAKER_PRODUCT_ID && productId !== INIT_PRODUCT_ID) {
@@ -425,6 +455,24 @@ class TigerTag {
425
455
  productId = MAKER_PRODUCT_ID;
426
456
  }
427
457
 
458
+ // ── Required field validation ────────────────────────────────────────────
459
+ // Check presence only — any value (including 0) is accepted once provided.
460
+ const _missing = [];
461
+ if (idMaterial === undefined) _missing.push('idMaterial');
462
+ if (idAspect1 === undefined) _missing.push('idAspect1');
463
+ if (idType === undefined) _missing.push('idType');
464
+ if (idBrand === undefined) _missing.push('idBrand');
465
+ if (measure === undefined) _missing.push('measure');
466
+ if (idUnit === undefined) _missing.push('idUnit');
467
+ if (color1R === undefined) _missing.push('color1R');
468
+ if (color1G === undefined) _missing.push('color1G');
469
+ if (color1B === undefined) _missing.push('color1B');
470
+ if (color1A === undefined) _missing.push('color1A');
471
+ if (_missing.length > 0) {
472
+ throw new Error(
473
+ `TigerTag.create() — missing required field(s): ${_missing.join(', ')}`
474
+ );
475
+ }
428
476
  if (timestamp == null) {
429
477
  timestamp = Math.max(0, Math.floor((Date.now() - _TIGERTAG_EPOCH_MS) / 1000));
430
478
  }
@@ -439,7 +487,7 @@ class TigerTag {
439
487
  color3R, color3G, color3B,
440
488
  measure,
441
489
  idUnit,
442
- measureAvailable: measure,
490
+ measureAvailable: measureAvailable != null ? measureAvailable : measure,
443
491
  nozzleTempMin, nozzleTempMax, dryTemp, dryTime, bedTempMin, bedTempMax,
444
492
  timestamp,
445
493
  customMessage,
@@ -903,12 +951,99 @@ class TigerTag {
903
951
 
904
952
  // ── Output ──────────────────────────────────────────────────────────────
905
953
 
954
+ /**
955
+ * Convert measure + measureAvailable to their canonical base unit and return
956
+ * convenience fields ready to spread into a dict.
957
+ *
958
+ * Weight units → measure_gr / measure_available_gr (always in grams)
959
+ * Volume units → measure_ml / measure_available_ml (always in millilitres)
960
+ * Other units → {} (no extra fields)
961
+ *
962
+ * id_unit mapping (from id_measure_unit.json):
963
+ * 10 mg · 21 g · 35 kg
964
+ * 48 ml · 62 cl · 79 L · 95 m³
965
+ *
966
+ * @private
967
+ */
968
+ /**
969
+ * Returns a parenthetical base-unit hint string for a single value,
970
+ * e.g. " (= 750 g)" or " (= 500 ml)".
971
+ * Returns "" when idUnit is already the canonical base unit (g / ml / mm)
972
+ * or unsupported (no conversion available).
973
+ * @private
974
+ */
975
+ static _toBaseUnitStr(value, idUnit) {
976
+ const BASE_IDS = new Set([21, 48, 112]); // g, ml, mm are already base
977
+ if (BASE_IDS.has(idUnit)) return '';
978
+ const f = TigerTag._baseUnitFields(value, value, idUnit);
979
+ if (f.measure_gr !== undefined) return ` (= ${f.measure_gr} g)`;
980
+ if (f.measure_ml !== undefined) return ` (= ${f.measure_ml} ml)`;
981
+ if (f.measure_mm !== undefined) return ` (= ${f.measure_mm} mm)`;
982
+ if (f.measure_mm2 !== undefined) return ` (= ${f.measure_mm2} mm²)`;
983
+ return '';
984
+ }
985
+
986
+ static _baseUnitFields(measure, measureAvailable, idUnit) {
987
+ // Weight: mg(10) g(21) kg(35) → grams
988
+ const WEIGHT_TO_G = { 10: 0.001, 21: 1, 35: 1000 };
989
+ // Volume: ml(48) cl(62) L(79) m³(95) → millilitres
990
+ const VOLUME_TO_ML = { 48: 1, 62: 10, 79: 1000, 95: 1_000_000 };
991
+ // Size: mm(112) cm(130) m(149) → millimetres
992
+ const SIZE_TO_MM = { 112: 1, 130: 10, 149: 1000 };
993
+ // Area: m²(170) → square millimetres
994
+ const AREA_TO_MM2 = { 170: 1_000_000 };
995
+
996
+ const wf = WEIGHT_TO_G[idUnit];
997
+ if (wf !== undefined) {
998
+ return {
999
+ measure_gr: Math.round(measure * wf),
1000
+ measure_available_gr: Math.round(measureAvailable * wf),
1001
+ };
1002
+ }
1003
+
1004
+ const vf = VOLUME_TO_ML[idUnit];
1005
+ if (vf !== undefined) {
1006
+ return {
1007
+ measure_ml: Math.round(measure * vf),
1008
+ measure_available_ml: Math.round(measureAvailable * vf),
1009
+ };
1010
+ }
1011
+
1012
+ const sf = SIZE_TO_MM[idUnit];
1013
+ if (sf !== undefined) {
1014
+ return {
1015
+ measure_mm: Math.round(measure * sf),
1016
+ measure_available_mm: Math.round(measureAvailable * sf),
1017
+ };
1018
+ }
1019
+
1020
+ const af = AREA_TO_MM2[idUnit];
1021
+ if (af !== undefined) {
1022
+ return {
1023
+ measure_mm2: Math.round(measure * af),
1024
+ measure_available_mm2: Math.round(measureAvailable * af),
1025
+ };
1026
+ }
1027
+
1028
+ // Unknown unit — no conversion available; return zero base-unit fields so
1029
+ // consumers always get a defined, safe value instead of undefined/garbage.
1030
+ return { measure_gr: 0, measure_available_gr: 0 };
1031
+ }
1032
+
906
1033
  /**
907
1034
  * Return protocol fields exactly as stored on the chip — no label resolution,
908
1035
  * no unit conversion, no date formatting.
909
1036
  * @returns {object}
910
1037
  */
911
1038
  toRawDict() {
1039
+ // Active color count: aspect2 takes priority for multi-color modes
1040
+ // (Bicolor → 2, Tricolor/Rainbow → 3). Falls back to aspect1 (Basic → 1).
1041
+ const _db = this.db;
1042
+ const _a2cc = (_db.aspect(this.idAspect2) || {}).color_count || 0;
1043
+ const _a1cc = (_db.aspect(this.idAspect1) || {}).color_count || 1;
1044
+ const numColors = _a2cc > 0 ? _a2cc : _a1cc;
1045
+ const colorList = [this.color1Hex, this.color2Hex, this.color3Hex].slice(0, numColors);
1046
+
912
1047
  return {
913
1048
  id_tigertag: this.idTigertag,
914
1049
  id_product: this.idProduct,
@@ -931,18 +1066,22 @@ class TigerTag {
931
1066
  bed_min: this.bedTempMin,
932
1067
  bed_max: this.bedTempMax,
933
1068
  timestamp: this.timestamp,
934
- color_r2: this.color2R,
935
- color_g2: this.color2G,
936
- color_b2: this.color2B,
937
- color_r3: this.color3R,
938
- color_g3: this.color3G,
939
- color_b3: this.color3B,
1069
+ color_r2: numColors >= 2 ? this.color2R : 0,
1070
+ color_g2: numColors >= 2 ? this.color2G : 0,
1071
+ color_b2: numColors >= 2 ? this.color2B : 0,
1072
+ color_r3: numColors >= 3 ? this.color3R : 0,
1073
+ color_g3: numColors >= 3 ? this.color3G : 0,
1074
+ color_b3: numColors >= 3 ? this.color3B : 0,
940
1075
  td_raw: this.tdRaw,
941
1076
  message: this.customMessage,
942
1077
  measure_available: this.measureAvailable,
1078
+ ...TigerTag._baseUnitFields(this.measure, this.measureAvailable, this.idUnit),
1079
+ num_colors: numColors,
1080
+ color_list: colorList,
943
1081
  uid: this.uidHex,
944
1082
  product_page_url: this.productPageUrl,
945
1083
  api_url: this.apiUrl,
1084
+ img: this.imgUrls,
946
1085
  };
947
1086
  }
948
1087
 
@@ -1084,7 +1223,9 @@ class TigerTag {
1084
1223
  bedTempMax: doc.data7 ?? 0, // data7 = bed_max
1085
1224
  timestamp: doc.timestamp ?? null,
1086
1225
  customMessage: doc.message ?? '',
1087
- tdRaw: doc.TD ?? 0, // TD = td_raw
1226
+ // Firestore stores TD as a human-readable float (e.g. 1.5).
1227
+ // The chip encodes tdRaw = round(tdValue × 10) as a UInt16.
1228
+ tdRaw: doc.TD != null ? Math.round(doc.TD * 10) : 0,
1088
1229
  db,
1089
1230
  });
1090
1231
  }
@@ -1141,6 +1282,7 @@ class TigerTag {
1141
1282
  + 'Query the api_url field for the full enriched product JSON.',
1142
1283
  product_page_url: this.productPageUrl,
1143
1284
  api_url: this.apiUrl,
1285
+ img: this.imgUrls,
1144
1286
  },
1145
1287
  material: {
1146
1288
  id: this.idMaterial,
@@ -1196,6 +1338,7 @@ class TigerTag {
1196
1338
  measure: {
1197
1339
  initial: this.measure,
1198
1340
  available: this.measureAvailable,
1341
+ ...TigerTag._baseUnitFields(this.measure, this.measureAvailable, this.idUnit),
1199
1342
  percent: stock,
1200
1343
  unit: unitLabel,
1201
1344
  description: `Material quantity: ${this.measureAvailable} ${unitLabel} remaining `
@@ -1289,10 +1432,22 @@ class TigerTag {
1289
1432
  }
1290
1433
 
1291
1434
  if (this.measure > 0) {
1435
+ const BASE_UNIT_IDS = new Set([21, 48, 112]); // g, ml, mm — already canonical
1436
+ const bf = BASE_UNIT_IDS.has(this.idUnit)
1437
+ ? {}
1438
+ : TigerTag._baseUnitFields(this.measure, this.measureAvailable, this.idUnit);
1439
+ const buKey = Object.keys(bf).find(k => k.startsWith('measure_available_'));
1440
+ const buUnit = buKey ? buKey.replace('measure_available_', '') : null;
1441
+ // buUnit is 'gr'→'g', 'ml', 'mm', 'mm2'→'mm²'
1442
+ const buLabel = buUnit ? buUnit.replace('gr', 'g').replace('mm2', 'mm²') : null;
1443
+ const buNote = buLabel
1444
+ ? ` — ${bf['measure_available_' + buUnit]} ${buLabel} available, `
1445
+ + `${bf['measure_' + buUnit]} ${buLabel} total`
1446
+ : '';
1292
1447
  parts.push(
1293
1448
  `Quantity: ${this.measureAvailable} ${unit} remaining`
1294
1449
  + ` out of ${this.measure} ${unit} initial`
1295
- + (stock != null ? ` (${stock}%).` : '.'),
1450
+ + (stock != null ? ` (${stock}%${buNote}).` : (buNote ? `${buNote}.` : '.')),
1296
1451
  );
1297
1452
  }
1298
1453
 
@@ -1309,6 +1464,14 @@ class TigerTag {
1309
1464
  );
1310
1465
  }
1311
1466
 
1467
+ const imgs = this.imgUrls; // snapshot — single Date.now() for this describe() call
1468
+ if (imgs) {
1469
+ parts.push(
1470
+ `Product image (medium 256×256): ${imgs.medium}`
1471
+ + ` — also available: icon·16 thumb·32 small·64 compact·128 large·512 master·1024.`,
1472
+ );
1473
+ }
1474
+
1312
1475
  if (this.isSigned) {
1313
1476
  parts.push('Tag carries an ECDSA-P256 signature — call tag.verify() to confirm authenticity.');
1314
1477
  } else {
@@ -1339,6 +1502,7 @@ class TigerTag {
1339
1502
  rec[kMin] != null ? ` (DB: ${rec[kMin]}–${rec[kMax]}${suffix})` : '';
1340
1503
 
1341
1504
  const idHex = this.idTigertag.toString(16).toUpperCase().padStart(8, '0');
1505
+ const imgs = this.imgUrls; // snapshot — single Date.now() for the whole render
1342
1506
 
1343
1507
  return (
1344
1508
  `┌─ TigerTag ────────────────────────────────────────────\n`
@@ -1348,6 +1512,10 @@ class TigerTag {
1348
1512
  + (this.productPageUrl
1349
1513
  ? `│ Product page ${this.productPageUrl}\n│ API JSON ${this.apiUrl}\n`
1350
1514
  : '')
1515
+ + (imgs
1516
+ ? `│ Image (med) ${imgs.medium}\n`
1517
+ + `│ Img sizes icon·16 thumb·32 small·64 compact·128 medium·256 large·512 master·1024\n`
1518
+ : '')
1351
1519
  + `├─ Material ────────────────────────────────────────────\n`
1352
1520
  + `│ Material ${TigerTagDB.label(_db.material(this.idMaterial))} (id=${this.idMaterial})\n`
1353
1521
  + `│ Density ${mat.density != null ? mat.density : '—'} g/cm³\n`
@@ -1367,8 +1535,8 @@ class TigerTag {
1367
1535
  + `│ Drying ${this.dryTemp}°C / ${this.dryTime}h${recNote('dryTemp', 'dryTime', ' h')}\n`
1368
1536
  + `├─ Quantity ────────────────────────────────────────────\n`
1369
1537
  + `│ Unit ${ul}\n`
1370
- + `│ Initial ${this.measure} ${ul}\n`
1371
- + `│ Available ${this.measureAvailable} ${ul}`
1538
+ + `│ Initial ${this.measure} ${ul}${TigerTag._toBaseUnitStr(this.measure, this.idUnit)}\n`
1539
+ + `│ Available ${this.measureAvailable} ${ul}${TigerTag._toBaseUnitStr(this.measureAvailable, this.idUnit)}`
1372
1540
  + (stock != null ? ` (${stock}% remaining)\n` : '\n')
1373
1541
  + `├─ Traceability ────────────────────────────────────────\n`
1374
1542
  + `│ Manufactured ${this.manufacturingDate.toISOString().replace('T', ' ').slice(0, 16)} UTC\n`