homebridge-roborock-vacuum-update
Version:
Comprehensive Homebridge plugin for Roborock vacuum cleaners with full HomeKit integration including mopping, dock features, and advanced controls.
294 lines (244 loc) • 9.07 kB
JavaScript
"use strict";
const crypto = require("crypto");
const Parser = require("binary-parser").Parser;
const net = require("net");
const dgram = require("dgram");
const server = dgram.createSocket("udp4");
const PORT = 58866;
const TIMEOUT = 5000; // 5 Sekunden Timeout
const BROADCAST_TOKEN = Buffer.from("qWKYcdQWrbm9hPqe", "utf8");
class EnhancedSocket extends net.Socket {
constructor(options) {
super(options);
this.connected = false;
this.chunkBuffer = Buffer.alloc(0);
this.on("connect", () => {
this.connected = true;
});
this.on("close", () => {
this.connected = false;
});
this.on("error", () => {
this.connected = false;
});
this.on("end", () => {
this.connected = false;
});
}
}
const localMessageParser = new Parser()
.endianess("big")
.string("version", {
length: 3,
})
.uint32("seq")
.uint16("protocol")
.uint16("payloadLen")
.buffer("payload", {
length: "payloadLen",
})
.uint32("crc32");
class localConnector {
constructor(adapter) {
this.adapter = adapter;
this.localClients = {};
}
async createClient(duid, ip) {
const client = new EnhancedSocket();
// Wrap the connect method in a promise to await its completion
await new Promise((resolve, reject) => {
client
.connect(58867, ip, () => {
this.adapter.log.debug(`tcp client for ${duid} connected`);
resolve();
})
.on("error", (error) => {
this.adapter.log.debug(`error on tcp client for ${duid}. ${error.message}`);
reject(error);
});
}).catch((error) => {
const online = this.adapter.onlineChecker(duid);
if (online) { // if the device is online, we can assume that the device is a remote device
this.adapter.log.info(`error on tcp client for ${duid}. Marking this device as remote device. Connecting via MQTT instead ${error.message}`);
this.adapter.remoteDevices.add(duid);
// this.adapter.catchError(`Failed to create tcp client: ${error.stack}`, `function createClient`, duid);
}
});
client.on("data", async (message) => {
try {
if (client.chunkBuffer.length == 0) {
this.adapter.log.debug(`new chunk started`);
client.chunkBuffer = message;
} else {
this.adapter.log.debug(`new chunk received`);
client.chunkBuffer = Buffer.concat([client.chunkBuffer, message]);
}
// this.adapter.log.debug(`new chunk received: ${message.toString("hex")}`);
let offset = 0;
if (this.checkComplete(client.chunkBuffer)) {
this.adapter.log.debug(`Chunk buffer data is complete. Processing...`);
// this.adapter.log.debug(`chunkBuffer: ${client.chunkBuffer.toString("hex")}`);
while (offset + 4 <= client.chunkBuffer.length) {
const segmentLength = client.chunkBuffer.readUInt32BE(offset);
// length of 17 does not contain any useful data.
// The parser for this looks like this: const shortMessageParser = new Parser().endianess("big").string("version", {length: 3,}).uint32("seq").uint32("random").uint32("timestamp").uint16("protocol")
if (segmentLength != 17) {
const currentBuffer = client.chunkBuffer.subarray(offset + 4, offset + segmentLength + 4);
const data = this.adapter.message._decodeMsg(currentBuffer, duid);
if (data.protocol == 4) {
const dps = JSON.parse(data.payload).dps;
if (dps) {
const _102 = JSON.stringify(dps["102"]);
const parsed_102 = JSON.parse(JSON.parse(_102));
const id = parsed_102.id;
const result = parsed_102.result;
if (this.adapter.pendingRequests.has(id)) {
this.adapter.log.debug(`Local message with protocol 4 and id ${id} received. Result: ${JSON.stringify(result)}`);
const { resolve, timeout } = this.adapter.pendingRequests.get(id);
this.adapter.clearTimeout(timeout);
this.adapter.pendingRequests.delete(id);
resolve(result);
if(this.adapter.deviceNotify !== undefined){
this.adapter.deviceNotify("LocalMessage", result);
}
}
}
}
}
offset += 4 + segmentLength;
}
this.clearChunkBuffer(duid);
}
} catch (error) {
this.adapter.catchError(`Failed to create tcp client: ${error.stack}`, `function createClient`, duid);
}
});
client.on("close", () => {
this.adapter.log.debug(`tcp client for ${duid} disconnected, attempting to reconnect...`);
setTimeout(async () => {
await this.createClient(duid, ip);
}, 60000);
client.connected = false;
});
client.on("error", (error) => {
this.adapter.log.debug(`error on tcp client for ${duid}. ${error.message}`);
});
this.localClients[duid] = client;
}
checkComplete(buffer) {
let totalLength = 0;
let offset = 0;
while (offset + 4 <= buffer.length) {
const segmentLength = buffer.readUInt32BE(offset);
totalLength += 4 + segmentLength;
offset += 4 + segmentLength;
if (offset > buffer.length) {
return false; // Data is not complete yet
}
}
return totalLength <= buffer.length;
}
clearChunkBuffer(duid) {
if (this.localClients[duid]) {
this.localClients[duid].chunkBuffer = Buffer.alloc(0);
}
}
sendMessage(duid, message) {
const client = this.localClients[duid];
if (client) {
client.write(message);
}
}
isConnected(duid) {
if (this.localClients[duid]) {
return this.localClients[duid].connected;
}
}
async getLocalDevices() {
return new Promise((resolve, reject) => {
const devices = {};
server.on("message", (msg) => {
const parsedMessage = localMessageParser.parse(msg);
const decodedMessage = this.decryptECB(parsedMessage.payload, BROADCAST_TOKEN); // this might be decryptCBC for A01. Haven't checked this yet
if(decodedMessage == null){
this.adapter.log.debug(`getLocalDevices: decodedMessage is null`);
return;
}
const parsedDecodedMessage = JSON.parse(decodedMessage);
this.adapter.log.debug(`getLocalDevices parsedDecodedMessage: ${JSON.stringify(parsedDecodedMessage)}`);
if (parsedDecodedMessage) {
const localKey = this.adapter.localKeys.get(parsedDecodedMessage.duid);
this.adapter.log.debug(`getLocalDevices localKey: ${localKey}`);
if (localKey) {
// if there's no localKey, decryption cannot work. For example when the found robot is not associated with a roborock account
if (!devices[parsedDecodedMessage.duid]) {
devices[parsedDecodedMessage.duid] = parsedDecodedMessage.ip;
}
}
}
});
server.on("error", (error) => {
this.adapter.catchError(`Discover server error: ${error.stack}`);
server.close();
reject(error);
});
server.bind(PORT);
this.localDevicesTimeout = this.adapter.setTimeout(() => {
server.close();
resolve(devices);
}, TIMEOUT);
});
}
safeRemovePkcs7(buf) {
if (!buf || buf.length === 0) return Buffer.alloc(0);
const pad = buf[buf.length - 1];
// 僅在 1..16 且最後 pad 個 byte 都等於 pad 時才移除
if (pad > 0 && pad <= 16) {
for (let i = 0; i < pad; i++) {
if (buf[buf.length - 1 - i] !== pad) return buf; // padding 形狀不對,視為無 padding
}
return buf.slice(0, buf.length - pad);
}
return buf; // 看起來沒有標準 PKCS#7 padding
}
decryptECB(encrypted, aesKey) {
// --- 1) Key/輸入檢查 ---
const key = Buffer.isBuffer(aesKey) ? aesKey : Buffer.from(aesKey);
if (key.length !== 16) {
// AES-128 需要 16 bytes 的 key
return null;
}
const input = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted, "latin1"); // "binary" 等同 latin1
if (input.length === 0 || (input.length % 16) !== 0) {
// 密文長度不是 16 的倍數,多半是封包不完整;丟回 null 讓上層忽略本次
return null;
}
try {
// --- 2) 固定用 Buffer,關閉自動 padding(你要自己移除) ---
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
decipher.setAutoPadding(false);
const decryptedBuf = Buffer.concat([decipher.update(input), decipher.final()]);
const unpadded = safeRemovePkcs7(decryptedBuf);
// 若原協定內容是 UTF-8,這裡再轉字串;否則直接回傳 Buffer 讓上層處理
return unpadded.toString("utf8");
} catch (err) {
// 例如 wrong final block length、key 不對等情況
// 這裡不要讓程式炸掉,直接忽略這個封包
// 你也可以在這裡做一次 debug log
// console.debug("decryptECB error:", err);
return null;
}
}
removePadding(str) {
const paddingLength = str.charCodeAt(str.length - 1);
return str.slice(0, -paddingLength);
}
clearLocalDevicedTimeout() {
if (this.localDevicesTimeout) {
this.adapter.clearTimeout(this.localDevicesTimeout);
}
}
}
module.exports = {
localConnector,
};