/** * Chafon H102 RFID Daemon * Bridges the USB serial RFID reader to the browser extension via HTTP on port 7790. * * Protocol: CF [ADDR] [CMD_H] [CMD_L] [LEN] [DATA...] [CRC16_H] [CRC16_L] * Baud: 115200, 8N1 * * Endpoints: * GET /status — health check, returns connected port * GET /inventory — scan for any tag in field (diagnostic) * POST /write-tag — { sku: "20260330153245" } — writes SKU to tag on platen * GET /read-tag — reads EPC from tag on platen */ 'use strict'; const os = require('os'); const { SerialPort } = require('serialport'); const express = require('express'); const cors = require('cors'); const { Client: SshClient } = require('ssh2'); const mysql = require('mysql2/promise'); const fs = require('fs'); // node-hid is optional — if it fails to load (missing native build, etc.) // the scale endpoints respond with online:false instead of crashing the daemon. let HID = null; try { HID = require('node-hid'); } catch (e) { console.warn('node-hid not available — scale endpoints disabled:', e.message); } const HOSTNAME = os.hostname(); const IS_ULTRA = (HOSTNAME === 'ultra.local' || HOSTNAME === 'ultra'); const ULTRA_TAILSCALE_IP = '100.91.239.7'; const QUIET = process.argv.includes('--quiet') || process.argv.includes('-q'); // verbose() — protocol-level byte dumps, suppressed by --quiet / -q const verbose = QUIET ? () => {} : (...a) => console.log(...a); // readerOnline: false = reader UART is silent (asleep/HID mode). // All tag operations fail immediately with a clear message. // Set to true once any command gets a response. Reset on /recover failure. let readerOnline = false; function requireReaderOnline() { if (!readerOnline) throw new Error('Reader offline — press the trigger button on the gun to wake it, then click Recover Reader.'); } const HTTP_PORT = 7790; const DEFAULT_INVENTORY_HOST = '100.123.123.64'; const DEFAULT_SSH_USER = 'mrpadmin'; const BAUD_RATE = parseInt(process.env.RFID_BAUD || '115200', 10); const ADDR = 0x00; // default device address const CMD_INVENTORY = [0x00, 0x01]; const CMD_READ = [0x00, 0x03]; const CMD_WRITE = [0x00, 0x04]; const MEM_EPC = 0x01; const DEFAULT_ACCESS_PWD = Buffer.from([0x00, 0x00, 0x00, 0x00]); // ── Serial port ────────────────────────────────────────────────────────────── let serial = null; let connectedPath = null; async function findPort() { if (process.env.RFID_PORT) return process.env.RFID_PORT; const ports = await SerialPort.list(); console.log('[rfid] available ports:', ports.map(p => `${p.path} (${p.manufacturer || 'unknown'})`).join(', ') || 'none'); // macOS: serialport only enumerates tty.* — swap to cu.* for outgoing comms const macMatch = ports.find(p => /\/dev\/tty\.usbserial/i.test(p.path)); if (macMatch) return macMatch.path.replace('/dev/tty.', '/dev/cu.'); const match = ports.find(p => /ttyUSB/i.test(p.path) || /ttyACM/i.test(p.path) || (/COM\d+/i.test(p.path) && /ch34|wch|qinheng|ftdi|prolific|silicon/i.test(p.manufacturer || '')) ); return match ? match.path : null; } async function openSerial(path) { return new Promise((resolve, reject) => { const port = new SerialPort({ path, baudRate: BAUD_RATE, autoOpen: false }); port.open(err => err ? reject(err) : resolve(port)); }); } // Guard against concurrent open calls (e.g. /status and /read-tag firing at the // same time when the RFID tab opens). A second call while opening is in progress // waits for the first to finish rather than trying to open the port a second time // (which would fail with "Cannot lock port" at the OS level). let _connecting = null; async function ensureConnected() { if (serial?.isOpen) return; if (!_connecting) { _connecting = (async () => { const path = await findPort(); if (!path) throw new Error('Chafon reader not found. Check USB connection.'); serial = await openSerial(path); connectedPath = path; console.log(`[rfid] connected to ${path} @ ${BAUD_RATE} baud`); serial.on('data', chunk => { const hex = chunk.toString('hex').replace(/../g, '$& ').trim().toUpperCase(); const ascii = chunk.toString('ascii').replace(/[^\x20-\x7E]/g, '.'); verbose(`[rfid] UNSOLICITED RX: ${hex} "${ascii}"`); }); serial.on('error', err => { console.error('[rfid] serial error:', err.message); serial = null; connectedPath = null; }); serial.on('close', () => { console.log('[rfid] port closed'); serial = null; connectedPath = null; readerOnline = false; }); // Quick ping to check if the reader UART is alive at all. // If this times out, readerOnline stays false and all tag ops fail fast. try { await sendCommand(buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)), 1500); // readerOnline set to true inside sendCommand on valid response await autoConfigureAnswerMode(); } catch (_) { console.log('[rfid] reader not responding — press the trigger button to wake it, then click Recover Reader.'); } })().finally(() => { _connecting = null; }); } return _connecting; } // Flush the serial RX buffer (discard accumulated bytes from active-mode streaming). function flushSerial() { return new Promise(resolve => serial.flush(err => resolve())); } // Read the reader's current WorkMode and force it to answer mode (0) if needed. // Active mode (1) and trigger mode (2) both prevent reliable command/response // operation — the reader ignores or drops WRITE commands while mid-scan. // When the reader is actively streaming inventory frames we flush the RX buffer // and hammer STOP repeatedly before attempting GET_ALL_PARAM. async function autoConfigureAnswerMode() { try { // In active/trigger mode the reader streams inventory frames continuously. // Flush accumulated RX bytes and retry STOP up to 5 times so we can get // a clean command/response window before querying params. let stopped = false; for (let i = 0; i < 5 && !stopped; i++) { await flushSerial(); try { await sendCommand(buildStopFrame(), 600); stopped = true; } catch (_) { /* keep trying */ } } if (!stopped) { console.log('[rfid] reader not responding to STOP — it may be asleep. Press the trigger button to wake it.'); return; } await flushSerial(); const cfgResp = await sendCommand(buildFrame([0x00, 0x72], Buffer.alloc(0)), 2000); if (cfgResp.status !== 0x00) return; const workMode = cfgResp.data[2]; const modeNames = { 0: 'answer', 1: 'active', 2: 'trigger' }; console.log(`[rfid] reader work mode: ${modeNames[workMode] ?? workMode}`); if (workMode === 0) return; // already correct console.log('[rfid] switching reader to answer mode (stops continuous scanning)...'); const params = Buffer.from(cfgResp.data); params[2] = 0; const setResp = await sendCommand(buildFrame([0x00, 0x71], params), 3000); if (setResp.status === 0x00) { console.log('[rfid] answer mode set — reader will now respond to commands only'); } else { console.log(`[rfid] warning: could not set answer mode (status 0x${setResp.status.toString(16)})`); } } catch (err) { console.log('[rfid] note: could not read/set reader work mode:', err.message); } } // ── CRC-16/IBM (poly 0x8408, init 0xFFFF, LSB-first) ──────────────────────── // Per Chafon H100/H102/H103/H104 user manual Appendix B. function crc16(buf) { let crc = 0xFFFF; for (const byte of buf) { crc ^= byte; for (let i = 0; i < 8; i++) { crc = (crc & 1) ? ((crc >> 1) ^ 0x8408) : (crc >> 1); } } return crc; } // ── Frame builder ──────────────────────────────────────────────────────────── // Frame: CF [ADDR] [CMD_H] [CMD_L] [LEN] [DATA...] [CRC_H] [CRC_L] // CRC covers all bytes from CF through last DATA byte. function buildFrame(cmd2, data) { const header = Buffer.from([0xCF, ADDR, cmd2[0], cmd2[1], data.length]); const body = Buffer.concat([header, data]); const chk = crc16(body); return Buffer.concat([body, Buffer.from([(chk >> 8) & 0xFF, chk & 0xFF])]); } const CMD_STOP = [0x00, 0x02]; const CMD_LOCK = [0x00, 0x05]; const CMD_SELECTMASK = [0x00, 0x07]; const CMD_DEVICE_INFO = [0x00, 0x70]; const CMD_BATTERY = [0x00, 0x83]; // Inventory: InvType(1) + InvParam(4) // InvType=0x00 → time-based (invParam = seconds, 0 = scan forever until STOP) // InvType=0x01 → count-based (invParam = number of cycles, must be ≥ 1) function buildInventoryFrame(invType = 0x00, invParam = 1) { return buildFrame(CMD_INVENTORY, Buffer.from([ invType, 0x00, 0x00, 0x00, invParam & 0xFF ])); } function buildStopFrame() { return buildFrame(CMD_STOP, Buffer.alloc(0)); } // SELECTMASK: Pointer(2) + Length_bits(1) + Mask(N) // Pass epcBytes=null to select-all (Length=0, matches any tag in field). // The H102 uses pointer 0x0000 to mean "start of EPC data" — it handles CRC/PC // internally and does NOT follow the raw EPC Gen2 bank bit-offset convention. function buildSelectMaskFrame(epcBytes) { if (!epcBytes || epcBytes.length === 0) { return buildFrame(CMD_SELECTMASK, Buffer.from([0x00, 0x00, 0x00])); // match-all } const lengthBits = epcBytes.length * 8; return buildFrame(CMD_SELECTMASK, Buffer.concat([ Buffer.from([0x00, 0x00, lengthBits & 0xFF]), epcBytes ])); } // Send stop and wait for the ack before returning. // The device must finish its current inventory cycle before it will process // a write/read command — if we don't wait, the write races with inventory. async function sendStop() { if (!serial?.isOpen) return; try { await sendCommand(buildStopFrame(), 500); } catch (_) { /* already idle */ } } // Write: Option(1) + AccPwd(4) + MemBank(1) + WordPtr(2) + WordCount(1) + Data(N) function buildWriteFrame(memBank, wordPtr, dataBytes) { if (dataBytes.length % 2 !== 0) throw new Error('Data must be word-aligned (even bytes)'); const wordCount = dataBytes.length / 2; const data = Buffer.concat([ Buffer.from([0x00]), // Option (unused, default 0x00) DEFAULT_ACCESS_PWD, // 4 bytes Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount]), dataBytes ]); return buildFrame(CMD_WRITE, data); } // Read: Option(1) + AccPwd(4) + MemBank(1) + WordPtr(2) + WordCount(1) function buildReadFrame(memBank, wordPtr, wordCount) { const data = Buffer.concat([ Buffer.from([0x00]), // Option (unused, default 0x00) DEFAULT_ACCESS_PWD, // 4 bytes Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount]) ]); return buildFrame(CMD_READ, data); } // ── EPC encoding ───────────────────────────────────────────────────────────── // // Layout: single 96-bit big-endian integer = SKU * 10^9 + releaseId // // Decimal view: [ digit 1-14 = SKU ][ digit 15-23 = releaseId (zero-padded) ] // Example: SKU 20260321001001 + releaseId 35332 // → 20260321001001_000035332 (underscores for clarity only) // → stored as 12-byte big-endian integer // // Because SKU is always exactly 14 digits, releaseId always starts at digit 15. // Any UHF scanner that reads the EPC as hex can convert to decimal and split there. // const RELID_FACTOR = 1_000_000_000n; // 9 digits: supports up to ~1 billion Discogs IDs function buildEpcPayload(sku, releaseId) { const combined = BigInt(sku) * RELID_FACTOR + BigInt(releaseId || 0); const buf = Buffer.alloc(12, 0); let tmp = combined; for (let i = 11; i >= 0; i--) { buf[i] = Number(tmp & 0xFFn); tmp >>= 8n; } return buf; } function parseEpcData(buf) { // Decode 12 bytes as big-endian BigInt, split at 10^9. // Returns null for anything that is NOT our scheme — a short/malformed read, // or a foreign/factory EPC (e.g. E28069...) whose value/10^9 exceeds 14 digits // — so a stray tag can never be reported to the operator as a real product SKU. if (!buf || buf.length !== 12) return null; let value = 0n; for (const b of buf) value = (value << 8n) | BigInt(b); const skuNum = value / RELID_FACTOR; if (skuNum >= 100000000000000n) return null; // > 14 digits → not a PRICEGOD SKU const releaseRaw = Number(value % RELID_FACTOR); const sku = skuNum.toString().padStart(14, '0'); return { sku, releaseId: releaseRaw === 0 ? null : releaseRaw }; } // Parse the EPC bytes from an inventory response payload. // H102 inventory data layout: RSSI(1) ANT(1) PC(2) EPC_LEN(1) EPC(12 bytes) = 17 bytes // bytes[2:4] = PC word, bytes[4] = EPC length byte (0x0C = 12), bytes[5:17] = EPC data. // (Earlier assumption of no PC word was wrong — the singulated EPC diagnostic // confirmed bytes 2-4 are PC + length, not EPC.) function parseEpcFromInventoryData(data) { if (!data || data.length < 17) return null; return data.slice(5, 17); // 12 EPC bytes after RSSI + ANT + PC(2) + EPC_LEN(1) } // ── Serial command runner ──────────────────────────────────────────────────── function hexDump(buf) { return Buffer.from(buf).toString('hex').replace(/../g, '$& ').trim().toUpperCase(); } // Response frame: CF [ADDR] [CMD_H] [CMD_L] [LEN] [STATUS] [DATA...] [CRC_H] [CRC_L] // Total bytes: 5 (header+len) + LEN (status+data) + 2 (crc) = 7 + LEN // // The device continuously streams inventory frames (CMD=0x0001) whenever a tag is present. // We must ignore frames whose CMD bytes don't match what we sent. // // skipStatuses: status bytes to treat as intermediate (keep waiting rather than resolving). // Used for writes: some tags need an internal RF retry — device sends 0x14 (first-slot miss) // then 0x12 (retry success). Without skipping 0x14 we'd bail before the successful retry. function sendCommand(frame, timeoutMs = 5000, skipStatuses = []) { verbose(`[rfid] TX: ${hexDump(frame)}`); // CMD bytes are at positions 2 and 3 of the outgoing frame const expectedCmd = [frame[2], frame[3]]; return new Promise((resolve, reject) => { let settled = false; const settle = (fn, val) => { if (settled) return; settled = true; clearTimeout(timeout); serial?.removeListener('data', onData); serial?.removeListener('close', onClose); serial?.removeListener('error', onErr); fn(val); }; const timeout = setTimeout(() => { settle(reject, new Error('RFID reader timeout — is a tag present on the reader?')); }, timeoutMs); const onClose = () => settle(reject, new Error('Serial port closed unexpectedly')); // Without this, a port 'error' that emits no 'close' leaves the command // hanging for the full timeout (up to 12s on a read) and misreports a cable // glitch as "is a tag present?". const onErr = (e) => settle(reject, new Error(`Serial error: ${e && e.message ? e.message : e}`)); let buf = Buffer.alloc(0); function onData(chunk) { buf = Buffer.concat([buf, chunk]); verbose(`[rfid] RX chunk: ${hexDump(chunk)} (buf ${buf.length} bytes)`); // Consume all complete frames from the buffer, skip non-matching ones while (buf.length >= 7) { // Find next start byte 0xCF const start = buf.indexOf(0xCF); if (start < 0) { buf = Buffer.alloc(0); return; } if (start > 0) buf = buf.slice(start); if (buf.length < 7) return; // byte 4 = LEN (length of STATUS + DATA) const dataLen = buf[4]; const totalLen = 5 + dataLen + 2; // header(5) + status+data + crc(2) if (buf.length < totalLen) return; // wait for more data const respFrame = buf.slice(0, totalLen); // Verify CRC before trusting the frame. A corrupt LEN byte or a 0xCF // appearing inside a payload can fabricate a false boundary; on any CRC // failure, drop the leading 0xCF and re-scan rather than consuming a bogus // length (which would swallow the real frame queued behind it). const rxCrc = (respFrame[totalLen - 2] << 8) | respFrame[totalLen - 1]; const calcCrc = crc16(respFrame.slice(0, totalLen - 2)); if (rxCrc !== calcCrc) { verbose(`[rfid] RX bad CRC got=${rxCrc.toString(16)} want=${calcCrc.toString(16)} — resync`); buf = buf.slice(1); // skip this 0xCF, hunt for the next start byte continue; } buf = buf.slice(totalLen); // consume this good frame const cmdH = respFrame[2]; const cmdL = respFrame[3]; if (cmdH !== expectedCmd[0] || cmdL !== expectedCmd[1]) { verbose(`[rfid] RX skip (CMD=${cmdH.toString(16).padStart(2,'0')}${cmdL.toString(16).padStart(2,'0')} != expected ${expectedCmd[0].toString(16).padStart(2,'0')}${expectedCmd[1].toString(16).padStart(2,'0')})`); continue; // skip streaming inventory frame, keep looking } const status = respFrame[5]; if (skipStatuses.includes(status)) { verbose(`[rfid] RX intermediate status=0x${status.toString(16).padStart(2,'0')}, waiting for final response...`); continue; // device is retrying internally — wait for the real result } verbose(`[rfid] RX full: ${hexDump(respFrame)}`); readerOnline = true; // got a valid response — reader UART is alive const data = respFrame.slice(6, totalLen - 2); settle(resolve, { status, data }); return; } } serial.on('data', onData); serial.on('close', onClose); serial.on('error', onErr); serial.write(frame, err => { if (err) settle(reject, err); }); }); } // ── Serial mutex ───────────────────────────────────────────────────────────── // Serialises all serial I/O so concurrent HTTP requests (e.g. auto-read fired // when the RFID tab opens + a user-triggered write) can't interleave commands // and corrupt each other's responses. let _serialBusy = Promise.resolve(); function withSerial(fn) { const ticket = _serialBusy.then(() => fn()); _serialBusy = ticket.catch(() => {}); return ticket; } // ── Tag operations ─────────────────────────────────────────────────────────── // EPC bank word 0: CRC (read-only) word 1: PC words 2+: EPC data // WordPtr=2 skips CRC+PC so we land on writable EPC data area. // WordPtr=0 would try to overwrite the CRC word → tag NAKs → STATUS=0x14 (timeout). const EPC_WORD_PTR = 0x0002; // word 2 = first writable EPC data word const SKU_WORD_COUNT = 6; // 6 words = 12 bytes: 8 bytes SKU + 4 bytes releaseId // Clear any active SELECTMASK in the reader firmware. // A stale mask (left by a failed write) makes ALL tags invisible — inventory // returns 0x12 and reads/writes fail with "no tag" even when a tag is present. // Call this before any operation that needs to find a tag. async function clearMask() { try { await sendCommand(buildSelectMaskFrame(null), 2000); verbose('[rfid] SELECTMASK cleared'); } catch (_) { // Reader may not ack SELECTMASK — that's OK, command was still sent. } } // Run inventory and confirm a tag is present, then send STOP so the reader // returns to idle. Used before READ commands. async function primeRF() { await _runInventory(); await sendStop(); } // Run one inventory cycle, then immediately set SELECTMASK to the found EPC. // Setting a full 96-bit SELECT mask before WRITE tells the reader's internal // inventory exactly which tag to address. Without this: // - STOP before WRITE → tag returns to Arbitration → internal inventory fails (0x12) // - No STOP → reader still busy with timed inventory → ignores WRITE (timeout) // With the SELECT mask locked to the EPC, the WRITE's internal singulation // succeeds regardless of timing. // // If targetEpc is provided, throws if the singulated tag doesn't match — prevents // accidentally writing to a different tag that happens to be in the RF field. async function singulateForWrite(targetEpc) { const invResp = await _runInventory(); const epc = parseEpcFromInventoryData(invResp.data); console.log(`[rfid] singulated EPC: ${epc ? epc.toString('hex').toUpperCase() : '(unknown)'}`); if (targetEpc && epc && !epc.equals(targetEpc)) { const found = epc.toString('hex').toUpperCase(); const wanted = targetEpc.toString('hex').toUpperCase(); throw new Error(`Wrong tag on reader — found ${found}, expected ${wanted}. Remove extra tags from field.`); } await sendStop(); if (epc) { await sendCommand(buildSelectMaskFrame(epc), 2000).catch(() => {}); console.log(`[rfid] SELECT mask → ${epc.toString('hex').toUpperCase()}`); } return epc; } // Shared inventory logic used by primeRF and singulateForWrite. // clearMask() is intentionally NOT called here — sending the 0-bit SELECTMASK // frame causes the H102 to interpret it as a SELECT that matches zero EPC bits, // which its internal WRITE singulation then uses and finds nothing → 0x12. // There is no stale mask to clear because we never explicitly set one. // The /clear-mask endpoint still exists for manual recovery if needed. async function _runInventory() { requireReaderOnline(); await sendStop(); let invResp; try { invResp = await sendCommand(buildInventoryFrame(), 3000); } catch (err) { if (!err.message.includes('timeout')) throw err; console.log('[rfid] reader not responding — retrying...'); await sendStop(); try { invResp = await sendCommand(buildInventoryFrame(), 3000); } catch (_) { throw new Error('Reader asleep — press the trigger button on the gun to wake it, then try again'); } } if (invResp.status !== 0x00) { throw new Error('No tag found — place tag on reader'); } return invResp; } // EPC Gen2 LOCK payload (20 bits): [Mask 10 bits][Action 10 bits] // Bit positions within each 10-bit half (MSB first): // [9:8]=Kill pwd [7:6]=Access pwd [5:4]=EPC bank [3:2]=TID [1:0]=User // Each 2-bit field: bit1=permalock bit0=write-lock // // To unlock EPC bank (make freely writable without password): // Mask bit 15 = 1, bit 14 = 1 → change both EPC bits // Action bit 5 = 0, bit 4 = 0 → set EPC: not permalocked, not locked // 20-bit value: 0b 0000_1100_0000_0000_0000 = 0x0C000 // As 3 bytes (right-aligned): 0x00 0xC0 0x00 const LOCK_PAYLOAD_UNLOCK_EPC = Buffer.from([0x00, 0xC0, 0x00]); function buildLockFrame(lockPayload) { const data = Buffer.concat([ Buffer.from([0x00]), // Option DEFAULT_ACCESS_PWD, // 4 bytes (must match tag's current access pwd) lockPayload // 3 bytes (20-bit Gen2 LOCK payload, right-aligned) ]); return buildFrame(CMD_LOCK, data); } async function unlockEpcBank() { await primeRF(); const frame = buildLockFrame(LOCK_PAYLOAD_UNLOCK_EPC); const resp = await sendCommand(frame, 8000, [0x14]); if (resp.status !== 0x00) { // only 0x00 is a real unlock; 0x12/0x13 = no tag was unlocked const codes = { 0x01: 'Parameter error', 0x12: 'No tag found (keep tag on reader)', 0x13: 'No tag found', 0x17: 'Wrong password — access pwd is not 00000000' }; throw new Error(`Unlock failed: ${codes[resp.status] || `status 0x${resp.status.toString(16)}`}`); } } async function writeSkuToTag(sku, releaseId) { requireReaderOnline(); if (!/^\d{14}$/.test(sku)) throw new Error(`Invalid SKU: "${sku}" (must be 14 digits)`); // releaseId shares the 96-bit EPC with the SKU (value = sku*10^9 + releaseId), // so it MUST fit in 9 digits or it silently corrupts the SKU half. Normalise // 0/empty -> null so verify matches parseEpcData (which returns null relId for 0). const relIdNum = (releaseId === null || releaseId === undefined || releaseId === '') ? 0 : parseInt(releaseId, 10); if (!Number.isFinite(relIdNum) || relIdNum < 0 || relIdNum >= Number(RELID_FACTOR)) { throw new Error(`releaseId out of range: "${releaseId}" (must be 0..999999999)`); } const epcData = buildEpcPayload(sku, relIdNum); const wantedRelId = relIdNum === 0 ? null : relIdNum; const codes = { 0x01: 'Parameter error', 0x09: 'Wrong access password', 0x12: 'No tag found (keep tag on reader)', 0x13: 'No tag found', 0x17: 'Wrong password' }; console.log(`[rfid] writing EPC: ${epcData.toString('hex').toUpperCase()} (sku=${sku} relId=${relIdNum})`); let written = false; const MAX_WRITE_ATTEMPTS = 3; try { for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS && !written; attempt++) { // Singulate the ONE tag in the field and lock a full-EPC SELECT mask onto // its CURRENT epc, so the WRITE can only address that exact physical tag — // never a neighbour/factory tag that happens to be in range. (singulateForWrite // was previously dead code; this is what it is for.) let currentEpc; try { currentEpc = await singulateForWrite(null); } catch (err) { if (attempt >= MAX_WRITE_ATTEMPTS - 1) throw err; continue; } // Only trust a "tag already carries the target EPC → skip" short-circuit on // a RETRY (a prior attempt may have committed but lost its ack to a timeout). // On the first attempt always issue the write: a single marginal inventory // misread must never be able to SKIP a real write, and re-writing the same // EPC is harmless. (Observed live: one transient last-bit RF misread matched // the target and skipped the write.) if (attempt > 0 && currentEpc && epcData.equals(currentEpc)) { console.log(`[rfid] target EPC already on the singulated tag after retry — prior write committed`); written = true; break; } if (currentEpc) console.log(`[rfid] tag EPC: ${currentEpc.toString('hex').toUpperCase()}`); let resp; try { resp = await sendCommand(buildWriteFrame(MEM_EPC, EPC_WORD_PTR, epcData), 5000, [0x14]); } catch (err) { console.log(`[rfid] write attempt ${attempt + 1} TIMEOUT`); if (attempt >= MAX_WRITE_ATTEMPTS - 1) throw new Error(`Write timed out — keep tag flat on reader and try again`); continue; } const statusStr = `0x${resp.status.toString(16).padStart(2,'0')}`; if (resp.status === 0x00) { console.log(`[rfid] write → ${statusStr} OK`); written = true; } else if (resp.status === 0x12 || resp.status === 0x13) { console.log(`[rfid] write attempt ${attempt + 1} → ${statusStr} (retrying)`); } else { throw new Error(`Write failed: ${codes[resp.status] || `status ${statusStr}`}`); } } if (!written) throw new Error(`Write failed after ${MAX_WRITE_ATTEMPTS} attempts — keep tag flat on the reader`); // ── Verify ────────────────────────────────────────────────────────────── // Mask to the NEW EPC (now on the tag) and read it back. await sendCommand(buildSelectMaskFrame(epcData), 2000).catch(() => {}); const verify = await readSkuFromTag(); if (verify.sku !== sku || verify.releaseId !== wantedRelId) { console.warn(`[rfid] VERIFY MISMATCH — wrote sku=${sku} relId=${wantedRelId} but tag has sku=${verify.sku} relId=${verify.releaseId}`); throw new Error(`Verify failed: tag has sku=${verify.sku} relId=${verify.releaseId}, expected sku=${sku} relId=${wantedRelId}`); } console.log(`[rfid] verify OK: sku=${verify.sku} relId=${verify.releaseId}`); return sku; } finally { // Always clear the SELECT mask so a later plain read/inventory sees every tag // again — a stale full-EPC mask blinds the reader to any other tag. await clearMask().catch(() => {}); } } async function readSkuFromTag() { const wordCount = SKU_WORD_COUNT; // 6 words = 12 bytes await primeRF(); const frame = buildReadFrame(MEM_EPC, EPC_WORD_PTR, wordCount); const resp = await sendCommand(frame, 12000); // Always stop after a READ so the reader returns to idle before the next command. // Without this, the reader stays in an active-read state; a subsequent primeRF // then gets no response to its inventory command and times out. await sendStop(); if (resp.status !== 0x00) { const codes = { 0x01: 'Parameter error', 0x13: 'No tag found', 0x14: 'Tag timeout' }; throw new Error(`Read failed: ${codes[resp.status] || `status 0x${resp.status.toString(16)}`}`); } // Response data layout: RSSI(1) ANT(1) CRC(2) PC(2) EPCLEN(1) EPC(...) WORDCNT(1) READDATA(N) // READDATA is the last wordCount*2 bytes. const rssi = resp.data[0] >= 128 ? resp.data[0] - 256 : resp.data[0]; // signed dBm const antenna = resp.data[1]; // antenna port 1-4 const readData = resp.data.slice(resp.data.length - wordCount * 2); // 12 bytes const parsed = parseEpcData(readData); if (!parsed) throw new Error('Unrecognized tag — not a PRICEGOD SKU (foreign/factory EPC). Put one programmed tag on the reader.'); return { ...parsed, rssi, antenna }; } // ── HTTP server ────────────────────────────────────────────────────────────── const app = express(); app.use(cors({ origin: '*' })); app.use(express.json()); app.get('/status', async (req, res) => { try { await ensureConnected(); res.json({ ok: true, port: connectedPath }); } catch (err) { res.status(503).json({ ok: false, error: err.message }); } }); app.get('/inventory', (req, res) => { withSerial(async () => { try { await ensureConnected(); const invType = req.query.type === 'count' ? 0x01 : 0x00; const invParam = Math.min(255, Math.max(1, parseInt(req.query.param || '1', 10))); const resp = await sendCommand(buildInventoryFrame(invType, invParam), invType === 0x01 ? 10000 : 5000); await sendStop(); const statusCodes = { 0x00: 'Tag found', 0x12: 'Inventory complete', 0x13: 'No tags found', 0x14: 'Tag timeout' }; const found = resp.status === 0x00; const rssi = found && resp.data.length >= 2 ? (resp.data[0] >= 128 ? resp.data[0] - 256 : resp.data[0]) : null; const antenna = found && resp.data.length >= 2 ? resp.data[1] : null; const epcBytes = found ? parseEpcFromInventoryData(resp.data) : null; res.json({ ok: found || resp.status === 0x12, status: resp.status, statusText: statusCodes[resp.status] || `0x${resp.status.toString(16)}`, rssi, antenna, epc: epcBytes ? epcBytes.toString('hex').toUpperCase() : null, rawHex: hexDump(resp.data) }); } catch (err) { await sendStop(); res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /clear-mask — clears any active SELECTMASK in the reader firmware. // Use this if the reader stops seeing tags after a failed write operation. app.post('/clear-mask', (req, res) => { withSerial(async () => { try { await ensureConnected(); await sendStop(); await clearMask(); console.log('[rfid] SELECTMASK cleared'); res.json({ ok: true, message: 'SELECTMASK cleared — reader will now see all tags' }); } catch (err) { console.error('[rfid] clear-mask error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /recover-mode — closes serial, scans baud rates, sends SET_RFID_MODE to switch // the H102 back from barcode/QR scanner mode to RFID mode, then reconnects normally. // Run this when the gun has accidentally switched modes. index.js stays running. app.post('/recover-mode', async (req, res) => { const BAUDS_TO_TRY = [115200, 9600, 19200, 38400, 57600]; // Use broadcast addr 0xFF so the command reaches the device regardless of state. const buildBcastFrame = (cmd, data = Buffer.alloc(0)) => { const header = Buffer.from([0xCF, 0xFF, cmd[0], cmd[1], data.length]); const body = Buffer.concat([header, data]); const chk = crc16(body); return Buffer.concat([body, Buffer.from([(chk >> 8) & 0xFF, chk & 0xFF])]); }; const CMD_READMODE = [0x00, 0x8E]; const CMD_REBOOT = [0x00, 0x52]; const GET_PARAM = buildBcastFrame([0x00, 0x72]); const SET_RFID_MODE = buildBcastFrame(CMD_READMODE, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); const FACTORY_RESET = buildBcastFrame(CMD_REBOOT); // Remember path before closing const path = connectedPath || await findPort().catch(() => null); if (!path) return res.status(500).json({ ok: false, error: 'Device not found — check USB connection.' }); // Close the existing connection so we can reopen at different baud rates if (serial?.isOpen) { await new Promise(resolve => serial.close(() => resolve())); serial = null; connectedPath = null; readerOnline = false; } let restoredAtBaud = null; for (const baud of BAUDS_TO_TRY) { console.log(`[rfid] recover-mode: trying ${baud} baud on ${path}`); const ok = await new Promise(resolve => { const port = new SerialPort({ path, baudRate: baud, autoOpen: false }); let rxBuf = Buffer.alloc(0); let settled = false; const finish = result => { if (!settled) { settled = true; setTimeout(() => port.close(() => resolve(result)), 200); } }; port.open(err => { if (err) { console.log(`[rfid] recover-mode: open failed at ${baud}: ${err.message}`); return resolve(false); } port.on('data', chunk => { rxBuf = Buffer.concat([rxBuf, chunk]); }); port.on('error', () => finish(false)); // Probe: does the device respond at this baud at all? port.write(GET_PARAM); setTimeout(() => { if (rxBuf.length === 0) { console.log(`[rfid] recover-mode: no response at ${baud}`); return finish(false); } console.log(`[rfid] recover-mode: device responding at ${baud} baud`); rxBuf = Buffer.alloc(0); // Send SET RFID mode port.write(SET_RFID_MODE); setTimeout(() => { const status = rxBuf[5]; if (status === 0x00) { console.log(`[rfid] recover-mode: RFID mode restored at ${baud} baud`); return finish(true); } // Unexpected status — try factory reset as fallback console.log(`[rfid] recover-mode: SET_RFID_MODE status 0x${(status || 0).toString(16)} — sending factory reset`); rxBuf = Buffer.alloc(0); port.write(FACTORY_RESET); setTimeout(() => finish(true), 2000); }, 2000); }, 2000); }); }); if (ok) { restoredAtBaud = baud; break; } } if (!restoredAtBaud) { return res.status(500).json({ ok: false, error: 'Could not restore RFID mode at any baud rate. Try holding the trigger button for 8–10 seconds to hardware reset.' }); } // Brief pause then reconnect normally at the daemon's standard baud rate await new Promise(resolve => setTimeout(resolve, 600)); try { await ensureConnected(); res.json({ ok: true, message: `RFID mode restored. Reader reconnected — ready to use.` }); } catch (_) { res.json({ ok: true, message: 'RFID mode restored. Unplug and replug the H102 if commands still fail.' }); } }); // POST /recover — hammers STOP + flushes serial to recover a stuck/active reader. // Use this if the reader is streaming or not responding to commands. app.post('/recover', (req, res) => { withSerial(async () => { try { await ensureConnected(); await autoConfigureAnswerMode(); await clearMask(); // Verify the reader is actually responding by pinging device info. const pingFrame = buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)); try { await sendCommand(pingFrame, 2000); // readerOnline set to true inside sendCommand on valid response console.log('[rfid] recover: reader responding'); res.json({ ok: true, message: 'Reader recovered — try again.' }); } catch (_) { readerOnline = false; console.log('[rfid] recover: reader still not responding — press trigger to wake it'); res.status(500).json({ ok: false, error: 'Reader still not responding — press the trigger button on the gun to wake it, then try again.' }); } } catch (err) { console.error('[rfid] recover error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /unlock-tag — unlocks EPC bank so the tag can be freely rewritten. // Only needed once per tag (or whenever a tag refuses writes). // Requires the tag's access password to still be the factory default 00000000. app.post('/unlock-tag', (req, res) => { withSerial(async () => { try { await ensureConnected(); await unlockEpcBank(); console.log('[rfid] EPC bank unlocked'); res.json({ ok: true, message: 'EPC bank unlocked — tag is now freely rewritable' }); } catch (err) { console.error('[rfid] unlock-tag error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); app.post('/write-tag', (req, res) => { const { sku } = req.body; const releaseId = req.body.releaseId ?? req.body.release_id ?? null; // accept both cases if (!sku) return res.status(400).json({ ok: false, error: 'Missing sku' }); withSerial(async () => { try { await ensureConnected(); const written = await writeSkuToTag(String(sku), releaseId || null); console.log(`[rfid] wrote SKU ${written}${releaseId ? ' + release ' + releaseId : ''}`); res.json({ ok: true, sku: written, releaseId: releaseId || null }); } catch (err) { console.error('[rfid] write-tag error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); app.get('/read-tag', (req, res) => { withSerial(async () => { try { await ensureConnected(); const { sku, releaseId, rssi, antenna } = await readSkuFromTag(); console.log(`[rfid] read SKU ${sku}${releaseId ? ' release ' + releaseId : ''} (RSSI ${rssi} dBm, ant ${antenna})`); res.json({ ok: true, sku, releaseId, rssi, antenna }); } catch (err) { console.error('[rfid] read-tag error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); // GET /read-tid — reads the TID bank for chip identification (diagnostic only). app.get('/read-tid', (req, res) => { withSerial(async () => { try { await ensureConnected(); await primeRF(); const resp = await sendCommand(buildReadFrame(0x02, 0x00, 4), 6000); if (resp.status !== 0x00) { return res.status(500).json({ ok: false, error: `TID read status 0x${resp.status.toString(16)}` }); } const tidBytes = resp.data.slice(resp.data.length - 8); const mdid = tidBytes[1]; const chipNames = { 0x80: 'Impinj Monza', 0x00: 'NXP UCODE', 0x82: 'Alien Higgs', 0x03: 'EM Microelectronic' }; const chip = chipNames[mdid] || `vendor 0x${mdid.toString(16).padStart(2,'0')}`; console.log(`[rfid] TID: ${hexDump(tidBytes)} chip: ${chip}`); res.json({ ok: true, chip, tid: hexDump(tidBytes) }); } catch (err) { console.error('[rfid] read-tid error:', err.message); res.status(500).json({ ok: false, error: err.message }); } }); }); // GET /get-config — reads all device params (WorkMode, Interface, etc.) app.get('/get-config', (req, res) => { withSerial(async () => { try { await ensureConnected(); const frame = buildFrame([0x00, 0x72], Buffer.alloc(0)); const resp = await sendCommand(frame, 3000); if (resp.status !== 0x00) { return res.status(500).json({ ok: false, error: `GET_ALL_PARAM status 0x${resp.status.toString(16)}` }); } const d = resp.data; const workModeNames = { 0: 'answer', 1: 'active', 2: 'trigger' }; const ifaceNames = { 0x80: 'RS232', 0x40: 'RS485', 0x20: 'RJ45', 0x10: 'WiFi', 0x01: 'USB', 0x02: 'keyboard', 0x04: 'CDC_COM' }; const baudNames = { 0: '9600', 1: '19200', 2: '38400', 3: '57600', 4: '115200' }; const rfidProNames = { 0x00: 'ISO 18000-6C', 0x01: 'GB/T 29768', 0x02: 'GJB 7377.1' }; const freqNames = { 0x00: 'Custom', 0x01: 'US 902-927MHz', 0x02: 'Korea 917-923MHz', 0x03: 'EU 865-868MHz', 0x04: 'Japan 952-953MHz', 0x05: 'Malaysia 919-922MHz', 0x06: 'EU3 865-867MHz', 0x07: 'China Band1 840-844MHz', 0x08: 'China Band2 920-924MHz' }; // Byte offsets 5-15 inferred from SDK AllParamBean ordering — verify against raw if unsure res.json({ ok: true, raw: hexDump(d), addr: d[0], rfidPro: d[1], rfidProName: rfidProNames[d[1]] || `0x${(d[1]||0).toString(16)}`, workMode: d[2], workModeName: workModeNames[d[2]] || 'unknown', interface: d[3], interfaceName: ifaceNames[d[3]] || `0x${(d[3]||0).toString(16)}`, baudRate: d[4], baudRateName: baudNames[d[4]] || `idx${d[4]}`, ant: d[5], // antenna bitmask: bit0=ant1, bit1=ant2, ... qValue: d[6], // EPC Gen2 Q value 0-15 (≈ 2^Q expected tags) session: d[7], // EPC Gen2 session S0-S3 inquiryArea: d[8], // 0=Reserved,1=EPC,2=TID,3=User,4=EPC+TID,5=EPC+User,6=all acsAddr: d[9], // tag access start address (bytes) acsDataLen: d[10], // tag access data length (bytes) filterTime: d[11], // duplicate filter window (0-255 s, 0=off) triggerTime: d[12], // trigger-mode scan duration (0-255 s) rfidPower: d[13], // RF output power raw byte (÷10 = dBm, e.g. 244 → 24.4 dBm, max ~33 dBm) buzzerTime: d[14], // buzzer on-time (0-255 × 10ms, 0=silent) pollingInterval: d[15], // scan interval in active mode (0-255 × 10ms) rfidFreq: d.length > 16 ? hexDump(d.slice(16, 24)) : null, rfidFreqName: d.length > 16 ? (freqNames[d[16]] || `0x${(d[16]||0).toString(16)}`) : null, wgSet: d.length > 24 ? d[24] : null, }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /set-workmode — { mode: 0 } sets WorkMode (0=answer, 1=active, 2=trigger) app.post('/set-workmode', (req, res) => { const mode = parseInt(req.body?.mode ?? '', 10); if (isNaN(mode) || mode < 0 || mode > 2) { return res.status(400).json({ ok: false, error: 'mode must be 0, 1, or 2' }); } withSerial(async () => { try { await ensureConnected(); const getFrame = buildFrame([0x00, 0x72], Buffer.alloc(0)); const getResp = await sendCommand(getFrame, 3000); if (getResp.status !== 0x00) { return res.status(500).json({ ok: false, error: `GET_ALL_PARAM failed: 0x${getResp.status.toString(16)}` }); } const params = Buffer.from(getResp.data); params[2] = mode; const setFrame = buildFrame([0x00, 0x71], params); const setResp = await sendCommand(setFrame, 3000); if (setResp.status !== 0x00) { return res.status(500).json({ ok: false, error: `SET_ALL_PARAM failed: 0x${setResp.status.toString(16)}` }); } res.json({ ok: true, workMode: mode }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); }); // GET /device-info — returns hardware version, firmware version, serial number, RFID module info. app.get('/device-info', (req, res) => { withSerial(async () => { try { await ensureConnected(); const frame = buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)); const resp = await sendCommand(frame, 3000); if (resp.status !== 0x00) { return res.status(500).json({ ok: false, error: `GET_DEVICE_INFO status 0x${resp.status.toString(16)}` }); } const d = resp.data; // Layout (inferred): HwVer(2) + FirmVer(2) + SN(4) + ModuleVer(2) + ModuleName(N) const hwVer = d.length >= 2 ? `${d[0]}.${d[1]}` : null; const firmVer = d.length >= 4 ? `${d[2]}.${d[3]}` : null; const sn = d.length >= 8 ? hexDump(d.slice(4, 8)) : null; const modVer = d.length >= 10 ? `${d[8]}.${d[9]}` : null; const modName = d.length > 10 ? d.slice(10).toString('ascii').replace(/\0/g, '').trim() : null; console.log(`[rfid] device info: hw=${hwVer} firm=${firmVer} sn=${sn} mod=${modName}@${modVer}`); res.json({ ok: true, hwVer, firmVer, sn, moduleVer: modVer, moduleName: modName, raw: hexDump(d) }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); }); // GET /battery — returns battery percentage (0-100). Only relevant for battery-powered H102 variants. app.get('/battery', (req, res) => { withSerial(async () => { try { await ensureConnected(); const frame = buildFrame(CMD_BATTERY, Buffer.alloc(0)); const resp = await sendCommand(frame, 3000); if (resp.status !== 0x00) { return res.status(500).json({ ok: false, error: `GET_BATTERY status 0x${resp.status.toString(16)}` }); } const pct = resp.data[0]; console.log(`[rfid] battery: ${pct}%`); res.json({ ok: true, battery: pct }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /set-config — update individual AllParam fields without touching others. // Body: { qValue, session, filterTime, triggerTime, buzzerTime, pollingInterval, rfidPower, ant, workMode } // Byte offsets are inferred from SDK AllParamBean ordering — they match /get-config labels. app.post('/set-config', (req, res) => { withSerial(async () => { try { await ensureConnected(); const getFrame = buildFrame([0x00, 0x72], Buffer.alloc(0)); const getResp = await sendCommand(getFrame, 3000); if (getResp.status !== 0x00) { return res.status(500).json({ ok: false, error: `GET_ALL_PARAM failed` }); } const params = Buffer.from(getResp.data); const fieldMap = { workMode: 2, ant: 5, qValue: 6, session: 7, inquiryArea: 8, acsAddr: 9, acsDataLen: 10, filterTime: 11, triggerTime: 12, rfidPower: 13, buzzerTime: 14, pollingInterval: 15 }; const changed = []; for (const [key, byteIdx] of Object.entries(fieldMap)) { if (req.body[key] !== undefined) { const val = parseInt(req.body[key], 10); if (!isNaN(val) && val >= 0 && val <= 255 && byteIdx < params.length) { params[byteIdx] = val; changed.push(`${key}=${val}`); } } } if (changed.length === 0) { return res.status(400).json({ ok: false, error: 'No valid fields provided' }); } const setFrame = buildFrame([0x00, 0x71], params); const setResp = await sendCommand(setFrame, 3000); if (setResp.status !== 0x00) { return res.status(500).json({ ok: false, error: `SET_ALL_PARAM failed: 0x${setResp.status.toString(16)}` }); } console.log(`[rfid] config updated: ${changed.join(', ')}`); res.json({ ok: true, changed }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); }); // POST /shutdown — gracefully stops the daemon process. app.post('/shutdown', (req, res) => { res.json({ ok: true, message: 'Daemon shutting down' }); console.log('[rfid] shutdown requested — exiting'); setTimeout(() => process.exit(0), 200); }); // ── DYMO M10 USB postal scale ──────────────────────────────────────────────── // HID device: vendor 0x0922, product 0x8003 (also 0x8004 on some firmwares). // Report layout (6 bytes after the report-id): // byte0 = report id (0x03) -- some platforms include it, some don't // byte1 = status (2=zero, 4=stable, 5=moving, 6=over, 7=neg, 0xB=cal req) // byte2 = unit (2 = g, 11 = oz) // byte3 = scaling (signed; usually 0 for g, -1 or -2 for oz) // byte4 = weight low byte // byte5 = weight high byte // // node-hid hands us either a 6- or 7-byte buffer depending on platform. // We normalise by sniffing for the status byte. const DYMO_VENDOR = 0x0922; const DYMO_PIDS = [0x8003, 0x8004]; // M5/M10/M25 variants function findDymoScale() { if (!HID) return null; try { const devices = HID.devices(); return devices.find(d => d.vendorId === DYMO_VENDOR && DYMO_PIDS.includes(d.productId)) || null; } catch (e) { return null; } } function parseScaleReport(buf) { if (!buf || buf.length < 5) return null; // Skip leading report-id byte on platforms that prepend one. // Heuristic: if buf[0] === 3 and buf.length >= 6, slice it off. const b = (buf[0] === 3 && buf.length >= 6) ? buf.slice(1) : buf; if (b.length < 5) return null; const status = b[0]; const unit = b[1]; const scaling = (b[2] & 0x80) ? b[2] - 256 : b[2]; // signed const raw = b[3] | (b[4] << 8); const value = raw * Math.pow(10, scaling); let grams, ounces; if (unit === 2) { // grams native grams = value; ounces = value / 28.3495; } else if (unit === 11) { // ounces native ounces = value; grams = value * 28.3495; } else { grams = null; ounces = null; } return { status, // 4 = stable, 5 = moving, etc. stable: status === 4 || status === 2, // 2 = zeroed (also a valid resting state) grams: grams != null ? Math.round(grams) : null, ounces: ounces != null ? +ounces.toFixed(3) : null, unit, raw: Array.from(b.slice(0, 5)) }; } async function readScaleOnce(timeoutMs = 800) { if (!HID) return { online: false, reason: 'node-hid not loaded' }; const info = findDymoScale(); if (!info) return { online: false, reason: 'no DYMO scale detected' }; let dev; try { dev = new HID.HID(info.path); } catch (e) { return { online: false, reason: `open failed: ${e.message}` }; } return new Promise((resolve) => { let settled = false; const finish = (val) => { if (!settled) { settled = true; try { dev.close(); } catch (_) {} resolve(val); } }; const timer = setTimeout(() => finish({ online: true, error: 'read timeout' }), timeoutMs); dev.on('data', (data) => { clearTimeout(timer); const parsed = parseScaleReport(data); if (parsed) finish({ online: true, ...parsed }); else finish({ online: true, error: 'unparseable report', raw: Array.from(data) }); }); dev.on('error', (err) => { clearTimeout(timer); finish({ online: false, reason: err.message }); }); }); } // ── Streaming scale session ─────────────────────────────────────────────────── // While active: one HID handle stays open, scale reports stream in continuously, // /weight returns the cached latest reading instantly. Sustained USB activity // also tends to suppress the M10's idle auto-power-off. // // While inactive: /weight falls back to a one-shot open-read-close (the // readScaleOnce path above) — slower per call but no USB activity between // calls, so the scale powers itself off normally to save batteries. const scaleSession = { active: false, device: null, startedAt: null, latest: null, // { online, grams, stable, ... } latestAt: null, reopenTimer: null }; function scaleSessionStart() { if (!HID) throw new Error('node-hid not loaded'); if (scaleSession.active) return { ok: true, alreadyActive: true }; const info = findDymoScale(); if (!info) throw new Error('no DYMO scale detected'); const dev = new HID.HID(info.path); dev.on('data', (data) => { const parsed = parseScaleReport(data); if (parsed) { scaleSession.latest = { online: true, ...parsed }; scaleSession.latestAt = Date.now(); } }); dev.on('error', (err) => { console.warn('[scale] HID error:', err.message); scaleSession.latest = { online: false, reason: err.message }; scaleSession.latestAt = Date.now(); // Try to reopen in 2s — handles brief scale auto-power-off → on cycles if (scaleSession.active && !scaleSession.reopenTimer) { scaleSession.reopenTimer = setTimeout(() => { scaleSession.reopenTimer = null; try { scaleSession.device && scaleSession.device.close(); } catch (_) {} scaleSession.device = null; try { scaleSessionStart(); } catch (e) { console.warn('[scale] reopen failed:', e.message); } }, 2000); } }); scaleSession.device = dev; scaleSession.active = true; scaleSession.startedAt = Date.now(); scaleSession.latest = { online: true, grams: null, stable: false, note: 'awaiting first report' }; scaleSession.latestAt = Date.now(); console.log('[scale] session started'); return { ok: true, startedAt: scaleSession.startedAt }; } function scaleSessionStop() { if (!scaleSession.active) return { ok: true, alreadyStopped: true }; if (scaleSession.reopenTimer) { clearTimeout(scaleSession.reopenTimer); scaleSession.reopenTimer = null; } try { scaleSession.device && scaleSession.device.close(); } catch (_) {} scaleSession.device = null; scaleSession.active = false; scaleSession.latest = null; scaleSession.latestAt = null; scaleSession.startedAt = null; console.log('[scale] session stopped'); return { ok: true }; } app.post('/scale/start', express.json(), (req, res) => { try { res.json(scaleSessionStart()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/scale/stop', express.json(), (req, res) => { res.json(scaleSessionStop()); }); app.get('/scale/status', (req, res) => { res.json({ active: scaleSession.active, startedAt: scaleSession.startedAt, latestAt: scaleSession.latestAt, latest: scaleSession.latest, hidAvailable: !!HID }); }); app.get('/weight', async (req, res) => { // Streaming session active → return cached reading (fast, USB stays warm). if (scaleSession.active) { const ageMs = scaleSession.latestAt ? (Date.now() - scaleSession.latestAt) : null; // If the cached value is too old (>4s), the scale probably auto-powered off // mid-session; surface it as offline so the UI shows the right state. if (scaleSession.latest && ageMs != null && ageMs <= 4000) { return res.json({ sessionActive: true, ageMs, ...scaleSession.latest }); } return res.json({ sessionActive: true, ageMs, online: false, reason: 'no recent report (scale asleep?)' }); } // No session → fall back to one-shot open-read-close. try { const result = await readScaleOnce(); res.json({ sessionActive: false, ...result }); } catch (e) { res.status(500).json({ sessionActive: false, online: false, error: e.message }); } }); // ── Update wp_rmp_disc_inventory.actual_weight for one SKU ──────────────────── async function updateInventoryWeight({ releaseId, sku, grams, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser }) { const keyPath = (sshKeyPath || '~/.ssh/id_rsa').replace(/^~/, os.homedir()); let privateKey; try { privateKey = fs.readFileSync(keyPath); } catch (e) { throw new Error(`Cannot read SSH key at ${keyPath}: ${e.message}`); } return new Promise((resolve, reject) => { let settled = false; const done = (fn, val) => { if (!settled) { settled = true; fn(val); } }; const ssh = new SshClient(); ssh.on('ready', () => { ssh.forwardOut('127.0.0.1', 0, '127.0.0.1', 3306, async (err, stream) => { if (err) { ssh.end(); return done(reject, new Error(`SSH forward failed: ${err.message}`)); } let conn; try { conn = await mysql.createConnection({ host: '127.0.0.1', user: dbUser, password: dbPass, database: dbName, stream }); const [result] = await conn.execute( 'UPDATE wp_rmp_disc_inventory SET actual_weight = ? WHERE release_id = ? AND sku = ?', [parseInt(grams, 10), parseInt(releaseId, 10), String(sku)] ); await conn.end(); ssh.end(); done(resolve, { affectedRows: result.affectedRows, changedRows: result.changedRows }); } catch (e) { try { if (typeof conn !== 'undefined') await conn.end(); } catch (_) {} ssh.end(); done(reject, e); } }); }); ssh.on('error', (e) => { ssh.end(); done(reject, new Error(`SSH error: ${e.message}`)); }); ssh.connect({ host: dbHost || DEFAULT_INVENTORY_HOST, port: 22, username: sshUser || DEFAULT_SSH_USER, privateKey }); }); } app.post('/inventory-update-weight', express.json(), async (req, res) => { const { release_id, sku, grams, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser } = req.body || {}; if (!release_id || !sku || grams == null) { return res.status(400).json({ error: 'release_id, sku and grams are required' }); } if (!dbUser || !dbPass || !dbName) { return res.status(400).json({ error: 'dbUser, dbPass, and dbName are required' }); } const gN = parseInt(grams, 10); if (!Number.isFinite(gN) || gN < 1 || gN > 9999) { return res.status(400).json({ error: 'grams out of sane range (1-9999)' }); } try { const result = await updateInventoryWeight({ releaseId: release_id, sku, grams: gN, dbHost: dbHost || DEFAULT_INVENTORY_HOST, dbName, dbUser, dbPass, sshKeyPath: sshKeyPath || '~/.ssh/id_rsa', sshUser: sshUser || DEFAULT_SSH_USER }); res.json({ ok: true, grams: gN, sku, release_id, ...result }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Inventory lookup via SSH tunnel ────────────────────────────────────────── async function inventoryLookup({ releaseId, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser }) { const keyPath = (sshKeyPath || '~/.ssh/id_rsa').replace(/^~/, os.homedir()); let privateKey; try { privateKey = fs.readFileSync(keyPath); } catch (e) { throw new Error(`Cannot read SSH key at ${keyPath}: ${e.message}`); } return new Promise((resolve, reject) => { let settled = false; const done = (fn, val) => { if (!settled) { settled = true; fn(val); } }; const ssh = new SshClient(); ssh.on('ready', () => { ssh.forwardOut('127.0.0.1', 0, '127.0.0.1', 3306, async (err, stream) => { if (err) { ssh.end(); return done(reject, new Error(`SSH forward failed: ${err.message}`)); } let conn; try { conn = await mysql.createConnection({ host: '127.0.0.1', user: dbUser, password: dbPass, database: dbName, stream }); const [rows] = await conn.execute( 'SELECT sku, price, media_condition, sleeve_condition, instock, crate_id, slot_number, sold_date, actual_weight FROM wp_rmp_disc_inventory WHERE release_id = ? ORDER BY sku ASC', [parseInt(releaseId, 10)] ); await conn.end(); ssh.end(); done(resolve, rows); } catch (e) { try { if (typeof conn !== 'undefined') await conn.end(); } catch (_) {} ssh.end(); done(reject, e); } }); }); ssh.on('error', (e) => { ssh.end(); done(reject, new Error(`SSH error: ${e.message}`)); }); ssh.connect({ host: dbHost || DEFAULT_INVENTORY_HOST, port: 22, username: sshUser || DEFAULT_SSH_USER, privateKey }); }); } app.get('/inventory-lookup', async (req, res) => { const { release_id, dbHost, dbName, dbUser, dbPass, sshKeyPath, sshUser } = req.query; if (!release_id) { return res.status(400).json({ error: 'release_id is required' }); } if (isNaN(parseInt(release_id, 10))) { return res.status(400).json({ error: 'release_id must be a number' }); } if (!dbUser || !dbPass || !dbName) { return res.status(400).json({ error: 'dbUser, dbPass, and dbName are required' }); } try { const rows = await inventoryLookup({ releaseId: release_id, dbHost: dbHost || DEFAULT_INVENTORY_HOST, dbName, dbUser, dbPass, sshKeyPath: sshKeyPath || '~/.ssh/id_rsa', sshUser: sshUser || DEFAULT_SSH_USER }); res.json({ rows: rows.map(r => ({ sku: r.sku, price: r.price != null ? String(r.price) : null, media_condition: r.media_condition || null, sleeve_condition: r.sleeve_condition || null, instock: r.instock, crate_id: r.crate_id != null ? String(r.crate_id) : null, slot_number: r.slot_number != null ? String(r.slot_number) : null, sold_date: r.sold_date ? String(r.sold_date) : null, actual_weight: r.actual_weight != null ? Number(r.actual_weight) : null })) }); } catch (e) { console.error('[inventory-lookup]', e.message); res.status(500).json({ error: e.message }); } }); app.listen(HTTP_PORT, '0.0.0.0', () => { console.log(`[rfid] daemon listening on http://0.0.0.0:${HTTP_PORT}`); if (IS_ULTRA) { console.log(`[rfid] Running LOCALLY on ${HOSTNAME}.`); } else { console.log(`[rfid] Running REMOTELY on ${HOSTNAME}.`); } console.log('[rfid] endpoints: GET /status /inventory /read-tag /read-tid /get-config /device-info /battery /weight /scale/status /inventory-lookup POST /write-tag /set-config /clear-mask /unlock-tag /set-workmode /shutdown /inventory-update-weight /scale/start /scale/stop'); });