pliceclogs-og/rfid-daemon/index.js
type-two e5b8504bc6 Import pliceclogs as-is — the original Discogs seller extension
Snapshot of the working tree exactly as it stood, no edits. This is the predecessor
PRICEGOD was rewritten from ("kept intact, untouched" per pricegod/README.md), and it
is still the only place the DYMO scale is actually implemented —
rfid-daemon/index.js:1068-1265: HID discovery, parseScaleReport, one-shot read, and a
streaming /weight + /scale/start|stop session whose JSON shape PRICEGOD's daemon.js
already speaks.

Preserved verbatim on purpose (hence -og), including the known bug: DYMO_PIDS at
index.js:1082 is [0x8003, 0x8004], so it cannot see the bench M25 (0x8009). Fix that
in whatever daemon inherits the scale, not here.

node_modules stays ignored (22M of the 24M tree). No credentials in the import: the
two PEM markers in utils.js/sheets.js only strip headers off a key read from settings,
and mrpadmin / johnking are an SSH and a Postgres username, both key/trust auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:47:09 +10:00

1440 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 810 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');
});