#!/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 <name> Serial port name, e.g. COM3
|
--baudRate <number> Baud rate, default 4800
|
--dataBits <number> Data bits, default 8
|
--stopBits <number> Stop bits, default 1
|
--parity <value> none | even | odd, default none
|
--raw Print raw incoming bytes in hex
|
--no-timestamp Omit timestamp in JSON output
|
--simulate <hex> 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,
|
};
|