mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-08-06 02:47:25 +08:00
380 lines
9.3 KiB
JavaScript
380 lines
9.3 KiB
JavaScript
import api from "@/config/api.js";
|
|
|
|
function normalizeSocketUrl(url = "") {
|
|
return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
}
|
|
|
|
const mqttEndpoint = normalizeSocketUrl(api.mqtt);
|
|
|
|
function stringToUtf8Bytes(str = "") {
|
|
const encoded = encodeURIComponent(String(str));
|
|
const bytes = [];
|
|
for (let i = 0; i < encoded.length; i++) {
|
|
if (encoded[i] === "%") {
|
|
bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16));
|
|
i += 2;
|
|
} else {
|
|
bytes.push(encoded.charCodeAt(i));
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function utf8BytesToString(bytes) {
|
|
let encoded = "";
|
|
bytes.forEach((byte) => {
|
|
if (byte < 0x80) {
|
|
encoded += String.fromCharCode(byte);
|
|
} else {
|
|
encoded += `%${byte.toString(16).padStart(2, "0")}`;
|
|
}
|
|
});
|
|
try {
|
|
return decodeURIComponent(encoded);
|
|
} catch (e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function encodeString(str) {
|
|
const bytes = stringToUtf8Bytes(str);
|
|
return [(bytes.length >> 8) & 0xff, bytes.length & 0xff, ...bytes];
|
|
}
|
|
|
|
function encodeRemainingLength(length) {
|
|
const bytes = [];
|
|
do {
|
|
let digit = length % 128;
|
|
length = Math.floor(length / 128);
|
|
if (length > 0) digit |= 0x80;
|
|
bytes.push(digit);
|
|
} while (length > 0);
|
|
return bytes;
|
|
}
|
|
|
|
function toArrayBuffer(bytes) {
|
|
return new Uint8Array(bytes).buffer;
|
|
}
|
|
|
|
function decodeRemainingLength(bytes, offset = 1) {
|
|
let multiplier = 1;
|
|
let value = 0;
|
|
let index = offset;
|
|
let digit = 0;
|
|
do {
|
|
digit = bytes[index++];
|
|
value += (digit & 127) * multiplier;
|
|
multiplier *= 128;
|
|
} while ((digit & 128) !== 0 && index < bytes.length);
|
|
return { value, bytesUsed: index - offset, nextIndex: index };
|
|
}
|
|
|
|
class MqttClient {
|
|
constructor(options = {}) {
|
|
const defaultOptions = {
|
|
endpoint: mqttEndpoint,
|
|
username: "admin",
|
|
password: "hivemq",
|
|
keepalive: 30,
|
|
clean: true,
|
|
connectTimeout: 20000,
|
|
topicHandlers: [],
|
|
};
|
|
|
|
this.options = { ...defaultOptions, ...options };
|
|
this.socketTask = null;
|
|
this.connected = false;
|
|
this.connecting = false;
|
|
this.manualDisconnect = false;
|
|
this.packetId = 1;
|
|
this.topics = [];
|
|
this.pingTimer = null;
|
|
this.connectTimeoutTimer = null;
|
|
|
|
this.onConnectCallback = null;
|
|
this.onErrorCallback = null;
|
|
this.onCloseCallback = null;
|
|
this.onOfflineCallback = null;
|
|
this.onMessageCallback = null;
|
|
}
|
|
|
|
connect() {
|
|
if (!this.options.endpoint) {
|
|
this.onError(new Error("MQTT 地址未配置,请检查 config/api.js 的 mqtt 字段"));
|
|
return this;
|
|
}
|
|
|
|
this.disconnect();
|
|
this.manualDisconnect = false;
|
|
this.connecting = true;
|
|
|
|
console.log("[MQTT-MP] 开始连接:", this.options.endpoint);
|
|
this.socketTask = uni.connectSocket({
|
|
url: this.options.endpoint,
|
|
protocols: ["mqtt"],
|
|
success: () => {},
|
|
fail: (error) => this.onError(error),
|
|
});
|
|
|
|
this.socketTask.onOpen(() => {
|
|
this.sendConnectPacket();
|
|
this.connectTimeoutTimer = setTimeout(() => {
|
|
if (!this.connected) {
|
|
this.onError(new Error("连接超时"));
|
|
this.disconnect();
|
|
}
|
|
}, this.options.connectTimeout);
|
|
});
|
|
|
|
this.socketTask.onMessage((event) => {
|
|
this.handlePacket(event.data);
|
|
});
|
|
|
|
this.socketTask.onError((error) => {
|
|
this.onError(error);
|
|
});
|
|
|
|
this.socketTask.onClose(() => {
|
|
this.clearTimers();
|
|
this.connected = false;
|
|
this.connecting = false;
|
|
if (!this.manualDisconnect) {
|
|
this.onCloseCallback?.();
|
|
}
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
sendConnectPacket() {
|
|
const variableHeader = [
|
|
...encodeString("MQTT"),
|
|
0x04,
|
|
(this.options.username ? 0x80 : 0) |
|
|
(this.options.password ? 0x40 : 0) |
|
|
(this.options.clean ? 0x02 : 0),
|
|
(this.options.keepalive >> 8) & 0xff,
|
|
this.options.keepalive & 0xff,
|
|
];
|
|
const payload = [
|
|
...encodeString(this.options.clientId),
|
|
...(this.options.username ? encodeString(this.options.username) : []),
|
|
...(this.options.password ? encodeString(this.options.password) : []),
|
|
];
|
|
const body = [...variableHeader, ...payload];
|
|
this.sendBytes([0x10, ...encodeRemainingLength(body.length), ...body]);
|
|
}
|
|
|
|
subscribeToTopics() {
|
|
this.options.topicHandlers.forEach(({ topic, qos = 0, handler }) => {
|
|
this.subscribe(topic, { qos }, handler);
|
|
});
|
|
}
|
|
|
|
subscribe(topic, options = { qos: 0 }, handler) {
|
|
if (!this.isClientValid()) {
|
|
console.warn("[MQTT-MP] 客户端无效,无法订阅");
|
|
return false;
|
|
}
|
|
|
|
const qos = options.qos || 0;
|
|
const packetId = this.nextPacketId();
|
|
const payload = [...encodeString(topic), qos];
|
|
const variableHeader = [(packetId >> 8) & 0xff, packetId & 0xff];
|
|
const body = [...variableHeader, ...payload];
|
|
this.sendBytes([0x82, ...encodeRemainingLength(body.length), ...body]);
|
|
|
|
const idx = this.topics.findIndex((item) => item.topic === topic);
|
|
if (idx >= 0) {
|
|
this.topics[idx] = { topic, qos, handler };
|
|
} else {
|
|
this.topics.push({ topic, qos, handler });
|
|
}
|
|
console.log(`[MQTT-MP] 订阅发送: ${topic}`);
|
|
return true;
|
|
}
|
|
|
|
sendBytes(bytes) {
|
|
if (!this.socketTask) return;
|
|
this.socketTask.send({
|
|
data: toArrayBuffer(bytes),
|
|
fail: (error) => this.onError(error),
|
|
});
|
|
}
|
|
|
|
handlePacket(data) {
|
|
const bytes = new Uint8Array(data);
|
|
const packetType = bytes[0] >> 4;
|
|
const flags = bytes[0] & 0x0f;
|
|
const remaining = decodeRemainingLength(bytes);
|
|
let index = remaining.nextIndex;
|
|
|
|
if (packetType === 2) {
|
|
const returnCode = bytes[index + 1];
|
|
if (returnCode === 0) {
|
|
this.connected = true;
|
|
this.connecting = false;
|
|
this.clearConnectTimeout();
|
|
this.startPing();
|
|
this.subscribeToTopics();
|
|
this.onConnectCallback?.();
|
|
} else {
|
|
this.onError(new Error(`CONNACK 失败: ${returnCode}`));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (packetType === 3) {
|
|
const topicLength = (bytes[index] << 8) + bytes[index + 1];
|
|
index += 2;
|
|
const topic = utf8BytesToString(Array.from(bytes.slice(index, index + topicLength)));
|
|
index += topicLength;
|
|
|
|
const qos = (flags >> 1) & 0x03;
|
|
let packetId = 0;
|
|
if (qos > 0) {
|
|
packetId = (bytes[index] << 8) + bytes[index + 1];
|
|
index += 2;
|
|
}
|
|
|
|
const payloadEnd = remaining.nextIndex + remaining.value;
|
|
const message = utf8BytesToString(Array.from(bytes.slice(index, payloadEnd)));
|
|
this.dispatchMessage(topic, message);
|
|
|
|
if (qos === 1 && packetId) {
|
|
this.sendBytes([0x40, 0x02, (packetId >> 8) & 0xff, packetId & 0xff]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (packetType === 9) {
|
|
console.log("[MQTT-MP] 订阅确认");
|
|
}
|
|
}
|
|
|
|
dispatchMessage(topic, message) {
|
|
this.options.topicHandlers
|
|
.filter((handler) => handler.topic === topic || this.matchTopicPattern(handler.topic, topic))
|
|
.forEach((handler) => {
|
|
try {
|
|
handler.handler(message, topic);
|
|
} catch (error) {
|
|
console.error(`[MQTT-MP] 处理主题 ${topic} 消息失败:`, error);
|
|
}
|
|
});
|
|
this.onMessageCallback?.(topic, message);
|
|
}
|
|
|
|
matchTopicPattern(pattern, topic) {
|
|
const regex = new RegExp(
|
|
`^${pattern
|
|
.replace(/\+/g, "[^/]+")
|
|
.replace(/#$/, ".*")
|
|
.replace(/\//g, "\\/")}$`
|
|
);
|
|
return regex.test(topic);
|
|
}
|
|
|
|
nextPacketId() {
|
|
this.packetId += 1;
|
|
if (this.packetId > 65535) this.packetId = 1;
|
|
return this.packetId;
|
|
}
|
|
|
|
startPing() {
|
|
this.stopPing();
|
|
this.pingTimer = setInterval(() => {
|
|
if (this.connected) {
|
|
this.sendBytes([0xc0, 0x00]);
|
|
}
|
|
}, Math.max(10000, (this.options.keepalive * 1000) / 2));
|
|
}
|
|
|
|
stopPing() {
|
|
if (this.pingTimer) {
|
|
clearInterval(this.pingTimer);
|
|
this.pingTimer = null;
|
|
}
|
|
}
|
|
|
|
clearConnectTimeout() {
|
|
if (this.connectTimeoutTimer) {
|
|
clearTimeout(this.connectTimeoutTimer);
|
|
this.connectTimeoutTimer = null;
|
|
}
|
|
}
|
|
|
|
clearTimers() {
|
|
this.stopPing();
|
|
this.clearConnectTimeout();
|
|
}
|
|
|
|
disconnect() {
|
|
this.manualDisconnect = true;
|
|
this.clearTimers();
|
|
|
|
if (this.socketTask) {
|
|
try {
|
|
if (this.connected) {
|
|
this.sendBytes([0xe0, 0x00]);
|
|
}
|
|
this.socketTask.close({});
|
|
} catch (error) {
|
|
console.warn("[MQTT-MP] 断开连接异常:", error);
|
|
}
|
|
}
|
|
|
|
this.socketTask = null;
|
|
this.connected = false;
|
|
this.connecting = false;
|
|
this.topics = [];
|
|
return this;
|
|
}
|
|
|
|
onError(error) {
|
|
console.error("[MQTT-MP] 错误:", error?.message || error);
|
|
this.connecting = false;
|
|
this.onErrorCallback?.(error);
|
|
}
|
|
|
|
isClientValid() {
|
|
return this.socketTask && this.connected;
|
|
}
|
|
|
|
onConnected(callback) {
|
|
this.onConnectCallback = callback;
|
|
return this;
|
|
}
|
|
|
|
onErrorOccurred(callback) {
|
|
this.onErrorCallback = callback;
|
|
return this;
|
|
}
|
|
|
|
onClosed(callback) {
|
|
this.onCloseCallback = callback;
|
|
return this;
|
|
}
|
|
|
|
onClientOffline(callback) {
|
|
this.onOfflineCallback = callback;
|
|
return this;
|
|
}
|
|
|
|
isConnected() {
|
|
return this.connected;
|
|
}
|
|
}
|
|
|
|
let mqttInstance = null;
|
|
|
|
export function createMqttClient(options = {}) {
|
|
if (mqttInstance && mqttInstance.isConnected()) {
|
|
mqttInstance.disconnect();
|
|
}
|
|
mqttInstance = new MqttClient(options);
|
|
return mqttInstance;
|
|
}
|
|
|
|
export { MqttClient };
|