homebridge-roborock-vacuum-update
Version:
Comprehensive Homebridge plugin for Roborock vacuum cleaners with full HomeKit integration including mopping, dock features, and advanced controls.
350 lines (295 loc) • 11.9 kB
JavaScript
;
const mqtt = require("mqtt");
const crypto = require("crypto");
const Parser = require("binary-parser").Parser;
const zlib = require("zlib");
const forge = require("node-forge");
const protocol301Parser = new Parser()
.endianess("little")
.string("endpoint", {
length: 15,
stripNull: true,
})
.uint8("unknown1")
.uint16("id")
.buffer("unknown2", {
length: 6,
});
const photoParser = new Parser()
.endianess("little")
.string("roborock", {
length: 8,
stripNull: true,
})
.uint8("id");
let mqttUser;
let mqttPassword;
let client;
let endpoint;
let rriot;
let photoGzipChunks = [];
let photoChunkID = 0;
class roborock_mqtt_connector {
constructor(adapter) {
this.adapter = adapter;
this.connected = false;
const keypair = forge.pki.rsa.generateKeyPair(2048);
this.keys = {
public: { n: null, e: null },
private: {
n: null,
e: null,
d: null,
p: null,
q: null,
dmp1: null,
dmq1: null,
coeff: null,
},
};
// Convert the keys to the desired format
this.keys.public.n = keypair.publicKey.n.toString(16);
this.keys.public.e = keypair.publicKey.e.toString(16);
this.keys.private.n = keypair.privateKey.n.toString(16);
this.keys.private.e = keypair.privateKey.e.toString(16);
this.keys.private.d = keypair.privateKey.d.toString(16);
this.keys.private.p = keypair.privateKey.p.toString(16);
this.keys.private.q = keypair.privateKey.q.toString(16);
this.keys.private.dmp1 = keypair.privateKey.dP.toString(16);
this.keys.private.dmq1 = keypair.privateKey.dQ.toString(16);
this.keys.private.coeff = keypair.privateKey.qInv.toString(16);
}
async initUser(userdata) {
rriot = userdata.rriot;
endpoint = this.md5bin(rriot.k).subarray(8, 14).toString("base64"); // Could be a random but rather static string. The app generates it on first run.
mqttUser = this.md5hex(rriot.u + ":" + rriot.k).substring(2, 10);
mqttPassword = this.md5hex(rriot.s + ":" + rriot.k).substring(16);
client = mqtt.connect(rriot.r.m, {
clientId: mqttUser,
username: mqttUser,
password: mqttPassword,
keepalive: 30,
});
}
async initMQTT_Subscribe() {
const timeout = setTimeout(async () => {
this.adapter.restart();
}, 30000);
await client.on("connect", (result) => {
if (typeof result != "undefined") {
client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
if (err) {
this.adapter.catchError(`Failed to subscribe to Roborock MQTT Server! Error: ${err}, granted: ${JSON.stringify(granted)}`, `client.on("connect")`);
}
});
clearTimeout(timeout);
this.connected = true;
}
this.adapter.log.debug(`MQTT connection connected ${JSON.stringify(result)}.`);
});
await client.on("error", (result) => {
this.adapter.catchError(`MQTT connection error: ${result}`, `client.on("error")`);
this.connected = false;
});
await client.on("close", () => {
this.adapter.log.info(`MQTT connection close.`);
this.connected = false;
});
await client.on("reconnect", (error) => {
if (error) {
this.adapter.catchError(`Failed to reconnect to MQTT server.`, `mqtt client reconnect`);
} else {
client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
if (err) {
this.adapter.catchError(`Failed to subscribe to Roborock MQTT Server! Error: ${err}, granted: ${JSON.stringify(granted)}`, `client.on("reconnect")`);
}
});
clearTimeout(timeout);
}
this.adapter.log.info(`MQTT connection reconnect.`);
});
await client.on("offline", (result) => {
this.adapter.catchError(`MQTT connection offline: ${result}`, `client.on("offline")`);
this.connected = false;
});
}
async isArray(what) {
return Object.prototype.toString.call(what) === "[object Array]";
}
async initMQTT_Message() {
this.adapter.log.info(`MQTT initialized`);
client.on("message", (topic, message) => {
try {
const duid = topic.split("/").slice(-1)[0];
const data = this.adapter.message._decodeMsg(message, duid);
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)} raw: ${JSON.stringify(mqttMessageParser.parse(message))} message: ${message}`);
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data.toString("hex"))} message: ${message}`);
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)}`);
// this.adapter.log.debug("Protocol: " + data.protocol);
if (data.protocol == 102) {
// sometimes JSON.parse(data.payload).dps["102"] is not a JSON. Check for this!
let dps;
if (typeof JSON.parse(data.payload).dps["102"] != "undefined") {
dps = JSON.parse(JSON.parse(data.payload).dps["102"]);
} else {
dps = JSON.parse(data.payload).dps;
}
if(dps.id !== undefined){
this.adapter.log.debug(`Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`);
this.adapter.setStateAsync("CloudMessage", dps.result);
}
else{
this.adapter.log.debug(`Cloud message with protocol 102 received. Result: ${data.payload}`);
if(this.adapter.deviceNotify !== undefined){
this.adapter.deviceNotify("CloudMessage", JSON.parse(data.payload));
}
}
// special check for secure request like get_map_v1 etc. Don't process if result is OK. Instead wait for the actual response for protocol 301
if (dps.result != "ok") {
if (this.adapter.pendingRequests.has(dps.id)) {
const { resolve, timeout } = this.adapter.pendingRequests.get(dps.id);
this.adapter.clearTimeout(timeout);
this.adapter.pendingRequests.delete(dps.id);
resolve(dps.result);
}
}
// protocol 300 seems to be for get_photo 0 only. get_photo 0 is for large images. 1 is for small images.
} else if (data.protocol == 300) {
if (data.payload.subarray(0, 8) == "ROBOROCK") {
const photoData = photoParser.parse(data.payload);
if (this.adapter.pendingRequests.has(photoData.id)) {
this.adapter.log.debug(`First photo gzip chunk detected!`);
photoGzipChunks.push(data.payload.slice(56));
photoChunkID = photoData.id;
}
}
} else if (data.protocol == 301) {
const data2 = protocol301Parser.parse(data.payload.subarray(0, 24));
if (data.seq == 2 && photoGzipChunks != [] && photoChunkID != 0) {
this.adapter.log.debug(`Second photo gzip chunk detected!`);
photoGzipChunks.push(data.payload);
if (this.adapter.pendingRequests.has(photoChunkID)) {
const { resolve, timeout } = this.adapter.pendingRequests.get(photoChunkID);
this.adapter.clearTimeout(timeout);
this.adapter.pendingRequests.delete(photoChunkID);
const finalPhotoGzip = Buffer.concat(photoGzipChunks);
photoGzipChunks = [];
photoChunkID = 0;
resolve(finalPhotoGzip);
}
} else {
if (data.payload.subarray(0, 8) == "ROBOROCK") {
const photoData = photoParser.parse(data.payload);
this.adapter.log.debug(`Cloud message with protocol 301 and photo id ${photoData.id} received.`);
if (this.adapter.pendingRequests.has(photoData.id)) {
const { resolve, timeout } = this.adapter.pendingRequests.get(photoData.id);
this.adapter.clearTimeout(timeout);
this.adapter.pendingRequests.delete(photoData.id);
this.adapter.log.debug(`Cloud message with protocol 301 and photo id ${photoData.id} received.`);
resolve(data.payload.slice(56));
}
} else if (endpoint.startsWith(data2.endpoint)) {
const iv = Buffer.alloc(16, 0);
const decipher = crypto.createDecipheriv("aes-128-cbc", this.adapter.nonce, iv);
let decrypted = Buffer.concat([decipher.update(data.payload.subarray(24)), decipher.final()]);
decrypted = zlib.gunzipSync(decrypted);
// this.adapter.log.debug("raw 301: " + decrypted);
if (this.adapter.pendingRequests.has(data2.id)) {
const { resolve, timeout } = this.adapter.pendingRequests.get(data2.id);
this.adapter.clearTimeout(timeout);
this.adapter.pendingRequests.delete(data2.id);
// this.adapter.log.debug("protocol 301 OK check: " + JSON.stringify(decrypted));
this.adapter.log.debug(`Cloud message with protocol 301 and id ${data2.id} received.`);
resolve(decrypted);
}
}
}
} else if (data.protocol == 500) { // 500 is for general information
const dataString = data.payload.toString("utf8");
let parsedData;
try {
parsedData = JSON.parse(dataString);
} catch (error) {
// If parsing fails, the data might be corrupted or in an unexpected format
this.adapter.log.warn(`Unable to parse message for ${duid}. Error: ${error.message}. Data: ${dataString}`);
return;
}
// Check if the device is online
if (parsedData.online == false) {
this.adapter.log.info(`Couldn't process message. The device ${duid} is offline.`);
} else if (parsedData.online == true) {
// this.adapter.log.info(`Device ${duid} is online.`);
} else if (
// Check for firmware update information
parsedData.mqttOtaData
) {
const otaStatus = parsedData.mqttOtaData.mqttOtaStatus?.status;
const otaProgress = parsedData.mqttOtaData.mqttOtaProgress?.progress;
if (otaStatus) {
this.adapter.log.info(`Device ${duid} firmware update status: ${otaStatus}`);
}
if (otaProgress !== undefined) {
this.adapter.log.info(`Device ${duid} firmware update progress: ${otaProgress}%`);
}
} else {
// Received an unrecognized message
this.adapter.log.warn(`Received an unrecognized message for ${duid}. Data: ${dataString}`);
}
}
else {
this.adapter.log.debug(`Received message with unknown protocol ${data.protocol} data: ${JSON.stringify(data)}.`);
}
} catch (error) {
this.adapter.log.error(`client.on message: ${error.stack} with topic ${topic} and message ${message.toString("hex")}`);
}
});
}
_encodeTimestamp(timestamp) {
const hex = timestamp.toString(16).padStart(8, "0").split("");
return [5, 6, 3, 7, 1, 2, 0, 4].map((idx) => hex[idx]).join("");
}
getEndpoint() {
return endpoint;
}
sendMessage(duid, roborockMessage) {
client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${duid}`, roborockMessage, { qos: 1 });
}
isConnected() {
return this.connected;
}
async reconnectClient() {
if (client) {
try {
this.adapter.log.info("Reconnecting mqtt client!");
await client.end();
client.reconnect();
} catch (error) {
this.adapter.catchError(`Failed to reconnect with error: ${error}`, `reconnectClient`);
}
}
}
md5hex(str) {
return crypto.createHash("md5").update(str).digest("hex");
}
md5bin(str) {
return crypto.createHash("md5").update(str).digest();
}
decryptWithPrivateKey(privateKeyPem, encryptedData) {
const privateKey = crypto.createPrivateKey({
key: privateKeyPem,
format: "pem",
type: "pkcs8",
});
const decryptedData = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
},
encryptedData
);
return decryptedData;
}
}
module.exports = {
roborock_mqtt_connector,
};