tigertag 1.0.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/CHANGELOG.md +35 -0
- package/LICENSE.md +21 -0
- package/README.md +653 -0
- package/bin/tigertag.js +132 -0
- package/database/id_aspect.json +1 -0
- package/database/id_brand.json +1 -0
- package/database/id_diameter.json +1 -0
- package/database/id_material.json +1 -0
- package/database/id_measure_unit.json +1 -0
- package/database/id_type.json +1 -0
- package/database/id_version.json +1 -0
- package/database/last_update.json +1 -0
- package/package.json +54 -0
- package/src/db.js +292 -0
- package/src/index.js +62 -0
- package/src/signature.js +130 -0
- package/src/tag.js +1245 -0
package/src/tag.js
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// TigerTag RFID Guide
|
|
4
|
+
// Copyright (C) 2025 TigerTag
|
|
5
|
+
//
|
|
6
|
+
// This program is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU General Public License as published by
|
|
8
|
+
// the Free Software Foundation, either version 3 of the License.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TigerTag class — binary parsing, serialization, CRUD, and cloud sync.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH } = require('./db');
|
|
15
|
+
const { SignatureResult, verifySignature } = require('./signature');
|
|
16
|
+
|
|
17
|
+
// ── Protocol constants ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const CHIP_DUMP_LEN = 180;
|
|
20
|
+
const FULL_DATA_LEN = 144;
|
|
21
|
+
const MIN_DATA_LEN = 80;
|
|
22
|
+
|
|
23
|
+
const MAKER_PRODUCT_ID = 0xFFFFFFFF;
|
|
24
|
+
const INIT_PRODUCT_ID = 0x00000000;
|
|
25
|
+
|
|
26
|
+
const ID_TIGERTAG = 0x5BF59264;
|
|
27
|
+
const ID_TIGERTAG_PLUS = 0xBC0FCB97;
|
|
28
|
+
const ID_TIGERTAG_INIT = 0x6C41A2E1;
|
|
29
|
+
|
|
30
|
+
const TIGERTAG_EPOCH = new Date('2000-01-01T00:00:00Z');
|
|
31
|
+
const _TIGERTAG_EPOCH_MS = TIGERTAG_EPOCH.getTime();
|
|
32
|
+
|
|
33
|
+
const _PRODUCT_PAGE_BASE = 'https://tigertag.io/pages/product-infos';
|
|
34
|
+
const _API_PRODUCT_BASE = 'https://api.tigertag.io/api:tigertag/product/get';
|
|
35
|
+
|
|
36
|
+
const _PROTECTED_FIELDS = new Set([
|
|
37
|
+
'idTigertag', 'idProduct', 'uid', 'signatureR', 'signatureS',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const _PATCHABLE_FIELDS = new Set([
|
|
41
|
+
'idMaterial', 'idAspect1', 'idAspect2', 'idType', 'idDiameter', 'idBrand',
|
|
42
|
+
'color1R', 'color1G', 'color1B', 'color1A',
|
|
43
|
+
'color2R', 'color2G', 'color2B',
|
|
44
|
+
'color3R', 'color3G', 'color3B',
|
|
45
|
+
'measure', 'idUnit', 'measureAvailable',
|
|
46
|
+
'nozzleTempMin', 'nozzleTempMax', 'dryTemp', 'dryTime', 'bedTempMin', 'bedTempMax',
|
|
47
|
+
'timestamp', 'customMessage', 'tdRaw',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// ── ApiDiff ─────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A single field difference between chip data and the TigerTag+ cloud API.
|
|
54
|
+
*/
|
|
55
|
+
class ApiDiff {
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} field - Field name (e.g. "nozzle_min")
|
|
58
|
+
* @param {*} chipValue - Value currently stored on the chip
|
|
59
|
+
* @param {*} apiValue - Value returned by the cloud API
|
|
60
|
+
*/
|
|
61
|
+
constructor(field, chipValue, apiValue) {
|
|
62
|
+
this.field = field;
|
|
63
|
+
this.chipValue = chipValue;
|
|
64
|
+
this.apiValue = apiValue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toString() {
|
|
68
|
+
return ` ${this.field}: chip=${JSON.stringify(this.chipValue)} → api=${JSON.stringify(this.apiValue)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── TigerTag ─────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* TigerTag NTAG-compatible chip payload — CRUD interface.
|
|
76
|
+
*
|
|
77
|
+
* All fields are plain integers/Buffers/strings. Use TigerTagDB (via the
|
|
78
|
+
* .db property or pass one to toDict()) to resolve IDs to labels.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* // Read
|
|
82
|
+
* const tag = TigerTag.fromPages(payload, uid);
|
|
83
|
+
* const tag = TigerTag.fromDump(data);
|
|
84
|
+
* const tag = TigerTag.fromFile('dump.bin');
|
|
85
|
+
*
|
|
86
|
+
* // Create
|
|
87
|
+
* const tag = TigerTag.create({ idMaterial: 38219, nozzleTempMin: 190, ... });
|
|
88
|
+
*
|
|
89
|
+
* // Update
|
|
90
|
+
* const newTag = tag.patch({ dryTemp: 55, nozzleTempMax: 240 });
|
|
91
|
+
*/
|
|
92
|
+
class TigerTag {
|
|
93
|
+
/**
|
|
94
|
+
* Internal constructor — use static factory methods instead.
|
|
95
|
+
* @param {object} fields
|
|
96
|
+
*/
|
|
97
|
+
constructor(fields) {
|
|
98
|
+
// Identity
|
|
99
|
+
this.idTigertag = fields.idTigertag;
|
|
100
|
+
this.idProduct = fields.idProduct;
|
|
101
|
+
|
|
102
|
+
// Material
|
|
103
|
+
this.idMaterial = fields.idMaterial || 0;
|
|
104
|
+
this.idAspect1 = fields.idAspect1 || 0;
|
|
105
|
+
this.idAspect2 = fields.idAspect2 || 0;
|
|
106
|
+
this.idType = fields.idType || 0;
|
|
107
|
+
this.idDiameter = fields.idDiameter || 0;
|
|
108
|
+
this.idBrand = fields.idBrand || 0;
|
|
109
|
+
|
|
110
|
+
// Colors
|
|
111
|
+
this.color1R = fields.color1R || 0;
|
|
112
|
+
this.color1G = fields.color1G || 0;
|
|
113
|
+
this.color1B = fields.color1B || 0;
|
|
114
|
+
this.color1A = fields.color1A != null ? fields.color1A : 255;
|
|
115
|
+
this.color2R = fields.color2R || 0;
|
|
116
|
+
this.color2G = fields.color2G || 0;
|
|
117
|
+
this.color2B = fields.color2B || 0;
|
|
118
|
+
this.color3R = fields.color3R || 0;
|
|
119
|
+
this.color3G = fields.color3G || 0;
|
|
120
|
+
this.color3B = fields.color3B || 0;
|
|
121
|
+
|
|
122
|
+
// Quantity
|
|
123
|
+
this.measure = fields.measure || 0;
|
|
124
|
+
this.idUnit = fields.idUnit || 0;
|
|
125
|
+
this.measureAvailable = fields.measureAvailable || 0;
|
|
126
|
+
|
|
127
|
+
// Temperatures
|
|
128
|
+
this.nozzleTempMin = fields.nozzleTempMin || 0;
|
|
129
|
+
this.nozzleTempMax = fields.nozzleTempMax || 0;
|
|
130
|
+
this.dryTemp = fields.dryTemp || 0;
|
|
131
|
+
this.dryTime = fields.dryTime || 0;
|
|
132
|
+
this.bedTempMin = fields.bedTempMin || 0;
|
|
133
|
+
this.bedTempMax = fields.bedTempMax || 0;
|
|
134
|
+
|
|
135
|
+
// Traceability
|
|
136
|
+
this.timestamp = fields.timestamp || 0;
|
|
137
|
+
this.customMessage = fields.customMessage || '';
|
|
138
|
+
|
|
139
|
+
// HueForge
|
|
140
|
+
this.tdRaw = fields.tdRaw || 0;
|
|
141
|
+
|
|
142
|
+
// Signature (optional)
|
|
143
|
+
this.signatureR = fields.signatureR instanceof Buffer ? fields.signatureR : Buffer.alloc(32);
|
|
144
|
+
this.signatureS = fields.signatureS instanceof Buffer ? fields.signatureS : Buffer.alloc(32);
|
|
145
|
+
|
|
146
|
+
// Chip UID (7 bytes, null if unavailable)
|
|
147
|
+
this.uid = fields.uid || null;
|
|
148
|
+
|
|
149
|
+
// Lazily loaded DB (private)
|
|
150
|
+
this._db = fields._db || null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Derived properties ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** True when idProduct === 0xFFFFFFFF (offline Maker tag). */
|
|
156
|
+
get isMaker() { return this.idProduct === MAKER_PRODUCT_ID; }
|
|
157
|
+
|
|
158
|
+
/** True when idProduct === 0x00000000 (blank/uninitialized tag). */
|
|
159
|
+
get isInit() { return this.idProduct === INIT_PRODUCT_ID; }
|
|
160
|
+
|
|
161
|
+
/** True when idTigertag === ID_TIGERTAG_PLUS (cloud product). */
|
|
162
|
+
get isPlus() { return !this.isMaker && !this.isInit; }
|
|
163
|
+
|
|
164
|
+
/** True when the tag carries an ECDSA signature (pages 0x18-0x27 non-zero). */
|
|
165
|
+
get isSigned() {
|
|
166
|
+
const empty = Buffer.alloc(32);
|
|
167
|
+
return !this.signatureR.equals(empty) || !this.signatureS.equals(empty);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** UID as uppercase hex string (e.g. '04AABBCCDDEE11'), or null. */
|
|
171
|
+
get uidHex() {
|
|
172
|
+
return this.uid ? this.uid.toString('hex').toUpperCase() : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** HueForge TD as float. 0.0 = undefined, valid range 0.1–100.0. */
|
|
176
|
+
get tdValue() { return this.tdRaw / 10.0; }
|
|
177
|
+
|
|
178
|
+
/** Manufacturing timestamp as UTC Date. */
|
|
179
|
+
get manufacturingDate() {
|
|
180
|
+
return new Date(_TIGERTAG_EPOCH_MS + this.timestamp * 1000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Primary color as #RRGGBB hex string. */
|
|
184
|
+
get color1Hex() {
|
|
185
|
+
return `#${_hex2(this.color1R)}${_hex2(this.color1G)}${_hex2(this.color1B)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Secondary color as #RRGGBB hex string. */
|
|
189
|
+
get color2Hex() {
|
|
190
|
+
return `#${_hex2(this.color2R)}${_hex2(this.color2G)}${_hex2(this.color2B)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Tertiary color as #RRGGBB hex string. */
|
|
194
|
+
get color3Hex() {
|
|
195
|
+
return `#${_hex2(this.color3R)}${_hex2(this.color3G)}${_hex2(this.color3B)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Remaining material as a percentage, or null if measure is zero. */
|
|
199
|
+
get stockPercent() {
|
|
200
|
+
if (this.measure === 0) return null;
|
|
201
|
+
return Math.round((this.measureAvailable / this.measure) * 1000) / 10;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Public product page URL (TigerTag+ only, null otherwise). */
|
|
205
|
+
get productPageUrl() {
|
|
206
|
+
if (this.isMaker || this.isInit) return null;
|
|
207
|
+
return `${_PRODUCT_PAGE_BASE}/${this.idProduct}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Direct API URL returning the full enriched product JSON (TigerTag+ only). */
|
|
211
|
+
get apiUrl() {
|
|
212
|
+
if (this.isMaker || this.isInit) return null;
|
|
213
|
+
const uidPart = this.uidHex ? `uid=${BigInt('0x' + this.uidHex)}&` : '';
|
|
214
|
+
return `${_API_PRODUCT_BASE}?${uidPart}product_id=${this.idProduct}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Lazily loaded bundled database. */
|
|
218
|
+
get db() {
|
|
219
|
+
if (!this._db) this._db = new TigerTagDB();
|
|
220
|
+
return this._db;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Constructors ────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parse a TigerTag from NFC SDK native output. (Primary method)
|
|
227
|
+
*
|
|
228
|
+
* @param {Buffer} payload - 80 or 144 bytes (pages 0x04–0x27).
|
|
229
|
+
* @param {Buffer} uid - 7-byte chip UID as returned by the NFC SDK.
|
|
230
|
+
* @param {TigerTagDB} [db]
|
|
231
|
+
* @returns {TigerTag}
|
|
232
|
+
*/
|
|
233
|
+
static fromPages(payload, uid, db = null) {
|
|
234
|
+
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
235
|
+
if (buf.length !== MIN_DATA_LEN && buf.length !== FULL_DATA_LEN) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Invalid payload size: ${buf.length} bytes. `
|
|
238
|
+
+ `Expected 80B (pages 0x04-0x17) or 144B (pages 0x04-0x27).`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (!uid || uid.length !== 7) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Invalid UID: expected 7 bytes, got ${uid ? uid.length : 0}. `
|
|
244
|
+
+ 'Pass the raw bytes returned by your NFC SDK.',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const tag = TigerTag._parse(buf, db);
|
|
248
|
+
tag.uid = Buffer.isBuffer(uid) ? uid : Buffer.from(uid);
|
|
249
|
+
return tag;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a TigerTag from a raw binary dump.
|
|
254
|
+
*
|
|
255
|
+
* @param {Buffer} data - 80, 144, or 180 bytes.
|
|
256
|
+
* @param {TigerTagDB} [db]
|
|
257
|
+
* @returns {TigerTag}
|
|
258
|
+
*/
|
|
259
|
+
static fromDump(data, db = null) {
|
|
260
|
+
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
261
|
+
let uid = null;
|
|
262
|
+
|
|
263
|
+
if (buf.length === CHIP_DUMP_LEN) {
|
|
264
|
+
// Extract 7-byte UID from system pages: page0[0:3] + page1[0:4]
|
|
265
|
+
uid = Buffer.concat([buf.subarray(0, 3), buf.subarray(4, 8)]);
|
|
266
|
+
buf = buf.subarray(16, 160);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (buf.length !== MIN_DATA_LEN && buf.length !== FULL_DATA_LEN) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Invalid dump size: ${buf.length} bytes.\n`
|
|
272
|
+
+ 'Accepted: 180B (full chip), 144B (user+sig), 80B (user only).',
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const tag = TigerTag._parse(buf, db);
|
|
277
|
+
tag.uid = uid;
|
|
278
|
+
return tag;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse a TigerTag from a .bin file.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} filePath - Path to binary dump file.
|
|
285
|
+
* @param {TigerTagDB} [db]
|
|
286
|
+
* @returns {TigerTag}
|
|
287
|
+
*/
|
|
288
|
+
static fromFile(filePath, db = null) {
|
|
289
|
+
const fs = require('fs');
|
|
290
|
+
return TigerTag.fromDump(fs.readFileSync(filePath), db);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Internal parser — expects exactly 80 or 144 bytes of user memory.
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
static _parse(buf, db = null) {
|
|
298
|
+
const u8 = (o) => buf[o];
|
|
299
|
+
const u16 = (o) => buf.readUInt16BE(o);
|
|
300
|
+
const u24 = (o) => ((buf[o] << 16) | (buf[o + 1] << 8) | buf[o + 2]);
|
|
301
|
+
const u32 = (o) => buf.readUInt32BE(o);
|
|
302
|
+
|
|
303
|
+
// custom_message: 28 bytes at offset 48, null-terminated UTF-8
|
|
304
|
+
const msgBuf = buf.subarray(48, 76);
|
|
305
|
+
const nullIdx = msgBuf.indexOf(0);
|
|
306
|
+
const customMessage = msgBuf.subarray(0, nullIdx === -1 ? 28 : nullIdx).toString('utf8');
|
|
307
|
+
|
|
308
|
+
const signatureR = buf.length >= FULL_DATA_LEN
|
|
309
|
+
? Buffer.from(buf.subarray(80, 112))
|
|
310
|
+
: Buffer.alloc(32);
|
|
311
|
+
const signatureS = buf.length >= FULL_DATA_LEN
|
|
312
|
+
? Buffer.from(buf.subarray(112, 144))
|
|
313
|
+
: Buffer.alloc(32);
|
|
314
|
+
|
|
315
|
+
return new TigerTag({
|
|
316
|
+
idTigertag: u32(0),
|
|
317
|
+
idProduct: u32(4),
|
|
318
|
+
idMaterial: u16(8),
|
|
319
|
+
idAspect1: u8(10),
|
|
320
|
+
idAspect2: u8(11),
|
|
321
|
+
idType: u8(12),
|
|
322
|
+
idDiameter: u8(13),
|
|
323
|
+
idBrand: u16(14),
|
|
324
|
+
color1R: u8(16),
|
|
325
|
+
color1G: u8(17),
|
|
326
|
+
color1B: u8(18),
|
|
327
|
+
color1A: u8(19),
|
|
328
|
+
measure: u24(20),
|
|
329
|
+
idUnit: u8(23),
|
|
330
|
+
nozzleTempMin: u16(24),
|
|
331
|
+
nozzleTempMax: u16(26),
|
|
332
|
+
dryTemp: u8(28),
|
|
333
|
+
dryTime: u8(29),
|
|
334
|
+
bedTempMin: u8(30),
|
|
335
|
+
bedTempMax: u8(31),
|
|
336
|
+
timestamp: u32(32),
|
|
337
|
+
color2R: u8(36),
|
|
338
|
+
color2G: u8(37),
|
|
339
|
+
color2B: u8(38),
|
|
340
|
+
color3R: u8(40),
|
|
341
|
+
color3G: u8(41),
|
|
342
|
+
color3B: u8(42),
|
|
343
|
+
tdRaw: u16(44),
|
|
344
|
+
customMessage,
|
|
345
|
+
measureAvailable: u24(76),
|
|
346
|
+
signatureR,
|
|
347
|
+
signatureS,
|
|
348
|
+
uid: null,
|
|
349
|
+
_db: db,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── CRUD constructors ───────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Create a new TigerTag from scratch.
|
|
357
|
+
*
|
|
358
|
+
* The protocol version (idTigertag) is inferred automatically:
|
|
359
|
+
* - productId omitted or 0xFFFFFFFF → TigerTag (Maker / offline)
|
|
360
|
+
* - productId is a real cloud ID → TigerTag+
|
|
361
|
+
*
|
|
362
|
+
* @param {object} [options]
|
|
363
|
+
* @param {number} [options.productId=MAKER_PRODUCT_ID]
|
|
364
|
+
* @param {Buffer} [options.uid]
|
|
365
|
+
* @param {number} [options.idMaterial=0]
|
|
366
|
+
* @param {number} [options.idAspect1=0]
|
|
367
|
+
* @param {number} [options.idAspect2=0]
|
|
368
|
+
* @param {number} [options.idType=0]
|
|
369
|
+
* @param {number} [options.idDiameter=0]
|
|
370
|
+
* @param {number} [options.idBrand=0]
|
|
371
|
+
* @param {number} [options.color1R=0]
|
|
372
|
+
* @param {number} [options.color1G=0]
|
|
373
|
+
* @param {number} [options.color1B=0]
|
|
374
|
+
* @param {number} [options.color1A=255]
|
|
375
|
+
* @param {number} [options.color2R=0]
|
|
376
|
+
* @param {number} [options.color2G=0]
|
|
377
|
+
* @param {number} [options.color2B=0]
|
|
378
|
+
* @param {number} [options.color3R=0]
|
|
379
|
+
* @param {number} [options.color3G=0]
|
|
380
|
+
* @param {number} [options.color3B=0]
|
|
381
|
+
* @param {number} [options.measure=0]
|
|
382
|
+
* @param {number} [options.idUnit=0]
|
|
383
|
+
* @param {number} [options.nozzleTempMin=0]
|
|
384
|
+
* @param {number} [options.nozzleTempMax=0]
|
|
385
|
+
* @param {number} [options.dryTemp=0]
|
|
386
|
+
* @param {number} [options.dryTime=0]
|
|
387
|
+
* @param {number} [options.bedTempMin=0]
|
|
388
|
+
* @param {number} [options.bedTempMax=0]
|
|
389
|
+
* @param {number} [options.timestamp] - Seconds since 2000-01-01 UTC. Defaults to now.
|
|
390
|
+
* @param {string} [options.customMessage='']
|
|
391
|
+
* @param {number} [options.tdRaw=0]
|
|
392
|
+
* @param {TigerTagDB} [options.db]
|
|
393
|
+
* @returns {TigerTag}
|
|
394
|
+
*/
|
|
395
|
+
static create({
|
|
396
|
+
productId = MAKER_PRODUCT_ID,
|
|
397
|
+
uid = null,
|
|
398
|
+
idMaterial = 0,
|
|
399
|
+
idAspect1 = 0,
|
|
400
|
+
idAspect2 = 0,
|
|
401
|
+
idType = 0,
|
|
402
|
+
idDiameter = 0,
|
|
403
|
+
idBrand = 0,
|
|
404
|
+
color1R = 0, color1G = 0, color1B = 0, color1A = 255,
|
|
405
|
+
color2R = 0, color2G = 0, color2B = 0,
|
|
406
|
+
color3R = 0, color3G = 0, color3B = 0,
|
|
407
|
+
measure = 0,
|
|
408
|
+
idUnit = 0,
|
|
409
|
+
nozzleTempMin = 0,
|
|
410
|
+
nozzleTempMax = 0,
|
|
411
|
+
dryTemp = 0,
|
|
412
|
+
dryTime = 0,
|
|
413
|
+
bedTempMin = 0,
|
|
414
|
+
bedTempMax = 0,
|
|
415
|
+
timestamp = null,
|
|
416
|
+
customMessage = '',
|
|
417
|
+
tdRaw = 0,
|
|
418
|
+
db = null,
|
|
419
|
+
} = {}) {
|
|
420
|
+
let idTigertag;
|
|
421
|
+
if (productId !== MAKER_PRODUCT_ID && productId !== INIT_PRODUCT_ID) {
|
|
422
|
+
idTigertag = ID_TIGERTAG_PLUS;
|
|
423
|
+
} else {
|
|
424
|
+
idTigertag = ID_TIGERTAG;
|
|
425
|
+
productId = MAKER_PRODUCT_ID;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (timestamp == null) {
|
|
429
|
+
timestamp = Math.max(0, Math.floor((Date.now() - _TIGERTAG_EPOCH_MS) / 1000));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return new TigerTag({
|
|
433
|
+
idTigertag,
|
|
434
|
+
idProduct: productId,
|
|
435
|
+
idMaterial,
|
|
436
|
+
idAspect1, idAspect2, idType, idDiameter, idBrand,
|
|
437
|
+
color1R, color1G, color1B, color1A,
|
|
438
|
+
color2R, color2G, color2B,
|
|
439
|
+
color3R, color3G, color3B,
|
|
440
|
+
measure,
|
|
441
|
+
idUnit,
|
|
442
|
+
measureAvailable: measure,
|
|
443
|
+
nozzleTempMin, nozzleTempMax, dryTemp, dryTime, bedTempMin, bedTempMax,
|
|
444
|
+
timestamp,
|
|
445
|
+
customMessage,
|
|
446
|
+
tdRaw,
|
|
447
|
+
uid,
|
|
448
|
+
_db: db,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Create a TigerTag Init payload.
|
|
454
|
+
* Marks the chip as reserved for TigerTag without programming any material data.
|
|
455
|
+
*
|
|
456
|
+
* @param {Buffer} [uid] - 7-byte chip UID, if known.
|
|
457
|
+
* @returns {TigerTag}
|
|
458
|
+
*/
|
|
459
|
+
static asInit(uid = null) {
|
|
460
|
+
const ts = Math.max(0, Math.floor((Date.now() - _TIGERTAG_EPOCH_MS) / 1000));
|
|
461
|
+
return new TigerTag({
|
|
462
|
+
idTigertag: ID_TIGERTAG_INIT,
|
|
463
|
+
idProduct: INIT_PRODUCT_ID,
|
|
464
|
+
idMaterial: 0,
|
|
465
|
+
idAspect1: 0, idAspect2: 0, idType: 0, idDiameter: 0, idBrand: 0,
|
|
466
|
+
color1R: 0, color1G: 0, color1B: 0, color1A: 255,
|
|
467
|
+
color2R: 0, color2G: 0, color2B: 0,
|
|
468
|
+
color3R: 0, color3G: 0, color3B: 0,
|
|
469
|
+
measure: 0,
|
|
470
|
+
idUnit: 0,
|
|
471
|
+
measureAvailable: 0,
|
|
472
|
+
nozzleTempMin: 0, nozzleTempMax: 0, dryTemp: 0, dryTime: 0, bedTempMin: 0, bedTempMax: 0,
|
|
473
|
+
timestamp: ts,
|
|
474
|
+
customMessage: '',
|
|
475
|
+
tdRaw: 0,
|
|
476
|
+
uid,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Return the 80-byte payload that wipes a TigerTag chip back to blank NDEF.
|
|
482
|
+
* @returns {Buffer}
|
|
483
|
+
*/
|
|
484
|
+
static erase() {
|
|
485
|
+
return Buffer.alloc(MIN_DATA_LEN);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Serializer ──────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Serialize to binary (pages 0x04 onward).
|
|
492
|
+
*
|
|
493
|
+
* @param {boolean} [includeSignature=false] - If true, append 64-byte ECDSA signature.
|
|
494
|
+
* @returns {Buffer} 80 bytes (user data) or 144 bytes (with signature).
|
|
495
|
+
*/
|
|
496
|
+
toBytes(includeSignature = false) {
|
|
497
|
+
const buf = Buffer.alloc(MIN_DATA_LEN);
|
|
498
|
+
let o = 0;
|
|
499
|
+
|
|
500
|
+
buf.writeUInt32BE(this.idTigertag >>> 0, o); o += 4;
|
|
501
|
+
buf.writeUInt32BE(this.idProduct >>> 0, o); o += 4;
|
|
502
|
+
buf.writeUInt16BE(this.idMaterial & 0xFFFF, o); o += 2;
|
|
503
|
+
buf[o++] = this.idAspect1 & 0xFF;
|
|
504
|
+
buf[o++] = this.idAspect2 & 0xFF;
|
|
505
|
+
buf[o++] = this.idType & 0xFF;
|
|
506
|
+
buf[o++] = this.idDiameter & 0xFF;
|
|
507
|
+
buf.writeUInt16BE(this.idBrand & 0xFFFF, o); o += 2;
|
|
508
|
+
|
|
509
|
+
buf[o++] = this.color1R & 0xFF;
|
|
510
|
+
buf[o++] = this.color1G & 0xFF;
|
|
511
|
+
buf[o++] = this.color1B & 0xFF;
|
|
512
|
+
buf[o++] = this.color1A & 0xFF;
|
|
513
|
+
|
|
514
|
+
const m = this.measure & 0xFFFFFF;
|
|
515
|
+
buf[o++] = (m >> 16) & 0xFF;
|
|
516
|
+
buf[o++] = (m >> 8) & 0xFF;
|
|
517
|
+
buf[o++] = m & 0xFF;
|
|
518
|
+
buf[o++] = this.idUnit & 0xFF;
|
|
519
|
+
|
|
520
|
+
buf.writeUInt16BE(this.nozzleTempMin & 0xFFFF, o); o += 2;
|
|
521
|
+
buf.writeUInt16BE(this.nozzleTempMax & 0xFFFF, o); o += 2;
|
|
522
|
+
buf[o++] = this.dryTemp & 0xFF;
|
|
523
|
+
buf[o++] = this.dryTime & 0xFF;
|
|
524
|
+
buf[o++] = this.bedTempMin & 0xFF;
|
|
525
|
+
buf[o++] = this.bedTempMax & 0xFF;
|
|
526
|
+
|
|
527
|
+
buf.writeUInt32BE(this.timestamp >>> 0, o); o += 4;
|
|
528
|
+
|
|
529
|
+
buf[o++] = this.color2R & 0xFF;
|
|
530
|
+
buf[o++] = this.color2G & 0xFF;
|
|
531
|
+
buf[o++] = this.color2B & 0xFF;
|
|
532
|
+
buf[o++] = 0x00;
|
|
533
|
+
|
|
534
|
+
buf[o++] = this.color3R & 0xFF;
|
|
535
|
+
buf[o++] = this.color3G & 0xFF;
|
|
536
|
+
buf[o++] = this.color3B & 0xFF;
|
|
537
|
+
buf[o++] = 0x00;
|
|
538
|
+
|
|
539
|
+
buf.writeUInt16BE(this.tdRaw & 0xFFFF, o); o += 2;
|
|
540
|
+
buf[o++] = 0x00;
|
|
541
|
+
buf[o++] = 0x00;
|
|
542
|
+
|
|
543
|
+
// custom_message: 28 bytes, UTF-8, zero-padded
|
|
544
|
+
const msgBytes = Buffer.from(this.customMessage || '', 'utf8').subarray(0, 28);
|
|
545
|
+
msgBytes.copy(buf, o);
|
|
546
|
+
o += 28;
|
|
547
|
+
|
|
548
|
+
const ma = this.measureAvailable & 0xFFFFFF;
|
|
549
|
+
buf[o++] = (ma >> 16) & 0xFF;
|
|
550
|
+
buf[o++] = (ma >> 8) & 0xFF;
|
|
551
|
+
buf[o++] = ma & 0xFF;
|
|
552
|
+
buf[o++] = 0x00;
|
|
553
|
+
|
|
554
|
+
// o === 80 at this point
|
|
555
|
+
|
|
556
|
+
if (!includeSignature) return buf;
|
|
557
|
+
|
|
558
|
+
const sigR = Buffer.alloc(32);
|
|
559
|
+
const sigS = Buffer.alloc(32);
|
|
560
|
+
if (this.signatureR) this.signatureR.copy(sigR);
|
|
561
|
+
if (this.signatureS) this.signatureS.copy(sigS);
|
|
562
|
+
return Buffer.concat([buf, sigR, sigS]);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Patch (immutable update) ────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Return a new TigerTag with selected fields replaced.
|
|
569
|
+
*
|
|
570
|
+
* Protected fields (idTigertag, idProduct, uid, signatureR, signatureS)
|
|
571
|
+
* cannot be modified — they are covered by the ECDSA signature.
|
|
572
|
+
*
|
|
573
|
+
* @param {object} kwargs - Field names (camelCase) and their new values.
|
|
574
|
+
* @returns {TigerTag}
|
|
575
|
+
*/
|
|
576
|
+
patch(kwargs) {
|
|
577
|
+
const protected_ = Object.keys(kwargs).filter((k) => _PROTECTED_FIELDS.has(k));
|
|
578
|
+
if (protected_.length > 0) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`Cannot modify protected field(s): ${protected_.sort().join(', ')}. `
|
|
581
|
+
+ 'These fields are covered by the ECDSA signature and must never change.',
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const unknown = Object.keys(kwargs).filter((k) => !_PATCHABLE_FIELDS.has(k));
|
|
585
|
+
if (unknown.length > 0) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Unknown field(s): ${unknown.sort().join(', ')}. `
|
|
588
|
+
+ `Valid patchable fields: ${[..._PATCHABLE_FIELDS].sort().join(', ')}`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return new TigerTag(Object.assign({}, this, kwargs));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Validation ──────────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Basic field-level sanity checks.
|
|
598
|
+
* @returns {string[]} List of warning strings. Empty = no issues.
|
|
599
|
+
*/
|
|
600
|
+
validate() {
|
|
601
|
+
const warnings = [];
|
|
602
|
+
if (this.nozzleTempMin > this.nozzleTempMax && this.nozzleTempMax > 0) {
|
|
603
|
+
warnings.push(
|
|
604
|
+
`Nozzle temp min (${this.nozzleTempMin}°C) > max (${this.nozzleTempMax}°C)`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
if (this.bedTempMin > this.bedTempMax && this.bedTempMax > 0) {
|
|
608
|
+
warnings.push(
|
|
609
|
+
`Bed temp min (${this.bedTempMin}°C) > max (${this.bedTempMax}°C)`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (this.tdRaw !== 0 && !(this.tdRaw >= 10 && this.tdRaw <= 1000)) {
|
|
613
|
+
warnings.push(
|
|
614
|
+
`TD HueForge out of range: ${this.tdRaw} (valid: 10–1000 or 0=undefined)`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (this.measure > 0 && this.measureAvailable > this.measure) {
|
|
618
|
+
warnings.push(
|
|
619
|
+
`measure_available (${this.measureAvailable}) > initial measure (${this.measure})`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (Buffer.byteLength(this.customMessage, 'utf8') > 28) {
|
|
623
|
+
warnings.push('custom_message exceeds 28 bytes');
|
|
624
|
+
}
|
|
625
|
+
return warnings;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── Signature verification ──────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Verify the ECDSA-P256 signature — fully autonomous.
|
|
632
|
+
*
|
|
633
|
+
* Signed message: SHA-256( uid_bytes + block4 + block5 )
|
|
634
|
+
* uid_bytes = 7 raw bytes from chip pages 0-1
|
|
635
|
+
* block4 = idTigertag as 4-byte big-endian
|
|
636
|
+
* block5 = idProduct as 4-byte big-endian
|
|
637
|
+
*
|
|
638
|
+
* @param {TigerTagDB} [db] - Optional pre-loaded TigerTagDB.
|
|
639
|
+
* @returns {SignatureResult}
|
|
640
|
+
*/
|
|
641
|
+
verify(db = null) {
|
|
642
|
+
if (!this.isSigned) {
|
|
643
|
+
return new SignatureResult(SignatureResult.UNSIGNED);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!this.uid) {
|
|
647
|
+
return new SignatureResult(
|
|
648
|
+
SignatureResult.NO_UID,
|
|
649
|
+
'UID required for signature verification. '
|
|
650
|
+
+ 'Use fromPages(payload, uid) — the NFC SDK always exposes '
|
|
651
|
+
+ 'the UID separately. For binary dumps, use a full 180-byte dump.',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const _db = db || this.db;
|
|
656
|
+
const versionEntry = _db.version(this.idTigertag) || {};
|
|
657
|
+
const pem = (versionEntry.public_key || '').trim();
|
|
658
|
+
if (!pem) {
|
|
659
|
+
return new SignatureResult(
|
|
660
|
+
SignatureResult.NO_KEY,
|
|
661
|
+
`No public_key in id_version.json for 0x${this.idTigertag.toString(16).toUpperCase().padStart(8, '0')}.`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return verifySignature({
|
|
666
|
+
uid: this.uid,
|
|
667
|
+
idTigertag: this.idTigertag,
|
|
668
|
+
idProduct: this.idProduct,
|
|
669
|
+
signatureR: this.signatureR,
|
|
670
|
+
signatureS: this.signatureS,
|
|
671
|
+
publicKeyPem: pem,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Cloud API ───────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Fetch the raw TigerTag+ cloud product data from the API.
|
|
679
|
+
* Only meaningful for TigerTag+ chips (returns null for Maker/Init tags).
|
|
680
|
+
*
|
|
681
|
+
* @param {number} [timeout=5000] - Request timeout in milliseconds.
|
|
682
|
+
* @returns {Promise<object|null>}
|
|
683
|
+
*/
|
|
684
|
+
async rawApi(timeout = 5000) {
|
|
685
|
+
if (this.isMaker || this.isInit) return null;
|
|
686
|
+
|
|
687
|
+
const controller = new AbortController();
|
|
688
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
689
|
+
try {
|
|
690
|
+
const resp = await fetch(this.apiUrl, { signal: controller.signal });
|
|
691
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
692
|
+
return await resp.json();
|
|
693
|
+
} catch (err) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`TigerTag+ API request failed (${err.message}). `
|
|
696
|
+
+ `Verify network access or browse: ${this.productPageUrl}`,
|
|
697
|
+
);
|
|
698
|
+
} finally {
|
|
699
|
+
clearTimeout(timer);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Compare chip data against the TigerTag+ cloud API.
|
|
705
|
+
* Returns a list of ApiDiff entries for every differing field.
|
|
706
|
+
* An empty list means the chip is fully in sync.
|
|
707
|
+
*
|
|
708
|
+
* @param {object|null} [apiData] - Pre-fetched result of rawApi(). Fetched automatically if null.
|
|
709
|
+
* @param {TigerTagDB} [db]
|
|
710
|
+
* @returns {Promise<ApiDiff[]>}
|
|
711
|
+
*/
|
|
712
|
+
async diffApi(apiData = null, db = null) {
|
|
713
|
+
if (this.isMaker || this.isInit) return [];
|
|
714
|
+
|
|
715
|
+
const _db = db || this.db;
|
|
716
|
+
const data = apiData != null ? apiData : await this.rawApi();
|
|
717
|
+
if (!data) return [];
|
|
718
|
+
|
|
719
|
+
const diffs = [];
|
|
720
|
+
|
|
721
|
+
const _lbl = (entry) => (TigerTagDB.label(entry) || '').trim().toLowerCase();
|
|
722
|
+
const _check = (field, chipVal, apiVal) => {
|
|
723
|
+
if (apiVal == null) return;
|
|
724
|
+
const c = String(chipVal).trim().toLowerCase();
|
|
725
|
+
const a = String(apiVal).trim().toLowerCase();
|
|
726
|
+
if (c !== a) diffs.push(new ApiDiff(field, chipVal, apiVal));
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const fil = data.filament || {};
|
|
730
|
+
const nozzle = data.nozzle || {};
|
|
731
|
+
const bed = data.bed || {};
|
|
732
|
+
const dryer = data.dryer || {};
|
|
733
|
+
|
|
734
|
+
_check('nozzle_min', this.nozzleTempMin, nozzle.temp_min);
|
|
735
|
+
_check('nozzle_max', this.nozzleTempMax, nozzle.temp_max);
|
|
736
|
+
_check('bed_min', this.bedTempMin, bed.temp_min);
|
|
737
|
+
_check('bed_max', this.bedTempMax, bed.temp_max);
|
|
738
|
+
_check('dry_temp', this.dryTemp, dryer.temp);
|
|
739
|
+
_check('dry_time', this.dryTime, dryer.time);
|
|
740
|
+
|
|
741
|
+
_check('type', _lbl(_db.type(this.idType)), (data.product_type || '').toLowerCase());
|
|
742
|
+
_check('material', _lbl(_db.material(this.idMaterial)), (fil.material || '').toLowerCase());
|
|
743
|
+
_check('brand', _lbl(_db.brand(this.idBrand)), (data.brand || '').toLowerCase());
|
|
744
|
+
_check('diameter', TigerTagDB.label(_db.diameter(this.idDiameter)) || '',
|
|
745
|
+
String(fil.diameter || ''));
|
|
746
|
+
|
|
747
|
+
const _EMPTY_ASPECT = new Set(['', 'unknown', '-', 'none']);
|
|
748
|
+
const _checkAspect = (fname, chipId, apiVal) => {
|
|
749
|
+
const chipLbl = _lbl(_db.aspect(chipId));
|
|
750
|
+
const apiNorm = (apiVal || '').trim().toLowerCase();
|
|
751
|
+
if (_EMPTY_ASPECT.has(chipLbl) && _EMPTY_ASPECT.has(apiNorm)) return;
|
|
752
|
+
if (chipLbl !== apiNorm) diffs.push(new ApiDiff(fname, chipLbl || 'none', apiVal || 'none'));
|
|
753
|
+
};
|
|
754
|
+
_checkAspect('aspect_1', this.idAspect1, fil.aspect1);
|
|
755
|
+
_checkAspect('aspect_2', this.idAspect2, fil.aspect2);
|
|
756
|
+
|
|
757
|
+
const _parseApiColor = (hexStr) => {
|
|
758
|
+
const h = (hexStr || '').replace('#', '');
|
|
759
|
+
try {
|
|
760
|
+
if (h.length === 8) return [
|
|
761
|
+
parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16),
|
|
762
|
+
parseInt(h.slice(4,6),16), parseInt(h.slice(6,8),16),
|
|
763
|
+
];
|
|
764
|
+
if (h.length === 6) return [
|
|
765
|
+
parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16),
|
|
766
|
+
parseInt(h.slice(4,6),16), 255,
|
|
767
|
+
];
|
|
768
|
+
} catch (_) {}
|
|
769
|
+
return null;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
let apiColors = [];
|
|
773
|
+
const colorInfo = fil.color_info || {};
|
|
774
|
+
if (colorInfo.colors && colorInfo.colors.length > 0) {
|
|
775
|
+
apiColors = colorInfo.colors.filter(Boolean);
|
|
776
|
+
} else if (fil.color) {
|
|
777
|
+
apiColors = [fil.color];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (apiColors.length > 0) {
|
|
781
|
+
const c = _parseApiColor(apiColors[0]);
|
|
782
|
+
if (c) {
|
|
783
|
+
const chipHex = `#${_hex2(this.color1R)}${_hex2(this.color1G)}${_hex2(this.color1B)}${_hex2(this.color1A)}`;
|
|
784
|
+
const apiHex = `#${_hex2(c[0])}${_hex2(c[1])}${_hex2(c[2])}${_hex2(c[3])}`;
|
|
785
|
+
if (chipHex.toLowerCase() !== apiHex.toLowerCase()) {
|
|
786
|
+
diffs.push(new ApiDiff('color_1', chipHex, apiHex));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (apiColors.length > 1) {
|
|
791
|
+
const c = _parseApiColor(apiColors[1]);
|
|
792
|
+
if (c) {
|
|
793
|
+
const chipHex = `#${_hex2(this.color2R)}${_hex2(this.color2G)}${_hex2(this.color2B)}`;
|
|
794
|
+
const apiHex = `#${_hex2(c[0])}${_hex2(c[1])}${_hex2(c[2])}`;
|
|
795
|
+
if (chipHex.toLowerCase() !== apiHex.toLowerCase()) {
|
|
796
|
+
diffs.push(new ApiDiff('color_2', chipHex, apiHex));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (apiColors.length > 2) {
|
|
801
|
+
const c = _parseApiColor(apiColors[2]);
|
|
802
|
+
if (c) {
|
|
803
|
+
const chipHex = `#${_hex2(this.color3R)}${_hex2(this.color3G)}${_hex2(this.color3B)}`;
|
|
804
|
+
const apiHex = `#${_hex2(c[0])}${_hex2(c[1])}${_hex2(c[2])}`;
|
|
805
|
+
if (chipHex.toLowerCase() !== apiHex.toLowerCase()) {
|
|
806
|
+
diffs.push(new ApiDiff('color_3', chipHex, apiHex));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (fil.grams != null) _check('measure_g', this.measure, Math.round(fil.grams));
|
|
812
|
+
if (fil.measure_unit) {
|
|
813
|
+
_check('measure_unit',
|
|
814
|
+
TigerTagDB.label(_db.unit(this.idUnit)) || '',
|
|
815
|
+
(fil.measure_unit || '').trim(),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return diffs;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Apply API-sourced field updates surgically, without touching the signature.
|
|
824
|
+
*
|
|
825
|
+
* @param {object|null} [apiData] - Pre-fetched API dict. If null, calls rawApi().
|
|
826
|
+
* @param {TigerTagDB} [db]
|
|
827
|
+
* @returns {Promise<[TigerTag, ApiDiff[]]>}
|
|
828
|
+
*/
|
|
829
|
+
async patchFromApi(apiData = null, db = null) {
|
|
830
|
+
if (this.isMaker || this.isInit) return [this, []];
|
|
831
|
+
|
|
832
|
+
const _db = db || this.db;
|
|
833
|
+
const data = apiData != null ? apiData : await this.rawApi();
|
|
834
|
+
if (!data) return [this, []];
|
|
835
|
+
|
|
836
|
+
const diffs = await this.diffApi(data, _db);
|
|
837
|
+
if (!diffs.length) return [this, []];
|
|
838
|
+
|
|
839
|
+
const _FIELD_MAP = {
|
|
840
|
+
nozzle_min: 'nozzleTempMin',
|
|
841
|
+
nozzle_max: 'nozzleTempMax',
|
|
842
|
+
bed_min: 'bedTempMin',
|
|
843
|
+
bed_max: 'bedTempMax',
|
|
844
|
+
dry_temp: 'dryTemp',
|
|
845
|
+
dry_time: 'dryTime',
|
|
846
|
+
measure_g: 'measure',
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const _hexToRgba = (hexStr) => {
|
|
850
|
+
const h = hexStr.replace('#', '');
|
|
851
|
+
try {
|
|
852
|
+
if (h.length === 8) return [
|
|
853
|
+
parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16),
|
|
854
|
+
parseInt(h.slice(4,6),16), parseInt(h.slice(6,8),16),
|
|
855
|
+
];
|
|
856
|
+
if (h.length === 6) return [
|
|
857
|
+
parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16),
|
|
858
|
+
parseInt(h.slice(4,6),16), 255,
|
|
859
|
+
];
|
|
860
|
+
} catch (_) {}
|
|
861
|
+
return null;
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const updates = {};
|
|
865
|
+
const applied = [];
|
|
866
|
+
|
|
867
|
+
for (const diff of diffs) {
|
|
868
|
+
if (diff.field === 'color_1' || diff.field === 'color_2' || diff.field === 'color_3') {
|
|
869
|
+
const rgba = _hexToRgba(String(diff.apiValue));
|
|
870
|
+
if (rgba) {
|
|
871
|
+
const [r, g, b, a] = rgba;
|
|
872
|
+
if (diff.field === 'color_1') Object.assign(updates, { color1R: r, color1G: g, color1B: b, color1A: a });
|
|
873
|
+
else if (diff.field === 'color_2') Object.assign(updates, { color2R: r, color2G: g, color2B: b });
|
|
874
|
+
else Object.assign(updates, { color3R: r, color3G: g, color3B: b });
|
|
875
|
+
applied.push(diff);
|
|
876
|
+
}
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
const tagField = _FIELD_MAP[diff.field];
|
|
880
|
+
if (tagField) {
|
|
881
|
+
updates[tagField] = Math.round(Number(diff.apiValue));
|
|
882
|
+
applied.push(diff);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (Object.keys(updates).length === 0) return [this, []];
|
|
887
|
+
return [this.patch(updates), applied];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Download or update reference databases.
|
|
892
|
+
*
|
|
893
|
+
* @param {string} [dbPath] - Target folder. Defaults to the bundled database directory.
|
|
894
|
+
* @param {boolean} [force=false] - Re-download all files even if up to date.
|
|
895
|
+
* @returns {Promise<string[]>} List of filenames that were downloaded/updated.
|
|
896
|
+
*/
|
|
897
|
+
async syncDb(dbPath = null, force = false) {
|
|
898
|
+
const targetPath = dbPath || _BUNDLED_DB_PATH;
|
|
899
|
+
const updated = await syncDatabases(targetPath, { force, verbose: true });
|
|
900
|
+
this._db = new TigerTagDB({ dbPath: targetPath });
|
|
901
|
+
return updated;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── Output ──────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Return protocol fields exactly as stored on the chip — no label resolution,
|
|
908
|
+
* no unit conversion, no date formatting.
|
|
909
|
+
* @returns {object}
|
|
910
|
+
*/
|
|
911
|
+
toRawDict() {
|
|
912
|
+
return {
|
|
913
|
+
id_tigertag: this.idTigertag,
|
|
914
|
+
id_product: this.idProduct,
|
|
915
|
+
id_material: this.idMaterial,
|
|
916
|
+
id_aspect1: this.idAspect1,
|
|
917
|
+
id_aspect2: this.idAspect2,
|
|
918
|
+
id_type: this.idType,
|
|
919
|
+
id_diameter: this.idDiameter,
|
|
920
|
+
id_brand: this.idBrand,
|
|
921
|
+
color_r: this.color1R,
|
|
922
|
+
color_g: this.color1G,
|
|
923
|
+
color_b: this.color1B,
|
|
924
|
+
color_a: this.color1A,
|
|
925
|
+
measure: this.measure,
|
|
926
|
+
id_unit: this.idUnit,
|
|
927
|
+
nozzle_min: this.nozzleTempMin,
|
|
928
|
+
nozzle_max: this.nozzleTempMax,
|
|
929
|
+
dry_temp: this.dryTemp,
|
|
930
|
+
dry_time: this.dryTime,
|
|
931
|
+
bed_min: this.bedTempMin,
|
|
932
|
+
bed_max: this.bedTempMax,
|
|
933
|
+
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,
|
|
940
|
+
td_raw: this.tdRaw,
|
|
941
|
+
message: this.customMessage,
|
|
942
|
+
measure_available: this.measureAvailable,
|
|
943
|
+
uid: this.uidHex,
|
|
944
|
+
product_page_url: this.productPageUrl,
|
|
945
|
+
api_url: this.apiUrl,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Return a fully-resolved object with labels, units, and semantic context.
|
|
951
|
+
* Suitable for JSON serialization, API responses, or LLM context injection.
|
|
952
|
+
*
|
|
953
|
+
* @param {TigerTagDB} [db]
|
|
954
|
+
* @returns {object}
|
|
955
|
+
*/
|
|
956
|
+
toDict(db = null) {
|
|
957
|
+
const _db = db || this.db;
|
|
958
|
+
const mat = _db.material(this.idMaterial) || {};
|
|
959
|
+
const rec = mat.recommended || {};
|
|
960
|
+
const stock = this.stockPercent;
|
|
961
|
+
const unitLabel = TigerTagDB.label(_db.unit(this.idUnit));
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
sdk: 'tigertag-sdk-js',
|
|
965
|
+
sdk_mode: 'offline',
|
|
966
|
+
protocol: 'TigerTag Open Source v2.1',
|
|
967
|
+
chip: 'NTAG213/215/216',
|
|
968
|
+
uid: this.uidHex,
|
|
969
|
+
version: {
|
|
970
|
+
id: this.idTigertag,
|
|
971
|
+
hex: `0x${this.idTigertag.toString(16).toUpperCase().padStart(8, '0')}`,
|
|
972
|
+
label: TigerTagDB.label(_db.version(this.idTigertag)),
|
|
973
|
+
},
|
|
974
|
+
product: {
|
|
975
|
+
id: this.idProduct,
|
|
976
|
+
mode: this.isMaker ? 'maker' : this.isInit ? 'init' : 'cloud',
|
|
977
|
+
description: this.isMaker
|
|
978
|
+
? 'TigerTag Maker — all data stored on chip, no cloud dependency.'
|
|
979
|
+
: this.isInit
|
|
980
|
+
? 'TigerTag Init — blank/uninitialized chip.'
|
|
981
|
+
: `TigerTag+ — cloud product ID ${this.idProduct}. `
|
|
982
|
+
+ 'Query the api_url field for the full enriched product JSON.',
|
|
983
|
+
product_page_url: this.productPageUrl,
|
|
984
|
+
api_url: this.apiUrl,
|
|
985
|
+
},
|
|
986
|
+
material: {
|
|
987
|
+
id: this.idMaterial,
|
|
988
|
+
label: TigerTagDB.label(_db.material(this.idMaterial)),
|
|
989
|
+
density: mat.density,
|
|
990
|
+
density_unit: 'g/cm³',
|
|
991
|
+
filled: mat.filled,
|
|
992
|
+
recommended_by_db: rec.nozzleTempMin != null ? {
|
|
993
|
+
nozzle: { min: rec.nozzleTempMin, max: rec.nozzleTempMax, unit: 'celsius' },
|
|
994
|
+
bed: { min: rec.bedTempMin, max: rec.bedTempMax, unit: 'celsius' },
|
|
995
|
+
dry: { temp: rec.dryTemp, time_h: rec.dryTime, temp_unit: 'celsius', time_unit: 'hours' },
|
|
996
|
+
} : null,
|
|
997
|
+
metadata: mat.metadata,
|
|
998
|
+
},
|
|
999
|
+
aspect_1: {
|
|
1000
|
+
id: this.idAspect1,
|
|
1001
|
+
label: TigerTagDB.label(_db.aspect(this.idAspect1)),
|
|
1002
|
+
color_count: (_db.aspect(this.idAspect1) || {}).color_count || 1,
|
|
1003
|
+
},
|
|
1004
|
+
aspect_2: {
|
|
1005
|
+
id: this.idAspect2,
|
|
1006
|
+
label: TigerTagDB.label(_db.aspect(this.idAspect2)),
|
|
1007
|
+
color_count: (_db.aspect(this.idAspect2) || {}).color_count || 1,
|
|
1008
|
+
description: 'Check aspect_2 first for multi-color modes (Bicolor/Tricolor/Rainbow). '
|
|
1009
|
+
+ 'color_count defines how many of the three color fields are active.',
|
|
1010
|
+
},
|
|
1011
|
+
type: { id: this.idType, label: TigerTagDB.label(_db.type(this.idType)) },
|
|
1012
|
+
diameter: { id: this.idDiameter, label: TigerTagDB.label(_db.diameter(this.idDiameter)), unit: 'mm' },
|
|
1013
|
+
brand: { id: this.idBrand, label: TigerTagDB.label(_db.brand(this.idBrand)) },
|
|
1014
|
+
colors: {
|
|
1015
|
+
primary: {
|
|
1016
|
+
hex: this.color1Hex,
|
|
1017
|
+
rgba: [this.color1R, this.color1G, this.color1B, this.color1A],
|
|
1018
|
+
description: 'Main filament color (RGBA — alpha=255 means fully opaque).',
|
|
1019
|
+
},
|
|
1020
|
+
secondary: {
|
|
1021
|
+
hex: this.color2Hex,
|
|
1022
|
+
rgb: [this.color2R, this.color2G, this.color2B],
|
|
1023
|
+
description: 'Secondary color for bi-color or gradient filaments.',
|
|
1024
|
+
},
|
|
1025
|
+
tertiary: {
|
|
1026
|
+
hex: this.color3Hex,
|
|
1027
|
+
rgb: [this.color3R, this.color3G, this.color3B],
|
|
1028
|
+
description: 'Tertiary color for tri-color filaments.',
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
hueforge_td: {
|
|
1032
|
+
value: this.tdRaw !== 0 ? this.tdValue : null,
|
|
1033
|
+
unit: 'TD (Transmission Distance)',
|
|
1034
|
+
description: 'HueForge Transmission Distance — opacity parameter for image-to-model slicing. '
|
|
1035
|
+
+ 'Valid range: 0.1–100.0. null means undefined (no HueForge data on this tag).',
|
|
1036
|
+
},
|
|
1037
|
+
measure: {
|
|
1038
|
+
initial: this.measure,
|
|
1039
|
+
available: this.measureAvailable,
|
|
1040
|
+
percent: stock,
|
|
1041
|
+
unit: unitLabel,
|
|
1042
|
+
description: `Material quantity: ${this.measureAvailable} ${unitLabel} remaining `
|
|
1043
|
+
+ `out of ${this.measure} ${unitLabel} initial `
|
|
1044
|
+
+ (stock != null ? `(${stock}%).` : '(percentage unavailable).'),
|
|
1045
|
+
},
|
|
1046
|
+
temperatures: {
|
|
1047
|
+
unit: 'celsius',
|
|
1048
|
+
on_chip: {
|
|
1049
|
+
description: 'Settings programmed by the manufacturer on the chip.',
|
|
1050
|
+
nozzle: { min: this.nozzleTempMin, max: this.nozzleTempMax },
|
|
1051
|
+
bed: { min: this.bedTempMin, max: this.bedTempMax },
|
|
1052
|
+
dry: { temp: this.dryTemp, time_h: this.dryTime },
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
manufacturing_date: this.manufacturingDate.toISOString(),
|
|
1056
|
+
twin_tag_pairing_id: this.timestamp,
|
|
1057
|
+
custom_message: this.customMessage,
|
|
1058
|
+
authentication: {
|
|
1059
|
+
signed: this.isSigned,
|
|
1060
|
+
uid_present: this.uid != null,
|
|
1061
|
+
description: this.isSigned
|
|
1062
|
+
? 'Tag carries an ECDSA-P256 signature. Call tag.verify() to check authenticity.'
|
|
1063
|
+
: 'Tag is not signed — authenticity cannot be verified cryptographically.',
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Return a concise natural-language description of the tag.
|
|
1070
|
+
* Designed for injection into LLM prompts.
|
|
1071
|
+
*
|
|
1072
|
+
* @param {TigerTagDB} [db]
|
|
1073
|
+
* @returns {string}
|
|
1074
|
+
*/
|
|
1075
|
+
describe(db = null) {
|
|
1076
|
+
const _db = db || this.db;
|
|
1077
|
+
const mat = _db.material(this.idMaterial) || {};
|
|
1078
|
+
const rec = mat.recommended || {};
|
|
1079
|
+
|
|
1080
|
+
const material = TigerTagDB.label(_db.material(this.idMaterial));
|
|
1081
|
+
const type_ = TigerTagDB.label(_db.type(this.idType));
|
|
1082
|
+
const diameter = TigerTagDB.label(_db.diameter(this.idDiameter));
|
|
1083
|
+
const brand = TigerTagDB.label(_db.brand(this.idBrand));
|
|
1084
|
+
const unit = TigerTagDB.label(_db.unit(this.idUnit));
|
|
1085
|
+
const density = mat.density;
|
|
1086
|
+
const stock = this.stockPercent;
|
|
1087
|
+
|
|
1088
|
+
const asp2Entry = _db.aspect(this.idAspect2);
|
|
1089
|
+
const asp1Entry = _db.aspect(this.idAspect1);
|
|
1090
|
+
let colorCount = 1;
|
|
1091
|
+
if (asp2Entry && (asp2Entry.color_count || 1) > 1) colorCount = asp2Entry.color_count;
|
|
1092
|
+
else if (asp1Entry && (asp1Entry.color_count || 1) > 1) colorCount = asp1Entry.color_count;
|
|
1093
|
+
|
|
1094
|
+
const aspect1Label = TigerTagDB.label(asp1Entry);
|
|
1095
|
+
const aspect2Label = TigerTagDB.label(asp2Entry);
|
|
1096
|
+
|
|
1097
|
+
const parts = [];
|
|
1098
|
+
|
|
1099
|
+
parts.push(
|
|
1100
|
+
`TigerTag RFID chip read successfully.`
|
|
1101
|
+
+ ` Material: ${material} ${type_}`
|
|
1102
|
+
+ (!['Unknown', '-'].includes(diameter) ? ` (${diameter}mm diameter)` : '')
|
|
1103
|
+
+ (density ? `, density ${density} g/cm³` : '')
|
|
1104
|
+
+ (!['Unknown', '-'].includes(brand) ? `, by ${brand}` : '')
|
|
1105
|
+
+ '.',
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const finishParts = [aspect1Label, aspect2Label].filter(
|
|
1109
|
+
(l) => !['Unknown', '-', 'None'].includes(l),
|
|
1110
|
+
);
|
|
1111
|
+
if (finishParts.length > 0) parts.push(`Finish: ${finishParts.join(' + ')}.`);
|
|
1112
|
+
|
|
1113
|
+
const colorParts = [`primary ${this.color1Hex}`];
|
|
1114
|
+
if (colorCount >= 2) colorParts.push(`secondary ${this.color2Hex}`);
|
|
1115
|
+
if (colorCount >= 3) colorParts.push(`tertiary ${this.color3Hex}`);
|
|
1116
|
+
parts.push(`Color: ${colorParts.join(', ')}.`);
|
|
1117
|
+
|
|
1118
|
+
parts.push(
|
|
1119
|
+
`Print settings (on chip): nozzle ${this.nozzleTempMin}–${this.nozzleTempMax}°C,`
|
|
1120
|
+
+ ` bed ${this.bedTempMin}–${this.bedTempMax}°C,`
|
|
1121
|
+
+ ` drying ${this.dryTemp}°C for ${this.dryTime}h.`,
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
if (rec.nozzleTempMin != null) {
|
|
1125
|
+
parts.push(
|
|
1126
|
+
`Database recommended settings: nozzle ${rec.nozzleTempMin}–${rec.nozzleTempMax}°C,`
|
|
1127
|
+
+ ` bed ${rec.bedTempMin || '?'}–${rec.bedTempMax || '?'}°C,`
|
|
1128
|
+
+ ` drying ${rec.dryTemp || '?'}°C for ${rec.dryTime || '?'}h.`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (this.measure > 0) {
|
|
1133
|
+
parts.push(
|
|
1134
|
+
`Quantity: ${this.measureAvailable} ${unit} remaining`
|
|
1135
|
+
+ ` out of ${this.measure} ${unit} initial`
|
|
1136
|
+
+ (stock != null ? ` (${stock}%).` : '.'),
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (this.tdRaw !== 0) parts.push(`HueForge TD: ${this.tdValue.toFixed(1)}.`);
|
|
1141
|
+
|
|
1142
|
+
const dateStr = this.manufacturingDate.toISOString().slice(0, 10);
|
|
1143
|
+
parts.push(`Manufactured: ${dateStr}.`);
|
|
1144
|
+
if (this.customMessage) parts.push(`Custom message on chip: "${this.customMessage}".`);
|
|
1145
|
+
if (this.uidHex) parts.push(`Chip UID: ${this.uidHex}.`);
|
|
1146
|
+
|
|
1147
|
+
if (this.productPageUrl) {
|
|
1148
|
+
parts.push(
|
|
1149
|
+
`Product page: ${this.productPageUrl} — API JSON: ${this.apiUrl}`,
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (this.isSigned) {
|
|
1154
|
+
parts.push('Tag carries an ECDSA-P256 signature — call tag.verify() to confirm authenticity.');
|
|
1155
|
+
} else {
|
|
1156
|
+
parts.push('Tag is not ECDSA-signed.');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return parts.join(' ');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Human-readable summary of the tag contents.
|
|
1164
|
+
*
|
|
1165
|
+
* @param {TigerTagDB} [db]
|
|
1166
|
+
* @param {SignatureResult} [sigResult]
|
|
1167
|
+
* @returns {string}
|
|
1168
|
+
*/
|
|
1169
|
+
pretty(db = null, sigResult = null) {
|
|
1170
|
+
const _db = db || this.db;
|
|
1171
|
+
const mat = _db.material(this.idMaterial) || {};
|
|
1172
|
+
const rec = mat.recommended || {};
|
|
1173
|
+
const stock = this.stockPercent;
|
|
1174
|
+
const ul = TigerTagDB.label(_db.unit(this.idUnit));
|
|
1175
|
+
const sig = sigResult
|
|
1176
|
+
? String(sigResult)
|
|
1177
|
+
: (this.isSigned ? 'signed ✓' : 'not signed');
|
|
1178
|
+
|
|
1179
|
+
const recNote = (kMin, kMax, suffix = '°C') =>
|
|
1180
|
+
rec[kMin] != null ? ` (DB: ${rec[kMin]}–${rec[kMax]}${suffix})` : '';
|
|
1181
|
+
|
|
1182
|
+
const idHex = this.idTigertag.toString(16).toUpperCase().padStart(8, '0');
|
|
1183
|
+
|
|
1184
|
+
return (
|
|
1185
|
+
`┌─ TigerTag ────────────────────────────────────────────\n`
|
|
1186
|
+
+ `│ Version ${TigerTagDB.label(_db.version(this.idTigertag))} (0x${idHex})\n`
|
|
1187
|
+
+ `│ Product ${this.isMaker ? 'TigerTag Maker' : this.isInit ? 'TigerTag Init' : `TigerTag+ (cloud #${this.idProduct})`}\n`
|
|
1188
|
+
+ `│ UID ${this.uidHex || '— (partial dump)'}\n`
|
|
1189
|
+
+ (this.productPageUrl
|
|
1190
|
+
? `│ Product page ${this.productPageUrl}\n│ API JSON ${this.apiUrl}\n`
|
|
1191
|
+
: '')
|
|
1192
|
+
+ `├─ Material ────────────────────────────────────────────\n`
|
|
1193
|
+
+ `│ Material ${TigerTagDB.label(_db.material(this.idMaterial))} (id=${this.idMaterial})\n`
|
|
1194
|
+
+ `│ Density ${mat.density != null ? mat.density : '—'} g/cm³\n`
|
|
1195
|
+
+ `│ Type ${TigerTagDB.label(_db.type(this.idType))}\n`
|
|
1196
|
+
+ `│ Diameter ${TigerTagDB.label(_db.diameter(this.idDiameter))}\n`
|
|
1197
|
+
+ `│ Brand ${TigerTagDB.label(_db.brand(this.idBrand))}\n`
|
|
1198
|
+
+ `│ Aspect 1 ${TigerTagDB.label(_db.aspect(this.idAspect1))}\n`
|
|
1199
|
+
+ `│ Aspect 2 ${TigerTagDB.label(_db.aspect(this.idAspect2))}\n`
|
|
1200
|
+
+ `├─ Colors ──────────────────────────────────────────────\n`
|
|
1201
|
+
+ `│ Color 1 ${this.color1Hex} α=${this.color1A}\n`
|
|
1202
|
+
+ `│ Color 2 ${this.color2Hex}\n`
|
|
1203
|
+
+ `│ Color 3 ${this.color3Hex}\n`
|
|
1204
|
+
+ `│ HueForge TD ${this.tdValue.toFixed(1)}${this.tdRaw === 0 ? ' (undefined)' : ''}\n`
|
|
1205
|
+
+ `├─ Temperatures ────────────────────────────────────────\n`
|
|
1206
|
+
+ `│ Nozzle ${this.nozzleTempMin}°C → ${this.nozzleTempMax}°C${recNote('nozzleTempMin', 'nozzleTempMax')}\n`
|
|
1207
|
+
+ `│ Bed ${this.bedTempMin}°C → ${this.bedTempMax}°C${recNote('bedTempMin', 'bedTempMax')}\n`
|
|
1208
|
+
+ `│ Drying ${this.dryTemp}°C / ${this.dryTime}h${recNote('dryTemp', 'dryTime', ' h')}\n`
|
|
1209
|
+
+ `├─ Quantity ────────────────────────────────────────────\n`
|
|
1210
|
+
+ `│ Unit ${ul}\n`
|
|
1211
|
+
+ `│ Initial ${this.measure} ${ul}\n`
|
|
1212
|
+
+ `│ Available ${this.measureAvailable} ${ul}`
|
|
1213
|
+
+ (stock != null ? ` (${stock}% remaining)\n` : '\n')
|
|
1214
|
+
+ `├─ Traceability ────────────────────────────────────────\n`
|
|
1215
|
+
+ `│ Manufactured ${this.manufacturingDate.toISOString().replace('T', ' ').slice(0, 16)} UTC\n`
|
|
1216
|
+
+ `│ Twin tag ID ${this.timestamp}\n`
|
|
1217
|
+
+ `│ Message ${JSON.stringify(this.customMessage)}\n`
|
|
1218
|
+
+ `├─ Signature ───────────────────────────────────────────\n`
|
|
1219
|
+
+ `│ ECDSA ${sig}\n`
|
|
1220
|
+
+ `└───────────────────────────────────────────────────────`
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1226
|
+
|
|
1227
|
+
function _hex2(n) {
|
|
1228
|
+
return (n & 0xFF).toString(16).padStart(2, '0').toUpperCase();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
1232
|
+
|
|
1233
|
+
module.exports = {
|
|
1234
|
+
TigerTag,
|
|
1235
|
+
ApiDiff,
|
|
1236
|
+
CHIP_DUMP_LEN,
|
|
1237
|
+
FULL_DATA_LEN,
|
|
1238
|
+
MIN_DATA_LEN,
|
|
1239
|
+
MAKER_PRODUCT_ID,
|
|
1240
|
+
INIT_PRODUCT_ID,
|
|
1241
|
+
ID_TIGERTAG,
|
|
1242
|
+
ID_TIGERTAG_PLUS,
|
|
1243
|
+
ID_TIGERTAG_INIT,
|
|
1244
|
+
TIGERTAG_EPOCH,
|
|
1245
|
+
};
|