gx
chenyc
5 天以前 747a86bb94006aaca721cfc0c0ce7061643a9ea6
gx
33个文件已添加
1个文件已删除
15个文件已修改
19837 ■■■■■ 已修改文件
JH2028对外接口通讯协议 - 20260511.pdf 补丁 | 查看 | 原始文档 | blame | 历史
aliyun-service.js 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app.js 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
build/ncc/index.js 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
build/ncc/logs/jhm-service-20260415.log 1553 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
build/ncc/logs/jhm-service-20260430.log 7717 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
build/pkg/bundle/index.cjs 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/.gitignore 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/README.md 288 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/alModel.json 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/aliyun-service.js 343 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/app.js 283 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/config.json 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/dashboard-service.js 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/dashboard/app.js 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/dashboard/index.html 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/dashboard/styles.css 361 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/decoder.js 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/logger.js 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/mqtt-service.js 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/package-lock.json 4250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/package.json 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/runtime-paths.js 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/scripts/build-executables.js 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/state-cache.js 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/tcp-service.js 227 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/tcp-simulator.js 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/app.test.js 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/dashboard-service.test.js 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/decoder.test.js 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/mqtt-service.test.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/state-cache.test.js 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/test/tcp-service.test.js 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/verify-protocol.js 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/实施部署文档.md 622 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/服务通讯功能流程图.md 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jh2028-new-service/配置说明.md 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
jhm2028-service.js 92 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
metric-aggregator.js 65 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
runtime-paths.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcp-service.js 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcp-simulator.js 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
test/app.test.js 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
test/metric-aggregator.test.js 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
test/tcp-simulator.test.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tttt.md 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
verify-all-commands.js 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
生产实施部署文档.md 280 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
JH2028对外接口通讯协议 - 20260511.pdf
Binary files differ
aliyun-service.js
@@ -63,7 +63,7 @@
    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)) {
@@ -71,7 +71,7 @@
        .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') {
@@ -87,7 +87,7 @@
    }
    if (normalized === undefined || normalized === null || normalized === '') {
      return 'unknown-error';
      return '未知错误';
    }
    return String(normalized);
@@ -139,7 +139,7 @@
      return this.aliyunSdk;
    }
    // eslint-disable-next-line global-require
    // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。
    this.aliyunSdk = require('aliyun-iot-device-sdk');
    return this.aliyunSdk;
  }
@@ -219,7 +219,7 @@
    try {
      responseBody = JSON.parse(rawText);
    } catch (_error) {
      throw new Error('三元组接口返回不是有效 JSON');
      throw new Error('三元组接口返回的不是有效 JSON');
    }
    return this.extractTuple(responseBody, deviceId);
@@ -241,7 +241,7 @@
    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;
app.js
@@ -15,6 +15,7 @@
  error: 40,
};
// 统一生成本地日志时间,兼顾控制台和文件日志格式。
function formatLocalTimestamp(date = new Date()) {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -101,7 +102,7 @@
      try {
        fs.appendFileSync(logFilePath, `${line}\n`, 'utf8');
      } catch (error) {
        console.error(`[LOGGER] Failed to append log file: ${error.message}`);
        console.error(`[LOGGER] 写入日志文件失败: ${error.message}`);
      }
    }
  }
@@ -150,23 +151,23 @@
}
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');
@@ -208,66 +209,67 @@
  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('、')}`);
    }
  }
}
@@ -278,6 +280,29 @@
  }
  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() {
@@ -292,7 +317,7 @@
    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');
@@ -306,21 +331,22 @@
  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;
@@ -329,7 +355,7 @@
          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}`);
        }
      }
@@ -339,14 +365,14 @@
          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}`);
        }
      }
@@ -360,27 +386,27 @@
    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;
@@ -390,7 +416,7 @@
    }
    shuttingDown = true;
    logger.warn(`[APP] Received ${signal}, shutting down`);
    logger.warn(`[APP] 收到 ${signal},开始关闭服务`);
    await tcpService.stop();
    await aggregator.stop();
@@ -403,7 +429,7 @@
      await aliyunService.stop();
    }
    logger.info('[APP] Service shutdown completed');
    logger.info('[APP] 服务已完成关闭');
    await logger.close();
    process.exit(0);
  }
@@ -417,11 +443,11 @@
  });
  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}`);
  });
}
@@ -440,6 +466,7 @@
  getSendOptions,
  loadConfig,
  main,
  createOnMetricHandler,
  normalizeLogLevel,
  parseArgs,
  printHelp,
