#!/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 <name> 串口名称,例如 COM3
|
--baudRate <number> 波特率,默认 4800
|
--dataBits <number> 数据位,默认 8
|
--stopBits <number> 停止位,默认 1
|
--parity <value> 校验位:none | even | odd,默认 none
|
--raw 以十六进制打印原始输入
|
--no-timestamp JSON 输出中不带时间戳
|
--simulate <hex> 不打开串口,直接解析一段十六进制字节
|
--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,
|
};
|