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>
793 lines
34 KiB
JavaScript
793 lines
34 KiB
JavaScript
'use strict';
|
|
// Parameterised core for the variant experiment.
|
|
// Behaves identically to ../index.js when started with no flags. Each flag
|
|
// adds ONE feature change so variants can be A/B compared. Don't run this
|
|
// directly — use one of variants/{control,keepalive,preflight,slowpace,broadcast}.js.
|
|
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { SerialPort } = require('serialport');
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const { Client: SshClient } = require('ssh2');
|
|
const mysql = require('mysql2/promise');
|
|
const { createLogger } = require('./log');
|
|
|
|
function startDaemon(config) {
|
|
const { variant } = config;
|
|
const flags = config.flags || {};
|
|
const HOSTNAME = os.hostname();
|
|
const IS_ULTRA = (HOSTNAME === 'ultra.local' || HOSTNAME === 'ultra');
|
|
|
|
const QUIET = process.argv.includes('--quiet') || process.argv.includes('-q');
|
|
const verbose = QUIET ? () => {} : (...a) => console.log(...a);
|
|
|
|
const HTTP_PORT = parseInt(process.env.RFID_HTTP_PORT || '7790', 10);
|
|
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 = flags.broadcast ? 0xFF : 0x00;
|
|
const KEEPALIVE_MS = flags.keepalive ? 10000 : 0;
|
|
const SLOWPACE_TX_MS = flags.slowpace ? 500 : 0;
|
|
const SLOWPACE_POSTWRITE_MS = flags.slowpace ? 2000 : 0;
|
|
|
|
const diagDir = path.join(__dirname, '..', 'diagnostics');
|
|
const logger = createLogger(variant, diagDir);
|
|
console.log(`[rfid:${variant}] flags=${JSON.stringify(flags)} addr=0x${ADDR.toString(16).padStart(2,'0')}`);
|
|
console.log(`[rfid:${variant}] logging session events to ${logger.logPath}`);
|
|
logger.event('start', { variant, flags, port: HTTP_PORT, addr: ADDR, baud: BAUD_RATE });
|
|
|
|
const CMD_INVENTORY = [0x00, 0x01];
|
|
const CMD_STOP = [0x00, 0x02];
|
|
const CMD_READ = [0x00, 0x03];
|
|
const CMD_WRITE = [0x00, 0x04];
|
|
const CMD_LOCK = [0x00, 0x05];
|
|
const CMD_SELECTMASK = [0x00, 0x07];
|
|
const CMD_GET_PARAM = [0x00, 0x72];
|
|
const CMD_SET_PARAM = [0x00, 0x71];
|
|
const CMD_DEVICE_INFO = [0x00, 0x70];
|
|
const CMD_BATTERY = [0x00, 0x83];
|
|
const MEM_EPC = 0x01;
|
|
const DEFAULT_ACCESS_PWD = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
|
|
|
let serial = null;
|
|
let connectedPath = null;
|
|
let readerOnline = false;
|
|
let lastTxAt = 0;
|
|
|
|
function requireReaderOnline() {
|
|
if (!readerOnline) throw new Error('Reader offline — press the trigger button on the gun to wake it, then click Recover Reader.');
|
|
}
|
|
|
|
// ── Serial port discovery ────────────────────────────────────────────────
|
|
async function findPort() {
|
|
if (process.env.RFID_PORT) return process.env.RFID_PORT;
|
|
const ports = await SerialPort.list();
|
|
console.log(`[rfid:${variant}] available ports:`, ports.map(p => `${p.path} (${p.manufacturer || 'unknown'})`).join(', ') || 'none');
|
|
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));
|
|
});
|
|
}
|
|
|
|
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:${variant}] connected to ${path} @ ${BAUD_RATE} baud`);
|
|
logger.event('connected', { path, baud: BAUD_RATE });
|
|
serial.on('data', chunk => {
|
|
const hex = chunk.toString('hex').replace(/../g, '$& ').trim().toUpperCase();
|
|
const ascii = chunk.toString('ascii').replace(/[^\x20-\x7E]/g, '.');
|
|
verbose(`[rfid:${variant}] UNSOLICITED RX: ${hex} "${ascii}"`);
|
|
});
|
|
serial.on('error', err => {
|
|
console.error(`[rfid:${variant}] serial error:`, err.message);
|
|
serial = null; connectedPath = null;
|
|
});
|
|
serial.on('close', () => {
|
|
console.log(`[rfid:${variant}] port closed`);
|
|
serial = null; connectedPath = null; readerOnline = false;
|
|
});
|
|
try {
|
|
await sendCommand(buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)), 1500);
|
|
await autoConfigureAnswerMode();
|
|
} catch (_) {
|
|
console.log(`[rfid:${variant}] reader not responding — press the trigger button to wake it, then click Recover Reader.`);
|
|
}
|
|
})().finally(() => { _connecting = null; });
|
|
}
|
|
return _connecting;
|
|
}
|
|
|
|
function flushSerial() {
|
|
return new Promise(resolve => serial.flush(() => resolve()));
|
|
}
|
|
|
|
async function autoConfigureAnswerMode() {
|
|
try {
|
|
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:${variant}] reader not responding to STOP — it may be asleep.`);
|
|
return;
|
|
}
|
|
await flushSerial();
|
|
const cfgResp = await sendCommand(buildFrame(CMD_GET_PARAM, 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:${variant}] reader work mode: ${modeNames[workMode] ?? workMode}`);
|
|
logger.event('workmode_observed', { workMode, name: modeNames[workMode] ?? String(workMode) });
|
|
if (workMode === 0) return;
|
|
console.log(`[rfid:${variant}] switching reader to answer mode...`);
|
|
const params = Buffer.from(cfgResp.data);
|
|
params[2] = 0;
|
|
const setResp = await sendCommand(buildFrame(CMD_SET_PARAM, params), 3000);
|
|
if (setResp.status === 0x00) {
|
|
console.log(`[rfid:${variant}] answer mode set`);
|
|
logger.event('workmode_set', { to: 0 });
|
|
} else {
|
|
console.log(`[rfid:${variant}] warning: could not set answer mode (status 0x${setResp.status.toString(16)})`);
|
|
}
|
|
} catch (err) {
|
|
console.log(`[rfid:${variant}] note: could not read/set reader work mode:`, err.message);
|
|
}
|
|
}
|
|
|
|
// ── CRC-16 / framing ─────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
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])]);
|
|
}
|
|
|
|
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)); }
|
|
|
|
function buildSelectMaskFrame(epcBytes) {
|
|
if (!epcBytes || epcBytes.length === 0) {
|
|
return buildFrame(CMD_SELECTMASK, Buffer.from([0x00, 0x00, 0x00]));
|
|
}
|
|
const lengthBits = epcBytes.length * 8;
|
|
return buildFrame(CMD_SELECTMASK, Buffer.concat([
|
|
Buffer.from([0x00, 0x00, lengthBits & 0xFF]),
|
|
epcBytes
|
|
]));
|
|
}
|
|
|
|
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]),
|
|
DEFAULT_ACCESS_PWD,
|
|
Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount]),
|
|
dataBytes
|
|
]);
|
|
return buildFrame(CMD_WRITE, data);
|
|
}
|
|
|
|
function buildReadFrame(memBank, wordPtr, wordCount) {
|
|
const data = Buffer.concat([
|
|
Buffer.from([0x00]),
|
|
DEFAULT_ACCESS_PWD,
|
|
Buffer.from([memBank, (wordPtr >> 8) & 0xFF, wordPtr & 0xFF, wordCount])
|
|
]);
|
|
return buildFrame(CMD_READ, data);
|
|
}
|
|
|
|
// ── EPC encoding ─────────────────────────────────────────────────────────
|
|
const RELID_FACTOR = 1_000_000_000n;
|
|
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) {
|
|
let value = 0n;
|
|
for (const b of buf) value = (value << 8n) | BigInt(b);
|
|
const releaseRaw = Number(value % RELID_FACTOR);
|
|
const sku = (value / RELID_FACTOR).toString().padStart(14, '0');
|
|
return { sku, releaseId: releaseRaw === 0 ? null : releaseRaw };
|
|
}
|
|
|
|
function parseEpcFromInventoryData(data) {
|
|
if (!data || data.length < 17) return null;
|
|
return data.slice(5, 17);
|
|
}
|
|
|
|
// ── Serial command runner ────────────────────────────────────────────────
|
|
function hexDump(buf) {
|
|
return Buffer.from(buf).toString('hex').replace(/../g, '$& ').trim().toUpperCase();
|
|
}
|
|
|
|
async function maybeSlowpaceTxGap() {
|
|
if (SLOWPACE_TX_MS > 0) {
|
|
const since = Date.now() - lastTxAt;
|
|
const wait = SLOWPACE_TX_MS - since;
|
|
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
|
}
|
|
}
|
|
|
|
function sendCommand(frame, timeoutMs = 5000, skipStatuses = []) {
|
|
return (async () => {
|
|
await maybeSlowpaceTxGap();
|
|
verbose(`[rfid:${variant}] TX: ${hexDump(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);
|
|
fn(val);
|
|
};
|
|
const timeout = setTimeout(() => {
|
|
logger.event('timeout', { cmd: `0x${expectedCmd[0].toString(16).padStart(2,'0')}${expectedCmd[1].toString(16).padStart(2,'0')}`, timeoutMs });
|
|
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'));
|
|
let buf = Buffer.alloc(0);
|
|
function onData(chunk) {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
verbose(`[rfid:${variant}] RX chunk: ${hexDump(chunk)} (buf ${buf.length} bytes)`);
|
|
while (buf.length >= 7) {
|
|
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;
|
|
const dataLen = buf[4];
|
|
const totalLen = 5 + dataLen + 2;
|
|
if (buf.length < totalLen) return;
|
|
const respFrame = buf.slice(0, totalLen);
|
|
buf = buf.slice(totalLen);
|
|
const cmdH = respFrame[2];
|
|
const cmdL = respFrame[3];
|
|
if (cmdH !== expectedCmd[0] || cmdL !== expectedCmd[1]) {
|
|
verbose(`[rfid:${variant}] 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;
|
|
}
|
|
const status = respFrame[5];
|
|
if (skipStatuses.includes(status)) {
|
|
verbose(`[rfid:${variant}] RX intermediate status=0x${status.toString(16).padStart(2,'0')}`);
|
|
continue;
|
|
}
|
|
verbose(`[rfid:${variant}] RX full: ${hexDump(respFrame)}`);
|
|
readerOnline = true;
|
|
const data = respFrame.slice(6, totalLen - 2);
|
|
settle(resolve, { status, data });
|
|
return;
|
|
}
|
|
}
|
|
serial.on('data', onData);
|
|
serial.on('close', onClose);
|
|
serial.write(frame, err => {
|
|
lastTxAt = Date.now();
|
|
if (err) settle(reject, err);
|
|
});
|
|
});
|
|
})();
|
|
}
|
|
|
|
// ── Serial mutex ─────────────────────────────────────────────────────────
|
|
let _serialBusy = Promise.resolve();
|
|
function withSerial(fn) {
|
|
const ticket = _serialBusy.then(() => fn());
|
|
_serialBusy = ticket.catch(() => {});
|
|
return ticket;
|
|
}
|
|
|
|
// ── Tag operations ───────────────────────────────────────────────────────
|
|
const EPC_WORD_PTR = 0x0002;
|
|
const SKU_WORD_COUNT = 6;
|
|
|
|
async function clearMask() {
|
|
try {
|
|
await sendCommand(buildSelectMaskFrame(null), 2000);
|
|
verbose(`[rfid:${variant}] SELECTMASK cleared`);
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function sendStop() {
|
|
if (!serial?.isOpen) return;
|
|
try { await sendCommand(buildStopFrame(), 500); } catch (_) {}
|
|
}
|
|
|
|
// Preflight: re-assert answer mode before each tag op. Cheaper than the
|
|
// full autoConfigureAnswerMode (no STOP retry loop).
|
|
async function preflight() {
|
|
if (!flags.preflight) return;
|
|
try {
|
|
await sendStop();
|
|
const cfg = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 1200).catch(() => null);
|
|
if (!cfg || cfg.status !== 0x00) {
|
|
logger.event('preflight_run', { ok: false, reason: 'no_config_response' });
|
|
return;
|
|
}
|
|
const wm = cfg.data[2];
|
|
if (wm !== 0) {
|
|
const params = Buffer.from(cfg.data);
|
|
params[2] = 0;
|
|
await sendCommand(buildFrame(CMD_SET_PARAM, params), 2000).catch(() => {});
|
|
logger.event('preflight_run', { ok: true, hadDrift: true, fromMode: wm });
|
|
} else {
|
|
logger.event('preflight_run', { ok: true, hadDrift: false });
|
|
}
|
|
} catch (err) {
|
|
logger.event('preflight_run', { ok: false, reason: err.message });
|
|
}
|
|
}
|
|
|
|
async function primeRF() {
|
|
await _runInventory();
|
|
await sendStop();
|
|
}
|
|
|
|
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:${variant}] 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;
|
|
}
|
|
|
|
const LOCK_PAYLOAD_UNLOCK_EPC = Buffer.from([0x00, 0xC0, 0x00]);
|
|
function buildLockFrame(lockPayload) {
|
|
const data = Buffer.concat([
|
|
Buffer.from([0x00]),
|
|
DEFAULT_ACCESS_PWD,
|
|
lockPayload
|
|
]);
|
|
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 && resp.status !== 0x12) {
|
|
const codes = { 0x01: 'Parameter error', 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) {
|
|
const startedAt = Date.now();
|
|
requireReaderOnline();
|
|
if (!/^\d{14}$/.test(sku)) throw new Error(`Invalid SKU: "${sku}" (must be 14 digits)`);
|
|
await preflight();
|
|
const epcData = buildEpcPayload(sku, releaseId);
|
|
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:${variant}] writing EPC: ${epcData.toString('hex').toUpperCase()} (sku=${sku} relId=${releaseId || 0})`);
|
|
let written = false;
|
|
const MAX_WRITE_ATTEMPTS = 3;
|
|
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS && !written; attempt++) {
|
|
await clearMask();
|
|
await sendStop();
|
|
let currentEpc = null;
|
|
try {
|
|
const invResp = await sendCommand(buildInventoryFrame(), 4000);
|
|
if (invResp.status === 0x00) currentEpc = parseEpcFromInventoryData(invResp.data);
|
|
} catch (_) {}
|
|
await sendStop();
|
|
if (currentEpc && epcData.equals(currentEpc)) {
|
|
console.log(`[rfid:${variant}] target EPC already on tag at attempt ${attempt + 1} — prior write committed`);
|
|
written = true;
|
|
break;
|
|
}
|
|
let resp;
|
|
try {
|
|
resp = await sendCommand(buildWriteFrame(MEM_EPC, EPC_WORD_PTR, epcData), 5000, [0x14]);
|
|
} catch (err) {
|
|
console.log(`[rfid:${variant}] 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:${variant}] write → ${statusStr} OK`);
|
|
written = true;
|
|
} else if (resp.status === 0x12 || resp.status === 0x13) {
|
|
console.log(`[rfid:${variant}] 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`);
|
|
await sendCommand(buildSelectMaskFrame(epcData), 2000).catch(() => {});
|
|
const verify = await readSkuFromTag({ skipPreflight: true });
|
|
const wantedRelId = releaseId ? parseInt(releaseId, 10) : null;
|
|
if (verify.sku !== sku || verify.releaseId !== wantedRelId) {
|
|
logger.event('write_failed', { sku, releaseId, error: 'verify_mismatch', got: verify });
|
|
throw new Error(`Verify failed: tag has sku=${verify.sku} relId=${verify.releaseId}, expected sku=${sku} relId=${wantedRelId}`);
|
|
}
|
|
console.log(`[rfid:${variant}] verify OK: sku=${verify.sku} relId=${verify.releaseId}`);
|
|
if (SLOWPACE_POSTWRITE_MS > 0) {
|
|
await new Promise(r => setTimeout(r, SLOWPACE_POSTWRITE_MS));
|
|
}
|
|
logger.event('write_ok', { sku, releaseId, durationMs: Date.now() - startedAt });
|
|
return sku;
|
|
}
|
|
|
|
async function readSkuFromTag(opts = {}) {
|
|
if (!opts.skipPreflight) await preflight();
|
|
const wordCount = SKU_WORD_COUNT;
|
|
await primeRF();
|
|
const frame = buildReadFrame(MEM_EPC, EPC_WORD_PTR, wordCount);
|
|
const resp = await sendCommand(frame, 12000);
|
|
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)}`}`);
|
|
}
|
|
const rssi = resp.data[0] >= 128 ? resp.data[0] - 256 : resp.data[0];
|
|
const antenna = resp.data[1];
|
|
const readData = resp.data.slice(resp.data.length - wordCount * 2);
|
|
return { ...parseEpcData(readData), 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, variant, flags });
|
|
} catch (err) {
|
|
res.status(503).json({ ok: false, error: err.message, variant });
|
|
}
|
|
});
|
|
|
|
app.get('/inventory', (req, res) => {
|
|
withSerial(async () => {
|
|
const startedAt = Date.now();
|
|
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);
|
|
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;
|
|
if (found) logger.event('inventory_ok', { epc: epcBytes ? epcBytes.toString('hex').toUpperCase() : null, durationMs: Date.now() - startedAt });
|
|
else logger.event('inventory_failed', { status: resp.status, durationMs: Date.now() - startedAt });
|
|
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) {
|
|
sendStop();
|
|
logger.event('inventory_failed', { error: err.message, durationMs: Date.now() - startedAt });
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/clear-mask', (req, res) => {
|
|
withSerial(async () => {
|
|
try {
|
|
await ensureConnected();
|
|
await sendStop();
|
|
await clearMask();
|
|
res.json({ ok: true, message: 'SELECTMASK cleared' });
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/recover', (req, res) => {
|
|
withSerial(async () => {
|
|
try {
|
|
await ensureConnected();
|
|
logger.event('recover_attempted');
|
|
await autoConfigureAnswerMode();
|
|
await clearMask();
|
|
const pingFrame = buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0));
|
|
try {
|
|
await sendCommand(pingFrame, 2000);
|
|
res.json({ ok: true, message: 'Reader recovered — try again.' });
|
|
} catch (_) {
|
|
readerOnline = false;
|
|
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) {
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/unlock-tag', (req, res) => {
|
|
withSerial(async () => {
|
|
try {
|
|
await ensureConnected();
|
|
await unlockEpcBank();
|
|
res.json({ ok: true, message: 'EPC bank unlocked' });
|
|
} catch (err) {
|
|
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;
|
|
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);
|
|
res.json({ ok: true, sku: written, releaseId: releaseId || null });
|
|
} catch (err) {
|
|
logger.event('write_failed', { sku, releaseId, error: err.message });
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get('/read-tag', (req, res) => {
|
|
withSerial(async () => {
|
|
const startedAt = Date.now();
|
|
try {
|
|
await ensureConnected();
|
|
const { sku, releaseId, rssi, antenna } = await readSkuFromTag();
|
|
logger.event('read_ok', { sku, releaseId, rssi, durationMs: Date.now() - startedAt });
|
|
res.json({ ok: true, sku, releaseId, rssi, antenna });
|
|
} catch (err) {
|
|
logger.event('read_failed', { error: err.message, durationMs: Date.now() - startedAt });
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
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')}`;
|
|
res.json({ ok: true, chip, tid: hexDump(tidBytes) });
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get('/get-config', (req, res) => {
|
|
withSerial(async () => {
|
|
try {
|
|
await ensureConnected();
|
|
const resp = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 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' };
|
|
res.json({
|
|
ok: true, raw: hexDump(d),
|
|
addr: d[0], rfidPro: d[1],
|
|
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], qValue: d[6], session: d[7], inquiryArea: d[8],
|
|
acsAddr: d[9], acsDataLen: d[10], filterTime: d[11], triggerTime: d[12],
|
|
rfidPower: d[13], buzzerTime: d[14], pollingInterval: d[15],
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
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 getResp = await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 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 setResp = await sendCommand(buildFrame(CMD_SET_PARAM, params), 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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get('/device-info', (req, res) => {
|
|
withSerial(async () => {
|
|
try {
|
|
await ensureConnected();
|
|
const resp = await sendCommand(buildFrame(CMD_DEVICE_INFO, Buffer.alloc(0)), 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;
|
|
res.json({ ok: true, raw: hexDump(d) });
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.get('/diag-summary', (req, res) => {
|
|
res.json({ ok: true, summary: logger.summary() });
|
|
});
|
|
|
|
app.post('/shutdown', (req, res) => {
|
|
res.json({ ok: true, message: 'Daemon shutting down' });
|
|
console.log(`[rfid:${variant}] shutdown requested — exiting`);
|
|
setTimeout(() => { logger.close(); process.exit(0); }, 200);
|
|
});
|
|
|
|
// ── Inventory lookup via SSH tunnel (unchanged from index.js) ───────────
|
|
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 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
|
|
}))
|
|
});
|
|
} catch (e) {
|
|
console.error(`[rfid:${variant}][inventory-lookup]`, e.message);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── Keepalive ────────────────────────────────────────────────────────────
|
|
let keepaliveTimer = null;
|
|
if (KEEPALIVE_MS > 0) {
|
|
keepaliveTimer = setInterval(() => {
|
|
const since = Date.now() - lastTxAt;
|
|
if (since < KEEPALIVE_MS - 500) return; // recent activity, skip
|
|
withSerial(async () => {
|
|
if (!serial?.isOpen) return;
|
|
try {
|
|
await sendCommand(buildFrame(CMD_GET_PARAM, Buffer.alloc(0)), 1500);
|
|
logger.event('keepalive_sent', { ok: true });
|
|
} catch (err) {
|
|
logger.event('keepalive_sent', { ok: false, reason: err.message });
|
|
}
|
|
});
|
|
}, KEEPALIVE_MS);
|
|
keepaliveTimer.unref?.();
|
|
}
|
|
|
|
// ── Graceful shutdown ────────────────────────────────────────────────────
|
|
function gracefulExit(signal) {
|
|
console.log(`[rfid:${variant}] ${signal} received — flushing log and exiting`);
|
|
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
logger.close();
|
|
setTimeout(() => process.exit(0), 100);
|
|
}
|
|
process.on('SIGINT', () => gracefulExit('SIGINT'));
|
|
process.on('SIGTERM', () => gracefulExit('SIGTERM'));
|
|
|
|
app.listen(HTTP_PORT, '0.0.0.0', () => {
|
|
console.log(`[rfid:${variant}] daemon listening on http://0.0.0.0:${HTTP_PORT}`);
|
|
if (IS_ULTRA) console.log(`[rfid:${variant}] Running LOCALLY on ${HOSTNAME}.`);
|
|
else console.log(`[rfid:${variant}] Running REMOTELY on ${HOSTNAME}.`);
|
|
console.log(`[rfid:${variant}] endpoints: GET /status /inventory /read-tag /read-tid /get-config /device-info /diag-summary POST /write-tag /set-workmode /clear-mask /unlock-tag /recover /shutdown`);
|
|
});
|
|
}
|
|
|
|
module.exports = { startDaemon };
|