fastbee/yack-mini/miniprogram/services/mqtt-client.js
2026-09-27 15:17:17 +08:00

514 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.

/**
* 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,
};