homebridge-roborock-vacuum-update
Version:
Comprehensive Homebridge plugin for Roborock vacuum cleaners with full HomeKit integration including mopping, dock features, and advanced controls.
1,769 lines (1,431 loc) • 49.7 kB
JavaScript
;
const path = require("path");
const fs = require('fs');
const axios = require("axios");
const crypto = require("crypto");
const express = require("express");
const { debug } = require("console");
const { get } = require("http");
const rrLocalConnector = require("./lib/localConnector").localConnector;
const roborock_mqtt_connector = require("./lib/roborock_mqtt_connector").roborock_mqtt_connector;
const rrMessage = require("./lib/message").message;
const vacuum_class = require("./lib/vacuum").vacuum;
const roborockPackageHelper = require("./lib/roborockPackageHelper").roborockPackageHelper;
const deviceFeatures = require("./lib/deviceFeatures").deviceFeatures;
const messageQueueHandler = require("./lib/messageQueueHandler").messageQueueHandler;
let socketServer, webserver;
const dockingStationStates = ["cleanFluidStatus", "waterBoxFilterStatus", "dustBagStatus", "dirtyWaterBoxStatus", "clearWaterBoxStatus", "isUpdownWaterReady"];
function md5hex(str) {
return crypto.createHash("md5").update(str).digest("hex");
}
class Roborock {
constructor(options) {
this.bInited = false;
this.config = options;
this.updateInterval = options.updateInterval || 180;
this.log = options.log || console;
this.language = options.language || "en";
this.localKeys = null;
this.roomIDs = {};
this.vacuums = {};
this.socket = null;
this.objects = {};
this.states = {};
this.idCounter = 0;
this.nonce = crypto.randomBytes(16);
this.messageQueue = new Map();
this.roborockPackageHelper = new roborockPackageHelper(this);
this.localConnector = new rrLocalConnector(this);
this.rr_mqtt_connector = new roborock_mqtt_connector(this);
this.message = new rrMessage(this);
this.messageQueueHandler = new messageQueueHandler(this);
this.pendingRequests = new Map();
this.localDevices = {};
this.remoteDevices = new Set();
this.scenesData = null; // Store scenes data locally
this.name = "roborock";
this.deviceNotify = null;
this.baseURL = options.baseURL || "usiot.roborock.com";
}
isInited() {
return this.bInited;
}
setInterval(callback, interval, ...args) {
return setInterval(() => callback(...args), interval);
}
clearInterval(interval) {
clearInterval(interval);
}
setTimeout(callback, timeout, ...args) {
return setTimeout(() => callback(...args), timeout);
}
clearTimeout(timeout) {
clearTimeout(timeout);
}
//dummy function for calling setObjectNotExistsAsync
async setObjectNotExistsAsync(id, obj) {
}
//dummy function for calling setObjectAsync
async setObjectAsync(id, obj) {
}
//dummy function for calling getObjectAsync
async getObjectAsync(id) {
}
//dummy function for calling delObjectAsync
async delObjectAsync(id) {
}
getStateAsync(id) {
try {
if(id == "UserData" || id == "clientID"){
const dataDir = path.resolve(__dirname, `./data`);
const filePath = path.resolve(dataDir, id);
// Create data directory if it doesn't exist
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Return null if file doesn't exist (first run)
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
return this.states[id];
}catch(error) {
// Only log error if file exists (don't spam on first run)
if (error.code !== 'ENOENT') {
this.log.error(`getStateAsync: ${error}`);
}
}
return null;
}
async setStateAsync(id, state) {
try {
if(id == "UserData" || id == "clientID"){
const dataDir = path.resolve(__dirname, `./data`);
const filePath = path.resolve(dataDir, id);
// Create data directory if it doesn't exist
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf8');
}
this.states[id] = state;
if(this.deviceNotify && (id == "HomeData" || id == "CloudMessage")){
this.deviceNotify(id, state);
}
}catch(error) {
this.log.error(`setStateAsync: ${error}`);
}
}
async setStateChangedAsync(id, state) {
await this.setStateAsync(id, state);
}
async deleteStateAsync(id) {
try {
if(id == "UserData" || id == "clientID"){
const filePath = path.resolve(__dirname, `./data/${id}`);
// Only delete if file exists
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
delete this.states[id];
}catch(error) {
// Only log error if it's not "file doesn't exist"
if (error.code !== 'ENOENT') {
this.log.error(`deleteStateAsync: ${error}`);
}
}
}
subscribeStates(id) {
this.log.debug(`subscribeStates: ${id}`);
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async startService(callback) {
this.log.info(`Starting adapter. This might take a few minutes depending on your setup. Please wait.`);
this.translations = require(`./i18n/${this.language || "en"}/translations.json`);
// create new clientID if it doesn't exist yet
let clientID = "";
try {
const storedClientID = await this.getStateAsync("clientID");
if (storedClientID && storedClientID.val) {
clientID = storedClientID.val.toString();
this.log.debug(`Loaded existing clientID: ${clientID}`);
} else {
clientID = crypto.randomUUID();
await this.setStateAsync("clientID", { val: clientID, ack: true });
this.log.info(`Generated new clientID: ${clientID}`);
}
} catch (error) {
this.log.error(`Error while retrieving or setting clientID: ${error.message}`);
// Generate a new clientID if retrieval failed
clientID = crypto.randomUUID();
try {
await this.setStateAsync("clientID", { val: clientID, ack: true });
this.log.info(`Generated fallback clientID: ${clientID}`);
} catch (setError) {
this.log.error(`Failed to save clientID: ${setError.message}`);
}
}
if (!clientID) {
this.log.error("Failed to get or create clientID!");
return;
}
if (!this.config.username || !this.config.password) {
this.log.error("Username or password missing!");
return;
}
this.instance = clientID;
// Initialize the login API (which is needed to get access to the real API).
this.loginApi = axios.create({
baseURL: 'https://' + this.baseURL,
headers: {
header_clientid: crypto.createHash("md5").update(this.config.username).update(clientID).digest().toString("base64"),
},
});
await this.setStateAsync("info.connection", { val: true, ack: true });
// api/v1/getUrlByEmail(email = ...)
// Try to load existing UserData first
let userdata = null;
try {
const storedUserData = await this.getStateAsync("UserData");
if (storedUserData && storedUserData.val) {
try {
// UserData is stored as a JSON string
if (typeof storedUserData.val === 'string') {
userdata = JSON.parse(storedUserData.val);
} else {
userdata = storedUserData.val;
}
this.log.debug("Loaded existing UserData from storage");
} catch (parseError) {
this.log.warn("Failed to parse stored UserData, will re-login: " + parseError.message);
userdata = null;
}
}
} catch (error) {
this.log.debug("No stored UserData found, will perform login");
}
// If no valid userdata, perform login
if (!userdata || !userdata.token) {
this.log.info("Performing login...");
userdata = await this.getUserData(this.loginApi);
}
try {
this.loginApi.defaults.headers.common["Authorization"] = userdata.token;
} catch (error) {
this.log.error("Failed to login. Most likely wrong token! Deleting HomeData and UserData. Try again! " + error);
this.deleteStateAsync("HomeData");
this.deleteStateAsync("UserData");
}
const rriot = userdata.rriot;
// Initialize the real API.
this.api = axios.create({
baseURL: rriot.r.a,
});
this.api.interceptors.request.use((config) => {
try {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(6).toString("base64").substring(0, 6).replace("+", "X").replace("/", "Y");
let url;
if (this.api) {
url = new URL(this.api.getUri(config));
const prestr = [rriot.u, rriot.s, nonce, timestamp, md5hex(url.pathname), /*queryparams*/ "", /*body*/ ""].join(":");
const mac = crypto.createHmac("sha256", rriot.h).update(prestr).digest("base64");
config.headers["Authorization"] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
}
} catch (error) {
this.log.error("Failed to initialize API. Error: " + error);
}
return config;
});
// Get home details.
try {
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
if (homeDetail) {
const homeId = homeDetail.data.data.rrHomeId;
if (this.api) {
const homedata = await this.api.get(`v2/user/homes/${homeId}`);
const homedataResult = homedata.data.result;
const scene = await this.api.get(`user/scene/home/${homeId}`);
await this.setStateAsync("HomeData", {
val: JSON.stringify(homedataResult),
ack: true,
});
// skip devices that sn in ingoredDevices
const ignoredDevices = this.config.ignoredDevices || [];
// create devices and set states
this.products = homedataResult.products;
this.devices = homedataResult.devices;
this.devices = this.devices.filter((device) => !ignoredDevices.includes(device.sn));
this.localKeys = new Map(this.devices.map((device) => [device.duid, device.localKey]));
// this.adapter.log.debug(`initUser test: ${JSON.stringify(Array.from(this.adapter.localKeys.entries()))}`);
await this.rr_mqtt_connector.initUser(userdata);
await this.rr_mqtt_connector.initMQTT_Subscribe();
await this.rr_mqtt_connector.initMQTT_Message();
// store name of each room via ID
const rooms = homedataResult.rooms;
for (const room in rooms) {
const roomID = rooms[room].id;
const roomName = rooms[room].name;
this.roomIDs[roomID] = roomName;
}
this.log.debug(`RoomIDs debug: ${JSON.stringify(this.roomIDs)}`);
// reconnect every 3 hours (10800 seconds)
this.reconnectIntervall = this.setInterval(async () => {
this.log.debug(`Reconnecting after 3 hours!`);
await this.rr_mqtt_connector.reconnectClient();
}, 3600 * 1000);
this.processScene(scene);
this.homedataInterval = this.setInterval(this.updateHomeData.bind(this), this.updateInterval * 1000, homeId);
await this.updateHomeData(homeId);
const discoveredDevices = await this.localConnector.getLocalDevices();
await this.createDevices();
await this.getNetworkInfo();
// merge udp discovered devices with local devices found via mqtt
Object.entries(discoveredDevices).forEach(([duid, ip]) => {
if (!Object.prototype.hasOwnProperty.call(this.localDevices, duid)) {
this.localDevices[duid] = ip;
}
});
this.log.debug(`localDevices: ${JSON.stringify(this.localDevices)}`);
for (const device in this.localDevices) {
const duid = device;
const ip = this.localDevices[device];
await this.localConnector.createClient(duid, ip);
}
await this.initializeDeviceUpdates();
this.bInited = true;
this.log.info(`Starting adapter finished. Lets go!!!!!!!`);
} else {
this.log.info(`Most likely failed to login. Deleting UserData to force new login!`);
await this.deleteStateAsync(`UserData`);
}
}
} catch (error) {
this.log.error("Failed to get home details: " + error.stack);
}
if(callback){
callback();
}
}
async stopService() {
try {
await this.clearTimersAndIntervals();
this.bInited = false;
} catch (e) {
this.catchError(e.stack);
}
}
async getUserData(loginApi) {
try {
const response = await loginApi.post(
"api/v1/login",
new URLSearchParams({
username: this.config.username,
password: this.config.password,
needtwostepauth: "false",
}).toString()
);
const userdata = response.data.data;
if (!userdata) {
throw new Error("Login returned empty userdata.");
}
await this.setStateAsync("UserData", {
val: JSON.stringify(userdata),
ack: true,
});
return userdata;
} catch (error) {
this.log.error(`Error in getUserData: ${error.message}`);
await this.deleteStateAsync("HomeData");
await this.deleteStateAsync("UserData");
throw error;
}
}
async getNetworkInfo() {
const devices = this.devices;
for (const device in devices) {
const duid = devices[device].duid;
const vacuum = this.vacuums[duid];
await vacuum.getParameter(duid, "get_network_info");
}
}
async createDevices() {
const devices = this.devices;
for (const device of devices) {
const duid = device.duid;
const name = device.name;
this.log.debug(`Creating device: ${name} with duid: ${duid}`);
const robotModel = this.getProductAttribute(duid, "model");
//model nust starts with "roborock.vacuum."
if (!robotModel.startsWith("roborock.vacuum.")) {
this.log.error(`Unknown model: ${robotModel}`);
continue;
}
this.vacuums[duid] = new vacuum_class(this, robotModel);
this.vacuums[duid].name = name;
this.vacuums[duid].features = new deviceFeatures(this, device.featureSet, device.newFeatureSet, duid);
await this.vacuums[duid].features.processSupportedFeatures();
await this.vacuums[duid].setUpObjects(duid);
// sub to all commands of this robot
this.subscribeStates("Devices." + duid + ".commands.*");
this.subscribeStates("Devices." + duid + ".reset_consumables.*");
this.subscribeStates("Devices." + duid + ".programs.startProgram");
this.subscribeStates("Devices." + duid + ".deviceInfo.online");
}
}
async initializeDeviceUpdates() {
this.log.debug(`initializeDeviceUpdates`);
const devices = this.devices;
for (const device of devices) {
const duid = device.duid;
const robotModel = this.getProductAttribute(duid);
this.vacuums[duid].mainUpdateInterval = () =>
this.setInterval(this.updateDataMinimumData.bind(this), this.updateInterval * 1000, duid, this.vacuums[duid], robotModel);
if (device.online) {
this.log.debug(`${duid} online. Starting mainUpdateInterval.`);
this.vacuums[duid].mainUpdateInterval(); // actually start mainUpdateInterval()
}
this.vacuums[duid].getStatusIntervall = () => this.setInterval(this.getStatus.bind(this), 1000, duid, this.vacuums[duid], robotModel);
if (device.online) {
this.log.debug(`${duid} online. Starting getStatusIntervall.`);
this.vacuums[duid].getStatusIntervall(); // actually start getStatusIntervall()
}
await this.updateDataExtraData(duid, this.vacuums[duid]);
await this.updateDataMinimumData(duid, this.vacuums[duid], robotModel);
await this.vacuums[duid].getCleanSummary(duid);
}
}
async processScene(scene) {
if (scene && scene.data.result) {
this.log.debug(`Processing scene ${JSON.stringify(scene.data.result)}`);
const programs = {};
for (const program in scene.data.result) {
const enabled = scene.data.result[program].enabled;
const programID = scene.data.result[program].id;
const programName = scene.data.result[program].name;
const param = scene.data.result[program].param;
this.log.debug(`Processing scene param ${param}`);
const duid = JSON.parse(param).action.items[0].entityId;
if (!programs[duid]) {
programs[duid] = {};
}
programs[duid][programID] = programName;
await this.setObjectNotExistsAsync(`Devices.${duid}.programs`, {
type: "folder",
common: {
name: "Programs",
},
native: {},
});
await this.setObjectAsync(`Devices.${duid}.programs.${programID}`, {
type: "folder",
common: {
name: programName,
},
native: {},
});
const enabledPath = `Devices.${duid}.programs.${programID}.enabled`;
await this.createStateObjectHelper(enabledPath, "enabled", "boolean", null, null, "value");
this.setStateAsync(enabledPath, enabled, true);
const items = JSON.parse(param).action.items;
for (const item in items) {
for (const attribute in items[item]) {
const objectPath = `Devices.${duid}.programs.${programID}.items.${item}.${attribute}`;
let value = items[item][attribute];
const typeOfValue = typeof value;
await this.createStateObjectHelper(objectPath, attribute, typeOfValue, null, null, "value", true, false);
if (typeOfValue == "object") {
value = value.toString();
}
this.setStateAsync(objectPath, value, true);
}
}
}
for (const duid in programs) {
const objectPath = `Devices.${duid}.programs.startProgram`;
await this.createStateObjectHelper(objectPath, "Start saved program", "string", null, Object.keys(programs[duid])[0], "value", true, true, programs[duid]);
}
}
}
async executeScene(sceneID) {
if (this.api) {
try {
await this.api.post(`user/scene/${sceneID.val}/execute`);
} catch (error) {
this.catchError(error.stack, "executeScene");
}
}
}
/**
* Get the home ID from the login API
* @returns {Promise<string>} The home ID
*/
async getHomeID() {
if (!this.loginApi) {
throw new Error("loginApi is not initialized. Call init() first.");
}
try {
const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
if (homeDetail && homeDetail.data && homeDetail.data.data) {
return homeDetail.data.data.rrHomeId;
}
throw new Error("Failed to get home ID from homeDetail response");
} catch (error) {
this.log.error(`Failed to get home ID: ${error.message}`);
throw error;
}
}
/**
* Get scenes from the Roborock API
* @returns {Promise<Object>} The scenes data
*/
async getScenes() {
if (!this.loginApi) {
throw new Error("loginApi is not initialized. Call init() first.");
}
if (!this.api) {
throw new Error("api is not initialized. Call initializeRealApi() first");
}
try {
const homeId = await this.getHomeID();
const response = await this.api.get(`user/scene/home/${homeId}`);
// Store scenes data locally
this.scenesData = response.data;
return response.data;
} catch (error) {
this.log.error(`Failed to get scenes: ${error.message}`);
throw error;
}
}
/**
* Get scenes for a specific device by duid
* @param {string} duid - The device unique identifier
* @returns {Array} Array of scenes for the specified device
*/
getScenesForDevice(duid) {
// If duid provided, filter scenes for that device
if (!this.scenesData || !this.scenesData.result) {
this.log.warn(`No scenes data available. Call getScenes() first.`);
return [];
}
try {
const deviceScenes = [];
for (const scene of this.scenesData.result) {
if (scene.param) {
try {
const param = JSON.parse(scene.param);
if (param.action && param.action.items) {
// Check if any item in the scene has the matching entityId (duid)
const hasMatchingDevice = param.action.items.some(item =>
item.entityId === duid
);
if (hasMatchingDevice) {
deviceScenes.push({
id: scene.id,
name: scene.name,
enabled: scene.enabled,
type: scene.type,
param: scene.param
});
}
}
} catch (parseError) {
this.log.warn(`Failed to parse scene param for scene ${scene.id}: ${parseError.message}`);
}
}
}
this.log.debug(`Found ${deviceScenes.length} scenes for device ${duid}`);
return deviceScenes;
} catch (error) {
this.log.error(`Failed to filter scenes for device ${duid}: ${error.message}`);
return [];
}
}
getProductAttribute(duid, attribute) {
const products = this.products;
const productID = this.devices.find((device) => device.duid == duid).productId;
const product = products.find((product) => product.id == productID);
return product ? product[attribute] : null;
}
startMainUpdateInterval(duid, online) {
const robotModel = this.getProductAttribute(duid, "model");
this.vacuums[duid].mainUpdateInterval = () =>
this.setInterval(this.updateDataMinimumData.bind(this), this.updateInterval * 1000, duid, this.vacuums[duid], robotModel);
if (online) {
this.log.debug(`${duid} online. Starting mainUpdateInterval.`);
this.vacuums[duid].mainUpdateInterval(); // actually start mainUpdateInterval()
// Map updater gets startet automatically via getParameter with get_status
}
}
decodeSniffedMessage(data, devices) {
const dataString = JSON.stringify(data);
const duidMatch = dataString.match(/\/(\w+)\.\w{3}'/);
if (duidMatch) {
const duidSniffed = duidMatch[1];
const device = devices.find((device) => device.duid === duidSniffed);
if (device) {
const localKey = device.localKey;
const payloadMatch = dataString.match(/'([a-fA-F0-9]+)'/);
if (payloadMatch) {
const hexPayload = payloadMatch[1];
const msg = Buffer.from(hexPayload, "hex");
const decodedMessage = this.message._decodeMsg(msg, localKey);
this.log.debug(`Decoded sniffing message: ${JSON.stringify(JSON.parse(decodedMessage.payload))}`);
}
}
}
}
async onlineChecker(duid) {
const homedata = await this.getStateAsync("HomeData");
// If the home data is not found or if its value is not a string, return false.
if (homedata && typeof homedata.val == "string") {
const homedataJSON = JSON.parse(homedata.val);
const device = homedataJSON.devices.find((device) => device.duid == duid);
const receivedDevice = homedataJSON.receivedDevices.find((device) => device.duid == duid);
// If the device is not found, return false.
if (!device && !receivedDevice) {
return false;
}
return device?.online || receivedDevice?.online;
} else {
return false;
}
}
async isRemoteDevice(duid) {
const homedata = await this.getStateAsync("HomeData");
if (homedata && typeof homedata.val == "string") {
const homedataJSON = JSON.parse(homedata.val);
const receivedDevice = homedataJSON.receivedDevices.find((device) => device.duid == duid);
const remoteDevice = this.remoteDevices.has(duid);
if (receivedDevice || remoteDevice) {
return true;
}
return false;
} else {
return false;
}
}
async getConnector(duid) {
const isRemote = await this.isRemoteDevice(duid);
if (isRemote) {
return this.rr_mqtt_connector;
} else {
return this.localConnector;
}
}
async manageDeviceIntervals(duid) {
return this.onlineChecker(duid)
.then((onlineState) => {
if (!onlineState && this.vacuums[duid].mainUpdateInterval) {
this.clearInterval(this.vacuums[duid].getStatusIntervall);
this.clearInterval(this.vacuums[duid].mainUpdateInterval);
} else if (!this.vacuums[duid].mainUpdateInterval) {
this.vacuums[duid].getStatusIntervall();
this.startMainUpdateInterval(duid, onlineState);
}
return onlineState;
})
.catch((error) => {
this.log.error("startStopIntervals " + error);
return false; // Make device appear as offline on error. Just in case.
});
}
async updateDataMinimumData(duid, vacuum, robotModel) {
this.log.debug(`Latest data requested`);
if (robotModel == "roborock.wm.a102") {
// nothing for now
} else if (robotModel == "roborock.wetdryvac.a56") {
// nothing for now
} else {
await vacuum.getParameter(duid, "get_room_mapping");
await vacuum.getParameter(duid, "get_consumable");
await vacuum.getParameter(duid, "get_server_timer");
await vacuum.getParameter(duid, "get_timer");
await this.checkForNewFirmware(duid);
switch (robotModel) {
case "roborock.vacuum.s4":
case "roborock.vacuum.s5":
case "roborock.vacuum.s5e":
case "roborock.vacuum.a08":
case "roborock.vacuum.a10":
case "roborock.vacuum.a40":
//do nothing
break;
case "roborock.vacuum.s6":
await vacuum.getParameter(duid, "get_carpet_mode");
break;
case "roborock.vacuum.a27":
await vacuum.getParameter(duid, "get_dust_collection_switch_status");
await vacuum.getParameter(duid, "get_wash_towel_mode");
await vacuum.getParameter(duid, "get_smart_wash_params");
await vacuum.getParameter(duid, "app_get_dryer_setting");
break;
default:
await vacuum.getParameter(duid, "get_carpet_mode");
await vacuum.getParameter(duid, "get_carpet_clean_mode");
await vacuum.getParameter(duid, "get_water_box_custom_mode");
}
}
}
async updateDataExtraData(duid, vacuum) {
await vacuum.getParameter(duid, "get_fw_features");
await vacuum.getParameter(duid, "get_multi_maps_list");
}
clearTimersAndIntervals() {
if (this.reconnectIntervall) {
this.clearInterval(this.reconnectIntervall);
}
if (this.homedataInterval) {
this.clearInterval(this.homedataInterval);
}
if (this.commandTimeout) {
this.clearTimeout(this.commandTimeout);
}
this.localConnector.clearLocalDevicedTimeout();
for (const duid in this.vacuums) {
this.clearInterval(this.vacuums[duid].getStatusIntervall);
this.clearInterval(this.vacuums[duid].mainUpdateInterval);
}
this.messageQueue.forEach(({ timeout102, timeout301 }) => {
this.clearTimeout(timeout102);
if (timeout301) {
this.clearTimeout(timeout301);
}
});
// Clear the messageQueue map
this.messageQueue.clear();
if (this.webSocketInterval) {
this.clearInterval(this.webSocketInterval);
}
}
checkAndClearRequest(requestId) {
const request = this.messageQueue.get(requestId);
if (!request?.timeout102 && !request?.timeout301) {
this.messageQueue.delete(requestId);
// this.log.debug(`Cleared messageQueue`);
} else {
this.log.debug(`Not clearing messageQueue. ${request.timeout102} - ${request.timeout301}`);
}
this.log.debug(`Length of message queue: ${this.messageQueue.size}`);
}
async updateHomeData(homeId) {
this.log.debug(`Updating HomeData with homeId: ${homeId}`);
if (this.api) {
try {
const home = await this.api.get(`user/homes/${homeId}`);
const homedata = home.data.result;
if (homedata) {
await this.setStateAsync("HomeData", {
val: JSON.stringify(homedata),
ack: true,
});
this.log.debug(`homedata successfully updated`);
await this.updateConsumablesPercent(homedata.devices);
await this.updateConsumablesPercent(homedata.receivedDevices);
await this.updateDeviceInfo(homedata.devices);
await this.updateDeviceInfo(homedata.receivedDevices);
await this.getScenes();
} else {
this.log.warn("homedata failed to download");
}
} catch (error) {
this.log.error(`Failed to update updateHomeData with error: ${error}`);
}
}
}
async updateConsumablesPercent(devices) {
for (const device of devices) {
const duid = device.duid;
const deviceStatus = device.deviceStatus;
for (const [attribute, value] of Object.entries(deviceStatus)) {
const targetConsumable = await this.getObjectAsync(`Devices.${duid}.consumables.${attribute}`);
if (targetConsumable) {
const val = value >= 0 && value <= 100 ? parseInt(value) : 0;
await this.setStateAsync(`Devices.${duid}.consumables.${attribute}`, { val: val, ack: true });
}
}
}
}
async updateDeviceInfo(devices) {
for (const device in devices) {
const duid = devices[device].duid;
for (const deviceAttribute in devices[device]) {
if (typeof devices[device][deviceAttribute] != "object") {
let unit;
if (deviceAttribute == "activeTime") {
unit = "h";
devices[device][deviceAttribute] = Math.round(devices[device][deviceAttribute] / 1000 / 60 / 60);
}
await this.setObjectAsync("Devices." + duid + ".deviceInfo." + deviceAttribute, {
type: "state",
common: {
name: deviceAttribute,
type: this.getType(devices[device][deviceAttribute]),
unit: unit,
role: "value",
read: true,
write: false,
},
native: {},
});
this.setStateChangedAsync("Devices." + duid + ".deviceInfo." + deviceAttribute, { val: devices[device][deviceAttribute], ack: true });
}
}
}
}
async checkForNewFirmware(duid) {
const isLocalDevice = !this.isRemoteDevice(duid);
if (isLocalDevice) {
this.log.debug(`getting firmware status`);
if (this.api) {
try {
const update = await this.api.get(`ota/firmware/${duid}/updatev2`);
await this.setObjectNotExistsAsync("Devices." + duid + ".updateStatus", {
type: "folder",
common: {
name: "Update status",
},
native: {},
});
for (const state in update.data.result) {
await this.setObjectNotExistsAsync("Devices." + duid + ".updateStatus." + state, {
type: "state",
common: {
name: state,
type: this.getType(update.data.result[state]),
role: "value",
read: true,
write: false,
},
native: {},
});
this.setStateAsync("Devices." + duid + ".updateStatus." + state, {
val: update.data.result[state],
ack: true,
});
}
} catch (error) {
this.catchError(error, "checkForNewFirmware()", duid);
}
}
}
}
getType(attribute) {
// Get the type of the attribute.
const type = typeof attribute;
// Return the appropriate string representation of the type.
switch (type) {
case "boolean":
return "boolean";
case "number":
return "number";
default:
return "string";
}
}
async createStateObjectHelper(path, name, type, unit, def, role, read, write, states, native = {}) {
const common = {
name: name,
type: type,
unit: unit,
role: role,
read: read,
write: write,
states: states,
};
if (def !== undefined && def !== null && def !== "") {
common.def = def;
}
this.setObjectAsync(path, {
type: "state",
common: common,
native: native,
});
}
async createCommand(duid, command, type, defaultState, states) {
const path = `Devices.${duid}.commands.${command}`;
const name = this.translations[command];
const common = {
name: name,
type: type,
role: "value",
read: true,
write: true,
def: defaultState,
states: states,
};
this.setObjectAsync(path, {
type: "state",
common: common,
native: {},
});
}
async createDeviceStatus(duid, state, type, states, unit) {
const path = `Devices.${duid}.deviceStatus.${state}`;
const name = this.translations[state];
const common = {
name: name,
type: type,
role: "value",
unit: unit,
read: true,
write: false,
states: states,
};
this.setObjectAsync(path, {
type: "state",
common: common,
native: {},
});
}
async createDockingStationObject(duid) {
for (const state of dockingStationStates) {
const path = `Devices.${duid}.dockingStationStatus.${state}`;
const name = this.translations[state];
this.setObjectNotExistsAsync(path, {
type: "state",
common: {
name: name,
type: "number",
role: "value",
read: true,
write: false,
states: { 0: "UNKNOWN", 1: "ERROR", 2: "OK" },
},
native: {},
});
}
}
async createConsumable(duid, state, type, states, unit) {
const path = `Devices.${duid}.consumables.${state}`;
const name = this.translations[state];
const common = {
name: name,
type: type,
role: "value",
unit: unit,
read: true,
write: false,
states: states,
};
this.setObjectAsync(path, {
type: "state",
common: common,
native: {},
});
}
async createResetConsumables(duid, state) {
const path = `Devices.${duid}.resetConsumables.${state}`;
const name = this.translations[state];
this.setObjectNotExistsAsync(path, {
type: "state",
common: {
name: name,
type: "boolean",
role: "value",
read: true,
write: true,
def: false,
},
native: {},
});
}
async createCleaningRecord(duid, state, type, states, unit) {
let start = 0;
let end = 19;
const robotModel = this.getProductAttribute(duid, "model");
if (robotModel == "roborock.vacuum.a97") {
start = 1;
end = 20;
}
for (let i = start; i <= end; i++) {
await this.setObjectAsync(`Devices.${duid}.cleaningInfo.records.${i}`, {
type: "folder",
common: {
name: `Cleaning record ${i}`,
},
native: {},
});
this.setObjectAsync(`Devices.${duid}.cleaningInfo.records.${i}.${state}`, {
type: "state",
common: {
name: this.translations[state],
type: type,
role: "value",
unit: unit,
read: true,
write: false,
states: states,
},
native: {},
});
await this.setObjectAsync(`Devices.${duid}.cleaningInfo.records.${i}.map`, {
type: "folder",
common: {
name: "Map",
},
native: {},
});
for (const name of ["mapBase64", "mapBase64Truncated", "mapData"]) {
const objectString = `Devices.${duid}.cleaningInfo.records.${i}.map.${name}`;
await this.createStateObjectHelper(objectString, name, "string", null, null, "value", true, false);
}
}
}
async createCleaningInfo(duid, key, object) {
const path = `Devices.${duid}.cleaningInfo.${key}`;
const name = this.translations[object.name];
this.setObjectAsync(path, {
type: "state",
common: {
name: name,
type: "number",
role: "value",
unit: object.unit,
read: true,
write: false,
},
native: {},
});
}
async createBaseRobotObjects(duid) {
for (const name of ["mapBase64", "mapBase64Truncated", "mapData"]) {
const objectString = `Devices.${duid}.map.${name}`;
await this.createStateObjectHelper(objectString, name, "string", null, null, "value", true, false);
}
this.createNetworkInfoObjects(duid);
}
async createBasicVacuumObjects(duid) {
this.createNetworkInfoObjects(duid);
}
async createBasicWashingMachineObjects(duid) {
this.createNetworkInfoObjects(duid);
}
async createNetworkInfoObjects(duid) {
for (const name of ["ssid", "ip", "mac", "bssid", "rssi"]) {
const objectString = `Devices.${duid}.networkInfo.${name}`;
const objectType = name == "rssi" ? "number" : "string";
await this.createStateObjectHelper(objectString, name, objectType, null, null, "value", true, false);
}
}
async startCommand(duid, command, parameters) {
if(!this.isInited()){
this.log.warn("Adapter not inited. Command not executed.");
return;
}
switch (command) {
case "app_zoned_clean":
case "app_goto_target":
case "app_start":
case "app_stop":
case "stop_zoned_clean":
case "app_pause":
case "app_charge":
case "app_spot":
case "find_me":
case "set_custom_mode":
case "set_water_box_custom_mode":
case "set_mop_mode":
case "app_segment_clean":
case "set_led_status":
case "change_sound_volume":
case "set_child_lock_status":
case "set_dnd_timer":
case "close_dnd_timer":
case "app_start_collect_dust":
case "app_stop_collect_dust":
case "set_dust_collection_mode":
case "set_dust_collection_switch_status":
case "set_carpet_mode":
case "set_carpet_clean_mode":
case "set_wash_towel_mode":
case "app_start_wash":
case "app_stop_wash":
case "set_flow_led_status":
case "app_zoned_clean":
case "stop_zoned_clean":
case "resume_zoned_clean":
case "app_set_dryer_status":
case "app_set_dryer_setting":
case "set_smart_wash_params":
this.vacuums[duid].command(duid, command, parameters);
break;
case "get_photo":
this.vacuums[duid].getParameter(duid, "get_photo", parameters);
break;
case "sniffing_decrypt":
await this.getStateAsync("HomeData")
.then((homedata) => {
if (homedata) {
const homedataVal = homedata.val;
if (typeof homedataVal == "string") {
// this.log.debug("Sniffing message received!");
const homedataParsed = JSON.parse(homedataVal);
this.decodeSniffedMessage(data, homedataParsed.devices);
this.decodeSniffedMessage(data, homedataParsed.receivedDevices);
}
}
})
.catch((error) => {
this.log.error("Failed to decode/decrypt sniffing message. " + error);
});
break;
default:
this.log.warn(`Command ${command} not found.`);
}
}
isCleaning(state) {
switch (state) {
case 4: // Remote Control
case 5: // Cleaning
case 6: // Returning Dock
case 7: // Manual Mode
case 11: // Spot Cleaning
case 15: // Docking
case 16: // Go To
case 17: // Zone Clean
case 18: // Room Clean
case 26: // Going to wash the mop
return true;
default:
return false;
}
}
async getRobotVersion(duid) {
const homedata = await this.getStateAsync("HomeData");
if (homedata && homedata.val) {
const devices = JSON.parse(homedata.val.toString()).devices.concat(JSON.parse(homedata.val.toString()).receivedDevices);
for (const device in devices) {
if (devices[device].duid == duid) return devices[device].pv;
}
}
return "Error in getRobotVersion. Version not found.";
}
getRequestId() {
if (this.idCounter >= 9999) {
this.idCounter = 0;
return this.idCounter;
}
return this.idCounter++;
}
async setupBasicObjects() {
await this.setObjectAsync("Devices", {
type: "folder",
common: {
name: "Devices",
},
native: {},
});
await this.setObjectAsync("UserData", {
type: "state",
common: {
name: "UserData string",
type: "string",
role: "value",
read: true,
write: false,
},
native: {},
});
await this.setObjectAsync("HomeData", {
type: "state",
common: {
name: "HomeData string",
type: "string",
role: "value",
read: true,
write: false,
},
native: {},
});
await this.setObjectAsync("clientID", {
type: "state",
common: {
name: "Client ID",
type: "string",
role: "value",
read: true,
write: false,
},
native: {},
});
}
async catchError(error, attribute, duid, model) {
if (error) {
if (error.toString().includes("retry") || error.toString().includes("locating") || error.toString().includes("timed out after 10 seconds")) {
this.log.warn(`Failed to execute ${attribute} on robot ${duid} (${model || "unknown model"}): ${error}`);
} else {
this.log.error(`Failed to execute ${attribute} on robot ${duid} (${model || "unknown model"}): ${error.stack || error}`);
}
}
}
async app_start(duid){
await this.startCommand(duid, "app_start", null);
}
async app_stop(duid){
await this.startCommand(duid, "app_stop", null);
}
async app_charge(duid){
await this.startCommand(duid, "app_charge", null);
}
async app_pause(duid){
await this.startCommand(duid, "app_pause", null);
}
async app_spot(duid){
await this.startCommand(duid, "app_spot", null);
}
async find_me(duid){
await this.startCommand(duid, "find_me", null);
}
async set_custom_mode(duid, mode){
await this.startCommand(duid, "set_custom_mode", [mode]);
}
async set_water_box_custom_mode(duid, mode){
await this.startCommand(duid, "set_water_box_custom_mode", [mode]);
}
async set_mop_mode(duid, mode){
await this.startCommand(duid, "set_mop_mode", [mode]);
}
async app_segment_clean(duid, roomList){
await this.startCommand(duid, "app_segment_clean", roomList);
}
// LED Status
async set_led_status(duid, status){
await this.startCommand(duid, "set_led_status", [status]);
}
// Sound Volume
async change_sound_volume(duid, volume){
await this.startCommand(duid, "change_sound_volume", [volume]);
}
// Child Lock
async set_child_lock_status(duid, status){
await this.startCommand(duid, "set_child_lock_status", [{lock_status: status}]);
}
// Do Not Disturb
async set_dnd_timer(duid, dndTimer){
await this.startCommand(duid, "set_dnd_timer", dndTimer);
}
async close_dnd_timer(duid){
await this.startCommand(duid, "close_dnd_timer", null);
}
// Dust Collection
async app_start_collect_dust(duid){
await this.startCommand(duid, "app_start_collect_dust", null);
}
async app_stop_collect_dust(duid){
await this.startCommand(duid, "app_stop_collect_dust", null);
}
async set_dust_collection_mode(duid, mode){
await this.startCommand(duid, "set_dust_collection_mode", [mode]);
}
async set_dust_collection_switch_status(duid, status){
await this.startCommand(duid, "set_dust_collection_switch_status", [status]);
}
// Carpet Mode
async set_carpet_mode(duid, mode){
await this.startCommand(duid, "set_carpet_mode", [mode]);
}
async set_carpet_clean_mode(duid, mode){
await this.startCommand(duid, "set_carpet_clean_mode", [{carpet_clean_mode: mode}]);
}
// Wash Towel Mode (Wash Dock)
async set_wash_towel_mode(duid, mode){
await this.startCommand(duid, "set_wash_towel_mode", [mode]);
}
async app_start_wash(duid){
await this.startCommand(duid, "app_start_wash", null);
}
async app_stop_wash(duid){
await this.startCommand(duid, "app_stop_wash", null);
}
// Flow LED Status
async set_flow_led_status(duid, status){
await this.startCommand(duid, "set_flow_led_status", [status]);
}
// Zone Cleaning
async app_zoned_clean(duid, zones){
// zones should be an array of zone arrays: [[x1, y1, x2, y2, cleanCount], ...]
await this.startCommand(duid, "app_zoned_clean", zones);
}
async stop_zoned_clean(duid){
await this.startCommand(duid, "stop_zoned_clean", null);
}
async resume_zoned_clean(duid){
await this.startCommand(duid, "resume_zoned_clean", null);
}
// Drying (Dry Dock)
async app_set_dryer_status(duid, status){
// status: JSON string like '{"status": 1}' for on, '{"status": 0}' for off
await this.startCommand(duid, "app_set_dryer_status", typeof status === 'string' ? JSON.parse(status) : status);
}
async app_set_dryer_setting(duid, setting){
// setting: JSON with dry_time in seconds and status
// Example: {"on":{"dry_time":10800},"status":1} for 3h on
await this.startCommand(duid, "app_set_dryer_setting", typeof setting === 'string' ? JSON.parse(setting) : setting);
}
// Smart Wash Params (Wash Dock)
async set_smart_wash_params(duid, params){
// params: JSON with smart_wash (0 or 1) and wash_interval in seconds
// Example: {"smart_wash":0,"wash_interval":1800} for 30 min
await this.startCommand(duid, "set_smart_wash_params", typeof params === 'string' ? JSON.parse(params) : params);
}
async getStatus(duid, vacuum) {
await vacuum.getParameter(duid, "get_status");
}
async getStatus(duid) {
try{
await this.vacuums[duid].getParameter(duid, "get_status", "state");
}catch(error){
this.catchError(error, "getStatus", duid);
}
}
getProductData(productId) {
const homedata = this.getStateAsync("HomeData");
if (homedata && typeof homedata.val == "string") {
const homedataJSON = JSON.parse(homedata.val);
const product = homedataJSON.products.find((product) => product.id == productId);
return product;
}
}
getVacuumDeviceData(duid) {
const homedata = this.getStateAsync("HomeData");
if (homedata && typeof homedata.val == "string") {
const homedataJSON = JSON.parse(homedata.val);
const device = homedataJSON.devices.find((device) => device.duid == duid);
const receivedDevice = homedataJSON.receivedDevices.find((device) => device.duid == duid);
return device || receivedDevice;
}
}
getVacuumSchemaId(duid, code) {
const productId = this.getVacuumDeviceInfo(duid, "productId");
const product = this.getProductData(productId);
if (product) {
const schema = product.schema;
const schemaId = schema.find((schema) => schema.code == code);
if (schemaId) {
return schemaId.id;
}
}
return null;
}
getVacuumDeviceInfo(duid, property) {
const device = this.getVacuumDeviceData(duid);
if (device) {
return device[property];
} else {
return "";
}
}
getVacuumDeviceStatus(duid, property) {
const propertyID = this.getVacuumSchemaId(duid, property);
if(propertyID == null){
return "";
}
const device = this.getVacuumDeviceData(duid);
if (device.deviceStatus && device.deviceStatus[propertyID] != undefined) {
return device.deviceStatus[propertyID];
}
return "";
}
// Check if device supports a specific feature
hasFeature(duid, featureName) {
if (!this.vacuums[duid]) {
return false;
}
const vacuum = this.vacuums[duid];
if (vacuum.deviceFeatures && vacuum.deviceFeatures.getFeatureList) {
const features = vacuum.deviceFeatures.getFeatureList();
return features[featureName] === true;
}
return false;
}
// Check if device supports water level control
supportsWaterLevel(duid) {
return this.hasFeature(duid, "isShakeMopSetSupported") ||
this.hasFeature(duid, "isElectronicWaterBoxSupported") ||
this.getVacuumDeviceStatus(duid, "water_box_custom_mode") !== "";
}
// Check if device supports mop mode
supportsMopMode(duid) {
return this.hasFeature(duid, "isCleanRouteFastModeSupported") ||
this.getVacuumDeviceStatus(duid, "mop_mode") !== "";
}
// Check if device uses MILD/STANDARD/INTENSE (shake mop) or LOW/MEDIUM/HIGH
usesShakeMopWaterModes(duid) {
return this.hasFeature(duid, "isShakeMopSetSupported");
}
// Check if device supports LED status
supportsLedStatus(duid) {
return this.hasFeature(duid, "isLedStatusSwitchSupported") ||
this.getVacuumDeviceStatus(duid, "led_status") !== "";
}
// Check if device supports child lock
supportsChildLock(duid) {
return this.hasFeature(duid, "isSetChildSupported") ||
this.getVacuumDeviceStatus(duid, "child_lock_status") !== "";
}
// Check if device supports dust collection
supportsDustCollection(duid) {
return this.hasFeature(duid, "isDustCollectionSettingSupported") ||
this.hasFeature(duid, "isCollectDustModeSupported") ||
this.getVacuumDeviceStatus(duid, "dust_collection_switch_status") !== "";
}
// Check if device supports carpet mode
supportsCarpetMode(duid) {
return this.hasFeature(duid, "isCarpetSupported") ||
this.getVacuumDeviceStatus(duid, "carpet_mode") !== "";
}
// Check if device supports flow LED
supportsFlowLed(duid) {
return this.hasFeature(duid, "isFlowLedSettingSupported") ||
this.getVacuumDeviceStatus(duid, "flow_led_status") !== "";
}
// Check if device supports washing (wash dock)
supportsWashing(duid) {
return this.hasFeature(duid, "isWashThenChargeCmdSupported") ||
this.getVacuumDeviceStatus(duid, "wash_status") !== "";
}
// Check if device supports drying (dry dock)
supportsDrying(duid) {
return this.hasFeature(duid, "isSupportedDrying") ||
this.getVacuumDeviceStatus(duid, "dry_status") !== "";
}
getRoomMapping(duid) {
// This will be populated after get_room_mapping is called
// Returns array of [segmentId, roomId] pairs
return this.vacuums[duid]?.roomMapping || [];
}
getRoomList(duid) {
// Get list of rooms with their names
const mapping = this.getRoomMapping(duid);
const rooms = [];
for (const room of mapping) {
const roomId = room[1];
const roomName = this.roomIDs[roomId] || `Room ${room[0]}`;
rooms.push({
segmentId: room[0],
roomId: roomId,
name: roomName
});
}
return rooms;
}
getVacuumList() {
const homedata = this.getStateAsync("HomeData");
if (homedata && typeof homedata.val == "string") {
const homedataJSON = JSON.parse(homedata.val);
const devices = homedataJSON.devices.concat(homedataJSON.receivedDevices);
return devices;
}
return [];
}
setDeviceNotify(callback){
this.deviceNotify = callback;
}
}
module.exports = {Roborock};
////////////////////////////////////////////////////////////////////////////////////////////////////