33个文件已添加
1个文件已删除
15个文件已修改
| | |
| | | const normalized = this.normalizeEventPayload(errorLike); |
| | | |
| | | if (normalized instanceof Error) { |
| | | return normalized.message || normalized.name || 'unknown-error'; |
| | | return normalized.message || normalized.name || '未知错误'; |
| | | } |
| | | |
| | | if (Array.isArray(normalized)) { |
| | |
| | | .map((item) => this.formatErrorMessage(item)) |
| | | .filter(Boolean); |
| | | |
| | | return parts.length > 0 ? parts.join(' | ') : 'unknown-error'; |
| | | return parts.length > 0 ? parts.join(' | ') : '未知错误'; |
| | | } |
| | | |
| | | if (normalized && typeof normalized === 'object') { |
| | |
| | | } |
| | | |
| | | if (normalized === undefined || normalized === null || normalized === '') { |
| | | return 'unknown-error'; |
| | | return '未知错误'; |
| | | } |
| | | |
| | | return String(normalized); |
| | |
| | | return this.aliyunSdk; |
| | | } |
| | | |
| | | // eslint-disable-next-line global-require |
| | | // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。 |
| | | this.aliyunSdk = require('aliyun-iot-device-sdk'); |
| | | return this.aliyunSdk; |
| | | } |
| | |
| | | try { |
| | | responseBody = JSON.parse(rawText); |
| | | } catch (_error) { |
| | | throw new Error('三元组接口返回不是有效 JSON'); |
| | | throw new Error('三元组接口返回的不是有效 JSON'); |
| | | } |
| | | |
| | | return this.extractTuple(responseBody, deviceId); |
| | |
| | | |
| | | if (context.lastRegisterAttempt > 0 && (now - context.lastRegisterAttempt) < retryMs && context.lastRegisterError) { |
| | | this.logger.warn(`[ALIYUN] 三元组请求冷却中 deviceId=${device.deviceId} retryMs=${retryMs} lastError=${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中 ${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中: ${context.lastRegisterError}`); |
| | | } |
| | | |
| | | context.lastRegisterAttempt = now; |
| | |
| | | error: 40, |
| | | }; |
| | | |
| | | // 统一生成本地日志时间,兼顾控制台和文件日志格式。 |
| | | function formatLocalTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | |
| | | try { |
| | | fs.appendFileSync(logFilePath, `${line}\n`, 'utf8'); |
| | | } catch (error) { |
| | | console.error(`[LOGGER] Failed to append log file: ${error.message}`); |
| | | console.error(`[LOGGER] 写入日志文件失败: ${error.message}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM TCP Socket Gateway |
| | | console.log(`JHM TCP Socket 网关服务 |
| | | |
| | | Usage: |
| | | 用法: |
| | | node app.js |
| | | node app.js --config ./config.json |
| | | jhm-service.exe --config .\\runtime\\config.json |
| | | ./jhm-service --config ./runtime/config.json |
| | | |
| | | Options: |
| | | --config <path> set config file path |
| | | --help, -h show help |
| | | 参数: |
| | | --config <path> 指定配置文件路径 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | | function loadConfig(configPath) { |
| | | if (!fs.existsSync(configPath)) { |
| | | throw new Error(`config file not found: ${configPath}`); |
| | | throw new Error(`未找到配置文件: ${configPath}`); |
| | | } |
| | | |
| | | const content = fs.readFileSync(configPath, 'utf8'); |
| | |
| | | |
| | | return { |
| | | publishTime: bloodPressure ? bloodPressure.publishTime !== false : true, |
| | | flushImmediately: bloodPressure ? bloodPressure.flushImmediately !== false : true, |
| | | }; |
| | | } |
| | | |
| | | function validateConfig(config) { |
| | | if (!config.tcp || !Array.isArray(config.devices)) { |
| | | throw new Error('config.json must include tcp and devices'); |
| | | throw new Error('config.json 必须包含 tcp 和 devices'); |
| | | } |
| | | |
| | | if (!config.tcp.host || !config.tcp.port) { |
| | | throw new Error('config.json must include tcp.host and tcp.port'); |
| | | throw new Error('config.json 必须包含 tcp.host 和 tcp.port'); |
| | | } |
| | | |
| | | const channels = getSendChannels(config); |
| | | |
| | | if (channels.length === 0) { |
| | | throw new Error('config.json send.channels must enable at least one channel'); |
| | | throw new Error('config.json 的 send.channels 至少要启用一个通道'); |
| | | } |
| | | |
| | | if (channels.includes('mqtt')) { |
| | | if (!config.mqtt) { |
| | | throw new Error('config.json enabled mqtt but missing mqtt config'); |
| | | throw new Error('config.json 已启用 mqtt,但缺少 mqtt 配置'); |
| | | } |
| | | |
| | | const hasBrokerUrl = Boolean(config.mqtt.brokerUrl); |
| | | const hasHostMode = Boolean(config.mqtt.protocol && config.mqtt.host && config.mqtt.port); |
| | | |
| | | if (!hasBrokerUrl && !hasHostMode) { |
| | | throw new Error('config.json mqtt requires brokerUrl or protocol/host/port'); |
| | | throw new Error('config.json 的 mqtt 必须配置 brokerUrl 或 protocol/host/port'); |
| | | } |
| | | |
| | | if (!config.mqtt.topicTemplate && !config.mqtt.defaultTopicPrefix) { |
| | | throw new Error('config.json mqtt requires topicTemplate or defaultTopicPrefix'); |
| | | throw new Error('config.json 的 mqtt 必须配置 topicTemplate 或 defaultTopicPrefix'); |
| | | } |
| | | } |
| | | |
| | | if (channels.includes('aliyun')) { |
| | | if (!config.aliyun) { |
| | | throw new Error('config.json enabled aliyun but missing aliyun config'); |
| | | throw new Error('config.json 已启用 aliyun,但缺少 aliyun 配置'); |
| | | } |
| | | |
| | | if (!config.aliyun.tupleApiBaseUrl && !config.aliyun.tupleApiUrl) { |
| | | throw new Error('config.json aliyun requires tupleApiBaseUrl or tupleApiUrl'); |
| | | throw new Error('config.json 的 aliyun 必须配置 tupleApiBaseUrl 或 tupleApiUrl'); |
| | | } |
| | | } |
| | | |
| | | if (!config.protocol || !config.protocol.alModelPath) { |
| | | throw new Error('config.json protocol.alModelPath is required'); |
| | | throw new Error('config.json 必须配置 protocol.alModelPath'); |
| | | } |
| | | |
| | | const sendOptions = getSendOptions(config); |
| | | |
| | | if (sendOptions.flushIntervalMs <= 0) { |
| | | throw new Error('config.json send.flushIntervalMs must be > 0'); |
| | | throw new Error('config.json 的 send.flushIntervalMs 必须大于 0'); |
| | | } |
| | | |
| | | if (config.logging && config.logging.level) { |
| | | const supportedLevels = ['debug', 'info', 'warn', 'error']; |
| | | |
| | | if (!supportedLevels.includes(String(config.logging.level).toLowerCase())) { |
| | | throw new Error(`config.json logging.level only supports ${supportedLevels.join(', ')}`); |
| | | throw new Error(`config.json 的 logging.level 仅支持 ${supportedLevels.join('、')}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | return path.join(path.dirname(configFilePath), targetPath); |
| | | } |
| | | |
| | | function createOnMetricHandler({ logger, aggregator, sendOptions, bloodPressureOptions }) { |
| | | return function onMetric(device, metric, result) { |
| | | logger.info(`[APP] 指标已缓存 deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | return aggregator.ingest(device, metric) |
| | | .then(() => { |
| | | if ( |
| | | sendOptions.mode === 'batch' |
| | | && bloodPressureOptions.flushImmediately |
| | | && result |
| | | && result.protocol === 'blood-pressure' |
| | | ) { |
| | | logger.info(`[APP] 血压报文触发整包立即发送 deviceId=${device.deviceId}`); |
| | | return aggregator.flush({ reason: 'blood-pressure', deviceId: device.deviceId }); |
| | | } |
| | | |
| | | return null; |
| | | }) |
| | | .catch((error) => { |
| | | logger.error(`[APP] 指标处理失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }; |
| | | } |
| | | |
| | | async function main() { |
| | |
| | | enabled: false, |
| | | console: true, |
| | | }); |
| | | bootstrapLogger.info(`[APP] Startup arguments parsed config=${options.configPath}`); |
| | | bootstrapLogger.info(`[APP] 启动参数解析完成 config=${options.configPath}`); |
| | | |
| | | const config = loadConfig(options.configPath); |
| | | const logDir = resolveConfigPath(options.configPath, (config.logging && config.logging.dir) || './logs'); |
| | |
| | | const alModelPath = resolveConfigPath(options.configPath, config.protocol.alModelPath); |
| | | |
| | | if (config.logging && config.logging.enabled === false) { |
| | | logger.info('[APP] File logging is disabled; console output only'); |
| | | logger.info('[APP] 文件日志已禁用,仅输出到控制台'); |
| | | } else { |
| | | logger.info(`[APP] Local logging enabled dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | logger.info(`[APP] 本地日志已启用 dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | } |
| | | |
| | | logger.info(`[APP] Config loaded tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | logger.info(`[APP] 配置加载完成 tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | |
| | | const mqttService = sendChannels.includes('mqtt') ? new MqttService(config.mqtt, logger) : null; |
| | | const aliyunService = sendChannels.includes('aliyun') ? new AliyunService(config.aliyun, logger) : null; |
| | | |
| | | // 聚合器负责把多次单点指标合并成一次完整物模型上报。 |
| | | const aggregator = new MetricAggregator({ |
| | | logger, |
| | | ...sendOptions, |
| | | onFlush: async (device, payload, meta) => { |
| | | logger.info(`[APP] Batch dispatch deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | logger.info(`[APP] 开始批量分发 deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | |
| | | let ok = true; |
| | | |
| | |
| | | mqttService.publish(device, payload); |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] MQTT batch dispatch failed deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] MQTT 批量分发失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | if (result && result.skipped) { |
| | | ok = false; |
| | | logger.warn(`[APP] Aliyun batch dispatch skipped deviceId=${device.deviceId} reason=${result.reason}`); |
| | | logger.warn(`[APP] 阿里云批量分发已跳过 deviceId=${device.deviceId} reason=${result.reason}`); |
| | | } else if (result && result.ok === false) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch failed deviceId=${device.deviceId}: ${result.reason}`); |
| | | logger.error(`[APP] 阿里云批量分发失败 deviceId=${device.deviceId}: ${result.reason}`); |
| | | } |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch exception deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] 阿里云批量分发异常 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | alModelPath, |
| | | publishBloodPressureTime: bloodPressureOptions.publishTime, |
| | | logger, |
| | | onMetric(device, metric) { |
| | | logger.info(`[APP] Metric cached deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | aggregator.ingest(device, metric).catch((error) => { |
| | | logger.error(`[APP] Metric cache failed deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }, |
| | | onMetric: createOnMetricHandler({ |
| | | logger, |
| | | aggregator, |
| | | sendOptions, |
| | | bloodPressureOptions, |
| | | }), |
| | | }); |
| | | |
| | | if (mqttService) { |
| | | logger.info('[APP] Starting MQTT channel'); |
| | | logger.info('[APP] 正在启动 MQTT 通道'); |
| | | mqttService.start(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | logger.info('[APP] Starting Aliyun channel'); |
| | | logger.info('[APP] 正在启动阿里云通道'); |
| | | aliyunService.start(); |
| | | } |
| | | |
| | | aggregator.start(); |
| | | await tcpService.start(); |
| | | logger.info('[APP] All services started'); |
| | | logger.info('[APP] 所有服务已启动'); |
| | | |
| | | let shuttingDown = false; |
| | | |
| | |
| | | } |
| | | |
| | | shuttingDown = true; |
| | | logger.warn(`[APP] Received ${signal}, shutting down`); |
| | | logger.warn(`[APP] 收到 ${signal},开始关闭服务`); |
| | | |
| | | await tcpService.stop(); |
| | | await aggregator.stop(); |
| | |
| | | await aliyunService.stop(); |
| | | } |
| | | |
| | | logger.info('[APP] Service shutdown completed'); |
| | | logger.info('[APP] 服务已完成关闭'); |
| | | await logger.close(); |
| | | process.exit(0); |
| | | } |
| | |
| | | }); |
| | | |
| | | process.on('uncaughtException', (error) => { |
| | | logger.error(`[APP] Uncaught exception: ${error.stack || error.message}`); |
| | | logger.error(`[APP] 未捕获异常: ${error.stack || error.message}`); |
| | | }); |
| | | |
| | | process.on('unhandledRejection', (reason) => { |
| | | logger.error(`[APP] Unhandled promise rejection: ${reason}`); |
| | | logger.error(`[APP] 未处理的 Promise 拒绝: ${reason}`); |
| | | }); |
| | | } |
| | | |
| | |
| | | getSendOptions, |
| | | loadConfig, |
| | | main, |
| | | createOnMetricHandler, |
| | | normalizeLogLevel, |
| | | parseArgs, |
| | | printHelp, |
| | |
| | | const normalized = this.normalizeEventPayload(errorLike); |
| | | |
| | | if (normalized instanceof Error) { |
| | | return normalized.message || normalized.name || 'unknown-error'; |
| | | return normalized.message || normalized.name || '未知错误'; |
| | | } |
| | | |
| | | if (Array.isArray(normalized)) { |
| | |
| | | .map((item) => this.formatErrorMessage(item)) |
| | | .filter(Boolean); |
| | | |
| | | return parts.length > 0 ? parts.join(' | ') : 'unknown-error'; |
| | | return parts.length > 0 ? parts.join(' | ') : '未知错误'; |
| | | } |
| | | |
| | | if (normalized && typeof normalized === 'object') { |
| | |
| | | } |
| | | |
| | | if (normalized === undefined || normalized === null || normalized === '') { |
| | | return 'unknown-error'; |
| | | return '未知错误'; |
| | | } |
| | | |
| | | return String(normalized); |
| | |
| | | return this.aliyunSdk; |
| | | } |
| | | |
| | | // eslint-disable-next-line global-require |
| | | // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。 |
| | | this.aliyunSdk = __nccwpck_require__(9871); |
| | | return this.aliyunSdk; |
| | | } |
| | |
| | | try { |
| | | responseBody = JSON.parse(rawText); |
| | | } catch (_error) { |
| | | throw new Error('三元组接口返回不是有效 JSON'); |
| | | throw new Error('三元组接口返回的不是有效 JSON'); |
| | | } |
| | | |
| | | return this.extractTuple(responseBody, deviceId); |
| | |
| | | |
| | | if (context.lastRegisterAttempt > 0 && (now - context.lastRegisterAttempt) < retryMs && context.lastRegisterError) { |
| | | this.logger.warn(`[ALIYUN] 三元组请求冷却中 deviceId=${device.deviceId} retryMs=${retryMs} lastError=${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中 ${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中: ${context.lastRegisterError}`); |
| | | } |
| | | |
| | | context.lastRegisterAttempt = now; |
| | |
| | | error: 40, |
| | | }; |
| | | |
| | | // 统一生成本地日志时间,兼顾控制台和文件日志格式。 |
| | | function formatLocalTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | |
| | | try { |
| | | fs.appendFileSync(logFilePath, `${line}\n`, 'utf8'); |
| | | } catch (error) { |
| | | console.error(`[LOGGER] Failed to append log file: ${error.message}`); |
| | | console.error(`[LOGGER] 写入日志文件失败: ${error.message}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM TCP Socket Gateway |
| | | console.log(`JHM TCP Socket 网关服务 |
| | | |
| | | Usage: |
| | | 用法: |
| | | node app.js |
| | | node app.js --config ./config.json |
| | | jhm-service.exe --config .\\runtime\\config.json |
| | | ./jhm-service --config ./runtime/config.json |
| | | |
| | | Options: |
| | | --config <path> set config file path |
| | | --help, -h show help |
| | | 参数: |
| | | --config <path> 指定配置文件路径 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | | function loadConfig(configPath) { |
| | | if (!fs.existsSync(configPath)) { |
| | | throw new Error(`config file not found: ${configPath}`); |
| | | throw new Error(`未找到配置文件: ${configPath}`); |
| | | } |
| | | |
| | | const content = fs.readFileSync(configPath, 'utf8'); |
| | |
| | | |
| | | return { |
| | | publishTime: bloodPressure ? bloodPressure.publishTime !== false : true, |
| | | flushImmediately: bloodPressure ? bloodPressure.flushImmediately !== false : true, |
| | | }; |
| | | } |
| | | |
| | | function validateConfig(config) { |
| | | if (!config.tcp || !Array.isArray(config.devices)) { |
| | | throw new Error('config.json must include tcp and devices'); |
| | | throw new Error('config.json 必须包含 tcp 和 devices'); |
| | | } |
| | | |
| | | if (!config.tcp.host || !config.tcp.port) { |
| | | throw new Error('config.json must include tcp.host and tcp.port'); |
| | | throw new Error('config.json 必须包含 tcp.host 和 tcp.port'); |
| | | } |
| | | |
| | | const channels = getSendChannels(config); |
| | | |
| | | if (channels.length === 0) { |
| | | throw new Error('config.json send.channels must enable at least one channel'); |
| | | throw new Error('config.json 的 send.channels 至少要启用一个通道'); |
| | | } |
| | | |
| | | if (channels.includes('mqtt')) { |
| | | if (!config.mqtt) { |
| | | throw new Error('config.json enabled mqtt but missing mqtt config'); |
| | | throw new Error('config.json 已启用 mqtt,但缺少 mqtt 配置'); |
| | | } |
| | | |
| | | const hasBrokerUrl = Boolean(config.mqtt.brokerUrl); |
| | | const hasHostMode = Boolean(config.mqtt.protocol && config.mqtt.host && config.mqtt.port); |
| | | |
| | | if (!hasBrokerUrl && !hasHostMode) { |
| | | throw new Error('config.json mqtt requires brokerUrl or protocol/host/port'); |
| | | throw new Error('config.json 的 mqtt 必须配置 brokerUrl 或 protocol/host/port'); |
| | | } |
| | | |
| | | if (!config.mqtt.topicTemplate && !config.mqtt.defaultTopicPrefix) { |
| | | throw new Error('config.json mqtt requires topicTemplate or defaultTopicPrefix'); |
| | | throw new Error('config.json 的 mqtt 必须配置 topicTemplate 或 defaultTopicPrefix'); |
| | | } |
| | | } |
| | | |
| | | if (channels.includes('aliyun')) { |
| | | if (!config.aliyun) { |
| | | throw new Error('config.json enabled aliyun but missing aliyun config'); |
| | | throw new Error('config.json 已启用 aliyun,但缺少 aliyun 配置'); |
| | | } |
| | | |
| | | if (!config.aliyun.tupleApiBaseUrl && !config.aliyun.tupleApiUrl) { |
| | | throw new Error('config.json aliyun requires tupleApiBaseUrl or tupleApiUrl'); |
| | | throw new Error('config.json 的 aliyun 必须配置 tupleApiBaseUrl 或 tupleApiUrl'); |
| | | } |
| | | } |
| | | |
| | | if (!config.protocol || !config.protocol.alModelPath) { |
| | | throw new Error('config.json protocol.alModelPath is required'); |
| | | throw new Error('config.json 必须配置 protocol.alModelPath'); |
| | | } |
| | | |
| | | const sendOptions = getSendOptions(config); |
| | | |
| | | if (sendOptions.flushIntervalMs <= 0) { |
| | | throw new Error('config.json send.flushIntervalMs must be > 0'); |
| | | throw new Error('config.json 的 send.flushIntervalMs 必须大于 0'); |
| | | } |
| | | |
| | | if (config.logging && config.logging.level) { |
| | | const supportedLevels = ['debug', 'info', 'warn', 'error']; |
| | | |
| | | if (!supportedLevels.includes(String(config.logging.level).toLowerCase())) { |
| | | throw new Error(`config.json logging.level only supports ${supportedLevels.join(', ')}`); |
| | | throw new Error(`config.json 的 logging.level 仅支持 ${supportedLevels.join('、')}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | return path.join(path.dirname(configFilePath), targetPath); |
| | | } |
| | | |
| | | function createOnMetricHandler({ logger, aggregator, sendOptions, bloodPressureOptions }) { |
| | | return function onMetric(device, metric, result) { |
| | | logger.info(`[APP] 指标已缓存 deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | return aggregator.ingest(device, metric) |
| | | .then(() => { |
| | | if ( |
| | | sendOptions.mode === 'batch' |
| | | && bloodPressureOptions.flushImmediately |
| | | && result |
| | | && result.protocol === 'blood-pressure' |
| | | ) { |
| | | logger.info(`[APP] 血压报文触发整包立即发送 deviceId=${device.deviceId}`); |
| | | return aggregator.flush({ reason: 'blood-pressure', deviceId: device.deviceId }); |
| | | } |
| | | |
| | | return null; |
| | | }) |
| | | .catch((error) => { |
| | | logger.error(`[APP] 指标处理失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }; |
| | | } |
| | | |
| | | async function main() { |
| | |
| | | enabled: false, |
| | | console: true, |
| | | }); |
| | | bootstrapLogger.info(`[APP] Startup arguments parsed config=${options.configPath}`); |
| | | bootstrapLogger.info(`[APP] 启动参数解析完成 config=${options.configPath}`); |
| | | |
| | | const config = loadConfig(options.configPath); |
| | | const logDir = resolveConfigPath(options.configPath, (config.logging && config.logging.dir) || './logs'); |
| | |
| | | const alModelPath = resolveConfigPath(options.configPath, config.protocol.alModelPath); |
| | | |
| | | if (config.logging && config.logging.enabled === false) { |
| | | logger.info('[APP] File logging is disabled; console output only'); |
| | | logger.info('[APP] 文件日志已禁用,仅输出到控制台'); |
| | | } else { |
| | | logger.info(`[APP] Local logging enabled dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | logger.info(`[APP] 本地日志已启用 dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | } |
| | | |
| | | logger.info(`[APP] Config loaded tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | logger.info(`[APP] 配置加载完成 tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | |
| | | const mqttService = sendChannels.includes('mqtt') ? new MqttService(config.mqtt, logger) : null; |
| | | const aliyunService = sendChannels.includes('aliyun') ? new AliyunService(config.aliyun, logger) : null; |
| | | |
| | | // 聚合器负责把多次单点指标合并成一次完整物模型上报。 |
| | | const aggregator = new MetricAggregator({ |
| | | logger, |
| | | ...sendOptions, |
| | | onFlush: async (device, payload, meta) => { |
| | | logger.info(`[APP] Batch dispatch deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | logger.info(`[APP] 开始批量分发 deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | |
| | | let ok = true; |
| | | |
| | |
| | | mqttService.publish(device, payload); |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] MQTT batch dispatch failed deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] MQTT 批量分发失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | if (result && result.skipped) { |
| | | ok = false; |
| | | logger.warn(`[APP] Aliyun batch dispatch skipped deviceId=${device.deviceId} reason=${result.reason}`); |
| | | logger.warn(`[APP] 阿里云批量分发已跳过 deviceId=${device.deviceId} reason=${result.reason}`); |
| | | } else if (result && result.ok === false) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch failed deviceId=${device.deviceId}: ${result.reason}`); |
| | | logger.error(`[APP] 阿里云批量分发失败 deviceId=${device.deviceId}: ${result.reason}`); |
| | | } |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch exception deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] 阿里云批量分发异常 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | alModelPath, |
| | | publishBloodPressureTime: bloodPressureOptions.publishTime, |
| | | logger, |
| | | onMetric(device, metric) { |
| | | logger.info(`[APP] Metric cached deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | aggregator.ingest(device, metric).catch((error) => { |
| | | logger.error(`[APP] Metric cache failed deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }, |
| | | onMetric: createOnMetricHandler({ |
| | | logger, |
| | | aggregator, |
| | | sendOptions, |
| | | bloodPressureOptions, |
| | | }), |
| | | }); |
| | | |
| | | if (mqttService) { |
| | | logger.info('[APP] Starting MQTT channel'); |
| | | logger.info('[APP] 正在启动 MQTT 通道'); |
| | | mqttService.start(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | logger.info('[APP] Starting Aliyun channel'); |
| | | logger.info('[APP] 正在启动阿里云通道'); |
| | | aliyunService.start(); |
| | | } |
| | | |
| | | aggregator.start(); |
| | | await tcpService.start(); |
| | | logger.info('[APP] All services started'); |
| | | logger.info('[APP] 所有服务已启动'); |
| | | |
| | | let shuttingDown = false; |
| | | |
| | |
| | | } |
| | | |
| | | shuttingDown = true; |
| | | logger.warn(`[APP] Received ${signal}, shutting down`); |
| | | logger.warn(`[APP] 收到 ${signal},开始关闭服务`); |
| | | |
| | | await tcpService.stop(); |
| | | await aggregator.stop(); |
| | |
| | | await aliyunService.stop(); |
| | | } |
| | | |
| | | logger.info('[APP] Service shutdown completed'); |
| | | logger.info('[APP] 服务已完成关闭'); |
| | | await logger.close(); |
| | | process.exit(0); |
| | | } |
| | |
| | | }); |
| | | |
| | | process.on('uncaughtException', (error) => { |
| | | logger.error(`[APP] Uncaught exception: ${error.stack || error.message}`); |
| | | logger.error(`[APP] 未捕获异常: ${error.stack || error.message}`); |
| | | }); |
| | | |
| | | process.on('unhandledRejection', (reason) => { |
| | | logger.error(`[APP] Unhandled promise rejection: ${reason}`); |
| | | logger.error(`[APP] 未处理的 Promise 拒绝: ${reason}`); |
| | | }); |
| | | } |
| | | |
| | |
| | | getSendOptions, |
| | | loadConfig, |
| | | main, |
| | | createOnMetricHandler, |
| | | normalizeLogLevel, |
| | | parseArgs, |
| | | printHelp, |
| | |
| | | this.timer = null; |
| | | this.started = false; |
| | | this.flushing = false; |
| | | this.flushQueued = false; |
| | | this.queuedFlushOptions = null; |
| | | } |
| | | |
| | | start() { |
| | |
| | | |
| | | const entry = this.getOrCreateEntry(device); |
| | | entry.device = device; |
| | | const now = Date.now(); |
| | | |
| | | // 首次进入脏状态时记录时间,便于控制“缓存满一轮再发送”。 |
| | | if (!entry.dirty) { |
| | | entry.dirtySinceAt = now; |
| | | } |
| | | |
| | | entry.dirty = true; |
| | | entry.lastUpdateAt = Date.now(); |
| | | entry.lastUpdateAt = now; |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | entry.payload[this.deviceIdField] = device.deviceId; |
| | |
| | | device, |
| | | payload: {}, |
| | | dirty: false, |
| | | dirtySinceAt: 0, |
| | | firstSeenAt: Date.now(), |
| | | lastUpdateAt: 0, |
| | | lastFlushAt: 0, |
| | |
| | | |
| | | this.timer = setTimeout(() => { |
| | | this.flush({ reason: 'timer' }).catch((error) => { |
| | | this.logger.error(`[APP] Aggregator flush failed: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器刷新失败: ${error.message}`); |
| | | }); |
| | | }, this.computeDelayMs()); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | mergeQueuedFlushOptions(options = {}) { |
| | | const nextOptions = { |
| | | reason: options.reason || 'manual', |
| | | deviceId: options.deviceId || '', |
| | | }; |
| | | |
| | | if (!this.queuedFlushOptions) { |
| | | this.queuedFlushOptions = nextOptions; |
| | | return; |
| | | } |
| | | |
| | | const currentOptions = this.queuedFlushOptions; |
| | | const currentIsTimer = currentOptions.reason === 'timer' || currentOptions.reason === 'queued'; |
| | | const nextIsTimer = nextOptions.reason === 'timer' || nextOptions.reason === 'queued'; |
| | | |
| | | if (currentIsTimer && !nextIsTimer) { |
| | | currentOptions.reason = nextOptions.reason; |
| | | } |
| | | |
| | | if (!currentOptions.deviceId || !nextOptions.deviceId || currentOptions.deviceId !== nextOptions.deviceId) { |
| | | currentOptions.deviceId = ''; |
| | | } |
| | | } |
| | | |
| | | async flush(options = {}) { |
| | | const reason = options.reason || 'manual'; |
| | | const deviceId = options.deviceId || ''; |
| | | |
| | | if (this.flushing) { |
| | | this.flushQueued = true; |
| | | this.mergeQueuedFlushOptions({ reason, deviceId }); |
| | | return false; |
| | | } |
| | | |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 定时触发时要求数据至少缓存满一个周期,避免刚收到就被单独发出。 |
| | | if ((reason === 'timer' || reason === 'queued') && entry.dirtySinceAt > 0) { |
| | | const dirtyAgeMs = Date.now() - entry.dirtySinceAt; |
| | | |
| | | if (dirtyAgeMs < this.flushIntervalMs) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | }); |
| | | |
| | | for (const entry of entries) { |
| | | const flushUpdateAt = entry.lastUpdateAt; |
| | | const payload = { |
| | | ...entry.payload, |
| | | }; |
| | |
| | | continue; |
| | | } |
| | | |
| | | entry.dirty = false; |
| | | if (entry.lastUpdateAt === flushUpdateAt) { |
| | | entry.dirty = false; |
| | | entry.dirtySinceAt = 0; |
| | | } |
| | | |
| | | entry.lastFlushAt = Date.now(); |
| | | } catch (error) { |
| | | this.logger.error(`[APP] Aggregator device flush failed deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器设备刷新失败 deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | this.scheduleNextFlush(); |
| | | } |
| | | |
| | | if (this.flushQueued) { |
| | | this.flushQueued = false; |
| | | await this.flush({ reason: 'queued' }); |
| | | if (this.queuedFlushOptions) { |
| | | const queuedOptions = this.queuedFlushOptions; |
| | | this.queuedFlushOptions = null; |
| | | await this.flush(queuedOptions); |
| | | } |
| | | } |
| | | } |
| | |
| | | const fs = __nccwpck_require__(9896); |
| | | const path = __nccwpck_require__(6928); |
| | | |
| | | // 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。 |
| | | function fileExists(filePath) { |
| | | try { |
| | | return fs.statSync(filePath).isFile(); |
| | |
| | | |
| | | map.set(normalizeIp(device.ip), { |
| | | ...device, |
| | | name: device.name || device['备注'] || device.deviceId, |
| | | name: device.name || device.备注 || device['澶囨敞'] || device.deviceId, |
| | | }); |
| | | } |
| | | |
| | |
| | | this.server.maxConnections = this.tcpConfig.maxConnections || 100; |
| | | |
| | | this.server.on('error', (error) => { |
| | | this.logger.error(`[TCP] 服务错误: ${error.message}`); |
| | | this.logger.error(`[TCP] 服务异常: ${error.message}`); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | |
| | | }); |
| | | }); |
| | | |
| | | this.logger.info(`[TCP] 已监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | this.logger.info(`[TCP] 已开始监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | } |
| | | |
| | | handleConnection(socket) { |
| | |
| | | const results = session.decoder.push(chunk); |
| | | |
| | | if (results.length === 0) { |
| | | this.logger.warn(`[TCP] 当前数据片段未形成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | this.logger.warn(`[TCP] 当前数据片段尚未组成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | } |
| | | |
| | | for (const result of results) { |
| | |
| | | const normalized = this.normalizeEventPayload(errorLike); |
| | | |
| | | if (normalized instanceof Error) { |
| | | return normalized.message || normalized.name || 'unknown-error'; |
| | | return normalized.message || normalized.name || '未知错误'; |
| | | } |
| | | |
| | | if (Array.isArray(normalized)) { |
| | |
| | | .map((item) => this.formatErrorMessage(item)) |
| | | .filter(Boolean); |
| | | |
| | | return parts.length > 0 ? parts.join(' | ') : 'unknown-error'; |
| | | return parts.length > 0 ? parts.join(' | ') : '未知错误'; |
| | | } |
| | | |
| | | if (normalized && typeof normalized === 'object') { |
| | |
| | | } |
| | | |
| | | if (normalized === undefined || normalized === null || normalized === '') { |
| | | return 'unknown-error'; |
| | | return '未知错误'; |
| | | } |
| | | |
| | | return String(normalized); |
| | |
| | | return this.aliyunSdk; |
| | | } |
| | | |
| | | // eslint-disable-next-line global-require |
| | | // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。 |
| | | this.aliyunSdk = __nccwpck_require__(9871); |
| | | return this.aliyunSdk; |
| | | } |
| | |
| | | try { |
| | | responseBody = JSON.parse(rawText); |
| | | } catch (_error) { |
| | | throw new Error('三元组接口返回不是有效 JSON'); |
| | | throw new Error('三元组接口返回的不是有效 JSON'); |
| | | } |
| | | |
| | | return this.extractTuple(responseBody, deviceId); |
| | |
| | | |
| | | if (context.lastRegisterAttempt > 0 && (now - context.lastRegisterAttempt) < retryMs && context.lastRegisterError) { |
| | | this.logger.warn(`[ALIYUN] 三元组请求冷却中 deviceId=${device.deviceId} retryMs=${retryMs} lastError=${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中 ${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中: ${context.lastRegisterError}`); |
| | | } |
| | | |
| | | context.lastRegisterAttempt = now; |
| | |
| | | error: 40, |
| | | }; |
| | | |
| | | // 统一生成本地日志时间,兼顾控制台和文件日志格式。 |
| | | function formatLocalTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | |
| | | try { |
| | | fs.appendFileSync(logFilePath, `${line}\n`, 'utf8'); |
| | | } catch (error) { |
| | | console.error(`[LOGGER] Failed to append log file: ${error.message}`); |
| | | console.error(`[LOGGER] 写入日志文件失败: ${error.message}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM TCP Socket Gateway |
| | | console.log(`JHM TCP Socket 网关服务 |
| | | |
| | | Usage: |
| | | 用法: |
| | | node app.js |
| | | node app.js --config ./config.json |
| | | jhm-service.exe --config .\\runtime\\config.json |
| | | ./jhm-service --config ./runtime/config.json |
| | | |
| | | Options: |
| | | --config <path> set config file path |
| | | --help, -h show help |
| | | 参数: |
| | | --config <path> 指定配置文件路径 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | | function loadConfig(configPath) { |
| | | if (!fs.existsSync(configPath)) { |
| | | throw new Error(`config file not found: ${configPath}`); |
| | | throw new Error(`未找到配置文件: ${configPath}`); |
| | | } |
| | | |
| | | const content = fs.readFileSync(configPath, 'utf8'); |
| | |
| | | |
| | | return { |
| | | publishTime: bloodPressure ? bloodPressure.publishTime !== false : true, |
| | | flushImmediately: bloodPressure ? bloodPressure.flushImmediately !== false : true, |
| | | }; |
| | | } |
| | | |
| | | function validateConfig(config) { |
| | | if (!config.tcp || !Array.isArray(config.devices)) { |
| | | throw new Error('config.json must include tcp and devices'); |
| | | throw new Error('config.json 必须包含 tcp 和 devices'); |
| | | } |
| | | |
| | | if (!config.tcp.host || !config.tcp.port) { |
| | | throw new Error('config.json must include tcp.host and tcp.port'); |
| | | throw new Error('config.json 必须包含 tcp.host 和 tcp.port'); |
| | | } |
| | | |
| | | const channels = getSendChannels(config); |
| | | |
| | | if (channels.length === 0) { |
| | | throw new Error('config.json send.channels must enable at least one channel'); |
| | | throw new Error('config.json 的 send.channels 至少要启用一个通道'); |
| | | } |
| | | |
| | | if (channels.includes('mqtt')) { |
| | | if (!config.mqtt) { |
| | | throw new Error('config.json enabled mqtt but missing mqtt config'); |
| | | throw new Error('config.json 已启用 mqtt,但缺少 mqtt 配置'); |
| | | } |
| | | |
| | | const hasBrokerUrl = Boolean(config.mqtt.brokerUrl); |
| | | const hasHostMode = Boolean(config.mqtt.protocol && config.mqtt.host && config.mqtt.port); |
| | | |
| | | if (!hasBrokerUrl && !hasHostMode) { |
| | | throw new Error('config.json mqtt requires brokerUrl or protocol/host/port'); |
| | | throw new Error('config.json 的 mqtt 必须配置 brokerUrl 或 protocol/host/port'); |
| | | } |
| | | |
| | | if (!config.mqtt.topicTemplate && !config.mqtt.defaultTopicPrefix) { |
| | | throw new Error('config.json mqtt requires topicTemplate or defaultTopicPrefix'); |
| | | throw new Error('config.json 的 mqtt 必须配置 topicTemplate 或 defaultTopicPrefix'); |
| | | } |
| | | } |
| | | |
| | | if (channels.includes('aliyun')) { |
| | | if (!config.aliyun) { |
| | | throw new Error('config.json enabled aliyun but missing aliyun config'); |
| | | throw new Error('config.json 已启用 aliyun,但缺少 aliyun 配置'); |
| | | } |
| | | |
| | | if (!config.aliyun.tupleApiBaseUrl && !config.aliyun.tupleApiUrl) { |
| | | throw new Error('config.json aliyun requires tupleApiBaseUrl or tupleApiUrl'); |
| | | throw new Error('config.json 的 aliyun 必须配置 tupleApiBaseUrl 或 tupleApiUrl'); |
| | | } |
| | | } |
| | | |
| | | if (!config.protocol || !config.protocol.alModelPath) { |
| | | throw new Error('config.json protocol.alModelPath is required'); |
| | | throw new Error('config.json 必须配置 protocol.alModelPath'); |
| | | } |
| | | |
| | | const sendOptions = getSendOptions(config); |
| | | |
| | | if (sendOptions.flushIntervalMs <= 0) { |
| | | throw new Error('config.json send.flushIntervalMs must be > 0'); |
| | | throw new Error('config.json 的 send.flushIntervalMs 必须大于 0'); |
| | | } |
| | | |
| | | if (config.logging && config.logging.level) { |
| | | const supportedLevels = ['debug', 'info', 'warn', 'error']; |
| | | |
| | | if (!supportedLevels.includes(String(config.logging.level).toLowerCase())) { |
| | | throw new Error(`config.json logging.level only supports ${supportedLevels.join(', ')}`); |
| | | throw new Error(`config.json 的 logging.level 仅支持 ${supportedLevels.join('、')}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | return path.join(path.dirname(configFilePath), targetPath); |
| | | } |
| | | |
| | | function createOnMetricHandler({ logger, aggregator, sendOptions, bloodPressureOptions }) { |
| | | return function onMetric(device, metric, result) { |
| | | logger.info(`[APP] 指标已缓存 deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | return aggregator.ingest(device, metric) |
| | | .then(() => { |
| | | if ( |
| | | sendOptions.mode === 'batch' |
| | | && bloodPressureOptions.flushImmediately |
| | | && result |
| | | && result.protocol === 'blood-pressure' |
| | | ) { |
| | | logger.info(`[APP] 血压报文触发整包立即发送 deviceId=${device.deviceId}`); |
| | | return aggregator.flush({ reason: 'blood-pressure', deviceId: device.deviceId }); |
| | | } |
| | | |
| | | return null; |
| | | }) |
| | | .catch((error) => { |
| | | logger.error(`[APP] 指标处理失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }; |
| | | } |
| | | |
| | | async function main() { |
| | |
| | | enabled: false, |
| | | console: true, |
| | | }); |
| | | bootstrapLogger.info(`[APP] Startup arguments parsed config=${options.configPath}`); |
| | | bootstrapLogger.info(`[APP] 启动参数解析完成 config=${options.configPath}`); |
| | | |
| | | const config = loadConfig(options.configPath); |
| | | const logDir = resolveConfigPath(options.configPath, (config.logging && config.logging.dir) || './logs'); |
| | |
| | | const alModelPath = resolveConfigPath(options.configPath, config.protocol.alModelPath); |
| | | |
| | | if (config.logging && config.logging.enabled === false) { |
| | | logger.info('[APP] File logging is disabled; console output only'); |
| | | logger.info('[APP] 文件日志已禁用,仅输出到控制台'); |
| | | } else { |
| | | logger.info(`[APP] Local logging enabled dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | logger.info(`[APP] 本地日志已启用 dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | } |
| | | |
| | | logger.info(`[APP] Config loaded tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | logger.info(`[APP] 配置加载完成 tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | |
| | | const mqttService = sendChannels.includes('mqtt') ? new MqttService(config.mqtt, logger) : null; |
| | | const aliyunService = sendChannels.includes('aliyun') ? new AliyunService(config.aliyun, logger) : null; |
| | | |
| | | // 聚合器负责把多次单点指标合并成一次完整物模型上报。 |
| | | const aggregator = new MetricAggregator({ |
| | | logger, |
| | | ...sendOptions, |
| | | onFlush: async (device, payload, meta) => { |
| | | logger.info(`[APP] Batch dispatch deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | logger.info(`[APP] 开始批量分发 deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | |
| | | let ok = true; |
| | | |
| | |
| | | mqttService.publish(device, payload); |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] MQTT batch dispatch failed deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] MQTT 批量分发失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | if (result && result.skipped) { |
| | | ok = false; |
| | | logger.warn(`[APP] Aliyun batch dispatch skipped deviceId=${device.deviceId} reason=${result.reason}`); |
| | | logger.warn(`[APP] 阿里云批量分发已跳过 deviceId=${device.deviceId} reason=${result.reason}`); |
| | | } else if (result && result.ok === false) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch failed deviceId=${device.deviceId}: ${result.reason}`); |
| | | logger.error(`[APP] 阿里云批量分发失败 deviceId=${device.deviceId}: ${result.reason}`); |
| | | } |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch exception deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] 阿里云批量分发异常 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | alModelPath, |
| | | publishBloodPressureTime: bloodPressureOptions.publishTime, |
| | | logger, |
| | | onMetric(device, metric) { |
| | | logger.info(`[APP] Metric cached deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | aggregator.ingest(device, metric).catch((error) => { |
| | | logger.error(`[APP] Metric cache failed deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }, |
| | | onMetric: createOnMetricHandler({ |
| | | logger, |
| | | aggregator, |
| | | sendOptions, |
| | | bloodPressureOptions, |
| | | }), |
| | | }); |
| | | |
| | | if (mqttService) { |
| | | logger.info('[APP] Starting MQTT channel'); |
| | | logger.info('[APP] 正在启动 MQTT 通道'); |
| | | mqttService.start(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | logger.info('[APP] Starting Aliyun channel'); |
| | | logger.info('[APP] 正在启动阿里云通道'); |
| | | aliyunService.start(); |
| | | } |
| | | |
| | | aggregator.start(); |
| | | await tcpService.start(); |
| | | logger.info('[APP] All services started'); |
| | | logger.info('[APP] 所有服务已启动'); |
| | | |
| | | let shuttingDown = false; |
| | | |
| | |
| | | } |
| | | |
| | | shuttingDown = true; |
| | | logger.warn(`[APP] Received ${signal}, shutting down`); |
| | | logger.warn(`[APP] 收到 ${signal},开始关闭服务`); |
| | | |
| | | await tcpService.stop(); |
| | | await aggregator.stop(); |
| | |
| | | await aliyunService.stop(); |
| | | } |
| | | |
| | | logger.info('[APP] Service shutdown completed'); |
| | | logger.info('[APP] 服务已完成关闭'); |
| | | await logger.close(); |
| | | process.exit(0); |
| | | } |
| | |
| | | }); |
| | | |
| | | process.on('uncaughtException', (error) => { |
| | | logger.error(`[APP] Uncaught exception: ${error.stack || error.message}`); |
| | | logger.error(`[APP] 未捕获异常: ${error.stack || error.message}`); |
| | | }); |
| | | |
| | | process.on('unhandledRejection', (reason) => { |
| | | logger.error(`[APP] Unhandled promise rejection: ${reason}`); |
| | | logger.error(`[APP] 未处理的 Promise 拒绝: ${reason}`); |
| | | }); |
| | | } |
| | | |
| | |
| | | getSendOptions, |
| | | loadConfig, |
| | | main, |
| | | createOnMetricHandler, |
| | | normalizeLogLevel, |
| | | parseArgs, |
| | | printHelp, |
| | |
| | | this.timer = null; |
| | | this.started = false; |
| | | this.flushing = false; |
| | | this.flushQueued = false; |
| | | this.queuedFlushOptions = null; |
| | | } |
| | | |
| | | start() { |
| | |
| | | |
| | | const entry = this.getOrCreateEntry(device); |
| | | entry.device = device; |
| | | const now = Date.now(); |
| | | |
| | | // 首次进入脏状态时记录时间,便于控制“缓存满一轮再发送”。 |
| | | if (!entry.dirty) { |
| | | entry.dirtySinceAt = now; |
| | | } |
| | | |
| | | entry.dirty = true; |
| | | entry.lastUpdateAt = Date.now(); |
| | | entry.lastUpdateAt = now; |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | entry.payload[this.deviceIdField] = device.deviceId; |
| | |
| | | device, |
| | | payload: {}, |
| | | dirty: false, |
| | | dirtySinceAt: 0, |
| | | firstSeenAt: Date.now(), |
| | | lastUpdateAt: 0, |
| | | lastFlushAt: 0, |
| | |
| | | |
| | | this.timer = setTimeout(() => { |
| | | this.flush({ reason: 'timer' }).catch((error) => { |
| | | this.logger.error(`[APP] Aggregator flush failed: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器刷新失败: ${error.message}`); |
| | | }); |
| | | }, this.computeDelayMs()); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | mergeQueuedFlushOptions(options = {}) { |
| | | const nextOptions = { |
| | | reason: options.reason || 'manual', |
| | | deviceId: options.deviceId || '', |
| | | }; |
| | | |
| | | if (!this.queuedFlushOptions) { |
| | | this.queuedFlushOptions = nextOptions; |
| | | return; |
| | | } |
| | | |
| | | const currentOptions = this.queuedFlushOptions; |
| | | const currentIsTimer = currentOptions.reason === 'timer' || currentOptions.reason === 'queued'; |
| | | const nextIsTimer = nextOptions.reason === 'timer' || nextOptions.reason === 'queued'; |
| | | |
| | | if (currentIsTimer && !nextIsTimer) { |
| | | currentOptions.reason = nextOptions.reason; |
| | | } |
| | | |
| | | if (!currentOptions.deviceId || !nextOptions.deviceId || currentOptions.deviceId !== nextOptions.deviceId) { |
| | | currentOptions.deviceId = ''; |
| | | } |
| | | } |
| | | |
| | | async flush(options = {}) { |
| | | const reason = options.reason || 'manual'; |
| | | const deviceId = options.deviceId || ''; |
| | | |
| | | if (this.flushing) { |
| | | this.flushQueued = true; |
| | | this.mergeQueuedFlushOptions({ reason, deviceId }); |
| | | return false; |
| | | } |
| | | |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 定时触发时要求数据至少缓存满一个周期,避免刚收到就被单独发出。 |
| | | if ((reason === 'timer' || reason === 'queued') && entry.dirtySinceAt > 0) { |
| | | const dirtyAgeMs = Date.now() - entry.dirtySinceAt; |
| | | |
| | | if (dirtyAgeMs < this.flushIntervalMs) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | }); |
| | | |
| | | for (const entry of entries) { |
| | | const flushUpdateAt = entry.lastUpdateAt; |
| | | const payload = { |
| | | ...entry.payload, |
| | | }; |
| | |
| | | continue; |
| | | } |
| | | |
| | | entry.dirty = false; |
| | | if (entry.lastUpdateAt === flushUpdateAt) { |
| | | entry.dirty = false; |
| | | entry.dirtySinceAt = 0; |
| | | } |
| | | |
| | | entry.lastFlushAt = Date.now(); |
| | | } catch (error) { |
| | | this.logger.error(`[APP] Aggregator device flush failed deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器设备刷新失败 deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | this.scheduleNextFlush(); |
| | | } |
| | | |
| | | if (this.flushQueued) { |
| | | this.flushQueued = false; |
| | | await this.flush({ reason: 'queued' }); |
| | | if (this.queuedFlushOptions) { |
| | | const queuedOptions = this.queuedFlushOptions; |
| | | this.queuedFlushOptions = null; |
| | | await this.flush(queuedOptions); |
| | | } |
| | | } |
| | | } |
| | |
| | | const fs = __nccwpck_require__(9896); |
| | | const path = __nccwpck_require__(6928); |
| | | |
| | | // 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。 |
| | | function fileExists(filePath) { |
| | | try { |
| | | return fs.statSync(filePath).isFile(); |
| | |
| | | |
| | | map.set(normalizeIp(device.ip), { |
| | | ...device, |
| | | name: device.name || device['备注'] || device.deviceId, |
| | | name: device.name || device.备注 || device['澶囨敞'] || device.deviceId, |
| | | }); |
| | | } |
| | | |
| | |
| | | this.server.maxConnections = this.tcpConfig.maxConnections || 100; |
| | | |
| | | this.server.on('error', (error) => { |
| | | this.logger.error(`[TCP] 服务错误: ${error.message}`); |
| | | this.logger.error(`[TCP] 服务异常: ${error.message}`); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | |
| | | }); |
| | | }); |
| | | |
| | | this.logger.info(`[TCP] 已监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | this.logger.info(`[TCP] 已开始监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | } |
| | | |
| | | handleConnection(socket) { |
| | |
| | | const results = session.decoder.push(chunk); |
| | | |
| | | if (results.length === 0) { |
| | | this.logger.warn(`[TCP] 当前数据片段未形成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | this.logger.warn(`[TCP] 当前数据片段尚未组成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | } |
| | | |
| | | for (const result of results) { |
| New file |
| | |
| | | node_modules/ |
| | | build/ |
| | | dist/ |
| | | logs/ |
| | | *.log |
| New file |
| | |
| | | # JH2028 新协议 TCP 网关服务 |
| | | |
| | | 这是一个独立的新项目,用于适配设备厂商 2026-05-11 版 JH2028 对外接口通讯协议。 |
| | | |
| | | 服务通过 TCP 接收串口透传盒子发来的数据,按来源 IP 匹配设备编号,解析新版 `55 AA` 协议帧,为每台设备缓存最后一次完整数据,并在收到实时数据或血压数据后立即上报到 MQTT 和/或阿里云。 |
| | | |
| | | 老项目不参与本项目运行;本项目也不兼容旧版 `EE 55` 协议。 |
| | | |
| | | ## 协议格式 |
| | | |
| | | 新版帧格式: |
| | | |
| | | ```text |
| | | 55 AA LEN TT CMDTYPE CMDID CMDDATA CRC8 |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | ```text |
| | | 55 AA 固定帧头 |
| | | LEN 帧总长度,包含帧头和 CRC |
| | | TT 发送序列号,仅记录,不回复 |
| | | CMDTYPE 命令类型 |
| | | CMDID 命令 ID |
| | | CMDDATA 命令数据 |
| | | CRC8 从帧头开始计算的 CRC8 校验 |
| | | ``` |
| | | |
| | | 当前支持的命令: |
| | | |
| | | ```text |
| | | CMDTYPE=01 CMDID=00 设备实时数据 |
| | | CMDTYPE=01 CMDID=01 血压数据 |
| | | ``` |
| | | |
| | | 服务端不回复 ACK。多字节字段按 PDF 文档要求使用小端解析。 |
| | | |
| | | ## 字段映射 |
| | | |
| | | 实时数据 `CMDTYPE=01, CMDID=00`: |
| | | |
| | | ```text |
| | | AF 设定温度,原始值 / 10 |
| | | F 当前透析液温度,原始值 / 10 |
| | | A 设定超滤总量,单位 mL |
| | | C 超滤率,单位 mL/h |
| | | B 超滤量,单位 mL |
| | | K 剩余时间,单位分钟 |
| | | L 透析液流量,单位 mL/min |
| | | D 有效血流量,单位 mL/min |
| | | H 静脉压,int16,单位 mmHg |
| | | o 动脉压,int16,单位 mmHg |
| | | J 跨膜压,int16,单位 mmHg |
| | | U 累计血流量,单位 mL |
| | | G 电导率,单位 mS/cm |
| | | Na 钠,单位 mmol/L |
| | | HCO3 碳酸氢根,单位 mmol/L |
| | | O2Sat 血氧饱和度,原始值 / 10 |
| | | Hct 红细胞比容,原始值 / 10 |
| | | Hb 血红蛋白,原始值 / 10 |
| | | Tblood 血液温度,原始值 / 10 |
| | | ktv Kt/V,原始值 / 10 |
| | | ``` |
| | | |
| | | 血压数据 `CMDTYPE=01, CMDID=01`: |
| | | |
| | | ```text |
| | | N 收缩压 |
| | | O 舒张压 |
| | | P 心率 |
| | | M 血压监测时间,使用服务端接收时间,格式 yyyy-mm-dd HH:mm:ss |
| | | ``` |
| | | |
| | | 以下字段不上传、不处理: |
| | | |
| | | ```text |
| | | 平均压 |
| | | 心率不齐 |
| | | 血压错误码 |
| | | ``` |
| | | |
| | | 如果收到血压错误码报文,服务只记录日志,不更新缓存,不上报血压字段。 |
| | | |
| | | ## 运行行为 |
| | | |
| | | 每台设备都会维护一份最后状态缓存。 |
| | | |
| | | 收到实时数据时: |
| | | |
| | | ```text |
| | | 1. 解码实时数据 |
| | | 2. 更新该设备缓存 |
| | | 3. 立即上传完整缓存 |
| | | ``` |
| | | |
| | | 收到血压数据时: |
| | | |
| | | ```text |
| | | 1. 解码 N/O/P |
| | | 2. 写入 M=服务端接收时间 |
| | | 3. 合并到该设备缓存 |
| | | 4. 立即上传完整缓存 |
| | | ``` |
| | | |
| | | 上传失败只记录日志,不做补发。 |
| | | |
| | | ## 配置说明 |
| | | |
| | | 主配置文件是 `config.json`。打包后主要维护 `runtime/config.json`。 |
| | | |
| | | 大屏配置: |
| | | |
| | | ```json |
| | | { |
| | | "dashboard": { |
| | | "enabled": true, |
| | | "host": "0.0.0.0", |
| | | "port": 9100, |
| | | "title": "JH2028 设备中央监测大屏", |
| | | "staleDataMs": 180000 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 服务启动后,在浏览器打开: |
| | | |
| | | ```text |
| | | 本机访问:http://127.0.0.1:9100 |
| | | 局域网访问:http://服务器真实IP:9100 |
| | | ``` |
| | | |
| | | 注意:`0.0.0.0` 只表示服务监听所有网卡,不是浏览器访问地址。不要在浏览器里打开 `http://0.0.0.0:9100`。 |
| | | |
| | | 大屏会展示设备在线/离线状态、数据是否超时、最近实时数据、最近血压数据和当前缓存指标。 |
| | | |
| | | 设备按 TCP 来源 IP 匹配: |
| | | |
| | | ```json |
| | | { |
| | | "deviceId": "JH-001", |
| | | "ip": "192.168.1.10", |
| | | "name": "1号透析机" |
| | | } |
| | | ``` |
| | | |
| | | 上报通道通过 `send.channels` 控制: |
| | | |
| | | ```json |
| | | { |
| | | "send": { |
| | | "channels": ["mqtt", "aliyun"] |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 可选组合: |
| | | |
| | | ```text |
| | | ["mqtt"] 只上报 MQTT |
| | | ["aliyun"] 只上报阿里云 |
| | | ["mqtt","aliyun"] 同时上报 MQTT 和阿里云 |
| | | ``` |
| | | |
| | | MQTT Topic 沿用老项目规则: |
| | | |
| | | ```text |
| | | defaultTopicPrefix/deviceId |
| | | ``` |
| | | |
| | | 例如: |
| | | |
| | | ```text |
| | | touxiji/JH-001 |
| | | ``` |
| | | |
| | | 阿里云三元组获取规则也沿用老项目: |
| | | |
| | | ```text |
| | | deviceName = deviceId |
| | | ``` |
| | | |
| | | ## 常用命令 |
| | | |
| | | 安装依赖: |
| | | |
| | | ```powershell |
| | | npm install |
| | | ``` |
| | | |
| | | 启动服务: |
| | | |
| | | ```powershell |
| | | npm start |
| | | ``` |
| | | |
| | | 指定配置文件启动: |
| | | |
| | | ```powershell |
| | | node app.js --config ./config.json |
| | | ``` |
| | | |
| | | 运行模拟器: |
| | | |
| | | ```powershell |
| | | npm run start:simulator -- --host 127.0.0.1 --port 9000 |
| | | |
| | | # 如果只想发送一轮后断开: |
| | | npm run start:simulator:once -- --host 127.0.0.1 --port 9000 |
| | | ``` |
| | | |
| | | 只打印模拟报文,不连接 TCP 服务: |
| | | |
| | | ```powershell |
| | | npm run print:simulator |
| | | ``` |
| | | |
| | | 运行测试: |
| | | |
| | | ```powershell |
| | | npm test |
| | | npm run verify |
| | | ``` |
| | | |
| | | 打包 Windows 和 Linux: |
| | | |
| | | ```powershell |
| | | npm run build |
| | | ``` |
| | | |
| | | 只打包 Windows: |
| | | |
| | | ```powershell |
| | | npm run build:win |
| | | ``` |
| | | |
| | | 只打包 Linux: |
| | | |
| | | ```powershell |
| | | npm run build:linux |
| | | ``` |
| | | |
| | | ## 打包产物 |
| | | |
| | | 打包后目录结构: |
| | | |
| | | ```text |
| | | dist/ |
| | | win-x64/ |
| | | jh2028-service.exe |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | logs/ |
| | | linux-x64/ |
| | | jh2028-service |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | logs/ |
| | | ``` |
| | | |
| | | Windows 试运行: |
| | | |
| | | ```powershell |
| | | cd dist\win-x64 |
| | | .\jh2028-service.exe --config .\runtime\config.json |
| | | ``` |
| | | |
| | | Linux 试运行: |
| | | |
| | | ```bash |
| | | cd dist/linux-x64 |
| | | chmod +x ./jh2028-service |
| | | ./jh2028-service --config ./runtime/config.json |
| | | ``` |
| | | |
| | | ## 联调建议 |
| | | |
| | | 目前项目使用模拟报文完成了协议验证。因为暂时没有厂家真实报文,现场联调时建议优先向厂家确认或抓取以下报文: |
| | | |
| | | ```text |
| | | 实时数据正常报文 1 条 |
| | | 血压正常报文 1 条 |
| | | 包含负压力的实时数据报文 1 条 |
| | | 血压错误码报文 1 条 |
| | | ``` |
| | | |
| | | 拿到真实报文后,建议先用 `decoder.js` 增加验证用例,确认 CRC8、小端、有符号数和单位换算全部一致,再部署到现场。 |
| New file |
| | |
| | | { |
| | | "properties": [ |
| | | { "identifier": "n", "name": "device id" }, |
| | | { "identifier": "AF", "name": "set dialysate temperature" }, |
| | | { "identifier": "F", "name": "dialysate temperature" }, |
| | | { "identifier": "A", "name": "set ultrafiltration total" }, |
| | | { "identifier": "C", "name": "ultrafiltration rate" }, |
| | | { "identifier": "B", "name": "ultrafiltration volume" }, |
| | | { "identifier": "K", "name": "remaining time" }, |
| | | { "identifier": "L", "name": "dialysate flow" }, |
| | | { "identifier": "D", "name": "effective blood flow" }, |
| | | { "identifier": "H", "name": "venous pressure" }, |
| | | { "identifier": "o", "name": "arterial pressure" }, |
| | | { "identifier": "J", "name": "transmembrane pressure" }, |
| | | { "identifier": "U", "name": "cumulative blood volume" }, |
| | | { "identifier": "G", "name": "conductivity" }, |
| | | { "identifier": "Na", "name": "sodium" }, |
| | | { "identifier": "HCO3", "name": "bicarbonate" }, |
| | | { "identifier": "O2Sat", "name": "oxygen saturation" }, |
| | | { "identifier": "Hct", "name": "hematocrit" }, |
| | | { "identifier": "Hb", "name": "hemoglobin" }, |
| | | { "identifier": "Tblood", "name": "blood temperature" }, |
| | | { "identifier": "ktv", "name": "Kt/V" }, |
| | | { "identifier": "N", "name": "systolic pressure" }, |
| | | { "identifier": "O", "name": "diastolic pressure" }, |
| | | { "identifier": "P", "name": "pulse" }, |
| | | { "identifier": "M", "name": "blood pressure receive time" }, |
| | | { "identifier": "suedtime", "name": "upload time" } |
| | | ] |
| | | } |
| New file |
| | |
| | | const { EventEmitter } = require('events'); |
| | | const { formatUploadTime } = require('./mqtt-service'); |
| | | |
| | | class AliyunService { |
| | | constructor(config = {}, logger = console, options = {}) { |
| | | this.config = config; |
| | | this.logger = logger; |
| | | this.fetchImpl = options.fetchImpl || global.fetch; |
| | | this.aliyunSdk = options.aliyunSdk || null; |
| | | this.contexts = new Map(); |
| | | this.started = false; |
| | | } |
| | | |
| | | start() { |
| | | if (!this.config.enabled) { |
| | | this.logger.info('[ALIYUN] 阿里云通道未启用'); |
| | | this.started = false; |
| | | return; |
| | | } |
| | | |
| | | if (typeof this.fetchImpl !== 'function') { |
| | | throw new Error('当前 Node.js 运行环境不支持 fetch,无法请求三元组接口'); |
| | | } |
| | | |
| | | this.started = true; |
| | | this.logger.info(`[ALIYUN] 阿里云通道已启用 三元组接口=${this.getTupleUrl()} 自动注册=${this.config.autoRegister === false ? 0 : 1}`); |
| | | } |
| | | |
| | | async stop() { |
| | | const closeTasks = []; |
| | | |
| | | for (const context of this.contexts.values()) { |
| | | this.clearConnectWaiter(context); |
| | | context.tuplePromise = null; |
| | | |
| | | if (context.iotDevice && typeof context.iotDevice.end === 'function') { |
| | | closeTasks.push(new Promise((resolve) => { |
| | | try { |
| | | context.iotDevice.end(false, resolve); |
| | | } catch (_error) { |
| | | resolve(); |
| | | } |
| | | })); |
| | | } |
| | | } |
| | | |
| | | await Promise.all(closeTasks); |
| | | this.contexts.clear(); |
| | | this.started = false; |
| | | this.logger.info('[ALIYUN] 阿里云通道已停止'); |
| | | } |
| | | |
| | | buildPayload(message) { |
| | | return { |
| | | ...(message || {}), |
| | | suedtime: formatUploadTime(), |
| | | }; |
| | | } |
| | | |
| | | getTupleUrl() { |
| | | if (this.config.tupleApiUrl) { |
| | | return this.config.tupleApiUrl; |
| | | } |
| | | |
| | | const baseUrl = String(this.config.tupleApiBaseUrl || '').replace(/\/$/, ''); |
| | | const apiPath = this.config.tupleApiPath || '/device/info/getAliyunDeviceSecret'; |
| | | return `${baseUrl}${apiPath}`; |
| | | } |
| | | |
| | | getAliyunSdk() { |
| | | if (!this.aliyunSdk) { |
| | | this.aliyunSdk = require('aliyun-iot-device-sdk'); |
| | | } |
| | | |
| | | return this.aliyunSdk; |
| | | } |
| | | |
| | | getContext(device) { |
| | | const deviceId = device.deviceId; |
| | | |
| | | if (!this.contexts.has(deviceId)) { |
| | | this.contexts.set(deviceId, { |
| | | deviceId, |
| | | tuple: null, |
| | | tuplePromise: null, |
| | | iotDevice: null, |
| | | connected: false, |
| | | connectPromise: null, |
| | | resolveConnectPromise: null, |
| | | rejectConnectPromise: null, |
| | | connectTimer: null, |
| | | lastRegisterAttempt: 0, |
| | | lastRegisterError: '', |
| | | }); |
| | | this.logger.info(`[ALIYUN] 创建设备上下文 设备=${deviceId}`); |
| | | } |
| | | |
| | | return this.contexts.get(deviceId); |
| | | } |
| | | |
| | | extractTuple(responseBody, deviceId) { |
| | | const data = responseBody && typeof responseBody === 'object' |
| | | ? (responseBody.data || responseBody.result || responseBody) |
| | | : null; |
| | | |
| | | if (!data || typeof data !== 'object') { |
| | | throw new Error('三元组接口返回为空'); |
| | | } |
| | | |
| | | const tuple = { |
| | | productKey: data.productKey || data.ProductKey || '', |
| | | deviceName: data.deviceName || data.DeviceName || deviceId, |
| | | deviceSecret: data.deviceSecret || data.DeviceSecret || '', |
| | | }; |
| | | |
| | | if (!tuple.productKey || !tuple.deviceName || !tuple.deviceSecret) { |
| | | throw new Error('三元组字段不完整'); |
| | | } |
| | | |
| | | return tuple; |
| | | } |
| | | |
| | | async requestTuple(deviceId) { |
| | | const formData = new URLSearchParams(); |
| | | formData.set('isAutoRegister', this.config.autoRegister === false ? '0' : '1'); |
| | | formData.set('deviceName', deviceId); |
| | | |
| | | const url = this.getTupleUrl(); |
| | | this.logger.info(`[ALIYUN] 请求三元组 设备=${deviceId} 地址=${url}`); |
| | | |
| | | const response = await this.fetchImpl(url, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/x-www-form-urlencoded', |
| | | }, |
| | | body: formData.toString(), |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | | throw new Error(`三元组接口请求失败 HTTP ${response.status}`); |
| | | } |
| | | |
| | | const text = await response.text(); |
| | | let body; |
| | | |
| | | try { |
| | | body = JSON.parse(text); |
| | | } catch (_error) { |
| | | throw new Error('三元组接口返回不是有效 JSON'); |
| | | } |
| | | |
| | | return this.extractTuple(body, deviceId); |
| | | } |
| | | |
| | | async ensureTuple(context, device) { |
| | | if (context.tuple) { |
| | | return context.tuple; |
| | | } |
| | | |
| | | if (context.tuplePromise) { |
| | | return context.tuplePromise; |
| | | } |
| | | |
| | | const retryMs = this.config.registerRetryMs || 60000; |
| | | const now = Date.now(); |
| | | |
| | | if (context.lastRegisterAttempt > 0 && (now - context.lastRegisterAttempt) < retryMs && context.lastRegisterError) { |
| | | throw new Error(`三元组请求重试冷却中: ${context.lastRegisterError}`); |
| | | } |
| | | |
| | | context.lastRegisterAttempt = now; |
| | | context.tuplePromise = (async () => { |
| | | try { |
| | | const tuple = await this.requestTuple(device.deviceId); |
| | | context.tuple = tuple; |
| | | context.lastRegisterError = ''; |
| | | this.logger.info(`[ALIYUN] 三元组获取成功 设备=${device.deviceId} 阿里云设备名=${tuple.deviceName}`); |
| | | return tuple; |
| | | } catch (error) { |
| | | context.lastRegisterError = error.message; |
| | | this.logger.error(`[ALIYUN] 三元组获取失败 设备=${device.deviceId}: ${error.message}`); |
| | | throw error; |
| | | } finally { |
| | | context.tuplePromise = null; |
| | | } |
| | | })(); |
| | | |
| | | return context.tuplePromise; |
| | | } |
| | | |
| | | createIotDevice(tuple) { |
| | | const sdk = this.getAliyunSdk(); |
| | | |
| | | if (!sdk || typeof sdk.device !== 'function') { |
| | | throw new Error('aliyun-iot-device-sdk 不可用'); |
| | | } |
| | | |
| | | return sdk.device({ |
| | | ProductKey: tuple.productKey, |
| | | DeviceName: tuple.deviceName, |
| | | DeviceSecret: tuple.deviceSecret, |
| | | }); |
| | | } |
| | | |
| | | clearConnectWaiter(context) { |
| | | if (context.connectTimer) { |
| | | clearTimeout(context.connectTimer); |
| | | } |
| | | |
| | | context.connectPromise = null; |
| | | context.resolveConnectPromise = null; |
| | | context.rejectConnectPromise = null; |
| | | context.connectTimer = null; |
| | | } |
| | | |
| | | resolveConnectWaiter(context, iotDevice) { |
| | | if (context.connectPromise && context.resolveConnectPromise) { |
| | | const resolve = context.resolveConnectPromise; |
| | | this.clearConnectWaiter(context); |
| | | resolve(iotDevice); |
| | | } |
| | | } |
| | | |
| | | rejectConnectWaiter(context, error) { |
| | | if (context.connectPromise && context.rejectConnectPromise) { |
| | | const reject = context.rejectConnectPromise; |
| | | this.clearConnectWaiter(context); |
| | | reject(error instanceof Error ? error : new Error(String(error))); |
| | | } |
| | | } |
| | | |
| | | bindIotDeviceEvents(context, device, iotDevice) { |
| | | const isCurrentDevice = () => context.iotDevice === iotDevice; |
| | | |
| | | iotDevice.on('connect', () => { |
| | | if (!isCurrentDevice()) { |
| | | return; |
| | | } |
| | | |
| | | context.connected = true; |
| | | this.logger.info(`[ALIYUN] 设备连接成功 设备=${device.deviceId}`); |
| | | this.resolveConnectWaiter(context, iotDevice); |
| | | }); |
| | | |
| | | iotDevice.on('error', (error) => { |
| | | if (!isCurrentDevice()) { |
| | | return; |
| | | } |
| | | |
| | | context.connected = false; |
| | | this.logger.error(`[ALIYUN] 设备连接异常 设备=${device.deviceId}: ${error.message || error}`); |
| | | this.rejectConnectWaiter(context, error); |
| | | }); |
| | | |
| | | iotDevice.on('offline', () => { |
| | | if (isCurrentDevice()) { |
| | | context.connected = false; |
| | | this.logger.warn(`[ALIYUN] 设备离线 设备=${device.deviceId}`); |
| | | } |
| | | }); |
| | | |
| | | iotDevice.on('close', () => { |
| | | if (isCurrentDevice()) { |
| | | context.connected = false; |
| | | this.logger.warn(`[ALIYUN] 连接已关闭 设备=${device.deviceId}`); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | async ensureIotDevice(context, device) { |
| | | if (context.iotDevice && context.connected) { |
| | | return context.iotDevice; |
| | | } |
| | | |
| | | if (context.connectPromise) { |
| | | return context.connectPromise; |
| | | } |
| | | |
| | | if (!context.iotDevice) { |
| | | const tuple = await this.ensureTuple(context, device); |
| | | const iotDevice = this.createIotDevice(tuple); |
| | | context.iotDevice = iotDevice; |
| | | this.bindIotDeviceEvents(context, device, iotDevice); |
| | | this.logger.info(`[ALIYUN] 创建 SDK 设备实例 设备=${device.deviceId}`); |
| | | } |
| | | |
| | | if (context.connected) { |
| | | return context.iotDevice; |
| | | } |
| | | |
| | | const connectTimeoutMs = this.config.connectTimeoutMs || 15000; |
| | | context.connectPromise = new Promise((resolve, reject) => { |
| | | context.resolveConnectPromise = resolve; |
| | | context.rejectConnectPromise = reject; |
| | | context.connectTimer = setTimeout(() => { |
| | | this.rejectConnectWaiter(context, new Error('阿里云连接超时')); |
| | | }, connectTimeoutMs); |
| | | }); |
| | | |
| | | return context.connectPromise; |
| | | } |
| | | |
| | | async publish(device, message) { |
| | | if (!this.started || !this.config.enabled) { |
| | | this.logger.warn(`[ALIYUN] 跳过上报 设备=${device.deviceId} 原因=通道未启用`); |
| | | return { skipped: true, reason: 'disabled' }; |
| | | } |
| | | |
| | | const context = this.getContext(device); |
| | | const payload = this.buildPayload(message); |
| | | |
| | | try { |
| | | const iotDevice = await this.ensureIotDevice(context, device); |
| | | this.logger.info(`[ALIYUN] 开始属性上报 设备=${device.deviceId} 字段=${Object.keys(payload).join(',')}`); |
| | | await new Promise((resolve, reject) => { |
| | | iotDevice.postProps(payload, (result) => { |
| | | if (result && (result.message === 'success' || result.success === true || result.code === 200)) { |
| | | resolve(result); |
| | | return; |
| | | } |
| | | |
| | | reject(new Error(result && (result.message || result.code) ? String(result.message || result.code) : '属性上报失败')); |
| | | }); |
| | | }); |
| | | this.logger.info(`[ALIYUN] 属性上报成功 设备=${device.deviceId} 数据=${JSON.stringify(payload)}`); |
| | | return { ok: true, payload }; |
| | | } catch (error) { |
| | | this.logger.error(`[ALIYUN] 上报失败 设备=${device.deviceId}: ${error.message}`); |
| | | return { ok: false, reason: error.message, payload }; |
| | | } |
| | | } |
| | | } |
| | | |
| | | class FakeAliyunDevice extends EventEmitter { |
| | | postProps(payload, callback) { |
| | | callback({ message: 'success', payload }); |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | AliyunService, |
| | | FakeAliyunDevice, |
| | | }; |
| New file |
| | |
| | | #!/usr/bin/env node |
| | | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | const { createLogger, normalizeLogLevel } = require('./logger'); |
| | | const { getDefaultConfigPath } = require('./runtime-paths'); |
| | | const { TcpService } = require('./tcp-service'); |
| | | const { StateCache } = require('./state-cache'); |
| | | const { MqttService } = require('./mqtt-service'); |
| | | const { AliyunService } = require('./aliyun-service'); |
| | | const { DashboardService, buildDashboardSnapshot } = require('./dashboard-service'); |
| | | |
| | | function parseArgs(argv) { |
| | | const options = { |
| | | configPath: getDefaultConfigPath(), |
| | | }; |
| | | |
| | | for (let index = 2; index < argv.length; index += 1) { |
| | | const arg = argv[index]; |
| | | const value = argv[index + 1]; |
| | | |
| | | if (arg === '--config' && value) { |
| | | options.configPath = path.resolve(value); |
| | | index += 1; |
| | | } else if (arg === '--help' || arg === '-h') { |
| | | options.help = true; |
| | | } |
| | | } |
| | | |
| | | return options; |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JH2028 TCP 网关服务 |
| | | |
| | | 用法: |
| | | node app.js |
| | | node app.js --config ./config.json |
| | | jh2028-service.exe --config .\\runtime\\config.json |
| | | ./jh2028-service --config ./runtime/config.json |
| | | |
| | | 参数: |
| | | --config <path> 指定配置文件路径 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | | function loadConfig(configPath) { |
| | | if (!fs.existsSync(configPath)) { |
| | | throw new Error(`未找到配置文件: ${configPath}`); |
| | | } |
| | | |
| | | const content = fs.readFileSync(configPath, 'utf8'); |
| | | const config = JSON.parse(content); |
| | | validateConfig(config); |
| | | return config; |
| | | } |
| | | |
| | | function resolveConfigPath(configFilePath, targetPath) { |
| | | if (path.isAbsolute(targetPath)) { |
| | | return targetPath; |
| | | } |
| | | |
| | | return path.join(path.dirname(configFilePath), targetPath); |
| | | } |
| | | |
| | | function getSendChannels(config) { |
| | | const channels = Array.isArray(config.send && config.send.channels) |
| | | ? config.send.channels |
| | | : ['mqtt']; |
| | | |
| | | return Array.from(new Set(channels |
| | | .map((item) => String(item || '').trim().toLowerCase()) |
| | | .filter(Boolean))); |
| | | } |
| | | |
| | | function getSendOptions(config) { |
| | | const send = config.send || {}; |
| | | return { |
| | | includeDeviceIdField: send.includeDeviceIdField !== false, |
| | | deviceIdField: send.deviceIdField || 'n', |
| | | }; |
| | | } |
| | | |
| | | function validateConfig(config) { |
| | | if (!config.tcp || !Array.isArray(config.devices)) { |
| | | throw new Error('config.json 必须包含 tcp 和 devices'); |
| | | } |
| | | |
| | | if (!config.tcp.host || !config.tcp.port) { |
| | | throw new Error('config.json 必须包含 tcp.host 和 tcp.port'); |
| | | } |
| | | |
| | | if (!config.protocol || !config.protocol.alModelPath) { |
| | | throw new Error('config.json 必须包含 protocol.alModelPath'); |
| | | } |
| | | |
| | | const channels = getSendChannels(config); |
| | | if (channels.length === 0) { |
| | | throw new Error('config.json 的 send.channels 至少要启用一个通道'); |
| | | } |
| | | |
| | | if (channels.includes('mqtt')) { |
| | | if (!config.mqtt) { |
| | | throw new Error('已启用 MQTT 通道,但缺少 mqtt 配置'); |
| | | } |
| | | |
| | | const hasBrokerUrl = Boolean(config.mqtt.brokerUrl); |
| | | const hasHostMode = Boolean(config.mqtt.protocol && config.mqtt.host && config.mqtt.port); |
| | | |
| | | if (!hasBrokerUrl && !hasHostMode) { |
| | | throw new Error('mqtt 必须配置 brokerUrl 或 protocol/host/port'); |
| | | } |
| | | |
| | | if (!config.mqtt.topicTemplate && !config.mqtt.defaultTopicPrefix) { |
| | | throw new Error('mqtt 必须配置 topicTemplate 或 defaultTopicPrefix'); |
| | | } |
| | | } |
| | | |
| | | if (channels.includes('aliyun')) { |
| | | if (!config.aliyun) { |
| | | throw new Error('已启用阿里云通道,但缺少 aliyun 配置'); |
| | | } |
| | | |
| | | if (!config.aliyun.tupleApiBaseUrl && !config.aliyun.tupleApiUrl) { |
| | | throw new Error('aliyun 必须配置 tupleApiBaseUrl 或 tupleApiUrl'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | function createOnMetricHandler({ logger, cache, mqttService, aliyunService, dashboardService }) { |
| | | return async function onMetric(device, metric, result) { |
| | | const payload = cache.update(device, metric, result); |
| | | logger.info(`[APP] 缓存已更新 设备=${device.deviceId} 类型=${result.messageType} 数据=${JSON.stringify(payload)}`); |
| | | |
| | | if (dashboardService) { |
| | | dashboardService.broadcastSnapshot(); |
| | | } |
| | | |
| | | const tasks = []; |
| | | |
| | | if (mqttService) { |
| | | tasks.push(mqttService.publish(device, payload).catch((error) => { |
| | | logger.error(`[APP] MQTT 上报失败 设备=${device.deviceId}: ${error.message}`); |
| | | return { ok: false, reason: error.message }; |
| | | })); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | tasks.push(aliyunService.publish(device, payload).catch((error) => { |
| | | logger.error(`[APP] 阿里云上报失败 设备=${device.deviceId}: ${error.message}`); |
| | | return { ok: false, reason: error.message }; |
| | | })); |
| | | } |
| | | |
| | | await Promise.all(tasks); |
| | | }; |
| | | } |
| | | |
| | | async function main() { |
| | | const options = parseArgs(process.argv); |
| | | |
| | | if (options.help) { |
| | | printHelp(); |
| | | return; |
| | | } |
| | | |
| | | const config = loadConfig(options.configPath); |
| | | const logDir = resolveConfigPath(options.configPath, (config.logging && config.logging.dir) || './logs'); |
| | | const logger = createLogger({ |
| | | ...(config.logging || {}), |
| | | dir: logDir, |
| | | }); |
| | | const sendChannels = getSendChannels(config); |
| | | const sendOptions = getSendOptions(config); |
| | | const alModelPath = resolveConfigPath(options.configPath, config.protocol.alModelPath); |
| | | |
| | | logger.info(`[APP] 配置加载完成 TCP=${config.tcp.host}:${config.tcp.port} 设备数量=${config.devices.length} 上报通道=${sendChannels.join(',')} 物模型=${alModelPath} 日志级别=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | |
| | | const cache = new StateCache(sendOptions); |
| | | const mqttService = sendChannels.includes('mqtt') ? new MqttService(config.mqtt, logger) : null; |
| | | const aliyunService = sendChannels.includes('aliyun') ? new AliyunService(config.aliyun, logger) : null; |
| | | let tcpService = null; |
| | | const dashboardService = new DashboardService({ |
| | | config: config.dashboard || {}, |
| | | logger, |
| | | getSnapshot: () => buildDashboardSnapshot({ |
| | | devices: config.devices, |
| | | cache, |
| | | tcpService, |
| | | config: config.dashboard || {}, |
| | | }), |
| | | }); |
| | | |
| | | if (mqttService) { |
| | | mqttService.start(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | aliyunService.start(); |
| | | } |
| | | |
| | | tcpService = new TcpService({ |
| | | tcpConfig: config.tcp, |
| | | devices: config.devices, |
| | | alModelPath, |
| | | logger, |
| | | logRawHex: config.logging && config.logging.logRawHex, |
| | | onConnectionChange: () => { |
| | | dashboardService.broadcastSnapshot(); |
| | | }, |
| | | onMetric: createOnMetricHandler({ |
| | | logger, |
| | | cache, |
| | | mqttService, |
| | | aliyunService, |
| | | dashboardService, |
| | | }), |
| | | }); |
| | | |
| | | await dashboardService.start(); |
| | | await tcpService.start(); |
| | | |
| | | let shuttingDown = false; |
| | | |
| | | async function shutdown(signal) { |
| | | if (shuttingDown) { |
| | | return; |
| | | } |
| | | |
| | | shuttingDown = true; |
| | | logger.warn(`[APP] 收到关闭信号 信号=${signal}`); |
| | | await tcpService.stop(); |
| | | await dashboardService.stop(); |
| | | |
| | | if (mqttService) { |
| | | await mqttService.stop(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | await aliyunService.stop(); |
| | | } |
| | | |
| | | logger.info('[APP] 服务已停止'); |
| | | await logger.close(); |
| | | process.exit(0); |
| | | } |
| | | |
| | | process.on('SIGINT', () => { |
| | | shutdown('SIGINT'); |
| | | }); |
| | | |
| | | process.on('SIGTERM', () => { |
| | | shutdown('SIGTERM'); |
| | | }); |
| | | |
| | | process.on('uncaughtException', (error) => { |
| | | logger.error(`[APP] 未捕获异常: ${error.stack || error.message}`); |
| | | }); |
| | | |
| | | process.on('unhandledRejection', (reason) => { |
| | | logger.error(`[APP] 未处理的 Promise 拒绝: ${reason}`); |
| | | }); |
| | | } |
| | | |
| | | if (require.main === module) { |
| | | main().catch((error) => { |
| | | console.error(error.message); |
| | | process.exit(1); |
| | | }); |
| | | } |
| | | |
| | | module.exports = { |
| | | createOnMetricHandler, |
| | | getSendChannels, |
| | | getSendOptions, |
| | | loadConfig, |
| | | main, |
| | | parseArgs, |
| | | printHelp, |
| | | resolveConfigPath, |
| | | validateConfig, |
| | | }; |
| New file |
| | |
| | | { |
| | | "send": { |
| | | "channels": [ |
| | | "mqtt", |
| | | "aliyun" |
| | | ], |
| | | "includeDeviceIdField": true, |
| | | "deviceIdField": "n" |
| | | }, |
| | | "logging": { |
| | | "enabled": true, |
| | | "console": true, |
| | | "dir": "./logs", |
| | | "filePrefix": "jh2028-service", |
| | | "level": "info", |
| | | "logRawHex": false |
| | | }, |
| | | "tcp": { |
| | | "host": "0.0.0.0", |
| | | "port": 9000, |
| | | "maxConnections": 100, |
| | | "socketTimeoutMs": 120000, |
| | | "keepAlive": true, |
| | | "keepAliveDelayMs": 10000, |
| | | "noDelay": true, |
| | | "backlog": 128, |
| | | "maxBufferBytes": 8192 |
| | | }, |
| | | "dashboard": { |
| | | "enabled": true, |
| | | "host": "0.0.0.0", |
| | | "port": 9100, |
| | | "title": "JH2028 设备中央监测大屏", |
| | | "staleDataMs": 180000 |
| | | }, |
| | | "mqtt": { |
| | | "protocol": "mqtt", |
| | | "host": "mqtt.ihemodialysis.com", |
| | | "port": 62283, |
| | | "username": "data", |
| | | "password": "data#2018", |
| | | "defaultTopicPrefix": "touxiji" |
| | | }, |
| | | "aliyun": { |
| | | "enabled": true, |
| | | "tupleApiBaseUrl": "https://things.icoldchain.cn", |
| | | "tupleApiPath": "/device/info/getAliyunDeviceSecret", |
| | | "autoRegister": true, |
| | | "registerRetryMs": 60000, |
| | | "connectTimeoutMs": 15000 |
| | | }, |
| | | "protocol": { |
| | | "name": "jh2028-20260511", |
| | | "alModelPath": "./alModel.json" |
| | | }, |
| | | "devices": [ |
| | | { |
| | | "deviceId": "JH-TEST-001", |
| | | "ip": "127.0.0.1", |
| | | "name": "测试设备001" |
| | | }, |
| | | { |
| | | "deviceId": "JH-TEST-002", |
| | | "ip": "127.0.0.2", |
| | | "name": "测试设备002" |
| | | } |
| | | ] |
| | | } |
| New file |
| | |
| | | const fs = require('fs'); |
| | | const http = require('http'); |
| | | const path = require('path'); |
| | | |
| | | const CONTENT_TYPES = { |
| | | '.html': 'text/html; charset=utf-8', |
| | | '.css': 'text/css; charset=utf-8', |
| | | '.js': 'application/javascript; charset=utf-8', |
| | | '.json': 'application/json; charset=utf-8', |
| | | }; |
| | | |
| | | function getDashboardDir(options = {}) { |
| | | if (options.dashboardDir) { |
| | | return options.dashboardDir; |
| | | } |
| | | |
| | | if (process.pkg) { |
| | | return path.join(path.dirname(process.execPath), 'runtime', 'dashboard'); |
| | | } |
| | | |
| | | return path.join(__dirname, 'dashboard'); |
| | | } |
| | | |
| | | function nowIso() { |
| | | return new Date().toISOString(); |
| | | } |
| | | |
| | | function buildDashboardSnapshot({ devices = [], cache, tcpService, config = {} }) { |
| | | const connectionMap = new Map(); |
| | | const cacheMap = cache && typeof cache.getSnapshotMap === 'function' |
| | | ? cache.getSnapshotMap() |
| | | : new Map(); |
| | | const staleDataMs = Number(config.staleDataMs) > 0 ? Number(config.staleDataMs) : 180000; |
| | | const now = Date.now(); |
| | | |
| | | if (tcpService && typeof tcpService.getConnectionSnapshot === 'function') { |
| | | for (const item of tcpService.getConnectionSnapshot()) { |
| | | connectionMap.set(item.deviceId, item); |
| | | } |
| | | } |
| | | |
| | | const deviceSnapshots = devices.map((device) => { |
| | | const connection = connectionMap.get(device.deviceId) || null; |
| | | const cacheEntry = cacheMap.get(device.deviceId) || null; |
| | | const lastUpdateAt = cacheEntry ? cacheEntry.lastUpdateAt : 0; |
| | | const ageMs = lastUpdateAt > 0 ? now - lastUpdateAt : null; |
| | | const dataStatus = !lastUpdateAt |
| | | ? 'waiting' |
| | | : (ageMs <= staleDataMs ? 'active' : 'stale'); |
| | | |
| | | return { |
| | | deviceId: device.deviceId, |
| | | name: device.name || device.deviceId, |
| | | ip: device.ip, |
| | | online: Boolean(connection), |
| | | dataStatus, |
| | | dataAgeMs: ageMs, |
| | | connectedAt: connection ? connection.connectedAt : 0, |
| | | lastSocketDataAt: connection ? connection.lastDataAt : 0, |
| | | lastUpdateAt, |
| | | lastRealtimeAt: cacheEntry ? cacheEntry.lastRealtimeAt : 0, |
| | | lastBloodPressureAt: cacheEntry ? cacheEntry.lastBloodPressureAt : 0, |
| | | payload: cacheEntry ? cacheEntry.payload : {}, |
| | | }; |
| | | }); |
| | | |
| | | const onlineCount = deviceSnapshots.filter((item) => item.online).length; |
| | | const activeCount = deviceSnapshots.filter((item) => item.dataStatus === 'active').length; |
| | | const waitingCount = deviceSnapshots.filter((item) => item.dataStatus === 'waiting').length; |
| | | const staleCount = deviceSnapshots.filter((item) => item.dataStatus === 'stale').length; |
| | | |
| | | return { |
| | | title: config.title || 'JH2028 设备中央监测大屏', |
| | | generatedAt: now, |
| | | generatedAtText: nowIso(), |
| | | totals: { |
| | | devices: deviceSnapshots.length, |
| | | online: onlineCount, |
| | | offline: deviceSnapshots.length - onlineCount, |
| | | active: activeCount, |
| | | waiting: waitingCount, |
| | | stale: staleCount, |
| | | }, |
| | | devices: deviceSnapshots, |
| | | }; |
| | | } |
| | | |
| | | class DashboardService { |
| | | constructor(options = {}) { |
| | | this.config = options.config || {}; |
| | | this.logger = options.logger || console; |
| | | this.getSnapshot = typeof options.getSnapshot === 'function' ? options.getSnapshot : () => ({ devices: [] }); |
| | | this.dashboardDir = getDashboardDir(options); |
| | | this.server = null; |
| | | this.clients = new Set(); |
| | | } |
| | | |
| | | async start() { |
| | | if (this.config.enabled === false) { |
| | | this.logger.info('[DASHBOARD] 大屏服务未启用'); |
| | | return; |
| | | } |
| | | |
| | | this.server = http.createServer((request, response) => { |
| | | this.handleRequest(request, response); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | | this.server.once('listening', resolve); |
| | | this.server.once('error', reject); |
| | | this.server.listen({ |
| | | host: this.config.host || '0.0.0.0', |
| | | port: this.config.port || 9100, |
| | | }); |
| | | }); |
| | | |
| | | const host = this.config.host || '0.0.0.0'; |
| | | const port = this.config.port || 9100; |
| | | const localUrl = `http://127.0.0.1:${port}`; |
| | | const listenText = host === '0.0.0.0' |
| | | ? '监听所有网卡,局域网访问请使用服务器真实 IP' |
| | | : `监听地址=${host}`; |
| | | |
| | | this.logger.info(`[DASHBOARD] 大屏服务已启动 ${listenText} 本机访问=${localUrl}`); |
| | | } |
| | | |
| | | async stop() { |
| | | for (const client of this.clients) { |
| | | client.end(); |
| | | } |
| | | |
| | | this.clients.clear(); |
| | | |
| | | if (!this.server) { |
| | | return; |
| | | } |
| | | |
| | | await new Promise((resolve) => { |
| | | this.server.close(resolve); |
| | | }); |
| | | this.server = null; |
| | | this.logger.info('[DASHBOARD] 大屏服务已停止'); |
| | | } |
| | | |
| | | handleRequest(request, response) { |
| | | const url = new URL(request.url, 'http://localhost'); |
| | | |
| | | if (url.pathname === '/api/snapshot') { |
| | | this.sendJson(response, this.getSnapshot()); |
| | | return; |
| | | } |
| | | |
| | | if (url.pathname === '/events') { |
| | | this.handleEvents(response); |
| | | return; |
| | | } |
| | | |
| | | const pathname = url.pathname === '/' ? '/index.html' : url.pathname; |
| | | this.serveStatic(pathname, response); |
| | | } |
| | | |
| | | handleEvents(response) { |
| | | response.writeHead(200, { |
| | | 'Content-Type': 'text/event-stream; charset=utf-8', |
| | | 'Cache-Control': 'no-cache', |
| | | Connection: 'keep-alive', |
| | | 'Access-Control-Allow-Origin': '*', |
| | | }); |
| | | response.write('\n'); |
| | | this.clients.add(response); |
| | | this.writeEvent(response, this.getSnapshot()); |
| | | |
| | | response.on('close', () => { |
| | | this.clients.delete(response); |
| | | }); |
| | | } |
| | | |
| | | broadcastSnapshot() { |
| | | const snapshot = this.getSnapshot(); |
| | | |
| | | for (const client of this.clients) { |
| | | this.writeEvent(client, snapshot); |
| | | } |
| | | } |
| | | |
| | | writeEvent(response, snapshot) { |
| | | response.write(`event: snapshot\n`); |
| | | response.write(`data: ${JSON.stringify(snapshot)}\n\n`); |
| | | } |
| | | |
| | | sendJson(response, payload) { |
| | | response.writeHead(200, { |
| | | 'Content-Type': 'application/json; charset=utf-8', |
| | | 'Cache-Control': 'no-cache', |
| | | }); |
| | | response.end(JSON.stringify(payload)); |
| | | } |
| | | |
| | | serveStatic(pathname, response) { |
| | | const normalizedPath = path.normalize(pathname.replace(/^\/+/, '')); |
| | | |
| | | if (path.isAbsolute(normalizedPath) || normalizedPath.startsWith('..')) { |
| | | response.writeHead(403); |
| | | response.end('Forbidden'); |
| | | return; |
| | | } |
| | | |
| | | const filePath = path.join(this.dashboardDir, normalizedPath); |
| | | |
| | | if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { |
| | | response.writeHead(404); |
| | | response.end('Not Found'); |
| | | return; |
| | | } |
| | | |
| | | const extension = path.extname(filePath).toLowerCase(); |
| | | response.writeHead(200, { |
| | | 'Content-Type': CONTENT_TYPES[extension] || 'application/octet-stream', |
| | | }); |
| | | fs.createReadStream(filePath).pipe(response); |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | DashboardService, |
| | | buildDashboardSnapshot, |
| | | getDashboardDir, |
| | | }; |
| New file |
| | |
| | | const state = { |
| | | snapshot: null, |
| | | selectedDeviceId: '', |
| | | }; |
| | | |
| | | const metricGroups = [ |
| | | { |
| | | title: '温度与治疗参数', |
| | | keys: [ |
| | | ['AF', '设定温度'], |
| | | ['F', '当前温度'], |
| | | ['A', '设定超滤总量'], |
| | | ['C', '超滤率'], |
| | | ['B', '超滤量'], |
| | | ['K', '剩余时间'], |
| | | ], |
| | | }, |
| | | { |
| | | title: '流量与压力', |
| | | keys: [ |
| | | ['L', '透析液流量'], |
| | | ['D', '有效血流量'], |
| | | ['H', '静脉压'], |
| | | ['o', '动脉压'], |
| | | ['J', '跨膜压'], |
| | | ['U', '累计血流量'], |
| | | ], |
| | | }, |
| | | { |
| | | title: '电解质与血液监测', |
| | | keys: [ |
| | | ['G', '电导率'], |
| | | ['Na', '钠'], |
| | | ['HCO3', '碳酸氢根'], |
| | | ['O2Sat', '血氧饱和度'], |
| | | ['Hct', '红细胞比容'], |
| | | ['Hb', '血红蛋白'], |
| | | ['Tblood', '血液温度'], |
| | | ['ktv', 'Kt/V'], |
| | | ], |
| | | }, |
| | | { |
| | | title: '血压数据', |
| | | keys: [ |
| | | ['N', '收缩压'], |
| | | ['O', '舒张压'], |
| | | ['P', '心率'], |
| | | ['M', '血压监测时间'], |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | function $(selector) { |
| | | return document.querySelector(selector); |
| | | } |
| | | |
| | | function formatClock(date = new Date()) { |
| | | return date.toLocaleTimeString('zh-CN', { hour12: false }); |
| | | } |
| | | |
| | | function formatTime(value) { |
| | | if (!value) { |
| | | return '暂无'; |
| | | } |
| | | |
| | | return new Date(value).toLocaleString('zh-CN', { hour12: false }); |
| | | } |
| | | |
| | | function formatAge(ageMs) { |
| | | if (ageMs === null || ageMs === undefined) { |
| | | return '暂无数据'; |
| | | } |
| | | |
| | | const seconds = Math.max(0, Math.floor(ageMs / 1000)); |
| | | if (seconds < 60) { |
| | | return `${seconds} 秒前`; |
| | | } |
| | | |
| | | const minutes = Math.floor(seconds / 60); |
| | | if (minutes < 60) { |
| | | return `${minutes} 分钟前`; |
| | | } |
| | | |
| | | return `${Math.floor(minutes / 60)} 小时前`; |
| | | } |
| | | |
| | | function getDataStatusText(status) { |
| | | if (status === 'active') { |
| | | return '数据正常'; |
| | | } |
| | | |
| | | if (status === 'stale') { |
| | | return '数据超时'; |
| | | } |
| | | |
| | | return '等待数据'; |
| | | } |
| | | |
| | | function setText(id, value) { |
| | | const element = $(id); |
| | | if (element) { |
| | | element.textContent = value; |
| | | } |
| | | } |
| | | |
| | | function render(snapshot) { |
| | | state.snapshot = snapshot; |
| | | |
| | | if (!state.selectedDeviceId && snapshot.devices.length > 0) { |
| | | state.selectedDeviceId = snapshot.devices[0].deviceId; |
| | | } |
| | | |
| | | if (state.selectedDeviceId && !snapshot.devices.some((device) => device.deviceId === state.selectedDeviceId)) { |
| | | state.selectedDeviceId = snapshot.devices[0] ? snapshot.devices[0].deviceId : ''; |
| | | } |
| | | |
| | | setText('#dashboard-title', snapshot.title || '设备中央监测大屏'); |
| | | setText('#refresh-time', `最后刷新 ${formatTime(snapshot.generatedAt)}`); |
| | | setText('#online-count', snapshot.totals.online); |
| | | setText('#offline-count', snapshot.totals.offline); |
| | | setText('#active-count', snapshot.totals.active); |
| | | setText('#stale-count', snapshot.totals.waiting + snapshot.totals.stale); |
| | | setText('#device-total', `${snapshot.totals.devices} 台设备`); |
| | | |
| | | renderDeviceList(snapshot.devices); |
| | | renderDetail(snapshot.devices.find((device) => device.deviceId === state.selectedDeviceId)); |
| | | } |
| | | |
| | | function renderDeviceList(devices) { |
| | | const container = $('#device-list'); |
| | | |
| | | container.innerHTML = ''; |
| | | |
| | | for (const device of devices) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = `device-card ${device.deviceId === state.selectedDeviceId ? 'selected' : ''}`; |
| | | button.innerHTML = ` |
| | | <div> |
| | | <div class="device-name">${escapeHtml(device.name)}</div> |
| | | <div class="device-meta">${escapeHtml(device.deviceId)} | ${escapeHtml(device.ip)} | ${formatAge(device.dataAgeMs)}</div> |
| | | </div> |
| | | <div class="badges"> |
| | | <span class="badge ${device.online ? 'online' : 'offline'}">${device.online ? '在线' : '离线'}</span> |
| | | <span class="badge ${device.dataStatus}">${getDataStatusText(device.dataStatus)}</span> |
| | | </div> |
| | | `; |
| | | button.addEventListener('click', () => { |
| | | state.selectedDeviceId = device.deviceId; |
| | | render(state.snapshot); |
| | | }); |
| | | container.appendChild(button); |
| | | } |
| | | } |
| | | |
| | | function renderDetail(device) { |
| | | const body = $('#detail-body'); |
| | | |
| | | if (!device) { |
| | | $('#detail-title').textContent = '设备数据状态'; |
| | | $('#detail-state').textContent = '未选择'; |
| | | body.className = 'detail-body empty'; |
| | | body.innerHTML = '<p>等待设备数据接入</p>'; |
| | | return; |
| | | } |
| | | |
| | | $('#detail-title').textContent = `${device.name} 数据状态`; |
| | | $('#detail-state').textContent = `${device.online ? '在线' : '离线'} / ${getDataStatusText(device.dataStatus)}`; |
| | | body.className = 'detail-body'; |
| | | body.innerHTML = ` |
| | | <div class="timeline"> |
| | | ${renderTimeBox('最近连接', device.connectedAt)} |
| | | ${renderTimeBox('最近实时数据', device.lastRealtimeAt)} |
| | | ${renderTimeBox('最近血压数据', device.lastBloodPressureAt)} |
| | | </div> |
| | | ${metricGroups.map((group) => renderMetricGroup(group, device.payload || {})).join('')} |
| | | `; |
| | | } |
| | | |
| | | function renderTimeBox(label, value) { |
| | | return ` |
| | | <div class="timebox"> |
| | | <span>${label}</span> |
| | | <strong>${formatTime(value)}</strong> |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | function renderMetricGroup(group, payload) { |
| | | return ` |
| | | <section class="metric-group"> |
| | | <h3>${group.title}</h3> |
| | | <div class="metric-grid"> |
| | | ${group.keys.map(([key, label]) => renderMetric(key, label, payload[key])).join('')} |
| | | </div> |
| | | </section> |
| | | `; |
| | | } |
| | | |
| | | function renderMetric(key, label, value) { |
| | | const text = value === undefined || value === null || value === '' ? '--' : value; |
| | | return ` |
| | | <div class="metric"> |
| | | <label>${label} (${key})</label> |
| | | <strong>${escapeHtml(String(text))}</strong> |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | function escapeHtml(value) { |
| | | return value |
| | | .replace(/&/g, '&') |
| | | .replace(/</g, '<') |
| | | .replace(/>/g, '>') |
| | | .replace(/"/g, '"') |
| | | .replace(/'/g, '''); |
| | | } |
| | | |
| | | async function fetchSnapshot() { |
| | | const response = await fetch('/api/snapshot', { cache: 'no-store' }); |
| | | if (!response.ok) { |
| | | throw new Error(`HTTP ${response.status}`); |
| | | } |
| | | |
| | | return response.json(); |
| | | } |
| | | |
| | | function startEvents() { |
| | | if (!window.EventSource) { |
| | | return false; |
| | | } |
| | | |
| | | const events = new EventSource('/events'); |
| | | events.addEventListener('snapshot', (event) => { |
| | | render(JSON.parse(event.data)); |
| | | }); |
| | | events.onerror = () => { |
| | | events.close(); |
| | | startPolling(); |
| | | }; |
| | | return true; |
| | | } |
| | | |
| | | function startPolling() { |
| | | const load = () => { |
| | | fetchSnapshot() |
| | | .then(render) |
| | | .catch(() => { |
| | | setText('#refresh-time', '连接大屏服务失败'); |
| | | }); |
| | | }; |
| | | |
| | | load(); |
| | | setInterval(load, 3000); |
| | | } |
| | | |
| | | setInterval(() => { |
| | | setText('#now-time', formatClock()); |
| | | }, 1000); |
| | | setText('#now-time', formatClock()); |
| | | |
| | | if (!startEvents()) { |
| | | startPolling(); |
| | | } |
| New file |
| | |
| | | <!doctype html> |
| | | <html lang="zh-CN"> |
| | | <head> |
| | | <meta charset="utf-8"> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | | <title>JH2028 设备中央监测大屏</title> |
| | | <link rel="stylesheet" href="./styles.css"> |
| | | </head> |
| | | <body> |
| | | <main class="shell"> |
| | | <header class="topbar"> |
| | | <div> |
| | | <p class="eyebrow">JH2028 TCP Gateway</p> |
| | | <h1 id="dashboard-title">设备中央监测大屏</h1> |
| | | </div> |
| | | <div class="clock"> |
| | | <span id="now-time">--:--:--</span> |
| | | <small id="refresh-time">等待数据</small> |
| | | </div> |
| | | </header> |
| | | |
| | | <section class="summary" aria-label="设备总览"> |
| | | <article class="summary-item online"> |
| | | <span>在线设备</span> |
| | | <strong id="online-count">0</strong> |
| | | </article> |
| | | <article class="summary-item offline"> |
| | | <span>离线设备</span> |
| | | <strong id="offline-count">0</strong> |
| | | </article> |
| | | <article class="summary-item active"> |
| | | <span>数据正常</span> |
| | | <strong id="active-count">0</strong> |
| | | </article> |
| | | <article class="summary-item stale"> |
| | | <span>等待/超时</span> |
| | | <strong id="stale-count">0</strong> |
| | | </article> |
| | | </section> |
| | | |
| | | <section class="layout"> |
| | | <section class="device-list" aria-label="设备列表"> |
| | | <div class="section-head"> |
| | | <h2>设备连接状态</h2> |
| | | <span id="device-total">0 台设备</span> |
| | | </div> |
| | | <div id="device-list" class="device-grid"></div> |
| | | </section> |
| | | |
| | | <section class="detail" aria-label="设备数据详情"> |
| | | <div class="section-head"> |
| | | <h2 id="detail-title">设备数据状态</h2> |
| | | <span id="detail-state">未选择</span> |
| | | </div> |
| | | <div id="detail-body" class="detail-body empty"> |
| | | <p>等待设备数据接入</p> |
| | | </div> |
| | | </section> |
| | | </section> |
| | | </main> |
| | | |
| | | <script src="./app.js"></script> |
| | | </body> |
| | | </html> |
| New file |
| | |
| | | :root { |
| | | --bg: #071015; |
| | | --panel: rgba(18, 34, 41, 0.92); |
| | | --panel-strong: rgba(21, 44, 52, 0.98); |
| | | --line: rgba(137, 174, 183, 0.24); |
| | | --text: #eef7f5; |
| | | --muted: #9fb6b7; |
| | | --cyan: #4bd4d2; |
| | | --green: #50d890; |
| | | --amber: #e9c46a; |
| | | --red: #ff6b6b; |
| | | --blue: #78a6ff; |
| | | --shadow: 0 24px 60px rgba(0, 0, 0, 0.35); |
| | | } |
| | | |
| | | * { |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | body { |
| | | min-height: 100vh; |
| | | margin: 0; |
| | | color: var(--text); |
| | | font-family: "Aptos Display", "Microsoft YaHei UI", "Segoe UI", sans-serif; |
| | | background: |
| | | radial-gradient(circle at top left, rgba(75, 212, 210, 0.18), transparent 34rem), |
| | | linear-gradient(135deg, #061014 0%, #0f2228 46%, #111b1f 100%); |
| | | } |
| | | |
| | | body::before { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: -1; |
| | | content: ""; |
| | | background-image: |
| | | linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), |
| | | linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px); |
| | | background-size: 36px 36px; |
| | | mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.88), rgba(0, 0, 0, 0.2)); |
| | | } |
| | | |
| | | .shell { |
| | | width: min(1760px, calc(100vw - 40px)); |
| | | margin: 0 auto; |
| | | padding: 28px 0 34px; |
| | | } |
| | | |
| | | .topbar, |
| | | .summary, |
| | | .layout { |
| | | display: grid; |
| | | gap: 18px; |
| | | } |
| | | |
| | | .topbar { |
| | | grid-template-columns: 1fr auto; |
| | | align-items: end; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .eyebrow { |
| | | margin: 0 0 8px; |
| | | color: var(--cyan); |
| | | font-size: 13px; |
| | | letter-spacing: 0; |
| | | text-transform: uppercase; |
| | | } |
| | | |
| | | h1, |
| | | h2, |
| | | p { |
| | | margin: 0; |
| | | } |
| | | |
| | | h1 { |
| | | font-size: 32px; |
| | | font-weight: 760; |
| | | } |
| | | |
| | | h2 { |
| | | font-size: 18px; |
| | | font-weight: 700; |
| | | } |
| | | |
| | | .clock { |
| | | min-width: 220px; |
| | | padding: 14px 18px; |
| | | text-align: right; |
| | | background: rgba(255, 255, 255, 0.07); |
| | | border: 1px solid var(--line); |
| | | border-radius: 8px; |
| | | } |
| | | |
| | | .clock span { |
| | | display: block; |
| | | font-size: 24px; |
| | | font-weight: 760; |
| | | } |
| | | |
| | | .clock small { |
| | | color: var(--muted); |
| | | } |
| | | |
| | | .summary { |
| | | grid-template-columns: repeat(4, minmax(0, 1fr)); |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .summary-item { |
| | | min-height: 116px; |
| | | padding: 20px; |
| | | background: var(--panel); |
| | | border: 1px solid var(--line); |
| | | border-left: 4px solid var(--cyan); |
| | | border-radius: 8px; |
| | | box-shadow: var(--shadow); |
| | | } |
| | | |
| | | .summary-item span { |
| | | display: block; |
| | | color: var(--muted); |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .summary-item strong { |
| | | display: block; |
| | | margin-top: 12px; |
| | | font-size: 44px; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .summary-item.online { |
| | | border-left-color: var(--green); |
| | | } |
| | | |
| | | .summary-item.offline { |
| | | border-left-color: var(--red); |
| | | } |
| | | |
| | | .summary-item.active { |
| | | border-left-color: var(--blue); |
| | | } |
| | | |
| | | .summary-item.stale { |
| | | border-left-color: var(--amber); |
| | | } |
| | | |
| | | .layout { |
| | | grid-template-columns: minmax(420px, 0.86fr) minmax(520px, 1.14fr); |
| | | align-items: start; |
| | | } |
| | | |
| | | .device-list, |
| | | .detail { |
| | | min-height: 520px; |
| | | padding: 18px; |
| | | background: var(--panel); |
| | | border: 1px solid var(--line); |
| | | border-radius: 8px; |
| | | box-shadow: var(--shadow); |
| | | } |
| | | |
| | | .section-head { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 16px; |
| | | padding-bottom: 14px; |
| | | border-bottom: 1px solid var(--line); |
| | | } |
| | | |
| | | .section-head span { |
| | | color: var(--muted); |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .device-grid { |
| | | display: grid; |
| | | gap: 12px; |
| | | margin-top: 16px; |
| | | } |
| | | |
| | | .device-card { |
| | | display: grid; |
| | | grid-template-columns: 1fr auto; |
| | | gap: 10px; |
| | | width: 100%; |
| | | padding: 14px; |
| | | color: inherit; |
| | | text-align: left; |
| | | background: rgba(255, 255, 255, 0.055); |
| | | border: 1px solid transparent; |
| | | border-radius: 8px; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .device-card:hover, |
| | | .device-card.selected { |
| | | border-color: rgba(75, 212, 210, 0.7); |
| | | background: rgba(75, 212, 210, 0.09); |
| | | } |
| | | |
| | | .device-name { |
| | | overflow: hidden; |
| | | font-size: 16px; |
| | | font-weight: 720; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .device-meta { |
| | | margin-top: 7px; |
| | | color: var(--muted); |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .badges { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .badge { |
| | | display: inline-flex; |
| | | min-width: 56px; |
| | | justify-content: center; |
| | | padding: 5px 8px; |
| | | color: #041015; |
| | | font-size: 12px; |
| | | font-weight: 760; |
| | | border-radius: 999px; |
| | | } |
| | | |
| | | .badge.online, |
| | | .badge.active { |
| | | background: var(--green); |
| | | } |
| | | |
| | | .badge.offline { |
| | | background: var(--red); |
| | | } |
| | | |
| | | .badge.waiting, |
| | | .badge.stale { |
| | | background: var(--amber); |
| | | } |
| | | |
| | | .detail-body { |
| | | padding-top: 16px; |
| | | } |
| | | |
| | | .detail-body.empty { |
| | | display: grid; |
| | | min-height: 420px; |
| | | place-items: center; |
| | | color: var(--muted); |
| | | border: 1px dashed var(--line); |
| | | border-radius: 8px; |
| | | } |
| | | |
| | | .metric-group { |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .metric-group h3 { |
| | | margin: 0 0 10px; |
| | | color: var(--cyan); |
| | | font-size: 14px; |
| | | font-weight: 720; |
| | | } |
| | | |
| | | .metric-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(4, minmax(118px, 1fr)); |
| | | gap: 10px; |
| | | } |
| | | |
| | | .metric { |
| | | min-height: 74px; |
| | | padding: 12px; |
| | | background: rgba(255, 255, 255, 0.055); |
| | | border: 1px solid var(--line); |
| | | border-radius: 8px; |
| | | } |
| | | |
| | | .metric label { |
| | | display: block; |
| | | min-height: 18px; |
| | | color: var(--muted); |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .metric strong { |
| | | display: block; |
| | | margin-top: 7px; |
| | | overflow-wrap: anywhere; |
| | | font-size: 20px; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .timeline { |
| | | display: grid; |
| | | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| | | gap: 10px; |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .timebox { |
| | | padding: 12px; |
| | | background: var(--panel-strong); |
| | | border: 1px solid var(--line); |
| | | border-radius: 8px; |
| | | } |
| | | |
| | | .timebox span { |
| | | display: block; |
| | | color: var(--muted); |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .timebox strong { |
| | | display: block; |
| | | margin-top: 6px; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | @media (max-width: 1180px) { |
| | | .summary { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | |
| | | .layout { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 720px) { |
| | | .shell { |
| | | width: min(100vw - 24px, 1760px); |
| | | padding-top: 18px; |
| | | } |
| | | |
| | | .topbar { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | |
| | | .clock { |
| | | width: 100%; |
| | | text-align: left; |
| | | } |
| | | |
| | | .summary, |
| | | .metric-grid, |
| | | .timeline { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | |
| | | h1 { |
| | | font-size: 26px; |
| | | } |
| | | } |
| New file |
| | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | |
| | | const HEADER_1 = 0x55; |
| | | const HEADER_2 = 0xAA; |
| | | const MIN_FRAME_LENGTH = 7; |
| | | const CMDTYPE_DATA_SYNC = 0x01; |
| | | const CMDID_REALTIME = 0x00; |
| | | const CMDID_BLOOD_PRESSURE = 0x01; |
| | | const REALTIME_PAYLOAD_LENGTH = 46; |
| | | const BLOOD_PRESSURE_PAYLOAD_LENGTH = 5; |
| | | |
| | | const REALTIME_FIELDS = [ |
| | | { identifier: 'AF', offset: 0, read: readInt16LE, scale: 10 }, |
| | | { identifier: 'F', offset: 2, read: readInt16LE, scale: 10 }, |
| | | { identifier: 'A', offset: 4, read: readUInt16LE }, |
| | | { identifier: 'C', offset: 6, read: readUInt16LE }, |
| | | { identifier: 'B', offset: 8, read: readUInt16LE }, |
| | | { identifier: 'K', offset: 10, read: readUInt16LE }, |
| | | { identifier: 'L', offset: 12, read: readUInt16LE }, |
| | | { identifier: 'D', offset: 14, read: readUInt16LE }, |
| | | { identifier: 'H', offset: 16, read: readInt16LE }, |
| | | { identifier: 'o', offset: 18, read: readInt16LE }, |
| | | { identifier: 'J', offset: 20, read: readInt16LE }, |
| | | { identifier: 'U', offset: 22, read: readUInt32LE }, |
| | | { identifier: 'G', offset: 26, read: readUInt16LE }, |
| | | { identifier: 'Na', offset: 28, read: readUInt16LE }, |
| | | { identifier: 'HCO3', offset: 30, read: readUInt16LE }, |
| | | { identifier: 'O2Sat', offset: 36, read: readUInt16LE, scale: 10 }, |
| | | { identifier: 'Hct', offset: 38, read: readUInt16LE, scale: 10 }, |
| | | { identifier: 'Hb', offset: 40, read: readUInt16LE, scale: 10 }, |
| | | { identifier: 'Tblood', offset: 42, read: readUInt16LE, scale: 10 }, |
| | | { identifier: 'ktv', offset: 44, read: readUInt16LE, scale: 10 }, |
| | | ]; |
| | | |
| | | function toHex(value) { |
| | | return `0x${value.toString(16).toUpperCase().padStart(2, '0')}`; |
| | | } |
| | | |
| | | function bytesToHex(buffer) { |
| | | return Array.from(buffer, toHex).join(' '); |
| | | } |
| | | |
| | | function crc8(bytes) { |
| | | let crc = 0; |
| | | |
| | | for (const value of bytes) { |
| | | crc ^= value; |
| | | |
| | | for (let index = 0; index < 8; index += 1) { |
| | | if ((crc & 0x01) !== 0) { |
| | | crc = ((crc >> 1) ^ 0x8C) & 0xFF; |
| | | } else { |
| | | crc = (crc >> 1) & 0xFF; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return crc & 0xFF; |
| | | } |
| | | |
| | | function readUInt16LE(buffer, offset) { |
| | | return buffer.readUInt16LE(offset); |
| | | } |
| | | |
| | | function readInt16LE(buffer, offset) { |
| | | return buffer.readInt16LE(offset); |
| | | } |
| | | |
| | | function readUInt32LE(buffer, offset) { |
| | | return buffer.readUInt32LE(offset); |
| | | } |
| | | |
| | | function scaled(value, scale) { |
| | | if (!scale) { |
| | | return value; |
| | | } |
| | | |
| | | return Number((value / scale).toFixed(1)); |
| | | } |
| | | |
| | | function resolveFilePath(filePath) { |
| | | if (path.isAbsolute(filePath)) { |
| | | return filePath; |
| | | } |
| | | |
| | | return path.join(process.cwd(), filePath); |
| | | } |
| | | |
| | | function loadAlModelMap(filePath) { |
| | | const resolvedPath = resolveFilePath(filePath); |
| | | const content = fs.readFileSync(resolvedPath, 'utf8'); |
| | | const model = JSON.parse(content); |
| | | const map = new Map(); |
| | | |
| | | for (const item of model.properties || []) { |
| | | if (item && item.identifier) { |
| | | map.set(item.identifier, item.name || item.identifier); |
| | | } |
| | | } |
| | | |
| | | return map; |
| | | } |
| | | |
| | | class Jh2028Decoder { |
| | | constructor(options = {}) { |
| | | this.buffer = Buffer.alloc(0); |
| | | this.maxBufferBytes = options.maxBufferBytes || 8192; |
| | | this.alModelMap = loadAlModelMap(options.alModelPath || './alModel.json'); |
| | | } |
| | | |
| | | push(chunk) { |
| | | if (!Buffer.isBuffer(chunk) || chunk.length === 0) { |
| | | return []; |
| | | } |
| | | |
| | | this.buffer = Buffer.concat([this.buffer, chunk]); |
| | | |
| | | if (this.buffer.length > this.maxBufferBytes) { |
| | | this.buffer = Buffer.alloc(0); |
| | | return [{ ok: false, publish: false, reason: 'buffer-overflow' }]; |
| | | } |
| | | |
| | | const results = []; |
| | | |
| | | while (this.buffer.length >= 2) { |
| | | const headerIndex = this.findHeader(); |
| | | |
| | | if (headerIndex < 0) { |
| | | this.buffer = this.buffer.slice(Math.max(0, this.buffer.length - 1)); |
| | | break; |
| | | } |
| | | |
| | | if (headerIndex > 0) { |
| | | this.buffer = this.buffer.slice(headerIndex); |
| | | } |
| | | |
| | | if (this.buffer.length < 3) { |
| | | break; |
| | | } |
| | | |
| | | const frameLength = this.buffer[2]; |
| | | |
| | | if (frameLength < MIN_FRAME_LENGTH) { |
| | | this.buffer = this.buffer.slice(1); |
| | | continue; |
| | | } |
| | | |
| | | if (this.buffer.length < frameLength) { |
| | | break; |
| | | } |
| | | |
| | | const frame = this.buffer.slice(0, frameLength); |
| | | this.buffer = this.buffer.slice(frameLength); |
| | | results.push(this.parseFrame(frame)); |
| | | } |
| | | |
| | | return results; |
| | | } |
| | | |
| | | findHeader() { |
| | | for (let index = 0; index <= this.buffer.length - 2; index += 1) { |
| | | if (this.buffer[index] === HEADER_1 && this.buffer[index + 1] === HEADER_2) { |
| | | return index; |
| | | } |
| | | } |
| | | |
| | | return -1; |
| | | } |
| | | |
| | | parseFrame(frame) { |
| | | const frameLength = frame[2]; |
| | | const timestamp = frame[3]; |
| | | const commandType = frame[4]; |
| | | const commandId = frame[5]; |
| | | const payload = frame.slice(6, frame.length - 1); |
| | | const receivedCrc = frame[frame.length - 1]; |
| | | const expectedCrc = crc8(frame.slice(0, -1)); |
| | | const rawHex = bytesToHex(frame); |
| | | |
| | | if (frameLength !== frame.length) { |
| | | return this.buildErrorResult('length-invalid', frame, { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | }); |
| | | } |
| | | |
| | | if (receivedCrc !== expectedCrc) { |
| | | return this.buildErrorResult('crc-invalid', frame, { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | receivedCrc, |
| | | expectedCrc, |
| | | }); |
| | | } |
| | | |
| | | if (commandType !== CMDTYPE_DATA_SYNC) { |
| | | return this.buildSkipResult('unsupported-command-type', { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | }); |
| | | } |
| | | |
| | | if (commandId === CMDID_REALTIME) { |
| | | return this.parseRealtimeFrame(payload, { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | }); |
| | | } |
| | | |
| | | if (commandId === CMDID_BLOOD_PRESSURE) { |
| | | return this.parseBloodPressureFrame(payload, { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | }); |
| | | } |
| | | |
| | | return this.buildSkipResult('unsupported-command-id', { |
| | | timestamp, |
| | | commandType, |
| | | commandId, |
| | | rawHex, |
| | | }); |
| | | } |
| | | |
| | | parseRealtimeFrame(payload, meta) { |
| | | if (payload.length < REALTIME_PAYLOAD_LENGTH) { |
| | | return this.buildErrorResult('payload-too-short', payload, meta); |
| | | } |
| | | |
| | | const metric = {}; |
| | | |
| | | for (const field of REALTIME_FIELDS) { |
| | | const rawValue = field.read(payload, field.offset); |
| | | metric[field.identifier] = scaled(rawValue, field.scale); |
| | | } |
| | | |
| | | return this.buildMetricResult(metric, { |
| | | ...meta, |
| | | protocol: 'jh2028-20260511', |
| | | messageType: 'realtime', |
| | | }); |
| | | } |
| | | |
| | | parseBloodPressureFrame(payload, meta) { |
| | | if (payload.length < BLOOD_PRESSURE_PAYLOAD_LENGTH) { |
| | | return this.buildErrorResult('payload-too-short', payload, meta); |
| | | } |
| | | |
| | | const systolicOrErrorCode = payload[0]; |
| | | const diastolic = payload[1]; |
| | | const pulse = payload[2]; |
| | | const irregularPulse = payload[3]; |
| | | const meanPressure = payload[4]; |
| | | const hasError = systolicOrErrorCode > 0 |
| | | && diastolic === 0 |
| | | && pulse === 0 |
| | | && irregularPulse === 0; |
| | | |
| | | if (hasError) { |
| | | return this.buildSkipResult('blood-pressure-error-code', { |
| | | ...meta, |
| | | protocol: 'jh2028-20260511', |
| | | messageType: 'blood-pressure', |
| | | errorCode: systolicOrErrorCode, |
| | | }); |
| | | } |
| | | |
| | | return this.buildMetricResult({ |
| | | N: systolicOrErrorCode, |
| | | O: diastolic, |
| | | P: pulse, |
| | | }, { |
| | | ...meta, |
| | | protocol: 'jh2028-20260511', |
| | | messageType: 'blood-pressure', |
| | | ignored: { |
| | | irregularPulse, |
| | | meanPressure, |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | buildMetricResult(metric, meta) { |
| | | const entries = Object.entries(metric) |
| | | .filter(([, value]) => value !== undefined && value !== null) |
| | | .filter(([identifier]) => this.alModelMap.has(identifier)); |
| | | |
| | | if (entries.length === 0) { |
| | | return this.buildSkipResult('identifier-not-in-almodel', meta); |
| | | } |
| | | |
| | | return { |
| | | ok: true, |
| | | publish: true, |
| | | reason: 'ok', |
| | | metric: Object.fromEntries(entries), |
| | | identifiers: entries.map(([identifier]) => identifier), |
| | | ...meta, |
| | | }; |
| | | } |
| | | |
| | | buildErrorResult(reason, _frame, meta = {}) { |
| | | return { |
| | | ok: false, |
| | | publish: false, |
| | | reason, |
| | | ...meta, |
| | | }; |
| | | } |
| | | |
| | | buildSkipResult(reason, meta = {}) { |
| | | return { |
| | | ok: true, |
| | | publish: false, |
| | | reason, |
| | | ...meta, |
| | | }; |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | BLOOD_PRESSURE_PAYLOAD_LENGTH, |
| | | CMDID_BLOOD_PRESSURE, |
| | | CMDID_REALTIME, |
| | | CMDTYPE_DATA_SYNC, |
| | | HEADER_1, |
| | | HEADER_2, |
| | | Jh2028Decoder, |
| | | MIN_FRAME_LENGTH, |
| | | REALTIME_FIELDS, |
| | | REALTIME_PAYLOAD_LENGTH, |
| | | bytesToHex, |
| | | crc8, |
| | | loadAlModelMap, |
| | | toHex, |
| | | }; |
| New file |
| | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | |
| | | const LOG_LEVELS = { |
| | | debug: 10, |
| | | info: 20, |
| | | warn: 30, |
| | | error: 40, |
| | | }; |
| | | |
| | | function formatLocalTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(date.getDate()).padStart(2, '0'); |
| | | const hour = String(date.getHours()).padStart(2, '0'); |
| | | const minute = String(date.getMinutes()).padStart(2, '0'); |
| | | const second = String(date.getSeconds()).padStart(2, '0'); |
| | | const millisecond = String(date.getMilliseconds()).padStart(3, '0'); |
| | | return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`; |
| | | } |
| | | |
| | | function normalizeLogLevel(level) { |
| | | const normalized = String(level || 'info').trim().toLowerCase(); |
| | | return LOG_LEVELS[normalized] ? normalized : 'info'; |
| | | } |
| | | |
| | | function createLogger(loggingConfig = {}) { |
| | | const settings = { |
| | | enabled: loggingConfig.enabled !== false, |
| | | console: loggingConfig.console !== false, |
| | | dir: loggingConfig.dir || path.join(process.cwd(), 'logs'), |
| | | filePrefix: loggingConfig.filePrefix || 'jh2028-service', |
| | | level: normalizeLogLevel(loggingConfig.level), |
| | | }; |
| | | let currentDate = ''; |
| | | let currentFilePath = ''; |
| | | |
| | | function getDatePart(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(date.getDate()).padStart(2, '0'); |
| | | return `${year}${month}${day}`; |
| | | } |
| | | |
| | | function ensureLogFilePath() { |
| | | if (!settings.enabled) { |
| | | return null; |
| | | } |
| | | |
| | | const today = getDatePart(); |
| | | if (currentFilePath && currentDate === today) { |
| | | return currentFilePath; |
| | | } |
| | | |
| | | fs.mkdirSync(settings.dir, { recursive: true }); |
| | | currentDate = today; |
| | | currentFilePath = path.join(settings.dir, `${settings.filePrefix}-${today}.log`); |
| | | return currentFilePath; |
| | | } |
| | | |
| | | function shouldWrite(level) { |
| | | return LOG_LEVELS[level] >= LOG_LEVELS[settings.level]; |
| | | } |
| | | |
| | | function write(level, message) { |
| | | const normalizedLevel = normalizeLogLevel(level); |
| | | if (!shouldWrite(normalizedLevel)) { |
| | | return; |
| | | } |
| | | |
| | | const line = `[${formatLocalTimestamp()}] [${normalizedLevel.toUpperCase()}] ${message}`; |
| | | |
| | | if (settings.console) { |
| | | if (normalizedLevel === 'error') { |
| | | console.error(line); |
| | | } else if (normalizedLevel === 'warn') { |
| | | console.warn(line); |
| | | } else { |
| | | console.log(line); |
| | | } |
| | | } |
| | | |
| | | const logFilePath = ensureLogFilePath(); |
| | | if (logFilePath) { |
| | | try { |
| | | fs.appendFileSync(logFilePath, `${line}\n`, 'utf8'); |
| | | } catch (error) { |
| | | console.error(`[LOGGER] 写入日志文件失败: ${error.message}`); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return { |
| | | debug(message) { |
| | | write('debug', message); |
| | | }, |
| | | info(message) { |
| | | write('info', message); |
| | | }, |
| | | warn(message) { |
| | | write('warn', message); |
| | | }, |
| | | error(message) { |
| | | write('error', message); |
| | | }, |
| | | getLogFilePath() { |
| | | return ensureLogFilePath(); |
| | | }, |
| | | async close() { |
| | | currentDate = ''; |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | module.exports = { |
| | | createLogger, |
| | | formatLocalTimestamp, |
| | | normalizeLogLevel, |
| | | }; |
| New file |
| | |
| | | const mqtt = require('mqtt'); |
| | | |
| | | function formatUploadTime(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(date.getDate()).padStart(2, '0'); |
| | | const hour = String(date.getHours()).padStart(2, '0'); |
| | | const minute = String(date.getMinutes()).padStart(2, '0'); |
| | | const second = String(date.getSeconds()).padStart(2, '0'); |
| | | return `${year}-${month}-${day} ${hour}:${minute}:${second}`; |
| | | } |
| | | |
| | | class MqttService { |
| | | constructor(config = {}, logger = console) { |
| | | this.config = config; |
| | | this.logger = logger; |
| | | this.client = null; |
| | | this.connected = false; |
| | | } |
| | | |
| | | getBrokerUrl() { |
| | | if (this.config.brokerUrl) { |
| | | return this.config.brokerUrl; |
| | | } |
| | | |
| | | return `${this.config.protocol || 'mqtt'}://${this.config.host}:${this.config.port}`; |
| | | } |
| | | |
| | | getClientId() { |
| | | if (this.config.clientId) { |
| | | return this.config.clientId; |
| | | } |
| | | |
| | | const hostName = String(this.config.host || 'broker').replace(/[^a-zA-Z0-9]/g, '_'); |
| | | return `jh2028_gateway_${hostName}`; |
| | | } |
| | | |
| | | buildTopic(device) { |
| | | if (this.config.topicTemplate) { |
| | | return this.config.topicTemplate |
| | | .replace('{deviceId}', device.deviceId) |
| | | .replace('{ip}', device.ip) |
| | | .replace('{name}', device.name || device.deviceId); |
| | | } |
| | | |
| | | return `${this.config.defaultTopicPrefix || 'device'}/${device.deviceId}`; |
| | | } |
| | | |
| | | buildPayload(message) { |
| | | return { |
| | | ...(message || {}), |
| | | suedtime: formatUploadTime(), |
| | | }; |
| | | } |
| | | |
| | | start() { |
| | | const brokerUrl = this.getBrokerUrl(); |
| | | const clientId = this.getClientId(); |
| | | |
| | | this.logger.info(`[MQTT] 正在连接 服务地址=${brokerUrl} 客户端ID=${clientId}`); |
| | | this.client = mqtt.connect(brokerUrl, { |
| | | clientId, |
| | | username: this.config.username || undefined, |
| | | password: this.config.password || undefined, |
| | | reconnectPeriod: this.config.reconnectPeriod || 5000, |
| | | connectTimeout: this.config.connectTimeoutMs || 30000, |
| | | }); |
| | | |
| | | this.client.on('connect', () => { |
| | | this.connected = true; |
| | | this.logger.info(`[MQTT] 已连接 服务地址=${brokerUrl}`); |
| | | }); |
| | | |
| | | this.client.on('reconnect', () => { |
| | | this.connected = false; |
| | | this.logger.warn('[MQTT] 正在重连'); |
| | | }); |
| | | |
| | | this.client.on('close', () => { |
| | | this.connected = false; |
| | | this.logger.warn('[MQTT] 连接已关闭'); |
| | | }); |
| | | |
| | | this.client.on('error', (error) => { |
| | | this.logger.error(`[MQTT] 连接异常: ${error.message}`); |
| | | }); |
| | | } |
| | | |
| | | publish(device, message) { |
| | | if (!this.client) { |
| | | return Promise.reject(new Error('MQTT service is not started')); |
| | | } |
| | | |
| | | const topic = this.buildTopic(device); |
| | | const payloadObject = this.buildPayload(message); |
| | | const payload = JSON.stringify(payloadObject); |
| | | |
| | | if (!this.connected) { |
| | | this.logger.warn(`[MQTT] 当前未连接,消息将进入客户端队列 主题=${topic}`); |
| | | } |
| | | |
| | | return new Promise((resolve, reject) => { |
| | | this.client.publish(topic, payload, { |
| | | qos: this.config.qos || 0, |
| | | retain: Boolean(this.config.retain), |
| | | }, (error) => { |
| | | if (error) { |
| | | this.logger.error(`[MQTT] 发布失败 主题=${topic}: ${error.message}`); |
| | | reject(error); |
| | | return; |
| | | } |
| | | |
| | | this.logger.info(`[MQTT] 发布成功 主题=${topic} 数据=${payload}`); |
| | | resolve({ ok: true, topic, payload: payloadObject }); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | async stop() { |
| | | if (!this.client) { |
| | | return; |
| | | } |
| | | |
| | | this.logger.info('[MQTT] 正在停止'); |
| | | await new Promise((resolve) => { |
| | | this.client.end(false, resolve); |
| | | }); |
| | | this.connected = false; |
| | | this.client = null; |
| | | this.logger.info('[MQTT] 已停止'); |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | MqttService, |
| | | formatUploadTime, |
| | | }; |
| New file |
| | |
| | | { |
| | | "name": "jh2028-new-service", |
| | | "version": "1.0.0", |
| | | "description": "JH2028 20260511 TCP gateway service", |
| | | "license": "ISC", |
| | | "type": "commonjs", |
| | | "main": "app.js", |
| | | "scripts": { |
| | | "start": "node app.js", |
| | | "start:simulator": "node tcp-simulator.js --host 127.0.0.1 --port 9000 --repeat 0", |
| | | "start:simulator:once": "node tcp-simulator.js --host 127.0.0.1 --port 9000 --repeat 1", |
| | | "print:simulator": "node tcp-simulator.js --mode both --print-only", |
| | | "test": "mocha \"test/**/*.test.js\"", |
| | | "verify": "node verify-protocol.js", |
| | | "build": "node scripts/build-executables.js", |
| | | "build:win": "node scripts/build-executables.js --target win-x64", |
| | | "build:linux": "node scripts/build-executables.js --target linux-x64" |
| | | }, |
| | | "dependencies": { |
| | | "aliyun-iot-device-sdk": "^1.0.1", |
| | | "mqtt": "^5.15.1" |
| | | }, |
| | | "devDependencies": { |
| | | "@vercel/ncc": "^0.38.4", |
| | | "pkg": "^5.8.1", |
| | | "mocha": "^11.7.5" |
| | | }, |
| | | "pkg": { |
| | | "assets": [ |
| | | "config.json", |
| | | "alModel.json" |
| | | ] |
| | | } |
| | | } |
| New file |
| | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | |
| | | function fileExists(filePath) { |
| | | try { |
| | | return fs.statSync(filePath).isFile(); |
| | | } catch (_error) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | function getDefaultConfigCandidates(options = {}) { |
| | | const packaged = options.packaged !== undefined ? options.packaged : Boolean(process.pkg); |
| | | const cwd = options.cwd || process.cwd(); |
| | | const appDir = options.appDir || __dirname; |
| | | const execPath = options.execPath || process.execPath; |
| | | |
| | | if (packaged) { |
| | | const execDir = path.dirname(execPath); |
| | | return [ |
| | | path.join(execDir, 'runtime', 'config.json'), |
| | | path.join(execDir, 'config.json'), |
| | | path.join(cwd, 'config.json'), |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | path.join(cwd, 'config.json'), |
| | | path.join(appDir, 'config.json'), |
| | | ]; |
| | | } |
| | | |
| | | function getDefaultConfigPath(options = {}) { |
| | | const candidates = getDefaultConfigCandidates(options); |
| | | return candidates.find(fileExists) || candidates[0]; |
| | | } |
| | | |
| | | module.exports = { |
| | | fileExists, |
| | | getDefaultConfigCandidates, |
| | | getDefaultConfigPath, |
| | | }; |
| New file |
| | |
| | | #!/usr/bin/env node |
| | | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | const { spawnSync } = require('child_process'); |
| | | |
| | | const rootDir = path.join(__dirname, '..'); |
| | | const buildDir = path.join(rootDir, 'build'); |
| | | const distDir = path.join(rootDir, 'dist'); |
| | | const bundleDir = path.join(buildDir, 'ncc'); |
| | | const pkgProjectDir = path.join(buildDir, 'pkg'); |
| | | const runtimeAssets = [ |
| | | 'config.json', |
| | | 'alModel.json', |
| | | ]; |
| | | const distDocs = [ |
| | | '配置说明.md', |
| | | '实施部署文档.md', |
| | | ]; |
| | | const targets = { |
| | | 'win-x64': { |
| | | pkgTarget: 'node18-win-x64', |
| | | outputName: 'jh2028-service.exe', |
| | | executableMode: null, |
| | | }, |
| | | 'linux-x64': { |
| | | pkgTarget: 'node18-linux-x64', |
| | | outputName: 'jh2028-service', |
| | | executableMode: 0o755, |
| | | }, |
| | | }; |
| | | |
| | | function parseArgs(argv) { |
| | | const selectedTargets = new Set(Object.keys(targets)); |
| | | |
| | | for (let index = 2; index < argv.length; index += 1) { |
| | | const arg = argv[index]; |
| | | const value = argv[index + 1]; |
| | | |
| | | if (arg === '--target' && value) { |
| | | selectedTargets.clear(); |
| | | |
| | | for (const item of value.split(',').map((entry) => entry.trim()).filter(Boolean)) { |
| | | if (!targets[item]) { |
| | | throw new Error(`不支持的打包目标: ${item}`); |
| | | } |
| | | |
| | | selectedTargets.add(item); |
| | | } |
| | | |
| | | index += 1; |
| | | } |
| | | } |
| | | |
| | | return Array.from(selectedTargets); |
| | | } |
| | | |
| | | function runNode(scriptPath, args) { |
| | | const result = spawnSync(process.execPath, [scriptPath, ...args], { |
| | | cwd: rootDir, |
| | | stdio: 'inherit', |
| | | }); |
| | | |
| | | if (result.status !== 0) { |
| | | throw new Error(`命令执行失败: node ${scriptPath} ${args.join(' ')}`); |
| | | } |
| | | } |
| | | |
| | | function ensureCleanWorkspace() { |
| | | fs.rmSync(buildDir, { recursive: true, force: true }); |
| | | fs.rmSync(distDir, { recursive: true, force: true }); |
| | | fs.mkdirSync(buildDir, { recursive: true }); |
| | | fs.mkdirSync(distDir, { recursive: true }); |
| | | } |
| | | |
| | | function buildBundle() { |
| | | const nccCli = require.resolve('@vercel/ncc/dist/ncc/cli'); |
| | | runNode(nccCli, ['build', 'app.js', '-o', bundleDir, '--target', 'es2019']); |
| | | fs.rmSync(path.join(bundleDir, 'logs'), { recursive: true, force: true }); |
| | | } |
| | | |
| | | function preparePkgProject() { |
| | | const pkgBundleDir = path.join(pkgProjectDir, 'bundle'); |
| | | const pkgEntryPath = path.join(pkgProjectDir, 'entry.js'); |
| | | const pkgConfigPath = path.join(pkgProjectDir, 'package.json'); |
| | | const bundleSourcePath = path.join(bundleDir, 'index.js'); |
| | | const bundleTargetPath = path.join(pkgBundleDir, 'index.cjs'); |
| | | const bootstrapSource = `#!/usr/bin/env node |
| | | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | const Module = require('module'); |
| | | |
| | | const bundlePath = path.join(__dirname, 'bundle', 'index.cjs'); |
| | | const source = fs.readFileSync(bundlePath, 'utf8'); |
| | | const bundledModule = new Module(bundlePath); |
| | | |
| | | bundledModule.filename = bundlePath; |
| | | bundledModule.id = '.'; |
| | | bundledModule.paths = Module._nodeModulePaths(path.dirname(bundlePath)); |
| | | Module._cache[bundlePath] = bundledModule; |
| | | process.mainModule = bundledModule; |
| | | require.main = bundledModule; |
| | | |
| | | bundledModule._compile(source, bundlePath); |
| | | `; |
| | | const pkgConfig = { |
| | | name: 'jh2028-pkg-bootstrap', |
| | | version: '1.0.0', |
| | | private: true, |
| | | type: 'commonjs', |
| | | bin: 'entry.js', |
| | | pkg: { |
| | | assets: [ |
| | | 'bundle/index.cjs', |
| | | ], |
| | | }, |
| | | }; |
| | | |
| | | fs.mkdirSync(pkgBundleDir, { recursive: true }); |
| | | fs.copyFileSync(bundleSourcePath, bundleTargetPath); |
| | | fs.writeFileSync(pkgEntryPath, bootstrapSource, 'utf8'); |
| | | fs.writeFileSync(pkgConfigPath, `${JSON.stringify(pkgConfig, null, 2)}\n`, 'utf8'); |
| | | } |
| | | |
| | | function buildExecutable(targetKey, targetConfig) { |
| | | const pkgCli = require.resolve('pkg/lib-es5/bin.js'); |
| | | const targetDir = path.join(distDir, targetKey); |
| | | const outputPath = path.join(targetDir, targetConfig.outputName); |
| | | |
| | | fs.mkdirSync(targetDir, { recursive: true }); |
| | | |
| | | runNode(pkgCli, [ |
| | | pkgProjectDir, |
| | | '--targets', |
| | | targetConfig.pkgTarget, |
| | | '--output', |
| | | outputPath, |
| | | '--public', |
| | | '--public-packages', |
| | | '*', |
| | | '--no-bytecode', |
| | | ]); |
| | | |
| | | if (!fs.existsSync(outputPath)) { |
| | | throw new Error(`未生成预期的可执行文件: ${outputPath}`); |
| | | } |
| | | |
| | | if (targetConfig.executableMode) { |
| | | fs.chmodSync(outputPath, targetConfig.executableMode); |
| | | } |
| | | } |
| | | |
| | | function copyRuntimeAssets(targetDir) { |
| | | const runtimeDir = path.join(targetDir, 'runtime'); |
| | | const logsDir = path.join(targetDir, 'logs'); |
| | | const dashboardSourceDir = path.join(rootDir, 'dashboard'); |
| | | const dashboardTargetDir = path.join(runtimeDir, 'dashboard'); |
| | | |
| | | fs.mkdirSync(runtimeDir, { recursive: true }); |
| | | fs.mkdirSync(logsDir, { recursive: true }); |
| | | |
| | | for (const assetName of runtimeAssets) { |
| | | const sourcePath = path.join(rootDir, assetName); |
| | | |
| | | if (!fs.existsSync(sourcePath)) { |
| | | continue; |
| | | } |
| | | |
| | | fs.copyFileSync(sourcePath, path.join(runtimeDir, assetName)); |
| | | } |
| | | |
| | | if (fs.existsSync(dashboardSourceDir)) { |
| | | fs.cpSync(dashboardSourceDir, dashboardTargetDir, { recursive: true }); |
| | | } |
| | | |
| | | for (const docName of distDocs) { |
| | | const sourcePath = path.join(rootDir, docName); |
| | | |
| | | if (!fs.existsSync(sourcePath)) { |
| | | continue; |
| | | } |
| | | |
| | | fs.copyFileSync(sourcePath, path.join(targetDir, docName)); |
| | | } |
| | | } |
| | | |
| | | function main() { |
| | | const selectedTargets = parseArgs(process.argv); |
| | | ensureCleanWorkspace(); |
| | | buildBundle(); |
| | | preparePkgProject(); |
| | | |
| | | for (const targetKey of selectedTargets) { |
| | | buildExecutable(targetKey, targets[targetKey]); |
| | | copyRuntimeAssets(path.join(distDir, targetKey)); |
| | | } |
| | | |
| | | console.log('[BUILD] 打包完成'); |
| | | } |
| | | |
| | | main(); |
| New file |
| | |
| | | function cloneObject(value) { |
| | | return JSON.parse(JSON.stringify(value || {})); |
| | | } |
| | | |
| | | class StateCache { |
| | | constructor(options = {}) { |
| | | this.includeDeviceIdField = options.includeDeviceIdField !== false; |
| | | this.deviceIdField = options.deviceIdField || 'n'; |
| | | this.entries = new Map(); |
| | | } |
| | | |
| | | update(device, metric = {}, meta = {}) { |
| | | if (!device || !device.deviceId) { |
| | | throw new Error('设备缺少 deviceId'); |
| | | } |
| | | |
| | | const entry = this.getOrCreateEntry(device); |
| | | entry.device = device; |
| | | entry.lastUpdateAt = Date.now(); |
| | | |
| | | if (meta.messageType === 'realtime') { |
| | | entry.lastRealtimeAt = entry.lastUpdateAt; |
| | | } |
| | | |
| | | if (meta.messageType === 'blood-pressure') { |
| | | entry.lastBloodPressureAt = entry.lastUpdateAt; |
| | | } |
| | | |
| | | Object.assign(entry.payload, metric); |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | entry.payload[this.deviceIdField] = device.deviceId; |
| | | } |
| | | |
| | | return this.getPayload(device.deviceId); |
| | | } |
| | | |
| | | getOrCreateEntry(device) { |
| | | if (!this.entries.has(device.deviceId)) { |
| | | const payload = {}; |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | payload[this.deviceIdField] = device.deviceId; |
| | | } |
| | | |
| | | this.entries.set(device.deviceId, { |
| | | device, |
| | | payload, |
| | | firstSeenAt: Date.now(), |
| | | lastUpdateAt: 0, |
| | | lastRealtimeAt: 0, |
| | | lastBloodPressureAt: 0, |
| | | }); |
| | | } |
| | | |
| | | return this.entries.get(device.deviceId); |
| | | } |
| | | |
| | | getPayload(deviceId) { |
| | | const entry = this.entries.get(deviceId); |
| | | if (!entry) { |
| | | return null; |
| | | } |
| | | |
| | | return cloneObject(entry.payload); |
| | | } |
| | | |
| | | getSnapshot(deviceId) { |
| | | if (deviceId) { |
| | | const entry = this.entries.get(deviceId); |
| | | if (!entry) { |
| | | return null; |
| | | } |
| | | |
| | | return { |
| | | deviceId, |
| | | payload: cloneObject(entry.payload), |
| | | firstSeenAt: entry.firstSeenAt, |
| | | lastUpdateAt: entry.lastUpdateAt, |
| | | lastRealtimeAt: entry.lastRealtimeAt, |
| | | lastBloodPressureAt: entry.lastBloodPressureAt, |
| | | }; |
| | | } |
| | | |
| | | return Array.from(this.entries.keys()).map((key) => this.getSnapshot(key)); |
| | | } |
| | | |
| | | getSnapshotMap() { |
| | | const map = new Map(); |
| | | |
| | | for (const item of this.getSnapshot()) { |
| | | map.set(item.deviceId, item); |
| | | } |
| | | |
| | | return map; |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | StateCache, |
| | | }; |
| New file |
| | |
| | | const net = require('net'); |
| | | const { Jh2028Decoder, bytesToHex } = require('./decoder'); |
| | | |
| | | function normalizeIp(address) { |
| | | if (!address) { |
| | | return ''; |
| | | } |
| | | |
| | | if (address.startsWith('::ffff:')) { |
| | | return address.slice(7); |
| | | } |
| | | |
| | | return address; |
| | | } |
| | | |
| | | function formatReceivedTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(date.getDate()).padStart(2, '0'); |
| | | const hour = String(date.getHours()).padStart(2, '0'); |
| | | const minute = String(date.getMinutes()).padStart(2, '0'); |
| | | const second = String(date.getSeconds()).padStart(2, '0'); |
| | | return `${year}-${month}-${day} ${hour}:${minute}:${second}`; |
| | | } |
| | | |
| | | class TcpService { |
| | | constructor(options = {}) { |
| | | this.tcpConfig = options.tcpConfig || {}; |
| | | this.devices = options.devices || []; |
| | | this.alModelPath = options.alModelPath; |
| | | this.logger = options.logger || console; |
| | | this.logRawHex = Boolean(options.logRawHex); |
| | | this.onMetric = typeof options.onMetric === 'function' ? options.onMetric : async () => {}; |
| | | this.onConnectionChange = typeof options.onConnectionChange === 'function' ? options.onConnectionChange : () => {}; |
| | | this.server = null; |
| | | this.sessions = new Map(); |
| | | this.socketsByDeviceId = new Map(); |
| | | this.deviceMap = this.buildDeviceMap(this.devices); |
| | | } |
| | | |
| | | buildDeviceMap(devices) { |
| | | const map = new Map(); |
| | | |
| | | for (const device of devices) { |
| | | if (!device || !device.ip || !device.deviceId) { |
| | | continue; |
| | | } |
| | | |
| | | map.set(normalizeIp(device.ip), { |
| | | ...device, |
| | | name: device.name || device.deviceId, |
| | | }); |
| | | } |
| | | |
| | | return map; |
| | | } |
| | | |
| | | async start() { |
| | | this.server = net.createServer((socket) => { |
| | | this.handleConnection(socket); |
| | | }); |
| | | |
| | | this.server.maxConnections = this.tcpConfig.maxConnections || 100; |
| | | this.server.on('error', (error) => { |
| | | this.logger.error(`[TCP] 服务异常: ${error.message}`); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | | this.server.once('listening', resolve); |
| | | this.server.once('error', reject); |
| | | this.server.listen({ |
| | | host: this.tcpConfig.host, |
| | | port: this.tcpConfig.port, |
| | | backlog: this.tcpConfig.backlog || 128, |
| | | }); |
| | | }); |
| | | |
| | | this.logger.info(`[TCP] 已开始监听 ${this.tcpConfig.host}:${this.tcpConfig.port} 设备数量=${this.deviceMap.size}`); |
| | | } |
| | | |
| | | handleConnection(socket) { |
| | | const clientIp = normalizeIp(socket.remoteAddress); |
| | | const device = this.deviceMap.get(clientIp); |
| | | |
| | | if (!device) { |
| | | this.logger.warn(`[TCP] 未配置设备接入 IP=${clientIp} 端口=${socket.remotePort},连接将关闭`); |
| | | socket.destroy(); |
| | | return; |
| | | } |
| | | |
| | | const oldSocket = this.socketsByDeviceId.get(device.deviceId); |
| | | if (oldSocket && oldSocket !== socket) { |
| | | this.logger.warn(`[TCP] 同设备新连接接入,将关闭旧连接 设备=${device.deviceId} IP=${clientIp}`); |
| | | oldSocket.destroy(); |
| | | } |
| | | |
| | | const decoder = new Jh2028Decoder({ |
| | | alModelPath: this.alModelPath, |
| | | maxBufferBytes: this.tcpConfig.maxBufferBytes || 8192, |
| | | }); |
| | | |
| | | this.sessions.set(socket, { |
| | | clientIp, |
| | | device, |
| | | decoder, |
| | | connectedAt: Date.now(), |
| | | lastDataAt: 0, |
| | | remotePort: socket.remotePort, |
| | | }); |
| | | this.socketsByDeviceId.set(device.deviceId, socket); |
| | | |
| | | if (this.tcpConfig.keepAlive) { |
| | | socket.setKeepAlive(true, this.tcpConfig.keepAliveDelayMs || 10000); |
| | | } |
| | | |
| | | socket.setNoDelay(Boolean(this.tcpConfig.noDelay)); |
| | | socket.setTimeout(this.tcpConfig.socketTimeoutMs || 120000); |
| | | |
| | | this.logger.info(`[TCP] 设备已连接 设备=${device.deviceId} IP=${clientIp} 端口=${socket.remotePort}`); |
| | | this.notifyConnectionChange(device); |
| | | |
| | | socket.on('data', (chunk) => { |
| | | this.handleData(socket, chunk); |
| | | }); |
| | | |
| | | socket.on('timeout', () => { |
| | | this.logger.warn(`[TCP] 连接超时 设备=${device.deviceId} IP=${clientIp}`); |
| | | socket.destroy(); |
| | | }); |
| | | |
| | | socket.on('error', (error) => { |
| | | this.logger.error(`[TCP] 连接异常 设备=${device.deviceId} IP=${clientIp}: ${error.message}`); |
| | | }); |
| | | |
| | | socket.on('close', () => { |
| | | this.sessions.delete(socket); |
| | | if (this.socketsByDeviceId.get(device.deviceId) === socket) { |
| | | this.socketsByDeviceId.delete(device.deviceId); |
| | | } |
| | | this.logger.info(`[TCP] 设备已断开 设备=${device.deviceId} IP=${clientIp}`); |
| | | this.notifyConnectionChange(device); |
| | | }); |
| | | } |
| | | |
| | | handleData(socket, chunk) { |
| | | const session = this.sessions.get(socket); |
| | | if (!session) { |
| | | return; |
| | | } |
| | | |
| | | this.logger.info(`[TCP] 收到原始数据 设备=${session.device.deviceId} 字节数=${chunk.length}`); |
| | | session.lastDataAt = Date.now(); |
| | | if (this.logRawHex) { |
| | | this.logger.debug(`[TCP] 原始报文 设备=${session.device.deviceId} ${bytesToHex(chunk)}`); |
| | | } |
| | | |
| | | const results = session.decoder.push(chunk); |
| | | if (results.length === 0) { |
| | | this.logger.warn(`[TCP] 当前数据片段尚未组成完整报文 设备=${session.device.deviceId} 字节数=${chunk.length}`); |
| | | return; |
| | | } |
| | | |
| | | for (const result of results) { |
| | | if (!result.publish) { |
| | | const level = result.ok ? 'warn' : 'warn'; |
| | | this.logger[level](`[TCP] 跳过未发布报文 设备=${session.device.deviceId} 原因=${result.reason} 原始报文=${result.rawHex || ''}`); |
| | | continue; |
| | | } |
| | | |
| | | const metric = { ...result.metric }; |
| | | if (result.messageType === 'blood-pressure') { |
| | | metric.M = formatReceivedTimestamp(); |
| | | } |
| | | |
| | | this.logger.info(`[TCP] 解析到指标 设备=${session.device.deviceId} 类型=${result.messageType} 指标=${JSON.stringify(metric)}`); |
| | | Promise.resolve(this.onMetric(session.device, metric, result)).catch((error) => { |
| | | this.logger.error(`[TCP] 指标处理失败 设备=${session.device.deviceId}: ${error.message}`); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | async stop() { |
| | | this.logger.info(`[TCP] 正在停止 TCP 服务 当前连接数=${this.sessions.size}`); |
| | | |
| | | for (const socket of this.sessions.keys()) { |
| | | socket.destroy(); |
| | | } |
| | | |
| | | this.sessions.clear(); |
| | | this.socketsByDeviceId.clear(); |
| | | |
| | | if (!this.server) { |
| | | return; |
| | | } |
| | | |
| | | await new Promise((resolve) => { |
| | | this.server.close(resolve); |
| | | }); |
| | | |
| | | this.server = null; |
| | | this.logger.info('[TCP] TCP 服务已停止'); |
| | | } |
| | | |
| | | notifyConnectionChange(device) { |
| | | Promise.resolve(this.onConnectionChange(device, this.getConnectionSnapshot())).catch((error) => { |
| | | this.logger.error(`[TCP] 连接状态通知失败 设备=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | } |
| | | |
| | | getConnectionSnapshot() { |
| | | return Array.from(this.sessions.values()).map((session) => ({ |
| | | deviceId: session.device.deviceId, |
| | | ip: session.clientIp, |
| | | name: session.device.name || session.device.deviceId, |
| | | remotePort: session.remotePort, |
| | | connectedAt: session.connectedAt, |
| | | lastDataAt: session.lastDataAt, |
| | | online: true, |
| | | })); |
| | | } |
| | | } |
| | | |
| | | module.exports = { |
| | | TcpService, |
| | | formatReceivedTimestamp, |
| | | normalizeIp, |
| | | }; |
| New file |
| | |
| | | #!/usr/bin/env node |
| | | |
| | | const net = require('net'); |
| | | const { crc8 } = require('./decoder'); |
| | | |
| | | const COMMAND_TYPE_DATA_SYNC = 0x01; |
| | | const COMMAND_ID_REALTIME = 0x00; |
| | | const COMMAND_ID_BLOOD_PRESSURE = 0x01; |
| | | |
| | | function parseArgs(argv) { |
| | | const options = { |
| | | host: '127.0.0.1', |
| | | port: 9000, |
| | | localAddress: '', |
| | | repeat: 1, |
| | | intervalMs: 500, |
| | | mode: 'both', |
| | | printOnly: false, |
| | | }; |
| | | |
| | | 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 === '--local-address' && value) { |
| | | options.localAddress = value; |
| | | index += 1; |
| | | } else if (arg === '--repeat' && value) { |
| | | options.repeat = Number(value); |
| | | index += 1; |
| | | } else if (arg === '--interval' && value) { |
| | | options.intervalMs = Number(value); |
| | | index += 1; |
| | | } else if (arg === '--mode' && value) { |
| | | options.mode = value.trim().toLowerCase(); |
| | | index += 1; |
| | | } else if (arg === '--print-only') { |
| | | options.printOnly = true; |
| | | } else if (arg === '--help' || arg === '-h') { |
| | | options.help = true; |
| | | } |
| | | } |
| | | |
| | | return options; |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JH2028 新协议 TCP 模拟器 |
| | | |
| | | 说明: |
| | | 本脚本只生成新版 2026-05-11 协议报文,帧头固定为 55 AA。 |
| | | 不发送旧版 EE 55 协议,也不发送旧版 AA 55 血压扩展协议。 |
| | | |
| | | 用法: |
| | | node tcp-simulator.js --host 127.0.0.1 --port 9000 |
| | | node tcp-simulator.js --mode realtime --print-only |
| | | node tcp-simulator.js --mode blood-pressure --print-only |
| | | |
| | | 参数: |
| | | --host <ip> TCP 服务地址,默认 127.0.0.1 |
| | | --port <number> TCP 服务端口,默认 9000 |
| | | --local-address <ip> 绑定本地源 IP,用于模拟设备来源 IP |
| | | --repeat <number> 发送轮数,默认 1,0 表示一直循环 |
| | | --interval <ms> 发送间隔,默认 500 |
| | | --mode <realtime|blood-pressure|both> |
| | | realtime 只发送实时数据 |
| | | blood-pressure 只发送血压数据 |
| | | both 先发送实时数据,再发送血压数据 |
| | | --print-only 只打印报文,不连接 TCP 服务 |
| | | `); |
| | | } |
| | | |
| | | function buildFrame(timestamp, commandType, commandId, payload) { |
| | | const frameLength = 7 + payload.length; |
| | | const frameWithoutCrc = Buffer.from([ |
| | | 0x55, |
| | | 0xAA, |
| | | frameLength, |
| | | timestamp & 0xFF, |
| | | commandType & 0xFF, |
| | | commandId & 0xFF, |
| | | ...payload, |
| | | ]); |
| | | |
| | | return Buffer.concat([frameWithoutCrc, Buffer.from([crc8(frameWithoutCrc)])]); |
| | | } |
| | | |
| | | function writeInt16LE(value) { |
| | | const buffer = Buffer.alloc(2); |
| | | buffer.writeInt16LE(value, 0); |
| | | return Array.from(buffer); |
| | | } |
| | | |
| | | function writeUInt16LE(value) { |
| | | const buffer = Buffer.alloc(2); |
| | | buffer.writeUInt16LE(value, 0); |
| | | return Array.from(buffer); |
| | | } |
| | | |
| | | function writeUInt32LE(value) { |
| | | const buffer = Buffer.alloc(4); |
| | | buffer.writeUInt32LE(value, 0); |
| | | return Array.from(buffer); |
| | | } |
| | | |
| | | function buildRealtimePayload() { |
| | | return Buffer.from([ |
| | | ...writeInt16LE(365), |
| | | ...writeInt16LE(368), |
| | | ...writeUInt16LE(2000), |
| | | ...writeUInt16LE(500), |
| | | ...writeUInt16LE(250), |
| | | ...writeUInt16LE(90), |
| | | ...writeUInt16LE(500), |
| | | ...writeUInt16LE(320), |
| | | ...writeInt16LE(-100), |
| | | ...writeInt16LE(120), |
| | | ...writeInt16LE(-80), |
| | | ...writeUInt32LE(2048), |
| | | ...writeUInt16LE(138), |
| | | ...writeUInt16LE(140), |
| | | ...writeUInt16LE(25), |
| | | ...writeUInt16LE(0), |
| | | ...writeUInt16LE(0), |
| | | ...writeUInt16LE(987), |
| | | ...writeUInt16LE(365), |
| | | ...writeUInt16LE(132), |
| | | ...writeUInt16LE(367), |
| | | ...writeUInt16LE(15), |
| | | ]); |
| | | } |
| | | |
| | | function buildBloodPressurePayload() { |
| | | return Buffer.from([120, 80, 76, 0, 93]); |
| | | } |
| | | |
| | | function bufferToHex(buffer) { |
| | | return Array.from(buffer, (value) => value.toString(16).toUpperCase().padStart(2, '0')).join(' '); |
| | | } |
| | | |
| | | function describeFrame(frame) { |
| | | return { |
| | | header: bufferToHex(frame.slice(0, 2)), |
| | | length: frame[2], |
| | | timestamp: frame[3], |
| | | commandType: `0x${frame[4].toString(16).toUpperCase().padStart(2, '0')}`, |
| | | commandId: `0x${frame[5].toString(16).toUpperCase().padStart(2, '0')}`, |
| | | crc: `0x${frame[frame.length - 1].toString(16).toUpperCase().padStart(2, '0')}`, |
| | | rawHex: bufferToHex(frame), |
| | | }; |
| | | } |
| | | |
| | | function prepareFrames(mode) { |
| | | const frames = []; |
| | | let timestamp = 1; |
| | | |
| | | if (mode === 'realtime' || mode === 'both') { |
| | | frames.push({ |
| | | name: '实时数据', |
| | | frame: buildFrame(timestamp, COMMAND_TYPE_DATA_SYNC, COMMAND_ID_REALTIME, buildRealtimePayload()), |
| | | }); |
| | | timestamp += 1; |
| | | } |
| | | |
| | | if (mode === 'blood-pressure' || mode === 'both') { |
| | | frames.push({ |
| | | name: '血压数据', |
| | | frame: buildFrame(timestamp, COMMAND_TYPE_DATA_SYNC, COMMAND_ID_BLOOD_PRESSURE, buildBloodPressurePayload()), |
| | | }); |
| | | } |
| | | |
| | | return frames; |
| | | } |
| | | |
| | | function printFrames(frames) { |
| | | for (const item of frames) { |
| | | const frameInfo = describeFrame(item.frame); |
| | | console.log(`[SIM] ${item.name} 帧头=${frameInfo.header} 长度=${frameInfo.length} 命令类型=${frameInfo.commandType} 命令ID=${frameInfo.commandId} CRC=${frameInfo.crc}`); |
| | | console.log(`[SIM] ${frameInfo.rawHex}`); |
| | | } |
| | | } |
| | | |
| | | function main() { |
| | | const options = parseArgs(process.argv); |
| | | |
| | | if (options.help) { |
| | | printHelp(); |
| | | return; |
| | | } |
| | | |
| | | const frames = prepareFrames(options.mode); |
| | | |
| | | if (frames.length === 0) { |
| | | throw new Error(`不支持的模拟模式: ${options.mode}`); |
| | | } |
| | | |
| | | if (options.printOnly) { |
| | | printFrames(frames); |
| | | return; |
| | | } |
| | | |
| | | const socketOptions = { |
| | | host: options.host, |
| | | port: options.port, |
| | | }; |
| | | |
| | | if (options.localAddress) { |
| | | socketOptions.localAddress = options.localAddress; |
| | | } |
| | | |
| | | const socket = net.createConnection(socketOptions); |
| | | let round = 0; |
| | | let index = 0; |
| | | let timer = null; |
| | | |
| | | function stop(reason) { |
| | | if (timer) { |
| | | clearInterval(timer); |
| | | timer = null; |
| | | } |
| | | |
| | | if (!socket.destroyed) { |
| | | socket.end(); |
| | | socket.destroy(); |
| | | } |
| | | |
| | | if (reason) { |
| | | console.log(reason); |
| | | } |
| | | } |
| | | |
| | | function sendNext() { |
| | | const item = frames[index]; |
| | | const frameInfo = describeFrame(item.frame); |
| | | socket.write(item.frame); |
| | | console.log(`[SIM] 已发送 ${item.name} 轮次=${round + 1} 帧序号=${index + 1} 帧头=${frameInfo.header} 命令类型=${frameInfo.commandType} 命令ID=${frameInfo.commandId}`); |
| | | console.log(`[SIM] ${frameInfo.rawHex}`); |
| | | |
| | | index += 1; |
| | | |
| | | if (index >= frames.length) { |
| | | index = 0; |
| | | round += 1; |
| | | |
| | | if (options.repeat > 0 && round >= options.repeat) { |
| | | stop(`[SIM] 已完成 轮数=${options.repeat}`); |
| | | } |
| | | } |
| | | } |
| | | |
| | | socket.on('connect', () => { |
| | | console.log(`[SIM] 已连接 ${options.host}:${options.port}`); |
| | | sendNext(); |
| | | timer = setInterval(sendNext, options.intervalMs); |
| | | }); |
| | | |
| | | socket.on('error', (error) => { |
| | | stop(`[SIM] 连接异常: ${error.message}`); |
| | | process.exitCode = 1; |
| | | }); |
| | | |
| | | socket.on('close', () => { |
| | | if (timer) { |
| | | stop('[SIM] 连接已关闭'); |
| | | } |
| | | }); |
| | | |
| | | process.on('SIGINT', () => { |
| | | stop('[SIM] 收到 SIGINT,停止发送'); |
| | | process.exit(0); |
| | | }); |
| | | |
| | | process.on('SIGTERM', () => { |
| | | stop('[SIM] 收到 SIGTERM,停止发送'); |
| | | process.exit(0); |
| | | }); |
| | | } |
| | | |
| | | if (require.main === module) { |
| | | main(); |
| | | } |
| | | |
| | | module.exports = { |
| | | buildBloodPressurePayload, |
| | | buildFrame, |
| | | buildRealtimePayload, |
| | | describeFrame, |
| | | parseArgs, |
| | | prepareFrames, |
| | | }; |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { |
| | | createOnMetricHandler, |
| | | getSendChannels, |
| | | getSendOptions, |
| | | validateConfig, |
| | | } = require('../app'); |
| | | const { StateCache } = require('../state-cache'); |
| | | |
| | | describe('app', () => { |
| | | it('normalizes send channels and options', () => { |
| | | assert.deepStrictEqual(getSendChannels({ send: { channels: ['mqtt', 'aliyun', 'mqtt'] } }), ['mqtt', 'aliyun']); |
| | | assert.deepStrictEqual(getSendOptions({}), { |
| | | includeDeviceIdField: true, |
| | | deviceIdField: 'n', |
| | | }); |
| | | }); |
| | | |
| | | it('validates minimal config', () => { |
| | | assert.doesNotThrow(() => validateConfig({ |
| | | send: { channels: ['mqtt'] }, |
| | | tcp: { host: '0.0.0.0', port: 9000 }, |
| | | mqtt: { |
| | | protocol: 'mqtt', |
| | | host: '127.0.0.1', |
| | | port: 1883, |
| | | defaultTopicPrefix: 'touxiji', |
| | | }, |
| | | protocol: { alModelPath: './alModel.json' }, |
| | | devices: [{ deviceId: 'JH-001', ip: '127.0.0.1' }], |
| | | })); |
| | | }); |
| | | |
| | | it('updates cache and publishes complete payload to enabled channels', async () => { |
| | | const calls = []; |
| | | const cache = new StateCache(); |
| | | const handler = createOnMetricHandler({ |
| | | logger: { info() {}, error() {} }, |
| | | cache, |
| | | mqttService: { |
| | | publish: async (device, payload) => { |
| | | calls.push({ channel: 'mqtt', deviceId: device.deviceId, payload }); |
| | | }, |
| | | }, |
| | | aliyunService: { |
| | | publish: async (device, payload) => { |
| | | calls.push({ channel: 'aliyun', deviceId: device.deviceId, payload }); |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | await handler( |
| | | { deviceId: 'JH-001' }, |
| | | { F: 36.8 }, |
| | | { messageType: 'realtime' }, |
| | | ); |
| | | await handler( |
| | | { deviceId: 'JH-001' }, |
| | | { N: 120, O: 80, P: 76, M: '2026-05-12 10:20:30' }, |
| | | { messageType: 'blood-pressure' }, |
| | | ); |
| | | |
| | | assert.strictEqual(calls.length, 4); |
| | | assert.deepStrictEqual(calls[2].payload, { |
| | | n: 'JH-001', |
| | | F: 36.8, |
| | | N: 120, |
| | | O: 80, |
| | | P: 76, |
| | | M: '2026-05-12 10:20:30', |
| | | }); |
| | | assert.deepStrictEqual(calls[3].payload, calls[2].payload); |
| | | }); |
| | | }); |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { buildDashboardSnapshot } = require('../dashboard-service'); |
| | | const { StateCache } = require('../state-cache'); |
| | | |
| | | describe('dashboard-service', () => { |
| | | it('builds device status snapshot from configured devices, cache, and TCP sessions', () => { |
| | | const cache = new StateCache(); |
| | | const device = { deviceId: 'JH-001', ip: '127.0.0.1', name: '1号机' }; |
| | | |
| | | cache.update(device, { F: 36.8, N: 120 }, { messageType: 'realtime' }); |
| | | |
| | | const snapshot = buildDashboardSnapshot({ |
| | | devices: [device, { deviceId: 'JH-002', ip: '192.168.1.2', name: '2号机' }], |
| | | cache, |
| | | tcpService: { |
| | | getConnectionSnapshot() { |
| | | return [{ |
| | | deviceId: 'JH-001', |
| | | ip: '127.0.0.1', |
| | | connectedAt: Date.now(), |
| | | lastDataAt: Date.now(), |
| | | online: true, |
| | | }]; |
| | | }, |
| | | }, |
| | | config: { |
| | | title: '测试大屏', |
| | | staleDataMs: 180000, |
| | | }, |
| | | }); |
| | | |
| | | assert.strictEqual(snapshot.title, '测试大屏'); |
| | | assert.strictEqual(snapshot.totals.devices, 2); |
| | | assert.strictEqual(snapshot.totals.online, 1); |
| | | assert.strictEqual(snapshot.totals.offline, 1); |
| | | assert.strictEqual(snapshot.devices[0].online, true); |
| | | assert.strictEqual(snapshot.devices[0].dataStatus, 'active'); |
| | | assert.deepStrictEqual(snapshot.devices[0].payload, { |
| | | n: 'JH-001', |
| | | F: 36.8, |
| | | N: 120, |
| | | }); |
| | | assert.strictEqual(snapshot.devices[1].online, false); |
| | | assert.strictEqual(snapshot.devices[1].dataStatus, 'waiting'); |
| | | }); |
| | | }); |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { |
| | | Jh2028Decoder, |
| | | crc8, |
| | | } = require('../decoder'); |
| | | const { |
| | | buildBloodPressurePayload, |
| | | buildFrame, |
| | | buildRealtimePayload, |
| | | } = require('../tcp-simulator'); |
| | | |
| | | describe('Jh2028Decoder', () => { |
| | | it('parses realtime frame with little-endian fields and scale rules', () => { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | const frame = buildFrame(1, 0x01, 0x00, buildRealtimePayload()); |
| | | const [result] = decoder.push(frame); |
| | | |
| | | assert.strictEqual(result.publish, true); |
| | | assert.strictEqual(result.messageType, 'realtime'); |
| | | assert.deepStrictEqual(result.metric, { |
| | | AF: 36.5, |
| | | F: 36.8, |
| | | A: 2000, |
| | | C: 500, |
| | | B: 250, |
| | | K: 90, |
| | | L: 500, |
| | | D: 320, |
| | | H: -100, |
| | | o: 120, |
| | | J: -80, |
| | | U: 2048, |
| | | G: 138, |
| | | Na: 140, |
| | | HCO3: 25, |
| | | O2Sat: 98.7, |
| | | Hct: 36.5, |
| | | Hb: 13.2, |
| | | Tblood: 36.7, |
| | | ktv: 1.5, |
| | | }); |
| | | }); |
| | | |
| | | it('parses blood pressure frame and ignores mean pressure and irregular pulse', () => { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | const frame = buildFrame(2, 0x01, 0x01, buildBloodPressurePayload()); |
| | | const [result] = decoder.push(frame); |
| | | |
| | | assert.strictEqual(result.publish, true); |
| | | assert.strictEqual(result.messageType, 'blood-pressure'); |
| | | assert.deepStrictEqual(result.metric, { |
| | | N: 120, |
| | | O: 80, |
| | | P: 76, |
| | | }); |
| | | assert.deepStrictEqual(result.ignored, { |
| | | irregularPulse: 0, |
| | | meanPressure: 93, |
| | | }); |
| | | }); |
| | | |
| | | it('skips blood pressure error code frames', () => { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | const frame = buildFrame(3, 0x01, 0x01, Buffer.from([2, 0, 0, 0, 0])); |
| | | const [result] = decoder.push(frame); |
| | | |
| | | assert.strictEqual(result.publish, false); |
| | | assert.strictEqual(result.ok, true); |
| | | assert.strictEqual(result.reason, 'blood-pressure-error-code'); |
| | | assert.strictEqual(result.errorCode, 2); |
| | | }); |
| | | |
| | | it('rejects invalid CRC frames', () => { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | const frame = Buffer.from(buildFrame(1, 0x01, 0x00, buildRealtimePayload())); |
| | | frame[frame.length - 1] ^= 0xFF; |
| | | const [result] = decoder.push(frame); |
| | | |
| | | assert.strictEqual(result.publish, false); |
| | | assert.strictEqual(result.ok, false); |
| | | assert.strictEqual(result.reason, 'crc-invalid'); |
| | | }); |
| | | |
| | | it('handles sticky packets and split packets', () => { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | const realtimeFrame = buildFrame(1, 0x01, 0x00, buildRealtimePayload()); |
| | | const bpFrame = buildFrame(2, 0x01, 0x01, buildBloodPressurePayload()); |
| | | const firstPart = realtimeFrame.slice(0, 10); |
| | | const secondPart = Buffer.concat([realtimeFrame.slice(10), bpFrame]); |
| | | |
| | | assert.deepStrictEqual(decoder.push(firstPart), []); |
| | | const results = decoder.push(secondPart); |
| | | |
| | | assert.strictEqual(results.length, 2); |
| | | assert.strictEqual(results[0].messageType, 'realtime'); |
| | | assert.strictEqual(results[1].messageType, 'blood-pressure'); |
| | | }); |
| | | |
| | | it('calculates CRC8 from the PDF algorithm', () => { |
| | | const frameWithoutCrc = Buffer.from([0x55, 0xAA, 0x07, 0x01, 0x01, 0x7F]); |
| | | assert.strictEqual(crc8(frameWithoutCrc), 0x1B); |
| | | }); |
| | | }); |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { MqttService } = require('../mqtt-service'); |
| | | |
| | | describe('MqttService', () => { |
| | | it('builds topic with default prefix', () => { |
| | | const service = new MqttService({ defaultTopicPrefix: 'touxiji' }); |
| | | |
| | | assert.strictEqual(service.buildTopic({ deviceId: 'JH-001', ip: '127.0.0.1' }), 'touxiji/JH-001'); |
| | | }); |
| | | |
| | | it('builds topic with template', () => { |
| | | const service = new MqttService({ topicTemplate: 'site/{deviceId}/{ip}' }); |
| | | |
| | | assert.strictEqual(service.buildTopic({ deviceId: 'JH-001', ip: '127.0.0.1' }), 'site/JH-001/127.0.0.1'); |
| | | }); |
| | | }); |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { StateCache } = require('../state-cache'); |
| | | |
| | | describe('StateCache', () => { |
| | | it('merges realtime and blood pressure metrics into one complete payload', () => { |
| | | const cache = new StateCache(); |
| | | const device = { deviceId: 'JH-001', ip: '127.0.0.1' }; |
| | | |
| | | const realtimePayload = cache.update(device, { |
| | | F: 36.8, |
| | | A: 2000, |
| | | }, { messageType: 'realtime' }); |
| | | |
| | | assert.deepStrictEqual(realtimePayload, { |
| | | n: 'JH-001', |
| | | F: 36.8, |
| | | A: 2000, |
| | | }); |
| | | |
| | | const bloodPressurePayload = cache.update(device, { |
| | | N: 120, |
| | | O: 80, |
| | | P: 76, |
| | | M: '2026-05-12 10:20:30', |
| | | }, { messageType: 'blood-pressure' }); |
| | | |
| | | assert.deepStrictEqual(bloodPressurePayload, { |
| | | n: 'JH-001', |
| | | F: 36.8, |
| | | A: 2000, |
| | | N: 120, |
| | | O: 80, |
| | | P: 76, |
| | | M: '2026-05-12 10:20:30', |
| | | }); |
| | | }); |
| | | |
| | | it('keeps devices isolated', () => { |
| | | const cache = new StateCache(); |
| | | |
| | | cache.update({ deviceId: 'JH-001' }, { F: 36.8 }, { messageType: 'realtime' }); |
| | | cache.update({ deviceId: 'JH-002' }, { F: 37.1 }, { messageType: 'realtime' }); |
| | | |
| | | assert.deepStrictEqual(cache.getPayload('JH-001'), { |
| | | n: 'JH-001', |
| | | F: 36.8, |
| | | }); |
| | | assert.deepStrictEqual(cache.getPayload('JH-002'), { |
| | | n: 'JH-002', |
| | | F: 37.1, |
| | | }); |
| | | }); |
| | | }); |
| New file |
| | |
| | | const assert = require('assert'); |
| | | const { formatReceivedTimestamp, normalizeIp, TcpService } = require('../tcp-service'); |
| | | const { buildFrame, buildBloodPressurePayload } = require('../tcp-simulator'); |
| | | |
| | | describe('tcp-service', () => { |
| | | it('normalizes IPv4 mapped addresses', () => { |
| | | assert.strictEqual(normalizeIp('::ffff:192.168.1.10'), '192.168.1.10'); |
| | | assert.strictEqual(normalizeIp('127.0.0.1'), '127.0.0.1'); |
| | | }); |
| | | |
| | | it('formats receive timestamp', () => { |
| | | assert.strictEqual(formatReceivedTimestamp(new Date('2026-05-12T10:20:30')), '2026-05-12 10:20:30'); |
| | | }); |
| | | |
| | | it('adds M when blood pressure frame is handled', async () => { |
| | | let receivedMetric = null; |
| | | const service = new TcpService({ |
| | | tcpConfig: { maxBufferBytes: 8192 }, |
| | | devices: [], |
| | | alModelPath: './alModel.json', |
| | | logger: { info() {}, warn() {}, error() {}, debug() {} }, |
| | | onMetric(_device, metric) { |
| | | receivedMetric = metric; |
| | | }, |
| | | }); |
| | | const fakeSocket = {}; |
| | | |
| | | service.sessions.set(fakeSocket, { |
| | | device: { deviceId: 'JH-001', ip: '127.0.0.1' }, |
| | | decoder: { |
| | | push() { |
| | | return [{ |
| | | ok: true, |
| | | publish: true, |
| | | messageType: 'blood-pressure', |
| | | metric: { N: 120, O: 80, P: 76 }, |
| | | }]; |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | service.handleData(fakeSocket, buildFrame(1, 0x01, 0x01, buildBloodPressurePayload())); |
| | | |
| | | assert.strictEqual(receivedMetric.N, 120); |
| | | assert.strictEqual(receivedMetric.O, 80); |
| | | assert.strictEqual(receivedMetric.P, 76); |
| | | assert.match(receivedMetric.M, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); |
| | | }); |
| | | }); |
| New file |
| | |
| | | #!/usr/bin/env node |
| | | |
| | | const assert = require('assert'); |
| | | const { Jh2028Decoder } = require('./decoder'); |
| | | const { buildFrame, buildRealtimePayload, buildBloodPressurePayload } = require('./tcp-simulator'); |
| | | |
| | | function main() { |
| | | const decoder = new Jh2028Decoder({ alModelPath: './alModel.json' }); |
| | | |
| | | const realtimeFrame = buildFrame(1, 0x01, 0x00, buildRealtimePayload()); |
| | | const realtimeResult = decoder.push(realtimeFrame)[0]; |
| | | |
| | | assert.ok(realtimeResult); |
| | | assert.strictEqual(realtimeResult.publish, true); |
| | | assert.deepStrictEqual(realtimeResult.metric, { |
| | | AF: 36.5, |
| | | F: 36.8, |
| | | A: 2000, |
| | | C: 500, |
| | | B: 250, |
| | | K: 90, |
| | | L: 500, |
| | | D: 320, |
| | | H: -100, |
| | | o: 120, |
| | | J: -80, |
| | | U: 2048, |
| | | G: 138, |
| | | Na: 140, |
| | | HCO3: 25, |
| | | O2Sat: 98.7, |
| | | Hct: 36.5, |
| | | Hb: 13.2, |
| | | Tblood: 36.7, |
| | | ktv: 1.5, |
| | | }); |
| | | |
| | | const bpFrame = buildFrame(2, 0x01, 0x01, buildBloodPressurePayload()); |
| | | const bpResult = decoder.push(bpFrame)[0]; |
| | | |
| | | assert.ok(bpResult); |
| | | assert.strictEqual(bpResult.publish, true); |
| | | assert.deepStrictEqual(bpResult.metric, { |
| | | N: 120, |
| | | O: 80, |
| | | P: 76, |
| | | }); |
| | | |
| | | console.log('协议验证通过'); |
| | | } |
| | | |
| | | main(); |
| New file |
| | |
| | | # JH2028 新协议 TCP 网关服务实施部署文档 |
| | | |
| | | ## 1. 文档目的 |
| | | |
| | | 本文档用于指导现场实施人员部署已经打包完成的 JH2028 新协议 TCP 网关服务。 |
| | | |
| | | 本项目部署后承担以下职责: |
| | | |
| | | - 作为 TCP 服务端接收设备数据盒子上报的数据。 |
| | | - 按设备数据盒子的来源 IP 匹配设备编号。 |
| | | - 解析 JH2028 2026-05-11 版 `55 AA` 新协议报文。 |
| | | - 将解析后的数据上报到 MQTT 和/或阿里云。 |
| | | - 提供设备中央监测大屏页面。 |
| | | |
| | | 说明: |
| | | |
| | | - 本服务是独立新项目,不依赖老项目运行。 |
| | | - 本服务不兼容旧版 `EE 55` 协议。 |
| | | - 现场部署优先使用 `dist` 目录下已经打包好的可执行文件,不需要在现场安装 Node.js 或执行 `npm install`。 |
| | | |
| | | ## 2. 部署包说明 |
| | | |
| | | 当前项目已经生成 Windows 和 Linux 两类部署包: |
| | | |
| | | ```text |
| | | dist/ |
| | | win-x64/ |
| | | jh2028-service.exe |
| | | 配置说明.md |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | dashboard/ |
| | | logs/ |
| | | linux-x64/ |
| | | jh2028-service |
| | | 配置说明.md |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | dashboard/ |
| | | logs/ |
| | | ``` |
| | | |
| | | 目录说明: |
| | | |
| | | | 路径 | 说明 | |
| | | | --- | --- | |
| | | | `jh2028-service.exe` | Windows 服务程序 | |
| | | | `jh2028-service` | Linux 服务程序 | |
| | | | `runtime/config.json` | 主配置文件,现场主要修改此文件 | |
| | | | `runtime/alModel.json` | 阿里云物模型字段文件,一般不修改 | |
| | | | `runtime/dashboard/` | 大屏静态页面文件 | |
| | | | `logs/` | 日志目录 | |
| | | | `配置说明.md` | 配置字段说明 | |
| | | |
| | | ## 3. 部署前准备 |
| | | |
| | | 部署前请确认以下信息: |
| | | |
| | | | 项目 | 说明 | |
| | | | --- | --- | |
| | | | 服务器系统 | Windows x64 或 Linux x64 | |
| | | | 服务器固定 IP | 设备数据盒子需要连接该 IP | |
| | | | TCP 服务端口 | 默认 `9000`,设备数据盒子连接此端口 | |
| | | | 大屏访问端口 | 默认 `9100`,浏览器访问此端口 | |
| | | | 设备数据盒子 IP | 每台设备数据盒子的来源 IP 需要写入配置 | |
| | | | MQTT 地址 | 如启用 MQTT,需要确认地址、端口、账号、密码、Topic 前缀 | |
| | | | 阿里云三元组接口 | 如启用阿里云,需要确认接口域名和路径 | |
| | | | 防火墙策略 | 服务器需要放通 TCP 服务端口和大屏端口 | |
| | | |
| | | 建议现场网络关系: |
| | | |
| | | ```text |
| | | 设备数据盒子 ---> 服务器IP:9000 ---> JH2028 TCP 网关服务 |
| | | 浏览器 ---> 服务器IP:9100 ---> 设备中央监测大屏 |
| | | 网关服务 ---> MQTT / 阿里云平台 |
| | | ``` |
| | | |
| | | ## 4. Windows 部署 |
| | | |
| | | ### 4.1 上传部署包 |
| | | |
| | | 将 `dist/win-x64` 整个目录上传到服务器,例如: |
| | | |
| | | ```text |
| | | D:\jh2028-service\win-x64 |
| | | ``` |
| | | |
| | | 不要只复制 `jh2028-service.exe`,必须同时保留 `runtime` 目录。 |
| | | |
| | | ### 4.2 修改配置 |
| | | |
| | | 编辑: |
| | | |
| | | ```text |
| | | D:\jh2028-service\win-x64\runtime\config.json |
| | | ``` |
| | | |
| | | 现场通常需要修改: |
| | | |
| | | ```text |
| | | tcp.port |
| | | dashboard.port |
| | | devices |
| | | mqtt.host |
| | | mqtt.port |
| | | mqtt.username |
| | | mqtt.password |
| | | mqtt.defaultTopicPrefix |
| | | aliyun.tupleApiBaseUrl |
| | | aliyun.tupleApiPath |
| | | ``` |
| | | |
| | | 设备配置示例: |
| | | |
| | | ```json |
| | | { |
| | | "devices": [ |
| | | { |
| | | "deviceId": "JH-001", |
| | | "ip": "192.168.1.10", |
| | | "name": "1号透析机" |
| | | }, |
| | | { |
| | | "deviceId": "JH-002", |
| | | "ip": "192.168.1.11", |
| | | "name": "2号透析机" |
| | | } |
| | | ] |
| | | } |
| | | ``` |
| | | |
| | | 注意: |
| | | |
| | | - `ip` 是设备数据盒子的来源 IP,不是服务器 IP。 |
| | | - 同一个 IP 不要配置给多台设备。 |
| | | - 当前服务按来源 IP 匹配设备,不从报文中解析设备编号。 |
| | | |
| | | ### 4.3 手动启动 |
| | | |
| | | 打开 PowerShell,执行: |
| | | |
| | | ```powershell |
| | | cd D:\jh2028-service\win-x64 |
| | | .\jh2028-service.exe --config .\runtime\config.json |
| | | ``` |
| | | |
| | | 看到类似日志表示启动成功: |
| | | |
| | | ```text |
| | | [APP] 配置加载完成 TCP=0.0.0.0:9000 |
| | | [TCP] 已开始监听 0.0.0.0:9000 |
| | | [DASHBOARD] 已开始监听 0.0.0.0:9100 |
| | | ``` |
| | | |
| | | ### 4.4 配置 Windows 开机自启 |
| | | |
| | | 推荐使用 NSSM 或 Windows 任务计划程序托管进程。 |
| | | |
| | | #### 方案一:NSSM |
| | | |
| | | 安装服务: |
| | | |
| | | ```powershell |
| | | nssm install jh2028-service |
| | | ``` |
| | | |
| | | 在弹窗中填写: |
| | | |
| | | ```text |
| | | Path: D:\jh2028-service\win-x64\jh2028-service.exe |
| | | Startup directory: D:\jh2028-service\win-x64 |
| | | Arguments: --config .\runtime\config.json |
| | | ``` |
| | | |
| | | 启动服务: |
| | | |
| | | ```powershell |
| | | nssm start jh2028-service |
| | | ``` |
| | | |
| | | 停止服务: |
| | | |
| | | ```powershell |
| | | nssm stop jh2028-service |
| | | ``` |
| | | |
| | | 删除服务: |
| | | |
| | | ```powershell |
| | | nssm remove jh2028-service confirm |
| | | ``` |
| | | |
| | | #### 方案二:任务计划程序 |
| | | |
| | | 可创建一个“计算机启动时”触发的任务: |
| | | |
| | | ```text |
| | | 程序: D:\jh2028-service\win-x64\jh2028-service.exe |
| | | 参数: --config .\runtime\config.json |
| | | 起始于: D:\jh2028-service\win-x64 |
| | | ``` |
| | | |
| | | ### 4.5 Windows 防火墙放通 |
| | | |
| | | 以管理员 PowerShell 执行: |
| | | |
| | | ```powershell |
| | | New-NetFirewallRule -DisplayName "JH2028 TCP 9000" -Direction Inbound -Protocol TCP -LocalPort 9000 -Action Allow |
| | | New-NetFirewallRule -DisplayName "JH2028 Dashboard 9100" -Direction Inbound -Protocol TCP -LocalPort 9100 -Action Allow |
| | | ``` |
| | | |
| | | 如现场修改了端口,请替换为实际端口。 |
| | | |
| | | ## 5. Linux 部署 |
| | | |
| | | ### 5.1 上传部署包 |
| | | |
| | | 将 `dist/linux-x64` 整个目录上传到服务器,例如: |
| | | |
| | | ```text |
| | | /opt/jh2028-service/linux-x64 |
| | | ``` |
| | | |
| | | 不要只复制 `jh2028-service`,必须同时保留 `runtime` 目录。 |
| | | |
| | | ### 5.2 修改配置 |
| | | |
| | | 编辑: |
| | | |
| | | ```bash |
| | | vi /opt/jh2028-service/linux-x64/runtime/config.json |
| | | ``` |
| | | |
| | | 配置内容与 Windows 相同,重点确认: |
| | | |
| | | - `tcp.port` |
| | | - `dashboard.port` |
| | | - `devices` |
| | | - `mqtt` |
| | | - `aliyun` |
| | | - `send.channels` |
| | | |
| | | ### 5.3 手动启动 |
| | | |
| | | 执行: |
| | | |
| | | ```bash |
| | | cd /opt/jh2028-service/linux-x64 |
| | | chmod +x ./jh2028-service |
| | | ./jh2028-service --config ./runtime/config.json |
| | | ``` |
| | | |
| | | 看到类似日志表示启动成功: |
| | | |
| | | ```text |
| | | [APP] 配置加载完成 TCP=0.0.0.0:9000 |
| | | [TCP] 已开始监听 0.0.0.0:9000 |
| | | [DASHBOARD] 已开始监听 0.0.0.0:9100 |
| | | ``` |
| | | |
| | | ### 5.4 配置 systemd 服务 |
| | | |
| | | 创建服务文件: |
| | | |
| | | ```bash |
| | | sudo vi /etc/systemd/system/jh2028-service.service |
| | | ``` |
| | | |
| | | 写入: |
| | | |
| | | ```ini |
| | | [Unit] |
| | | Description=JH2028 TCP Gateway Service |
| | | After=network-online.target |
| | | Wants=network-online.target |
| | | |
| | | [Service] |
| | | Type=simple |
| | | WorkingDirectory=/opt/jh2028-service/linux-x64 |
| | | ExecStart=/opt/jh2028-service/linux-x64/jh2028-service --config ./runtime/config.json |
| | | Restart=always |
| | | RestartSec=5 |
| | | StandardOutput=journal |
| | | StandardError=journal |
| | | |
| | | [Install] |
| | | WantedBy=multi-user.target |
| | | ``` |
| | | |
| | | 加载并启动: |
| | | |
| | | ```bash |
| | | sudo systemctl daemon-reload |
| | | sudo systemctl enable jh2028-service |
| | | sudo systemctl start jh2028-service |
| | | ``` |
| | | |
| | | 查看状态: |
| | | |
| | | ```bash |
| | | sudo systemctl status jh2028-service |
| | | ``` |
| | | |
| | | 查看实时日志: |
| | | |
| | | ```bash |
| | | journalctl -u jh2028-service -f |
| | | ``` |
| | | |
| | | 停止服务: |
| | | |
| | | ```bash |
| | | sudo systemctl stop jh2028-service |
| | | ``` |
| | | |
| | | 重启服务: |
| | | |
| | | ```bash |
| | | sudo systemctl restart jh2028-service |
| | | ``` |
| | | |
| | | ### 5.5 Linux 防火墙放通 |
| | | |
| | | 如使用 firewalld: |
| | | |
| | | ```bash |
| | | sudo firewall-cmd --add-port=9000/tcp --permanent |
| | | sudo firewall-cmd --add-port=9100/tcp --permanent |
| | | sudo firewall-cmd --reload |
| | | ``` |
| | | |
| | | 如使用 ufw: |
| | | |
| | | ```bash |
| | | sudo ufw allow 9000/tcp |
| | | sudo ufw allow 9100/tcp |
| | | ``` |
| | | |
| | | 如现场修改了端口,请替换为实际端口。 |
| | | |
| | | ## 6. 配置重点说明 |
| | | |
| | | ### 6.1 TCP 服务配置 |
| | | |
| | | ```json |
| | | { |
| | | "tcp": { |
| | | "host": "0.0.0.0", |
| | | "port": 9000 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 建议: |
| | | |
| | | - `host` 保持 `0.0.0.0`,表示监听服务器所有网卡。 |
| | | - `port` 与设备数据盒子连接端口保持一致。 |
| | | |
| | | ### 6.2 大屏配置 |
| | | |
| | | ```json |
| | | { |
| | | "dashboard": { |
| | | "enabled": true, |
| | | "host": "0.0.0.0", |
| | | "port": 9100 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 浏览器访问: |
| | | |
| | | ```text |
| | | 本机访问:http://127.0.0.1:9100 |
| | | 局域网访问:http://服务器真实IP:9100 |
| | | ``` |
| | | |
| | | 不要在浏览器打开: |
| | | |
| | | ```text |
| | | http://0.0.0.0:9100 |
| | | ``` |
| | | |
| | | `0.0.0.0` 只表示服务监听地址,不是浏览器访问地址。 |
| | | |
| | | ### 6.3 上报通道配置 |
| | | |
| | | ```json |
| | | { |
| | | "send": { |
| | | "channels": ["mqtt", "aliyun"] |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 可选值: |
| | | |
| | | ```text |
| | | ["mqtt"] 只上报 MQTT |
| | | ["aliyun"] 只上报阿里云 |
| | | ["mqtt","aliyun"] 同时上报 MQTT 和阿里云 |
| | | ``` |
| | | |
| | | 注意:上传失败只记录日志,不做补发。 |
| | | |
| | | ### 6.4 日志配置 |
| | | |
| | | ```json |
| | | { |
| | | "logging": { |
| | | "dir": "./logs", |
| | | "level": "info", |
| | | "logRawHex": false |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 说明: |
| | | |
| | | - 日志文件按日期生成,例如 `jh2028-service-20260519.log`。 |
| | | - 当启动命令使用 `--config ./runtime/config.json` 时,`./logs` 会按 `runtime` 目录计算。 |
| | | - 联调排查原始报文时,可临时将 `logRawHex` 改为 `true`,排查完成后建议改回 `false`。 |
| | | |
| | | ## 7. 部署验证 |
| | | |
| | | ### 7.1 检查进程 |
| | | |
| | | Windows: |
| | | |
| | | ```powershell |
| | | Get-Process | Where-Object { $_.ProcessName -like "*jh2028*" } |
| | | ``` |
| | | |
| | | Linux: |
| | | |
| | | ```bash |
| | | ps -ef | grep jh2028-service |
| | | ``` |
| | | |
| | | ### 7.2 检查端口监听 |
| | | |
| | | Windows: |
| | | |
| | | ```powershell |
| | | netstat -ano | findstr ":9000" |
| | | netstat -ano | findstr ":9100" |
| | | ``` |
| | | |
| | | Linux: |
| | | |
| | | ```bash |
| | | ss -lntp | grep -E "9000|9100" |
| | | ``` |
| | | |
| | | ### 7.3 检查大屏 |
| | | |
| | | 浏览器打开: |
| | | |
| | | ```text |
| | | http://服务器真实IP:9100 |
| | | ``` |
| | | |
| | | 验证点: |
| | | |
| | | - 页面能正常打开。 |
| | | - 设备列表显示正确。 |
| | | - 设备连接后状态变为在线。 |
| | | - 收到数据后指标刷新。 |
| | | |
| | | ### 7.4 检查设备接入 |
| | | |
| | | 设备数据盒子连接服务器后,日志应出现: |
| | | |
| | | ```text |
| | | [TCP] 设备已连接 设备=JH-001 IP=192.168.1.10 |
| | | [TCP] 收到原始数据 设备=JH-001 字节数=... |
| | | [TCP] 解析到指标 设备=JH-001 类型=... |
| | | ``` |
| | | |
| | | 如果出现以下日志,说明设备 IP 未配置或配置不一致: |
| | | |
| | | ```text |
| | | [TCP] 未配置设备接入 IP=... |
| | | ``` |
| | | |
| | | ### 7.5 检查上报 |
| | | |
| | | MQTT 上报成功时,日志中会出现 MQTT 发布相关记录。 |
| | | |
| | | 阿里云上报失败时,日志中会出现: |
| | | |
| | | ```text |
| | | [APP] 阿里云上报失败 设备=... |
| | | ``` |
| | | |
| | | MQTT 上报失败时,日志中会出现: |
| | | |
| | | ```text |
| | | [APP] MQTT 上报失败 设备=... |
| | | ``` |
| | | |
| | | ## 8. 常见问题处理 |
| | | |
| | | ### 8.1 服务启动失败:未找到配置文件 |
| | | |
| | | 现象: |
| | | |
| | | ```text |
| | | 未找到配置文件: ... |
| | | ``` |
| | | |
| | | 处理: |
| | | |
| | | - 确认启动命令中的 `--config` 路径正确。 |
| | | - 确认当前工作目录是部署包目录。 |
| | | - Windows 推荐在 `win-x64` 目录下执行启动命令。 |
| | | - Linux systemd 需要正确设置 `WorkingDirectory`。 |
| | | |
| | | ### 8.2 服务启动失败:端口被占用 |
| | | |
| | | 现象: |
| | | |
| | | ```text |
| | | EADDRINUSE |
| | | ``` |
| | | |
| | | 处理: |
| | | |
| | | - 检查 `9000` 或 `9100` 是否已被其他程序占用。 |
| | | - 停止占用端口的程序,或修改 `runtime/config.json` 中的端口。 |
| | | - 修改端口后同步调整防火墙和设备数据盒子连接配置。 |
| | | |
| | | ### 8.3 设备无法连接 |
| | | |
| | | 处理顺序: |
| | | |
| | | 1. 确认设备数据盒子连接的是服务器真实 IP 和 `tcp.port`。 |
| | | 2. 确认服务器防火墙已放通 TCP 端口。 |
| | | 3. 确认设备数据盒子和服务器网络互通。 |
| | | 4. 确认 `devices[].ip` 是设备数据盒子的来源 IP。 |
| | | 5. 查看日志是否出现“未配置设备接入”。 |
| | | |
| | | ### 8.4 大屏打不开 |
| | | |
| | | 处理顺序: |
| | | |
| | | 1. 确认服务已启动。 |
| | | 2. 确认 `dashboard.enabled` 为 `true`。 |
| | | 3. 确认 `dashboard.port` 未被占用。 |
| | | 4. 确认防火墙放通大屏端口。 |
| | | 5. 使用 `http://服务器真实IP:9100` 访问,不要使用 `http://0.0.0.0:9100`。 |
| | | |
| | | ### 8.5 收到数据但没有上报 |
| | | |
| | | 处理顺序: |
| | | |
| | | 1. 确认 `send.channels` 已启用对应通道。 |
| | | 2. 确认 MQTT 或阿里云配置正确。 |
| | | 3. 查看日志中的 MQTT / 阿里云失败信息。 |
| | | 4. 确认服务器可以访问 MQTT 或阿里云接口地址。 |
| | | 5. 如需排查原始报文,临时开启 `logging.logRawHex=true`。 |
| | | |
| | | ## 9. 升级部署 |
| | | |
| | | 升级前建议备份: |
| | | |
| | | ```text |
| | | runtime/config.json |
| | | runtime/alModel.json |
| | | logs/ |
| | | ``` |
| | | |
| | | 升级步骤: |
| | | |
| | | 1. 停止当前服务。 |
| | | 2. 备份当前部署目录。 |
| | | 3. 上传新的 `win-x64` 或 `linux-x64` 部署包。 |
| | | 4. 将旧版本 `runtime/config.json` 中的现场配置迁移到新版本。 |
| | | 5. 如物模型有调整,再确认 `runtime/alModel.json`。 |
| | | 6. 启动服务。 |
| | | 7. 按“部署验证”章节完成验证。 |
| | | |
| | | 注意: |
| | | |
| | | - 不建议直接覆盖整个 `runtime` 目录,避免覆盖现场配置。 |
| | | - 如果新版本配置字段有变化,以新版本 `config.json` 为准,将现场值迁移进去。 |
| | | |
| | | ## 10. 回滚方案 |
| | | |
| | | 如升级后现场异常,可按以下步骤回滚: |
| | | |
| | | 1. 停止新版本服务。 |
| | | 2. 恢复升级前备份的部署目录。 |
| | | 3. 启动旧版本服务。 |
| | | 4. 检查端口监听、大屏访问、设备连接和上报日志。 |
| | | |
| | | 建议每次升级保留至少一个可用旧版本目录,例如: |
| | | |
| | | ```text |
| | | D:\jh2028-service\backup\win-x64-20260519 |
| | | /opt/jh2028-service/backup/linux-x64-20260519 |
| | | ``` |
| | | |
| | | ## 11. 交付检查清单 |
| | | |
| | | | 检查项 | 结果 | |
| | | | --- | --- | |
| | | | 部署包完整,包含可执行文件、`runtime`、`logs` | | |
| | | | `runtime/config.json` 已按现场设备和平台信息修改 | | |
| | | | TCP 端口已放通 | | |
| | | | 大屏端口已放通 | | |
| | | | 服务已配置开机自启 | | |
| | | | 服务进程运行正常 | | |
| | | | TCP 端口监听正常 | | |
| | | | 大屏可以通过服务器真实 IP 访问 | | |
| | | | 设备连接日志正常 | | |
| | | | 数据解析日志正常 | | |
| | | | MQTT 或阿里云上报验证正常 | | |
| | | | 已备份最终配置文件 | | |
| | | |
| New file |
| | |
| | | # JH2028 新协议服务通讯功能流程图 |
| | | |
| | | 本文档描述 `jh2028-new-service` 的服务通讯主流程,覆盖 TCP 接收、设备识别、协议解码、本地缓存、大屏展示、MQTT 上报和阿里云上报。 |
| | | |
| | | ## 0. 通讯角色说明 |
| | | |
| | | 现场通讯里需要先区分两个角色: |
| | | |
| | | - **本程序是 TCP 服务端**:本程序监听固定 IP 和端口,等待设备数据盒子主动连接。 |
| | | - **设备数据盒子是 TCP 客户端**:盒子连接本程序后,持续发送设备透传数据。 |
| | | |
| | | 因此,从 TCP 连接方向看: |
| | | |
| | | ```text |
| | | 设备数据盒子 TCP 客户端 ---> 本程序 TCP 服务端 |
| | | ``` |
| | | |
| | | 从数据流方向看: |
| | | |
| | | ```text |
| | | 设备/透传盒 ---> 本程序 ---> MQTT / 阿里云 / 监测大屏 |
| | | ``` |
| | | |
| | | 本程序启动后监听 `config.tcp.host` 和 `config.tcp.port`。设备列表中维护每台设备盒子的 `ip`、`deviceId`、`name`,程序根据 TCP 来源 IP 匹配设备编号。 |
| | | |
| | | ## 1. 总体通讯流程 |
| | | |
| | | ```mermaid |
| | | flowchart TD |
| | | A[JH2028 设备] --> B[串口透传盒子] |
| | | B -->|主动连接 TCP 服务端<br/>持续发送数据| C[本程序 TCP 服务端<br/>tcp-service.js] |
| | | |
| | | C --> D{来源 IP 是否在<br/>config.devices 中} |
| | | D -->|否| D1[记录未知设备日志<br/>丢弃本包数据] |
| | | D -->|是| E[绑定设备编号 deviceId] |
| | | |
| | | E --> F[接收二进制数据流] |
| | | F --> G[按 55 AA 帧头和 LEN 拆包] |
| | | G --> H{是否完整帧} |
| | | H -->|否| H1[继续缓存等待后续数据] |
| | | H -->|是| I[CRC8 校验] |
| | | |
| | | I --> J{CRC 是否正确} |
| | | J -->|否| J1[记录校验失败日志<br/>丢弃该帧] |
| | | J -->|是| K[协议解码<br/>decoder.js] |
| | | |
| | | K --> L{帧类型} |
| | | L -->|实时数据<br/>CMDTYPE=01 CMDID=00| M[解析治疗/温度/压力/血液指标] |
| | | L -->|血压数据<br/>CMDTYPE=01 CMDID=01| N[解析 N/O/P<br/>写入 M=接收时间] |
| | | L -->|血压错误码| N1[只记录日志<br/>不更新缓存/不上报] |
| | | L -->|未知命令| L1[记录不支持命令日志] |
| | | |
| | | M --> O[更新设备本地缓存<br/>state-cache.js] |
| | | N --> O |
| | | |
| | | O --> P[生成完整上报数据<br/>包含最后一次实时数据和血压数据] |
| | | P --> Q[刷新大屏快照<br/>dashboard-service.js] |
| | | Q --> Q1[浏览器大屏<br/>SSE 实时更新/轮询兜底] |
| | | |
| | | P --> R{上报通道配置<br/>send.channels} |
| | | R -->|mqtt| S[MQTT 上报<br/>mqtt-service.js] |
| | | R -->|aliyun| T[阿里云上报<br/>aliyun-service.js] |
| | | R -->|mqtt + aliyun| S |
| | | R -->|mqtt + aliyun| T |
| | | |
| | | S --> U{上报结果} |
| | | T --> U |
| | | U -->|成功| U1[记录成功日志] |
| | | U -->|失败| U2[记录失败日志<br/>不补发] |
| | | ``` |
| | | |
| | | ## 2. 服务启动流程 |
| | | |
| | | ```mermaid |
| | | flowchart TD |
| | | A[启动 app.js] --> B[读取 config.json] |
| | | B --> C[校验 tcp/devices/protocol/send 配置] |
| | | C --> D[初始化日志 logger] |
| | | D --> E[加载物模型 alModel.json] |
| | | E --> F[初始化 StateCache] |
| | | |
| | | F --> G{send.channels} |
| | | G -->|包含 mqtt| H[初始化 MQTT 服务] |
| | | G -->|包含 aliyun| I[初始化阿里云服务] |
| | | G --> J[初始化 DashboardService] |
| | | |
| | | H --> K[启动 TCP 服务] |
| | | I --> K |
| | | J --> K |
| | | |
| | | K --> L[监听 TCP 端口<br/>默认 9000] |
| | | J --> M[监听大屏端口<br/>默认 9100] |
| | | |
| | | L --> N[等待设备盒子接入] |
| | | M --> O[等待浏览器访问] |
| | | ``` |
| | | |
| | | ## 3. 单包数据处理时序 |
| | | |
| | | ```mermaid |
| | | sequenceDiagram |
| | | participant Device as JH2028设备/透传盒子 |
| | | participant TCP as TCP服务 |
| | | participant Decoder as 协议解码器 |
| | | participant Cache as 本地状态缓存 |
| | | participant UI as 监测大屏 |
| | | participant MQTT as MQTT服务 |
| | | participant Aliyun as 阿里云服务 |
| | | |
| | | Device->>TCP: 主动建立 TCP 连接 |
| | | Device->>TCP: 持续发送 55 AA 新协议数据帧 |
| | | TCP->>TCP: 根据来源 IP 匹配 deviceId |
| | | TCP->>TCP: 数据流拆包,得到完整帧 |
| | | TCP->>Decoder: CRC8 校验并解码 |
| | | Decoder-->>TCP: 返回 realtime 或 bloodPressure 数据 |
| | | TCP->>Cache: 合并到设备最后一次完整缓存 |
| | | Cache-->>TCP: 返回完整 payload |
| | | TCP->>UI: 推送最新设备状态和指标 |
| | | TCP->>MQTT: 按原 Topic 规则立即上报完整 payload |
| | | TCP->>Aliyun: 获取/复用三元组并立即上报完整 payload |
| | | MQTT-->>TCP: 返回上报结果 |
| | | Aliyun-->>TCP: 返回上报结果 |
| | | TCP->>TCP: 成功/失败均记录日志,失败不补发 |
| | | ``` |
| | | |
| | | ## 4. 设备连接状态流程 |
| | | |
| | | ```mermaid |
| | | stateDiagram-v2 |
| | | [*] --> 未连接 |
| | | 未连接 --> 已连接: 设备盒子主动连接 |
| | | 已连接 --> 等待数据: 已匹配设备 IP |
| | | 等待数据 --> 数据正常: 收到并成功解码一帧 |
| | | 数据正常 --> 数据正常: 持续收到有效数据 |
| | | 数据正常 --> 数据超时: 超过 staleDataMs 未更新 |
| | | 数据超时 --> 数据正常: 再次收到有效数据 |
| | | 已连接 --> 离线: socket close/error/timeout |
| | | 等待数据 --> 离线: socket close/error/timeout |
| | | 数据正常 --> 离线: socket close/error/timeout |
| | | 数据超时 --> 离线: socket close/error/timeout |
| | | 离线 --> 已连接: 设备盒子重新连接 |
| | | ``` |
| | | |
| | | ## 5. 阿里云上报子流程 |
| | | |
| | | ```mermaid |
| | | flowchart TD |
| | | A[收到完整 payload] --> B{是否启用 aliyun 通道} |
| | | B -->|否| B1[跳过阿里云上报] |
| | | B -->|是| C{设备是否已有可用连接} |
| | | |
| | | C -->|有| H[调用 postProps 上报属性] |
| | | C -->|没有| D[按 deviceId 请求三元组接口] |
| | | |
| | | D --> E{三元组是否完整} |
| | | E -->|否| E1[记录失败日志<br/>本次不补发] |
| | | E -->|是| F[创建阿里云 IoT Device 实例] |
| | | F --> G[等待/复用连接] |
| | | G --> H |
| | | |
| | | H --> I{postProps 是否成功} |
| | | I -->|成功| I1[记录上报成功] |
| | | I -->|失败| I2[记录失败原因<br/>不补发] |
| | | ``` |
| | | |
| | | ## 6. 大屏数据来源 |
| | | |
| | | ```mermaid |
| | | flowchart LR |
| | | A[config.devices<br/>设备清单] --> D[大屏快照] |
| | | B[TCP 连接会话<br/>在线/离线/连接时间] --> D |
| | | C[StateCache<br/>最后一次完整 payload] --> D |
| | | |
| | | D --> E[/api/snapshot] |
| | | D --> F[/events SSE] |
| | | E --> G[浏览器大屏] |
| | | F --> G |
| | | ``` |
| | | |
| | | ## 7. 当前关键规则 |
| | | |
| | | - 本程序是 TCP 服务端,设备数据盒子作为 TCP 客户端主动连接本程序。 |
| | | - 设备身份按 `config.devices` 中配置的来源 IP 匹配,不从报文中解析设备编号。 |
| | | - 服务端不回复设备,设备会持续主动发送数据。 |
| | | - 收到实时数据或血压数据后,立即合并缓存并上传完整 payload。 |
| | | - 血压数据接收时写入 `M`,值为服务端接收时间。 |
| | | - 平均压、心率不齐、血压错误码不上传;血压错误码只记录日志。 |
| | | - MQTT Topic 沿用老项目规则。 |
| | | - 阿里云三元组获取规则沿用老项目,`deviceName = deviceId`。 |
| | | - 上报失败只记录日志,不做补发。 |
| | | - 大屏只读取内存状态,不影响 TCP 接收和上报链路。 |
| New file |
| | |
| | | # JH2028 新协议服务配置说明 |
| | | |
| | | 本文档用于说明打包产物中的 `runtime/config.json` 应该如何配置。 |
| | | |
| | | ## 1. 目录说明 |
| | | |
| | | Windows 打包目录示例: |
| | | |
| | | ```text |
| | | win-x64/ |
| | | jh2028-service.exe 服务程序 |
| | | 配置说明.md 本说明文件 |
| | | runtime/ |
| | | config.json 主配置文件,现场主要修改这个文件 |
| | | alModel.json 阿里云物模型字段,不建议随意修改 |
| | | dashboard/ 设备中央监测大屏页面文件 |
| | | logs/ 预留日志目录 |
| | | ``` |
| | | |
| | | Linux 打包目录示例: |
| | | |
| | | ```text |
| | | linux-x64/ |
| | | jh2028-service 服务程序 |
| | | 配置说明.md 本说明文件 |
| | | runtime/ |
| | | config.json 主配置文件,现场主要修改这个文件 |
| | | alModel.json 阿里云物模型字段,不建议随意修改 |
| | | dashboard/ 设备中央监测大屏页面文件 |
| | | logs/ 预留日志目录 |
| | | ``` |
| | | |
| | | 注意:如果启动命令指定 `--config ./runtime/config.json`,则 `config.json` 中相对路径会按 `runtime/` 目录计算。 |
| | | |
| | | ## 2. TCP 服务配置 |
| | | |
| | | ```json |
| | | { |
| | | "tcp": { |
| | | "host": "0.0.0.0", |
| | | "port": 9000, |
| | | "maxConnections": 100, |
| | | "socketTimeoutMs": 120000, |
| | | "keepAlive": true, |
| | | "keepAliveDelayMs": 10000, |
| | | "noDelay": true, |
| | | "backlog": 128, |
| | | "maxBufferBytes": 8192 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `host`:服务监听地址。一般保持 `0.0.0.0`,表示监听所有网卡。 |
| | | - `port`:TCP 服务端口,设备数据盒子需要连接这个端口。 |
| | | - `maxConnections`:最大连接数。 |
| | | - `socketTimeoutMs`:连接长时间无数据后的超时时间,单位毫秒。 |
| | | - `keepAlive`:是否启用 TCP KeepAlive。 |
| | | - `maxBufferBytes`:单连接缓存区上限,防止异常数据撑爆内存。 |
| | | |
| | | 当前通讯角色: |
| | | |
| | | ```text |
| | | 本程序 = TCP 服务端 |
| | | 设备数据盒子 = TCP 客户端 |
| | | 设备数据盒子主动连接 本程序IP:9000 |
| | | ``` |
| | | |
| | | ## 3. 设备列表配置 |
| | | |
| | | ```json |
| | | { |
| | | "devices": [ |
| | | { |
| | | "deviceId": "JH-001", |
| | | "ip": "192.168.1.10", |
| | | "name": "1号透析机" |
| | | } |
| | | ] |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `deviceId`:设备编号,也是 MQTT Topic 和阿里云 `deviceName` 使用的设备标识。 |
| | | - `ip`:设备数据盒子的来源 IP。程序收到 TCP 连接后按这个 IP 匹配设备。 |
| | | - `name`:大屏展示名称。 |
| | | |
| | | 现场新增设备时,在 `devices` 数组中增加一项即可。 |
| | | |
| | | 注意: |
| | | |
| | | - 当前不从报文中解析设备编号。 |
| | | - 同一个 IP 不要配置给多台设备。 |
| | | - 设备盒子 IP 变化后,需要同步修改这里的 `ip`。 |
| | | |
| | | ## 4. 大屏配置 |
| | | |
| | | ```json |
| | | { |
| | | "dashboard": { |
| | | "enabled": true, |
| | | "host": "0.0.0.0", |
| | | "port": 9100, |
| | | "title": "JH2028 设备中央监测大屏", |
| | | "staleDataMs": 180000 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `enabled`:是否启用大屏。 |
| | | - `host`:大屏监听地址,一般保持 `0.0.0.0`。 |
| | | - `port`:大屏访问端口。 |
| | | - `title`:页面标题。 |
| | | - `staleDataMs`:超过多久没收到数据后,大屏显示数据超时。 |
| | | |
| | | 浏览器访问地址: |
| | | |
| | | ```text |
| | | 本机访问:http://127.0.0.1:9100 |
| | | 局域网访问:http://服务器真实IP:9100 |
| | | ``` |
| | | |
| | | 不要在浏览器里打开: |
| | | |
| | | ```text |
| | | http://0.0.0.0:9100 |
| | | ``` |
| | | |
| | | `0.0.0.0` 只表示服务监听所有网卡,不是浏览器访问地址。 |
| | | |
| | | ## 5. 上报通道配置 |
| | | |
| | | ```json |
| | | { |
| | | "send": { |
| | | "channels": ["mqtt", "aliyun"], |
| | | "includeDeviceIdField": true, |
| | | "deviceIdField": "n" |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `channels`:启用哪些上报通道。 |
| | | - `includeDeviceIdField`:上报数据中是否包含设备编号字段。 |
| | | - `deviceIdField`:设备编号字段名,当前沿用老项目字段 `n`。 |
| | | |
| | | 可选组合: |
| | | |
| | | ```text |
| | | ["mqtt"] 只上报 MQTT |
| | | ["aliyun"] 只上报阿里云 |
| | | ["mqtt","aliyun"] 同时上报 MQTT 和阿里云 |
| | | ``` |
| | | |
| | | 上传失败只记录日志,不做补发。 |
| | | |
| | | ## 6. MQTT 配置 |
| | | |
| | | ```json |
| | | { |
| | | "mqtt": { |
| | | "protocol": "mqtt", |
| | | "host": "mqtt.ihemodialysis.com", |
| | | "port": 62283, |
| | | "username": "data", |
| | | "password": "data#2018", |
| | | "defaultTopicPrefix": "touxiji" |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `protocol`:通常为 `mqtt`。 |
| | | - `host`:MQTT 服务器地址。 |
| | | - `port`:MQTT 端口。 |
| | | - `username` / `password`:MQTT 账号密码。 |
| | | - `defaultTopicPrefix`:默认 Topic 前缀。 |
| | | |
| | | 当前 Topic 规则沿用老项目: |
| | | |
| | | ```text |
| | | defaultTopicPrefix/deviceId |
| | | ``` |
| | | |
| | | 例如: |
| | | |
| | | ```text |
| | | touxiji/JH-001 |
| | | ``` |
| | | |
| | | ## 7. 阿里云配置 |
| | | |
| | | ```json |
| | | { |
| | | "aliyun": { |
| | | "enabled": true, |
| | | "tupleApiBaseUrl": "https://things.icoldchain.cn", |
| | | "tupleApiPath": "/device/info/getAliyunDeviceSecret", |
| | | "autoRegister": true, |
| | | "registerRetryMs": 60000, |
| | | "connectTimeoutMs": 15000 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `tupleApiBaseUrl`:获取阿里云三元组接口域名。 |
| | | - `tupleApiPath`:获取阿里云三元组接口路径。 |
| | | - `autoRegister`:是否允许后端自动注册设备。 |
| | | - `registerRetryMs`:获取三元组失败后的重试间隔。 |
| | | - `connectTimeoutMs`:阿里云连接超时时间。 |
| | | |
| | | 阿里云设备名规则: |
| | | |
| | | ```text |
| | | deviceName = deviceId |
| | | ``` |
| | | |
| | | ## 8. 日志配置 |
| | | |
| | | ```json |
| | | { |
| | | "logging": { |
| | | "enabled": true, |
| | | "console": true, |
| | | "dir": "./logs", |
| | | "filePrefix": "jh2028-service", |
| | | "level": "info", |
| | | "logRawHex": false |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `enabled`:是否启用日志。 |
| | | - `console`:是否输出到控制台。 |
| | | - `dir`:日志目录。相对路径会按配置文件所在目录计算。 |
| | | - `filePrefix`:日志文件名前缀。 |
| | | - `level`:日志级别,常用 `info`。 |
| | | - `logRawHex`:是否记录原始十六进制报文。联调排查时可临时改为 `true`。 |
| | | |
| | | ## 9. 协议配置 |
| | | |
| | | ```json |
| | | { |
| | | "protocol": { |
| | | "name": "jh2028-20260511", |
| | | "alModelPath": "./alModel.json" |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 字段说明: |
| | | |
| | | - `name`:协议名称,仅用于标识。 |
| | | - `alModelPath`:物模型字段文件路径。 |
| | | |
| | | 一般不需要修改。 |
| | | |
| | | ## 10. 启动命令 |
| | | |
| | | Windows: |
| | | |
| | | ```powershell |
| | | cd dist\win-x64 |
| | | .\jh2028-service.exe --config .\runtime\config.json |
| | | ``` |
| | | |
| | | Linux: |
| | | |
| | | ```bash |
| | | cd dist/linux-x64 |
| | | chmod +x ./jh2028-service |
| | | ./jh2028-service --config ./runtime/config.json |
| | | ``` |
| | | |
| | | ## 11. 现场最常修改项 |
| | | |
| | | 通常只需要改这些字段: |
| | | |
| | | ```text |
| | | tcp.port |
| | | dashboard.port |
| | | devices |
| | | mqtt.host |
| | | mqtt.port |
| | | mqtt.username |
| | | mqtt.password |
| | | mqtt.defaultTopicPrefix |
| | | aliyun.tupleApiBaseUrl |
| | | aliyun.tupleApiPath |
| | | ``` |
| | | |
| | | 其余字段建议保持默认,除非现场网络或平台规则有明确变化。 |
| | |
| | | #!/usr/bin/env node |
| | | |
| | | /** |
| | | * JHM-2028 serial communication service. |
| | | * JHM-2028 串口调试服务。 |
| | | * |
| | | * 当前项目正式运行入口是 `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" |
| | | */ |
| | | |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM-2028 Node.js serial communication service |
| | | console.log(`JHM-2028 Node.js 串口调试服务 |
| | | |
| | | 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 |
| | | 参数: |
| | | --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 显示帮助 |
| | | |
| | | Protocol: |
| | | Frame = EE 55 NN XX1 XX2 XX3 XX4 CY |
| | | 协议格式: |
| | | 帧格式 = EE 55 NN XX1 XX2 XX3 XX4 CY |
| | | CY = (NN + XX1 + XX2 + XX3 + XX4) & 0xFF |
| | | `); |
| | | } |
| | |
| | | raw, |
| | | value: raw / 10, |
| | | unit: '°C', |
| | | note: 'PDF example shows 0x00000172 => 370 => 37.0°C', |
| | | note: '协议文档示例:0x00000172 => 370 => 37.0°C', |
| | | }; |
| | | } |
| | | |
| | |
| | | return { |
| | | name: 'value_0x03', |
| | | raw, |
| | | note: 'PDF indicates XX1<<24 + XX2<<16 + XX3<<8 + XX4', |
| | | note: '协议文档说明:数值为 XX1<<24 + XX2<<16 + XX3<<8 + XX4', |
| | | }; |
| | | } |
| | | |
| | |
| | | name: 'dual_uint16_0x04', |
| | | value1, |
| | | value2, |
| | | note: 'PDF indicates first two bytes and last two bytes are separate values', |
| | | note: '协议文档说明:前两字节和后两字节分别表示独立数值', |
| | | }; |
| | | } |
| | | |
| | |
| | | name: 'stateful_value_0x09', |
| | | mode, |
| | | raw, |
| | | note: 'PDF indicates XX1 is a flag (0x00/0x01) and XX2-XX4 compose the value', |
| | | note: '协议文档说明:XX1 为符号标志位(0x00/0x01),XX2-XX4 组成数值', |
| | | }; |
| | | } |
| | | |
| | |
| | | flag, |
| | | raw, |
| | | value: raw / 10, |
| | | note: 'PDF indicates XX1 is 0x00 and XX2-XX4 value should be divided by 10', |
| | | note: '协议文档说明:XX1 固定为 0x00,XX2-XX4 组成的数值需除以 10', |
| | | }; |
| | | } |
| | | |
| | |
| | | name: `command_${toHex(command)}`, |
| | | raw: uint32BE(payload), |
| | | payload: Array.from(payload), |
| | | note: 'Meaning not fully legible in source PDF; raw data preserved', |
| | | note: '原始协议文档语义不够清晰,先保留原始数据', |
| | | }; |
| | | } |
| | | } |
| | |
| | | if (valid) { |
| | | parsed.decoded = decodePayload(command, payload); |
| | | } else { |
| | | parsed.error = 'Checksum mismatch'; |
| | | parsed.error = '校验和不匹配'; |
| | | } |
| | | |
| | | return parsed; |
| | |
| | | const hexPairs = normalized.split(/\s+/).filter(Boolean); |
| | | |
| | | if (hexPairs.length === 0) { |
| | | throw new Error('No hex bytes found in simulate input'); |
| | | throw new Error('模拟输入中未找到十六进制字节'); |
| | | } |
| | | |
| | | const values = hexPairs.map((pair) => { |
| | | if (pair.length > 2) { |
| | | throw new Error(`Invalid byte: ${pair}`); |
| | | throw new Error(`非法字节: ${pair}`); |
| | | } |
| | | |
| | | return Number.parseInt(pair, 16); |
| | |
| | | |
| | | function validateOptions(options) { |
| | | if (!options.simulate && !options.port) { |
| | | throw new Error('Missing serial port. Example: node jhm2028-service.js --port COM3'); |
| | | throw new Error('缺少串口参数,例如: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'); |
| | | throw new Error('dataBits 只能是 5、6、7、8 之一'); |
| | | } |
| | | |
| | | if (![1, 1.5, 2].includes(options.stopBits)) { |
| | | throw new Error('stopBits must be one of 1, 1.5, 2'); |
| | | throw new Error('stopBits 只能是 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'); |
| | | throw new Error('parity 只能是 none、even、odd、mark、space 之一'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.baudRate) || options.baudRate <= 0) { |
| | | throw new Error('baudRate must be a positive integer'); |
| | | throw new Error('baudRate 必须是正整数'); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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...'); |
| | | 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(`[raw] ${bytesToHex(chunk)}`) : null, |
| | | onRawChunk: options.raw ? (chunk) => console.log(`[原始数据] ${bytesToHex(chunk)}`) : null, |
| | | onFrame: printJson, |
| | | }); |
| | | |
| | |
| | | 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}`); |
| | | console.error(`警告:模拟输入长度为 ${buffer.length} 字节,不是 ${FRAME_LENGTH} 的整数倍`); |
| | | } |
| | | } |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | console.log('\nClosing serial port...'); |
| | | console.log('\n正在关闭串口...'); |
| | | port.close((error) => { |
| | | if (error) { |
| | | console.error('Failed to close serial port:', error.message); |
| | | console.error('关闭串口失败:', error.message); |
| | | process.exit(1); |
| | | return; |
| | | } |
| | |
| | | |
| | | const parser = new Jhm2028Parser({ |
| | | includeTimestamp: options.timestamp, |
| | | onRawChunk: options.raw ? (chunk) => console.log(`[raw] ${bytesToHex(chunk)}`) : null, |
| | | onRawChunk: options.raw ? (chunk) => console.log(`[原始数据] ${bytesToHex(chunk)}`) : null, |
| | | onFrame: printJson, |
| | | }); |
| | | |
| | |
| | | }); |
| | | |
| | | port.on('error', (error) => { |
| | | console.error('Serial port error:', error.message); |
| | | console.error('串口异常:', error.message); |
| | | }); |
| | | |
| | | port.on('close', () => { |
| | | console.log('Serial port closed'); |
| | | console.log('串口已关闭'); |
| | | }); |
| | | |
| | | process.on('SIGINT', () => { |
| | |
| | | |
| | | port.open((error) => { |
| | | if (error) { |
| | | console.error('Failed to open serial port:', error.message); |
| | | console.error('打开串口失败:', error.message); |
| | | process.exit(1); |
| | | } |
| | | }); |
| | |
| | | runSerialService(options); |
| | | } catch (error) { |
| | | console.error(error.message); |
| | | console.error('Use --help to see available options.'); |
| | | console.error('可使用 --help 查看可用参数。'); |
| | | process.exit(1); |
| | | } |
| | | } |
| | |
| | | this.timer = null; |
| | | this.started = false; |
| | | this.flushing = false; |
| | | this.flushQueued = false; |
| | | this.queuedFlushOptions = null; |
| | | } |
| | | |
| | | start() { |
| | |
| | | |
| | | const entry = this.getOrCreateEntry(device); |
| | | entry.device = device; |
| | | const now = Date.now(); |
| | | |
| | | // 首次进入脏状态时记录时间,便于控制“缓存满一轮再发送”。 |
| | | if (!entry.dirty) { |
| | | entry.dirtySinceAt = now; |
| | | } |
| | | |
| | | entry.dirty = true; |
| | | entry.lastUpdateAt = Date.now(); |
| | | entry.lastUpdateAt = now; |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | entry.payload[this.deviceIdField] = device.deviceId; |
| | |
| | | device, |
| | | payload: {}, |
| | | dirty: false, |
| | | dirtySinceAt: 0, |
| | | firstSeenAt: Date.now(), |
| | | lastUpdateAt: 0, |
| | | lastFlushAt: 0, |
| | |
| | | |
| | | this.timer = setTimeout(() => { |
| | | this.flush({ reason: 'timer' }).catch((error) => { |
| | | this.logger.error(`[APP] Aggregator flush failed: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器刷新失败: ${error.message}`); |
| | | }); |
| | | }, this.computeDelayMs()); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | mergeQueuedFlushOptions(options = {}) { |
| | | const nextOptions = { |
| | | reason: options.reason || 'manual', |
| | | deviceId: options.deviceId || '', |
| | | }; |
| | | |
| | | if (!this.queuedFlushOptions) { |
| | | this.queuedFlushOptions = nextOptions; |
| | | return; |
| | | } |
| | | |
| | | const currentOptions = this.queuedFlushOptions; |
| | | const currentIsTimer = currentOptions.reason === 'timer' || currentOptions.reason === 'queued'; |
| | | const nextIsTimer = nextOptions.reason === 'timer' || nextOptions.reason === 'queued'; |
| | | |
| | | if (currentIsTimer && !nextIsTimer) { |
| | | currentOptions.reason = nextOptions.reason; |
| | | } |
| | | |
| | | if (!currentOptions.deviceId || !nextOptions.deviceId || currentOptions.deviceId !== nextOptions.deviceId) { |
| | | currentOptions.deviceId = ''; |
| | | } |
| | | } |
| | | |
| | | async flush(options = {}) { |
| | | const reason = options.reason || 'manual'; |
| | | const deviceId = options.deviceId || ''; |
| | | |
| | | if (this.flushing) { |
| | | this.flushQueued = true; |
| | | this.mergeQueuedFlushOptions({ reason, deviceId }); |
| | | return false; |
| | | } |
| | | |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 定时触发时要求数据至少缓存满一个周期,避免刚收到就被单独发出。 |
| | | if ((reason === 'timer' || reason === 'queued') && entry.dirtySinceAt > 0) { |
| | | const dirtyAgeMs = Date.now() - entry.dirtySinceAt; |
| | | |
| | | if (dirtyAgeMs < this.flushIntervalMs) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | }); |
| | | |
| | | for (const entry of entries) { |
| | | const flushUpdateAt = entry.lastUpdateAt; |
| | | const payload = { |
| | | ...entry.payload, |
| | | }; |
| | |
| | | continue; |
| | | } |
| | | |
| | | entry.dirty = false; |
| | | if (entry.lastUpdateAt === flushUpdateAt) { |
| | | entry.dirty = false; |
| | | entry.dirtySinceAt = 0; |
| | | } |
| | | |
| | | entry.lastFlushAt = Date.now(); |
| | | } catch (error) { |
| | | this.logger.error(`[APP] Aggregator device flush failed deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器设备刷新失败 deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | this.scheduleNextFlush(); |
| | | } |
| | | |
| | | if (this.flushQueued) { |
| | | this.flushQueued = false; |
| | | await this.flush({ reason: 'queued' }); |
| | | if (this.queuedFlushOptions) { |
| | | const queuedOptions = this.queuedFlushOptions; |
| | | this.queuedFlushOptions = null; |
| | | await this.flush(queuedOptions); |
| | | } |
| | | } |
| | | } |
| | |
| | | "scripts": { |
| | | "start": "node app.js", |
| | | "start:serial": "node jhm2028-service.js", |
| | | "start:simulator": "node tcp-simulator.js", |
| | | "start:simulator": "node tcp-simulator.js --with-bp", |
| | | "start:simulator:bp": "node tcp-simulator.js --bp --host 127.0.0.1 --port 9000 --repeat 1", |
| | | "clean": "node scripts/clean-build.js", |
| | | "build": "node scripts/build-executables.js", |
| | |
| | | const fs = require('fs'); |
| | | const path = require('path'); |
| | | |
| | | // 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。 |
| | | function fileExists(filePath) { |
| | | try { |
| | | return fs.statSync(filePath).isFile(); |
| | |
| | | |
| | | map.set(normalizeIp(device.ip), { |
| | | ...device, |
| | | name: device.name || device['备注'] || device.deviceId, |
| | | name: device.name || device.备注 || device['澶囨敞'] || device.deviceId, |
| | | }); |
| | | } |
| | | |
| | |
| | | this.server.maxConnections = this.tcpConfig.maxConnections || 100; |
| | | |
| | | this.server.on('error', (error) => { |
| | | this.logger.error(`[TCP] 服务错误: ${error.message}`); |
| | | this.logger.error(`[TCP] 服务异常: ${error.message}`); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | |
| | | }); |
| | | }); |
| | | |
| | | this.logger.info(`[TCP] 已监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | this.logger.info(`[TCP] 已开始监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | } |
| | | |
| | | handleConnection(socket) { |
| | |
| | | const results = session.decoder.push(chunk); |
| | | |
| | | if (results.length === 0) { |
| | | this.logger.warn(`[TCP] 当前数据片段未形成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | this.logger.warn(`[TCP] 当前数据片段尚未组成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | } |
| | | |
| | | for (const result of results) { |
| | |
| | | }, |
| | | }; |
| | | |
| | | // 模拟器支持三种方式:文件回放、单独血压、文件加血压混发。 |
| | | 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 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} 必须是 0 到 65535 之间的整数`); |
| | | } |
| | | } |
| | | |
| | | if (options.bpIncludeTime && options.bpTime) { |
| | | parseBloodPressureTimeText(options.bpTime); |
| | | } |
| | | } |
| | | |
| | | function validateOptions(options) { |
| | | if (!options.host) { |
| | | throw new Error('host is required'); |
| | | throw new Error('必须指定 host'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.port) || options.port <= 0) { |
| | | throw new Error('port must be a positive integer'); |
| | | throw new Error('port 必须是正整数'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.intervalMs) || options.intervalMs <= 0) { |
| | | throw new Error('interval must be a positive integer in ms'); |
| | | throw new Error('interval 必须是大于 0 的毫秒整数'); |
| | | } |
| | | |
| | | if (!Number.isInteger(options.repeat) || options.repeat < 0) { |
| | | throw new Error('repeat must be an integer >= 0'); |
| | | throw new Error('repeat 必须是大于等于 0 的整数'); |
| | | } |
| | | |
| | | if (!['file', 'blood-pressure'].includes(options.mode)) { |
| | | throw new Error(`unsupported mode: ${options.mode}`); |
| | | throw new Error(`不支持的模式: ${options.mode}`); |
| | | } |
| | | |
| | | if (options.mode === 'file' && !fs.existsSync(options.framesFile)) { |
| | | throw new Error(`frames file not found: ${options.framesFile}`); |
| | | throw new Error(`未找到报文文件: ${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); |
| | | } |
| | | 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); |
| | | }); |
| | | } |
| | |
| | | const assert = require('assert'); |
| | | const { createOnMetricHandler, getBloodPressureOptions } = require('../app'); |
| | | |
| | | describe('应用程序功能测试', () => { |
| | | it('应该返回正确的结果', () => { |
| | | assert.strictEqual(1 + 1, 2); |
| | | describe('app', () => { |
| | | it('defaults blood pressure immediate flush to enabled', () => { |
| | | assert.deepStrictEqual(getBloodPressureOptions({}), { |
| | | publishTime: true, |
| | | flushImmediately: true, |
| | | }); |
| | | }); |
| | | |
| | | assert.deepStrictEqual(getBloodPressureOptions({ |
| | | protocol: { |
| | | bloodPressure: { |
| | | publishTime: false, |
| | | flushImmediately: false, |
| | | }, |
| | | }, |
| | | }), { |
| | | publishTime: false, |
| | | flushImmediately: false, |
| | | }); |
| | | }); |
| | | |
| | | it('flushes the full cached payload immediately after blood pressure arrives in batch mode', async () => { |
| | | const calls = []; |
| | | const handler = createOnMetricHandler({ |
| | | logger: { info() {}, error() {} }, |
| | | sendOptions: { mode: 'batch' }, |
| | | bloodPressureOptions: { flushImmediately: true }, |
| | | aggregator: { |
| | | ingest: async (device, metric) => { |
| | | calls.push({ type: 'ingest', deviceId: device.deviceId, metric }); |
| | | return true; |
| | | }, |
| | | flush: async (options) => { |
| | | calls.push({ type: 'flush', options }); |
| | | return true; |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | await handler( |
| | | { deviceId: 'JH-001' }, |
| | | { N: 120, O: 80, P: 89, M: '2026-04-30 10:20:30' }, |
| | | { protocol: 'blood-pressure' }, |
| | | ); |
| | | |
| | | assert.deepStrictEqual(calls, [ |
| | | { |
| | | type: 'ingest', |
| | | deviceId: 'JH-001', |
| | | metric: { N: 120, O: 80, P: 89, M: '2026-04-30 10:20:30' }, |
| | | }, |
| | | { |
| | | type: 'flush', |
| | | options: { reason: 'blood-pressure', deviceId: 'JH-001' }, |
| | | }, |
| | | ]); |
| | | }); |
| | | |
| | | it('does not trigger immediate flush for non-blood-pressure metrics', async () => { |
| | | const calls = []; |
| | | const handler = createOnMetricHandler({ |
| | | logger: { info() {}, error() {} }, |
| | | sendOptions: { mode: 'batch' }, |
| | | bloodPressureOptions: { flushImmediately: true }, |
| | | aggregator: { |
| | | ingest: async () => { |
| | | calls.push('ingest'); |
| | | return true; |
| | | }, |
| | | flush: async () => { |
| | | calls.push('flush'); |
| | | return true; |
| | | }, |
| | | }, |
| | | }); |
| | | |
| | | await handler( |
| | | { deviceId: 'JH-001' }, |
| | | { F: 36.7 }, |
| | | { protocol: 'jhm' }, |
| | | ); |
| | | |
| | | assert.deepStrictEqual(calls, ['ingest']); |
| | | }); |
| | | }); |
| | |
| | | F: 24.6, |
| | | }); |
| | | }); |
| | | |
| | | it('waits a full flush interval before timer flushes newly cached data', async () => { |
| | | const originalNow = Date.now; |
| | | let now = 1_000; |
| | | Date.now = () => now; |
| | | |
| | | try { |
| | | const flushed = []; |
| | | const aggregator = new MetricAggregator({ |
| | | mode: 'batch', |
| | | flushIntervalMs: 60_000, |
| | | onFlush: async (_device, payload) => { |
| | | flushed.push(payload); |
| | | return true; |
| | | }, |
| | | }); |
| | | |
| | | aggregator.ingest({ deviceId: 'JH-001' }, { F: 24.6 }); |
| | | |
| | | now = 30_000; |
| | | await aggregator.flush({ reason: 'timer' }); |
| | | assert.strictEqual(flushed.length, 0); |
| | | |
| | | now = 61_000; |
| | | await aggregator.flush({ reason: 'timer' }); |
| | | assert.strictEqual(flushed.length, 1); |
| | | assert.deepStrictEqual(flushed[0], { |
| | | n: 'JH-001', |
| | | F: 24.6, |
| | | }); |
| | | } finally { |
| | | Date.now = originalNow; |
| | | } |
| | | }); |
| | | |
| | | it('preserves immediate flush intent when a second flush is queued during an ongoing flush', async () => { |
| | | const flushed = []; |
| | | let releaseFirstFlush = null; |
| | | const firstFlushDone = new Promise((resolve) => { |
| | | releaseFirstFlush = resolve; |
| | | }); |
| | | |
| | | const aggregator = new MetricAggregator({ |
| | | mode: 'batch', |
| | | onFlush: async (_device, payload) => { |
| | | flushed.push(payload); |
| | | |
| | | if (flushed.length === 1) { |
| | | await firstFlushDone; |
| | | } |
| | | |
| | | return true; |
| | | }, |
| | | }); |
| | | |
| | | aggregator.ingest({ deviceId: 'JH-001' }, { F: 24.6 }); |
| | | const firstFlushPromise = aggregator.flush({ reason: 'manual' }); |
| | | |
| | | await new Promise((resolve) => setTimeout(resolve, 0)); |
| | | |
| | | aggregator.ingest({ deviceId: 'JH-001' }, { N: 120, O: 80, P: 89 }); |
| | | const queuedFlushResult = await aggregator.flush({ reason: 'blood-pressure', deviceId: 'JH-001' }); |
| | | |
| | | assert.strictEqual(queuedFlushResult, false); |
| | | |
| | | releaseFirstFlush(); |
| | | await firstFlushPromise; |
| | | |
| | | assert.strictEqual(flushed.length, 2); |
| | | assert.deepStrictEqual(flushed[0], { |
| | | n: 'JH-001', |
| | | F: 24.6, |
| | | }); |
| | | assert.deepStrictEqual(flushed[1], { |
| | | n: 'JH-001', |
| | | F: 24.6, |
| | | N: 120, |
| | | O: 80, |
| | | P: 89, |
| | | }); |
| | | }); |
| | | }); |
| | |
| | | const assert = require('assert'); |
| | | const { buildBloodPressureFrame, parseBloodPressureTimeText } = require('../tcp-simulator'); |
| | | const fs = require('fs'); |
| | | const os = require('os'); |
| | | const path = require('path'); |
| | | const { |
| | | buildBloodPressureFrame, |
| | | parseBloodPressureTimeText, |
| | | prepareSimulation, |
| | | } = require('../tcp-simulator'); |
| | | |
| | | describe('tcp-simulator', () => { |
| | | it('parses blood pressure time text', () => { |
| | |
| | | 'AA 55 0E BA 00 78 50 59 1A 04 0F 09 1E 3C', |
| | | ); |
| | | }); |
| | | |
| | | it('can append a blood pressure frame to file mode simulation', () => { |
| | | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tcp-simulator-')); |
| | | const framesFile = path.join(tempDir, 'frames.txt'); |
| | | fs.writeFileSync(framesFile, 'EE 55 01 00 00 01 72 74\n', 'utf8'); |
| | | |
| | | try { |
| | | const prepared = prepareSimulation({ |
| | | mode: 'file', |
| | | framesFile, |
| | | scenarioConfig: null, |
| | | includeBloodPressure: true, |
| | | bpSystolic: 120, |
| | | bpDiastolic: 80, |
| | | bpPulse: 89, |
| | | bpTime: '2026-04-15 09:30', |
| | | bpIncludeTime: true, |
| | | }); |
| | | |
| | | assert.strictEqual(prepared.frames.length, 2); |
| | | assert.strictEqual(prepared.frames[1].raw, 'AA 55 0E BA 00 78 50 59 1A 04 0F 09 1E 3C'); |
| | | assert.deepStrictEqual(prepared.analysis.snapshot, { |
| | | F: 37, |
| | | N: 120, |
| | | O: 80, |
| | | P: 89, |
| | | }); |
| | | } finally { |
| | | fs.rmSync(tempDir, { recursive: true, force: true }); |
| | | } |
| | | }); |
| | | }); |
| New file |
| | |
| | | 设定温度 AF |
| | | 当前透析液温度 F |
| | | 设定超滤总量 A |
| | | 超滤率 C |
| | | 超滤量 B |
| | | 剩余时间 K |
| | | 透析液流量 L |
| | | 有效血流量 D |
| | | 静脉压 H |
| | | 动脉压 o |
| | | 跨膜压 J |
| | | 累计血流量 U |
| | | 电导率 G |
| | | 钠含量 Na |
| | | 碳酸氢根含量 HCO3 |
| | | 血氧饱和度 O2Sat |
| | | 红细胞比容 Hct |
| | | 血红蛋白 Hb |
| | | 血液温度 Tblood |
| | | Kt/V ktv |
| | | 收缩压(高压), |
| | | 舒张压(低压), |
| | | 心率值,单位: |
| | | |
| | |
| | | maxBufferBytes: config.tcp.maxBufferBytes, |
| | | }); |
| | | const mqttService = new MqttService(config.mqtt, console); |
| | | const device = config.devices[0] || { deviceId: 'JHM-TEST', ip: '127.0.0.1', name: 'test-device' }; |
| | | const device = config.devices[0] || { deviceId: 'JHM-TEST', ip: '127.0.0.1', name: '测试设备' }; |
| | | |
| | | function buildFrame(command, payload) { |
| | | const cy = (command + payload[0] + payload[1] + payload[2] + payload[3]) & 0xFF; |
| | |
| | | } |
| | | |
| | | const cases = [ |
| | | { command: 0x01, payload: [0x00, 0x00, 0x01, 0x77], expectedPublish: true, expectedMetric: { F: 37.5 }, note: 'temperature = 375 / 10' }, |
| | | { command: 0x02, payload: [0x00, 0x00, 0x01, 0x77], expectedPublish: false, reason: 'identifier-missing', note: 'set temperature not published' }, |
| | | { command: 0x03, payload: [0x00, 0x00, 0x04, 0xD2], expectedPublish: true, expectedMetric: { A: 1.234 }, note: 'ultrafiltration total = 1234mL => 1.234L' }, |
| | | { command: 0x04, payload: [0x00, 0x01, 0x00, 0x1E], expectedPublish: true, expectedMetric: { sysj: 90 }, note: 'remaining minutes = 1*60 + 30' }, |
| | | { command: 0x05, payload: [0x00, 0x00, 0x01, 0xF4], expectedPublish: true, expectedMetric: { C: 0.5 }, note: 'uf rate = 500mL => 0.5L' }, |
| | | { command: 0x06, payload: [0x00, 0x00, 0x00, 0xFA], expectedPublish: true, expectedMetric: { B: 0.25 }, note: 'uf volume = 250mL => 0.25L' }, |
| | | { command: 0x07, payload: [0x00, 0x00, 0x02, 0x58], expectedPublish: true, expectedMetric: { L: 600 }, note: 'dialysate flow = 600' }, |
| | | { command: 0x08, payload: [0x00, 0x00, 0x01, 0x40], expectedPublish: true, expectedMetric: { D: 320 }, note: 'effective blood flow = 320' }, |
| | | { command: 0x09, payload: [0x01, 0x00, 0x00, 0x64], expectedPublish: true, expectedMetric: { H: -100 }, note: 'venous pressure = -100' }, |
| | | { command: 0x0A, payload: [0x00, 0x00, 0x00, 0x78], expectedPublish: true, expectedMetric: { o: 120 }, note: 'arterial pressure = 120' }, |
| | | { command: 0x0B, payload: [0x01, 0x00, 0x00, 0x50], expectedPublish: true, expectedMetric: { J: -80 }, note: 'transmembrane pressure = -80' }, |
| | | { command: 0x0C, payload: [0x00, 0x00, 0x08, 0x00], expectedPublish: true, expectedMetric: { U: 2048 }, note: 'accumulated blood flow = 2048' }, |
| | | { command: 0x0D, payload: [0x00, 0x00, 0x00, 0x7B], expectedPublish: true, expectedMetric: { G: 12.3 }, note: 'conductivity = 123 / 10' }, |
| | | { command: 0x0E, payload: [0x00, 0x00, 0x00, 0x8C], expectedPublish: true, expectedMetric: { Na: 140 }, note: 'sodium concentration = 140' }, |
| | | { command: 0x0F, payload: [0x00, 0x00, 0x00, 0x19], expectedPublish: true, expectedMetric: { HCO3: 25 }, note: 'HCO3 concentration = 25' }, |
| | | { command: 0x01, payload: [0x00, 0x00, 0x01, 0x77], expectedPublish: true, expectedMetric: { F: 37.5 }, note: '温度 = 375 / 10' }, |
| | | { command: 0x02, payload: [0x00, 0x00, 0x01, 0x77], expectedPublish: false, reason: 'identifier-missing', note: '设定温度不发布' }, |
| | | { command: 0x03, payload: [0x00, 0x00, 0x04, 0xD2], expectedPublish: true, expectedMetric: { A: 1.234 }, note: '超滤总量 = 1234mL => 1.234L' }, |
| | | { command: 0x04, payload: [0x00, 0x01, 0x00, 0x1E], expectedPublish: true, expectedMetric: { sysj: 90 }, note: '剩余时间 = 1*60 + 30 分钟' }, |
| | | { command: 0x05, payload: [0x00, 0x00, 0x01, 0xF4], expectedPublish: true, expectedMetric: { C: 0.5 }, note: '超滤率 = 500mL => 0.5L' }, |
| | | { command: 0x06, payload: [0x00, 0x00, 0x00, 0xFA], expectedPublish: true, expectedMetric: { B: 0.25 }, note: '超滤量 = 250mL => 0.25L' }, |
| | | { command: 0x07, payload: [0x00, 0x00, 0x02, 0x58], expectedPublish: true, expectedMetric: { L: 600 }, note: '透析液流量 = 600' }, |
| | | { command: 0x08, payload: [0x00, 0x00, 0x01, 0x40], expectedPublish: true, expectedMetric: { D: 320 }, note: '有效血流量 = 320' }, |
| | | { command: 0x09, payload: [0x01, 0x00, 0x00, 0x64], expectedPublish: true, expectedMetric: { H: -100 }, note: '静脉压 = -100' }, |
| | | { command: 0x0A, payload: [0x00, 0x00, 0x00, 0x78], expectedPublish: true, expectedMetric: { o: 120 }, note: '动脉压 = 120' }, |
| | | { command: 0x0B, payload: [0x01, 0x00, 0x00, 0x50], expectedPublish: true, expectedMetric: { J: -80 }, note: '跨膜压 = -80' }, |
| | | { command: 0x0C, payload: [0x00, 0x00, 0x08, 0x00], expectedPublish: true, expectedMetric: { U: 2048 }, note: '累计血流量 = 2048' }, |
| | | { command: 0x0D, payload: [0x00, 0x00, 0x00, 0x7B], expectedPublish: true, expectedMetric: { G: 12.3 }, note: '电导率 = 123 / 10' }, |
| | | { command: 0x0E, payload: [0x00, 0x00, 0x00, 0x8C], expectedPublish: true, expectedMetric: { Na: 140 }, note: '钠离子浓度 = 140' }, |
| | | { command: 0x0F, payload: [0x00, 0x00, 0x00, 0x19], expectedPublish: true, expectedMetric: { HCO3: 25 }, note: 'HCO3 浓度 = 25' }, |
| | | ]; |
| | | |
| | | const bloodPressureCase = { |
| | |
| | | function main() { |
| | | const topic = mqttService.buildTopic(device); |
| | | |
| | | console.log(`verify device: ${device.deviceId} (${device.ip})`); |
| | | console.log(`verify topic: ${topic}`); |
| | | console.log(`校验设备: ${device.deviceId} (${device.ip})`); |
| | | console.log(`校验主题: ${topic}`); |
| | | console.log(''); |
| | | |
| | | for (const item of cases) { |
| | |
| | | |
| | | if (item.expectedPublish) { |
| | | assert.deepStrictEqual(result.metric, item.expectedMetric, `command 0x${item.command.toString(16).toUpperCase()} metric mismatch`); |
| | | console.log(`[publish] command=0x${item.command.toString(16).toUpperCase().padStart(2, '0')} payload=${payloadToHex(item.payload)} topic=${topic} mqtt=${JSON.stringify(result.metric)} note=${item.note}`); |
| | | console.log(`[发布] command=0x${item.command.toString(16).toUpperCase().padStart(2, '0')} payload=${payloadToHex(item.payload)} topic=${topic} mqtt=${JSON.stringify(result.metric)} note=${item.note}`); |
| | | } else { |
| | | assert.strictEqual(result.reason, item.reason, `command 0x${item.command.toString(16).toUpperCase()} skip reason mismatch`); |
| | | console.log(`[skip] command=0x${item.command.toString(16).toUpperCase().padStart(2, '0')} payload=${payloadToHex(item.payload)} reason=${result.reason} note=${item.note}`); |
| | | console.log(`[跳过] command=0x${item.command.toString(16).toUpperCase().padStart(2, '0')} payload=${payloadToHex(item.payload)} reason=${result.reason} note=${item.note}`); |
| | | } |
| | | } |
| | | |
| | |
| | | assert.ok(bloodPressureResult, 'blood pressure frame returned no result'); |
| | | assert.strictEqual(bloodPressureResult.publish, true, 'blood pressure frame should publish'); |
| | | assert.deepStrictEqual(bloodPressureResult.metric, bloodPressureCase.expectedMetric, 'blood pressure metric mismatch'); |
| | | console.log(`[publish] command=0xBA raw=${bloodPressureResult.rawHex} topic=${topic} mqtt=${JSON.stringify(bloodPressureResult.metric)} note=blood pressure frame`); |
| | | console.log(`[发布] command=0xBA raw=${bloodPressureResult.rawHex} topic=${topic} mqtt=${JSON.stringify(bloodPressureResult.metric)} note=血压报文`); |
| | | |
| | | console.log(''); |
| | | console.log('verify-all-commands passed'); |
| | | console.log('verify-all-commands 校验通过'); |
| | | } |
| | | |
| | | main(); |
| | |
| | | |
| | | ## 1. 目标 |
| | | |
| | | 本文档用于指导 JHM 服务在生产环境中部署、配置和联调。 |
| | | 本文档用于指导 JHM 服务在生产环境中的打包、部署、配置、联调和验收。 |
| | | |
| | | 当前服务支持: |
| | | 当前服务能力包括: |
| | | |
| | | - 透析机 `EE 55` 固定帧协议 |
| | | - 血压计 `AA 55` 变长协议 |
| | | - 透析机 `EE 55` 固定帧协议解析 |
| | | - 血压计 `AA 55` 变长协议解析 |
| | | - MQTT 上报 |
| | | - 阿里云上报 |
| | | - 阿里云物模型上报 |
| | | - 批量聚合发送 |
| | | - 血压触发的整包立即发送 |
| | | |
| | | ## 2. 打包产物 |
| | | |
| | | 执行: |
| | | 在项目根目录执行: |
| | | |
| | | ```bash |
| | | ```powershell |
| | | npm run build |
| | | ``` |
| | | |
| | | 生成目录: |
| | | 如果只需要 Windows 包: |
| | | |
| | | ```powershell |
| | | npm run build:win |
| | | ``` |
| | | |
| | | 如果只需要 Linux 包: |
| | | |
| | | ```powershell |
| | | npm run build:linux |
| | | ``` |
| | | |
| | | 打包完成后生成目录: |
| | | |
| | | ```text |
| | | dist/ |
| | | 生产实施部署文档.md |
| | | win-x64/ |
| | | jhm-service.exe |
| | | 生产实施部署文档.md |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | 生产实施部署文档.md |
| | | logs/ |
| | | service/ |
| | | install-service.ps1 |
| | | uninstall-service.ps1 |
| | | linux-x64/ |
| | | jhm-service |
| | | 生产实施部署文档.md |
| | | runtime/ |
| | | config.json |
| | | alModel.json |
| | | 生产实施部署文档.md |
| | | logs/ |
| | | service/ |
| | | install-service.sh |
| | | uninstall-service.sh |
| | | jhm-service.service.tpl |
| | | ``` |
| | | |
| | | 说明: |
| | | |
| | | - `runtime/` 是现场主要维护目录。 |
| | | - `logs/` 为运行日志目录。 |
| | | - `service/` 为系统服务安装脚本模板。 |
| | | - 打包脚本会先清理旧的 `build/` 和 `dist/` 目录。 |
| | | |
| | | ## 3. 部署前检查 |
| | | |
| | | 部署前确认: |
| | | 部署前确认以下信息已准备完成: |
| | | |
| | | - 目标服务器 IP、系统、开放端口 |
| | | - 设备透传盒目标 TCP 地址和端口 |
| | | - `devices[].ip` 与现场实际来源 IP 一致 |
| | | - MQTT 或阿里云连接参数已准备完成 |
| | | - 目标服务器操作系统和架构是否正确 |
| | | - 目标服务器开放了业务监听端口 |
| | | - 透传盒子目标 TCP 地址和端口已配置正确 |
| | | - `devices[].ip` 与服务端实际看到的设备来源 IP 一致 |
| | | - MQTT 或阿里云连接参数已确认可用 |
| | | - `alModel.json` 与平台物模型保持一致 |
| | | - 现场是否需要血压时间字段 `M` |
| | | - 现场是否需要“血压触发整包立即发送” |
| | | |
| | | ## 4. 配置文件说明 |
| | | |
| | | 主要修改文件: |
| | | 主要配置文件: |
| | | |
| | | ```text |
| | | runtime/config.json |
| | | ``` |
| | | |
| | | 当前推荐结构: |
| | | 推荐配置结构如下: |
| | | |
| | | ```json |
| | | { |
| | | "send": { |
| | | "mode": "batch", |
| | | "flushIntervalMs": 60000, |
| | | "alignToMinute": true, |
| | | "includeDeviceIdField": true, |
| | | "deviceIdField": "n", |
| | | "publishOnShutdown": true, |
| | | "channels": ["aliyun"] |
| | | }, |
| | | "logging": { |
| | |
| | | "protocol": { |
| | | "alModelPath": "./alModel.json", |
| | | "bloodPressure": { |
| | | "publishTime": true |
| | | "publishTime": true, |
| | | "flushImmediately": true |
| | | } |
| | | }, |
| | | "devices": [ |
| | | { |
| | | "deviceId": "JHM-001", |
| | | "ip": "192.168.33.1", |
| | | "ip": "169.254.233.58", |
| | | "name": "1号透析机" |
| | | } |
| | | ] |
| | |
| | | |
| | | ### 4.1 `send` |
| | | |
| | | - `channels`:可选 `mqtt`、`aliyun` |
| | | - `mode`:发送模式,支持 `batch` 和 `immediate` |
| | | - `flushIntervalMs`:批量发送周期,单位毫秒,当前推荐 `60000` |
| | | - `alignToMinute`:是否按整分钟对齐发送 |
| | | - `includeDeviceIdField`:发送时是否在 payload 中带设备编号字段 |
| | | - `deviceIdField`:设备编号字段名,当前默认 `n` |
| | | - `publishOnShutdown`:停机前是否补发一次缓存数据 |
| | | - `channels`:发送通道,可选 `mqtt`、`aliyun` |
| | | |
| | | 常见组合: |
| | | |
| | | - 只发 MQTT:`["mqtt"]` |
| | | - 只发阿里云:`["aliyun"]` |
| | | - 双发:`["mqtt", "aliyun"]` |
| | | - 双通道发送:`["mqtt", "aliyun"]` |
| | | |
| | | ### 4.2 `logging` |
| | | |
| | | - `enabled`:是否写本地日志 |
| | | - `console`:是否输出控制台 |
| | | - `enabled`:是否写本地日志文件 |
| | | - `console`:是否输出控制台日志 |
| | | - `dir`:日志目录 |
| | | - `filePrefix`:日志文件前缀 |
| | | - `level`:`debug/info/warn/error` |
| | | - `filePrefix`:日志文件名前缀 |
| | | - `level`:日志级别,支持 `debug`、`info`、`warn`、`error` |
| | | |
| | | ### 4.3 `tcp` |
| | | |
| | | - `host`:监听地址,生产建议 `0.0.0.0` |
| | | - `host`:监听地址,生产推荐 `0.0.0.0` |
| | | - `port`:TCP 监听端口 |
| | | - `maxConnections`:最大连接数 |
| | | - `socketTimeoutMs`:连接超时时间 |
| | | - `socketTimeoutMs`:连接空闲超时时间 |
| | | - `keepAlive`:是否启用 KeepAlive |
| | | - `keepAliveDelayMs`:KeepAlive 延迟 |
| | | - `noDelay`:是否关闭 Nagle |
| | | - `keepAliveDelayMs`:KeepAlive 首次探测延迟 |
| | | - `noDelay`:是否关闭 Nagle 算法 |
| | | - `backlog`:监听队列长度 |
| | | - `maxBufferBytes`:解码缓冲区上限 |
| | | - `maxBufferBytes`:单连接解码缓冲区上限 |
| | | |
| | | ### 4.4 `mqtt` |
| | | |
| | | - `protocol`:通常为 `mqtt` |
| | | - `protocol`:通常填写 `mqtt` |
| | | - `host`:Broker 地址 |
| | | - `port`:Broker 端口 |
| | | - `username`:用户名 |
| | | - `password`:密码 |
| | | - `defaultTopicPrefix`:topic 前缀 |
| | | - `defaultTopicPrefix`:Topic 前缀 |
| | | - `topicTemplate`:如使用模板模式,可替代默认前缀模式 |
| | | |
| | | topic 规则: |
| | | 默认 Topic 规则: |
| | | |
| | | ```text |
| | | {defaultTopicPrefix}/{deviceId} |
| | |
| | | - `tupleApiBaseUrl`:三元组接口基础地址 |
| | | - `tupleApiPath`:三元组接口路径 |
| | | - `autoRegister`:是否允许自动注册 |
| | | - `registerRetryMs`:失败重试冷却时间 |
| | | - `connectTimeoutMs`:连接超时时间 |
| | | - `registerRetryMs`:三元组请求失败后的冷却重试时间 |
| | | - `connectTimeoutMs`:阿里云设备连接超时时间 |
| | | |
| | | ### 4.6 `protocol` |
| | | |
| | | - `alModelPath`:模型文件路径 |
| | | - `bloodPressure.publishTime`:是否发布血压监测时间 `M` |
| | | - `alModelPath`:物模型文件路径 |
| | | - `bloodPressure.publishTime`:是否上报血压时间字段 `M` |
| | | - `bloodPressure.flushImmediately`:血压报文到达后是否立即触发一次整包发送 |
| | | |
| | | 规则: |
| | | `publishTime` 规则: |
| | | |
| | | - `true`:发布 `N/O/P/M` |
| | | - `false`:只发布 `N/O/P` |
| | | - `true`:上报 `N/O/P/M` |
| | | - `false`:仅上报 `N/O/P` |
| | | - 未配置时默认 `true` |
| | | |
| | | 如果现场平台未接血压时间字段,建议配置: |
| | | `flushImmediately` 规则: |
| | | |
| | | - `true`:血压到达后,先缓存 `N/O/P/M`,再立即发送当前设备缓存中的整包物模型 |
| | | - `false`:血压仅进入缓存,继续等待定时批量发送 |
| | | - 未配置时默认 `true` |
| | | |
| | | 如果平台不接收血压时间字段,可配置: |
| | | |
| | | ```json |
| | | "protocol": { |
| | | "alModelPath": "./alModel.json", |
| | | "bloodPressure": { |
| | | "publishTime": false |
| | | { |
| | | "protocol": { |
| | | "alModelPath": "./alModel.json", |
| | | "bloodPressure": { |
| | | "publishTime": false, |
| | | "flushImmediately": true |
| | | } |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 如果现场明确要求“只按分钟发送,不要血压即时触发”,可配置: |
| | | |
| | | ```json |
| | | { |
| | | "protocol": { |
| | | "alModelPath": "./alModel.json", |
| | | "bloodPressure": { |
| | | "publishTime": true, |
| | | "flushImmediately": false |
| | | } |
| | | } |
| | | } |
| | | ``` |
| | |
| | | - `ip` |
| | | - `name` |
| | | |
| | | 注意: |
| | | 注意事项: |
| | | |
| | | - `ip` 必须与服务端实际看到的客户端 IP 一致 |
| | | - 如果经过 NAT,要填写 NAT 后服务端看到的 IP |
| | | - `ip` 必须与服务端实际看到的客户端来源 IP 完全一致 |
| | | - 如果经过 NAT,需要填写 NAT 后服务端可见的来源 IP |
| | | - 如果现场使用备注字段,也建议同步补齐 `name`,便于日志识别 |
| | | |
| | | ## 5. 血压计协议补充 |
| | | ## 5. 发送行为说明 |
| | | |
| | | 血压计示例报文: |
| | | ### 5.1 普通透析机指标 |
| | | |
| | | 透析机指标默认先进入聚合器缓存,在批量模式下按 `flushIntervalMs` 周期整包发送。 |
| | | |
| | | ### 5.2 血压报文 |
| | | |
| | | 血压报文示例: |
| | | |
| | | ```text |
| | | AA 55 0E BA 00 78 50 59 08 08 08 08 08 10 |
| | | ``` |
| | | |
| | | 解析含义: |
| | | 解析结果: |
| | | |
| | | - `00 78`:收缩压 `N` |
| | | - `50`:舒张压 `O` |
| | | - `59`:脉搏 `P` |
| | | - 后 5 字节:时间 `M` |
| | | - 后 5 个时间字节:时间 `M` |
| | | |
| | | 发布时间开启时: |
| | | 当 `publishTime=true` 时,血压指标示例: |
| | | |
| | | ```json |
| | | { |
| | |
| | | } |
| | | ``` |
| | | |
| | | 发布时间关闭时: |
| | | 当 `publishTime=false` 时,血压指标示例: |
| | | |
| | | ```json |
| | | { |
| | |
| | | } |
| | | ``` |
| | | |
| | | ## 6. Windows 部署 |
| | | 当 `flushImmediately=true` 时,行为如下: |
| | | |
| | | 1. 血压数据先写入缓存。 |
| | | 2. 立即触发一次当前设备的整包物模型发送。 |
| | | 3. 原有 1 分钟批量发送机制继续保留,不冲突。 |
| | | |
| | | 这样做的好处: |
| | | |
| | | - 血压结果更快到平台 |
| | | - 平台收到的仍然是完整物模型,不是单独的血压字段 |
| | | - 定时发送继续兜底,避免其他指标长时间不落地 |
| | | |
| | | ## 6. 模拟与联调 |
| | | |
| | | 项目内置 TCP 模拟器: |
| | | |
| | | ```powershell |
| | | npm run start:simulator -- --host 127.0.0.1 --port 9000 |
| | | ``` |
| | | |
| | | 说明: |
| | | |
| | | - 当前 `start:simulator` 默认会带透析机报文和血压报文混合发送 |
| | | - 如仅需发送血压,可执行 `npm run start:simulator:bp` |
| | | |
| | | 自定义血压参数示例: |
| | | |
| | | ```powershell |
| | | npm run start:simulator -- --bp-systolic 135 --bp-diastolic 88 --bp-pulse 76 |
| | | ``` |
| | | |
| | | ## 7. Windows 部署 |
| | | |
| | | 建议目录: |
| | | |
| | |
| | | .\jhm-service.exe --config .\runtime\config.json |
| | | ``` |
| | | |
| | | ## 7. Linux 部署 |
| | | 如需安装为服务,可使用: |
| | | |
| | | ```powershell |
| | | cd .\service |
| | | .\install-service.ps1 |
| | | ``` |
| | | |
| | | 卸载服务: |
| | | |
| | | ```powershell |
| | | cd .\service |
| | | .\uninstall-service.ps1 |
| | | ``` |
| | | |
| | | ## 8. Linux 部署 |
| | | |
| | | 建议目录: |
| | | |
| | |
| | | ./jhm-service --config ./runtime/config.json |
| | | ``` |
| | | |
| | | ## 8. 验证建议 |
| | | 如需安装为 systemd 服务,可参考: |
| | | |
| | | ```bash |
| | | cd ./service |
| | | chmod +x ./install-service.sh ./uninstall-service.sh |
| | | ./install-service.sh |
| | | ``` |
| | | |
| | | ## 9. 运行日志说明 |
| | | |
| | | 当前服务运行日志统一输出中文,默认写入: |
| | | |
| | | ```text |
| | | runtime/logs 或配置中的 logging.dir |
| | | ``` |
| | | |
| | | 重点关注以下日志: |
| | | |
| | | - TCP 监听成功 |
| | | - 设备连接和断开 |
| | | - 收到指标 |
| | | - 血压报文触发整包立即发送 |
| | | - MQTT 发布成功或失败 |
| | | - 阿里云属性上报成功或失败 |
| | | |
| | | ## 10. 验收建议 |
| | | |
| | | 部署完成后建议执行: |
| | | |
| | |
| | | npm run verify:commands |
| | | ``` |
| | | |
| | | 重点确认: |
| | | 现场重点确认: |
| | | |
| | | - TCP 端口监听正常 |
| | | - 设备连接日志正常 |
| | | - 原透析机数据解析正常 |
| | | - TCP 监听正常 |
| | | - 设备接入 IP 匹配正常 |
| | | - 透析机 `EE 55` 报文解析正常 |
| | | - 血压计 `AA 55` 报文解析正常 |
| | | - `M` 字段是否符合现场需求 |
| | | - 血压到达后是否按预期立即整包发送 |
| | | - 1 分钟批量发送是否仍正常执行 |
| | | - MQTT 或阿里云上报结果正常 |
| | | - 日志文件持续输出正常 |