matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
215 lines • 9.79 kB
JavaScript
import { debugStringify } from 'matterbridge/logger';
import mqtt from 'mqtt';
import { KEEPALIVE_INTERVAL_MS } from '../../constants/timeouts.js';
import * as CryptoUtils from '../helper/cryptoHelper.js';
import { AbstractClient } from '../routing/abstractClient.js';
export class MQTTClient extends AbstractClient {
clientName = 'MQTTClient';
rriot;
mqttUsername;
mqttPassword;
mqttClient = undefined;
keepConnectionAliveInterval = undefined;
connected = false;
isForceReconnecting = false;
consecutiveAuthErrors = 0;
authErrorBackoffTimeout = undefined;
constructor(logger, context, userdata, responseBroadcaster, responseTracker) {
super(logger, context, responseBroadcaster, responseTracker);
this.rriot = userdata.rriot;
this.mqttUsername = CryptoUtils.md5hex(`${userdata.rriot.u}:${userdata.rriot.k}`).substring(2, 10);
this.mqttPassword = CryptoUtils.md5hex(`${userdata.rriot.s}:${userdata.rriot.k}`).substring(16);
this.initializeConnectionStateListener(this);
}
isConnected() {
return this.connected;
}
isReady() {
return this.connected && this.mqttClient !== undefined;
}
connect() {
if (this.mqttClient) {
return; // Already connected
}
super.connect();
this.mqttClient = mqtt.connect(this.rriot.r.m, {
clientId: this.mqttUsername,
username: this.mqttUsername,
password: this.mqttPassword,
keepalive: 30,
log: () => {
// ...args: unknown[] this.logger.debug(`MQTTClient args: ${debugStringify(args)}`);
},
});
this.mqttClient.on('connect', this.onConnect.bind(this));
this.mqttClient.on('error', this.onError.bind(this));
this.mqttClient.on('reconnect', this.onReconnect.bind(this));
this.mqttClient.on('close', this.onClose.bind(this));
this.mqttClient.on('disconnect', this.onDisconnect.bind(this));
this.mqttClient.on('offline', this.onOffline.bind(this));
this.mqttClient.on('message', this.onMessage.bind(this));
this.keepConnectionAlive();
}
async disconnect() {
await super.disconnect();
if (!this.mqttClient || !this.connected) {
return Promise.resolve();
}
try {
if (this.keepConnectionAliveInterval) {
clearInterval(this.keepConnectionAliveInterval);
this.keepConnectionAliveInterval = undefined;
}
if (this.authErrorBackoffTimeout) {
clearTimeout(this.authErrorBackoffTimeout);
this.authErrorBackoffTimeout = undefined;
}
this.mqttClient.end();
this.mqttClient = undefined;
this.connected = false;
}
catch (error) {
this.logger.error(`[MQTTClient] client failed to disconnect with error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
}
}
async sendInternal(duid, request) {
if (!this.mqttClient || !this.connected) {
this.logger.error(`${duid}: mqtt is not available, ${debugStringify(request)}`);
return;
}
const mqttRequest = request.toMqttRequest();
mqttRequest.version = mqttRequest.version ?? this.context.getMQTTProtocolVersion(duid);
const message = this.serializer.serialize(duid, mqttRequest);
const topic = `rr/m/i/${this.rriot.u}/${this.mqttUsername}/${duid}`;
this.logger.debug(`[MQTTClient] sending message to ${topic}: ${debugStringify(mqttRequest)}`);
this.mqttClient.publish(topic, message.buffer, { qos: 1 });
this.logger.debug(`[MQTTClient] sent message to ${duid}`);
}
keepConnectionAlive() {
if (this.keepConnectionAliveInterval) {
clearInterval(this.keepConnectionAliveInterval);
this.keepConnectionAliveInterval.unref();
}
// Always do a reconnect because the mqtt sometimes does not response any more
this.keepConnectionAliveInterval = setInterval(() => {
if (this.mqttClient) {
this.logger.debug('[MQTTClient] Force reconnecting to ensure fresh connection');
this.isForceReconnecting = true;
this.mqttClient.end();
this.mqttClient.reconnect();
}
else {
this.logger.info('[MQTTClient] Force reconnecting to ensure fresh connection (new connection)');
this.connect();
}
}, KEEPALIVE_INTERVAL_MS);
}
async onConnect(result) {
if (!result) {
this.logger.error('[MQTTClient] onConnect called with no result');
return;
}
const wasForceReconnecting = this.isForceReconnecting;
this.isForceReconnecting = false;
this.connected = true;
this.consecutiveAuthErrors = 0;
this.logger.info(`[MQTTClient] connected to MQTT broker with result: ${debugStringify(result)}`);
this.subscribeToQueue();
if (!wasForceReconnecting) {
await this.connectionBroadcaster.onConnected(`mqtt-${this.mqttUsername}`);
}
}
subscribeToQueue() {
if (!this.mqttClient || !this.connected) {
this.logger.error('[MQTTClient] cannot subscribe, client not connected');
return;
}
this.mqttClient.subscribe(`rr/m/o/${this.rriot.u}/${this.mqttUsername}/#`, this.onSubscribe.bind(this));
}
async onSubscribe(err, subscription) {
const hasError = err !== null && err !== undefined;
if (hasError) {
this.logger.error(`[MQTTClient] Failed to subscribe: ${String(err)}`);
this.connected = false;
await this.connectionBroadcaster.onDisconnected(`mqtt-${this.mqttUsername}`, `Failed to subscribe to the queue: ${String(err)}`);
return;
}
this.logger.info(`[MQTTClient] Connection subscribed: ${subscription ? debugStringify(subscription) : 'unknown'}`);
}
async onDisconnect() {
this.connected = false;
await this.connectionBroadcaster.onDisconnected(`mqtt-${this.mqttUsername}`, 'Disconnected from MQTT broker');
}
async onError(result) {
// MQTT error code 5 = Connection Refused: Not Authorized (authentication failure)
const isAuthError = 'code' in result && result.code === 5;
const errorMessage = isAuthError ? 'Connection refused: Not authorized' : debugStringify(result);
this.logger.error(`MQTT connection error: ${errorMessage}`);
await this.connectionBroadcaster.onError(`mqtt-${this.mqttUsername}`, `MQTT connection error: ${errorMessage}`);
if (isAuthError) {
this.consecutiveAuthErrors++;
this.logger.warn(`[MQTTClient] Auth error count: ${this.consecutiveAuthErrors}/5`);
if (this.consecutiveAuthErrors >= 5) {
this.logger.error('[MQTTClient] Auth error threshold reached, entering 60-minute backoff');
this.terminateConnection();
// Wait 60 minutes then reconnect
this.authErrorBackoffTimeout = setTimeout(() => {
this.authErrorBackoffTimeout = undefined;
this.consecutiveAuthErrors = 0;
this.logger.info('[MQTTClient] Auth error backoff period ended, attempting reconnection');
this.connect();
}, KEEPALIVE_INTERVAL_MS);
this.authErrorBackoffTimeout.unref();
}
}
}
terminateConnection() {
if (this.keepConnectionAliveInterval) {
clearInterval(this.keepConnectionAliveInterval);
this.keepConnectionAliveInterval = undefined;
}
if (this.authErrorBackoffTimeout) {
clearTimeout(this.authErrorBackoffTimeout);
this.authErrorBackoffTimeout = undefined;
}
if (this.mqttClient) {
this.mqttClient.end(true);
this.mqttClient = undefined;
}
this.connected = false;
}
async onClose() {
if (this.connected && !this.isForceReconnecting) {
await this.connectionBroadcaster.onClose(`mqtt-${this.mqttUsername}`);
}
this.connected = false;
}
async onOffline() {
this.connected = false;
await this.connectionBroadcaster.onOffline(`mqtt-${this.mqttUsername}`);
}
onReconnect() {
// Note: 'reconnect' event fires when MQTT library *starts* a reconnection attempt,
// NOT when it successfully reconnects. The 'connect' event fires on successful reconnection.
// Do NOT call subscribeToQueue() here - it will be called by onConnect() when successful.
this.connectionBroadcaster.onReconnect('mqtt-' + this.mqttUsername, 'Attempting to reconnect to MQTT broker');
}
async onMessage(topic, message) {
if (!message) {
// Ignore empty messages
this.logger.notice(`[MQTTClient] received empty message from topic: ${topic}`);
return;
}
try {
const duid = topic.split('/').slice(-1)[0];
const response = this.deserializer.deserialize(duid, message, 'MQTTClient');
this.responseBroadcaster.tryResolve(response);
await this.responseBroadcaster.onMessage(response);
}
catch (error) {
const errMsg = error instanceof Error ? (error.stack ?? error.message) : String(error);
this.logger.error(`[MQTTClient]: unable to process message ${topic}: ${errMsg}`);
}
}
}
//# sourceMappingURL=mqttClient.js.map