build/ncc/index.js
@@ -67332,7 +67332,7 @@
    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)) {
@@ -67340,7 +67340,7 @@
        .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') {
@@ -67356,7 +67356,7 @@
    }
    if (normalized === undefined || normalized === null || normalized === '') {
      return 'unknown-error';
      return '未知错误';
    }
    return String(normalized);
@@ -67408,7 +67408,7 @@
      return this.aliyunSdk;
    }
    // eslint-disable-next-line global-require
    // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。
    this.aliyunSdk = __nccwpck_require__(9871);
    return this.aliyunSdk;
  }
@@ -67488,7 +67488,7 @@
    try {
      responseBody = JSON.parse(rawText);
    } catch (_error) {
      throw new Error('三元组接口返回不是有效 JSON');
      throw new Error('三元组接口返回的不是有效 JSON');
    }
    return this.extractTuple(responseBody, deviceId);
@@ -67510,7 +67510,7 @@
    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;
@@ -67774,6 +67774,7 @@
  error: 40,
};
// 统一生成本地日志时间,兼顾控制台和文件日志格式。
function formatLocalTimestamp(date = new Date()) {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -67860,7 +67861,7 @@
      try {
        fs.appendFileSync(logFilePath, `${line}\n`, 'utf8');
      } catch (error) {
        console.error(`[LOGGER] Failed to append log file: ${error.message}`);
        console.error(`[LOGGER] 写入日志文件失败: ${error.message}`);
      }
    }
  }
@@ -67909,23 +67910,23 @@
}
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');
@@ -67967,66 +67968,67 @@
  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('、')}`);
    }
  }
}
@@ -68037,6 +68039,29 @@
  }
  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() {
@@ -68051,7 +68076,7 @@
    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');
@@ -68065,21 +68090,22 @@
  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;
@@ -68088,7 +68114,7 @@
          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}`);
        }
      }
@@ -68098,14 +68124,14 @@
          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}`);
        }
      }
@@ -68119,27 +68145,27 @@
    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;
@@ -68149,7 +68175,7 @@
    }
    shuttingDown = true;
    logger.warn(`[APP] Received ${signal}, shutting down`);
    logger.warn(`[APP] 收到 ${signal},开始关闭服务`);
    await tcpService.stop();
    await aggregator.stop();
@@ -68162,7 +68188,7 @@
      await aliyunService.stop();
    }
    logger.info('[APP] Service shutdown completed');
    logger.info('[APP] 服务已完成关闭');
    await logger.close();
    process.exit(0);
  }
@@ -68176,11 +68202,11 @@
  });
  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}`);
  });
}
@@ -68199,6 +68225,7 @@
  getSendOptions,
  loadConfig,
  main,
  createOnMetricHandler,
  normalizeLogLevel,
  parseArgs,
  printHelp,
@@ -68605,7 +68632,7 @@
    this.timer = null;
    this.started = false;
    this.flushing = false;
    this.flushQueued = false;
    this.queuedFlushOptions = null;
  }
  start() {
@@ -68640,8 +68667,15 @@
    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;
@@ -68662,6 +68696,7 @@
        device,
        payload: {},
        dirty: false,
        dirtySinceAt: 0,
        firstSeenAt: Date.now(),
        lastUpdateAt: 0,
        lastFlushAt: 0,
@@ -68691,7 +68726,7 @@
    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());
@@ -68700,12 +68735,36 @@
    }
  }
  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;
    }
@@ -68726,10 +68785,20 @@
          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,
        };
