Skip to content

Commit fb00284

Browse files
committed
feat: tag index / tag count (TigerTag v2.2) on burn, read, Firestore and .ttag; catalogue grid no longer flickers on a favourite toggle
- SDK tigertag ^1.2.0, forced offline in the main process (TIGERTAG_OFFLINE=1) - Burn: guided encode stamps chip i of n at byte +39 (0x11 single, 0x12/0x22 twin); migrated docs record tag_index / tag_count - Re-writes keep the chip's own byte +39 with its timestamp (_keepChipIdentity) — a factory twin keeps 0x12/0x22 through weight / colour syncs - Read: scans store tag_index / tag_count on users/{uid}/inventory/{UID}; twin pairing refuses pairs the byte rules out (single, count mismatch, same rank) - Spool card: "Chip i of n" row + "twin still missing" banner; RFID tester shows Tag i of n - .ttag: exported verbatim, validated on import (0-15, dropped on chipless records); contract in docs/TTAG-FIELDS.md + fields editor - Chip-only fields: duplicates never inherit tag_index / tag_count - Catalogue grid patches product badges instead of rebuilding every card on a favourite toggle; product card keeps its photo node - Verified on real chips (TigerPOD, 2 readers)
1 parent c933d61 commit fb00284

24 files changed

Lines changed: 223 additions & 37 deletions

‎CODEMAP-main.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ L3810-3870 App lifecycle (whenReady, window-all-closed, activate)
4747
| 311-335 | UID hex→decimal + TigerTag SDK payload builder | `normalizeUid`, `_sdkPayload` |
4848
| 336-442 | `BrowserWindow` creation, preload wiring, CSP, devtools | `createWindow` |
4949

50-
## NFC / RFID reader + chip write (L563-1374)
50+
## NFC / RFID reader + chip write (L563-1560)
5151

5252
| L | What | Anchors / IPC |
5353
|---|---|---|

‎WORKLOG.md‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1-
# Worklog — v2.32.1
1+
# Worklog — v2.32.2 (in progress)
22

