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>
1440 lines
62 KiB
JavaScript
1440 lines
62 KiB
JavaScript
/**
|
||
* 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');
|
||
});
|