/** * MqttClient.uts * ------------------------------------------------------------------ * uni-app x (uvue) 最小 MQTT 3.1.1 over WebSocket 客户端 * * 依赖:uni.connectSocket / SocketTask 二进制收发(App 平台 4.61+ 支持 ArrayBuffer) * * 用法: * import { MqttClient } from './MqttClient.uts' * * const client = new MqttClient({ * url: 'ws://192.168.1.10:8083', // 后台「服务设置」里 WebSocket 端口,默认 8083 * clientId: 'app-' + Date.now().toString(), * username: '会员账号', // allow_member_login 开启时用框架会员账号;否则用后台「认证账号」 * password: '密码', * autoReconnect: true * }) * client.onConnAck = (code: number) => { console.log('连接结果 code=' + code) } // 0=成功 * client.onMessage = (topic: string, payload: string) => { console.log(topic, payload) } * client.onSubAck = (topic: string, code: number) => { console.log('订阅结果', topic, code) } // 128=被拒 * client.connect() * client.subscribe('/devices/+/state', 0) * client.publish('/devices/DEV001/cmd', '{"on":true}', 0, false) * client.disconnect() * ------------------------------------------------------------------ */ export interface MqttClientOptions { /** WebSocket 地址,形如 ws://192.168.1.10:8083 */ url: string /** 客户端 ID,需全局唯一,建议 app-时间戳-随机数 */ clientId: string /** MQTT 账号:allow_member_login=1 时为框架会员账号,否则为后台「认证账号」;匿名连接可留空 */ username?: string | null password?: string | null /** 心跳间隔秒,默认 10(App 后台 JS 定时器会暂停,缩短心跳可让服务端更快判定掉线、回前台更快重连) */ keepalive?: number /** 会话清除,默认 true */ cleanSession?: boolean /** 断线自动重连,默认 true(指数退避 1s~30s) */ autoReconnect?: boolean } interface Packet { cmd: number length: number body: Uint8Array } interface PublishInfo { topic: string payload: string } export class MqttClient { private opt: MqttClientOptions private task: SocketTask | null = null private connected: boolean = false private closedByUser: boolean = false private pingTimer: number | null = null private reconnectTimer: number | null = null private retryMs: number = 1000 private rxBuf: Uint8Array = new Uint8Array(0) private subscribedTopics: string[] = [] // SUBACK 只回 packetId,用两个平行数组记住「发出的订阅」以便把结果对回主题 private subPendMid: number[] = [] private subPendTopic: string[] = [] /** 连接结果回调:code=0 成功,1~5 见 MQTT CONNACK 返回码 */ onConnAck: ((code: number) => void) | null = null /** 连接成功(CONNACK=0)后触发 */ onOpen: (() => void) | null = null /** 收到消息回调 */ onMessage: ((topic: string, payload: string) => void) | null = null /** 订阅结果回调:code=0/1/2 为授予的 QoS,128(0x80) 表示服务端拒绝(ACL/归属校验不通过) */ onSubAck: ((topic: string, code: number) => void) | null = null /** 连接关闭回调 */ onClose: ((code: number) => void) | null = null /** 错误回调 */ onError: ((msg: string) => void) | null = null constructor(options: MqttClientOptions) { this.opt = options if (this.opt.keepalive == null || this.opt.keepalive <= 0) { this.opt.keepalive = 60 } if (this.opt.cleanSession == null) { this.opt.cleanSession = true } if (this.opt.autoReconnect == null) { this.opt.autoReconnect = true } this.rxBuf = new Uint8Array(0) this.subscribedTopics = [] this.subPendMid = [] this.subPendTopic = [] this.pingTimer = null this.reconnectTimer = null this.retryMs = 1000 this.connected = false this.closedByUser = false } get isConnected(): boolean { return this.connected } connect(): void { if (this.task != null) { return } this.closedByUser = false this.retryMs = 1000 this.consoleLog('connecting ' + this.opt.url) const task = uni.connectSocket({ url: this.opt.url, success: () => { }, fail: (err) => { this.emitError('connect fail: ' + JSON.stringify(err)) if (!this.closedByUser && this.opt.autoReconnect === true) { this.scheduleReconnect() } } }) this.task = task task.onOpen(() => { this.handleOpen() }) task.onMessage((res) => { this.handleMessage(res.data) }) task.onError(() => { this.emitError('socket error') }) task.onClose((res) => { this.handleClose(res.code) }) } /** 断开连接(不再自动重连) */ disconnect(): void { this.closedByUser = true this.connected = false this.stopPing() if (this.reconnectTimer != null) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } if (this.task != null) { try { // 发送 DISCONNECT(14) this.send(this.buildPacket(14, new Uint8Array(0))) } catch (e) { } this.task.close({ code: 1000, reason: 'bye' }) this.task = null } } /** 立即重连(App 回前台时调用)。后台期间 JS 定时器暂停、心跳停发,连接可能已被服务端断开, * 此时不等指数退避定时器,直接清理旧连接并重新连接 */ reconnectNow(): void { this.closedByUser = false this.retryMs = 1000 if (this.reconnectTimer != null) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } if (this.task != null) { try { this.task.close({ code: 1000, reason: 'reconnect' }) } catch (e) { } this.task = null } this.connected = false this.stopPing() this.connect() } /** 订阅主题,qos 0 或 1 */ subscribe(topic: string, qos: number = 0): void { if (topic.length === 0) { return } if (!this.connected) { this.emitError('not connected, cannot subscribe ' + topic) return } this.sendSubscribe(topic, qos) if (this.subscribedTopics.indexOf(topic) < 0) { this.subscribedTopics.push(topic) } } /** 发布消息,retain 仅对设备命令慎用 */ publish(topic: string, payload: string, qos: number = 0, retain: boolean = false): void { if (!this.connected) { this.emitError('not connected, cannot publish') return } const flags = ((qos & 0x03) << 1) | (retain ? 0x01 : 0x00) const body = this.join([this.encodeStr(topic), this.utf8Encode(payload)]) const pkt = this.buildPacket(3, body) pkt[0] = (pkt[0] & 0xf0) | flags this.send(pkt) } // ------------------------------------------------------------------ // 私有:WebSocket 生命周期 // ------------------------------------------------------------------ private handleOpen(): void { // 打开后立刻发送 MQTT CONNECT(1) const body = this.buildConnect() this.send(this.buildPacket(1, body)) } private handleMessage(data: string | ArrayBuffer): void { let u8: Uint8Array if (data instanceof ArrayBuffer) { u8 = new Uint8Array(data) } else { u8 = this.utf8Encode(data) } this.rxBuf = this.join([this.rxBuf, u8]) while (this.rxBuf.length > 0) { const pkt = this.parsePacket(this.rxBuf) if (pkt == null) { break } // 移除已消费字节 const restLen = this.rxBuf.length - pkt.length const rest = new Uint8Array(restLen) for (let i = 0; i < restLen; i++) { rest[i] = this.rxBuf[pkt.length + i] } this.rxBuf = rest this.handleCmd(pkt.cmd, pkt.body) } } private handleClose(code: number): void { this.connected = false this.stopPing() this.task = null if (this.onClose != null) { this.onClose(code) } if (!this.closedByUser && this.opt.autoReconnect === true) { this.scheduleReconnect() } } private scheduleReconnect(): void { if (this.reconnectTimer != null) { return } this.consoleLog('reconnect in ' + this.retryMs + 'ms') this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect() }, this.retryMs) this.retryMs = Math.min(this.retryMs * 2, 30000) } private startPing(): void { this.stopPing() const ka = this.opt.keepalive != null ? this.opt.keepalive : 10 const period = Math.max(10, Math.floor(ka / 2)) * 1000 this.pingTimer = setInterval(() => { if (this.connected) { // PINGREQ(12) this.send(this.buildPacket(12, new Uint8Array(0))) } }, period) } private stopPing(): void { if (this.pingTimer != null) { clearInterval(this.pingTimer) this.pingTimer = null } } // ------------------------------------------------------------------ // 私有:MQTT 报文 // ------------------------------------------------------------------ private buildConnect(): Uint8Array { const vh: Uint8Array[] = [] // 协议名 "MQTT" vh.push(new Uint8Array([0x00, 0x04])) vh.push(this.utf8Encode('MQTT')) // 协议级别 4(MQTT 3.1.1) vh.push(new Uint8Array([0x04])) // 连接标志 const user = this.opt.username != null ? this.opt.username : '' const pass = this.opt.password != null ? this.opt.password : '' let flags = this.opt.cleanSession === true ? 0x02 : 0x00 if (user.length > 0) { flags |= 0x80 } if (pass.length > 0) { flags |= 0x40 } const ka = this.opt.keepalive != null ? this.opt.keepalive : 10 vh.push(new Uint8Array([flags, (ka >> 8) & 0xff, ka & 0xff])) // Payload:clientId + username + password vh.push(this.encodeStr(this.opt.clientId)) if (user.length > 0) { vh.push(this.encodeStr(user)) } if (pass.length > 0) { vh.push(this.encodeStr(pass)) } return this.join(vh) } private sendSubscribe(topic: string, qos: number): void { const mid = Math.floor(Math.random() * 65535) + 1 const body = this.join([ new Uint8Array([(mid >> 8) & 0xff, mid & 0xff]), this.encodeStr(topic), new Uint8Array([qos & 0x03]) ]) const pkt = this.buildPacket(8, body) this.subPendMid.push(mid) this.subPendTopic.push(topic) // SUBSCRIBE 固定头固定为 0x82 pkt[0] = 0x82 this.send(pkt) } private handleCmd(cmd: number, body: Uint8Array): void { if (cmd === 2) { // CONNACK const code = body.length >= 2 ? body[1] : -1 if (code === 0) { this.connected = true this.retryMs = 1000 // 恢复订阅 for (let i = 0; i < this.subscribedTopics.length; i++) { this.sendSubscribe(this.subscribedTopics[i], 0) } this.startPing() if (this.onConnAck != null) { this.onConnAck(0) } if (this.onOpen != null) { this.onOpen() } } else { this.connected = false this.emitError('CONNACK reject code=' + code) if (this.onConnAck != null) { this.onConnAck(code) } this.disconnect() } } else if (cmd === 3) { // PUBLISH const p = this.parsePublish(body) if (p != null && this.onMessage != null) { this.onMessage(p.topic, p.payload) } } else if (cmd === 9) { // SUBACK:body = [packetIdHi, packetIdLo, code...] this.parseSubAck(body) } // PINGRESP(13) / PUBACK(4) 联调场景忽略 } /** 解析 SUBACK,把 code 对回到主题:128(0x80)=服务端拒绝订阅(ACL 未放行 / 非本人设备) */ private parseSubAck(body: Uint8Array): void { if (body.length < 3) { return } const mid = (body[0] << 8) | body[1] const code = body[2] let idx = -1 for (let i = 0; i < this.subPendMid.length; i++) { if (this.subPendMid[i] === mid) { idx = i break } } if (idx < 0) { return } const topic = this.subPendTopic[idx] this.subPendMid.splice(idx, 1) this.subPendTopic.splice(idx, 1) // 注意:订阅被拒**不是**连接错误,不走 emitError,否则调用方会把连接标记为失败 if (this.onSubAck != null) { this.onSubAck(topic, code) } } private parsePublish(body: Uint8Array): PublishInfo | null { if (body.length < 2) { return null } const tlen = (body[0] << 8) | body[1] if (body.length < 2 + tlen) { return null } const topic = this.utf8Decode(this.sliceU8(body, 2, tlen)) const payload = this.utf8Decode(this.sliceU8(body, 2 + tlen, body.length - 2 - tlen)) return { topic: topic, payload: payload } } private parsePacket(buf: Uint8Array): Packet | null { if (buf.length < 2) { return null } const cmd = (buf[0] >> 4) & 0x0f let multiplier = 1 let remLen = 0 let idx = 1 let more = true while (more) { if (idx >= buf.length) { return null } const b = buf[idx] remLen += (b & 0x7f) * multiplier idx += 1 multiplier *= 128 more = (b & 0x80) !== 0 if (multiplier > 128 * 128 * 128 * 128) { return null } } const total = idx + remLen if (buf.length < total) { return null } const body = this.sliceU8(buf, idx, remLen) return { cmd: cmd, length: total, body: body } } private buildPacket(cmd: number, body: Uint8Array): Uint8Array { let len = body.length const rem: number[] = [] do { let b = len % 128 len = Math.floor(len / 128) if (len > 0) { b |= 0x80 } rem.push(b) } while (len > 0) const header = new Uint8Array(rem.length + 1) header[0] = (cmd << 4) & 0xff for (let i = 0; i < rem.length; i++) { header[i + 1] = rem[i] } return this.join([header, body]) } private encodeStr(str: string): Uint8Array { const u8 = this.utf8Encode(str) const out = new Uint8Array(u8.length + 2) out[0] = (u8.length >> 8) & 0xff out[1] = u8.length & 0xff out.set(u8, 2) return out } private send(data: Uint8Array): void { if (this.task == null) { return } const ab = new ArrayBuffer(data.length) const copy = new Uint8Array(ab) copy.set(data, 0) this.task.send({ data: ab }) } // ------------------------------------------------------------------ // 私有:字节工具(不依赖 TextEncoder,保证 uni-app x 兼容) // ------------------------------------------------------------------ private join(parts: Uint8Array[]): Uint8Array { let total = 0 for (let i = 0; i < parts.length; i++) { total += parts[i].length } const out = new Uint8Array(total) let off = 0 for (let i = 0; i < parts.length; i++) { out.set(parts[i], off) off += parts[i].length } return out } private sliceU8(buf: Uint8Array, start: number, len: number): Uint8Array { const out = new Uint8Array(len) for (let i = 0; i < len; i++) { out[i] = buf[start + i] } return out } private utf8Encode(str: string): Uint8Array { const enc = encodeURIComponent(str) const bytes: number[] = [] let i = 0 while (i < enc.length) { const c = enc.charAt(i) if (c === '%') { bytes.push(parseInt(enc.substring(i + 1, i + 3), 16)) i += 3 } else { bytes.push(c.charCodeAt(0)) i += 1 } } const out = new Uint8Array(bytes.length) for (let j = 0; j < bytes.length; j++) { out[j] = bytes[j] } return out } private utf8Decode(bytes: Uint8Array): string { let s = '' let i = 0 while (i < bytes.length) { const b = bytes[i] if (b < 0x80) { s += String.fromCharCode(b) i += 1 } else if (b >= 0xc0 && b < 0xe0 && i + 1 < bytes.length) { s += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f)) i += 2 } else if (b >= 0xe0 && b < 0xf0 && i + 2 < bytes.length) { s += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f)) i += 3 } else { i += 1 } } return s } private emitError(msg: string): void { this.consoleLog('ERROR ' + msg) if (this.onError != null) { this.onError(msg) } } private consoleLog(msg: string): void { console.log('[Mqtt] ' + msg) } }