33
## Added
4+
- Tag index / tag count (TigerTag protocol v2.2, chip byte +39: high nibble = chip i, low nibble = n chips) — `main.js`, `preload.js`, `renderer/inventory.js`, `renderer/rfid_protocol/tigertag/parser.js`, `renderer/rfid_protocol/tigertag/index.js`, `package.json`, `package-lock.json`
5+
- SDK `tigertag` 1.1.0 → ^1.2.0, forced offline in the main process (`TIGERTAG_OFFLINE=1` — Studio has its own reference-data service; no daily api.tigertag.io check, no cache-folder writes)
6+
- Burn: guided encode stamps chip i of n (0x11 single, 0x12 / 0x22 twin) via `rfid:burn-one` `{ tagCount, tagIndex }`; the migrated docs record `tag_index` / `tag_count`
7+
- Re-write paths (`rfid:write-now`, `rfid:encode-cloud`) keep the chip's own byte +39 alongside its timestamp (`_keepChipTimestamp` → `_keepChipIdentity`) — a factory twin no longer loses its 0x12/0x22 on a weight or colour sync; a full write without a readable chip falls back to the doc's `tag_index` / `tag_count`
8+
- Read: every scan stores `tag_index` / `tag_count` on the inventory doc (unpacked from the SDK's `tag_info`); rows carry `tagIndex` / `tagCount`
9+
- Twin pairing (2-reader scan + `autoLinkTwinsByTimestamp`) refuses pairs the byte rules out — a chip marked single, mismatched counts, or the same rank twice; unknown (0) never blocks, legacy pairing unchanged
10+
- Spool card: "Chip i of n" row in Details; a "twin still missing — put the other chip on the reader" banner when `tag_count` ≥ 2 and the partner was never scanned; RFID tester shows "Tag i of n"
11+
- `.ttag`: exported with the record (verbatim doc); import keeps them when 0–15 and drops them on a chipless record or an out-of-range value; listed as on-chip, optional fields in `docs/TTAG-FIELDS.md` + `playground/ttag-fields-editor/index.html`; `TTAG_ON_CHIP_FIELDS` updated
12+
- Chip-only: `tag_index` / `tag_count` join `CHIP_ONLY_FIELDS`, so a duplicated (chipless) spool never inherits "chip 1 of 2"; rows of chipless docs ignore them
13+
- Docs: `docs/firestore-schema.md`, MCP data guide, backend `README.md` (inventory tree)
14+
- Verified on real chips (TigerPOD, 2 readers): factory 0x12 + single 0x11 of the same product → stored 1/2 and 1/1, NOT paired, missing-twin banner on the 1/2; weight write 1000→950→1000 g touched only page 0x17, byte +39 and timestamp unchanged
415

516
## Changed
17+
- TigerScale photo is now the V3 hardware render (README, scale card, empty state) — contributed in PR #34; shipped copy trimmed + resized to 512² and palettised (1.38 MB → 78 KB), full-res master archived (committed c933d61) — `assets/img/TigerScale_V3.png`, `assets-src/img/TigerScale_V3.png`, `assets-src/README.md`, `renderer/IoT/tigerscale/index.js`, `README.md`
618

719
## Fixed
8-
- Google sign-in failing with a bare "fetch failed" on some machines: the OAuth token exchange now goes through Electron's `net.fetch` (Chromium stack — system proxy, OS certificate store, same DNS as the browser) instead of Node's `fetch`, and the failure log includes the underlying cause — `main.js`
9-
- Windows: the whole app crawled on some machines (every click, even a window resize, took one to two minutes) — the USB-scale detection enumerated HID devices SYNCHRONOUSLY on the main thread every 5 s (`HID.devices()`), and on slow HID / Bluetooth drivers each call took seconds. It now uses `HID.devicesAsync()` (worker thread) with an in-flight guard, and when a scan takes over 2 s it logs it and slows polling to once a minute. Confirmed by a user log on v2.32.0: the 10 s launch freeze persisted after the TD1S scan was removed — it was this HID enumeration all along (it runs right after the TD1S init) — `main.js`; `CODEMAP-main.md` discovery-probe ranges re-synced
20+
- Deleting products (bulk "Delete" in Favorites) was always refused with "permission denied" since the tier-gated attachments rule (v2.12.0): the `products` rule read `request.resource.data` in a single `allow write`, which errors on a delete — split into `allow create, update` (attachment cap kept) + `allow delete: if isOwner()` — backend `firestore.rules` (deployed + committed 4aa71b8)
21+
- Security: `js-yaml` 4.3.1 → 4.3.2 (CVE-2026-84375 / GHSA-2883-xcg3-v3hh, YAML merge-key DoS), transitive via `electron-updater` (parses `latest*.yml`) — `package-lock.json` (already committed in 22b3f3c; internal, not for release note / What's New)
22+
23+
- Toggling ★/❤ on a catalogue product no longer flickers the catalogue grid: a render that lands on the same result list patches the cards' product badges instead of rebuilding every card (which re-inserted the photos 2-3× per click and dropped the chunks scrolled into view); the product card keeps its photo node across re-renders — `renderer/inventory.js` (other views still to audit)
1024

1125
## Removed
1226

1327
## i18n
28+
- Added: `detTagIndex`, `detTagIndexVal`, `twinMissingHint` — 11 locales

‎docs/TTAG-FIELDS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ The Header block is the same for every type; the rest of the contract (Required
7474
| `id_tigertag` | `uint32 · ref id_version.json` | **yes** | **Req if chip** | **Req if chip** | **Req if chip** | **Req if chip** | **OVERWRITTEN ON IMPORT**. The chip's tag id (bytes 0-3). It REFERENCES id_version.json, so only the four ids listed there are legal: 0 RFID Empty, 1542820452 TigerTag, 1816240865 TigerTag Init, 3155151767 TigerTag+. Required of a record that HAS a chip; a chipless record (uid TigerData_… or legacy CLOUD_…) has no chip and therefore no version, so it must NOT carry the field at all — it is written for the first time when a real chip is programmed. Studio ≤2.15.0 stored a random u32 here on chipless materials, a value outside the referential; 2.16.0 stopped writing it and deletes the stray ones. Import drops the field (which is what makes the copy not a TigerTag+); Restore keeps it verbatim. |
7575
| `id_product` | `uint32 (0–4294967295)` | **yes** | Opt | Opt | Opt | Opt | **OVERWRITTEN ON IMPORT**. Catalogue product id — look up a real one in the public catalogue at https://tigersystem.io/fr/catalog. When unknown it MUST be `4294967295` (`0xFFFFFFFF`, the “generic” sentinel), never absent. Import always resets it to that value. |
7676
| `timestamp` | `uint32 (0–4294967295, chip epoch)` | **yes** | **Req** | **Req** | **Req** | **Req** | Chip encode timestamp — SECONDS since 2000-01-01 (TigerTag chip epoch, not Unix, not Firestore). |
77+
| `tag_index` | `uint4 (0–15)` | **yes** | Opt | Opt | Opt | Opt | Chip i of n — which chip of the object this is, from 1 (chip byte +39 high nibble, protocol v2.2). 0 = unknown (any chip written before v2.2). Chip-only: a chipless record never carries it (dropped on import, as is any value outside 0–15). |
78+
| `tag_count` | `uint4 (0–15)` | **yes** | Opt | Opt | Opt | Opt | How many chips the object carries (chip byte +39 low nibble, protocol v2.2): 1 = single, 2 = twin. 0 = unknown. A `tag_count` of 2 on a record with no `twin_tag_uid` is legal — the other chip simply was never scanned. Chip-only, same import rules as `tag_index`. |
7779

7880
## Filament identity
7981

‎docs/firestore-schema.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ users/
101101
deleted boolean
102102
deleted_at number?
103103
twin_tag_uid string? — linked twin chip's UID (the OTHER RFID chip on the same physical spool). NOTE: the field is `twin_tag_uid`, not `twin_uid`
104+
tag_index number — which chip of the object this is, from 1 (chip byte +39 high nibble, protocol v2.2). 0 = unknown (chip written before v2.2, or chipless doc). Written on scan (from the chip) and on a guided burn.
105+
tag_count number — how many chips the object carries (byte +39 low nibble): 1 = single, 2 = twin. 0 = unknown. A `tag_count` of 2 with no `twin_tag_uid` = the other chip has not been scanned yet.
104106
message string? — user note (also the colour name for DIY/Cloud)
105107
tags string[]? — user-defined free-form labels (Shopify-style). Studio metadata only, never written to the physical chip. Normalised (trimmed, ≤32 chars, case-insensitive dedup, ≤20/spool). Mirrored onto the twin spool so both chips of one physical spool share the same tags. Owner-write, no rule change needed (inventory has no field whitelist). Cross-app field — mobile ignores it until it implements tags
106108
rfidListed boolean? — true once this UID has been recorded in rfidList/ (dedup marker; absent = not yet). Set by Studio's chip census / scan path. See rfidList/ below

‎main.js‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,11 @@ function normalizeUid(raw) {
402402
}
403403

404404
// ── TigerTag JS SDK ───────────────────────────────────────────────────────────
405+
// The SDK (≥1.2.0) can lazily build a TigerTagDB that checks api.tigertag.io once
406+
// a day and writes to the OS cache folder. Studio has its own reference-data
407+
// service (services/tigertagDbService.js) and only uses the SDK to encode/decode
408+
// bytes — so it runs strictly offline: no hidden network call, no stray cache.
409+
process.env.TIGERTAG_OFFLINE = '1';
405410
const { TigerTag } = require('tigertag');
406411

407412
// IPC payload — toRawDict() first, then rawApi() if TigerTag+.
@@ -923,7 +928,7 @@ ipcMain.handle('rfid:write-now', async (_evt, opts) => {
923928
// ── 1. Build the 80-byte payload from cloud doc ─────────────────────────────
924929
let newBytes;
925930
try {
926-
let tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc));
931+
let tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch(_tagInfoPatch(cloudDoc));
927932
if (patch && Object.keys(patch).length > 0) tag.patchFromRawDict(patch);
928933
newBytes = tag.toBytes(); // 80 bytes covering pages 0x04-0x17
929934
} catch (e) {
@@ -951,7 +956,7 @@ ipcMain.handle('rfid:write-now', async (_evt, opts) => {
951956
} else {
952957
// rawPagesHex now starts at page 0x04 — first 80 bytes are pages 0x04-0x17 (toBytes() range)
953958
const oldUserBytes = Buffer.from(readResult.rawPagesHex, 'hex').slice(0, 80);
954-
pages = _pagesToWrite(_keepChipTimestamp(newBytes, oldUserBytes), oldUserBytes);
959+
pages = _pagesToWrite(_keepChipIdentity(newBytes, oldUserBytes), oldUserBytes);
955960
}
956961
} else {
957962
pages = _pagesToWrite(newBytes, null); // full write — all 20 pages
@@ -1129,7 +1134,8 @@ ipcMain.handle('rfid:erase', async (_evt, { targets } = {}) => {
11291134
// }
11301135
//
11311136
// The payload is built ONCE (toBytes() called once) so that both chips receive
1132-
// identical bytes — same timestamp guaranteed.
1137+
// the same data and timestamp; each chip keeps its own tag index/count byte
1138+
// (offset 39) through `_keepChipIdentity`.
11331139
//
11341140
// Returns { ok, results: [{ readerName, uid, ok, pagesWritten?, error? }] }
11351141
ipcMain.handle('rfid:encode-cloud', async (_evt, { cloudDoc, targets }) => {
@@ -1138,8 +1144,8 @@ ipcMain.handle('rfid:encode-cloud', async (_evt, { cloudDoc, targets }) => {
11381144
return { ok: false, error: 'No target readers provided' };
11391145

11401146
// ── Build payload ONCE — same bytes → same timestamp for all chips ──────────
1141-
// Twins must share identical bytes to pair, so the timestamp is stamped once
1142-
// here (not per chip). Used both as the write source and the diff reference.
1147+
// Twins pair on the timestamp, so it is stamped once here (not per chip).
1148+
// Used both as the write source and the diff reference.
11431149
let newBytes;
11441150
try {
11451151
// KEEP the doc's timestamp. Both callers of this handler update a chip that
@@ -1149,10 +1155,11 @@ ipcMain.handle('rfid:encode-cloud', async (_evt, { cloudDoc, targets }) => {
11491155
// saw "timestamp changed" — the test for a chip reprogrammed ELSEWHERE — and
11501156
// deleted the document to start clean, taking the user's weight, masterspool
11511157
// and rack with it. This is only the best value to START from: whenever the
1152-
// chip can be read, `_keepChipTimestamp` overlays the chip's own before the
1153-
// diff, so the creation date is never rewritten even by a drifted document.
1158+
// chip can be read, `_keepChipIdentity` overlays the chip's own before the
1159+
// diff, so the creation date and the tag index/count are never rewritten
1160+
// even by a drifted document.
11541161
const _keepTs = Number(cloudDoc?.timestamp) > 0 ? Number(cloudDoc.timestamp) : _nowChipTs();
1155-
let tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch({ timestamp: _keepTs });
1162+
let tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch({ timestamp: _keepTs, ..._tagInfoPatch(cloudDoc) });
11561163
// The displayed colour lives in `online_color_list` (hex) and is what the
11571164
// user edits — the doc's baked color_r/g/b can lag behind it. So when a
11581165
// colour list is present, it is the source of truth: patch the chip colour
@@ -1220,7 +1227,7 @@ ipcMain.handle('rfid:encode-cloud', async (_evt, { cloudDoc, targets }) => {
12201227
const rd = await _readChip(readerName);
12211228
if (rd.ok && rd.rawPagesHex) {
12221229
const oldUserBytes = Buffer.from(rd.rawPagesHex, 'hex').slice(0, 80);
1223-
pages = _pagesToWrite(_keepChipTimestamp(newBytes, oldUserBytes), oldUserBytes);
1230+
pages = _pagesToWrite(_keepChipIdentity(newBytes, oldUserBytes), oldUserBytes);
12241231
} else {
12251232
console.warn(`[NFC] encode-cloud: ${readerName} surgical read failed, full write:`, rd.error);
12261233
pages = _pagesToWrite(newBytes, null);
@@ -1250,10 +1257,12 @@ ipcMain.handle('rfid:encode-cloud', async (_evt, { cloudDoc, targets }) => {
12501257
// ── Burn ONE chip with read-back verification ─────────────────────────────────
12511258
// Drives the guided dual-chip encode modal: the renderer orchestrates the
12521259
// sequence (one call per chip, 100 ms gap, presence re-check) and passes a
1253-
// SINGLE fixed `timestamp` (chip-epoch seconds) so both chips get identical
1254-
// bytes → they pair as twins. Returns { ok, verified, uid, pagesWritten,
1260+
// SINGLE fixed `timestamp` (chip-epoch seconds) — the twin pairing key — plus
1261+
// `tagCount` (chips in the sequence) and `tagIndex` (this chip's rank, from 1),
1262+
// stamped at offset 39 (0x11 single, 0x12 / 0x22 twin). Twins therefore differ
1263+
// in that one byte: they pair on the timestamp, never on identical bytes. Returns { ok, verified, uid, pagesWritten,
12551264
// mismatchPages, error }. Success for the caller = ok && verified.
1256-
ipcMain.handle('rfid:burn-one', async (_evt, { cloudDoc, timestamp, readerName }) => {
1265+
ipcMain.handle('rfid:burn-one', async (_evt, { cloudDoc, timestamp, readerName, tagCount, tagIndex }) => {
12571266
if (!_nfcChild) return { ok: false, error: 'NFC process not running' };
12581267
if (!readerName || !_nfcReaders.has(readerName))
12591268
return { ok: false, error: 'Reader not connected' };
@@ -1263,7 +1272,8 @@ ipcMain.handle('rfid:burn-one', async (_evt, { cloudDoc, timestamp, readerName }
12631272
let pages;
12641273
try {
12651274
const ts = Number.isFinite(timestamp) ? (timestamp >>> 0) : _nowChipTs();
1266-
const tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch({ timestamp: ts });
1275+
const info = _tagInfoPatch({ tag_count: tagCount, tag_index: tagIndex });
1276+
const tag = TigerTag.fromCloudDoc(_docForChip(cloudDoc)).patch({ timestamp: ts, ...info });
12671277
pages = _pagesToWrite(tag.toBytes(false), null); // full write — blank chip
12681278
} catch (e) {
12691279
return { ok: false, error: `SDK build failed: ${e.message}` };
@@ -1502,13 +1512,30 @@ ipcMain.handle('catalog:fetch-all', async () => {
15021512
// half. Rewriting it therefore destroys the pairing. Overlaying the chip's value
15031513
// before the surgical diff makes that page identical by construction, so it can
15041514
// never end up among the pages written — whatever the document happens to hold.
1505-
function _keepChipTimestamp(newUserBytes, oldUserBytes) {
1506-
if (!oldUserBytes || oldUserBytes.length < 36) return newUserBytes;
1515+
//
1516+
// Same rule for the TAG INFO byte (offset 39, protocol v2.2): high nibble = which
1517+
// chip of the object this is (from 1), low nibble = how many chips the object
1518+
// carries (0x12 / 0x22 = twin 1 of 2 / 2 of 2, 0x11 = single, 0x00 = unknown).
1519+
// It is written once at burn time; a document rebuilt through fromCloudDoc does
1520+
// not carry it reliably, so the chip's own byte always wins. Not covered by the
1521+
// TigerTag+ signature, so keeping it never invalidates a signed chip.
1522+
function _keepChipIdentity(newUserBytes, oldUserBytes) {
1523+
if (!oldUserBytes || oldUserBytes.length < 40) return newUserBytes;
15071524
const out = Buffer.from(newUserBytes);
1508-
oldUserBytes.copy(out, 32, 32, 36);
1525+
oldUserBytes.copy(out, 32, 32, 36); // creation timestamp = twin pairing key
1526+
out[39] = oldUserBytes[39]; // tag index / tag count
15091527
return out;
15101528
}
15111529

1530+
// Tag index / count recorded on the Firestore doc (`tag_index` / `tag_count`),
1531+
// as a SDK patch — used when the chip itself cannot be read before a full write.
1532+
// Anything outside a nibble is ignored (the SDK would throw a RangeError).
1533+
function _tagInfoPatch(doc) {
1534+
const nib = v => (Number.isInteger(+v) && +v >= 0 && +v <= 15) ? +v : 0;
1535+
const tagCount = nib(doc?.tag_count), tagIndex = nib(doc?.tag_index);
1536+
return { tagCount, tagIndex };
1537+
}
1538+
15121539
// Helper — split 80-byte user-data buffer into page descriptors.
15131540
// If oldUserBytes is provided, only includes pages where bytes differ (surgical mode).
15141541
// Page index is absolute (starts at 4 = chip page 0x04).

‎package-lock.json‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"node-hid": "^3.4.0",
4646
"qrcode-generator": "^2.0.4",
4747
"serialport": "^13.0.0",
48-
"tigertag": "^1.0.6",
48+
"tigertag": "^1.2.0",
4949
"yauzl": "^3.4.0"
5050
},
5151
"devDependencies": {

0 commit comments

Comments
 (0)