#!/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 target TCP server, default 127.0.0.1 --port target TCP port, default 9000 --interval interval between frames, default 1000ms --frames hex frame file, default ./数据模拟.md --scenario built-in scenario: ${Object.keys(BUILTIN_SCENARIOS).join(', ')} --repeat send rounds, 0 means infinite loop, default 0 --local-address bind local IP to simulate source device IP --mode --blood-pressure, --bp shorthand for --mode blood-pressure --bp-systolic blood pressure systolic value, default 120 --bp-diastolic blood pressure diastolic value, default 80 --bp-pulse blood pressure pulse value, default 89 --bp-time "" 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, };