/** * 网关采集服务(应用级常驻,单 enabled 设备) * * 职责:对当前唯一 enabled 的设备,自动连接 BLE 并持续接收 notify 帧, * 按「测点链路」上报:topic = /{productId}/{sn}/{measurePointId}/measure/post * - MQTT 连接由「我的」页独立建立(产品凭证),本服务只复用其连接状态 * - 采集模式:1=手动确认(数据需点击确认才上报)、2=自动上传(默认) * - MQTT 不可用时帧进离线缓存(带测点ID),恢复后自动补传 * * 页面通过 gateway.subscribe(cb) + gateway.getSnapshot() 订阅实时状态与数据, * 不再由页面各自持有 BLE/MQTT 连接。 * * 并发模型:所有启停/切换操作经 _chain() 串行排队,避免“旧 boot 未结束即切新设备” * 造成漏启/双连。数据通路(收帧→上屏/上报)独立于该队列。 */ const { bleService, BLE_STATE } = require('./ble.js'); const { mqttClient, MQTT_STATE } = require('./mqtt-client.js'); const auth = require('../utils/auth.js'); const storage = require('../utils/storage.js'); const measurePoint = require('../utils/measure-point.js'); const { TIMEOUT, LIMITS, RETRY } = require('../utils/constants.js'); // 网关阶段 const GATEWAY_PHASE = { IDLE: 'idle', // 无目标设备 STARTING: 'starting', // 正在连接(BLE/MQTT 未就绪) RUNNING: 'running', // BLE 已就绪,正在采集(MQTT 可选) ERROR: 'error', // 启动失败,等待重试 STOPPED: 'stopped', // 已停止 }; const GATEWAY_PHASE_TEXT = { idle: '未启用', starting: '连接中', running: '采集中', error: '异常', stopped: '已停止', }; const DEFAULTS = { FLUSH_MS: TIMEOUT.FLUSH_INTERVAL, // 收帧上屏/上报节流 MAX_LIST: LIMITS.MAX_DATA_LIST, // 屏幕保留条数 INIT_RETRY_MS: RETRY.INIT_RETRY_INTERVAL,// 启动失败自动重试间隔 STALL_MS: 30000, // BLE 静默阈值(超过则回前台时探活) RESUME_PROBE_MS: 5000, // 后台超过该时长 → 回前台必须探活 }; class Gateway { constructor() { this._phase = GATEWAY_PHASE.IDLE; this._device = null; // 正在采集的设备对象 this._bleReady = false; // 初始取真实状态:MQTT 可能在本单例构造前就已连上(事件回调是一次性的) this._mqttReady = mqttClient.isConnected(); this._mqttConnecting = false; // 自动连接去重闸门 // 数据与统计 this._pending = []; // 待上屏/上报缓冲 this._dataList = []; // 上屏列表(最新在前) this._stats = { received: 0, uploaded: 0, failed: 0 }; this._offlineCount = 0; // 诊断/生命周期 this._lastDataAt = 0; this._hiddenAt = 0; this._lastError = null; // 定时器与串行队列 this._flushTimer = null; this._initRetryTimer = null; this._flushingOffline = false; this._chainTail = Promise.resolve(); // 页面订阅 this._subs = new Set(); // 唯一数据来源:网关持有 BLE/MQTT 回调 bleService.onData((d) => this._onBleData(d)); bleService.onStateChange((s) => this._onBleState(s)); mqttClient.onStateChange((s) => this._onMqttState(s)); } /* ===================== 对外主流程(经串行队列) ===================== */ /** * 确保采集器运行:以“唯一 enabled 设备”为目标。 */ ensureStarted() { this._syncTarget(); return this._chain(() => this._ensureStartedNow()); } /** * 确保 MQTT 已连接(自动连接的唯一出口,幂等) * * 「我的」页录入的产品凭证是持久化的,采集器又要常驻 —— 因此冷启动、回前台、 * 网络恢复时都应自动把 MQTT 拉起来,而不是等用户手点「连接」。 * 唯一例外是用户主动断开(mqttClient.userDisconnected):那是明确意图,不覆盖。 * @returns {Promise} 是否已连接 */ ensureMqtt() { return this._chain(() => this._ensureMqttNow()); } /** * 启动采集(对目标设备)。不同设备会自动先拆旧会话并重置展示数据。 * @param {Object} device - 设备配置(serialNumber/productId/productSecret/deviceId/serviceUUID/enabled...) */ start(device) { this._syncTarget(); return this._chain(() => this._startNow(device || this._device)); } /** * 停止采集(保留已上屏数据与统计,便于回看;拆设备时才 _resetFeed)。 */ stop() { this._syncTarget(); return this._chain(() => this._stopNow()); } /** 切后台(onHide):记录时间,不 teardown */ suspend() { this._hiddenAt = Date.now(); this._clearInitRetry(); } /** 回前台(onShow):补起连接、探测静默并重连、补传离线 */ async resume() { const bgMs = this._hiddenAt ? Date.now() - this._hiddenAt : 0; this._hiddenAt = 0; this._flush(); if (!this._hasUser()) return; // MQTT 与 BLE 平权自愈:冷启动/回前台/网络恢复都补一次自动连接, // 否则"自动上传"会在用户没进「我的」页时静默退化成离线堆积。 // MQTT 是应用级连接,设备开关不影响它 this._ensureMqttNow(); const enabled = storage.getEnabledDevice(); // 无目标 → 若在跑则停;目标变化/未就绪 → 交给队列处理 if (!enabled || !this._device || this._device.serialNumber !== enabled.serialNumber || !this._bleReady) { return this.ensureStarted(); } // 回前台(后台超阈值)或长时间静默 → 必须真正探活一次,再决定是否重连 const silentMs = Date.now() - (this._lastDataAt || Date.now()); if (bgMs > DEFAULTS.RESUME_PROBE_MS || silentMs > DEFAULTS.STALL_MS) { console.log('[GW] 后台时长:', bgMs, 'ms, 静默时长:', silentMs, 'ms'); if (!bleService.isReady()) { // 状态已不是就绪:直接重连(重连中/失败由 ble 内部按退避自愈) console.warn('[GW] BLE 未就绪,强制重连'); return this._reconnectBle(); } // 静默计时器只说明"这段时间没收到包",它区分不了"设备没在测量(静默正常)" // 与"链路已被系统收走";回前台尤其危险 —— native 常在切后台时静默断链且 // 不派发状态回调,只看静默时长就会漏判(状态显示"已连接"却再也收不到数据)。 // 唯一可靠的判据是 GATT 读一次:探活失败才拆链重建,通过则原样复用 // (探活是单次读,不做周期轮询,不会把健康链路反复中断)。 const alive = await bleService.checkLinkAlive(); if (!this._device) return; if (alive) { console.log('[GW] BLE 探活通过,复用现有链路'); } else { console.warn('[GW] BLE 链路已死,强制重连'); return this._reconnectBle(); } } // MQTT 若断开:底层 lib 会按 reconnectPeriod 自连,重连成功后 // 经 _onMqttState(CONNECTED) 自动补传离线,此处不重复 connect 以免建重复连接。 if (this._mqttReady) this._flushOffline(); } /** * BLE 重连(失败不上屏为错误) * ble 内部已按指数退避自动重排重连,此处若把瞬时失败写成 lastError, * 链路恢复后详情页的 {{lastError}} 会一直残留("已连上却还显示报错") * @returns {Promise} */ _reconnectBle() { return bleService.forceReconnect().catch((err) => { console.error('[GW] BLE 重连失败(将自动重试):', err && err.message); }); } /** 清空展示数据与统计(不触碰离线缓存) */ clearHistory() { this._pending = []; this._dataList = []; this._stats = { received: 0, uploaded: 0, failed: 0 }; this._lastDataAt = 0; this._notify(); } /** 手动补传离线缓存(当前设备) */ flushOffline() { return this._flushOffline(); } /** * 手动重连 BLE(详情页 BLE 状态点击触发) * * 与"关开关再开"的区别:走复位蓝牙适配器(close → open)彻底清掉 native 残留连接, * 这是"连续连接超时后自动重连卡死"场景的确定性出口; * 关开关只是 stop + start,native 半开连接仍在,会一直 already connect / 超时。 */ retryBle() { return this._chain(async () => { if (!this._hasUser()) { return { ok: false, message: '未登录' }; } const device = this._device; if (!device) { return { ok: false, message: '设备未启用采集,请先在“我的设备”页开启' }; } // 详情页的 canRetryBle 只看"是否当前采集设备",设备被停用而拆链尚未收尾时 // 仍可能点到这里 —— 此处按唯一真相源再挡一次,避免给已关闭的设备重连 if (!this._isEnabled(device.serialNumber)) { return { ok: false, message: '设备已停用,请先在“我的设备”页开启' }; } this._clearInitRetry(); this._lastError = null; this._bleReady = false; this._setPhase(GATEWAY_PHASE.STARTING); this._notify(); try { await bleService.hardReconnect(device.deviceId, device.serviceUUID || null); this._bleReady = true; this._lastError = null; this._setPhase(GATEWAY_PHASE.RUNNING); console.log('[GW] 手动重连成功:', device.serialNumber); this._notify(); return { ok: true, message: '已重新连接' }; } catch (err) { console.error('[GW] 手动重连失败:', err); const message = (err && err.message) || '未知错误'; this._lastError = '重连失败: ' + message; this._setPhase(GATEWAY_PHASE.ERROR); this._scheduleInitRetry(); this._notify(); return { ok: false, message }; } }); } /** * 手动重连 MQTT(详情页 MQTT 状态点击触发) * * MQTT 是账号/产品级连接(凭证在「我的」页配置,clientId = S&{sn}&{productId}&{userId}), * 与"当前采集设备"无关,因此不要求 _device 存在; * 也不需要重新录入凭证,用已保存的配置重建即可。 * * 失败只经返回值/toast 呈现,不写 _lastError:lastError 的清理时机绑在 BLE 状态上, * MQTT 失败写进去会出现"BLE 一恢复就把 MQTT 报错擦掉"的不可预期呈现。 */ retryMqtt() { return this._chain(async () => { if (!this._hasUser()) { return { ok: false, message: '未登录' }; } const cfg = storage.getMqttConfig(); if (!cfg || !cfg.serialNumber || cfg.productId == null || cfg.productId === '' || !cfg.productSecret) { return { ok: false, message: '未配置 MQTT 凭证,请到「我的」页配置' }; } const creds = auth.getMqttCredentials(cfg.serialNumber, cfg.productId, cfg.productSecret); if (!creds) { return { ok: false, message: '生成 MQTT 凭证失败,请重新登录' }; } try { // 硬重连:关旧客户端 → 按凭证重建;成功后 mqttClient 会回调 _onMqttState(CONNECTED), // 由它统一置 _mqttReady 并触发离线补传(此处不重复写状态,避免两个真相源) await mqttClient.reconnect(creds); console.log('[GW] 手动重连 MQTT 成功:', creds.clientId); return { ok: true, message: '已重新连接' }; } catch (err) { console.error('[GW] 手动重连 MQTT 失败:', err); return { ok: false, message: (err && err.message) || '未知错误' }; } }); } /* ===================== 页面订阅 ===================== */ /** * 订阅网关快照 * @param {Function} cb - (snapshot) => void * @returns {Function} 退订函数 */ subscribe(cb) { if (typeof cb !== 'function') return () => {}; this._subs.add(cb); return () => this._subs.delete(cb); } /** * 当前快照 * @returns {Object} */ getSnapshot() { return { phase: this._phase, phaseText: GATEWAY_PHASE_TEXT[this._phase] || this._phase, device: this._device ? { ...this._device } : null, bleConnected: this._bleReady, mqttConnected: this._mqttReady, mqttState: mqttClient.getState(), // 区分"用户主动断开"与"被动掉线":前者不该显示"点击重连"(点不点由用户决定), // 后者才是需要一键恢复的异常态 mqttUserDisconnected: mqttClient.userDisconnected === true, bleState: bleService.getState(), // 浅拷贝元素,确保 setData diff 能识别 uploaded 等字段变化并触发视图刷新 dataList: this._dataList.map(it => ({ ...it })), stats: { ...this._stats }, offlineCount: this._offlineCount, lastError: this._lastError, // 测点链路上下文 measurePointId: measurePoint.getMeasurePointId(), collectMode: measurePoint.getCollectMode(), }; } /** 当前采集设备(浅拷贝) */ getCurrentDevice() { return this._device ? { ...this._device } : null; } /* ===================== 内部:串行队列 ===================== */ _chain(task) { const result = this._chainTail.then(() => task()); // 队列尾不因某次操作失败而中断 this._chainTail = result.catch(() => {}); return result; } async _ensureStartedNow() { if (!this._hasUser()) return; // MQTT 是**应用级**连接(凭证在「我的」页、clientId 按产品/账号), // 与"设备开关"无关:这里照旧无条件幂等补连,不因无启用设备而跳过 this._ensureMqttNow(); const enabled = storage.getEnabledDevice(); // 无启用设备 = 采集关闭:只收敛 BLE,不碰 MQTT if (!enabled) { await this._stopNow(); return; } await this._startNow(enabled); } /** * 自动连接 MQTT 的内部实现 * * 三重短路,保证幂等且不做无谓建连: * 1. 未登录 / 用户主动断开 → 不连 * 2. 已持有客户端(已连接或正在自愈)→ 不连(canAutoConnect) * 3. 无已保存凭证 → 不连(并保持静默,配置入口在「我的」页) * @returns {Promise} 是否已连接 */ async _ensureMqttNow() { if (!this._hasUser()) return false; if (!mqttClient.canAutoConnect()) return mqttClient.isConnected(); if (this._mqttConnecting) return mqttClient.isConnected(); const cfg = storage.getMqttConfig(); if (!cfg || !cfg.serialNumber || cfg.productId == null || cfg.productId === '' || !cfg.productSecret) { return false; } const creds = auth.getMqttCredentials(cfg.serialNumber, cfg.productId, cfg.productSecret); if (!creds) return false; this._mqttConnecting = true; try { await mqttClient.connect(creds); console.log('[GW] MQTT 自动连接成功:', creds.clientId); return true; } catch (err) { // 失败不阻断:底层会按 reconnectPeriod 自愈;连接成功会经 // _onMqttState(CONNECTED) 置位并触发离线补传 console.warn('[GW] MQTT 自动连接失败(底层将自愈重试):', err && err.message); return false; } finally { this._mqttConnecting = false; } } async _startNow(device) { if (!device || !device.serialNumber) return; // 目标必须是"唯一启用设备":启用开关是采集的唯一真相源, // 任何入口(扫描页 gateway.start / 失败重试)都不得替一个已停用的设备建链 if (!this._isEnabled(device.serialNumber)) { console.log('[GW] 目标设备未启用,不启动采集:', device.serialNumber); // 只有"它正是当前目标"时才需要收敛(清掉这个过期会话); // 否则只是别人传了个已停用的设备进来,不能连累当前正在跑的采集 if (this._device && this._device.serialNumber === device.serialNumber) { await this._stopNow(); } return; } this._clearInitRetry(); // 启用设备 = 开始采集:顺手确保 MQTT 在(幂等短路), // 否则首次扫码后必须回「我的」页点一次连接才能上传 this._ensureMqttNow(); const same = !!(this._device && this._device.serialNumber === device.serialNumber); if (same) { // 已在目标会话:BLE 就绪且已运行则幂等返回(MQTT 断开交给 lib 自连 + 失败重试) if (this._bleReady && this._phase === GATEWAY_PHASE.RUNNING) return; await this._boot(); return; } if (this._device) { // 切设备:先拆旧会话(保留旧 feed),再启动新会话并重置展示 await this._teardown(); this._resetFeed(); } this._device = device; this._lastError = null; if (this._phase !== GATEWAY_PHASE.STARTING) this._setPhase(GATEWAY_PHASE.STARTING); this._notify(); await this._boot(); } async _stopNow() { this._clearInitRetry(); if (!this._device && this._phase === GATEWAY_PHASE.STOPPED) return; await this._teardown(); } /* ===================== 内部:启动/停止 ===================== */ async _boot() { const device = this._device; if (!device) return; // "本次启动是否仍然有效"三条件:仍是当前目标 + 仍是存储里的唯一启用设备。 // 后者必须在每次判定时回读存储(而不是只看构造时那份内存快照), // 这样"用户中途把设备关掉"能立刻作废在途启动 —— 不再报启动失败、不再排自动重试 const alive = () => !!(this._device && this._device.serialNumber === device.serialNumber && this._isEnabled(device.serialNumber)); try { // 1. BLE:连接目标(适配器由 bleService.connectDevice 内部保证已打开,幂等; // 已就绪且同设备则直接复用,不做无谓建链) if (!alive()) return; if (!bleService.isReady() || bleService.deviceId !== device.deviceId) { // connectDevice 幂等:同设备在途连接会复用同一 Promise, // 不再需要"判断是否连接中 → 等待 → 二次补连"的兜底分支 await bleService.connectDevice(device.deviceId, device.serviceUUID || null); } if (!alive()) return; this._bleReady = true; this._lastError = null; // 链路已就绪:清掉历史错误,避免"已连上仍显示报错" this._setPhase(GATEWAY_PHASE.RUNNING); this._notify(); // MQTT 由「我的」页独立连接:此处不再主动连接, // 连接状态经 mqttClient.onStateChange 回调同步到 _mqttReady console.log('[GW] 采集器运行中(BLE):', device.serialNumber); } catch (err) { if (!alive()) return; // 切设备/主动断开导致在途连接作废:不是失败,不上屏也不重试 if (err && err.cancelled) return; console.error('[GW] 启动失败:', err); // 提供更友好的错误提示 let errorMsg = '启动失败'; if (err.message) { if (err.message.includes('BLE') || err.message.includes('蓝牙')) { errorMsg = '蓝牙连接失败: ' + err.message; } else if (err.message.includes('凭证')) { errorMsg = err.message; } else { errorMsg = '启动失败: ' + err.message; } } this._lastError = errorMsg; this._setPhase(GATEWAY_PHASE.ERROR); this._scheduleInitRetry(); // 错误也要推给订阅者:此前只置位不通知,"异常"相位永远到不了页面 // (mine 的「异常」标签与 detail 的 {{lastError}} 都靠这次通知) this._notify(); } } async _teardown() { this._clearInitRetry(); this._flush(); // 先处理缓冲(此时 _device 仍在) this._bleReady = false; // MQTT 连接由「我的」页建立并持有,网关只复用其状态: // 此处不得 disconnect(否则切设备/停止采集会把 MQTT 拆掉,且 _boot 不会重连), // _mqttReady 也保持真实值(由 _onMqttState 驱动),退出登录统一由 app.clearLoginInfo 断开 this._device = null; // 先置空,避免断开回调把 phase 打回 starting try { await bleService.disconnect(); } catch (e) { /* 忽略 */ } this._lastError = null; this._setPhase(GATEWAY_PHASE.STOPPED); this._notify(); } _resetFeed() { this._pending = []; this._dataList = []; this._stats = { received: 0, uploaded: 0, failed: 0 }; this._lastDataAt = 0; this._notify(); } /* ===================== 内部:状态回接 ===================== */ _onBleState(s) { if (!this._device) return; const ready = s === BLE_STATE.READY || s === BLE_STATE.CONNECTED; this._bleReady = ready; if (ready) { if (this._phase !== GATEWAY_PHASE.RUNNING) this._setPhase(GATEWAY_PHASE.RUNNING); // 链路已恢复:清掉历史错误,否则一次瞬时失败会一直挂在详情页 {{lastError}} 上 this._lastError = null; } else { // 目标会话仍在但 BLE 断开 → 自动重连中 if (this._phase !== GATEWAY_PHASE.ERROR) this._setPhase(GATEWAY_PHASE.STARTING); } this._notify(); } _onMqttState(s) { this._mqttReady = s === MQTT_STATE.CONNECTED; if (this._mqttReady) this._flushOffline(); this._notify(); } /* ===================== 内部:数据通路 ===================== */ _onBleData(d) { if (!this._device || !d || !d.hex) return; this._stats.received++; this._lastDataAt = Date.now(); this._pending.push({ id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, hex: d.hex, time: this._fmtTime(new Date()), uploaded: false, }); if (!this._flushTimer) { this._flushTimer = setTimeout(() => { this._flushTimer = null; this._flush(); }, DEFAULTS.FLUSH_MS); } } /** 节流上屏 + 上报/离线缓存,一次处理一批 */ _flush() { if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; } const batch = this._pending; this._pending = []; if (batch.length === 0) return; this._dataList = batch.concat(this._dataList).slice(0, DEFAULTS.MAX_LIST); const device = this._device; const mode = measurePoint.getCollectMode(); const measurePointId = measurePoint.getMeasurePointId(); // 上报身份在收帧批次内快照:手持采集仪逐点测量,确认/补传必须用「采集当时」的测点, // 否则「A点采完→切到B点→再确认」会把 A 点的数据归属到 B 点 const productId = this._resolveProductId(); const reportSn = this._resolveSerialNumber(); const offline = []; for (const item of batch) { item.mode = mode; item.measurePointId = measurePointId; item.productId = productId; item.reportSn = reportSn; if (mode === measurePoint.COLLECT_MODE.MANUAL) { // 手动确认模式:待确认,不自动上报、不进离线缓存(确认后由 confirmData 处理) item.confirmed = false; continue; } // 自动模式 if (!measurePoint.isValidMeasurePointId(measurePointId)) { // 测点ID 缺失或非法:不上报不缓存,标记原因(topic 第3段非法会导致服务端分段错位) item.pendingReason = measurePointId ? '测点ID格式非法' : '未设置测点ID'; continue; } if (this._mqttReady && device && device.enabled) { this._upload(item); // fire-and-forget } else { item.offline = true; offline.push(this._toOfflineItem(item, device)); } } if (offline.length > 0) { storage.cachePendingDataBatch(offline); this._refreshOfflineCount(); } this._notify(); } /** * 解析上报用产品ID:优先取“我的”页 MQTT 配置(单一数据源), * 设备配置不再录入产品信息,无 MQTT 配置时回退设备自身 productId(兼容旧数据) * @returns {number|string|null} */ _resolveProductId() { const cfg = storage.getMqttConfig(); if (cfg && cfg.productId != null && cfg.productId !== '') { return cfg.productId; } return this._device ? this._device.productId : null; } /** * 解析上报用设备编号:优先取“我的”页 MQTT 配置的 SN(与 clientId 依据一致), * 回退设备自身 serialNumber(BLE 名称自动生成的编号,用于本地标识) * * 不统一会导致:topic 里的 SN 与 MQTT 凭证 SN 不一致,后端按 topic 的 SN 查不到设备 * @returns {string|null} */ _resolveSerialNumber() { const cfg = storage.getMqttConfig(); if (cfg && cfg.serialNumber) { return cfg.serialNumber; } return this._device ? this._device.serialNumber : null; } /** * 手动确认上报(模式1):确认单条数据并上传 * @param {string} itemId - 数据项 id * @returns {Promise} */ confirmData(itemId) { const item = this._dataList.find(it => it.id === itemId); if (!item) { return Promise.reject(new Error('数据不存在')); } if (item.uploaded || item.confirmed) { return Promise.resolve(); } const device = this._device; // 归属一律取收帧快照:手动确认可能发生在切换测点之后 const measurePointId = item.measurePointId || ''; if (!measurePoint.isValidMeasurePointId(measurePointId)) { item.pendingReason = measurePointId ? '测点ID格式非法' : '未设置测点ID'; this._notify(); return Promise.reject(new Error('请先填写合法测点ID(纯数字, 1-64位)')); } // 产品ID 允许回退:手动模式下常出现「先采集、后到我的页配凭证、再确认」 const productId = item.productId || this._resolveProductId(); if (productId == null || productId === '') { item.pendingReason = '未配置产品ID'; this._notify(); return Promise.reject(new Error('请先在“我的”页配置 MQTT 产品凭证')); } item.confirmed = true; item.pendingReason = null; if (this._mqttReady && device && device.enabled) { this._upload(item); } else if (device) { // MQTT 未连接:确认后进离线缓存,待补传 storage.cachePendingData(this._toOfflineItem(item, device)); this._refreshOfflineCount(); item.offline = true; } this._notify(); return Promise.resolve(); } _upload(item) { const device = this._device; if (!device) return; const measurePointId = item.measurePointId || ''; if (!measurePoint.isValidMeasurePointId(measurePointId)) { item.pendingReason = measurePointId ? '测点ID格式非法' : '未设置测点ID'; this._notify(); return; } const productId = item.productId || this._resolveProductId(); if (productId == null || productId === '') { item.pendingReason = '未配置产品ID'; this._notify(); return; } const topic = auth.buildMeasurePostTopic( productId, item.reportSn || this._resolveSerialNumber(), measurePointId ); mqttClient.publish(topic, item.hex) .then(() => { item.uploaded = true; item.offline = false; this._stats.uploaded++; this._notify(); }) .catch((err) => { console.warn('[GW] 上报失败转离线:', err && err.message); this._stats.failed++; if (this._device) { storage.cachePendingData(this._toOfflineItem(item, this._device)); this._refreshOfflineCount(); item.offline = true; } this._notify(); }); } _toOfflineItem(item, device) { if (!device) return null; return { deviceId: device.serialNumber, // 设备身份(本地标识,用于缓存归属匹配) reportSn: item.reportSn || this._resolveSerialNumber(), // 上报身份(补传时用于构建 topic) measurePointId: item.measurePointId || '', // 采集当时的测点,补传不得重新解析 hex: item.hex, dataId: item.id, }; } /** 补传当前设备的离线缓存(成功后仅移除本设备项,其他设备残留保留) */ async _flushOffline() { if (this._flushingOffline) return; this._flushingOffline = true; try { const device = this._device; const productId = this._resolveProductId(); if (!device || !this._mqttReady || !productId) return; const list = storage.getPendingData(); if (!list || list.length === 0) return; const remaining = []; for (const it of list) { if (it.deviceId === device.serialNumber) { if (!measurePoint.isValidMeasurePointId(it.measurePointId)) { // 旧格式缓存(无测点ID)或非法测点ID 无法按测点上报,保留待人工清理 console.warn('[GW] 离线缓存测点ID缺失或非法,跳过:', it.measurePointId, it.hex); remaining.push(it); continue; } // 补传用缓存项当时的上报身份,缺失则回退当前解析结果 const sn = it.reportSn || this._resolveSerialNumber() || device.serialNumber; const topic = auth.buildMeasurePostTopic(productId, sn, it.measurePointId); try { await mqttClient.publish(topic, it.hex); this._stats.uploaded++; } catch (err) { console.warn('[GW] 离线补传失败,保留:', it.hex, err && err.message); remaining.push(it); } } else { remaining.push(it); } } storage.setPendingData(remaining); this._refreshOfflineCount(); this._notify(); } finally { this._flushingOffline = false; } } /* ===================== 内部:工具 ===================== */ _hasUser() { const info = storage.getUserInfo(); return !!(info && info.userId); } /** * 目标设备当前是否仍是"唯一启用设备" * * `enabled` 是采集开关的唯一真相源:设备一旦停用就不得再建链、不得再排自动重试。 * @param {string} serialNumber * @returns {boolean} */ _isEnabled(serialNumber) { if (!serialNumber) return false; const enabled = storage.getEnabledDevice(); return !!(enabled && enabled.serialNumber === serialNumber); } /** * 同步"存储中的启用设备"与"网关当前目标",不一致时**立即**中止在途 BLE 工作 * * 必须同步执行(不能只靠队列里的 _stopNow):_stopNow 要排在在途的 _boot 之后, * 最坏要等一次建链超时(10s)+ 一次适配器复位,而这期间在途流程还会继续 * 复位适配器、上报启动失败、排自动重试 —— 表现就是"设备都关了还在一直连蓝牙"。 * bleService.disconnect() 的置位、序号自增与计时器清理都是同步的, * 立刻作废在途连接流程并停掉自动重连;队列里的 _teardown 只负责收尾。 */ _syncTarget() { const enabled = storage.getEnabledDevice(); const next = enabled ? enabled.serialNumber : null; const current = this._device ? this._device.serialNumber : null; if (!current || current === next) return; console.log('[GW] 目标设备已停用,立即中止连接:', current); this._clearInitRetry(); bleService.disconnect().catch(() => { /* 无链路/已断开:无需处理 */ }); } _setPhase(p) { if (this._phase !== p) { this._phase = p; console.log('[GW] 阶段变更:', p); } } _notify() { const snap = this.getSnapshot(); this._subs.forEach((cb) => { try { cb(snap); } catch (e) { console.error('[GW] 订阅回调异常:', e); } }); } _refreshOfflineCount() { this._offlineCount = storage.getPendingData().length; } _scheduleInitRetry() { if (this._initRetryTimer) return; if (!this._device) return; this._initRetryTimer = setTimeout(() => { this._initRetryTimer = null; if (!this._device) return; console.log('[GW] 启动失败,自动重试...'); // 重试必须重新解析"唯一启用设备":直接 start(this._device) 会绕过启用开关, // 设备已被关掉时仍会继续重连。ensureStarted 在无启用设备时会直接收敛到停止 this.ensureStarted(); }, DEFAULTS.INIT_RETRY_MS); } _clearInitRetry() { if (this._initRetryTimer) { clearTimeout(this._initRetryTimer); this._initRetryTimer = null; } } _fmtTime(date) { const h = date.getHours().toString().padStart(2, '0'); const m = date.getMinutes().toString().padStart(2, '0'); const s = date.getSeconds().toString().padStart(2, '0'); const ms = date.getMilliseconds().toString().padStart(3, '0'); return `${h}:${m}:${s}.${ms}`; } } // 导出单例 const gateway = new Gateway(); module.exports = { GATEWAY_PHASE, GATEWAY_PHASE_TEXT, Gateway, gateway, };