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