@@ -68750,10 +68819,14 @@
            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}`);
        }
      }
@@ -68765,9 +68838,10 @@
        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);
      }
    }
  }
@@ -71058,6 +71132,7 @@
const fs = __nccwpck_require__(9896);
const path = __nccwpck_require__(6928);
// 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。
function fileExists(filePath) {
  try {
    return fs.statSync(filePath).isFile();
@@ -71154,7 +71229,7 @@
      map.set(normalizeIp(device.ip), {
        ...device,
        name: device.name || device['备注'] || device.deviceId,
        name: device.name || device.备注 || device['澶囨敞'] || device.deviceId,
      });
    }
@@ -71173,7 +71248,7 @@
    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) => {
@@ -71186,7 +71261,7 @@
      });
    });
    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) {
@@ -71250,7 +71325,7 @@
    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) {
build/ncc/logs/jhm-service-20260415.log
File was deleted
build/ncc/logs/jhm-service-20260430.log
New file
Diff too large
build/pkg/bundle/index.cjs
@@ -67332,7 +67332,7 @@
    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)) {
@@ -67340,7 +67340,7 @@
        .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') {
@@ -67356,7 +67356,7 @@
    }
    if (normalized === undefined || normalized === null || normalized === '') {
      return 'unknown-error';
      return '未知错误';
    }
    return String(normalized);
@@ -67408,7 +67408,7 @@
      return this.aliyunSdk;
    }
    // eslint-disable-next-line global-require
    // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。
    this.aliyunSdk = __nccwpck_require__(9871);
    return this.aliyunSdk;
  }
@@ -67488,7 +67488,7 @@
    try {
      responseBody = JSON.parse(rawText);
    } catch (_error) {
      throw new Error('三元组接口返回不是有效 JSON');
      throw new Error('三元组接口返回的不是有效 JSON');
    }
    return this.extractTuple(responseBody, deviceId);
@@ -67510,7 +67510,7 @@
    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;
@@ -67774,6 +67774,7 @@
  error: 40,
};
// 统一生成本地日志时间,兼顾控制台和文件日志格式。
function formatLocalTimestamp(date = new Date()) {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -67860,7 +67861,7 @@
      try {
        fs.appendFileSync(logFilePath, `${line}\n`, 'utf8');
      } catch (error) {
        console.error(`[LOGGER] Failed to append log file: ${error.message}`);
        console.error(`[LOGGER] 写入日志文件失败: ${error.message}`);
      }
    }
  }
@@ -67909,23 +67910,23 @@
}
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');
@@ -67967,66 +67968,67 @@
  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('、')}`);
    }
  }
}
@@ -68037,6 +68039,29 @@
  }
  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() {
@@ -68051,7 +68076,7 @@
    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');
@@ -68065,21 +68090,22 @@
  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;
@@ -68088,7 +68114,7 @@
          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}`);
        }
      }
@@ -68098,14 +68124,14 @@
          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}`);
        }
      }
@@ -68119,27 +68145,27 @@
    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;
@@ -68149,7 +68175,7 @@
    }
    shuttingDown = true;
    logger.warn(`[APP] Received ${signal}, shutting down`);
    logger.warn(`[APP] 收到 ${signal},开始关闭服务`);
    await tcpService.stop();
    await aggregator.stop();
@@ -68162,7 +68188,7 @@
      await aliyunService.stop();
    }
    logger.info('[APP] Service shutdown completed');
    logger.info('[APP] 服务已完成关闭');
    await logger.close();
    process.exit(0);
  }
@@ -68176,11 +68202,11 @@
  });
  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}`);
  });
}
@@ -68199,6 +68225,7 @@
  getSendOptions,
  loadConfig,
  main,
  createOnMetricHandler,
  normalizeLogLevel,
  parseArgs,
  printHelp,
@@ -68605,7 +68632,7 @@
    this.timer = null;
    this.started = false;
    this.flushing = false;
    this.flushQueued = false;
    this.queuedFlushOptions = null;
  }
  start() {
@@ -68640,8 +68667,15 @@
    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;
@@ -68662,6 +68696,7 @@
        device,
        payload: {},
        dirty: false,
        dirtySinceAt: 0,
        firstSeenAt: Date.now(),
        lastUpdateAt: 0,
        lastFlushAt: 0,
@@ -68691,7 +68726,7 @@
    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());
@@ -68700,12 +68735,36 @@
    }
  }
  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;
    }
@@ -68726,10 +68785,20 @@
          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,
        };
@@ -68750,10 +68819,14 @@
            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}`);
        }
      }
@@ -68765,9 +68838,10 @@
        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);
      }
    }
  }
