#!/usr/bin/env node /** * JHM-2028 serial communication service. * * 当前项目正式运行入口是 `app.js`,通过 `tcp-service.js` 接收透传盒子的 TCP 数据。 * 本文件保留为“串口直连调试参考脚本”,用于协议排查、串口联调和模拟数据验证。 * 它不参与当前 TCP + MQTT 的生产主流程。 * * Install: * npm install serialport * * Serial mode: * node jhm2028-service.js --port COM3 --baudRate 4800 * * Simulation mode: * node jhm2028-service.js --simulate "EE 55 01 00 00 01 72 74" */ let SerialPort; const HEADER_1 = 0xEE; const HEADER_2 = 0x55; const FRAME_LENGTH = 8; const DEFAULT_OPTIONS = { port: process.env.SERIAL_PORT || '', baudRate: Number(process.env.SERIAL_BAUD_RATE || 4800), dataBits: Number(process.env.SERIAL_DATA_BITS || 8), stopBits: Number(process.env.SERIAL_STOP_BITS || 1), parity: process.env.SERIAL_PARITY || 'none', raw: false, timestamp: true, simulate: '', }; function loadSerialPort() { if (!SerialPort) { ({ SerialPort } = require('serialport')); } return SerialPort; } function parseArgs(argv) { const options = { ...DEFAULT_OPTIONS }; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; const value = argv[i + 1]; if (arg === '--port' && value) { options.port = value; i += 1; } else if (arg === '--baudRate' && value) { options.baudRate = Number(value); i += 1; } else if (arg === '--dataBits' && value) { options.dataBits = Number(value); i += 1; } else if (arg === '--stopBits' && value) { options.stopBits = Number(value); i += 1; } else if (arg === '--parity' && value) { options.parity = value; i += 1; } else if (arg === '--simulate' && value) { options.simulate = value; i += 1; } else if (arg === '--raw') { options.raw = true; } else if (arg === '--no-timestamp') { options.timestamp = false; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); } } return options; } function printHelp() { console.log(`JHM-2028 Node.js serial communication service Usage: node jhm2028-service.js --port COM3 [options] node jhm2028-service.js --simulate "EE 55 01 00 00 01 72 74" Options: --port Serial port name, e.g. COM3 --baudRate Baud rate, default 4800 --dataBits Data bits, default 8 --stopBits Stop bits, default 1 --parity none | even | odd, default none --raw Print raw incoming bytes in hex --no-timestamp Omit timestamp in JSON output --simulate Parse one or more hex bytes without opening a serial port --help, -h Show this help Protocol: Frame = EE 55 NN XX1 XX2 XX3 XX4 CY CY = (NN + XX1 + XX2 + XX3 + XX4) & 0xFF `); } function toHex(value) { return `0x${value.toString(16).toUpperCase().padStart(2, '0')}`; } function bytesToHex(buffer) { return Array.from(buffer, toHex).join(' '); } function checksum(command, payload) { return (command + payload[0] + payload[1] + payload[2] + payload[3]) & 0xFF; } function uint32BE(bytes) { return ((bytes[0] * 0x1000000) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3]) >>> 0; } function uint16BE(high, low) { return ((high << 8) | low) >>> 0; } function decodePayload(command, payload) { switch (command) { case 0x01: { const raw = uint32BE(payload); return { name: 'temperature', raw, value: raw / 10, unit: '°C', note: 'PDF example shows 0x00000172 => 370 => 37.0°C', }; } case 0x03: { const raw = uint32BE(payload); return { name: 'value_0x03', raw, note: 'PDF indicates XX1<<24 + XX2<<16 + XX3<<8 + XX4', }; } case 0x04: { const value1 = uint16BE(payload[0], payload[1]); const value2 = uint16BE(payload[2], payload[3]); return { name: 'dual_uint16_0x04', value1, value2, note: 'PDF indicates first two bytes and last two bytes are separate values', }; } case 0x08: { const raw = uint32BE(payload); return { name: 'flow_rate', raw, unit: 'ml/min', }; } case 0x09: { const mode = payload[0]; const raw = uint32BE([0x00, payload[1], payload[2], payload[3]]); return { name: 'stateful_value_0x09', mode, raw, note: 'PDF indicates XX1 is a flag (0x00/0x01) and XX2-XX4 compose the value', }; } case 0x0C: { const raw = uint32BE(payload); return { name: 'volume', raw, unit: 'ml', }; } case 0x0D: { const flag = payload[0]; const raw = uint32BE([0x00, payload[1], payload[2], payload[3]]); return { name: 'scaled_value_0x0D', flag, raw, value: raw / 10, note: 'PDF indicates XX1 is 0x00 and XX2-XX4 value should be divided by 10', }; } default: return { name: `command_${toHex(command)}`, raw: uint32BE(payload), payload: Array.from(payload), note: 'Meaning not fully legible in source PDF; raw data preserved', }; } } function buildParsedFrame(frame, includeTimestamp) { const command = frame[2]; const payload = frame.slice(3, 7); const receivedChecksum = frame[7]; const expectedChecksum = checksum(command, payload); const valid = receivedChecksum === expectedChecksum; const parsed = { rawHex: bytesToHex(frame), frameLength: frame.length, header: [toHex(frame[0]), toHex(frame[1])], command, commandHex: toHex(command), payloadHex: bytesToHex(payload), payload: Array.from(payload), checksum: { received: receivedChecksum, receivedHex: toHex(receivedChecksum), expected: expectedChecksum, expectedHex: toHex(expectedChecksum), valid, }, }; if (includeTimestamp) { parsed.timestamp = new Date().toISOString(); } if (valid) { parsed.decoded = decodePayload(command, payload); } else { parsed.error = 'Checksum mismatch'; } return parsed; } function parseHexInput(input) { const normalized = input.replace(/0x/gi, ' ').replace(/[^a-fA-F0-9]/g, ' '); const hexPairs = normalized.split(/\s+/).filter(Boolean); if (hexPairs.length === 0) { throw new Error('No hex bytes found in simulate input'); } const values = hexPairs.map((pair) => { if (pair.length > 2) { throw new Error(`Invalid byte: ${pair}`); } return Number.parseInt(pair, 16); }); return Buffer.from(values); } class Jhm2028Parser { constructor(options) { this.onFrame = options.onFrame; this.onRawChunk = options.onRawChunk || null; this.includeTimestamp = options.includeTimestamp !== false; this.buffer = Buffer.alloc(0); } push(chunk) { if (!Buffer.isBuffer(chunk) || chunk.length === 0) { return; } if (this.onRawChunk) { this.onRawChunk(chunk); } this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= FRAME_LENGTH) { const headerIndex = this.findHeader(); if (headerIndex === -1) { this.buffer = this.buffer.slice(Math.max(0, this.buffer.length - 1)); return; } if (headerIndex > 0) { this.buffer = this.buffer.slice(headerIndex); } if (this.buffer.length < FRAME_LENGTH) { return; } const frame = this.buffer.slice(0, FRAME_LENGTH); this.buffer = this.buffer.slice(FRAME_LENGTH); this.onFrame(buildParsedFrame(frame, this.includeTimestamp)); } } findHeader() { for (let i = 0; i <= this.buffer.length - 2; i += 1) { if (this.buffer[i] === HEADER_1 && this.buffer[i + 1] === HEADER_2) { return i; } } return -1; } } function validateOptions(options) { if (!options.simulate && !options.port) { throw new Error('Missing serial port. Example: node jhm2028-service.js --port COM3'); } if (![5, 6, 7, 8].includes(options.dataBits)) { throw new Error('dataBits must be one of 5, 6, 7, 8'); } if (![1, 1.5, 2].includes(options.stopBits)) { throw new Error('stopBits must be one of 1, 1.5, 2'); } if (!['none', 'even', 'odd', 'mark', 'space'].includes(options.parity)) { throw new Error('parity must be one of none, even, odd, mark, space'); } if (!Number.isInteger(options.baudRate) || options.baudRate <= 0) { throw new Error('baudRate must be a positive integer'); } } function printJson(frame) { console.log(JSON.stringify(frame, null, 2)); } function printStartup(options) { console.log('JHM-2028 serial service started'); console.log(`Port: ${options.port}`); console.log(`Config: ${options.baudRate} baud, ${options.dataBits} data bits, ${options.stopBits} stop bit(s), parity=${options.parity}`); console.log(`Frame format: ${toHex(HEADER_1)} ${toHex(HEADER_2)} NN XX1 XX2 XX3 XX4 CY`); console.log('Waiting for data...'); } function runSimulation(options) { const parser = new Jhm2028Parser({ includeTimestamp: options.timestamp, onRawChunk: options.raw ? (chunk) => console.log(`[raw] ${bytesToHex(chunk)}`) : null, onFrame: printJson, }); const buffer = parseHexInput(options.simulate); parser.push(buffer); if (buffer.length % FRAME_LENGTH !== 0) { console.error(`Warning: simulate input length is ${buffer.length} bytes, not a multiple of ${FRAME_LENGTH}`); } } function closePort(port) { if (!port || !port.isOpen) { process.exit(0); return; } console.log('\nClosing serial port...'); port.close((error) => { if (error) { console.error('Failed to close serial port:', error.message); process.exit(1); return; } process.exit(0); }); } function runSerialService(options) { const SerialPortClass = loadSerialPort(); const port = new SerialPortClass({ path: options.port, baudRate: options.baudRate, dataBits: options.dataBits, stopBits: options.stopBits, parity: options.parity, autoOpen: false, }); const parser = new Jhm2028Parser({ includeTimestamp: options.timestamp, onRawChunk: options.raw ? (chunk) => console.log(`[raw] ${bytesToHex(chunk)}`) : null, onFrame: printJson, }); port.on('open', () => { printStartup(options); }); port.on('data', (chunk) => { parser.push(chunk); }); port.on('error', (error) => { console.error('Serial port error:', error.message); }); port.on('close', () => { console.log('Serial port closed'); }); process.on('SIGINT', () => { closePort(port); }); process.on('SIGTERM', () => { closePort(port); }); port.open((error) => { if (error) { console.error('Failed to open serial port:', error.message); process.exit(1); } }); } function main() { try { const options = parseArgs(process.argv); validateOptions(options); if (options.simulate) { runSimulation(options); return; } runSerialService(options); } catch (error) { console.error(error.message); console.error('Use --help to see available options.'); process.exit(1); } } if (require.main === module) { main(); } module.exports = { Jhm2028Parser, buildParsedFrame, checksum, decodePayload, parseHexInput, uint16BE, uint32BE, };