fastbee/yack-mini/miniprogram/services/gateway.js

591 lines
18 KiB
JavaScript
Raw 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 + MQTT,
* 持续接收 BLE notify 帧并透传 publish 到 yack 平台,
* 跨页面/跨 tab 运行;MQTT 不可用时帧进离线缓存,恢复后自动补传。
*
* 页面通过 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 { 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 静默阈值
MQTT_CONNECT_TIMEOUT: TIMEOUT.MQTT_CONNECT, // MQTT 首连超时保护
};
class Gateway {
constructor() {
this._phase = GATEWAY_PHASE.IDLE;
this._device = null; // 正在采集的设备对象
this._bleReady = false;
this._mqttReady = 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() {
return this._chain(() => this._ensureStartedNow());
}
/**
* 启动采集(对目标设备)。不同设备会自动先拆旧会话并重置展示数据。
* @param {Object} device - 设备配置(serialNumber/productId/productSecret/deviceId/serviceUUID/enabled...)
*/
start(device) {
return this._chain(() => this._startNow(device || this._device));
}
/**
* 停止采集(保留已上屏数据与统计,便于回看;拆设备时才 _resetFeed)。
*/
stop() {
return this._chain(() => this._stopNow());
}
/** 切后台(onHide):记录时间,不 teardown */
suspend() {
this._hiddenAt = Date.now();
this._clearInitRetry();
}
/** 回前台(onShow):补起连接、探测静默并重连、补传离线 */
resume() {
const bgMs = this._hiddenAt ? Date.now() - this._hiddenAt : 0;
this._hiddenAt = 0;
this._flush();
if (!this._hasUser()) return Promise.resolve();
const enabled = storage.getEnabledDevice();
// 无目标 → 若在跑则停;目标变化/未就绪 → 交给队列处理
if (!enabled || !this._device || this._device.serialNumber !== enabled.serialNumber || !this._bleReady) {
return this.ensureStarted();
}
// 已就绪但静默过久 / 后台超过 5s → 探测并强制重连
const silentMs = Date.now() - (this._lastDataAt || Date.now());
if (bgMs > 5000 || silentMs > DEFAULTS.STALL_MS) {
console.log('[GW] 后台时长:', bgMs, 'ms, 静默时长:', silentMs, 'ms');
// 检查 BLE 连接是否真的断开了
if (!bleService.isReady()) {
console.warn('[GW] BLE 未就绪,尝试重连');
return bleService.forceReconnect().catch((err) => {
this._lastError = err.message || '重连失败';
this._notify();
console.error('[GW] BLE 重连失败:', err);
});
}
// 探测连接是否仍然活跃
if (!bleService.probeConnection(DEFAULTS.STALL_MS)) {
console.warn('[GW] BLE 静默/掉线,强制重连');
return bleService.forceReconnect().catch((err) => {
this._lastError = err.message || '重连失败';
this._notify();
console.error('[GW] BLE 重连失败:', err);
});
}
}
// MQTT 若断开:底层 lib 会按 reconnectPeriod 自连,重连成功后
// 经 _onMqttState(CONNECTED) 自动补传离线,此处不重复 connect 以免建重复连接。
if (this._mqttReady) this._flushOffline();
return Promise.resolve();
}
/** 清空展示数据与统计(不触碰离线缓存) */
clearHistory() {
this._pending = [];
this._dataList = [];
this._stats = { received: 0, uploaded: 0, failed: 0 };
this._lastDataAt = 0;
this._notify();
}
/** 手动补传离线缓存(当前设备) */
flushOffline() {
return this._flushOffline();
}
/* ===================== 页面订阅 ===================== */
/**
* 订阅网关快照
* @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(),
bleState: bleService.getState(),
// 浅拷贝元素,确保 setData diff 能识别 uploaded 等字段变化并触发视图刷新
dataList: this._dataList.map(it => ({ ...it })),
stats: { ...this._stats },
offlineCount: this._offlineCount,
lastError: this._lastError,
};
}
/** 当前采集设备(浅拷贝) */
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;
const enabled = storage.getEnabledDevice();
if (!enabled) {
await this._stopNow();
return;
}
await this._startNow(enabled);
}
async _startNow(device) {
if (!device || !device.serialNumber) return;
this._clearInitRetry();
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);
try {
// 1. BLE:确保适配器打开并连接目标(已就绪同设备则复用)
await bleService.initBluetooth();
if (!alive()) return;
if (!bleService.isReady() || bleService.deviceId !== device.deviceId) {
// 若已有连接/自动重连在途,不重复发起,等其就绪或中止
if (bleService.isConnecting()) {
await this._waitBleReady();
} else {
await bleService.connectDevice(device.deviceId, device.serviceUUID || null);
}
if (!alive()) return;
// 等待/连接后仍未就绪且无连接在途 → 显式再连一次
if (!bleService.isReady() && !bleService.isConnecting()) {
await bleService.connectDevice(device.deviceId, device.serviceUUID || null);
}
}
if (!alive()) return;
this._bleReady = true;
this._setPhase(GATEWAY_PHASE.RUNNING);
this._notify();
// 2. MQTT:已连同一 clientId 时 connect() 内部复用;带超时保护
const creds = auth.getMqttCredentialsFromDevice(device);
if (!creds) {
throw new Error('获取 MQTT 凭证失败,请检查设备配置');
}
console.log('[GW] 开始连接 MQTT...');
await this._connectMqtt(creds);
if (!alive()) return;
this._mqttReady = true;
this._flushOffline();
console.log('[GW] 采集器运行中:', device.serialNumber);
} catch (err) {
if (!alive()) return;
console.error('[GW] 启动失败:', err);
// 提供更友好的错误提示
let errorMsg = '启动失败';
if (err.message) {
if (err.message.includes('MQTT')) {
errorMsg = err.message;
} else 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();
}
}
/**
* MQTT 连接(带超时保护,避免首连卡死阻塞切换队列)
*/
_connectMqtt(creds) {
return new Promise((resolve, reject) => {
let timedOut = false;
let timer = null;
timer = setTimeout(() => {
timedOut = true;
timer = null;
console.warn('[GW] MQTT 连接超时,断开重试');
mqttClient.disconnect().catch(() => {});
reject(new Error('MQTT 连接超时'));
}, DEFAULTS.MQTT_CONNECT_TIMEOUT);
mqttClient.connect(creds)
.then(() => {
if (timer) clearTimeout(timer);
if (!timedOut) {
resolve();
}
})
.catch((err) => {
if (timer) clearTimeout(timer);
if (!timedOut) {
reject(err);
}
});
});
}
/**
* 轮询等待 BLE 就绪或连接中止(避免与自动重连并发)
* @param {number} timeoutMs
* @returns {Promise}
*/
_waitBleReady(timeoutMs = 15000) {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (!this._device) { resolve(); return; }
if (bleService.isReady() || !bleService.isConnecting() || Date.now() - start > timeoutMs) {
resolve();
return;
}
setTimeout(tick, 200);
};
tick();
});
}
async _teardown() {
this._clearInitRetry();
this._flush(); // 先处理缓冲(此时 _device 仍在)
this._bleReady = false;
this._mqttReady = false;
this._device = null; // 先置空,避免断开回调把 phase 打回 starting
try { await bleService.disconnect(); } catch (e) { /* 忽略 */ }
try { await mqttClient.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);
} 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 offline = [];
for (const item of batch) {
if (this._mqttReady && device && device.enabled) {
this._upload(item); // fire-and-forget
} else {
offline.push(this._toOfflineItem(item, device));
}
}
if (offline.length > 0) {
storage.cachePendingDataBatch(offline);
this._refreshOfflineCount();
}
this._notify();
}
_upload(item) {
const device = this._device;
if (!device) return;
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
mqttClient.publish(topic, item.hex)
.then(() => {
item.uploaded = true;
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();
}
this._notify();
});
}
_toOfflineItem(item, device) {
if (!device) return null;
return {
deviceId: device.serialNumber,
productId: device.productId,
hex: item.hex,
dataId: item.id,
};
}
/** 补传当前设备的离线缓存(成功后仅移除本设备项,其他设备残留保留) */
async _flushOffline() {
if (this._flushingOffline) return;
this._flushingOffline = true;
try {
const device = this._device;
if (!device || !this._mqttReady) return;
const list = storage.getPendingData();
if (!list || list.length === 0) return;
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
const remaining = [];
for (const it of list) {
if (it.deviceId === device.serialNumber) {
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);
}
_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] 启动失败,自动重试...');
this.start(this._device);
}, 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');
return `${h}:${m}:${s}`;
}
}
// 导出单例
const gateway = new Gateway();
module.exports = {
GATEWAY_PHASE,
GATEWAY_PHASE_TEXT,
Gateway,
gateway,
};