#!/usr/bin/env node /** * JHM-2028 串口调试服务。 * * 当前项目正式运行入口是 `app.js`,通过 `tcp-service.js` 接收透传盒子的 TCP 数据。 * 本文件保留为“串口直连调试参考脚本”,用于协议排查、串口联调和模拟数据验证。 * 它不参与当前 TCP + MQTT 的生产主流程。 * * 安装依赖: * npm install serialport * * 串口模式: * node jhm2028-service.js --port COM3 --baudRate 4800 * * 模拟模式: * 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 串口调试服务 用法: node jhm2028-service.js --port COM3 [options] node jhm2028-service.js --simulate "EE 55 01 00 00 01 72 74" 参数: --port 串口名称,例如 COM3 --baudRate 波特率,默认 4800 --dataBits 数据位,默认 8 --stopBits 停止位,默认 1 --parity 校验位:none | even | odd,默认 none --raw 以十六进制打印原始输入 --no-timestamp JSON 输出中不带时间戳 --simulate 不打开串口,直接解析一段十六进制字节 --help, -h 显示帮助 协议格式: 帧格式 = 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: '协议文档示例:0x00000172 => 370 => 37.0°C', }; } case 0x03: { const raw = uint32BE(payload); return { name: 'value_0x03', raw, note: '协议文档说明:数值为 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: '协议文档说明:前两字节和后两字节分别表示独立数值', }; } 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: '协议文档说明:XX1 为符号标志位(0x00/0x01),XX2-XX4 组成数值', }; } 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: '协议文档说明:XX1 固定为 0x00,XX2-XX4 组成的数值需除以 10', }; } default: return { name: `command_${toHex(command)}`, raw: uint32BE(payload), payload: Array.from(payload), note: '原始协议文档语义不够清晰,先保留原始数据', }; } } 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 = '校验和不匹配'; } 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('模拟输入中未找到十六进制字节'); } const values = hexPairs.map((pair) => { if (pair.length > 2) { throw new Error(`非法字节: ${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('缺少串口参数,例如:node jhm2028-service.js --port COM3'); } if (![5, 6, 7, 8].includes(options.dataBits)) { throw new Error('dataBits 只能是 5、6、7、8 之一'); } if (![1, 1.5, 2].includes(options.stopBits)) { throw new Error('stopBits 只能是 1、1.5、2 之一'); } if (!['none', 'even', 'odd', 'mark', 'space'].includes(options.parity)) { throw new Error('parity 只能是 none、even、odd、mark、space 之一'); } if (!Number.isInteger(options.baudRate) || options.baudRate <= 0) { throw new Error('baudRate 必须是正整数'); } } function printJson(frame) { console.log(JSON.stringify(frame, null, 2)); } function printStartup(options) { console.log('JHM-2028 串口调试服务已启动'); console.log(`串口: ${options.port}`); console.log(`配置: ${options.baudRate} 波特率,${options.dataBits} 数据位,${options.stopBits} 停止位,parity=${options.parity}`); console.log(`帧格式: ${toHex(HEADER_1)} ${toHex(HEADER_2)} NN XX1 XX2 XX3 XX4 CY`); console.log('正在等待数据...'); } function runSimulation(options) { const parser = new Jhm2028Parser({ includeTimestamp: options.timestamp, onRawChunk: options.raw ? (chunk) => console.log(`[原始数据] ${bytesToHex(chunk)}`) : null, onFrame: printJson, }); const buffer = parseHexInput(options.simulate); parser.push(buffer); if (buffer.length % FRAME_LENGTH !== 0) { console.error(`警告:模拟输入长度为 ${buffer.length} 字节,不是 ${FRAME_LENGTH} 的整数倍`); } } function closePort(port) { if (!port || !port.isOpen) { process.exit(0); return; } console.log('\n正在关闭串口...'); port.close((error) => { if (error) { console.error('关闭串口失败:', 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(`[原始数据] ${bytesToHex(chunk)}`) : null, onFrame: printJson, }); port.on('open', () => { printStartup(options); }); port.on('data', (chunk) => { parser.push(chunk); }); port.on('error', (error) => { console.error('串口异常:', error.message); }); port.on('close', () => { console.log('串口已关闭'); }); process.on('SIGINT', () => { closePort(port); }); process.on('SIGTERM', () => { closePort(port); }); port.open((error) => { if (error) { console.error('打开串口失败:', 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('可使用 --help 查看可用参数。'); process.exit(1); } } if (require.main === module) { main(); } module.exports = { Jhm2028Parser, buildParsedFrame, checksum, decodePayload, parseHexInput, uint16BE, uint32BE, };