diff --git a/README.md b/README.md index e075aa80162..3adb322e4d4 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ The Valetudo binary however does not so if you are upgrading your firmware, you * Carpet Mode * Cleaning History * Volume Control -* [DEPRECATED] Image API which provides Map PNGs ### Screenshots: @@ -52,32 +51,6 @@ The Valetudo binary however does not so if you are upgrading your firmware, you 2. Replace the binary `/usr/local/bin/valetudo` by the new one (make sure to `chmod +x` it). 3. Start the service again `service valetudo start`. -### [DEPRECATED] Remote API -The map is available as a PNG using this API. -It can be found at: -`YOUR.VACUUM.ROBOT.IP/api/remote/map` - -The current implementation allows you to _grab_/**set**: -* The recent generated map as PNG -* The map contains the 2D contour and configurable: - - `drawPath` [**true**|undefined] - - `drawCharger` [**true**|undefined] - - `drawRobot` [**true**|undefined] - - `border` (in px around the map, will be scaled as well!), default: **2** - - `doCropping` (for debug purpose) [**true**|undefined] - - `scale` [1,..n], default: **4** -* The position of the charger (`charger[X,Y]`: position in px to overlay on the generated image) -* The position of the robot (`robot[X,Y]`: position in px to overlay on the generated image) -* The angle of the robot defined by the last path (`robotAngle`: angle in [0-360] of the robot; 0: oriented to the top, 90: oriented to the right) - -A fully configured call would look like that: -`YOUR.VACUUM.ROBOT.IP/api/remote/map?drawRobot=false&drawCharger=true&scale=5&border=3&doCropping=true&drawPath=true`. -The json answer would look like the following: -```json -{"scale":5, "border":15, "doCropping":true, "drawPath":true, "mapsrc":"/maps/2018-08-19_10-43-50.png", "drawCharger":true, "charger":[65,620], "drawRobot":false, "robot":[51,625], "robotAngle":90} -``` -If a parameter has not been defined/set, the default value will be used (marked bold above). - ### Misc Valetudo does not feature access controls and I'm not planning on adding it since I trust my local network. You could just put a reverse proxy with authentication in front of it if you really need it. diff --git a/client/index.html b/client/index.html index 84b6c424043..b7af5905cc9 100644 --- a/client/index.html +++ b/client/index.html @@ -2000,7 +2000,7 @@ if (remainingShownCount>0) { loadingBarSettingsCleaningHistory.setAttribute("indeterminate", "indeterminate"); var historyTimestamp = historyArray.shift(); //array is sorted with latest items in the beginning - fn.requestWithPayload("/api/clean_record", JSON.stringify({recordId : historyTimestamp}) , "PUT", function (err, res) { + fn.requestWithPayload("api/clean_record", JSON.stringify({recordId : historyTimestamp}) , "PUT", function (err, res) { loadingBarSettingsCleaningHistory.removeAttribute("indeterminate"); if (err) { ons.notification.toast(err, { buttonLabel: 'Dismiss', timeout: 1500 }); diff --git a/lib/Configuration.js b/lib/Configuration.js index e690db64012..a0c601a14da 100644 --- a/lib/Configuration.js +++ b/lib/Configuration.js @@ -13,7 +13,16 @@ const Configuration = function() { "mqtt" : { enabled: false, identifier: "rockrobo", - broker_url: "mqtt://foobar.example" + broker_url: "mqtt://user:pass@foobar.example", + mapSettings: { + drawPath: true, + drawCharger: true, + drawRobot: true, + border: 2, + doCropping: true, + scale: 4 + }, + mapUpdateInterval: 30000 } }; @@ -24,9 +33,10 @@ const Configuration = function() { try { this.settings = JSON.parse(fs.readFileSync(this.location)); } catch(e) { - //TODO: handle this console.error("Invalid configuration file!"); - throw e; + console.log("Writing new file using defaults"); + + this.persist(); } } else { console.log("No configuration file present. Creating one at:", this.location); diff --git a/lib/MqttClient.js b/lib/MqttClient.js index 4eb72a21614..61f748630d8 100644 --- a/lib/MqttClient.js +++ b/lib/MqttClient.js @@ -1,4 +1,5 @@ const mqtt = require("mqtt"); +const Tools = require("./Tools"); const COMMANDS = { @@ -27,23 +28,74 @@ const FAN_SPEEDS = { * @param options.vacuum {Vacuum} * @param options.brokerURL {string} * @param options.identifier {string} + * @param options.mapSettings {object} + * @param options.mapUpdateInterval {number} * @constructor */ const MqttClient = function(options) { this.vacuum = options.vacuum; this.brokerURL = options.brokerURL; this.identifier = options.identifier || "rockrobo"; + this.mapSettings = options.mapSettings || {}; + this.mapUpdateInterval = options.mapUpdateInterval || 30000; this.topics = { command: "valetudo/" + this.identifier + "/command", set_fan_speed: "valetudo/" + this.identifier + "/set_fan_speed", send_command: "valetudo/" + this.identifier + "/custom_command", state: "valetudo/" + this.identifier + "/state", - homeassistant_autoconf: "homeassistant/vacuum/valetudo_" + this.identifier + "/config" + map: "valetudo/" + this.identifier + "/map", + homeassistant_autoconf_vacuum: "homeassistant/vacuum/valetudo_" + this.identifier + "/config", + homeassistant_autoconf_map: "homeassistant/camera/valetudo_" + this.identifier + "_map/config" + }; + + this.autoconf_payloads = { + vacuum: { + name: this.identifier, + supported_features: [ + "turn_on", + "pause", + "stop", + "return_home", + "battery", + "status", + "locate", + "clean_spot", + "fan_speed", + "send_command" + ], + command_topic: this.topics.command, + battery_level_topic: this.topics.state, + battery_level_template: "{{ value_json.battery_level }}", + charging_topic: this.topics.state, + charging_template: "{{value_json.charging}}", + cleaning_topic: this.topics.state, + cleaning_template: "{{value_json.cleaning}}", + docked_topic: this.topics.state, + docked_template: "{{value_json.docked}}", + error_topic: this.topics.state, + error_template: "{{value_json.error}}", + fan_speed_topic: this.topics.state, + fan_speed_template: "{{ value_json.fan_speed }}", + set_fan_speed_topic: this.topics.set_fan_speed, + fan_speed_list: [ + "min", + "medium", + "high", + "max" + ], + send_command_topic: this.topics.send_command + }, + map: { + name: this.identifier + "_map", + unique_id: this.identifier + "_map", + topic: this.topics.map + } }; this.connect(); this.updateStateTopic(); + this.updateMapTopic(); }; MqttClient.prototype.connect = function() { @@ -56,45 +108,15 @@ MqttClient.prototype.connect = function() { this.topics.set_fan_speed ], err => { if(!err) { - this.client.publish(this.topics.homeassistant_autoconf, JSON.stringify({ - name: this.identifier, - supported_features: [ - "turn_on", - "pause", - "stop", - "return_home", - "battery", - "status", - "locate", - "clean_spot", - "fan_speed", - "send_command" - ], - command_topic: this.topics.command, - battery_level_topic: this.topics.state, - battery_level_template: "{{ value_json.battery_level }}", - charging_topic: this.topics.state, - charging_template: "{{value_json.charging}}", - cleaning_topic: this.topics.state, - cleaning_template: "{{value_json.cleaning}}", - docked_topic: this.topics.state, - docked_template: "{{value_json.docked}}", - error_topic: this.topics.state, - error_template: "{{value_json.error}}", - fan_speed_topic: this.topics.state, - fan_speed_template: "{{ value_json.fan_speed }}", - set_fan_speed_topic: this.topics.set_fan_speed, - fan_speed_list: [ - "min", - "medium", - "high", - "max" - ], - send_command_topic: this.topics.send_command - - }), { + + this.client.publish(this.topics.homeassistant_autoconf_vacuum, JSON.stringify(this.autoconf_payloads.vacuum), { + retain: true + }); + + this.client.publish(this.topics.homeassistant_autoconf_map, JSON.stringify(this.autoconf_payloads.map), { retain: true - }) + }); + } else { //TODO: needs more error handling console.error(err); @@ -108,6 +130,45 @@ MqttClient.prototype.connect = function() { } }; +MqttClient.prototype.updateMapTopic = function() { + if(this.mapUpdateTimeout) { + clearTimeout(this.mapUpdateTimeout); + } + + if(this.client && this.client.connected === true) { + Tools.FIND_LATEST_MAP((err, data) => { + if(!err) { + Tools.DRAW_MAP_PNG({ + mapData: data.mapData, + log: data.log, + settings: this.mapSettings + }, (err, buf) => { + if(!err) { + this.client.publish(this.topics.map, buf, {retain: true}); + this.mapUpdateTimeout = setTimeout(() => { + this.updateMapTopic() + }, this.mapUpdateInterval); + } else { + console.error(err); + this.mapUpdateTimeout = setTimeout(() => { + this.updateMapTopic() + }, this.mapUpdateInterval); + } + }) + } else { + console.error(err); + this.mapUpdateTimeout = setTimeout(() => { + this.updateMapTopic() + }, this.mapUpdateInterval); + } + }); + } else { + this.mapUpdateTimeout = setTimeout(() => { + this.updateMapTopic() + }, this.mapUpdateInterval); + } +}; + MqttClient.prototype.updateStateTopic = function() { if(this.stateUpdateTimeout) { clearTimeout(this.stateUpdateTimeout); @@ -144,7 +205,7 @@ MqttClient.prototype.updateStateTopic = function() { response.error = res.human_error; } - this.client.publish(this.topics.state, JSON.stringify(response)); + this.client.publish(this.topics.state, JSON.stringify(response), {retain: true}); this.stateUpdateTimeout = setTimeout(() => { this.updateStateTopic() }, 10000); @@ -227,4 +288,4 @@ MqttClient.prototype.handleCommand = function(topic, command) { }; -module.exports = MqttClient; +module.exports = MqttClient; \ No newline at end of file diff --git a/lib/Tools.js b/lib/Tools.js index d56e3fbd94e..819f6c7705b 100644 --- a/lib/Tools.js +++ b/lib/Tools.js @@ -1,8 +1,20 @@ const fs = require("fs"); const path = require("path"); +const Jimp = require("jimp"); +const zlib = require("zlib"); +const crypto = require("crypto"); + +const MapFunctions = require("../client/js/MapFunctions"); + +const chargerImagePath = path.join(__dirname, '../client/img/charger.png'); +const robotImagePath = path.join(__dirname, '../client/img/robot.png'); +const CORRECT_PPM_MAP_FILE_SIZE = 3145745; +const CORRECT_GRID_MAP_FILE_SIZE = 1048576; +const ENCRYPTED_ARCHIVE_DATA_PASSWORD = Buffer.from("RoCKR0B0@BEIJING"); + const Tools = { - MK_DIR_PATH : function(filepath) { + MK_DIR_PATH: function (filepath) { var dirname = path.dirname(filepath); if (!fs.existsSync(dirname)) { Tools.MK_DIR_PATH(dirname); @@ -10,7 +22,515 @@ const Tools = { if (!fs.existsSync(filepath)) { fs.mkdirSync(filepath); } + }, + BUFFER_IS_GZIP: function (buf) { + return Buffer.isBuffer(buf) && buf[0] === 0x1f && buf[1] === 0x8b; + }, + /** + * + * @param options {object} + * @param options.mapData + * @param options.log + * @param options.settings + * @param callback {function} + * @constructor + */ + DRAW_MAP_PNG: function (options, callback) { + const COLORS = { + free: Jimp.rgbaToInt(0, 118, 255, 255), + obstacle_weak: Jimp.rgbaToInt(102, 153, 255, 255), + obstacle_strong: Jimp.rgbaToInt(82, 174, 255, 255), + path: Jimp.rgbaToInt(255, 255, 255, 255) + }; + const DIMENSIONS = { + width: 1024, + height: 1024 + }; + + const settings = Object.assign({ + drawPath: true, + drawCharger: true, + drawRobot: true, + border: 2, + doCropping: true, + scale: 4 + }, options.settings); + + + const viewport = { + x: { + min: DIMENSIONS.width, + max: 0, + offset: 0 + }, + y: { + min: DIMENSIONS.height, + max: 0, + offset: 0 + } + }; + + new Jimp(DIMENSIONS.width, DIMENSIONS.height, function (err, image) { + if (!err) { + //Step 1: Draw Map + calculate viewport + options.mapData.map.forEach(function drawPixelAndKeepTrackOfViewport(px) { + let color; + const coordinates = {}; + + //This maps the ppm pixel index to its respective x/y value + coordinates.y = Math.floor(px[0] / (DIMENSIONS.height * 4)); + coordinates.x = ((px[0] - coordinates.y * (DIMENSIONS.width * 4)) / 4); + + + //Update Viewport so we can crop later + if (coordinates.x > viewport.x.max) { + viewport.x.max = coordinates.x; + } else if (coordinates.x < viewport.x.min) { + viewport.x.min = coordinates.x; + } + + if (coordinates.y > viewport.y.max) { + viewport.y.max = coordinates.y; + } else if (coordinates.y < viewport.y.min) { + viewport.y.min = coordinates.y; + } + + if (px[1] === 0 && px[2] === 0 && px[3] === 0) { + color = COLORS.obstacle_strong; + } else if (px[1] === 255 && px[2] === 255 && px[3] === 255) { + color = COLORS.free; + } else { + color = COLORS.obstacle_weak; + } + + image.setPixelColor(color, coordinates.x, coordinates.y); + }); + + //Step 2: Crop + if (settings.doCropping === true) { + viewport.x.offset = viewport.x.min - settings.border; + viewport.y.offset = viewport.y.min - settings.border; + + image.crop( + viewport.x.offset, + viewport.y.offset, + viewport.x.max - viewport.x.min + 2 * settings.border, + viewport.y.max - viewport.y.min + 2 * settings.border + ); + } + + //Step 3: Scale + image.scale(settings.scale, Jimp.RESIZE_NEAREST_NEIGHBOR); + + //Step 4: Analyze Path + const lines = options.log.split("\n"); + + let coords = []; + var startLine = 0; + + if (settings.drawPath === false && lines.length > 10) { + //reduce unnecessary calculation time if path is not drawn + startLine = lines.length - 10; + } + + for (let lc = startLine, len = lines.length; lc < len; lc++) { + const line = lines[lc]; + if (line.indexOf("reset") !== -1) { + coords = []; + } + if (line.indexOf("estimate") !== -1) { + let splitLine = line.split(" "); + let x = (DIMENSIONS.width / 2) + (splitLine[2] * 20); + let y = splitLine[3] * 20; + if (options.mapData.yFlipped) { + y = y * -1; + } + //move coordinates to match cropped viewport + x -= viewport.x.offset; + y -= viewport.y.offset; + coords.push([ + Math.round(x * settings.scale), + Math.round(((DIMENSIONS.width / 2) + y) * settings.scale) + ]); + } + } + + //Step 5: Draw Path + let first = true; + let oldPathX, oldPathY; // old Coordinates + let dx, dy; //delta x and y + let step, x, y, i; + coords.forEach(function (coord) { + if (!first && settings.drawPath) { + dx = (coord[0] - oldPathX); + dy = (coord[1] - oldPathY); + if (Math.abs(dx) >= Math.abs(dy)) { + step = Math.abs(dx); + } else { + step = Math.abs(dy); + } + dx = dx / step; + dy = dy / step; + x = oldPathX; + y = oldPathY; + i = 1; + while (i <= step) { + image.setPixelColor(COLORS.path, x, y); + x = x + dx; + y = y + dy; + i = i + 1; + } + } + oldPathX = coord[0]; + oldPathY = coord[1]; + first = false; + }); + // noinspection JSUnusedAssignment + const robotPosition = { + x: oldPathX, + y: oldPathY, + known: oldPathX !== undefined && oldPathY !== undefined + }; + + var robotAngle = 0; + if (coords.length > 2) { + //the image has the offset of 90 degrees (top = 0 deg) + robotAngle = 90 + Math.atan2(coords[coords.length - 1][1] - coords[coords.length - 2][1], coords[coords.length - 1][0] - coords[coords.length - 2][0]) * 180 / Math.PI; + } + + Jimp.read(chargerImagePath, function (err, chargerImage) { + if (!err) { + Jimp.read(robotImagePath, function (err, robotImage) { + if (!err) { + //Step 6: Draw Charger + if (settings.drawCharger === true) { + image.composite( + chargerImage, + (((DIMENSIONS.width / 2) - viewport.x.offset) * settings.scale) - chargerImage.bitmap.width / 2, + (((DIMENSIONS.height / 2) - viewport.y.offset) * settings.scale) - chargerImage.bitmap.height / 2 + ); + } + + //Step 7: Draw Robot + if (settings.drawRobot === true && robotPosition.known === true) { + image.composite( + robotImage.rotate(-1 * robotAngle), + robotPosition.x - robotImage.bitmap.width / 2, + robotPosition.y - robotImage.bitmap.height / 2 + ) + } + + image.getBuffer(Jimp.AUTO, callback) + } else { + callback(err); + } + }) + } else { + callback(err); + } + }); + } else { + callback(err); + } + }); + }, + GENERATE_TEST_MAP: function () { + const mapData = []; + for (let y = 0; y < 1024; y++) { + for (let x = 0; x < 1024; x++) { + let index = 4 * (y * 1024 + x); + + // 4x4m square + if (x >= 472 && x <= 552 && y >= 472 && y <= 552) { + if (x === 472 || x === 552 || y === 472 || y === 552) { + mapData.push([index, 0, 0, 0]); + } else { + mapData.push([index, 255, 255, 255]); + } + } + } + } + return {map: mapData, yFlipped: false}; + }, + GENERATE_TEST_PATH: function () { + const lines = [ + // 3 + "estimate 0 -1.5 -0.5", + "estimate 0 -1 -0.5", + "estimate 0 -1 0", + "estimate 0 -1.5 0", + "estimate 0 -1 0", + "estimate 0 -1 0.5", + "estimate 0 -1.5 0.5", + // 5 + "estimate 0 -0.75 -0.5", + "estimate 0 -0.25 -0.5", + "estimate 0 -0.75 -0.5", + "estimate 0 -0.75 0", + "estimate 0 -0.25 0", + "estimate 0 -0.25 0.5", + "estimate 0 -0.75 0.5", + // C + "estimate 0 0 -0.5", + "estimate 0 0.5 -0.5", + "estimate 0 0 -0.5", + "estimate 0 0 0.5", + "estimate 0 0.5 0.5", + // 3 + "estimate 0 0.75 -0.5", + "estimate 0 1.25 -0.5", + "estimate 0 1.25 0", + "estimate 0 0.75 0", + "estimate 0 1.25 0", + "estimate 0 1.25 0.5", + "estimate 0 0.75 0.5" + ]; + // noinspection JSConstructorReturnsPrimitive + return lines.join("\n"); + }, + FIND_LATEST_MAP: function (callback) { + if (process.env.VAC_MAP_TEST) { + callback(null, { + mapData: Tools.GENERATE_TEST_MAP(), + log: Tools.GENERATE_TEST_PATH() + }) + } else { + Tools.FIND_LATEST_MAP_IN_RAMDISK(callback); + } + }, + FIND_LATEST_MAP_IN_RAMDISK: function (callback) { + fs.readdir("/dev/shm", function (err, filenames) { + if (err) { + callback(err); + } else { + let mapFileName; + let logFileName; + + filenames.forEach(function (filename) { + if (filename.endsWith(".ppm")) { + mapFileName = filename; + } + if (filename === "SLAM_fprintf.log") { + logFileName = filename; + } + }); + + if (mapFileName && logFileName) { + fs.readFile(path.join("/dev/shm", logFileName), function (err, file) { + if (err) { + callback(err); + } else { + const log = file.toString(); + if (log.indexOf("estimate") !== -1) { + const mapPath = path.join("/dev/shm", mapFileName); + + fs.readFile(mapPath, function (err, file) { + if (err) { + callback(err); + } else { + if (file.length !== CORRECT_PPM_MAP_FILE_SIZE) { + let tries = 0; + let newFile = new Buffer.alloc(0); + + //I'm 1000% sure that there is a better way to fix incompletely written map files + //But since its a ramdisk I guess this hack shouldn't matter that much + //Maybe someday I'll implement a better solution. Not today though + while (newFile.length !== CORRECT_PPM_MAP_FILE_SIZE && tries <= 250) { + tries++; + newFile = fs.readFileSync(mapPath); + } + + if (newFile.length === CORRECT_PPM_MAP_FILE_SIZE) { + callback(null, { + mapData: Tools.PARSE_PPM_MAP(newFile), + log: log + }) + } else { + fs.readFile("/dev/shm/GridMap", function (err, gridMapFile) { + if (err) { + callback(new Error("Unable to get complete map file")) + } else { + callback(null, { + mapData: Tools.PARSE_GRID_MAP(gridMapFile), + log: log + }) + } + }) + } + } else { + callback(null, { + mapData: Tools.PARSE_PPM_MAP(file), + log: log + }) + } + } + }) + } else { + Tools.FIND_LATEST_MAP_IN_ARCHIVE(callback) + } + } + }) + } else { + Tools.FIND_LATEST_MAP_IN_ARCHIVE(callback) + } + } + }) + }, + FIND_LATEST_MAP_IN_ARCHIVE: function (callback) { + fs.readdir("/mnt/data/rockrobo/rrlog", function (err, filenames) { + if (err) { + callback(err); + } else { + let folders = []; + + filenames.forEach(function (filename) { + if (/^([0-9]{6})\.([0-9]{17})_(R([0-9]{4})S([0-9]{8})|[0-9]{13})_([0-9]{10})REL$/.test(filename)) { + folders.push(filename); + } + }); + folders = folders.sort().reverse(); + + let newestUsableFolderName; + let mapFileName; + let logFileName; + + for (let i in folders) { + const folder = folders[i]; + try { + const folderContents = fs.readdirSync(path.join("/mnt/data/rockrobo/rrlog", folder)); + let possibleMapFileNames = []; + mapFileName = undefined; + logFileName = undefined; + + + folderContents.forEach(function (filename) { + if (/^navmap([0-9]+)\.ppm\.([0-9]{4})(\.rr)?\.gz$/.test(filename)) { + possibleMapFileNames.push(filename); + } + if (/^SLAM_fprintf\.log\.([0-9]{4})(\.rr)?\.gz$/.test(filename)) { + logFileName = filename; + } + }); + + possibleMapFileNames = possibleMapFileNames.sort(); + mapFileName = possibleMapFileNames.pop(); + + if (mapFileName && logFileName) { + newestUsableFolderName = folder; + break; + } + } catch (e) { + console.error(e); + } + } + + if (newestUsableFolderName && mapFileName && logFileName) { + fs.readFile(path.join("/mnt/data/rockrobo/rrlog", newestUsableFolderName, logFileName), function (err, file) { + if (err) { + callback(err); + } else { + Tools.DECRYPT_AND_UNPACK_FILE(file, function (err, unzippedFile) { + if (err) { + callback(err); + } else { + const log = unzippedFile.toString(); + if (log.indexOf("estimate") !== -1) { + fs.readFile(path.join("/mnt/data/rockrobo/rrlog", newestUsableFolderName, mapFileName), function (err, file) { + if (err) { + callback(err); + } else { + Tools.DECRYPT_AND_UNPACK_FILE(file, function (err, unzippedFile) { + if (err) { + callback(err); + } else { + callback(null, { + mapData: Tools.PARSE_PPM_MAP(unzippedFile), + log: log, + }) + } + }); + } + }) + } else { + callback(new Error("No usable map data found")); + } + } + }); + } + }) + } else { + callback(new Error("No usable map data found")); + } + } + }) + }, + DECRYPT_AND_UNPACK_FILE: function (file, callback) { + const decipher = crypto.createDecipheriv("aes-128-ecb", ENCRYPTED_ARCHIVE_DATA_PASSWORD, ""); + let decryptedBuffer; + + if (Buffer.isBuffer(file)) { + //gzip magic bytes + if (Tools.BUFFER_IS_GZIP(file)) { + zlib.gunzip(file, callback); + } else { + try { + decryptedBuffer = Buffer.concat([decipher.update(file), decipher.final()]); + } catch (e) { + return callback(e); + } + if (Tools.BUFFER_IS_GZIP(decryptedBuffer)) { + zlib.gunzip(decryptedBuffer, callback); + } else { + callback(new Error("Couldn't decrypt file")); + } + } + } else { + callback(new Error("Missing file")) + } + }, + PARSE_GRID_MAP: function (buf) { + const map = []; + + if (buf.length === CORRECT_GRID_MAP_FILE_SIZE) { + for (let i = 0; i < buf.length; i++) { + let px = buf.readUInt8(i); + + if (px !== 0) { + px = px === 1 ? 0 : px; + map.push([i + i * 3, px, px, px]) + } + } + } + + let width = 1024, height = 1024, size = 4; + let transform = MapFunctions.TRANSFORM_COORD_FLIP_Y; + for (let i = map.length - 1; i >= 0; --i) { + let idx = map[i][0]; + let xy = MapFunctions.mapIndexToMapCoord(idx, width, height, size); + let xy2 = MapFunctions.applyCoordTransform(transform, xy, width, height); + map[i][0] = MapFunctions.mapCoordToMapIndex(xy2, width, height, size); + } + return {map: map, yFlipped: true}; + }, + PARSE_PPM_MAP: function (buf) { + const map = []; + + if (buf.length === CORRECT_PPM_MAP_FILE_SIZE) { + for (let i = 17, j = 0; i <= buf.length - 16; i += 3, j++) { + let r = buf.readUInt8(i); + let g = buf.readUInt8(i + 1); + let b = buf.readUInt8(i + 2); + + if (!(r === 125 && g === 125 && b === 125)) { + map.push([j + j * 3, r, g, b]) + } + } + } + + return {map: map, yFlipped: true}; } }; +//TODO: is yFlipped even needed anymore? + module.exports = Tools; \ No newline at end of file diff --git a/lib/Valetudo.js b/lib/Valetudo.js index 30cc898f808..70080e61c99 100644 --- a/lib/Valetudo.js +++ b/lib/Valetudo.js @@ -35,7 +35,9 @@ const Valetudo = function() { this.mqttClient = new MqttClient({ vacuum: this.vacuum, brokerURL: this.configuration.get("mqtt").broker_url, - identifier: this.configuration.get("mqtt").identifier + identifier: this.configuration.get("mqtt").identifier, + mapSettings: this.configuration.get("mqtt").mapSettings, + mapUpdateInterval: this.configuration.get("mqtt").mapUpdateInterval, }); } }; diff --git a/lib/webserver/WebServer.js b/lib/webserver/WebServer.js index 874da678399..d8a94aaa31c 100644 --- a/lib/webserver/WebServer.js +++ b/lib/webserver/WebServer.js @@ -10,6 +10,7 @@ const Jimp = require("jimp"); const url = require("url"); const MapFunctions = require("../../client/js/MapFunctions"); +const Tools = require("../Tools"); //assets @@ -529,283 +530,10 @@ const WebServer = function (options) { } }); - this.app.get("/api/remote/map", function (req, res) { - WebServer.FIND_LATEST_MAP(function (err, data) { - if (!err && data.mapData.map.length > 0) { - var width = 1024; - var height = 1024; - //create current map - new Jimp(width, height, function (err, image) { - if (!err) { - //configuration - //default parameter - var scale = 4; - var doCropping = true; - var border = 2; - var drawPath = true; - var drawCharger = true; - var drawRobot = true; - var returnImage = false; // per default: do not change output -> keep it default - //get given parameter - var urlObj = url.parse(req.url, true); - if (urlObj['query']['scale'] !== undefined) { - scale = parseInt(urlObj['query']['scale']); - } - if (urlObj['query']['border'] !== undefined) { - border = parseInt(urlObj['query']['border']); - } - if (urlObj['query']['drawPath'] !== undefined) { - drawPath = (urlObj['query']['drawPath'] == 'true'); - } - if (urlObj['query']['doCropping'] !== undefined) { - doCropping = (urlObj['query']['doCropping'] == 'true'); - } - if (urlObj['query']['drawCharger'] !== undefined) { - drawCharger = (urlObj['query']['drawCharger'] == 'true'); - } - if (urlObj['query']['drawRobot'] !== undefined) { - drawRobot = (urlObj['query']['drawRobot'] == 'true'); - } - // returnImage: identical to previous query checks - if (urlObj['query']['returnImage'] !== undefined) { - returnImage = (urlObj['query']['returnImage'] == 'true'); - } - //for cropping - var xMin = width; - var xMax = 0; - var yMin = height; - var yMax = 0; - //variables for colors - let color; - let colorFree = Jimp.rgbaToInt(0, 118, 255, 255); - let colorObstacleStrong = Jimp.rgbaToInt(102, 153, 255, 255); - let colorObstacleWeak = Jimp.rgbaToInt(82, 174, 255, 255); - data.mapData.map.forEach(function (px) { - //calculate positions of pixel number - var yPos = Math.floor(px[0] / (height * 4)); - var xPos = ((px[0] - yPos * (width * 4)) / 4); - //cropping - if (yPos > yMax) yMax = yPos; - else if (yPos < yMin) yMin = yPos; - if (xPos > xMax) xMax = xPos; - else if (xPos < xMin) xMin = xPos; - if (px[1] === 0 && px[2] === 0 && px[3] === 0) { - color = colorObstacleStrong; - } else if (px[1] === 255 && px[2] === 255 && px[3] === 255) { - color = colorFree; - } else { - color = colorObstacleWeak; - } - //set pixel on position - image.setPixelColor(color, xPos, yPos); - }); - //crop to the map content - let croppingOffsetX = 0; - let croppingOffsetY = 0; - if (doCropping) { - croppingOffsetX = (xMin - border); - croppingOffsetY = (yMin - border); - image.crop(croppingOffsetX, croppingOffsetY, xMax - xMin + 2 * border, yMax - yMin + 2 * border); - } - //scale the map - image.scale(scale, Jimp.RESIZE_NEAREST_NEIGHBOR); - //draw path - //1. get coordinates (take respekt of reset) - const lines = data.log.split("\n"); - let coords = []; - let line; - var startLine = 0; - if (!drawPath && lines.length > 10) { - //reduce unnecessarycalculation time if path is not drawn - startLine = lines.length - 10; - } - for (var lc = startLine, len = lines.length; lc < len; lc++) { - line = lines[lc]; - if (line.indexOf("reset") !== -1) { - coords = []; - } - if (line.indexOf("estimate") !== -1) { - let splitLine = line.split(" "); - let x = (width / 2) + (splitLine[2] * 20); - let y = splitLine[3] * 20; - if (data.mapData.yFlipped) { - y = y * -1; - } - //move coordinates to match cropped pane - x -= croppingOffsetX; - y -= croppingOffsetY; - coords.push([ - Math.round(x * scale), - Math.round(((width / 2) + y) * scale) - ]); - } - } - - //2. draw path - let first = true; - let pathColor = Jimp.rgbaToInt(255, 255, 255, 255); - let oldPathX, oldPathY; // old Coordinates - let dx, dy; //delta x and y - let step, x, y, i; - coords.forEach(function (coord) { - if (!first && drawPath) { - dx = (coord[0] - oldPathX); - dy = (coord[1] - oldPathY); - if (Math.abs(dx) >= Math.abs(dy)) { - step = Math.abs(dx); - } else { - step = Math.abs(dy); - } - dx = dx / step; - dy = dy / step; - x = oldPathX; - y = oldPathY; - i = 1; - while (i <= step) { - image.setPixelColor(pathColor, x, y); - x = x + dx; - y = y + dy; - i = i + 1; - } - } - oldPathX = coord[0]; - oldPathY = coord[1]; - first = false; - }); - var robotPositionX = oldPathX; - var robotPositionY = oldPathY; - var robotAngle = 0; - if (coords.length > 2) { - //the image has the offset of 90 degrees (top = 0 deg) - robotAngle = 90 + Math.atan2(coords[coords.length - 1][1] - coords[coords.length - 2][1], coords[coords.length - 1][0] - coords[coords.length - 2][0]) * 180 / Math.PI; - } - //use process.env.VAC_TMP_PATH to define a path on your dev machine - like C:/Windows/Temp - var tmpDir = (process.env.VAC_TMP_PATH ? process.env.VAC_TMP_PATH : "/tmp"); - var directory = "/maps/"; - if (!fs.existsSync(tmpDir + directory)) { - fs.mkdirSync(tmpDir + directory); - } - //delete old image (keep the last 5 images generated) - var numerOfFiles = 0; - fs.readdirSync(tmpDir + directory) - .sort(function (a, b) { - return a < b; - }) - .forEach(function (file) { - numerOfFiles++; - if (numerOfFiles > 5) { - fs.unlink(tmpDir + directory + file, err => { - if (err) console.log(err) - }); - //console.log( "removing " + toClientDir + directory + file); - } - }); - //Position on the bitmap (0/0 is bottom-left) - var homeX = 0; - var homeY = 0; - var imagePath; - //set charger position - homeX = ((width / 2) - croppingOffsetX) * scale; - homeY = ((height / 2) - croppingOffsetY) * scale; - //save image - var date = new Date(); - var dd = (date.getDate() < 10 ? '0' : '') + date.getDate(); - var mm = ((date.getMonth() + 1) < 10 ? '0' : '') + (date.getMonth() + 1); - var yyyy = date.getFullYear(); - var HH = (date.getHours() < 10 ? '0' : '') + date.getHours(); - var MM = (date.getMinutes() < 10 ? '0' : '') + date.getMinutes(); - var SS = (date.getSeconds() < 10 ? '0' : '') + date.getSeconds(); - var fileName = yyyy + "-" + mm + "-" + dd + "_" + HH + "-" + MM + "-" + SS + ".png"; - imagePath = directory + fileName; - //Pretty dumb case selection (doubled code for charger drawing), but no idea how to get this implemented in a more clever way. - //Help/Suggestions are (as always) very welcome! - //send result - function sendResult() { - // if returnImage is true, send directly the previously created map image - if (returnImage) { - // readFile with absolute path - fs.readFile(tmpDir + imagePath, function (err, content) { - if (err) { - res.status(500).send(err.toString()); - } else { - //specify response content - res.writeHead(200, {'Content-type': 'image/png'}); - res.end(content); - } - }); - } else { - res.json({ - scale, - border: border * scale, - doCropping, - drawPath, - mapsrc: imagePath, - drawCharger, - charger: [homeX, homeY], - drawRobot, - robot: [robotPositionX, robotPositionY], - robotAngle: Math.round(robotAngle) - }); - } - } - - if (!drawCharger && !drawRobot) { - //console.log("Drawing no charger - no robot!"); - image.write(tmpDir + imagePath); - sendResult(); - } else if (drawRobot) { - //robot should be drawn (and maybe charger) - Jimp.read(robotImagePath) - .then(robotImage => { - let xPos = robotPositionX - robotImage.bitmap.width / 2; - let yPos = robotPositionY - robotImage.bitmap.height / 2; - robotImage.rotate(-1 * robotAngle); //counter clock wise - image.composite(robotImage, xPos, yPos); - if (drawCharger) { - Jimp.read(chargerImagePath) - .then(chargerImage => { - let xPos = homeX - chargerImage.bitmap.width / 2; - let yPos = homeY - chargerImage.bitmap.height / 2; - image.composite(chargerImage, xPos, yPos); - //console.log("Drawing charger - robot!"); - image.write(tmpDir + imagePath); - sendResult(); - }); - } else { - //console.log("Drawing no charger - robot!"); - image.write(tmpDir + imagePath); - sendResult(); - } - }); - } else { - //draw charger but no robot - Jimp.read(chargerImagePath) - .then(chargerImage => { - let xPos = homeX - chargerImage.bitmap.width / 2; - let yPos = homeY - chargerImage.bitmap.height / 2; - image.composite(chargerImage, xPos, yPos); - //console.log("Drawing charger - no robot!"); - image.write(tmpDir + imagePath); - sendResult(); - }); - } - } else { - res.status(500).send(err.toString()); - } - }); - } else { - res.status(500).send(err != null ? err.toString() : "No usable map found, start cleaning and try again."); - } - }, - self.configuration.get("dontFlipGridMap"), //TODO: this whole dontFlipGridMap thing is just weird. Fix this mess - self.configuration.get("preferGridMap") //TODO: same for preferGridMap - ); - }); - this.app.get("/api/map/latest", function (req, res) { var parsedUrl = url.parse(req.url, true); const doNotTransformPath = parsedUrl.query.doNotTransformPath !== undefined; - WebServer.FIND_LATEST_MAP(function (err, data) { + Tools.FIND_LATEST_MAP(function (err, data) { if (!err) { const lines = data.log.split("\n"); let coords = []; @@ -842,365 +570,12 @@ const WebServer = function (options) { }); }); - //this results in searching client folder first and - //if file was not found within that folder, the tmp folder will be searched for that file this.app.use(express.static(path.join(__dirname, "../..", 'client'))); - this.app.use(express.static((process.env.VAC_TMP_PATH ? process.env.VAC_TMP_PATH : "/tmp"))); //TODO: Don't. No. This is bad. this.app.listen(this.port, function () { console.log("Webserver running on port", self.port) }) }; -WebServer.PARSE_PPM_MAP = function (buf) { - const map = []; - - if (buf.length === WebServer.CORRECT_PPM_MAP_FILE_SIZE) { - for (let i = 17, j = 0; i <= buf.length - 16; i += 3, j++) { - let r = buf.readUInt8(i); - let g = buf.readUInt8(i + 1); - let b = buf.readUInt8(i + 2); - - if (!(r === 125 && g === 125 && b === 125)) { - map.push([j + j * 3, r, g, b]) - } - } - } - - return {map: map, yFlipped: true}; -}; - -WebServer.PARSE_GRID_MAP = function (buf, dontFlipGridMap) { - const map = []; - - if (buf.length === WebServer.CORRECT_GRID_MAP_FILE_SIZE) { - for (let i = 0; i < buf.length; i++) { - let px = buf.readUInt8(i); - - if (px !== 0) { - px = px === 1 ? 0 : px; - map.push([i + i * 3, px, px, px]) - } - } - } - - // y will be flipped by default, unless dontFlipGridMap is set in the configuration. - let yFlipped = !dontFlipGridMap; - if (yFlipped) { - let width = 1024, height = 1024, size = 4; - let transform = MapFunctions.TRANSFORM_COORD_FLIP_Y; - for (let i = map.length - 1; i >= 0; --i) { - let idx = map[i][0]; - let xy = MapFunctions.mapIndexToMapCoord(idx, width, height, size); - let xy2 = MapFunctions.applyCoordTransform(transform, xy, width, height); - map[i][0] = MapFunctions.mapCoordToMapIndex(xy2, width, height, size); - } - } - return {map: map, yFlipped: yFlipped}; - -}; - - -//TODO: why is nothing using this??? -WebServer.PARSE_MAP_AUTO = function (filename, dontFlipGridMap) { - // this function automatically determines whether a GridMap or PPM map is used, based on file size. - let mapData = {map: [], yFlipped: true}; - try { - const mapBytes = fs.readFileSync(filename); - if (mapBytes.length === WebServer.CORRECT_GRID_MAP_FILE_SIZE) { - mapData = WebServer.PARSE_GRID_MAP(mapBytes, dontFlipGridMap); - } else if (mapBytes.length === WebServer.CORRECT_PPM_MAP_FILE_SIZE) { - mapData = WebServer.PARSE_PPM_MAP(mapBytes); - } - // else: map stays empty - } catch (err) { - // map stays empty - } - return mapData; -}; - -WebServer.GENERATE_TEST_MAP = function () { - let mapData = []; - for (let y = 0; y < 1024; y++) { - for (let x = 0; x < 1024; x++) { - let index = 4 * (y * 1024 + x); - - // 4x4m square - if (x >= 472 && x <= 552 && y >= 472 && y <= 552) { - if (x === 472 || x === 552 || y === 472 || y === 552) { - mapData.push([index, 0, 0, 0]); - } else { - mapData.push([index, 255, 255, 255]); - } - } - } - } - return {map: mapData, yFlipped: false}; -}; - -/** - * @return {string} - */ -WebServer.GENERATE_TEST_PATH = function () { - let lines = [ - // 3 - "estimate 0 -1.5 -0.5", - "estimate 0 -1 -0.5", - "estimate 0 -1 0", - "estimate 0 -1.5 0", - "estimate 0 -1 0", - "estimate 0 -1 0.5", - "estimate 0 -1.5 0.5", - // 5 - "estimate 0 -0.75 -0.5", - "estimate 0 -0.25 -0.5", - "estimate 0 -0.75 -0.5", - "estimate 0 -0.75 0", - "estimate 0 -0.25 0", - "estimate 0 -0.25 0.5", - "estimate 0 -0.75 0.5", - // C - "estimate 0 0 -0.5", - "estimate 0 0.5 -0.5", - "estimate 0 0 -0.5", - "estimate 0 0 0.5", - "estimate 0 0.5 0.5", - // 3 - "estimate 0 0.75 -0.5", - "estimate 0 1.25 -0.5", - "estimate 0 1.25 0", - "estimate 0 0.75 0", - "estimate 0 1.25 0", - "estimate 0 1.25 0.5", - "estimate 0 0.75 0.5" - ]; - return lines.join("\n"); -}; - - -WebServer.FIND_LATEST_MAP = function (callback, dontFlipGridMap, preferGridMap) { - if (process.env.VAC_MAP_TEST) { - callback(null, { - mapData: WebServer.GENERATE_TEST_MAP(), - log: WebServer.GENERATE_TEST_PATH() - }) - } else { - WebServer.FIND_LATEST_MAP_IN_RAMDISK(callback, dontFlipGridMap, preferGridMap); - } -}; - -WebServer.FIND_LATEST_MAP_IN_RAMDISK = function (callback, dontFlipGridMap, preferGridMap) { - fs.readdir("/dev/shm", function (err, filenames) { - if (err) { - callback(err); - } else { - let mapFileName; - let logFileName; - - filenames.forEach(function (filename) { - if (filename.endsWith(".ppm")) { - mapFileName = filename; - } - if (filename === "SLAM_fprintf.log") { - logFileName = filename; - } - }); - - if (mapFileName && logFileName) { - fs.readFile(path.join("/dev/shm", logFileName), function (err, file) { - if (err) { - callback(err); - } else { - const log = file.toString(); - if (log.indexOf("estimate") !== -1) { - - let loadGridMap = function () { - fs.readFile("/dev/shm/GridMap", function (err, gridMapFile) { - if (err) { - callback(new Error("Unable to get complete map file")) - } else { - callback(null, { - mapData: WebServer.PARSE_GRID_MAP(gridMapFile, dontFlipGridMap), - log: log - }) - } - }) - }; - - // if the user knows that there will only ever be usable gridmaps, - // setting the configuration option "preferGridMap" will take a shortcut. - if (preferGridMap) { - loadGridMap(); - } else { - let mapPath = path.join("/dev/shm", mapFileName); - - fs.readFile(mapPath, function (err, file) { - if (err) { - callback(err); - } else { - if (file.length !== WebServer.CORRECT_PPM_MAP_FILE_SIZE) { - let tries = 0; - let newFile = new Buffer.alloc(0); - - //I'm 1000% sure that there is a better way to fix incompletely written map files - //But since its a ramdisk I guess this hack shouldn't matter that much - //Maybe someday I'll implement a better solution. Not today though - while (newFile.length !== WebServer.CORRECT_PPM_MAP_FILE_SIZE && tries <= 250) { - tries++; - newFile = fs.readFileSync(mapPath); - } - - if (newFile.length === WebServer.CORRECT_PPM_MAP_FILE_SIZE) { - callback(null, { - mapData: WebServer.PARSE_PPM_MAP(newFile), - log: log - }) - } else { - loadGridMap(); - } - } else { - callback(null, { - mapData: WebServer.PARSE_PPM_MAP(file), - log: log - }) - } - } - }) - } - } else { - WebServer.FIND_LATEST_MAP_IN_ARCHIVE(callback) - } - } - }) - } else { - WebServer.FIND_LATEST_MAP_IN_ARCHIVE(callback) - } - } - }) -}; - -WebServer.FIND_LATEST_MAP_IN_ARCHIVE = function (callback) { - fs.readdir("/mnt/data/rockrobo/rrlog", function (err, filenames) { - if (err) { - callback(err); - } else { - let folders = []; - - filenames.forEach(function (filename) { - if (/^([0-9]{6})\.([0-9]{17})_(R([0-9]{4})S([0-9]{8})|[0-9]{13})_([0-9]{10})REL$/.test(filename)) { - folders.push(filename); - } - }); - folders = folders.sort().reverse(); - - let newestUsableFolderName; - let mapFileName; - let logFileName; - - for (let i in folders) { - const folder = folders[i]; - try { - const folderContents = fs.readdirSync(path.join("/mnt/data/rockrobo/rrlog", folder)); - let possibleMapFileNames = []; - mapFileName = undefined; - logFileName = undefined; - - - folderContents.forEach(function (filename) { - if (/^navmap([0-9]+)\.ppm\.([0-9]{4})(\.rr)?\.gz$/.test(filename)) { - possibleMapFileNames.push(filename); - } - if (/^SLAM_fprintf\.log\.([0-9]{4})(\.rr)?\.gz$/.test(filename)) { - logFileName = filename; - } - }); - - possibleMapFileNames = possibleMapFileNames.sort(); - mapFileName = possibleMapFileNames.pop(); - - if (mapFileName && logFileName) { - newestUsableFolderName = folder; - break; - } - } catch (e) { - console.error(e); - } - } - - if (newestUsableFolderName && mapFileName && logFileName) { - fs.readFile(path.join("/mnt/data/rockrobo/rrlog", newestUsableFolderName, logFileName), function (err, file) { - if (err) { - callback(err); - } else { - WebServer.DECRYPT_AND_UNPACK_FILE(file, function (err, unzippedFile) { - if (err) { - callback(err); - } else { - const log = unzippedFile.toString(); - if (log.indexOf("estimate") !== -1) { - fs.readFile(path.join("/mnt/data/rockrobo/rrlog", newestUsableFolderName, mapFileName), function (err, file) { - if (err) { - callback(err); - } else { - WebServer.DECRYPT_AND_UNPACK_FILE(file, function (err, unzippedFile) { - if (err) { - callback(err); - } else { - callback(null, { - mapData: WebServer.PARSE_PPM_MAP(unzippedFile), - log: log, - }) - } - }); - } - }) - } else { - callback(new Error("No usable map data found")); - } - } - }); - } - }) - } else { - callback(new Error("No usable map data found")); - } - } - }) -}; - -WebServer.DECRYPT_AND_UNPACK_FILE = function (file, callback) { - const decipher = crypto.createDecipheriv("aes-128-ecb", WebServer.ENCRYPTED_ARCHIVE_DATA_PASSWORD, ""); - let decryptedBuffer; - - if (Buffer.isBuffer(file)) { - //gzip magic bytes - if (WebServer.BUFFER_IS_GZIP(file)) { - zlib.gunzip(file, callback); - } else { - try { - decryptedBuffer = Buffer.concat([decipher.update(file), decipher.final()]); - } catch (e) { - return callback(e); - } - if (WebServer.BUFFER_IS_GZIP(decryptedBuffer)) { - zlib.gunzip(decryptedBuffer, callback); - } else { - callback(new Error("Couldn't decrypt file")); - } - } - } else { - callback(new Error("Missing file")) - } -}; - -WebServer.BUFFER_IS_GZIP = function (buf) { - return Buffer.isBuffer(buf) && buf[0] === 0x1f && buf[1] === 0x8b; -}; - - -WebServer.CORRECT_PPM_MAP_FILE_SIZE = 3145745; -WebServer.CORRECT_GRID_MAP_FILE_SIZE = 1048576; -WebServer.ENCRYPTED_ARCHIVE_DATA_PASSWORD = Buffer.from("RoCKR0B0@BEIJING"); - //This is the sole reason why I've bought a 21:9 monitor WebServer.WIFI_CONNECTED_IW_REGEX = /^Connected to ([0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2})(?:.*\s*)SSID: (.*)\s*freq: ([0-9]*)\s*signal: ([-]?[0-9]* dBm)\s*tx bitrate: ([0-9.]* .*)/; WebServer.OS_RELEASE_FW_REGEX = /^NAME=(.*)\nVERSION=(.*)\nID=(.*)\nID_LIKE=(.*)\nPRETTY_NAME=(.*)\nVERSION_ID=(.*)\nHOME_URL=(.*)\nSUPPORT_URL=(.*)\nBUG_REPORT_URL=(.*)\n(ROCKROBO|ROBOROCK)_VERSION=(.*)/; diff --git a/package.json b/package.json index 5aaa4ecaf14..09679664dc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "valetudo", - "version": "0.2.1", + "version": "0.2.2", "description": "Self-contained control webinterface for xiaomi vacuum robots", "main": "index.js", "bin": "index.js",