homebridge-roborock-vacuum-update
Version:
Comprehensive Homebridge plugin for Roborock vacuum cleaners with full HomeKit integration including mopping, dock features, and advanced controls.
548 lines (464 loc) • 21.4 kB
JavaScript
"use strict";
const rrMessage = require("./message").message;
const RRMapParser = require("./RRMapParser");
const fs = require("fs");
const zlib = require("zlib");
const mappedCleanSummary = {
0: "clean_time",
1: "clean_area",
2: "clean_count",
3: "records",
};
const mappedCleaningRecordAttribute = {
0: "begin",
1: "end",
2: "duration",
3: "area",
4: "error",
5: "complete",
6: "start_type",
7: "clean_type",
8: "finish_reason",
9: "dust_collection_status",
};
class vacuum {
constructor(adapter, robotModel) {
this.adapter = adapter;
this.adapter.log.debug(`Robot key: ${robotModel}`);
this.robotModel = robotModel;
this.message = new rrMessage(this.adapter);
this.mapParser = new RRMapParser(this.adapter);
this.roomMapping = []; // Store room mapping for this device
this.parameterFolders = {
get_mop_mode: "deviceStatus",
get_water_box_custom_mode: "deviceStatus",
get_network_info: "networkInfo",
get_consumable: "consumables",
get_fw_features: "firmwareFeatures",
get_carpet_mode: "deviceStatus",
get_carpet_clean_mode: "deviceStatus",
get_carpet_cleaning_mode: "deviceStatus",
};
}
async command(duid, parameter, value) {
try {
switch (parameter) {
case "app_segment_clean": {
this.adapter.log.debug("Start room cleaning");
const roomList = {};
roomList.segments = [];
const roomFloor = await this.adapter.getStateAsync(`Devices.${duid}.deviceStatus.map_status`);
const mappedRoomList = await this.adapter.messageQueueHandler.sendRequest(duid, "get_room_mapping", []);
if (mappedRoomList) {
for (const mappedRoom in mappedRoomList) {
const roomState = await this.adapter.getStateAsync(`Devices.${duid}.floors.${roomFloor.val}.${mappedRoomList[mappedRoom][0]}`);
if (roomState.val) {
roomList.segments.push(mappedRoomList[mappedRoom][0]);
}
}
}
const cleanCount = await this.adapter.getStateAsync(`Devices.${duid}.floors.cleanCount`);
roomList["repeat"] = cleanCount.val;
const result = await this.adapter.messageQueueHandler.sendRequest(duid, "app_segment_clean", [roomList]);
this.adapter.log.debug(`app_segment_clean with roomIDs: ${JSON.stringify(roomList)} result: ${result}`);
this.adapter.setStateAsync(`Devices.${duid}.floors.cleanCount`, { val: 1, ack: true });
break;
}
case "reset_consumable":
await this.adapter.messageQueueHandler.sendRequest(duid, parameter, [value]);
this.adapter.log.info(`Consumable ${parameter} successfully reset.`);
break;
case "app_set_dryer_status": {
const result = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, JSON.parse(value));
this.adapter.log.debug(`Command: ${parameter} result: ${result}`);
break;
}
case "app_goto_target":
case "app_zoned_clean": {
const result = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, value);
this.adapter.log.debug(`Command: ${parameter} with value: ${JSON.stringify(value)} result: ${result}`);
break;
}
case "set_water_box_distance_off": {
const mappedValue = ((value - 1) / (30 - 1)) * (60 - 205) + 205;
const parameterValue = { distance_off: mappedValue };
const result = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, parameterValue);
this.adapter.log.debug(`Command: ${parameter} with value: ${JSON.stringify(parameterValue)} result: ${result}`);
break;
}
default:
if (value && typeof value !== "boolean") {
const valueType = typeof value;
if (valueType === "string") {
value = await JSON.parse(value);
} else if (valueType === "number") {
value = [value];
}
// await is important here!!! Wait for the command to finish before sending the request to update deviceConfig!!!
const result = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, value);
this.adapter.log.debug(`Command: ${parameter} with value: ${JSON.stringify(value)} result: ${result}`);
// this is needed to update the states instantly after sending a command
const getCommand = parameter.replace("set", "get");
await this.getParameter(duid, getCommand);
} else {
const result = await this.adapter.messageQueueHandler.sendRequest(duid, parameter);
this.adapter.log.debug(`Command: ${parameter} result: ${result}`);
}
}
} catch (error) {
this.adapter.catchError(error, parameter, duid, this.robotModel);
}
}
async getParameter(duid, parameter, attribute) {
let mode;
try {
if (parameter == "get_network_info") {
mode = parameter;
const networkInfo = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
for (const attribute in networkInfo) {
if (attribute == "ip" && !(await this.adapter.isRemoteDevice(duid))) {
this.adapter.localDevices[duid] = networkInfo[attribute];
}
this.adapter.setStateAsync(`Devices.${duid}.networkInfo.${attribute}`, { val: networkInfo[attribute], ack: true });
}
} else if (parameter == "get_consumable") {
const consumables = (await this.adapter.messageQueueHandler.sendRequest(duid, "get_consumable", []))[0];
for (const consumable in consumables) {
const divider = this.adapter.vacuums[duid].features.getConsumablesDivider(consumable);
if (divider) {
const consumable_val = divider ? Math.round(consumables[consumable] / divider) : consumables[consumable];
this.adapter.setStateAsync(`Devices.${duid}.consumables.${consumable}`, { val: consumable_val, ack: true });
}
}
} else if (parameter == "get_status") {
const now = new Date();
const seconds = now.getSeconds();
if (this.adapter.socket || seconds % this.adapter.config.updateInterval == 0) {
// only send status every minute or if websocket is connected
// const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_status", []);
const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_prop", ["get_status"]);
for (const attribute in deviceStatus[0]) {
const isCleaning = this.adapter.isCleaning(deviceStatus[0]["state"]);
if (!(await this.adapter.getObjectAsync(`Devices.${duid}.deviceStatus.${attribute}`))) {
this.adapter.log.warn(
`Unsupported attribute: ${attribute} of get_status with value ${deviceStatus[0][attribute]}. Please contact the dev to add the newly found attribute of your robot. Model: ${this.robotModel}`
);
continue; // skip unsupported attributes
}
const divider = this.adapter.vacuums[duid].features.getStatusDivider(attribute);
if (divider) {
deviceStatus[0][attribute] = Math.round(deviceStatus[0][attribute] / divider);
}
if (typeof deviceStatus[0][attribute] == "object") {
deviceStatus[0][attribute] = JSON.stringify(deviceStatus[0][attribute]);
}
switch (attribute) {
case "dock_type":
this.adapter.vacuums[duid].features.processDockType(attribute);
break;
case "dss":
await this.adapter.createDockingStationObject(duid);
const dockingStationStatus = await this.parseDockingStationStatus(deviceStatus[0][attribute]);
for (const state in dockingStationStatus) {
this.adapter.setStateAsync(`Devices.${duid}.dockingStationStatus.${state}`, { val: parseInt(dockingStationStatus[state]), ack: true });
}
break;
case "map_status": {
deviceStatus[0][attribute] = deviceStatus[0][attribute] >> 2 ?? -1; // to get the currently selected map perform bitwise right shift
if (isCleaning) {
this.adapter.startMapUpdater(duid);
} else if (!isCleaning) {
this.adapter.stopMapUpdater(duid);
} else {
const mapCount = await this.adapter.getStateAsync(`Devices.${duid}.floors.multi_map_count`);
// don't process load_multi_map for single level configuration
if (mapCount) {
// sometimes mapCount is not available shortly after first start of adapter
if (mapCount.val > 1) {
const currentMap = deviceStatus[0][attribute];
const mapFromCommand = await this.adapter.getState(`Devices.${duid}.commands.load_multi_map`);
if (mapFromCommand && mapFromCommand.val != currentMap) {
await this.adapter.setStateAsync(`Devices.${duid}.commands.load_multi_map`, currentMap, true);
}
}
}
}
break;
}
case "state": {
if (this.adapter.socket) {
const sendValue = { duid: duid, command: "get_status", parameters: { isCleaning: isCleaning } };
this.adapter.socket.send(JSON.stringify(sendValue));
}
break;
}
case "last_clean_t":
deviceStatus[0][attribute] = new Date(deviceStatus[0][attribute]).toString();
break;
}
this.adapter.setStateChangedAsync(`Devices.${duid}.deviceStatus.${attribute}`, { val: deviceStatus[0][attribute], ack: true });
}
this.adapter.manageDeviceIntervals(duid);
}
} else if (parameter == "get_room_mapping") {
const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_status", []);
const roomFloor = deviceStatus[0]["map_status"] >> 2 ?? -1; // to get the currently selected map perform bitwise right shift
const mappedRooms = await this.adapter.messageQueueHandler.sendRequest(duid, "get_room_mapping", []);
// Store room mapping for API access
this.roomMapping = mappedRooms || [];
// if no rooms have been named, processing them can't work
if (mappedRooms.length < 1) {
this.adapter.log.warn(`Failed to map rooms. You need to name your rooms via the mobile app on your phone.`);
} else {
for (const mappedRoom of mappedRooms) {
const roomID = mappedRoom[1];
const roomName = this.adapter.roomIDs[roomID];
if (roomName) {
this.adapter.log.debug(`Mapped room matched: ${roomID} with name: ${roomName}`);
const objectString = `Devices.${duid}.floors.${roomFloor}.${mappedRoom[0]}`;
await this.adapter.createStateObjectHelper(objectString, roomName, "boolean", null, true, "value", true, true);
}
}
}
const objectString = `Devices.${duid}.floors.cleanCount`;
await this.adapter.createStateObjectHelper(objectString, "Clean count", "number", null, 1, "value", true, true);
} else if (parameter == "get_multi_maps_list") {
const mapList = await this.adapter.messageQueueHandler.sendRequest(duid, "get_multi_maps_list", []);
const mapInfo = mapList[0].map_info;
const maps = {};
// Set states for numeric parameters
for (const mapParameter in mapList[0]) {
if (typeof mapList[0][mapParameter] === "number") {
const statePath = `Devices.${duid}.floors.${mapParameter}`;
this.adapter.setStateAsync(statePath, { val: mapList[0][mapParameter], ack: true });
}
}
// Create map folders
for (const map in mapInfo) {
const roomFloor = mapInfo[map]["mapFlag"];
const mapName = mapInfo[map]["name"];
maps[roomFloor] = mapName;
const objectPath = `Devices.${duid}.floors.${roomFloor}`;
this.adapter.setObjectAsync(objectPath, {
type: "folder",
common: {
name: mapName,
},
native: {},
});
}
// Handle the load_multi_map command
const commandPath = `Devices.${duid}.commands.load_multi_map`;
if (mapList[0]["max_multi_map"] > 1) {
await this.adapter.createStateObjectHelper(commandPath, "Load map", "number", null, 0, "value", true, true, maps);
} else {
this.adapter.delObjectAsync(commandPath);
}
} else if (parameter == "get_fw_features") {
const firmwareFeatures = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
for (const firmwareFeature in firmwareFeatures) {
const featureID = firmwareFeatures[firmwareFeature];
const objectString = `Devices.${duid}.firmwareFeatures.${firmwareFeature}`;
await this.adapter.createStateObjectHelper(objectString, featureID.toString(), "string", null, null, "value", true, false);
const featureName = this.adapter.vacuums[duid].features.getFirmwareFeature(featureID);
// this dynamically processes robot features by ID if they are supported
if (typeof this.adapter.vacuums[duid].features[featureName] === "function") {
this.adapter.vacuums[duid].features[featureName]();
}
this.adapter.setStateAsync(objectString, { val: featureName, ack: true });
}
} else if (parameter == "get_server_timer") {
// const serverTimers = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
// if (typeof(attribute_val[0]) == "object") {
// attribute_val[0] = JSON.stringify(attribute_val[0]);
// }
// this.adapter.setStateAsync("Devices." + duid + "." + targetFolder + "." + mode, { val: attribute_val[0], ack: true });
} else if (parameter == "get_timer") {
// const timers = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
// if (typeof(attribute_val[0]) == "object") {
// attribute_val[0] = JSON.stringify(attribute_val[0]);
// }
// this.adapter.setStateAsync("Devices." + duid + "." + targetFolder + "." + mode, { val: attribute_val[0], ack: true });
} else if (parameter == "get_photo") {
const photoresponse = await this.adapter.messageQueueHandler.sendRequest(duid, "get_photo", attribute, true, true);
if (this.isGZIP(photoresponse)) {
this.adapter.log.debug(`gzipped photo found.`);
this.adapter.log.debug(JSON.stringify(photoresponse));
this.unzipBuffer(photoresponse, (error, photoData) => {
if (error) {
this.adapter.catchError(error, "get_photo", duid, this.robotModel);
if (this.adapter.supportsFeature && this.adapter.supportsFeature("PLUGINS")) {
if (this.adapter.sentryInstance) {
this.adapter.sentryInstance.getSentryObject().captureException(`Failed to extract gzip: ${JSON.stringify(error)}`);
}
}
} else {
const extractedPhoto = this.extractPhoto(photoData);
// fs.writeFile("slicedBuffer.jpg", extractedPhoto, (err) => {
// if (err) {
// console.error("Fehler beim Schreiben der Datei:", err);
// } else {
// console.log("Die Datei wurde erfolgreich gespeichert!");
// }
// });
if (extractedPhoto) {
const photo = {};
photo.duid = duid;
photo.command = "get_photo";
photo.image = `data:image/jpeg;base64,${extractedPhoto.toString("base64")}`;
if (this.adapter.socket) {
this.adapter.socket.send(JSON.stringify(photo));
}
}
}
});
}
} else if (
parameter == "get_dust_collection_switch_status" ||
parameter == "get_wash_towel_mode" ||
parameter == "get_smart_wash_params" ||
parameter == "get_dust_collection_mode"
) {
const attribute_val = JSON.stringify(await this.adapter.messageQueueHandler.sendRequest(duid, parameter, {}));
this.adapter.setStateAsync(`Devices.${duid}.commands.${parameter.replace("get", "set")}`, { val: attribute_val, ack: true });
} else if (parameter == "app_get_dryer_setting") {
const attribute_val = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, {});
const actualVal = JSON.stringify({ on: { dry_time: attribute_val.on.dry_time }, status: attribute_val.status });
this.adapter.setStateAsync(`Devices.${duid}.commands.${parameter.replace("get", "set")}`, { val: actualVal, ack: true });
} else if (this.parameterFolders[parameter]) {
mode = parameter.substring(4);
const attribute_val = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
if (typeof attribute_val[0] == "object") {
attribute_val[0] = JSON.stringify(attribute_val[0]);
}
const targetFolder = this.parameterFolders[parameter];
this.adapter.setStateAsync(`Devices.${duid}.${targetFolder}.${mode}`, { val: attribute_val[0], ack: true });
} else {
// unknown parameter
const unknown_parameter_val = await this.adapter.messageQueueHandler.sendRequest(duid, parameter, []);
// this.adapter.setStateAsync("Devices." + duid + "." + targetFolder + "." + mode, { val: attribute_val[0], ack: true });
if (typeof unknown_parameter_val == "object") {
if (typeof unknown_parameter_val[0] != "number") {
this.adapter.catchError(`Unknown parameter: ${JSON.stringify(unknown_parameter_val)}`, parameter, duid, this.robotModel);
}
} else {
this.adapter.catchError(`Unknown parameter: ${unknown_parameter_val}`, parameter, duid, this.robotModel);
}
}
} catch (error) {
this.adapter.catchError(error, parameter, duid, this.robotModel);
}
}
async setUpObjects(duid) {
await this.adapter.setObjectAsync("Devices." + duid, {
type: "device",
common: {
name: this.adapter.vacuums[duid].name,
statusStates: {
onlineId: `${this.adapter.name}.${this.adapter.instance}.Devices.${duid}.deviceInfo.online`,
},
},
native: {},
});
}
async parseDockingStationStatus(dss) {
return {
cleanFluidStatus: (dss >> 10) & 0b11,
waterBoxFilterStatus: (dss >> 8) & 0b11,
dustBagStatus: (dss >> 6) & 0b11,
dirtyWaterBoxStatus: (dss >> 4) & 0b11,
clearWaterBoxStatus: (dss >> 2) & 0b11,
isUpdownWaterReady: dss & 0b11,
};
}
async getCleanSummary(duid) {
try {
const cleaningAttributes = await this.adapter.messageQueueHandler.sendRequest(duid, "get_clean_summary", []);
for (const cleaningAttribute in cleaningAttributes) {
const mappedAttribute = mappedCleanSummary[cleaningAttribute] || cleaningAttribute;
if (["clean_time", "clean_area", "clean_count"].includes(mappedAttribute)) {
await this.adapter.setStateAsync(`Devices.${duid}.cleaningInfo.${cleaningAttribute}`, {
val: this.calculateCleaningValue(mappedAttribute, cleaningAttributes[cleaningAttribute]),
ack: true,
});
} else if (mappedAttribute == "records") {
const cleaningRecordsJSON = [];
for (const cleaningRecord in cleaningAttributes[cleaningAttribute]) {
const cleaningRecordID = cleaningAttributes[cleaningAttribute][cleaningRecord];
const cleaningRecordAttributes = (await this.adapter.messageQueueHandler.sendRequest(duid, "get_clean_record", [cleaningRecordID]))[0];
cleaningRecordsJSON[cleaningRecord] = cleaningRecordAttributes;
for (const cleaningRecordAttribute in cleaningRecordAttributes) {
const mappedRecordAttribute = mappedCleaningRecordAttribute[cleaningRecordAttribute] || cleaningRecordAttribute;
await this.adapter.setStateAsync(`Devices.${duid}.cleaningInfo.records.${cleaningRecord}.${mappedRecordAttribute}`, {
val: this.calculateRecordValue(mappedRecordAttribute, cleaningRecordAttributes[cleaningRecordAttribute]),
ack: true,
});
}
}
const objectString = `Devices.${duid}.cleaningInfo.JSON`;
await this.adapter.createStateObjectHelper(objectString, "cleaningInfoJSON", "string", null, null, "json", true, false);
this.adapter.setStateAsync(`Devices.${duid}.cleaningInfo.JSON`, { val: JSON.stringify(cleaningRecordsJSON), ack: true });
}
}
} catch (error) {
this.adapter.catchError(error, "get_clean_summary", duid, this.robotModel);
}
}
calculateCleaningValue(attribute, value) {
switch (attribute) {
case "clean_time":
return Math.round(value / 60 / 60);
case "clean_area":
return Math.round(value / 1000 / 1000);
default:
return value;
}
}
calculateRecordValue(attribute, value) {
switch (attribute) {
case "begin":
case "end":
return new Date(value * 1000).toString();
case "duration":
return Math.round(value / 60);
case "area":
return Math.round(value / 1000 / 1000);
default:
return value;
}
}
unzipBuffer(buffer, callback) {
zlib.gunzip(buffer, function (err, result) {
if (err) {
callback(err);
} else {
callback(null, result);
}
});
}
isGZIP(buffer) {
if (buffer.length < 2) {
return false;
}
if (buffer[0] == 31 && buffer[1] == 139) {
return true;
}
return false;
}
extractPhoto(buffer) {
// Verify that the buffer is long enough to hold the header
if (buffer.length < 10) {
return false;
}
// Check the signature
if (buffer[26] == 74 && buffer[27] == 70 && buffer[28] == 73 && buffer[29] == 70) {
return buffer.slice(20);
} else if (buffer[42] == 74 && buffer[43] == 70 && buffer[44] == 73 && buffer[45] == 70) {
return buffer.slice(36);
}
return false;
}
}
module.exports = {
vacuum,
};