| | |
| | | }, |
| | | }; |
| | | |
| | | // 模拟器支持三种方式:文件回放、单独血压、文件加血压混发。 |
| | | function parseArgs(argv) { |
| | | const options = { |
| | | host: '127.0.0.1', |
| | |
| | | repeat: 0, |
| | | localAddress: '', |
| | | mode: 'file', |
| | | includeBloodPressure: false, |
| | | bpSystolic: 120, |
| | | bpDiastolic: 80, |
| | | bpPulse: 89, |
| | |
| | | } else if (arg === '--mode' && value) { |
| | | options.mode = value.trim().toLowerCase(); |
| | | index += 1; |
| | | } else if (arg === '--with-blood-pressure' || arg === '--with-bp') { |
| | | options.includeBloodPressure = true; |
| | | } else if (arg === '--blood-pressure' || arg === '--bp') { |
| | | options.mode = 'blood-pressure'; |
| | | } else if (arg === '--bp-systolic' && value) { |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM TCP simulator |
| | | console.log(`JHM TCP 模拟客户端 |
| | | |
| | | 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 --host 127.0.0.1 --port 9000 --with-bp |
| | | 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 |
| | | 参数: |
| | | --host <ip> 目标 TCP 服务地址,默认 127.0.0.1 |
| | | --port <number> 目标 TCP 服务端口,默认 9000 |
| | | --interval <ms> 报文发送间隔,默认 1000ms |
| | | --frames <path> 十六进制报文文件,默认 ./数据模拟.md |
| | | --scenario <name> 内置场景:${Object.keys(BUILTIN_SCENARIOS).join(', ')} |
| | | --repeat <number> 发送轮数,0 表示无限循环,默认 0 |
| | | --local-address <ip> 绑定本地 IP,用于模拟设备来源 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 |
| | | --with-blood-pressure, --with-bp |
| | | 在每轮文件或场景回放后追加 1 条血压报文 |
| | | --blood-pressure, --bp 等同于 --mode blood-pressure |
| | | --bp-systolic <n> 收缩压,默认 120 |
| | | --bp-diastolic <n> 舒张压,默认 80 |
| | | --bp-pulse <n> 脉搏,默认 89 |
| | | --bp-time "<text>" 自定义时间,格式 YYYY-MM-DD HH:mm,默认当前本地时间 |
| | | --bp-no-time 发送全 0 时间字节 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | |
| | | const scenarioConfig = BUILTIN_SCENARIOS[options.scenario]; |
| | | |
| | | if (!scenarioConfig) { |
| | | throw new Error(`unknown scenario: ${options.scenario}`); |
| | | throw new Error(`未知场景: ${options.scenario}`); |
| | | } |
| | | |
| | | return { |
| | |
| | | }; |
| | | } |
| | | |
| | | 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') { |
| | | function validateBloodPressureOptions(options) { |
| | | 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`); |
| | | throw new Error(`${name} 必须是 0 到 65535 之间的整数`); |
| | | } |
| | | } |
| | | |
| | | if (options.bpIncludeTime && options.bpTime) { |
| | | parseBloodPressureTimeText(options.bpTime); |
| | | } |
| | | } |
| | | |
| | | function validateOptions(options) { |
| | | if (!options.host) { |
| | | throw new Error('必须指定 host'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.port) || options.port <= 0) { |
| | | throw new Error('port 必须是正整数'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.intervalMs) || options.intervalMs <= 0) { |
| | | throw new Error('interval 必须是大于 0 的毫秒整数'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.repeat) || options.repeat < 0) { |
| | | throw new Error('repeat 必须是大于等于 0 的整数'); |
| | | } |
| | | |
| | | if (!['file', 'blood-pressure'].includes(options.mode)) { |
| | | throw new Error(`不支持的模式: ${options.mode}`); |
| | | } |
| | | |
| | | if (options.mode === 'file' && !fs.existsSync(options.framesFile)) { |
| | | throw new Error(`未找到报文文件: ${options.framesFile}`); |
| | | } |
| | | |
| | | if (options.mode === 'blood-pressure' || options.includeBloodPressure) { |
| | | validateBloodPressureOptions(options); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | return Buffer.from(hexPairs.map((pair) => { |
| | | if (pair.length > 2) { |
| | | throw new Error(`invalid byte: ${pair}`); |
| | | throw new Error(`非法字节: ${pair}`); |
| | | } |
| | | |
| | | return Number.parseInt(pair, 16); |
| | |
| | | }; |
| | | } |
| | | |
| | | throw new Error('no sendable hex frames found in frames file'); |
| | | throw new Error('报文文件中没有找到可发送的十六进制报文'); |
| | | } |
| | | |
| | | function analyzeFrames(frames, scenarioConfig) { |
| | |
| | | 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`); |
| | | throw new Error(`--bp-time 格式不正确: ${value},应为 YYYY-MM-DD HH:mm`); |
| | | } |
| | | |
| | | const [, year, month, day, hour, minute] = match; |
| | |
| | | } |
| | | |
| | | const loadResult = loadFrames(options.framesFile); |
| | | const analysis = analyzeFrames(loadResult.frames, options.scenarioConfig); |
| | | const frames = options.includeBloodPressure |
| | | ? [...loadResult.frames, buildBloodPressureFrame(options)] |
| | | : loadResult.frames; |
| | | const analysis = analyzeFrames(frames, options.scenarioConfig); |
| | | |
| | | return { |
| | | frames: loadResult.frames, |
| | | frames, |
| | | analysis, |
| | | loadMode: loadResult.mode, |
| | | scenarioConfig: options.scenarioConfig, |
| | |
| | | function sendNextFrame() { |
| | | const frame = prepared.frames[frameIndex]; |
| | | socket.write(frame.buffer); |
| | | console.log(`[SIM] sent round=${round + 1} frame=${frameIndex + 1} -> ${frame.raw}`); |
| | | console.log(`[SIM] 已发送 round=${round + 1} frame=${frameIndex + 1} -> ${frame.raw}`); |
| | | |
| | | frameIndex += 1; |
| | | |
| | |
| | | round += 1; |
| | | |
| | | if (options.repeat > 0 && round >= options.repeat) { |
| | | stop(`[SIM] finished ${options.repeat} round(s)`); |
| | | stop(`[SIM] 已完成 ${options.repeat} 轮发送`); |
| | | } |
| | | } |
| | | } |
| | | |
| | | socket.on('connect', () => { |
| | | console.log(`[SIM] connected to ${options.host}:${options.port}`); |
| | | console.log(`[SIM] 已连接到 ${options.host}:${options.port}`); |
| | | |
| | | if (options.localAddress) { |
| | | console.log(`[SIM] local bind address ${options.localAddress}`); |
| | | console.log(`[SIM] 本地绑定地址 ${options.localAddress}`); |
| | | } |
| | | |
| | | if (prepared.scenarioConfig) { |
| | | console.log(`[SIM] scenario ${options.scenario} -> ${prepared.scenarioConfig.name}`); |
| | | console.log(`[SIM] scenario description: ${prepared.scenarioConfig.description}`); |
| | | console.log(`[SIM] 场景 ${options.scenario} -> ${prepared.scenarioConfig.name}`); |
| | | console.log(`[SIM] 场景说明: ${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'}`); |
| | | console.log(`[SIM] 当前模式为血压报文 systolic=${options.bpSystolic} diastolic=${options.bpDiastolic} pulse=${options.bpPulse}`); |
| | | console.log(`[SIM] 血压时间 ${options.bpIncludeTime ? (options.bpTime || '当前本地时间') : '已禁用 / 发送全 0 字节'}`); |
| | | } else { |
| | | console.log(`[SIM] loaded frames file ${path.basename(options.framesFile)}`); |
| | | console.log(`[SIM] 已加载报文文件 ${path.basename(options.framesFile)}`); |
| | | } |
| | | |
| | | console.log(`[SIM] data mode ${prepared.loadMode} frames=${prepared.analysis.totalFrames} publishable=${prepared.analysis.publishableFrames} unsupported=${prepared.analysis.unsupportedFrames}`); |
| | | if (options.mode === 'file' && options.includeBloodPressure) { |
| | | console.log(`[SIM] 每轮追加血压报文 systolic=${options.bpSystolic} diastolic=${options.bpDiastolic} pulse=${options.bpPulse}`); |
| | | console.log(`[SIM] 追加血压时间 ${options.bpIncludeTime ? (options.bpTime || '当前本地时间') : '已禁用 / 发送全 0 字节'}`); |
| | | } |
| | | |
| | | console.log(`[SIM] 数据模式 ${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}`); |
| | | console.log(`[SIM] 场景轮次统计 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}`); |
| | | console.log(`[SIM] 状态变化 ${transitionText}`); |
| | | } |
| | | |
| | | if (Object.keys(prepared.analysis.snapshot).length > 0) { |
| | | console.log(`[SIM] snapshot ${formatSnapshot(prepared.analysis.snapshot)}`); |
| | | console.log(`[SIM] 指标快照 ${formatSnapshot(prepared.analysis.snapshot)}`); |
| | | } |
| | | |
| | | sendNextFrame(); |
| | |
| | | }); |
| | | |
| | | socket.on('error', (error) => { |
| | | stop(`[SIM] connection error: ${error.message}`); |
| | | stop(`[SIM] 连接异常: ${error.message}`); |
| | | process.exitCode = 1; |
| | | }); |
| | | |
| | | socket.on('close', () => { |
| | | if (!stopped) { |
| | | stop('[SIM] connection closed'); |
| | | stop('[SIM] 连接已关闭'); |
| | | } |
| | | }); |
| | | |
| | | process.on('SIGINT', () => { |
| | | stop('[SIM] received SIGINT, stopping'); |
| | | stop('[SIM] 收到 SIGINT,停止发送'); |
| | | process.exit(0); |
| | | }); |
| | | |
| | | process.on('SIGTERM', () => { |
| | | stop('[SIM] received SIGTERM, stopping'); |
| | | stop('[SIM] 收到 SIGTERM,停止发送'); |
| | | process.exit(0); |
| | | }); |
| | | } |