tigertag 1.1.0 → 1.2.0

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/src/tag.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * TigerTag class — binary parsing, serialization, CRUD, and cloud sync.
25
25
  */
26
26
 
27
- const { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH } = require('./db');
27
+ const { TigerTagDB, syncDatabases } = require('./db');
28
28
  const { SignatureResult, verifySignature } = require('./signature');
29
29
 
30
30
  // ── Protocol constants ──────────────────────────────────────────────────────
@@ -61,6 +61,7 @@ const _PATCHABLE_FIELDS = new Set([
61
61
  'measure', 'idUnit', 'measureAvailable',
62
62
  'nozzleTempMin', 'nozzleTempMax', 'dryTemp', 'dryTime', 'bedTempMin', 'bedTempMax',
63
63
  'timestamp', 'customMessage', 'tdRaw',
64
+ 'tagInfo', 'tagCount', 'tagIndex',
64
65
  ]);
65
66
 
66
67
  // ── ApiDiff ─────────────────────────────────────────────────────────────────
@@ -155,6 +156,11 @@ class TigerTag {
155
156
  // HueForge
156
157
  this.tdRaw = fields.tdRaw || 0;
157
158
 
159
+ // Tag index / tag count (protocol v2.2) — u8 at payload offset +39.
160
+ // High nibble = which tag this one is, low nibble = tags on the item (0 = unknown),
161
+ // so the hex reads "index/count": 0x12 = tag 1 of 2.
162
+ this.tagInfo = fields.tagInfo || 0;
163
+
158
164
  // Signature (optional)
159
165
  this.signatureR = fields.signatureR instanceof Buffer ? fields.signatureR : Buffer.alloc(32);
160
166
  this.signatureS = fields.signatureS instanceof Buffer ? fields.signatureS : Buffer.alloc(32);
@@ -191,6 +197,21 @@ class TigerTag {
191
197
  /** HueForge TD as float. 0.0 = undefined, valid range 0.1–100.0. */
192
198
  get tdValue() { return this.tdRaw / 10.0; }
193
199
 
200
+ /**
201
+ * Number of TigerTags on the tagged item — a filament spool, a resin bottle… as given
202
+ * by idType (low nibble of tagInfo).
203
+ * 0 = unknown, 1 = single tag, 2 = twin tag, … up to 15.
204
+ * @returns {number}
205
+ */
206
+ get tagCount() { return this.tagInfo & 0x0F; }
207
+
208
+ /**
209
+ * Which of those tags this one is, from 1 (high nibble of tagInfo).
210
+ * 0 = unknown.
211
+ * @returns {number}
212
+ */
213
+ get tagIndex() { return this.tagInfo >> 4; }
214
+
194
215
  /** Manufacturing timestamp as UTC Date. */
195
216
  get manufacturingDate() {
196
217
  return new Date(_TIGERTAG_EPOCH_MS + this.timestamp * 1000);
@@ -379,6 +400,7 @@ class TigerTag {
379
400
  color2R: u8(36),
380
401
  color2G: u8(37),
381
402
  color2B: u8(38),
403
+ tagInfo: u8(39),
382
404
  color3R: u8(40),
383
405
  color3G: u8(41),
384
406
  color3B: u8(42),
@@ -431,6 +453,8 @@ class TigerTag {
431
453
  * @param {number} [options.timestamp] - Seconds since 2000-01-01 UTC. Defaults to now.
432
454
  * @param {string} [options.customMessage='']
433
455
  * @param {number} [options.tdRaw=0]
456
+ * @param {number} [options.tagCount=0] - TigerTags on the item (0 = unknown, 0–15).
457
+ * @param {number} [options.tagIndex=0] - Which tag this one is, from 1 (0 = unknown, 0–15).
434
458
  * @param {TigerTagDB} [options.db]
435
459
  * @returns {TigerTag}
436
460
  */
@@ -457,6 +481,8 @@ class TigerTag {
457
481
  timestamp = null,
458
482
  customMessage = '',
459
483
  tdRaw = 0,
484
+ tagCount = 0,
485
+ tagIndex = 0,
460
486
  measureAvailable = null,
461
487
  db = null,
462
488
  } = {}) {
@@ -505,6 +531,7 @@ class TigerTag {
505
531
  timestamp,
506
532
  customMessage,
507
533
  tdRaw,
534
+ tagInfo: TigerTag._packTagInfo(tagCount, tagIndex),
508
535
  uid,
509
536
  _db: db,
510
537
  });
@@ -534,6 +561,7 @@ class TigerTag {
534
561
  timestamp: ts,
535
562
  customMessage: '',
536
563
  tdRaw: 0,
564
+ tagInfo: 0,
537
565
  uid,
538
566
  });
539
567
  }
@@ -590,7 +618,7 @@ class TigerTag {
590
618
  buf[o++] = this.color2R & 0xFF;
591
619
  buf[o++] = this.color2G & 0xFF;
592
620
  buf[o++] = this.color2B & 0xFF;
593
- buf[o++] = 0x00;
621
+ buf[o++] = this.tagInfo & 0xFF;
594
622
 
595
623
  buf[o++] = this.color3R & 0xFF;
596
624
  buf[o++] = this.color3G & 0xFF;
@@ -631,8 +659,14 @@ class TigerTag {
631
659
  * Protected fields (idTigertag, idProduct, uid, signatureR, signatureS)
632
660
  * cannot be modified — they are covered by the ECDSA signature.
633
661
  *
662
+ * tagCount / tagIndex are accepted as shortcuts and re-encoded into
663
+ * tagInfo; the nibble that is not supplied keeps its current value.
664
+ * tagInfo is not covered by the signature, so patching it never
665
+ * invalidates a signed tag.
666
+ *
634
667
  * @param {object} kwargs - Field names (camelCase) and their new values.
635
668
  * @returns {TigerTag}
669
+ * @throws {RangeError} When tagCount / tagIndex is not an integer in 0–15.
636
670
  */
637
671
  patch(kwargs) {
638
672
  const protected_ = Object.keys(kwargs).filter((k) => _PROTECTED_FIELDS.has(k));
@@ -649,7 +683,38 @@ class TigerTag {
649
683
  + `Valid patchable fields: ${[..._PATCHABLE_FIELDS].sort().join(', ')}`,
650
684
  );
651
685
  }
652
- return new TigerTag(Object.assign({}, this, kwargs));
686
+ const updates = Object.assign({}, kwargs);
687
+ if ('tagCount' in updates || 'tagIndex' in updates) {
688
+ const base = 'tagInfo' in updates ? (updates.tagInfo & 0xFF) : this.tagInfo;
689
+ const count = 'tagCount' in updates ? updates.tagCount : base & 0x0F;
690
+ const index = 'tagIndex' in updates ? updates.tagIndex : base >> 4;
691
+ updates.tagInfo = TigerTag._packTagInfo(count, index);
692
+ delete updates.tagCount;
693
+ delete updates.tagIndex;
694
+ }
695
+ return new TigerTag(Object.assign({}, this, updates));
696
+ }
697
+
698
+ /**
699
+ * Pack a tag count and tag index into the tagInfo byte:
700
+ * (tagIndex << 4) | tagCount — the hex reads "index/count" (0x12 = tag 1 of 2).
701
+ *
702
+ * @param {number} tagCount - TigerTags on the item (0 = unknown, 0–15).
703
+ * @param {number} tagIndex - Which tag this one is, from 1 (0 = unknown, 0–15).
704
+ * @returns {number} u8 tagInfo value.
705
+ * @throws {RangeError} When either value is not an integer in 0–15
706
+ * (it would not fit in its 4-bit nibble).
707
+ * @private
708
+ */
709
+ static _packTagInfo(tagCount, tagIndex) {
710
+ const count = tagCount || 0;
711
+ const index = tagIndex || 0;
712
+ for (const [name, v] of [['tagCount', count], ['tagIndex', index]]) {
713
+ if (!Number.isInteger(v) || v < 0 || v > 15) {
714
+ throw new RangeError(`${name} must be an integer in 0–15, got ${v}`);
715
+ }
716
+ }
717
+ return (index << 4) | count;
653
718
  }
654
719
 
655
720
  // ── Validation ──────────────────────────────────────────────────────────
@@ -675,6 +740,25 @@ class TigerTag {
675
740
  `TD HueForge out of range: ${this.tdRaw} (valid: 10–1000 or 0=undefined)`,
676
741
  );
677
742
  }
743
+ const tagCount = this.tagCount;
744
+ const tagIndex = this.tagIndex;
745
+ if (!Number.isInteger(this.tagInfo) || this.tagInfo < 0 || this.tagInfo > 0xFF
746
+ || tagCount > 15 || tagIndex > 15) {
747
+ warnings.push(
748
+ `Tag index/count out of range: tag_info=${this.tagInfo} `
749
+ + `(count and index must each be 0–15, tag_info must be 0x00–0xFF)`,
750
+ );
751
+ }
752
+ if (tagCount > 0 && tagIndex > tagCount) {
753
+ warnings.push(
754
+ `Tag index (${tagIndex}) > tag count (${tagCount}) (valid index: 1–count or 0=unknown)`,
755
+ );
756
+ }
757
+ if (tagCount === 1 && tagIndex > 1) {
758
+ warnings.push(
759
+ `Single-tag item (count=1) but tag index is ${tagIndex} (expected 1 or 0=unknown)`,
760
+ );
761
+ }
678
762
  if (this.measure > 0 && this.measureAvailable > this.measure) {
679
763
  warnings.push(
680
764
  `measure_available (${this.measureAvailable}) > initial measure (${this.measure})`,
@@ -949,16 +1033,24 @@ class TigerTag {
949
1033
  }
950
1034
 
951
1035
  /**
952
- * Download or update reference databases.
1036
+ * Check for new reference tables now and reload this tag's database.
953
1037
  *
954
- * @param {string} [dbPath] - Target folder. Defaults to the bundled database directory.
1038
+ * Without dbPath: updates the data dir (see TigerTagDB.update()). With dbPath:
1039
+ * downloads into that custom folder, which is then used exclusively.
1040
+ *
1041
+ * @param {string} [dbPath] - Custom target folder. Default: the data dir.
955
1042
  * @param {boolean} [force=false] - Re-download all files even if up to date.
956
1043
  * @returns {Promise<string[]>} List of filenames that were downloaded/updated.
957
1044
  */
958
1045
  async syncDb(dbPath = null, force = false) {
959
- const targetPath = dbPath || _BUNDLED_DB_PATH;
960
- const updated = await syncDatabases(targetPath, { force, verbose: true });
961
- this._db = new TigerTagDB({ dbPath: targetPath });
1046
+ if (dbPath) {
1047
+ const updated = await syncDatabases(dbPath, { force, verbose: true });
1048
+ this._db = new TigerTagDB({ dbPath });
1049
+ return updated;
1050
+ }
1051
+ const db = new TigerTagDB({ autoUpdate: false });
1052
+ const updated = await db.update({ force });
1053
+ this._db = db;
962
1054
  return updated;
963
1055
  }
964
1056
 
@@ -1086,6 +1178,7 @@ class TigerTag {
1086
1178
  color_g3: numColors >= 3 ? this.color3G : 0,
1087
1179
  color_b3: numColors >= 3 ? this.color3B : 0,
1088
1180
  td_raw: this.tdRaw,
1181
+ tag_info: this.tagInfo,
1089
1182
  message: this.customMessage,
1090
1183
  measure_available: this.measureAvailable,
1091
1184
  ...TigerTag._baseUnitFields(this.measure, this.measureAvailable, this.idUnit),
@@ -1139,6 +1232,7 @@ class TigerTag {
1139
1232
  bed_max: 'bedTempMax',
1140
1233
  timestamp: 'timestamp',
1141
1234
  td_raw: 'tdRaw',
1235
+ tag_info: 'tagInfo',
1142
1236
  message: 'customMessage',
1143
1237
  };
1144
1238
  const kwargs = {};
@@ -1193,6 +1287,8 @@ class TigerTag {
1193
1287
  timestamp: raw.timestamp ?? null,
1194
1288
  customMessage: raw.message ?? '',
1195
1289
  tdRaw: raw.td_raw ?? 0,
1290
+ tagCount: (raw.tag_info ?? 0) & 0x0F,
1291
+ tagIndex: (raw.tag_info ?? 0) >> 4,
1196
1292
  db,
1197
1293
  });
1198
1294
  }
@@ -1243,6 +1339,101 @@ class TigerTag {
1243
1339
  });
1244
1340
  }
1245
1341
 
1342
+ /**
1343
+ * Build a ready-to-burn TigerTag+ from one entry of the official catalogue
1344
+ * (id_catalog.json). Pure and synchronous — see fromCatalog() to look the
1345
+ * product up by ID.
1346
+ *
1347
+ * RFID_Data mapping (same as the cloud document, see fromCloudDoc()):
1348
+ * data1 = idDiameter, data2/data3 = nozzle min/max, data4/data5 = dry temp/time,
1349
+ * data6/data7 = bed min/max; id_aspect2 null → 0 (none); missing values → 0.
1350
+ * Colours 2 and 3 come from RFID_Data color_r2…b3 when present, otherwise from
1351
+ * color_info.colors[1] / [2] ("#RRGGBBAA").
1352
+ *
1353
+ * @param {object} entry - Catalogue entry ({ id, RFID_Data, title, brand, color_info, … }).
1354
+ * @param {object} [options]
1355
+ * @param {Buffer} [options.uid] - 7-byte chip UID.
1356
+ * @param {number} [options.tagCount=0] - TigerTags on the item (twin tag: 2).
1357
+ * @param {number} [options.tagIndex=0] - Which tag this one is, from 1.
1358
+ * @param {number} [options.timestamp] - Seconds since 2000-01-01 UTC (default: now). Use the
1359
+ * same value on every tag of a twin tag.
1360
+ * @param {TigerTagDB} [options.db]
1361
+ * @returns {TigerTag} A TigerTag+ (idTigertag = ID_TIGERTAG_PLUS, idProduct = entry.id).
1362
+ * @throws {Error} When the entry has no RFID_Data (it cannot be written to a tag).
1363
+ */
1364
+ static fromCatalogEntry(entry, {
1365
+ uid = null, tagCount = 0, tagIndex = 0, timestamp = null, db = null,
1366
+ } = {}) {
1367
+ const r = entry && entry.RFID_Data;
1368
+ if (!r) {
1369
+ throw new Error(
1370
+ `Catalogue product ${entry && entry.id} ("${(entry && entry.title) || '?'}") has no RFID_Data `
1371
+ + '— it cannot be written to a tag.',
1372
+ );
1373
+ }
1374
+ const n = (v) => (v == null ? 0 : Number(v));
1375
+ const hex = (h) => {
1376
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(h || '');
1377
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 0, 0];
1378
+ };
1379
+ const colors = (entry.color_info && entry.color_info.colors) || [];
1380
+ 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];
1381
+ 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];
1382
+ return TigerTag.create({
1383
+ productId: Number(entry.id),
1384
+ uid,
1385
+ idMaterial: n(r.id_material),
1386
+ idAspect1: n(r.id_aspect1),
1387
+ idAspect2: n(r.id_aspect2),
1388
+ idType: n(r.id_type),
1389
+ idDiameter: n(r.data1),
1390
+ idBrand: n(r.id_brand),
1391
+ color1R: n(r.color_r),
1392
+ color1G: n(r.color_g),
1393
+ color1B: n(r.color_b),
1394
+ color1A: r.color_a == null ? 255 : n(r.color_a),
1395
+ color2R: c2[0], color2G: c2[1], color2B: c2[2],
1396
+ color3R: c3[0], color3G: c3[1], color3B: c3[2],
1397
+ measure: n(r.measure),
1398
+ idUnit: n(r.id_unit),
1399
+ nozzleTempMin: n(r.data2),
1400
+ nozzleTempMax: n(r.data3),
1401
+ dryTemp: n(r.data4),
1402
+ dryTime: n(r.data5),
1403
+ bedTempMin: n(r.data6),
1404
+ bedTempMax: n(r.data7),
1405
+ timestamp,
1406
+ tagCount,
1407
+ tagIndex,
1408
+ db,
1409
+ });
1410
+ }
1411
+
1412
+ /**
1413
+ * Build a ready-to-burn TigerTag+ from a product ID of the official catalogue.
1414
+ * The catalogue is downloaded on first use and cached on disk (see loadCatalog()).
1415
+ * Use catalogEntry(productId) for the display metadata (title, brand, sku,
1416
+ * barcode, img_src).
1417
+ *
1418
+ * @param {number} productId - TigerTag+ product ID.
1419
+ * @param {object} [options] - fromCatalogEntry() options, plus:
1420
+ * @param {Map<number, object>} [options.catalog] - Already loaded catalogue (otherwise loaded with
1421
+ * the loadCatalog() options url / cacheDir / maxAge / force).
1422
+ * @returns {Promise<TigerTag>}
1423
+ * @throws {Error} When the ID is not in the catalogue, the product has no RFID_Data, or the
1424
+ * catalogue cannot be loaded (offline with no cache).
1425
+ */
1426
+ static async fromCatalog(productId, options = {}) {
1427
+ const { loadCatalog } = require('./catalog');
1428
+ const { catalog: given, uid, tagCount, tagIndex, timestamp, db, ...loadOpts } = options;
1429
+ const catalog = given || await loadCatalog(loadOpts);
1430
+ const entry = catalog.get(Number(productId));
1431
+ if (!entry) {
1432
+ throw new Error(`Product ID ${productId} is not in the TigerTag catalogue (${catalog.size} products).`);
1433
+ }
1434
+ return TigerTag.fromCatalogEntry(entry, { uid, tagCount, tagIndex, timestamp, db });
1435
+ }
1436
+
1246
1437
  /**
1247
1438
  * Apply a surgical patch using snake_case keys (toRawDict format).
1248
1439
  * Only the supplied keys are changed — all other fields are preserved.
@@ -1276,7 +1467,7 @@ class TigerTag {
1276
1467
  return {
1277
1468
  sdk: 'tigertag-sdk-js',
1278
1469
  sdk_mode: 'offline',
1279
- protocol: 'TigerTag Open Source v2.1',
1470
+ protocol: 'TigerTag Open Source v2.2',
1280
1471
  chip: 'NTAG213/215/216',
1281
1472
  uid: this.uidHex,
1282
1473
  version: {
@@ -1369,6 +1560,8 @@ class TigerTag {
1369
1560
  },
1370
1561
  manufacturing_date: this.manufacturingDate.toISOString(),
1371
1562
  twin_tag_pairing_id: this.timestamp,
1563
+ tag_count: this.tagCount || null,
1564
+ tag_index: this.tagIndex || null,
1372
1565
  custom_message: this.customMessage,
1373
1566
  authentication: {
1374
1567
  signed: this.isSigned,
@@ -1465,6 +1658,11 @@ class TigerTag {
1465
1658
  }
1466
1659
 
1467
1660
  if (this.tdRaw !== 0) parts.push(`HueForge TD: ${this.tdValue.toFixed(1)}.`);
1661
+ if (this.tagInfo !== 0) {
1662
+ // What the item is comes from idType (Filament, Resin, …); unknown → "item"
1663
+ const typeLabel = ((_db.type(this.idType) || {}).label || '').trim().toLowerCase();
1664
+ parts.push(`Tag ${this.tagIndex || '?'} of ${this.tagCount || '?'} on this ${typeLabel || 'item'}.`);
1665
+ }
1468
1666
 
1469
1667
  const dateStr = this.manufacturingDate.toISOString().slice(0, 10);
1470
1668
  parts.push(`Manufactured: ${dateStr}.`);
@@ -1509,7 +1707,7 @@ class TigerTag {
1509
1707
  const ul = TigerTagDB.label(_db.unit(this.idUnit));
1510
1708
  const sig = sigResult
1511
1709
  ? String(sigResult)
1512
- : (this.isSigned ? 'signed ✓' : 'not signed');
1710
+ : (this.isSigned ? 'signed (not verified)' : 'not signed');
1513
1711
 
1514
1712
  const recNote = (kMin, kMax, suffix = '°C') =>
1515
1713
  rec[kMin] != null ? ` (DB: ${rec[kMin]}–${rec[kMax]}${suffix})` : '';
@@ -1554,6 +1752,7 @@ class TigerTag {
1554
1752
  + `├─ Traceability ────────────────────────────────────────\n`
1555
1753
  + `│ Manufactured ${this.manufacturingDate.toISOString().replace('T', ' ').slice(0, 16)} UTC\n`
1556
1754
  + `│ Twin tag ID ${this.timestamp}\n`
1755
+ + `│ Tag ${this.tagIndex || '?'} of ${this.tagCount || '?'}${this.tagInfo === 0 ? ' (unknown)' : ''}\n`
1557
1756
  + `│ Message ${JSON.stringify(this.customMessage)}\n`
1558
1757
  + `├─ Signature ───────────────────────────────────────────\n`
1559
1758
  + `│ ECDSA ${sig}\n`