fastbee/yack-mini/miniprogram/services/gateway.js
2026-09-27 14:46:05 +08:00

873 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 网关采集服务(应用级常驻,单 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<boolean>} 是否已连接
*/
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<boolean>} 是否已连接
*/
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,
};