/** * MQTT 客户端服务(包装层) * * ⚠️ 底层是自研的 `libs/mqtt-wx.min.js`,**不是 mqtt.js**,因此本层不得按 mqtt.js 的语义写: * - `end(force)` 的 force 无意义 → 本实现一律"终止性断开" * - 没有 `client.reconnecting` 属性(lib 侧已补 getter,但不要靠它做分支判断) * - `connect()` 的 Promise 只作为辅助通道,事件才是主通道 * * ⚠️ 曾因错误假设导致的真实故障(已修,勿回退): * 1. `!client.reconnecting` 恒为真 → 任何 error 都拆客户端 → 幽灵客户端 * 2. 旧实例回调未做实例校验 → 幽灵写入包装器状态 → UI"已连接"但 publish 全失败 * 3. 建连没有超时出口 → 「连接」按钮永久 loading * * 本层职责:把裸连接包装成"有状态、有确定语义"的服务 * - 单一真相源:所有回调做实例校验,旧实例的迟到回调一律丢弃 * - 彻底终止:换设备/断开时 end() 清掉旧客户端,不留自续重连的幽灵 * - 有出口:建连失败/超时都能结算,UI 不会永久 loading * - 用户意图:userDisconnected 表达"用户主动断开",自动连接不得覆盖 */ const mqtt = require('../libs/mqtt-wx.min.js'); const envConfig = require('../env.config.js'); // MQTT 状态常量 const MQTT_STATE = { DISCONNECTED: 'disconnected', CONNECTING: 'connecting', CONNECTED: 'connected', }; class MqttClient { constructor() { this.client = null; this.state = MQTT_STATE.DISCONNECTED; this.subscriptions = new Map(); this.onMessageCallback = null; this.onStateChangeCallback = null; this.reconnectAttempts = 0; // 当前连接的 clientId(用于检测连接复用) this.currentClientId = null; // 用户主动断开意图:置位后自动连接(启动/回前台/网络恢复)不得拉起它 this.userDisconnected = false; // 建连去重:同一 clientId 在途建连复用同一 Promise(避免并发建出两个连接) this._connectInFlight = null; this._connectInFlightClientId = null; } /** * 设置消息回调 * @param {Function} callback - (topic, message) => void */ onMessage(callback) { this.onMessageCallback = callback; } /** * 设置状态变化回调 * @param {Function} callback - (state) => void */ onStateChange(callback) { this.onStateChangeCallback = callback; } /** * 更新状态并通知 * 去重:重连循环里 CONNECTING / DISCONNECTED 会反复回摆, * 重复通知会带动网关重复补传离线,属无谓开销 * @param {string} state */ _setState(state) { if (this.state === state) return; this.state = state; if (this.onStateChangeCallback) { this.onStateChangeCallback(state); } console.log('[MQTT] 状态变更:', state); } /** * 建连前校验网络可用性 * 必须 await:wx.getNetworkType 是异步回调,若放在建连之后, * 检查会晚于 mqtt.connect 执行(断网时仍会建连,且 reject 落在已建连的 promise 上) * @returns {Promise} */ _ensureNetwork() { return new Promise((resolve, reject) => { wx.getNetworkType({ success: (res) => { console.log('[MQTT] 网络类型:', res.networkType); if (res.networkType === 'none') { reject(new Error('网络未连接,请检查网络设置')); } else { resolve(); } }, fail: () => { console.warn('[MQTT] 无法获取网络状态'); resolve(); }, }); }); } /** * 是否值得发起自动连接 * 持有客户端即视为"已连接或正在自愈",不需要再插一脚; * 用户主动断开则明确不连。 * @returns {boolean} */ canAutoConnect() { return !this.userDisconnected && !this.client; } /** 清除"用户主动断开"意图(登录 / 重新登录后允许自动连接) */ allowAutoConnect() { this.userDisconnected = false; } /** * 彻底终止一个客户端实例 * * 不做这件事会留下"幽灵客户端":对象已不可达,但内部还挂着自续重连定时器, * 用同一 clientId 连回来会被 broker 当成新会话踢掉(clean session 只保留一个会话), * 表现为状态周期性跳变。 * @param {Object} client */ _teardownClient(client) { if (!client) return; try { client.end(true); } catch (e) { console.warn('[MQTT] 关闭旧客户端异常:', e && e.message); } if (this.client === client) { this.client = null; this.currentClientId = null; } } /** * 连接 MQTT Broker * * 失败语义:建连阶段的失败**不拆客户端** —— 底层会按 reconnectPeriod 自愈, * 现场无人值守时也能自己连回来(工业现场网络抖动是常态)。 * 凭证类拒绝由底层置终止标记,避免以 5s 间隔无限撞 broker。 * * @param {Object} options - { clientId, username, password, server } * @returns {Promise} 建连结果(失败后底层仍在自愈重连,UI 状态由事件驱动) */ connect(options) { const newClientId = options.clientId; // 同一 clientId 在途建连:复用,绝不并发建出第二个连接 if (this._connectInFlight && this._connectInFlightClientId === newClientId) { console.log('[MQTT] 建连已在进行中,复用本次请求, clientId:', newClientId); return this._connectInFlight; } const promise = this._ensureNetwork().then(() => new Promise((resolve, reject) => { let settled = false; let guard = null; // 结算闸门:成功 / 失败 / 超时只允许结算一次 const finish = (fn, arg) => { if (settled) return; settled = true; if (guard) clearTimeout(guard); fn(arg); }; // 同一设备且连接仍可用:幂等复用 if (this.client && this.client.connected && this.currentClientId === newClientId) { console.log('[MQTT] 已连接, 复用连接, clientId:', newClientId); finish(resolve); return; } // 已有客户端:先彻底终止,杜绝"幽灵客户端 + 同 clientId 互踢" // // ⚠️ 此处**不得**清空 subscriptions:订阅表的生命周期属于"业务意图"而非"某次连接"。 // 清空后 connect 成功事件里的 _resubscribe() 只能拿到一张空表,订阅被永久丢失; // 而换 clientId 重连恰恰是最需要自动恢复订阅的场景。 // 唯一该清订阅的是 disconnect()(用户明确断开,不再需要这些回调)。 if (this.client) { console.log('[MQTT] 终止旧客户端:', this.currentClientId, '->', newClientId); this._teardownClient(this.client); } this._setState(MQTT_STATE.CONNECTING); this.userDisconnected = false; const server = options.server || envConfig.mqttServer; // 连接参数允许被调用方覆盖(调优/测试用):不传则用工业现场默认值 const connectOptions = { clientId: options.clientId, username: options.username, password: options.password, clean: true, keepalive: options.keepalive != null ? options.keepalive : 30, connectTimeout: options.connectTimeout != null ? options.connectTimeout : 30000, reconnectPeriod: options.reconnectPeriod != null ? options.reconnectPeriod : 5000, // 以下为可靠性参数,透传给底层(未传则用底层默认值): // pingTimeout 心跳判死时限 / pubackTimeout QoS1 重发间隔 / maxInflightRetry 重试上限 pingTimeout: options.pingTimeout, pubackTimeout: options.pubackTimeout, maxInflightRetry: options.maxInflightRetry, maxInflight: options.maxInflight, }; // 小程序真机只允许 wss:// / wxs://,ws:// 仅在开发者工具可用 if (/^ws:\/\//.test(server)) { console.warn('[MQTT] ws:// 仅开发者工具可用,真机请改用 wss:// 或 wxs://'); } console.log('[MQTT] 正在连接:', server, 'clientId:', options.clientId); // 包装层兜底超时:没有这个出口,「连接」按钮会永久停在 loading guard = setTimeout(() => { console.warn('[MQTT] 连接超时(包装层兜底)'); this._setState(MQTT_STATE.DISCONNECTED); finish(reject, new Error('MQTT 连接超时,请检查网络或服务器地址')); }, (connectOptions.connectTimeout || 30000) + 5000); let client; try { client = mqtt.connect(server, connectOptions); } catch (err) { console.error('[MQTT] 创建客户端失败:', err); this._setState(MQTT_STATE.DISCONNECTED); finish(reject, new Error('创建 MQTT 客户端失败: ' + (err.message || '未知错误'))); return; } this.client = client; this.currentClientId = options.clientId; this.reconnectAttempts = 0; // ⭐ 实例校验:只有"当前客户端"的回调能写状态。 // 旧实例(已 end 的幽灵)的迟到回调一律丢弃,否则它会覆写状态真相源 const isCurrent = () => this.client === client; client.on('connect', () => { if (!isCurrent()) return; console.log('[MQTT] 连接成功'); this._setState(MQTT_STATE.CONNECTED); this.reconnectAttempts = 0; this._resubscribe(); finish(resolve); }); client.on('error', (err) => { if (!isCurrent()) return; console.error('[MQTT] 连接错误:', err); const errMsg = (err && err.message) || '未知错误'; let userMsg = 'MQTT 连接失败'; if (errMsg.includes('ECONNREFUSED')) { userMsg = 'MQTT 服务器拒绝连接,请检查服务器配置'; } else if (errMsg.includes('ENOTFOUND')) { userMsg = 'MQTT 服务器地址解析失败,请检查地址'; } else if (errMsg.includes('ETIMEDOUT') || errMsg.includes('timeout') || errMsg.includes('超时')) { userMsg = 'MQTT 连接超时,请检查网络或服务器状态'; } else if (errMsg.includes('WebSocket')) { userMsg = 'WebSocket 连接失败,请检查协议(wss:// 或 wxs://)'; } else if (errMsg.includes('Bad username') || errMsg.includes('Not authorized')) { userMsg = 'MQTT 产品凭证不正确,请核对设备编号 / 产品ID / 产品密钥'; } // 建连阶段的失败:只结算,不拆客户端 —— 让底层继续自愈重连。 // 建连之后(已 resolve)的会话中抖动不 reject,仅记日志 if (!settled) { this._setState(MQTT_STATE.DISCONNECTED); finish(reject, new Error(userMsg + ': ' + errMsg)); } }); client.on('close', () => { if (!isCurrent()) return; console.log('[MQTT] 连接关闭'); this._setState(MQTT_STATE.DISCONNECTED); }); client.on('reconnect', () => { if (!isCurrent()) return; this.reconnectAttempts++; console.log('[MQTT] 正在重连, 尝试次数:', this.reconnectAttempts); this._setState(MQTT_STATE.CONNECTING); }); client.on('message', (topic, message) => { if (!isCurrent()) return; const msgStr = message.toString(); console.log('[MQTT] 收到消息, topic:', topic, 'message:', msgStr); if (this.onMessageCallback) { this.onMessageCallback(topic, msgStr); } if (this.subscriptions.has(topic)) { this.subscriptions.get(topic)(topic, msgStr); } }); })); this._connectInFlight = promise; this._connectInFlightClientId = newClientId; const clear = () => { if (this._connectInFlight === promise) { this._connectInFlight = null; this._connectInFlightClientId = null; } }; promise.then(clear, clear); return promise; } /** * 硬重连:先彻底关掉现有客户端(连带停掉底层自动重连),再按凭证重新建连 * * 手动重连必须是"点了就一定有结果"的确定性出口:底层可能停在僵尸 socket 上, * 只有 end() 清干净再建才是确定的。订阅表保留,建连成功后由 _resubscribe 恢复。 * @param {Object} options - { clientId, username, password, server } * @returns {Promise} */ reconnect(options) { this._teardownClient(this.client); this._setState(MQTT_STATE.DISCONNECTED); return this.connect(options); } /** * 发布消息 * QoS1 由底层等待 PUBACK 才算成功(超时会按 dup 重发,重试用尽才回调错误), * 因此这里的成功回调代表"broker 已确认收到" * * ⚠️ 必须有超时出口:底层的成功/失败**完全依赖回调**,而回调要等 PUBACK 或重试用尽。 * 一旦某条消息卡在在途队列里不结算,这个 Promise 就永久 pending —— 网关的离线补传 * (_flushOffline 串行 await publish,且被 _flushingOffline 闸门保护)会被它一次性 * 锁死,之后所有补传静默失效直到重启小程序。BLE 侧每个 GATT 操作都做了超时兜底, * 这里不能是唯一的例外。 * @param {string} topic - 主题 * @param {string} message - 消息内容 * @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean, publishTimeout: number} * @returns {Promise} */ publish(topic, message, options = { qos: 1 }) { const timeout = options.publishTimeout != null ? options.publishTimeout : 15000; return new Promise((resolve, reject) => { if (!this.client || !this.client.connected) { reject(new Error('MQTT 未连接')); return; } let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; console.warn('[MQTT] 发布超时, topic:', topic); reject(new Error('发布超时')); }, timeout); this.client.publish(topic, message, options, (err) => { if (settled) return; settled = true; clearTimeout(timer); if (err) { console.error('[MQTT] 发布失败:', err); reject(new Error('发布失败')); } else { console.log('[MQTT] 发布成功, topic:', topic); resolve(); } }); }); } /** * 订阅主题 * @param {string} topic - 主题 * @param {Function} callback - 消息回调(可选) * @param {Object} options - 订阅选项 {qos: 0|1|2} * @returns {Promise} */ subscribe(topic, callback = null, options = { qos: 1 }) { return new Promise((resolve, reject) => { if (!this.client || !this.client.connected) { reject(new Error('MQTT 未连接')); return; } this.client.subscribe(topic, options, (err) => { if (err) { console.error('[MQTT] 订阅失败:', err); reject(new Error('订阅失败')); } else { console.log('[MQTT] 订阅成功:', topic); if (callback) { this.subscriptions.set(topic, callback); } resolve(); } }); }); } /** * 取消订阅 * @param {string} topic - 主题 * @returns {Promise} */ unsubscribe(topic) { return new Promise((resolve, reject) => { if (!this.client || !this.client.connected) { this.subscriptions.delete(topic); resolve(); return; } this.client.unsubscribe(topic, (err) => { if (err) { console.error('[MQTT] 取消订阅失败:', err); reject(new Error('取消订阅失败')); } else { console.log('[MQTT] 取消订阅成功:', topic); this.subscriptions.delete(topic); resolve(); } }); }); } /** * 重新订阅所有主题 */ _resubscribe() { if (this.subscriptions.size === 0) return; this.subscriptions.forEach((callback, topic) => { this.subscribe(topic, callback).catch(err => { console.error('[MQTT] 重订阅失败:', topic, err); }); }); } /** * 主动断开连接(终止性操作) * * 置 userDisconnected = true 表达"用户意图":网关的自动连接不再把它拉起来, * 直到用户再次手动连接(或被显式 reconnect)。 * @returns {Promise} */ disconnect() { this.userDisconnected = true; const client = this.client; return new Promise((resolve) => { if (!client) { this._setState(MQTT_STATE.DISCONNECTED); resolve(); return; } this.subscriptions.clear(); try { client.end(false, () => { if (this.client === client) { this._setState(MQTT_STATE.DISCONNECTED); this.client = null; this.currentClientId = null; } console.log('[MQTT] 已断开连接'); resolve(); }); } catch (e) { console.warn('[MQTT] 断开异常,强制清理:', e && e.message); this.client = null; this.currentClientId = null; this._setState(MQTT_STATE.DISCONNECTED); resolve(); } }); } /** * 获取当前状态 * @returns {string} */ getState() { return this.state; } /** * 是否已连接 * @returns {boolean} */ isConnected() { return !!(this.client && this.client.connected); } /** * 清除消息回调(页面卸载时调用) */ clearCallbacks() { this.onMessageCallback = null; this.onStateChangeCallback = null; console.log('[MQTT] 已清除回调'); } } // 导出单例 const mqttClient = new MqttClient(); module.exports = { MQTT_STATE, MqttClient, mqttClient, };