| | |
| | | const normalized = this.normalizeEventPayload(errorLike); |
| | | |
| | | if (normalized instanceof Error) { |
| | | return normalized.message || normalized.name || 'unknown-error'; |
| | | return normalized.message || normalized.name || '未知错误'; |
| | | } |
| | | |
| | | if (Array.isArray(normalized)) { |
| | |
| | | .map((item) => this.formatErrorMessage(item)) |
| | | .filter(Boolean); |
| | | |
| | | return parts.length > 0 ? parts.join(' | ') : 'unknown-error'; |
| | | return parts.length > 0 ? parts.join(' | ') : '未知错误'; |
| | | } |
| | | |
| | | if (normalized && typeof normalized === 'object') { |
| | |
| | | } |
| | | |
| | | if (normalized === undefined || normalized === null || normalized === '') { |
| | | return 'unknown-error'; |
| | | return '未知错误'; |
| | | } |
| | | |
| | | return String(normalized); |
| | |
| | | return this.aliyunSdk; |
| | | } |
| | | |
| | | // eslint-disable-next-line global-require |
| | | // 延迟加载 SDK,避免未启用阿里云时引入额外依赖。 |
| | | this.aliyunSdk = __nccwpck_require__(9871); |
| | | return this.aliyunSdk; |
| | | } |
| | |
| | | try { |
| | | responseBody = JSON.parse(rawText); |
| | | } catch (_error) { |
| | | throw new Error('三元组接口返回不是有效 JSON'); |
| | | throw new Error('三元组接口返回的不是有效 JSON'); |
| | | } |
| | | |
| | | return this.extractTuple(responseBody, deviceId); |
| | |
| | | |
| | | if (context.lastRegisterAttempt > 0 && (now - context.lastRegisterAttempt) < retryMs && context.lastRegisterError) { |
| | | this.logger.warn(`[ALIYUN] 三元组请求冷却中 deviceId=${device.deviceId} retryMs=${retryMs} lastError=${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中 ${context.lastRegisterError}`); |
| | | throw new Error(`阿里云三元组重试冷却中: ${context.lastRegisterError}`); |
| | | } |
| | | |
| | | context.lastRegisterAttempt = now; |
| | |
| | | error: 40, |
| | | }; |
| | | |
| | | // 统一生成本地日志时间,兼顾控制台和文件日志格式。 |
| | | function formatLocalTimestamp(date = new Date()) { |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | |
| | | try { |
| | | fs.appendFileSync(logFilePath, `${line}\n`, 'utf8'); |
| | | } catch (error) { |
| | | console.error(`[LOGGER] Failed to append log file: ${error.message}`); |
| | | console.error(`[LOGGER] 写入日志文件失败: ${error.message}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | function printHelp() { |
| | | console.log(`JHM TCP Socket Gateway |
| | | console.log(`JHM TCP Socket 网关服务 |
| | | |
| | | Usage: |
| | | 用法: |
| | | node app.js |
| | | node app.js --config ./config.json |
| | | jhm-service.exe --config .\\runtime\\config.json |
| | | ./jhm-service --config ./runtime/config.json |
| | | |
| | | Options: |
| | | --config <path> set config file path |
| | | --help, -h show help |
| | | 参数: |
| | | --config <path> 指定配置文件路径 |
| | | --help, -h 显示帮助 |
| | | `); |
| | | } |
| | | |
| | | function loadConfig(configPath) { |
| | | if (!fs.existsSync(configPath)) { |
| | | throw new Error(`config file not found: ${configPath}`); |
| | | throw new Error(`未找到配置文件: ${configPath}`); |
| | | } |
| | | |
| | | const content = fs.readFileSync(configPath, 'utf8'); |
| | |
| | | |
| | | return { |
| | | publishTime: bloodPressure ? bloodPressure.publishTime !== false : true, |
| | | flushImmediately: bloodPressure ? bloodPressure.flushImmediately !== false : true, |
| | | }; |
| | | } |
| | | |
| | | function validateConfig(config) { |
| | | if (!config.tcp || !Array.isArray(config.devices)) { |
| | | throw new Error('config.json must include tcp and devices'); |
| | | throw new Error('config.json 必须包含 tcp 和 devices'); |
| | | } |
| | | |
| | | if (!config.tcp.host || !config.tcp.port) { |
| | | throw new Error('config.json must include tcp.host and tcp.port'); |
| | | throw new Error('config.json 必须包含 tcp.host 和 tcp.port'); |
| | | } |
| | | |
| | | const channels = getSendChannels(config); |
| | | |
| | | if (channels.length === 0) { |
| | | throw new Error('config.json send.channels must enable at least one channel'); |
| | | throw new Error('config.json 的 send.channels 至少要启用一个通道'); |
| | | } |
| | | |
| | | if (channels.includes('mqtt')) { |
| | | if (!config.mqtt) { |
| | | throw new Error('config.json enabled mqtt but missing mqtt config'); |
| | | throw new Error('config.json 已启用 mqtt,但缺少 mqtt 配置'); |
| | | } |
| | | |
| | | const hasBrokerUrl = Boolean(config.mqtt.brokerUrl); |
| | | const hasHostMode = Boolean(config.mqtt.protocol && config.mqtt.host && config.mqtt.port); |
| | | |
| | | if (!hasBrokerUrl && !hasHostMode) { |
| | | throw new Error('config.json mqtt requires brokerUrl or protocol/host/port'); |
| | | throw new Error('config.json 的 mqtt 必须配置 brokerUrl 或 protocol/host/port'); |
| | | } |
| | | |
| | | if (!config.mqtt.topicTemplate && !config.mqtt.defaultTopicPrefix) { |
| | | throw new Error('config.json mqtt requires topicTemplate or defaultTopicPrefix'); |
| | | throw new Error('config.json 的 mqtt 必须配置 topicTemplate 或 defaultTopicPrefix'); |
| | | } |
| | | } |
| | | |
| | | if (channels.includes('aliyun')) { |
| | | if (!config.aliyun) { |
| | | throw new Error('config.json enabled aliyun but missing aliyun config'); |
| | | throw new Error('config.json 已启用 aliyun,但缺少 aliyun 配置'); |
| | | } |
| | | |
| | | if (!config.aliyun.tupleApiBaseUrl && !config.aliyun.tupleApiUrl) { |
| | | throw new Error('config.json aliyun requires tupleApiBaseUrl or tupleApiUrl'); |
| | | throw new Error('config.json 的 aliyun 必须配置 tupleApiBaseUrl 或 tupleApiUrl'); |
| | | } |
| | | } |
| | | |
| | | if (!config.protocol || !config.protocol.alModelPath) { |
| | | throw new Error('config.json protocol.alModelPath is required'); |
| | | throw new Error('config.json 必须配置 protocol.alModelPath'); |
| | | } |
| | | |
| | | const sendOptions = getSendOptions(config); |
| | | |
| | | if (sendOptions.flushIntervalMs <= 0) { |
| | | throw new Error('config.json send.flushIntervalMs must be > 0'); |
| | | throw new Error('config.json 的 send.flushIntervalMs 必须大于 0'); |
| | | } |
| | | |
| | | if (config.logging && config.logging.level) { |
| | | const supportedLevels = ['debug', 'info', 'warn', 'error']; |
| | | |
| | | if (!supportedLevels.includes(String(config.logging.level).toLowerCase())) { |
| | | throw new Error(`config.json logging.level only supports ${supportedLevels.join(', ')}`); |
| | | throw new Error(`config.json 的 logging.level 仅支持 ${supportedLevels.join('、')}`); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | return path.join(path.dirname(configFilePath), targetPath); |
| | | } |
| | | |
| | | function createOnMetricHandler({ logger, aggregator, sendOptions, bloodPressureOptions }) { |
| | | return function onMetric(device, metric, result) { |
| | | logger.info(`[APP] 指标已缓存 deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | return aggregator.ingest(device, metric) |
| | | .then(() => { |
| | | if ( |
| | | sendOptions.mode === 'batch' |
| | | && bloodPressureOptions.flushImmediately |
| | | && result |
| | | && result.protocol === 'blood-pressure' |
| | | ) { |
| | | logger.info(`[APP] 血压报文触发整包立即发送 deviceId=${device.deviceId}`); |
| | | return aggregator.flush({ reason: 'blood-pressure', deviceId: device.deviceId }); |
| | | } |
| | | |
| | | return null; |
| | | }) |
| | | .catch((error) => { |
| | | logger.error(`[APP] 指标处理失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }; |
| | | } |
| | | |
| | | async function main() { |
| | |
| | | enabled: false, |
| | | console: true, |
| | | }); |
| | | bootstrapLogger.info(`[APP] Startup arguments parsed config=${options.configPath}`); |
| | | bootstrapLogger.info(`[APP] 启动参数解析完成 config=${options.configPath}`); |
| | | |
| | | const config = loadConfig(options.configPath); |
| | | const logDir = resolveConfigPath(options.configPath, (config.logging && config.logging.dir) || './logs'); |
| | |
| | | const alModelPath = resolveConfigPath(options.configPath, config.protocol.alModelPath); |
| | | |
| | | if (config.logging && config.logging.enabled === false) { |
| | | logger.info('[APP] File logging is disabled; console output only'); |
| | | logger.info('[APP] 文件日志已禁用,仅输出到控制台'); |
| | | } else { |
| | | logger.info(`[APP] Local logging enabled dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | logger.info(`[APP] 本地日志已启用 dir=${logDir} file=${logger.getLogFilePath()} level=${normalizeLogLevel(config.logging && config.logging.level)}`); |
| | | } |
| | | |
| | | logger.info(`[APP] Config loaded tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | logger.info(`[APP] 配置加载完成 tcp=${config.tcp.host}:${config.tcp.port} devices=${config.devices.length} channels=${sendChannels.join(',')} sendMode=${sendOptions.mode} flushIntervalMs=${sendOptions.flushIntervalMs} alModel=${alModelPath}`); |
| | | |
| | | const mqttService = sendChannels.includes('mqtt') ? new MqttService(config.mqtt, logger) : null; |
| | | const aliyunService = sendChannels.includes('aliyun') ? new AliyunService(config.aliyun, logger) : null; |
| | | |
| | | // 聚合器负责把多次单点指标合并成一次完整物模型上报。 |
| | | const aggregator = new MetricAggregator({ |
| | | logger, |
| | | ...sendOptions, |
| | | onFlush: async (device, payload, meta) => { |
| | | logger.info(`[APP] Batch dispatch deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | logger.info(`[APP] 开始批量分发 deviceId=${device.deviceId} channels=${sendChannels.join(',')} reason=${meta.reason} payload=${JSON.stringify(payload)}`); |
| | | |
| | | let ok = true; |
| | | |
| | |
| | | mqttService.publish(device, payload); |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] MQTT batch dispatch failed deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] MQTT 批量分发失败 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | if (result && result.skipped) { |
| | | ok = false; |
| | | logger.warn(`[APP] Aliyun batch dispatch skipped deviceId=${device.deviceId} reason=${result.reason}`); |
| | | logger.warn(`[APP] 阿里云批量分发已跳过 deviceId=${device.deviceId} reason=${result.reason}`); |
| | | } else if (result && result.ok === false) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch failed deviceId=${device.deviceId}: ${result.reason}`); |
| | | logger.error(`[APP] 阿里云批量分发失败 deviceId=${device.deviceId}: ${result.reason}`); |
| | | } |
| | | } catch (error) { |
| | | ok = false; |
| | | logger.error(`[APP] Aliyun batch dispatch exception deviceId=${device.deviceId}: ${error.message}`); |
| | | logger.error(`[APP] 阿里云批量分发异常 deviceId=${device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | alModelPath, |
| | | publishBloodPressureTime: bloodPressureOptions.publishTime, |
| | | logger, |
| | | onMetric(device, metric) { |
| | | logger.info(`[APP] Metric cached deviceId=${device.deviceId} mode=${sendOptions.mode} metric=${JSON.stringify(metric)}`); |
| | | aggregator.ingest(device, metric).catch((error) => { |
| | | logger.error(`[APP] Metric cache failed deviceId=${device.deviceId}: ${error.message}`); |
| | | }); |
| | | }, |
| | | onMetric: createOnMetricHandler({ |
| | | logger, |
| | | aggregator, |
| | | sendOptions, |
| | | bloodPressureOptions, |
| | | }), |
| | | }); |
| | | |
| | | if (mqttService) { |
| | | logger.info('[APP] Starting MQTT channel'); |
| | | logger.info('[APP] 正在启动 MQTT 通道'); |
| | | mqttService.start(); |
| | | } |
| | | |
| | | if (aliyunService) { |
| | | logger.info('[APP] Starting Aliyun channel'); |
| | | logger.info('[APP] 正在启动阿里云通道'); |
| | | aliyunService.start(); |
| | | } |
| | | |
| | | aggregator.start(); |
| | | await tcpService.start(); |
| | | logger.info('[APP] All services started'); |
| | | logger.info('[APP] 所有服务已启动'); |
| | | |
| | | let shuttingDown = false; |
| | | |
| | |
| | | } |
| | | |
| | | shuttingDown = true; |
| | | logger.warn(`[APP] Received ${signal}, shutting down`); |
| | | logger.warn(`[APP] 收到 ${signal},开始关闭服务`); |
| | | |
| | | await tcpService.stop(); |
| | | await aggregator.stop(); |
| | |
| | | await aliyunService.stop(); |
| | | } |
| | | |
| | | logger.info('[APP] Service shutdown completed'); |
| | | logger.info('[APP] 服务已完成关闭'); |
| | | await logger.close(); |
| | | process.exit(0); |
| | | } |
| | |
| | | }); |
| | | |
| | | process.on('uncaughtException', (error) => { |
| | | logger.error(`[APP] Uncaught exception: ${error.stack || error.message}`); |
| | | logger.error(`[APP] 未捕获异常: ${error.stack || error.message}`); |
| | | }); |
| | | |
| | | process.on('unhandledRejection', (reason) => { |
| | | logger.error(`[APP] Unhandled promise rejection: ${reason}`); |
| | | logger.error(`[APP] 未处理的 Promise 拒绝: ${reason}`); |
| | | }); |
| | | } |
| | | |
| | |
| | | getSendOptions, |
| | | loadConfig, |
| | | main, |
| | | createOnMetricHandler, |
| | | normalizeLogLevel, |
| | | parseArgs, |
| | | printHelp, |
| | |
| | | this.timer = null; |
| | | this.started = false; |
| | | this.flushing = false; |
| | | this.flushQueued = false; |
| | | this.queuedFlushOptions = null; |
| | | } |
| | | |
| | | start() { |
| | |
| | | |
| | | const entry = this.getOrCreateEntry(device); |
| | | entry.device = device; |
| | | const now = Date.now(); |
| | | |
| | | // 首次进入脏状态时记录时间,便于控制“缓存满一轮再发送”。 |
| | | if (!entry.dirty) { |
| | | entry.dirtySinceAt = now; |
| | | } |
| | | |
| | | entry.dirty = true; |
| | | entry.lastUpdateAt = Date.now(); |
| | | entry.lastUpdateAt = now; |
| | | |
| | | if (this.includeDeviceIdField) { |
| | | entry.payload[this.deviceIdField] = device.deviceId; |
| | |
| | | device, |
| | | payload: {}, |
| | | dirty: false, |
| | | dirtySinceAt: 0, |
| | | firstSeenAt: Date.now(), |
| | | lastUpdateAt: 0, |
| | | lastFlushAt: 0, |
| | |
| | | |
| | | this.timer = setTimeout(() => { |
| | | this.flush({ reason: 'timer' }).catch((error) => { |
| | | this.logger.error(`[APP] Aggregator flush failed: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器刷新失败: ${error.message}`); |
| | | }); |
| | | }, this.computeDelayMs()); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | mergeQueuedFlushOptions(options = {}) { |
| | | const nextOptions = { |
| | | reason: options.reason || 'manual', |
| | | deviceId: options.deviceId || '', |
| | | }; |
| | | |
| | | if (!this.queuedFlushOptions) { |
| | | this.queuedFlushOptions = nextOptions; |
| | | return; |
| | | } |
| | | |
| | | const currentOptions = this.queuedFlushOptions; |
| | | const currentIsTimer = currentOptions.reason === 'timer' || currentOptions.reason === 'queued'; |
| | | const nextIsTimer = nextOptions.reason === 'timer' || nextOptions.reason === 'queued'; |
| | | |
| | | if (currentIsTimer && !nextIsTimer) { |
| | | currentOptions.reason = nextOptions.reason; |
| | | } |
| | | |
| | | if (!currentOptions.deviceId || !nextOptions.deviceId || currentOptions.deviceId !== nextOptions.deviceId) { |
| | | currentOptions.deviceId = ''; |
| | | } |
| | | } |
| | | |
| | | async flush(options = {}) { |
| | | const reason = options.reason || 'manual'; |
| | | const deviceId = options.deviceId || ''; |
| | | |
| | | if (this.flushing) { |
| | | this.flushQueued = true; |
| | | this.mergeQueuedFlushOptions({ reason, deviceId }); |
| | | return false; |
| | | } |
| | | |
| | |
| | | return false; |
| | | } |
| | | |
| | | // 定时触发时要求数据至少缓存满一个周期,避免刚收到就被单独发出。 |
| | | if ((reason === 'timer' || reason === 'queued') && entry.dirtySinceAt > 0) { |
| | | const dirtyAgeMs = Date.now() - entry.dirtySinceAt; |
| | | |
| | | if (dirtyAgeMs < this.flushIntervalMs) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | }); |
| | | |
| | | for (const entry of entries) { |
| | | const flushUpdateAt = entry.lastUpdateAt; |
| | | const payload = { |
| | | ...entry.payload, |
| | | }; |
| | |
| | | continue; |
| | | } |
| | | |
| | | if (entry.lastUpdateAt === flushUpdateAt) { |
| | | entry.dirty = false; |
| | | entry.dirtySinceAt = 0; |
| | | } |
| | | |
| | | entry.lastFlushAt = Date.now(); |
| | | } catch (error) { |
| | | this.logger.error(`[APP] Aggregator device flush failed deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | this.logger.error(`[APP] 聚合器设备刷新失败 deviceId=${entry.device.deviceId}: ${error.message}`); |
| | | } |
| | | } |
| | | |
| | |
| | | this.scheduleNextFlush(); |
| | | } |
| | | |
| | | if (this.flushQueued) { |
| | | this.flushQueued = false; |
| | | await this.flush({ reason: 'queued' }); |
| | | if (this.queuedFlushOptions) { |
| | | const queuedOptions = this.queuedFlushOptions; |
| | | this.queuedFlushOptions = null; |
| | | await this.flush(queuedOptions); |
| | | } |
| | | } |
| | | } |
| | |
| | | const fs = __nccwpck_require__(9896); |
| | | const path = __nccwpck_require__(6928); |
| | | |
| | | // 按候选路径顺序寻找默认配置文件,兼容源码运行和打包运行两种场景。 |
| | | function fileExists(filePath) { |
| | | try { |
| | | return fs.statSync(filePath).isFile(); |
| | |
| | | |
| | | map.set(normalizeIp(device.ip), { |
| | | ...device, |
| | | name: device.name || device['备注'] || device.deviceId, |
| | | name: device.name || device.备注 || device['澶囨敞'] || device.deviceId, |
| | | }); |
| | | } |
| | | |
| | |
| | | this.server.maxConnections = this.tcpConfig.maxConnections || 100; |
| | | |
| | | this.server.on('error', (error) => { |
| | | this.logger.error(`[TCP] 服务错误: ${error.message}`); |
| | | this.logger.error(`[TCP] 服务异常: ${error.message}`); |
| | | }); |
| | | |
| | | await new Promise((resolve, reject) => { |
| | |
| | | }); |
| | | }); |
| | | |
| | | this.logger.info(`[TCP] 已监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | this.logger.info(`[TCP] 已开始监听 ${this.tcpConfig.host}:${this.tcpConfig.port} devices=${this.deviceMap.size} maxConnections=${this.server.maxConnections}`); |
| | | } |
| | | |
| | | handleConnection(socket) { |
| | |
| | | const results = session.decoder.push(chunk); |
| | | |
| | | if (results.length === 0) { |
| | | this.logger.warn(`[TCP] 当前数据片段未形成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | this.logger.warn(`[TCP] 当前数据片段尚未组成完整报文 deviceId=${session.device.deviceId} bytes=${chunk.length}`); |
| | | } |
| | | |
| | | for (const result of results) { |