#!/usr/bin/env node
|
|
const fs = require('fs');
|
const path = require('path');
|
const net = require('net');
|
const {
|
FRAME_LENGTH,
|
JhmDecoder,
|
additiveChecksum,
|
} = require('./decoder');
|
|
const DEFAULT_FRAMES_FILE = path.join(__dirname, '数据模拟.md');
|
const DEFAULT_INTERVAL_MS = 1000;
|
const DEFAULT_AL_MODEL_FILE = path.join(__dirname, 'alModel.json');
|
const BUILTIN_SCENARIOS = {
|
jihua20260414: {
|
name: '2026-04-14 真实业务抓包',
|
description: '按历史抓包循环回放透析机固定帧数据。',
|
framesFile: path.join(__dirname, 'jihua20260414.txt'),
|
cycleLength: 17,
|
},
|
};
|
|
function parseArgs(argv) {
|
const options = {
|
host: '127.0.0.1',
|
port: 9000,
|
intervalMs: DEFAULT_INTERVAL_MS,
|
framesFile: DEFAULT_FRAMES_FILE,
|
scenario: '',
|
repeat: 0,
|
localAddress: '',
|
mode: 'file',
|
bpSystolic: 120,
|
bpDiastolic: 80,
|
bpPulse: 89,
|
bpTime: '',
|
bpIncludeTime: true,
|
};
|
|
for (let index = 2; index < argv.length; index += 1) {
|
const arg = argv[index];
|
const value = argv[index + 1];
|
|
if (arg === '--host' && value) {
|
options.host = value;
|
index += 1;
|
} else if (arg === '--port' && value) {
|
options.port = Number(value);
|
index += 1;
|
} else if (arg === '--interval' && value) {
|
options.intervalMs = Number(value);
|
index += 1;
|
} else if (arg === '--frames' && value) {
|
options.framesFile = path.resolve(value);
|
index += 1;
|
} else if (arg === '--scenario' && value) {
|
options.scenario = value.trim();
|
index += 1;
|
} else if (arg === '--repeat' && value) {
|
options.repeat = Number(value);
|
index += 1;
|
} else if (arg === '--local-address' && value) {
|
options.localAddress = value;
|
index += 1;
|
} else if (arg === '--mode' && value) {
|
options.mode = value.trim().toLowerCase();
|
index += 1;
|
} else if (arg === '--blood-pressure' || arg === '--bp') {
|
options.mode = 'blood-pressure';
|
} else if (arg === '--bp-systolic' && value) {
|
options.bpSystolic = Number(value);
|
index += 1;
|
} else if (arg === '--bp-diastolic' && value) {
|
options.bpDiastolic = Number(value);
|
index += 1;
|
} else if (arg === '--bp-pulse' && value) {
|
options.bpPulse = Number(value);
|
index += 1;
|
} else if (arg === '--bp-time' && value) {
|
options.bpTime = value.trim();
|
index += 1;
|
} else if (arg === '--bp-no-time') {
|
options.bpIncludeTime = false;
|
} else if (arg === '--help' || arg === '-h') {
|
options.help = true;
|
}
|
}
|
|
return options;
|
}
|
|
function printHelp() {
|
console.log(`JHM TCP simulator
|
|
Usage:
|
node tcp-simulator.js --host 127.0.0.1 --port 9000
|
node tcp-simulator.js --scenario jihua20260414 --host 127.0.0.1 --port 9000 --repeat 1
|
node tcp-simulator.js --bp --host 127.0.0.1 --port 9000 --bp-systolic 120 --bp-diastolic 80 --bp-pulse 89
|
node tcp-simulator.js --bp --host 127.0.0.1 --port 9000 --bp-time "2026-04-15 09:30"
|
|
Options:
|
--host <ip> target TCP server, default 127.0.0.1
|
--port <number> target TCP port, default 9000
|
--interval <ms> interval between frames, default 1000ms
|
--frames <path> hex frame file, default ./数据模拟.md
|
--scenario <name> built-in scenario: ${Object.keys(BUILTIN_SCENARIOS).join(', ')}
|
--repeat <number> send rounds, 0 means infinite loop, default 0
|
--local-address <ip> bind local IP to simulate source device IP
|
--mode <file|blood-pressure>
|
--blood-pressure, --bp shorthand for --mode blood-pressure
|
--bp-systolic <n> blood pressure systolic value, default 120
|
--bp-diastolic <n> blood pressure diastolic value, default 80
|
--bp-pulse <n> blood pressure pulse value, default 89
|
--bp-time "<text>" custom time, format YYYY-MM-DD HH:mm, default current local time
|
--bp-no-time send zeroed time bytes
|
--help, -h show help
|
`);
|
}
|
|
function resolveScenario(options) {
|
if (!options.scenario) {
|
return { ...options, scenarioConfig: null };
|
}
|
|
const scenarioConfig = BUILTIN_SCENARIOS[options.scenario];
|
|
if (!scenarioConfig) {
|
throw new Error(`unknown scenario: ${options.scenario}`);
|
}
|
|
return {
|
...options,
|
mode: 'file',
|
framesFile: scenarioConfig.framesFile,
|
scenarioConfig,
|
};
|
}
|
|
function validateOptions(options) {
|
if (!options.host) {
|
throw new Error('host is required');
|
}
|
|
if (!Number.isInteger(options.port) || options.port <= 0) {
|
throw new Error('port must be a positive integer');
|
}
|
|
if (!Number.isInteger(options.intervalMs) || options.intervalMs <= 0) {
|
throw new Error('interval must be a positive integer in ms');
|
}
|
|
if (!Number.isInteger(options.repeat) || options.repeat < 0) {
|
throw new Error('repeat must be an integer >= 0');
|
}
|
|
if (!['file', 'blood-pressure'].includes(options.mode)) {
|
throw new Error(`unsupported mode: ${options.mode}`);
|
}
|
|
if (options.mode === 'file' && !fs.existsSync(options.framesFile)) {
|
throw new Error(`frames file not found: ${options.framesFile}`);
|
}
|
|
if (options.mode === 'blood-pressure') {
|
for (const [name, value] of [
|
['bp-systolic', options.bpSystolic],
|
['bp-diastolic', options.bpDiastolic],
|
['bp-pulse', options.bpPulse],
|
]) {
|
if (!Number.isInteger(value) || value < 0 || value > 0xFFFF) {
|
throw new Error(`${name} must be an integer between 0 and 65535`);
|
}
|
}
|
|
if (options.bpIncludeTime && options.bpTime) {
|
parseBloodPressureTimeText(options.bpTime);
|
}
|
}
|
}
|
|
function bufferToHex(buffer) {
|
return Array.from(buffer, (value) => value.toString(16).toUpperCase().padStart(2, '0')).join(' ');
|
}
|
|
function parseHexLine(line) {
|
const normalized = line.replace(/0x/gi, ' ').replace(/[^a-fA-F0-9]/g, ' ');
|
const hexPairs = normalized.split(/\s+/).filter(Boolean);
|
|
if (hexPairs.length === 0) {
|
return null;
|
}
|
|
return Buffer.from(hexPairs.map((pair) => {
|
if (pair.length > 2) {
|
throw new Error(`invalid byte: ${pair}`);
|
}
|
|
return Number.parseInt(pair, 16);
|
}));
|
}
|
|
function loadFramesByLines(content) {
|
const lines = content.split(/\r?\n/);
|
const frames = [];
|
const framePattern = /^(?:0x)?[0-9a-fA-F]{2}(?:\s+(?:0x)?[0-9a-fA-F]{2})+$/;
|
|
for (const line of lines) {
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('```') || trimmed.startsWith('#')) {
|
continue;
|
}
|
|
if (!framePattern.test(trimmed)) {
|
continue;
|
}
|
|
const frame = parseHexLine(trimmed);
|
|
if (frame && frame.length > 0) {
|
frames.push({ raw: trimmed, buffer: frame });
|
}
|
}
|
|
return frames;
|
}
|
|
function loadFramesFromContinuousHex(content) {
|
const bytes = (content.match(/[0-9a-fA-F]{2}/g) || []).map((pair) => Number.parseInt(pair, 16));
|
const frames = [];
|
|
for (let index = 0; index <= bytes.length - FRAME_LENGTH;) {
|
if (bytes[index] === 0xEE && bytes[index + 1] === 0x55) {
|
const buffer = Buffer.from(bytes.slice(index, index + FRAME_LENGTH));
|
frames.push({ raw: bufferToHex(buffer), buffer });
|
index += FRAME_LENGTH;
|
continue;
|
}
|
|
index += 1;
|
}
|
|
return frames;
|
}
|
|
function loadFrames(framesFile) {
|
const content = fs.readFileSync(framesFile, 'utf8');
|
const lineFrames = loadFramesByLines(content);
|
|
if (lineFrames.length > 0) {
|
return {
|
frames: lineFrames,
|
mode: 'line',
|
};
|
}
|
|
const continuousFrames = loadFramesFromContinuousHex(content);
|
|
if (continuousFrames.length > 0) {
|
return {
|
frames: continuousFrames,
|
mode: 'continuous',
|
};
|
}
|
|
throw new Error('no sendable hex frames found in frames file');
|
}
|
|
function analyzeFrames(frames, scenarioConfig) {
|
const decoder = new JhmDecoder({ alModelPath: DEFAULT_AL_MODEL_FILE });
|
const analysis = {
|
totalFrames: frames.length,
|
publishableFrames: 0,
|
unsupportedFrames: 0,
|
snapshot: {},
|
stateTransitions: [],
|
fullCycles: 0,
|
extraFrames: 0,
|
};
|
let lastState;
|
|
for (let index = 0; index < frames.length; index += 1) {
|
const results = decoder.push(frames[index].buffer);
|
|
for (const result of results) {
|
if (result.publish) {
|
analysis.publishableFrames += 1;
|
|
for (const [identifier, value] of Object.entries(result.metric || {})) {
|
if (!(identifier in analysis.snapshot)) {
|
analysis.snapshot[identifier] = value;
|
}
|
}
|
|
if (Object.prototype.hasOwnProperty.call(result.metric || {}, 'o') && result.metric.o !== lastState) {
|
analysis.stateTransitions.push({
|
frameIndex: index + 1,
|
value: result.metric.o,
|
});
|
lastState = result.metric.o;
|
}
|
}
|
|
if (result.reason === 'unsupported-command') {
|
analysis.unsupportedFrames += 1;
|
}
|
}
|
}
|
|
if (scenarioConfig && scenarioConfig.cycleLength) {
|
analysis.fullCycles = Math.floor(frames.length / scenarioConfig.cycleLength);
|
analysis.extraFrames = frames.length % scenarioConfig.cycleLength;
|
}
|
|
return analysis;
|
}
|
|
function formatSnapshot(snapshot) {
|
const orderedKeys = ['F', 'A', 'sysj', 'C', 'B', 'L', 'D', 'H', 'o', 'J', 'U', 'G', 'Na', 'HCO3', 'N', 'O', 'P', 'M'];
|
|
return orderedKeys
|
.filter((key) => snapshot[key] !== undefined)
|
.map((key) => `${key}=${snapshot[key]}`)
|
.join(', ');
|
}
|
|
function parseBloodPressureTimeText(value) {
|
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/.exec(value);
|
|
if (!match) {
|
throw new Error(`invalid --bp-time format: ${value}. Expected YYYY-MM-DD HH:mm`);
|
}
|
|
const [, year, month, day, hour, minute] = match;
|
|
return [
|
Number(year) % 100,
|
Number(month),
|
Number(day),
|
Number(hour),
|
Number(minute),
|
];
|
}
|
|
function getCurrentBloodPressureTimeBytes() {
|
const now = new Date();
|
|
return [
|
now.getFullYear() % 100,
|
now.getMonth() + 1,
|
now.getDate(),
|
now.getHours(),
|
now.getMinutes(),
|
];
|
}
|
|
function buildBloodPressureFrame(options) {
|
const timeBytes = options.bpIncludeTime
|
? (options.bpTime ? parseBloodPressureTimeText(options.bpTime) : getCurrentBloodPressureTimeBytes())
|
: [0, 0, 0, 0, 0];
|
const frameWithoutChecksum = Buffer.from([
|
0xAA,
|
0x55,
|
0x0E,
|
0xBA,
|
(options.bpSystolic >> 8) & 0xFF,
|
options.bpSystolic & 0xFF,
|
options.bpDiastolic & 0xFF,
|
options.bpPulse & 0xFF,
|
...timeBytes,
|
]);
|
const checksumByte = additiveChecksum(frameWithoutChecksum);
|
const buffer = Buffer.concat([frameWithoutChecksum, Buffer.from([checksumByte])]);
|
|
return {
|
raw: bufferToHex(buffer),
|
buffer,
|
};
|
}
|
|
function prepareSimulation(options) {
|
if (options.mode === 'blood-pressure') {
|
const frame = buildBloodPressureFrame(options);
|
const analysis = analyzeFrames([frame], null);
|
|
return {
|
frames: [frame],
|
analysis,
|
loadMode: 'blood-pressure',
|
scenarioConfig: null,
|
};
|
}
|
|
const loadResult = loadFrames(options.framesFile);
|
const analysis = analyzeFrames(loadResult.frames, options.scenarioConfig);
|
|
return {
|
frames: loadResult.frames,
|
analysis,
|
loadMode: loadResult.mode,
|
scenarioConfig: options.scenarioConfig,
|
};
|
}
|
|
function startClient(options, prepared) {
|
const socketOptions = {
|
host: options.host,
|
port: options.port,
|
};
|
|
if (options.localAddress) {
|
socketOptions.localAddress = options.localAddress;
|
}
|
|
const socket = net.createConnection(socketOptions);
|
let timer = null;
|
let frameIndex = 0;
|
let round = 0;
|
let stopped = false;
|
|
function stop(reason) {
|
if (stopped) {
|
return;
|
}
|
|
stopped = true;
|
|
if (timer) {
|
clearInterval(timer);
|
timer = null;
|
}
|
|
if (!socket.destroyed) {
|
socket.end();
|
socket.destroy();
|
}
|
|
if (reason) {
|
console.log(reason);
|
}
|
}
|
|
function sendNextFrame() {
|
const frame = prepared.frames[frameIndex];
|
socket.write(frame.buffer);
|
console.log(`[SIM] sent round=${round + 1} frame=${frameIndex + 1} -> ${frame.raw}`);
|
|
frameIndex += 1;
|
|
if (frameIndex >= prepared.frames.length) {
|
frameIndex = 0;
|
round += 1;
|
|
if (options.repeat > 0 && round >= options.repeat) {
|
stop(`[SIM] finished ${options.repeat} round(s)`);
|
}
|
}
|
}
|
|
socket.on('connect', () => {
|
console.log(`[SIM] connected to ${options.host}:${options.port}`);
|
|
if (options.localAddress) {
|
console.log(`[SIM] local bind address ${options.localAddress}`);
|
}
|
|
if (prepared.scenarioConfig) {
|
console.log(`[SIM] scenario ${options.scenario} -> ${prepared.scenarioConfig.name}`);
|
console.log(`[SIM] scenario description: ${prepared.scenarioConfig.description}`);
|
} else if (options.mode === 'blood-pressure') {
|
console.log(`[SIM] mode blood-pressure systolic=${options.bpSystolic} diastolic=${options.bpDiastolic} pulse=${options.bpPulse}`);
|
console.log(`[SIM] blood-pressure time ${options.bpIncludeTime ? (options.bpTime || 'current local time') : 'disabled / zero bytes'}`);
|
} else {
|
console.log(`[SIM] loaded frames file ${path.basename(options.framesFile)}`);
|
}
|
|
console.log(`[SIM] data mode ${prepared.loadMode} frames=${prepared.analysis.totalFrames} publishable=${prepared.analysis.publishableFrames} unsupported=${prepared.analysis.unsupportedFrames}`);
|
|
if (prepared.analysis.fullCycles > 0 || prepared.analysis.extraFrames > 0) {
|
console.log(`[SIM] scenario cycles fullCycles=${prepared.analysis.fullCycles} extraFrames=${prepared.analysis.extraFrames}`);
|
}
|
|
if (prepared.analysis.stateTransitions.length > 0) {
|
const transitionText = prepared.analysis.stateTransitions
|
.map((item) => `frame#${item.frameIndex}:o=${item.value}`)
|
.join(' -> ');
|
console.log(`[SIM] state transitions ${transitionText}`);
|
}
|
|
if (Object.keys(prepared.analysis.snapshot).length > 0) {
|
console.log(`[SIM] snapshot ${formatSnapshot(prepared.analysis.snapshot)}`);
|
}
|
|
sendNextFrame();
|
timer = setInterval(sendNextFrame, options.intervalMs);
|
});
|
|
socket.on('error', (error) => {
|
stop(`[SIM] connection error: ${error.message}`);
|
process.exitCode = 1;
|
});
|
|
socket.on('close', () => {
|
if (!stopped) {
|
stop('[SIM] connection closed');
|
}
|
});
|
|
process.on('SIGINT', () => {
|
stop('[SIM] received SIGINT, stopping');
|
process.exit(0);
|
});
|
|
process.on('SIGTERM', () => {
|
stop('[SIM] received SIGTERM, stopping');
|
process.exit(0);
|
});
|
}
|
|
function main() {
|
const parsedOptions = parseArgs(process.argv);
|
|
if (parsedOptions.help) {
|
printHelp();
|
return;
|
}
|
|
const options = resolveScenario(parsedOptions);
|
validateOptions(options);
|
const prepared = prepareSimulation(options);
|
startClient(options, prepared);
|
}
|
|
if (require.main === module) {
|
main();
|
}
|
|
module.exports = {
|
BUILTIN_SCENARIOS,
|
buildBloodPressureFrame,
|
getCurrentBloodPressureTimeBytes,
|
parseArgs,
|
parseBloodPressureTimeText,
|
prepareSimulation,
|
};
|