From 17ebfae6a8e211018bab0e8e8a56b94ac19eeeb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Beye?= Date: Fri, 5 Sep 2025 16:07:41 +0200 Subject: [PATCH] feat(vendor.midea): Obstacles galore --- .gitignore | 1 + assets/protobuf/midea.proto | 83 ++++++++++++++++ backend/.eslintignore | 1 + backend/lib/msmart/BEightParser.js | 20 +++- backend/lib/msmart/MSmartDummycloud.js | 18 ++++ backend/lib/msmart/dtos/MSmartStatusDTO.js | 18 +++- backend/lib/robots/midea/MideaConst.js | 40 +++++++- backend/lib/robots/midea/MideaMapParser.js | 76 ++++++++++++++- backend/lib/robots/midea/MideaQuirkFactory.js | 60 ++++++++++++ .../lib/robots/midea/MideaValetudoRobot.js | 14 ++- ...MideaObstacleAvoidanceControlCapability.js | 95 +++++++++++++++++++ .../MideaObstacleImagesCapability.js | 95 +++++++++++++++++++ .../lib/robots/midea/capabilities/index.js | 2 + backend/package.json | 4 +- package-lock.json | 53 +++++++++++ package.json | 1 + 16 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 assets/protobuf/midea.proto create mode 100644 backend/.eslintignore create mode 100644 backend/lib/robots/midea/capabilities/MideaObstacleAvoidanceControlCapability.js create mode 100644 backend/lib/robots/midea/capabilities/MideaObstacleImagesCapability.js diff --git a/.gitignore b/.gitignore index 23ac767d..d6fd4507 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build/ /backend/lib/res/valetudo.openapi.schema.json /backend/lib/res/build_metadata.json +/backend/lib/robots/midea/generated/midea_protobufs.js /docs/vendor /docs/_site diff --git a/assets/protobuf/midea.proto b/assets/protobuf/midea.proto new file mode 100644 index 00000000..f92d6461 --- /dev/null +++ b/assets/protobuf/midea.proto @@ -0,0 +1,83 @@ +syntax = "proto2"; + +message GridIndex { + optional int32 x = 1; + optional int32 y = 2; +} + +message Point { + optional float x = 1; + optional float y = 2; + optional float z = 3; +} + +message PointCloud { + repeated Point points = 1; +} + +message AIRect { + optional int32 x = 1; + optional int32 y = 2; + optional int32 width = 3; + optional int32 height = 4; +} + +message AIImageInfo { + optional int32 confidence = 1; + optional string absolute_path = 2; + optional AIRect rect = 3; + optional float float_val_10 = 10; + optional float float_val_11 = 11; +} + +message BoundingBox { + optional GridIndex corner_1 = 1; + optional GridIndex corner_2 = 2; + optional GridIndex corner_3 = 3; + optional GridIndex corner_4 = 4; +} + +message SemanticObject { + optional int32 object_type = 1; + optional int32 instance_id = 2; // e.g. the 5th shoe seen + optional GridIndex center_point = 3; + + // index 0: A serialized BoundingBox message. + // index 1: Some kind of hash? + repeated bytes field_4_data = 4; + + optional int32 int32_val_5 = 5; + optional int32 int32_val_6 = 6; + optional int64 timestamp_us = 7; + optional PointCloud point_cloud = 8; + optional AIImageInfo ai_image_info = 9; + optional float float_val_10 = 10; + optional float float_val_11 = 11; + optional int32 int32_val_12 = 12; + optional int32 int32_val_13 = 13; + optional int32 int32_val_14 = 14; + optional int32 int32_val_15 = 15; +} + +message SemanticMapInfo { + repeated SemanticObject objects = 1; + optional uint32 uint32_val_2 = 2; + optional uint32 uint32_val_3 = 3; + optional string clean_record_id = 4; +} + +message TrackPoint { + optional int64 int64_val_1 = 1; + optional int32 int32_val_2 = 2; + optional string name = 3; + optional string desc = 4; + optional int32 int32_val_5 = 5; + optional int32 int32_val_6 = 6; + + oneof data_oneof { + int64 oneof_int64_7 = 7; + int64 oneof_int64_8 = 8; + float oneof_float_9 = 9; + string oneof_string_10 = 10; + } +} \ No newline at end of file diff --git a/backend/.eslintignore b/backend/.eslintignore new file mode 100644 index 00000000..6a607f1c --- /dev/null +++ b/backend/.eslintignore @@ -0,0 +1 @@ +/lib/robots/midea/generated/midea_protobufs.js \ No newline at end of file diff --git a/backend/lib/msmart/BEightParser.js b/backend/lib/msmart/BEightParser.js index 61d8ff1b..f8136dc4 100644 --- a/backend/lib/msmart/BEightParser.js +++ b/backend/lib/msmart/BEightParser.js @@ -254,6 +254,9 @@ class BEightParser { data.cross_bridge_switch = !!(generalSwitchBits2 & 0b00000100); data.camera_led_switch = !!(generalSwitchBits2 & 0b00001000); data.map_3d_switch = !!(generalSwitchBits2 & 0b00010000); + + // Master toggle. When disabled, everything else will disable itself + // This is set to true once the user accepts some ToS data.ai_recognition_switch = !!(generalSwitchBits2 & 0b00100000); data.test_mode_type = payload[54]; @@ -297,9 +300,24 @@ class BEightParser { if (payload.length >= 71) { const generalSwitchBits7 = payload[70]; - data.big_object_detect_switch = !!(generalSwitchBits7 & 0b00000001); + + // possibly to know which room is which? What does the firmware do with it? + data.furniture_identify_switch = !!(generalSwitchBits7 & 0b00000001); + data.fall_detection_switch = !!(generalSwitchBits7 & 0b00000100); + data.obstacle_image_upload_switch = !!(generalSwitchBits7 & 0b00001000); + data.threshold_recognition_switch = !!(generalSwitchBits7 & 0b01000000); + data.curtain_recognition_switch = !!(generalSwitchBits7 & 0b10000000); } + if (payload.length >= 74) { + const generalSwitchBits8 = payload[73]; + + data.adb_switch = !!(generalSwitchBits8 & 0b00000001); + data.station_v2_switch = !!(generalSwitchBits8 & 0b00000010); + data.static_stain_recognition_switch = !!(generalSwitchBits8 & 0b00000100); + } + + return data; } diff --git a/backend/lib/msmart/MSmartDummycloud.js b/backend/lib/msmart/MSmartDummycloud.js index 03d92d4a..8c4f068c 100644 --- a/backend/lib/msmart/MSmartDummycloud.js +++ b/backend/lib/msmart/MSmartDummycloud.js @@ -454,6 +454,24 @@ class MSmartDummycloud { res.status(200).send(); }); + app.post("/v1/biz/file/device/uploadFileUrl", (req, res) => { + Logger.trace("Received request for a new presigned file upload URL"); + + res.status(200).json({ + code: "0", + msg: "OK", + data: { + url: `https://${req.hostname}/_valetudo/fileUpload?ts=${Date.now()}` + } + }); + }); + + app.put("/_valetudo/fileUpload", (req, res) => { + Logger.trace("Received file upload"); + + res.status(200).send(); + }); + app.all("*", (req, res) => { if (this.onHttpRequest) { diff --git a/backend/lib/msmart/dtos/MSmartStatusDTO.js b/backend/lib/msmart/dtos/MSmartStatusDTO.js index 06433d43..46316a4b 100644 --- a/backend/lib/msmart/dtos/MSmartStatusDTO.js +++ b/backend/lib/msmart/dtos/MSmartStatusDTO.js @@ -88,7 +88,14 @@ class MSmartStatusDTO extends MSmartDTO { * @param {boolean} [data.gap_deep_cleaning_switch] * @param {boolean} [data.furniture_legs_cleaning_switch] * @param {boolean} [data.edge_deep_vacuum_switch] - * @param {boolean} [data.big_object_detect_switch] + * @param {boolean} [data.furniture_identify_switch] + * @param {boolean} [data.fall_detection_switch] + * @param {boolean} [data.obstacle_image_upload_switch] + * @param {boolean} [data.threshold_recognition_switch] + * @param {boolean} [data.curtain_recognition_switch] + * @param {boolean} [data.adb_switch] + * @param {boolean} [data.station_v2_switch] + * @param {boolean} [data.static_stain_recognition_switch] */ constructor(data) { super(); @@ -178,7 +185,14 @@ class MSmartStatusDTO extends MSmartDTO { this.gap_deep_cleaning_switch = data.gap_deep_cleaning_switch; this.furniture_legs_cleaning_switch = data.furniture_legs_cleaning_switch; this.edge_deep_vacuum_switch = data.edge_deep_vacuum_switch; - this.big_object_detect_switch = data.big_object_detect_switch; + this.furniture_identify_switch = data.furniture_identify_switch; + this.fall_detection_switch = data.fall_detection_switch; + this.obstacle_image_upload_switch = data.obstacle_image_upload_switch; + this.threshold_recognition_switch = data.threshold_recognition_switch; + this.curtain_recognition_switch = data.curtain_recognition_switch; + this.adb_switch = data.adb_switch; + this.station_v2_switch = data.station_v2_switch; + this.static_stain_recognition_switch = data.static_stain_recognition_switch; Object.freeze(this); } diff --git a/backend/lib/robots/midea/MideaConst.js b/backend/lib/robots/midea/MideaConst.js index e45373a9..c3cfe1f3 100644 --- a/backend/lib/robots/midea/MideaConst.js +++ b/backend/lib/robots/midea/MideaConst.js @@ -53,7 +53,45 @@ tkciowVpUEiXK0tg/t8EgHj+sW9vUqX6ovKDx5Hy2nczd2CruRxHUieSQKX+zajv -----END CERTIFICATE----- `; +const AI_OBSTACLE_IDS = Object.freeze({ + "1": "Shoes", + "2": "Trash can", + "3": "Pet bowl", + "4": "Weighing scale", + "5": "Textiles", + "6": "Entrapping furniture", + "7": "Electric wire", + "8": "Charging base", + "9": "Feces", + "11": "Liquid Stain", + "12": "Solid Stain", + "13": "Mixed Solid and Liquid Stain", + "16": "Pet", + "17": "Pet", + "18": "Pedestal", + "19": "Fall Hazard", + "20": "Floor mirror", + "22": "Stuck Hazard / Base", + "24": "Power strip", + "25": "Obstacle", + "30": "Obstacle", + "31": "Obstacle", + "32": "Pet supplies", + "33": "Obstacle", + + "99": "Unknown Obstacle", + + "4001": "Grain Stain", + "4002": "Dust Stain", + "4003": "Liquid Stain", + "4004": "Mixed Solid and Liquid Stain", + + "65534": "Socks", +}); + module.exports = { DUMMY_CLIENT_CERT: DUMMY_CLIENT_CERT, - DUMMY_CLIENT_KEY: DUMMY_CLIENT_KEY + DUMMY_CLIENT_KEY: DUMMY_CLIENT_KEY, + AI_OBSTACLE_IDS: AI_OBSTACLE_IDS }; + diff --git a/backend/lib/robots/midea/MideaMapParser.js b/backend/lib/robots/midea/MideaMapParser.js index 825bb54b..3fd7a802 100644 --- a/backend/lib/robots/midea/MideaMapParser.js +++ b/backend/lib/robots/midea/MideaMapParser.js @@ -1,5 +1,8 @@ const Logger = require("../../Logger"); const mapEntities = require("../../entities/map"); +const MideaConst = require("./MideaConst"); +const Protobufs = require("./generated/midea_protobufs.js"); +const uuid = require("uuid"); const zlib = require("zlib"); class MideaMapParser { @@ -84,6 +87,9 @@ class MideaMapParser { case "evt_active_zones": await this.handleActiveZonesUpdate(data); break; + case "semantic_data": + await this.handleSemanticDataUpdate(data); + break; case "threshold_area": case "points": @@ -91,7 +97,6 @@ class MideaMapParser { case "user_defined_carpet": case "backup_map": case "3d": - case "semantic_data": case "stain_area": case "partition": case "adjacent": @@ -479,7 +484,7 @@ class MideaMapParser { } /** - * + * * @param {import("../../msmart/dtos/MSmartActiveZonesDTO")} data * @return {Promise} */ @@ -510,6 +515,70 @@ class MideaMapParser { this.entities.push(...entities); } + /** + * + * @param {string} data + * @return {Promise} + */ + async handleSemanticDataUpdate(data) { + this.entities = this.entities.filter(e => e.type !== mapEntities.PointMapEntity.TYPE.OBSTACLE); + + if (!data) { + return; + } + + const payload = await MideaMapParser.DECOMPRESS_PAYLOAD(data); + if (payload.length === 0) { + return; + } + + try { + const semanticInfo = Protobufs.decodeSemanticMapInfo(payload); + + if (!semanticInfo.objects || semanticInfo.objects.length === 0) { + return; + } + + const newObstacleEntities = []; + + for (const object of semanticInfo.objects) { + if (!object.center_point) { + continue; + } + const coords = this.convertToValetudoCoordinates(object.center_point.x, object.center_point.y); + + const obstacleType = MideaConst.AI_OBSTACLE_IDS[object.object_type] ?? `Unknown ID ${object.object_type}`; + const confidence = object.ai_image_info?.confidence ? `${object.ai_image_info.confidence}%` : "N/A"; + const image = object.ai_image_info?.absolute_path; + + let objectHash; + // field_4_data contains the BoundingBox and the unique hash. + if (object.field_4_data && object.field_4_data.length > 1) { + objectHash = object.field_4_data[1].toString("utf-8"); + } else { + objectHash = `${object.timestamp_us}_${object.center_point.x}_${object.center_point.y}`; + } + + newObstacleEntities.push(new mapEntities.PointMapEntity({ + points: [ + coords.x, + coords.y, + ], + type: mapEntities.PointMapEntity.TYPE.OBSTACLE, + metaData: { + label: `${obstacleType} (${confidence})`, + id: uuid.v5(objectHash, OBSTACLE_ID_NAMESPACE), + image: image + } + })); + } + + this.entities.push(...newObstacleEntities); + } catch (e) { + Logger.warn("Error while parsing semantic_data:", e); + } + } + /** * * @param {string} data @@ -547,6 +616,7 @@ MideaMapParser.PATH_TYPES = Object.freeze({ "CLEANING_TURN": 80, "CLEANING": 100, + // TODO: 120 "MAPPING": 170, "TAXIING": 180, @@ -555,4 +625,6 @@ MideaMapParser.PATH_TYPES = Object.freeze({ "HEADER": 2400, // Not a real type. Just the format header }); +const OBSTACLE_ID_NAMESPACE = "533c87f6-c6a7-4428-9df9-347f33994348"; + module.exports = MideaMapParser; diff --git a/backend/lib/robots/midea/MideaQuirkFactory.js b/backend/lib/robots/midea/MideaQuirkFactory.js index c03a44dd..257affe5 100644 --- a/backend/lib/robots/midea/MideaQuirkFactory.js +++ b/backend/lib/robots/midea/MideaQuirkFactory.js @@ -117,6 +117,65 @@ class MideaQuirkFactory { ) }); + await this.robot.sendCommand(packet.toHexString()); + } + }); + case MideaQuirkFactory.KNOWN_QUIRKS.AI_OBSTACLE_CLASSIFICATION: + return new Quirk({ + id: id, + title: "AI Obstacle Classification", + description: "Controls whether and how hard the robot should try to actually understand and work around the encountered obstacles.", + options: ["off", "high", "normal"], + getter: async () => { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_CLEANING_SETTINGS_1) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + const parsedResponse = BEightParser.PARSE(response); + + if (parsedResponse instanceof MSmartCleaningSettings1DTO) { + switch (parsedResponse.ai_grade_avoidance_mode) { + case 0: + return "off"; + case 1: + return "high"; + case 2: + return "normal"; + } + } else { + throw new Error("Invalid response from robot"); + } + }, + setter: async (value) => { + let val; + + switch (value) { + case "off": + val = 0; + break; + case "high": + val = 1; + break; + case "normal": + val = 2; + break; + default: + throw new Error(`Invalid obstacle avoidance sensitivity value: ${value}`); + } + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x15, // super-obstacle + val + ]) + ) + }); + await this.robot.sendCommand(packet.toHexString()); } }); @@ -129,6 +188,7 @@ class MideaQuirkFactory { MideaQuirkFactory.KNOWN_QUIRKS = { HAIR_CUTTING: "afa83002-87db-43bb-b8ff-e4b38863a5d3", HAIR_CUTTING_ONE_TIME_TURBO: "224b6a0a-1a51-48d7-9d4d-61645399d368", + AI_OBSTACLE_CLASSIFICATION: "75af01c4-5c24-4cb3-9619-41b46b6ce333", }; module.exports = MideaQuirkFactory; diff --git a/backend/lib/robots/midea/MideaValetudoRobot.js b/backend/lib/robots/midea/MideaValetudoRobot.js index 56cbd005..5dbee826 100644 --- a/backend/lib/robots/midea/MideaValetudoRobot.js +++ b/backend/lib/robots/midea/MideaValetudoRobot.js @@ -15,6 +15,7 @@ const MideaQuirkFactory = require("./MideaQuirkFactory"); const capabilities = require("./capabilities"); const QuirksCapability = require("../../core/capabilities/QuirksCapability"); +const {IMAGE_FILE_FORMAT} = require("../../utils/const"); const entities = require("../../entities"); const stateAttrs = entities.state.attributes; @@ -143,15 +144,26 @@ class MideaValetudoRobot extends ValetudoRobot { capabilities.MideaMopDockDryManualTriggerCapability, capabilities.MideaMopExtensionControlCapability, capabilities.MideaCameraLightControlCapability, + capabilities.MideaObstacleAvoidanceControlCapability, ].forEach(capability => { this.registerCapability(new capability({robot: this})); }); + this.registerCapability(new capabilities.MideaObstacleImagesCapability({ + robot: this, + fileFormat: IMAGE_FILE_FORMAT.JPG, + dimensions: { + width: 640, + height: 480 + } + })); + this.registerCapability(new QuirksCapability({ robot: this, quirks: [ quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING), - quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING_ONE_TIME_TURBO) + quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING_ONE_TIME_TURBO), + quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.AI_OBSTACLE_CLASSIFICATION) ] })); diff --git a/backend/lib/robots/midea/capabilities/MideaObstacleAvoidanceControlCapability.js b/backend/lib/robots/midea/capabilities/MideaObstacleAvoidanceControlCapability.js new file mode 100644 index 00000000..579a2c26 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaObstacleAvoidanceControlCapability.js @@ -0,0 +1,95 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); +const ObstacleAvoidanceControlCapability = require("../../../core/capabilities/ObstacleAvoidanceControlCapability"); + +/** + * @extends ObstacleAvoidanceControlCapability + */ +class MideaObstacleAvoidanceControlCapability extends ObstacleAvoidanceControlCapability { + + /** + * @returns {Promise} + */ + async isEnabled() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_STATUS) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + const parsedResponse = BEightParser.PARSE(response); + + if (parsedResponse instanceof MSmartStatusDTO) { + return parsedResponse.ai_avoidance_switch; + } else { + throw new Error("Invalid response from robot"); + } + } + + /** + * @returns {Promise} + */ + async enable() { + const statusPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_STATUS) + }); + + const response = await this.robot.sendCommand(statusPacket.toHexString()); + const status = BEightParser.PARSE(response); + + if (!(status instanceof MSmartStatusDTO)) { + throw new Error("Invalid status response from robot"); + } + + if (status.ai_recognition_switch === false) { + const tosPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x0f, // AI Recognition (ToS) + 0x01 // true + ]) + ) + }); + + await this.robot.sendCommand(tosPacket.toHexString()); + } + + const avoidancePacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x2c, // AI Obstacle Avoidance + 0x01 // true + ]) + ) + }); + + await this.robot.sendCommand(avoidancePacket.toHexString()); + } + + /** + * @returns {Promise} + */ + async disable() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x2c, // AI Obstacle Avoidance + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaObstacleAvoidanceControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaObstacleImagesCapability.js b/backend/lib/robots/midea/capabilities/MideaObstacleImagesCapability.js new file mode 100644 index 00000000..69f3c472 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaObstacleImagesCapability.js @@ -0,0 +1,95 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const fs = require("fs"); +const Logger = require("../../../Logger"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); +const ObstacleImagesCapability = require("../../../core/capabilities/ObstacleImagesCapability"); + + +/** + * @extends ObstacleImagesCapability + */ +class MideaObstacleImagesCapability extends ObstacleImagesCapability { + /** + * @returns {Promise} + */ + async isEnabled() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_STATUS) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + const parsedResponse = BEightParser.PARSE(response); + + if (parsedResponse instanceof MSmartStatusDTO) { + return parsedResponse.obstacle_image_upload_switch; + } else { + throw new Error("Invalid response from robot"); + } + } + + /** + * @returns {Promise} + */ + async enable() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x35, // AI Images + 0x01 // true + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @returns {Promise} + */ + async disable() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VARIOUS_TOGGLES, + Buffer.from([ + 0x35, // AI Images + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @param {string} image + * @returns {Promise} + */ + async getStreamForImage(image) { + if (!/^\/userdata\/aiimgs\/[^/]+\.jpg$/.test(image)) { + Logger.warn("Unexpected obstacle image path:", image); + + return null; + } + + try { + return fs.createReadStream(image, { + highWaterMark: 32 * 1024, + autoClose: true + }); + } catch (err) { + if (err.code === "ENOENT") { + return null; + } else { + throw new Error(`Unexpected error while trying to read obstacle image: ${err.message}`); + } + } + } +} + +module.exports = MideaObstacleImagesCapability; diff --git a/backend/lib/robots/midea/capabilities/index.js b/backend/lib/robots/midea/capabilities/index.js index ada4bf05..23178368 100644 --- a/backend/lib/robots/midea/capabilities/index.js +++ b/backend/lib/robots/midea/capabilities/index.js @@ -18,6 +18,8 @@ module.exports = { MideaMopDockCleanManualTriggerCapability: require("./MideaMopDockCleanManualTriggerCapability"), MideaMopDockDryManualTriggerCapability: require("./MideaMopDockDryManualTriggerCapability"), MideaMopExtensionControlCapability: require("./MideaMopExtensionControlCapability"), + MideaObstacleAvoidanceControlCapability: require("./MideaObstacleAvoidanceControlCapability"), + MideaObstacleImagesCapability: require("./MideaObstacleImagesCapability"), MideaOperationModeControlCapability: require("./MideaOperationModeControlCapability"), MideaSpeakerTestCapability: require("./MideaSpeakerTestCapability"), MideaSpeakerVolumeControlCapability: require("./MideaSpeakerVolumeControlCapability"), diff --git a/backend/package.json b/backend/package.json index b69cd14c..d7da735e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,9 @@ "ts-check": "tsc --noEmit", "test": "mocha \"test/**/*_spec.js\"", "prepare_commit": "npm run lint_fix && npm run ts-check && npm run test", - "pre_build": "node ../util/generate_build_metadata.js", + "generate_code": "npm run generate_midea_protobufs", + "generate_midea_protobufs": "pbjs --es5 ./lib/robots/midea/generated/midea_protobufs.js ../assets/protobuf/midea.proto", + "pre_build": "npm run generate_code && node ../util/generate_build_metadata.js", "build": "npm run build_armv7 && npm run build_aarch64 && npm run build_armv7_lowmem", "build_armv7": "npm run pre_build && cross-env PKG_CACHE_PATH=../build_dependencies/pkg pkg --targets node22-linuxstatic-armv7 --compress Brotli --no-bytecode --public-packages \"*\" --options \"expose-gc,max-heap-size=42\" . --output ../build/armv7/valetudo", "build_aarch64": "npm run pre_build && cross-env PKG_CACHE_PATH=../build_dependencies/pkg pkg --targets node22-linuxstatic-arm64 --compress Brotli --no-bytecode --public-packages \"*\" --options \"expose-gc,max-heap-size=64\" . --output ../build/aarch64/valetudo", diff --git a/package-lock.json b/package-lock.json index e9776e44..ccf61682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "eslint-plugin-regexp": "2.6.0", "eslint-plugin-sort-keys-fix": "1.1.2", "eslint-plugin-sort-requires": "git+https://npm@github.com/Hypfer/eslint-plugin-sort-requires.git#2.1.1", + "pbjs": "0.0.14", "swagger-jsdoc": "git+https://npm@github.com/Hypfer/swagger-jsdoc.git#7.0.0-rc.6-noyaml-monorepo-fix", "swagger-parser": "10.0.3", "typescript": "4.8.4", @@ -15927,6 +15928,28 @@ "node": ">=8" } }, + "node_modules/pbjs": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/pbjs/-/pbjs-0.0.14.tgz", + "integrity": "sha512-F4aA0ojrQ37kxFPOg4yRLP/vxb76rYQwMQigmVEljYlA7hZKmjaWjP6IkRn4nA0NdIj4Xxe4iqWrrIhJy+MwWQ==", + "dev": true, + "dependencies": { + "commander": "4.0.1", + "protocol-buffers-schema": "3.1.0" + }, + "bin": { + "pbjs": "cli.js" + } + }, + "node_modules/pbjs/node_modules/commander": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.0.1.tgz", + "integrity": "sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -17432,6 +17455,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.1.0.tgz", + "integrity": "sha512-1g9zFjLFhGN1Dc5UVO8D2loVslp6sVxk5sJqgD66CuWUITh2gOaTLRN/pIakGFfB6e0nNF6hImrYFDurEsA1UQ==", + "dev": true + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -33394,6 +33423,24 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "pbjs": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/pbjs/-/pbjs-0.0.14.tgz", + "integrity": "sha512-F4aA0ojrQ37kxFPOg4yRLP/vxb76rYQwMQigmVEljYlA7hZKmjaWjP6IkRn4nA0NdIj4Xxe4iqWrrIhJy+MwWQ==", + "dev": true, + "requires": { + "commander": "4.0.1", + "protocol-buffers-schema": "3.1.0" + }, + "dependencies": { + "commander": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.0.1.tgz", + "integrity": "sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA==", + "dev": true + } + } + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -34276,6 +34323,12 @@ "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz", "integrity": "sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==" }, + "protocol-buffers-schema": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.1.0.tgz", + "integrity": "sha512-1g9zFjLFhGN1Dc5UVO8D2loVslp6sVxk5sJqgD66CuWUITh2gOaTLRN/pIakGFfB6e0nNF6hImrYFDurEsA1UQ==", + "dev": true + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 4b067f05..c2e1131d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "eslint-plugin-regexp": "2.6.0", "eslint-plugin-sort-keys-fix": "1.1.2", "eslint-plugin-sort-requires": "git+https://npm@github.com/Hypfer/eslint-plugin-sort-requires.git#2.1.1", + "pbjs": "0.0.14", "swagger-jsdoc": "git+https://npm@github.com/Hypfer/swagger-jsdoc.git#7.0.0-rc.6-noyaml-monorepo-fix", "swagger-parser": "10.0.3", "typescript": "4.8.4",