@@ -71058,6 +71132,7 @@
const fs = __nccwpck_require__(9896);
const path = __nccwpck_require__(6928);
// 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。
function fileExists(filePath) {
  try {
    return fs.statSync(filePath).isFile();
@@ -71154,7 +71229,7 @@
      map.set(normalizeIp(device.ip), {
        ...device,
        name: device.name || device['备注'] || device.deviceId,
        name: device.name || device.备注 || device['澶囨敞'] || device.deviceId,
      });
    }
@@ -71173,7 +71248,7 @@
    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) => {
@@ -71186,7 +71261,7 @@
      });
    });
    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) {
@@ -71250,7 +71325,7 @@
    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) {
jh2028-new-service/.gitignore
New file
@@ -0,0 +1,5 @@
node_modules/
build/
dist/
logs/
*.log
jh2028-new-service/README.md
New file
@@ -0,0 +1,288 @@
# 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、小端、有符号数和单位换算全部一致,再部署到现场。
jh2028-new-service/alModel.json
New file
@@ -0,0 +1,30 @@
{
  "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" }
  ]
}
jh2028-new-service/aliyun-service.js
New file
@@ -0,0 +1,343 @@
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,
};
jh2028-new-service/app.js
New file
@@ -0,0 +1,283 @@
#!/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,
};
jh2028-new-service/config.json
New file
@@ -0,0 +1,68 @@
{
  "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"
    }
  ]
}
jh2028-new-service/dashboard-service.js
New file
@@ -0,0 +1,228 @@
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,
};
jh2028-new-service/dashboard/app.js
New file
@@ -0,0 +1,264 @@
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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}
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();
}
jh2028-new-service/dashboard/index.html
New file
@@ -0,0 +1,64 @@
<!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>
jh2028-new-service/dashboard/styles.css
New file
@@ -0,0 +1,361 @@
: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;
  }
}
jh2028-new-service/decoder.js
New file
@@ -0,0 +1,347 @@
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,
};
jh2028-new-service/logger.js
New file
@@ -0,0 +1,119 @@
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,
};
jh2028-new-service/mqtt-service.js
New file
@@ -0,0 +1,137 @@
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,
};
jh2028-new-service/package-lock.json
New file
Diff too large
jh2028-new-service/package.json
New file
@@ -0,0 +1,34 @@
{
  "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"
    ]
  }
}
jh2028-new-service/runtime-paths.js
New file
@@ -0,0 +1,42 @@
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,
};
jh2028-new-service/scripts/build-executables.js
New file
@@ -0,0 +1,202 @@
#!/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();
jh2028-new-service/state-cache.js
New file
@@ -0,0 +1,101 @@
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,
};
jh2028-new-service/tcp-service.js
New file
@@ -0,0 +1,227 @@
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,
};
jh2028-new-service/tcp-simulator.js
New file
@@ -0,0 +1,296 @@
#!/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,
};
jh2028-new-service/test/app.test.js
New file
@@ -0,0 +1,74 @@
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);
  });
});
jh2028-new-service/test/dashboard-service.test.js
New file
@@ -0,0 +1,46 @@
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');
  });
});
jh2028-new-service/test/decoder.test.js
New file
@@ -0,0 +1,103 @@
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);
  });
});
jh2028-new-service/test/mqtt-service.test.js
New file
@@ -0,0 +1,16 @@
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');
  });
});
jh2028-new-service/test/state-cache.test.js
New file
@@ -0,0 +1,53 @@
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,
    });
  });
});
jh2028-new-service/test/tcp-service.test.js
New file
@@ -0,0 +1,49 @@
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}$/);
  });
});
jh2028-new-service/verify-protocol.js
New file
@@ -0,0 +1,52 @@
#!/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();
jh2028-new-service/实施部署文档.md
New file
@@ -0,0 +1,622 @@
# 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 或阿里云上报验证正常 |  |
| 已备份最终配置文件 |  |
jh2028-new-service/服务通讯功能流程图.md
New file
@@ -0,0 +1,191 @@
# 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 接收和上报链路。
jh2028-new-service/配置说明.md
New file
@@ -0,0 +1,303 @@
# 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
```
其余字段建议保持默认,除非现场网络或平台规则有明确变化。
jhm2028-service.js
@@ -1,19 +1,19 @@
#!/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"
 */
@@ -80,25 +80,25 @@
}
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
`);
}
@@ -132,7 +132,7 @@
        raw,
        value: raw / 10,
        unit: '°C',
        note: 'PDF example shows 0x00000172 => 370 => 37.0°C',
        note: '协议文档示例:0x00000172 => 370 => 37.0°C',
      };
    }
