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

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

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

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

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 };