diff --git a/README.md b/README.md index 5c01d42430..0a361f3e8d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ This feature only works when map creation is enabled in the adapter options! Placeholder for the next version (at the beginning of the line): ### **WORK IN PROGRESS** --> + +### **WORK IN PROGRESS** + * (copystring) Add some missing attributes + * (copystring) Change min of update interval to 60s to prevent issues + * (copystring) Add web interface to sidebar + ### 0.6.16 (2024-10-02) * (copystring) Bugfixes * (copystring) update test-and-release.yml diff --git a/admin/jsonConfig.json b/admin/jsonConfig.json index 460de054d3..e8bac6db22 100644 --- a/admin/jsonConfig.json +++ b/admin/jsonConfig.json @@ -30,7 +30,7 @@ "type": "number", "label": "Update interval", "newLine": true, - "min": 1, + "min": 60, "max": 240, "style": { "width": "198px" diff --git a/io-package.json b/io-package.json index 236de3cc7f..bd71a01bde 100644 --- a/io-package.json +++ b/io-package.json @@ -147,8 +147,26 @@ "connectionType": "cloud", "dataSource": "poll", "adminUI": { - "config": "json" + "config": "json", + "tab": "html" }, + "adminTab": { + "name": { + "en": "Roborock", + "de": "Roborock", + "ru": "Roborock", + "pt": "Roborock", + "nl": "Roborock", + "fr": "Roborock", + "it": "Roborock", + "es": "Roborock", + "pl": "Roborock", + "zh-cn": "Roborock" + }, + "link": "%web_protocol%://%ip%:%webserverPort%/map.html", + "fa-icon": "" + }, + "localLink": "%web_protocol%://%ip%:%webserverPort%/map.html", "dependencies": [ { "js-controller": ">=5.0.19" diff --git a/lib/RRMapParser.js b/lib/RRMapParser.js index 463a2f9130..f82b780138 100644 --- a/lib/RRMapParser.js +++ b/lib/RRMapParser.js @@ -67,13 +67,7 @@ class RRMapParser { } BytesToInt(buffer, offset, len) { - let result = 0; - - for (let i = 0; i < len; i++) { - result |= (0x000000FF & parseInt(buffer[i + offset])) << 8 * i; - } - - return result; + return buffer.slice(offset, offset + len).reduce((acc, byte, i) => acc | (byte << (8 * i)), 0); } async parsedata(buf) { @@ -90,8 +84,7 @@ class RRMapParser { let dataPosition = 0x14; // Skip header - const result = {}; - result.metaData = metaData; + const result = { metaData }; while (dataPosition < metaData.data_length) { const type = buf.readUInt16LE(dataPosition); @@ -104,7 +97,6 @@ class RRMapParser { // this.adapter.log.debug("Known values: type=" + type + ", hlength=" + hlength + ", length=" + length); if (TYPES_REVERSE[type]) { - // this.adapter.log.debug("Test length: " + TYPES_REVERSE[type] + " " + length); // if (length < 100) this.adapter.log.debug("Test data type: " + TYPES_REVERSE[type] + " " + buf.toString("hex", dataPosition, dataPosition + length)); @@ -294,9 +286,12 @@ class RRMapParser { major: mapBuf.readUInt16LE(0x08), minor: mapBuf.readUInt16LE(0x0a), }, - map_index: mapBuf.readUInt32LE(0x0C), + map_index: mapBuf.readUInt32LE(0x0c), map_sequence: mapBuf.readUInt32LE(0x10), - SHA1: crypto.createHash("sha1").update(Uint8Array.prototype.slice.call(mapBuf, 0, mapBuf.length - 20)).digest("hex"), + SHA1: crypto + .createHash("sha1") + .update(Uint8Array.prototype.slice.call(mapBuf, 0, mapBuf.length - 20)) + .digest("hex"), expectedSHA1: Buffer.from(Uint8Array.prototype.slice.call(mapBuf, mapBuf.length - 20)).toString("hex"), }; } else { diff --git a/lib/deviceFeatures.js b/lib/deviceFeatures.js index 7e34e64178..b3a7e607a3 100644 --- a/lib/deviceFeatures.js +++ b/lib/deviceFeatures.js @@ -609,7 +609,6 @@ class deviceFeatures { "roborock.vacuum.a62", // S7 Pro Ultra "roborock.vacuum.a51", // S8 "roborock.vacuum.a15", // S7 - "roborock.vacuum.a72", // Q5 Pro "roborock.vacuum.a27", // S7 MaxV (Ultra) "roborock.vacuum.a19", // S4 Max "roborock.vacuum.a40", // Q7 @@ -766,6 +765,10 @@ class deviceFeatures { "set_back_type", "set_charge_status", "set_clean_percent", + "set_cleaned_area", + "set_switch_status", + "set_common_status", + "set_in_warmup", ], // Q8 Max "roborock.vacuum.a73": [ @@ -796,6 +799,7 @@ class deviceFeatures { "set_clean_percent", "set_rdt", "set_switch_status", + "set_cleaned_area", ], // S4 "roborock.vacuum.s4": ["setCleaningRecordsInt", "setConsumablesString"], @@ -855,6 +859,7 @@ class deviceFeatures { "set_in_warmup", "set_map_flag", "set_task_id", + "set_dss", ], // Roborock Qrevo S "roborock.vacuum.a104": [ @@ -905,7 +910,7 @@ class deviceFeatures { } } } else { - this.adapter.catchError(`This robot ${robotModel} is not fully supported just yet. Contact the dev to get this robot fully supported!`); + this.adapter.catchError(`This robot is not fully supported just yet. Contact the dev to get this robot fully supported!`, null, null, robotModel); } this.adapter.createBaseRobotObjects(this.duid); diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000000..0937ed196b --- /dev/null +++ b/lib/index.js @@ -0,0 +1,9 @@ +module.exports = { + localConnector: require("./localConnector").localConnector, + roborock_mqtt_connector: require("./roborock_mqtt_connector").roborock_mqtt_connector, + message: require("./message").message, + vacuum: require("./vacuum").vacuum, + roborockPackageHelper: require("./roborockPackageHelper").roborockPackageHelper, + deviceFeatures: require("./deviceFeatures").deviceFeatures, + messageQueueHandler: require("./messageQueueHandler").messageQueueHandler, +}; \ No newline at end of file diff --git a/lib/mapCreator.js b/lib/mapCreator.js index bcffee404c..aafb05ee7e 100644 --- a/lib/mapCreator.js +++ b/lib/mapCreator.js @@ -102,11 +102,12 @@ class MapCreator { const sy = y1 < y2 ? 1 : -1; let err = dx - dy; - for(;;) { + for (;;) { // Setze Pixel im ImageData - if (x1 >= 0 && x1 < imageData.width && y1 >= 0 && y1 < imageData.height) { // handle out of bounds. lineto would already do this but we need to set pixels directly + if (x1 >= 0 && x1 < imageData.width && y1 >= 0 && y1 < imageData.height) { + // handle out of bounds. lineto would already do this but we need to set pixels directly const index = (x1 + y1 * imageData.width) * 4; - pixels[index] = 128; // r + pixels[index] = 128; // r pixels[index + 1] = 128; // g pixels[index + 2] = 128; // b pixels[index + 3] = 128; // a @@ -157,8 +158,7 @@ class MapCreator { } else if (options.ROBOT === "originalRobot") { img = await loadImage(originalRobot); } - } - else { + } else { img = await loadImage(originalRobot); } img_charger = await loadImage(charger); @@ -210,7 +210,7 @@ class MapCreator { if (mapdata.IMAGE.pixels.segments && !mapdata.CURRENTLY_CLEANED_BLOCKS && colors.newmap) { mapdata.IMAGE.pixels.segments.forEach((px) => { - const segnum = (px >> 21); + const segnum = px >> 21; const x = this.getX(mapdata.IMAGE.dimensions, px & 0xfffff); const y = this.getY(mapdata.IMAGE.dimensions, px & 0xfffff); @@ -226,25 +226,25 @@ class MapCreator { } }); - Object.keys(segmentsData).forEach(segnum => { + Object.keys(segmentsData).forEach((segnum) => { const segment = segmentsData[segnum]; segmentsBounds[segnum] = { minX: segment.minX, maxX: segment.maxX, minY: segment.minY, - maxY: segment.maxY + maxY: segment.maxY, }; }); - Object.keys(segmentsBounds).forEach(segnum => { + Object.keys(segmentsBounds).forEach((segnum) => { const currentBounds = segmentsBounds[segnum]; - const adjacentSegments = Object.keys(segmentsBounds).filter(otherSegnum => { + const adjacentSegments = Object.keys(segmentsBounds).filter((otherSegnum) => { const otherBounds = segmentsBounds[otherSegnum]; return segnum !== otherSegnum && this.areRoomsAdjacent(currentBounds, otherBounds); }); - const usedColors = adjacentSegments.map(adjSegnum => assignedColors[adjSegnum]); - const availableColor = availableColors.find(color => !usedColors.includes(color)); + const usedColors = adjacentSegments.map((adjSegnum) => assignedColors[adjSegnum]); + const availableColor = availableColors.find((color) => !usedColors.includes(color)); if (availableColor) { assignedColors[segnum] = availableColor; @@ -253,11 +253,11 @@ class MapCreator { } }); - Object.keys(segmentsData).forEach(segnum => { + Object.keys(segmentsData).forEach((segnum) => { const segment = segmentsData[segnum]; ctx.fillStyle = assignedColors[segnum] || availableColors[0]; ctx.beginPath(); - segment.points.forEach(point => { + segment.points.forEach((point) => { ctx.rect(point.x, point.y, this.scaleFactor, this.scaleFactor); }); ctx.fill(); @@ -367,31 +367,24 @@ class MapCreator { } // Male den Pfad - if (mapdata.PATH) { - if (mapdata.PATH.points && mapdata.PATH.points.length !== 0) { - ctx.fillStyle = colors.path; - let first = true; - let cold1, cold2; + if (mapdata.PATH?.points?.length) { + ctx.fillStyle = colors.path; + ctx.lineWidth = this.scaleFactor / 2; + ctx.strokeStyle = colors.path; - ctx.beginPath(); - mapdata.PATH.points.forEach((coord) => { - if (first) { - (cold1 = this.robotXtoPixelX(mapdata.IMAGE, coord[0] / 50)), - (cold2 = this.robotYtoPixelY(mapdata.IMAGE, coord[1] / 50)), - ctx.fillRect(cold1, cold2, (1 * this.scaleFactor) / 2, (1 * this.scaleFactor) / 2); - first = false; - } else { - ctx.lineWidth = this.scaleFactor / 2; - ctx.strokeStyle = colors.path; + ctx.beginPath(); + let [cold1, cold2] = [this.robotXtoPixelX(mapdata.IMAGE, mapdata.PATH.points[0][0] / 50), this.robotYtoPixelY(mapdata.IMAGE, mapdata.PATH.points[0][1] / 50)]; + ctx.fillRect(cold1, cold2, (1 * this.scaleFactor) / 2, (1 * this.scaleFactor) / 2); + + mapdata.PATH.points.slice(1).forEach((coord) => { + ctx.moveTo(cold1, cold2); + cold1 = this.robotXtoPixelX(mapdata.IMAGE, coord[0] / 50); + cold2 = this.robotYtoPixelY(mapdata.IMAGE, coord[1] / 50); + ctx.lineTo(cold1, cold2); + }); - ctx.moveTo(cold1, cold2); - (cold1 = this.robotXtoPixelX(mapdata.IMAGE, coord[0] / 50)), (cold2 = this.robotYtoPixelY(mapdata.IMAGE, coord[1] / 50)), ctx.lineTo(cold1, cold2); - // ctx.stroke(); - } - }); - ctx.stroke(); - ctx.closePath(); - } + ctx.stroke(); + ctx.closePath(); } // Male geplanten Pfad @@ -692,7 +685,6 @@ class MapCreator { } else { return [createCanvas(1, 1).toDataURL(), createCanvas(1, 1).toDataURL()]; // return empty canvas } - } } diff --git a/lib/vacuum.js b/lib/vacuum.js index 5c70edf5d4..c4383f5257 100644 --- a/lib/vacuum.js +++ b/lib/vacuum.js @@ -64,7 +64,7 @@ class vacuum { // const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_status", []); const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_prop", ["get_status"]); - const selectedMap = deviceStatus[0].map_status >> 2 ?? -1; // to get the currently selected map perform bitwise right shift + const selectedMap = this.adapter.getSelectedMap(deviceStatus); // This is for testing and debugging maps. This can't be stored in a state. zlib.gzip(map, (error, buffer) => { @@ -101,7 +101,7 @@ class vacuum { } } } catch (error) { - this.adapter.catchError(error, "get_map_v1", duid), this.robotModel; + this.adapter.catchError(error, "get_map_v1", duid, this.robotModel); } } } @@ -286,7 +286,7 @@ class vacuum { } break; case "map_status": { - deviceStatus[0][attribute] = deviceStatus[0][attribute] >> 2 ?? -1; // to get the currently selected map perform bitwise right shift + deviceStatus[0][attribute] = this.adapter.getSelectedMap(deviceStatus); if (isCleaning) { this.adapter.startMapUpdater(duid); @@ -331,7 +331,7 @@ class vacuum { } } 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 roomFloor = this.adapter.getSelectedMap(deviceStatus); const mappedRooms = await this.adapter.messageQueueHandler.sendRequest(duid, "get_room_mapping", []); // if no rooms have been named, processing them can't work diff --git a/main.js b/main.js index 28ed104e02..c587de001b 100644 --- a/main.js +++ b/main.js @@ -1,71 +1,63 @@ -"use strict"; - const utils = require("@iobroker/adapter-core"); - const axios = require("axios"); -const crypto = require("crypto"); -const websocket = require("ws"); +const { randomBytes, createHmac, createHash } = require("crypto"); +const WebSocket = require("ws"); const express = require("express"); -const childProcess = require("child_process"); -const go2rtcPath = require("go2rtc-static"); // Pfad zur Binärdatei - -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; +const { spawn } = require("child_process"); +const go2rtcPath = require("go2rtc-static"); + +const { + localConnector: rrLocalConnector, + roborock_mqtt_connector, + message: rrMessage, + vacuum: vacuum_class, + roborockPackageHelper, + deviceFeatures, + messageQueueHandler, +} = require("./lib"); + let socketServer, webserver; const dockingStationStates = ["cleanFluidStatus", "waterBoxFilterStatus", "dustBagStatus", "dirtyWaterBoxStatus", "clearWaterBoxStatus", "isUpdownWaterReady"]; class Roborock extends utils.Adapter { - /** - * @param {Partial} [options={}] - */ - constructor(options) { - super({ - ...options, - name: "roborock", - useFormatDate: true, - }); + 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.localKeys = null; this.roomIDs = {}; this.vacuums = {}; this.socket = null; - this.idCounter = 0; - this.nonce = crypto.randomBytes(16); + this.nonce = randomBytes(16); this.messageQueue = new Map(); - + this.pendingRequests = new Map(); + this.localDevices = {}; + this.remoteDevices = new Set(); 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(); } /** * 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.log.info(`Starting adapter. This might take a few minutes depending on your setup. Please wait.`); this.sentryInstance = this.getPluginInstance("sentry"); - this.translations = require(`./admin/i18n/${this.language || "en"}/translations.json`); // fall back to en for test-and-release.yml await this.setupBasicObjects(); @@ -74,29 +66,20 @@ class Roborock extends utils.Adapter { let clientID = ""; try { const storedClientID = await this.getStateAsync("clientID"); - if (storedClientID) { - clientID = storedClientID.val?.toString() ?? ""; - } else { - clientID = crypto.randomUUID(); - await this.setStateAsync("clientID", { val: clientID, ack: true }); - } + clientID = storedClientID?.val?.toString() || crypto.randomUUID(); + await this.setState("clientID", { val: clientID, ack: true }); } catch (error) { - this.log.error(`Error while retrieving or setting clientID: ${error.message}`); - } - - if (!this.config.username || !this.config.password) { - this.log.error("Username or password missing!"); - return; + this.log.error(`Fehler beim Abrufen oder Setzen der clientID: ${error.message}`); } // Initialize the login API (which is needed to get access to the real API). this.loginApi = axios.create({ baseURL: "https://euiot.roborock.com", headers: { - header_clientid: crypto.createHash("md5").update(this.config.username).update(clientID).digest().toString("base64"), + header_clientid: createHash("md5").update(this.config.username).update(clientID).digest().toString("base64"), }, }); - await this.setStateAsync("info.connection", { val: true, ack: true }); + await this.setState("info.connection", { val: true, ack: true }); // api/v1/getUrlByEmail(email = ...) const userdata = await this.getUserData(this.loginApi); @@ -111,8 +94,8 @@ class Roborock extends utils.Adapter { this.sentryInstance.getSentryObject().captureException("Failed to login. Most likely wrong token! Deleting HomeData and UserData. Try again! " + error); } } - this.deleteStateAsync("HomeData"); - this.deleteStateAsync("UserData"); + this.delObjectAsync("HomeData"); + this.delObjectAsync("UserData"); } const rriot = userdata.rriot; @@ -123,12 +106,12 @@ class Roborock extends utils.Adapter { 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"); + const nonce = 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"); + const mac = createHmac("sha256", rriot.h).update(prestr).digest("base64"); config.headers["Authorization"] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`; } @@ -156,7 +139,7 @@ class Roborock extends utils.Adapter { const scene = await this.api.get(`user/scene/home/${homeId}`); - await this.setStateAsync("HomeData", { + await this.setState("HomeData", { val: JSON.stringify(homedataResult), ack: true, }); @@ -223,7 +206,7 @@ class Roborock extends utils.Adapter { 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`); + await this.delObjectAsync(`UserData`); } } } catch (error) { @@ -253,7 +236,7 @@ class Roborock extends utils.Adapter { throw new Error("Login returned empty userdata."); } - await this.setStateAsync("UserData", { + await this.setState("UserData", { val: JSON.stringify(userdata), ack: true, }); @@ -261,8 +244,8 @@ class Roborock extends utils.Adapter { return userdata; } catch (error) { this.log.error(`Error in getUserData: ${error.message}`); - await this.deleteStateAsync("HomeData"); - await this.deleteStateAsync("UserData"); + await this.delObjectAsync("HomeData"); + await this.delObjectAsync("UserData"); throw error; } } @@ -372,7 +355,7 @@ class Roborock extends utils.Adapter { const enabledPath = `Devices.${duid}.programs.${programID}.enabled`; await this.createStateObjectHelper(enabledPath, "enabled", "boolean", null, null, "value"); - this.setStateAsync(enabledPath, enabled, true); + this.setState(enabledPath, enabled, true); const items = JSON.parse(param).action.items; for (const item in items) { @@ -386,7 +369,7 @@ class Roborock extends utils.Adapter { if (typeOfValue == "object") { value = value.toString(); } - this.setStateAsync(objectPath, value, true); + this.setState(objectPath, value, true); } } } @@ -445,7 +428,7 @@ class Roborock extends utils.Adapter { } async startWebsocketServer() { - socketServer = new websocket.Server({ port: 7906 }); + socketServer = new WebSocket.Server({ port: 7906 }); let parameters, robot; socketServer.on("connection", async (socket) => { @@ -774,7 +757,7 @@ class Roborock extends utils.Adapter { const homedata = home.data.result; if (homedata) { - await this.setStateAsync("HomeData", { + await this.setState("HomeData", { val: JSON.stringify(homedata), ack: true, }); @@ -803,7 +786,7 @@ class Roborock extends utils.Adapter { if (targetConsumable) { const val = value >= 0 && value <= 100 ? parseInt(value) : 0; - await this.setStateAsync(`Devices.${duid}.consumables.${attribute}`, { val: val, ack: true }); + await this.setState(`Devices.${duid}.consumables.${attribute}`, { val: val, ack: true }); } } } @@ -867,7 +850,7 @@ class Roborock extends utils.Adapter { }, native: {}, }); - this.setStateAsync("Devices." + duid + ".updateStatus." + state, { + this.setState("Devices." + duid + ".updateStatus." + state, { val: update.data.result[state], ack: true, }); @@ -1137,6 +1120,12 @@ class Roborock extends utils.Adapter { return "Error in getRobotVersion. Version not found."; } + getSelectedMap(deviceStatus) { + const mapStatus = deviceStatus[0].map_status; + + return mapStatus >> 2; // to get the currently selected map perform bitwise right shift + } + getRequestId() { if (this.idCounter >= 9999) { this.idCounter = 0; @@ -1210,7 +1199,7 @@ class Roborock extends utils.Adapter { if (cameraCount > 0) { try { - const go2rtcProcess = childProcess.spawn(go2rtcPath.toString(), ["-config", JSON.stringify(go2rtcConfig)], { shell: false, detached: false, windowsHide: true }); + 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}`); @@ -1374,7 +1363,7 @@ class Roborock extends utils.Adapter { if (typeof state.val == "boolean") { this.commandTimeout = this.setTimeout(() => { - this.setStateAsync(id, false, true); + this.setState(id, false, true); }, 1000); } } @@ -1421,7 +1410,7 @@ if (require.main !== module) { //////////////////////////////////////////////////////////////////////////////////////////////////// function md5hex(str) { - return crypto.createHash("md5").update(str).digest("hex"); + return createHash("md5").update(str).digest("hex"); } // function md5bin(str) {