@@ -141,7 +141,7 @@
      return {
        name: 'value_0x03',
        raw,
        note: 'PDF indicates XX1<<24 + XX2<<16 + XX3<<8 + XX4',
        note: '协议文档说明:数值为 XX1<<24 + XX2<<16 + XX3<<8 + XX4',
      };
    }
@@ -152,7 +152,7 @@
        name: 'dual_uint16_0x04',
        value1,
        value2,
        note: 'PDF indicates first two bytes and last two bytes are separate values',
        note: '协议文档说明:前两字节和后两字节分别表示独立数值',
      };
    }
@@ -172,7 +172,7 @@
        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 组成数值',
      };
    }
@@ -193,7 +193,7 @@
        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',
      };
    }
@@ -202,7 +202,7 @@
        name: `command_${toHex(command)}`,
        raw: uint32BE(payload),
        payload: Array.from(payload),
        note: 'Meaning not fully legible in source PDF; raw data preserved',
        note: '原始协议文档语义不够清晰,先保留原始数据',
      };
  }
}
@@ -238,7 +238,7 @@
  if (valid) {
    parsed.decoded = decodePayload(command, payload);
  } else {
    parsed.error = 'Checksum mismatch';
    parsed.error = '校验和不匹配';
  }
  return parsed;
@@ -249,12 +249,12 @@
  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);
@@ -317,23 +317,23 @@
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 必须是正整数');
  }
}
@@ -342,17 +342,17 @@
}
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,
  });
@@ -360,7 +360,7 @@
  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} 的整数倍`);
  }
}
@@ -370,10 +370,10 @@
    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;
    }
@@ -395,7 +395,7 @@
  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,
  });
@@ -408,11 +408,11 @@
  });
  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', () => {
@@ -425,7 +425,7 @@
  port.open((error) => {
    if (error) {
      console.error('Failed to open serial port:', error.message);
      console.error('打开串口失败:', error.message);
      process.exit(1);
    }
  });
@@ -444,7 +444,7 @@
    runSerialService(options);
  } catch (error) {
    console.error(error.message);
    console.error('Use --help to see available options.');
    console.error('可使用 --help 查看可用参数。');
    process.exit(1);
  }
}
metric-aggregator.js
@@ -12,7 +12,7 @@
    this.timer = null;
    this.started = false;
    this.flushing = false;
    this.flushQueued = false;
    this.queuedFlushOptions = null;
  }
  start() {
@@ -47,8 +47,15 @@
    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;
@@ -69,6 +76,7 @@
        device,
        payload: {},
        dirty: false,
        dirtySinceAt: 0,
        firstSeenAt: Date.now(),
        lastUpdateAt: 0,
        lastFlushAt: 0,
@@ -98,7 +106,7 @@
    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());
@@ -107,12 +115,36 @@
    }
  }
  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;
    }
@@ -133,10 +165,20 @@
          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,
        };
@@ -157,10 +199,14 @@
            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}`);
        }
      }
@@ -172,9 +218,10 @@
        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);
      }
    }
  }
package.json
@@ -9,7 +9,7 @@
  "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",
