UNPKG

iobroker.roborock

Version:
1,053 lines (895 loc) 29.6 kB
const utils = require("@iobroker/adapter-core"); const { randomBytes } = require("crypto"); const WebSocket = require("ws"); const express = require("express"); const { spawn } = require("child_process"); const go2rtcPath = require("go2rtc-static"); const roborock_package_helper = require("./lib/roborock_package_helper"); const device_features = require("./lib/device_features"); const requests_handler = require("./lib/requests_handler"); const http_api = require("./lib/http_api"); const sniffing = require("./lib/sniffing"); let socketServer, webserver; const dockingStationStates = ["cleanFluidStatus", "waterBoxFilterStatus", "dustBagStatus", "dirtyWaterBoxStatus", "clearWaterBoxStatus", "isUpdownWaterReady"]; let updateIntervalCount = 0; class Roborock extends utils.Adapter { constructor(options = {}) { super({ ...options, name: "roborock", useFormatDate: true }); this.on("ready", this.onReady.bind(this)); this.on("stateChange", this.onStateChange.bind(this)); // this.on("objectChange", this.onObjectChange.bind(this)); // this.on("message", this.onMessage.bind(this)); this.on("unload", this.onUnload.bind(this)); this.socket = null; this.idCounter = 0; this.nonce = randomBytes(16); this.pendingRequests = new Map(); this.http_api = new http_api(this); this.roborock_package_helper = new roborock_package_helper(this); this.requests_handler = new requests_handler(this); this.device_features = new device_features(this); this.sniffing = new sniffing(this); } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { if (!this.config.username || !this.config.password) { this.log.error("Username or password missing!"); return; } this.sentryInstance = this.getPluginInstance("sentry"); this.translations = require(`./admin/i18n/${this.language || "en"}/translations.json`); // fall back to en for test-and-release.yml this.log.info(`Starting adapter. This might take a few minutes depending on your setup. Please wait.`); const clientID = await this.ensureClientID(); await this.http_api.init(clientID); await this.setupBasicObjects(); await this.requests_handler.init(); // this makes the requests handler connect to mqtt. tcp follows later when IP of each device have been received // get latest data on start of adapter const devices = await this.http_api.getDevices(); // need to get network data before processing any other data for (const device of devices) { const duid = device.duid; await this.requests_handler.getParameter(duid, "get_network_info"); // this needs to be called first on start of adapter to get the IP adresses of each device } // now network data is present, connect tcp client to devices await this.requests_handler.initTCP(); // now tcp clients are connected. process any further data for (const device of devices) { const duid = device.duid; await this.createDeviceObjects(device); await this.requests_handler.getStatus(duid); this.updateDeviceData(duid); this.updateConsumablesPercent(duid); this.updateDeviceInfo(duid); await this.requests_handler.getCleanSummary(duid); await this.requests_handler.getMap(duid); } await this.processScenes(); this.log.info(`Starting adapter finished. Let's go!!!!!!!`); // handle requests based on interval here. No separate interval needed anywhere this.log.debug(`initializing mainUpdateInterval`); this.mainUpdateInterval = this.setInterval(async () => { const devices = this.http_api.getDevices(); for (const device of devices) { if (device.online) { const duid = device.duid; try { // update status every second if websocket is connected or update interval is met if (this.socket || updateIntervalCount % this.config.updateInterval == 0) { await this.requests_handler.getStatus(duid); } // update device data on interval defined in options if (updateIntervalCount % this.config.updateInterval == 0) { this.updateDeviceData(duid); this.updateConsumablesPercent(duid); this.updateDeviceInfo(duid); updateIntervalCount = 0; } // update map when needed const isCleaning = this.requests_handler.isCleaning(duid); if (isCleaning) this.requests_handler.getMap(duid); } catch (error) { this.catchError(error.stack, "mainUpdateInterval", duid); } } } updateIntervalCount++; }, 1000); try { await this.start_go2rtc(); } catch (error) { this.catchError(`Failed to start go2rtc. ${error.stack}`); } // Start map creation if enabled if (this.config.enable_map_creation) { try { this.startWebserver(); await this.startWebsocketServer(); } catch (error) { this.catchError(error.stack); } } } async ensureClientID() { try { const clientID = await this.getStateAsync("clientID"); if (!clientID || !clientID.val) { const randomClientID = crypto.randomUUID(); await this.setState("clientID", { val: randomClientID, ack: true }); this.log.info(`Generated and saved new clientID: ${randomClientID}`); return randomClientID; } else { this.log.info(`Loaded existing clientID: ${clientID.val}`); return clientID.val; } } catch (error) { this.log.error(`Error ensuring clientID: ${error.message}`); throw error; // Optional, falls der Fehler weitergereicht werden soll } } async createDeviceObjects(device) { const duid = device.duid; const name = device.name; await this.device_features.processSupportedFeatures(duid); await this.setObjectAsync("Devices." + duid, { type: "device", common: { name: name, statusStates: { onlineId: `${this.name}.${this.instance}.Devices.${duid}.deviceInfo.online`, }, }, native: {}, }); // sub to all commands of this robot this.subscribeStates("Devices." + duid + ".commands.*"); this.subscribeStates("Devices." + duid + ".resetConsumables.*"); this.subscribeStates("Devices." + duid + ".programs.startProgram"); this.subscribeStates("Devices." + duid + ".deviceInfo.online"); } async processScenes() { const scenes = await this.http_api.getScenes(); if (scenes && scenes?.result) { const data = scenes.result; const programs = {}; for (const program in data) { const enabled = data[program].enabled; const programID = data[program].id; const programName = data[program].name; const param = data[program].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.setState(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.setState(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]); } } } startWebserver() { const app = express(); app.use(express.static("lib/map")); webserver = app.listen(this.config.webserverPort); webserver.on("error", (error) => { // This code will run if there was an error starting the server this.log.error(`Error occurred: ${error}`); }); } async stopWebserver() { webserver.close(); } async startWebsocketServer() { socketServer = new WebSocket.Server({ port: 7906 }); let parameters, robot; socketServer.on("connection", async (socket) => { this.socket = socket; this.log.debug(`Websocket client connected`); socket.on("pong", () => { this.socket = socket; }); this.webSocketInterval = this.setInterval(() => { if (!this.socket) { this.log.debug(`Client disconnected. Stopping interval.`); this.clearInterval(this.webSocketInterval); socket.terminate(); return; } this.socket = null; socket.ping(); }, 1000); socket.on("message", async (message) => { const data = JSON.parse(message.toString()); const command = data["command"]; const sendValue = {}; sendValue.parameters = []; const devices = this.http_api.getDevices(); 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": parameters = data["parameters"]; this.requests_handler.command(data["duid"], command, parameters); break; case "getRobots": sendValue.command = "robotList"; for (const robotID in devices) { robot = [robotID, devices.name]; sendValue.parameters.push(robot); } socket.send(JSON.stringify(sendValue)); break; case "getMap": this.requests_handler.getMap(data["duid"]); break; case "get_photo": this.requests_handler.getParameter(data["duid"], "get_photo", data["attribute"]); break; case "sniffing_decrypt": try { this.sniffing.decodeSniffedMessage(data, devices); } catch (error) { this.log.error("Failed to decode/decrypt sniffing message. " + error); if (this.supportsFeature && this.supportsFeature("PLUGINS")) { if (this.sentryInstance) { this.sentryInstance.getSentryObject().captureException("Failed to initialize API. Error: " + error); } } } break; } }); socket.on("close", () => { this.log.debug(`Client disconnected`); this.clearInterval(this.webSocketInterval); this.socket = null; }); socketServer.on("error", (error) => { this.log.error("WebSocket Server error: " + error); }); }); } async stopWebsocketServer() { socketServer.close(); } async onlineChecker(duid) { const devices = this.http_api.getDevices(); const device = devices.find((device) => device.duid == duid); // If the device is not found, return false. if (!device) { return false; } return device?.online; } async updateDeviceData(duid) { const robotModel = this.http_api.getRobotModel(duid); if (robotModel == "roborock.wm.a102") { // nothing for now } else if (robotModel == "roborock.wetdryvac.a56") { // nothing for now } else { await this.requests_handler.getParameter(duid, "get_fw_features"); await this.requests_handler.getParameter(duid, "get_multi_maps_list"); await this.requests_handler.getParameter(duid, "get_room_mapping"); await this.requests_handler.getParameter(duid, "get_consumable"); await this.requests_handler.getParameter(duid, "get_server_timer"); await this.requests_handler.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 this.requests_handler.getParameter(duid, "get_carpet_mode"); break; case "roborock.vacuum.a27": await this.requests_handler.getParameter(duid, "get_dust_collection_switch_status"); await this.requests_handler.getParameter(duid, "get_wash_towel_mode"); await this.requests_handler.getParameter(duid, "get_smart_wash_params"); await this.requests_handler.getParameter(duid, "app_get_dryer_setting"); break; default: await this.requests_handler.getParameter(duid, "get_carpet_mode"); await this.requests_handler.getParameter(duid, "get_carpet_clean_mode"); await this.requests_handler.getParameter(duid, "get_water_box_custom_mode"); } } } clearTimersAndIntervals() { if (this.commandTimeout) { this.clearTimeout(this.commandTimeout); } if (this.mainUpdateInterval) { this.clearInterval(this.mainUpdateInterval); } if (this.requests_handler) { this.requests_handler.clearQueue(); } if (this.mainUpdateInterval) { this.clearInterval(this.mainUpdateInterval); } if (this.webSocketInterval) { this.clearInterval(this.webSocketInterval); } } async updateConsumablesPercent(duid) { const devices = this.http_api.getDevices(); const device = devices.find((device) => device.duid === 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.setState(`Devices.${duid}.consumables.${attribute}`, { val: val, ack: true }); } } } async updateDeviceInfo(duid) { const devices = this.http_api.getDevices(); const device = devices.find((device) => device.duid === duid); for (const deviceAttribute in device) { let value = device[deviceAttribute]; if (typeof value != "object") { let unit; if (deviceAttribute == "activeTime") { unit = "h"; value = Math.round(device[deviceAttribute] / 1000 / 60 / 60); } await this.setObjectAsync("Devices." + duid + ".deviceInfo." + deviceAttribute, { type: "state", common: { name: deviceAttribute, type: this.getType(value), unit: unit, role: "value", read: true, write: false, }, native: {}, }); this.setStateChangedAsync("Devices." + duid + ".deviceInfo." + deviceAttribute, { val: value, ack: true }); } } } async checkForNewFirmware(duid) { this.log.debug(`Checking for new firmware`); const isLocalDevice = this.requests_handler.isLocalDevice(duid); if (isLocalDevice) { const update = await this.http_api.getFirmwareStates(duid); 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.setState("Devices." + duid + ".updateStatus." + state, { val: update.data.result[state], ack: true, }); } } } 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]; await 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 = await this.http_api.getRobotModel(duid); 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 getRobotVersion(duid) { const devices = this.http_api.getDevices(); 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.setObjectNotExistsAsync("clientID", { type: "state", common: { name: "Client ID", type: "string", role: "value", read: true, write: false, }, native: {}, }); await this.setObjectNotExistsAsync("endpoint", { type: "state", common: { name: "MQTT endpoint", type: "string", role: "value", read: true, write: false, }, native: {}, }); } async start_go2rtc() { const devices = this.http_api.getDevices(); let cameraCount = 0; const localKeys = this.http_api.getMatchedLocalKeys(); const go2rtcConfig = { streams: {} }; for (const device of devices) { const duid = device.duid; if (localKeys) { const localKey = localKeys.get(duid); const { u, s, k } = await this.http_api.get_rriot(); if (this.device_features.getFeatureList(duid).isCameraSupported) { cameraCount++; go2rtcConfig.streams[duid] = `roborock://mqtt-eu-3.roborock.com:8883?u=${u}&s=${s}&k=${k}&did=${duid}&key=${localKey}&pin=${this.config.cameraPin}`; } } } if (cameraCount > 0) { try { const go2rtcProcess = spawn(go2rtcPath.toString(), ["-config", JSON.stringify(go2rtcConfig)], { shell: false, detached: false, windowsHide: true }); go2rtcProcess.on("error", (error) => { this.log.error(`Error starting go2rtc: ${error.message}`); }); go2rtcProcess.stdout.on("data", (data) => { this.log.debug(`go2rtc output: ${data}`); }); go2rtcProcess.stderr.on("data", (data) => { this.log.error(`go2rtc error output: ${data}`); }); process.on("exit", () => { go2rtcProcess.kill(); }); } catch (error) { this.log.error(`Failed to start go2rtc: ${error.message}`); } } } async catchError(error, attribute, duid) { const robotModel = duid ? this.http_api.getRobotModel(duid) : "unknown device model"; if (error) { if (error.toString().includes("retry") || error.toString().includes("locating") || error.toString().includes("timed out after 30 seconds")) { this.log.warn(`Failed to process ${attribute} on robot ${duid} (${robotModel}): ${error}`); } else { this.log.error(`Failed to process ${attribute} on robot ${duid} (${robotModel}): ${error.stack || error}`); if (this.supportsFeature && this.supportsFeature("PLUGINS")) { if (this.sentryInstance) { this.sentryInstance.getSentryObject().captureException(`Error: ${error}`); } } } } } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * @param {() => void} callback */ onUnload(callback) { try { this.clearTimersAndIntervals(); this.setState("info.connection", { val: false, ack: true }); callback(); } catch (e) { this.catchError(e.stack); callback(); } } // If you need to react to object changes, uncomment the following block and the corresponding line in the constructor. // You also need to subscribe to the objects with `this.subscribeObjects`, similar to `this.subscribeStates`. // /** // * Is called if a subscribed object changes // * @param {string} id // * @param {ioBroker.Object | null | undefined} obj // */ // onObjectChange(id, obj) { // if (obj) { // // The object was changed // this.log.info(`object ${id} changed: ${JSON.stringify(obj)}`); // } else { // // The object was deleted // this.log.info(`object ${id} deleted`); // } // } /** * Is called if a subscribed state changes * @param {string} id * @param {ioBroker.State | null | undefined} state */ async onStateChange(id, state) { if (state) { const idParts = id.split("."); const duid = idParts[3]; const folder = idParts[4]; if (state.ack) { if (id.endsWith("online")) { this.log.info(`Device ${duid} is now ${state.val ? "online" : "offline"}`); } } else { const command = idParts[5]; this.log.debug(`onStateChange: ${command} with value: ${state.val}`); if (state.val == true && typeof state.val == "boolean") { if (folder == "resetConsumables") { await this.requests_handler.command(duid, "reset_consumable", command); } else { this.requests_handler.command(duid, command); } } else if (command == "load_multi_map") { await this.requests_handler.command(duid, command, [state.val]); } else if ( command == "app_start" || command == "app_segment_clean" || command == "app_charge" || command == "app_spot" || command == "app_zoned_clean" || command == "app_goto_target" ) { switch (command) { case "app_zoned_clean": case "app_goto_target": if (typeof state.val === "string") { try { const params = JSON.parse(state.val); if (command === "app_zoned_clean") { // Check if params is an array and contains multiple arrays if (Array.isArray(params) && params.every(Array.isArray)) { const allZonesValid = params.every((zone) => { const isCorrectLength = zone.length === 4 || zone.length === 5; const areAllNumbers = zone.every((item) => typeof item === "number"); const isValidFifth = zone.length !== 5 || (zone[4] >= 1 && zone[4] <= 3); return isCorrectLength && areAllNumbers && isValidFifth; }); if (allZonesValid) { this.requests_handler.command(duid, command, params); } else { this.log.error( `Invalid command parameters for ${command}: ${state.val}. Expected format: [[x1, y1, x2, y2, repeat], [x1, y1, x2, y2, repeat], ...] (where repeat is between 1 and 3)` ); } } else { this.log.error( `Invalid command parameters for ${command}: ${state.val}. Expected format: [[x1, y1, x2, y2, repeat], [x1, y1, x2, y2, repeat], ...] (where repeat is between 1 and 3)` ); } } else if (command === "app_goto_target") { // For app_goto_target, params should be an array with exactly two numbers const isCorrectLength = params.length === 2; const areAllNumbers = params.every((item) => typeof item === "number"); if (isCorrectLength && areAllNumbers) { this.requests_handler.command(duid, command, params); } else { this.log.error(`Invalid command parameters for ${command}: ${state.val}. Expected format: [x, y]`); } } } catch (error) { this.log.error(`Error parsing JSON for ${command}: ${error}`); } } break; } } else if (command == "startProgram") { this.http_api.executeScene(state); } else if (typeof state.val != "boolean") { this.requests_handler.command(duid, command, state.val); } if (typeof state.val == "boolean") { this.commandTimeout = this.setTimeout(() => { this.setState(id, false, true); }, 1000); } } } else { this.log.error(`Error! Missing state onChangeState!`); } } // If you need to accept messages in your adapter, uncomment the following block and the corresponding line in the constructor. // /** // * Some message was sent to this instance over message box. Used by email, pushover, text2speech, ... // * Using this method requires "common.messagebox" property to be set to true in io-package.json // * @param {ioBroker.Message} obj // */ // onMessage(obj) { // if (typeof obj === "object" && obj.message) { // if (obj.command === "send") { // // e.g. send email or pushover or whatever // this.log.info("send command"); // // Send response in callback if required // if (obj.callback) this.sendTo(obj.from, obj.command, "Message received", obj.callback); // } // } // } // this.log.debug(deviceId); // this.log.debug(id); // this.log.debug(result); // }); // } } if (require.main !== module) { // Export the constructor in compact mode /** * @param {Partial<utils.AdapterOptions>} [options={}] */ module.exports = (options) => new Roborock(options); } else { // otherwise start the instance directly new Roborock(); }