runtime-paths.js
@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
// 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。
function fileExists(filePath) {
  try {
    return fs.statSync(filePath).isFile();
tcp-service.js
@@ -47,7 +47,7 @@
      map.set(normalizeIp(device.ip), {
        ...device,
        name: device.name || device['备注'] || device.deviceId,
        name: device.name || device.备注 || device['澶囨敞'] || device.deviceId,
      });
    }
@@ -66,7 +66,7 @@
    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) => {
@@ -79,7 +79,7 @@
      });
    });
    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) {
@@ -143,7 +143,7 @@
    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) {
tcp-simulator.js
@@ -21,6 +21,7 @@
  },
};
// 模拟器支持三种方式:文件回放、单独血压、文件加血压混发。
function parseArgs(argv) {
  const options = {
    host: '127.0.0.1',
@@ -31,6 +32,7 @@
    repeat: 0,
    localAddress: '',
    mode: 'file',
    includeBloodPressure: false,
    bpSystolic: 120,
    bpDiastolic: 80,
    bpPulse: 89,
@@ -66,6 +68,8 @@
    } 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) {
@@ -91,30 +95,33 @@
}
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              显示帮助
`);
}
@@ -126,7 +133,7 @@
  const scenarioConfig = BUILTIN_SCENARIOS[options.scenario];
  if (!scenarioConfig) {
    throw new Error(`unknown scenario: ${options.scenario}`);
    throw new Error(`未知场景: ${options.scenario}`);
  }
  return {
@@ -137,45 +144,49 @@
  };
}
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);
  }
}
@@ -193,7 +204,7 @@
  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);
@@ -264,7 +275,7 @@
    };
  }
  throw new Error('no sendable hex frames found in frames file');
  throw new Error('报文文件中没有找到可发送的十六进制报文');
}
function analyzeFrames(frames, scenarioConfig) {
@@ -329,7 +340,7 @@
  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;
@@ -393,10 +404,13 @@
  }
  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,
@@ -444,7 +458,7 @@
  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;
@@ -453,43 +467,48 @@
      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();
@@ -497,23 +516,23 @@
  });
  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);
  });
}
test/app.test.js
@@ -1,7 +1,87 @@
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']);
  });
});
test/metric-aggregator.test.js
@@ -78,4 +78,85 @@
      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,
    });
  });
});
test/tcp-simulator.test.js
@@ -1,5 +1,12 @@
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', () => {
@@ -23,4 +30,35 @@
      '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 });
    }
  });
});
tttt.md
New file
@@ -0,0 +1,24 @@
设定温度         AF
当前透析液温度   F
设定超滤总量     A
超滤率             C
超滤量     B
剩余时间     K
透析液流量  L
有效血流量  D
静脉压   H
动脉压   o
跨膜压   J
累计血流量  U
电导率  G
钠含量 Na
碳酸氢根含量 HCO3
血氧饱和度    O2Sat
红细胞比容     Hct
血红蛋白     Hb
血液温度   Tblood
Kt/V      ktv
收缩压(高压),
舒张压(低压),
心率值,单位:
verify-all-commands.js
@@ -12,7 +12,7 @@
  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;
@@ -43,21 +43,21 @@
}
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 = {
@@ -78,8 +78,8 @@
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) {
@@ -90,10 +90,10 @@
    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}`);
    }
  }
@@ -101,10 +101,10 @@
  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();
生产实施部署文档.md
@@ -2,61 +2,106 @@
## 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": {
@@ -96,13 +141,14 @@
  "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号透析机"
    }
  ]
@@ -111,41 +157,51 @@
### 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}
@@ -157,27 +213,51 @@
- `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
    }
  }
}
```
@@ -190,27 +270,34 @@
- `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
{
@@ -221,7 +308,7 @@
}
```
发布时间关闭时:
当 `publishTime=false` 时,血压指标示例:
```json
{
@@ -231,7 +318,38 @@
}
```
## 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 部署
建议目录:
@@ -246,7 +364,21 @@
.\jhm-service.exe --config .\runtime\config.json
```
## 7. Linux 部署
如需安装为服务,可使用:
```powershell
cd .\service
.\install-service.ps1
```
卸载服务:
```powershell
cd .\service
.\uninstall-service.ps1
```
## 8. Linux 部署
建议目录:
@@ -262,7 +394,32 @@
./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. 验收建议
部署完成后建议执行:
@@ -271,10 +428,13 @@
npm run verify:commands
```
重点确认:
现场重点确认:
- TCP 端口监听正常
- 设备连接日志正常
- 原透析机数据解析正常
- TCP 监听正常
- 设备接入 IP 匹配正常
- 透析机 `EE 55` 报文解析正常
- 血压计 `AA 55` 报文解析正常
- `M` 字段是否符合现场需求
- 血压到达后是否按预期立即整包发送
- 1 分钟批量发送是否仍正常执行
- MQTT 或阿里云上报结果正常
- 日志文件持续输出正常