diff --git a/backend/lib/miio/RetryWrapper.js b/backend/lib/miio/RetryWrapper.js index 0e9f9cea..526892f8 100644 --- a/backend/lib/miio/RetryWrapper.js +++ b/backend/lib/miio/RetryWrapper.js @@ -105,12 +105,12 @@ class RetryWrapper { return new Promise((resolve, reject) => { this.mutex.take(() => { this.sendMessageHelper(msg, options).then(response => { - this.mutex.leave(); + resolve(response); }).catch(err => { - this.mutex.leave(); + reject(err); }); }); @@ -205,7 +205,7 @@ class RetryWrapper { await this.handshake(true); } - //remove all remains of a previous attempt + // remove all remains of a previous attempt delete(msg["id"]); } diff --git a/backend/lib/msmart/BEightParser.js b/backend/lib/msmart/BEightParser.js new file mode 100644 index 00000000..bdb42749 --- /dev/null +++ b/backend/lib/msmart/BEightParser.js @@ -0,0 +1,381 @@ +const Logger = require("../Logger"); +const MSmartConst = require("./MSmartConst"); +const MSmartPacket = require("./MSmartPacket"); + +const dtos = require("./dtos"); + +class BEightParser { + /** + * @param {MSmartPacket} packet + * @returns {import("./dtos/MSmartDTO")|"SKIP"|undefined} - FIXME: remove SKIP + */ + static PARSE(packet) { + const payload = packet.payload; + + switch (packet.messageType) { + case MSmartPacket.MESSAGE_TYPE.SETTING: { + // 0xaa 0x01 + switch (payload[2]) { + case 0xc4: // FIXME + // No idea. Sample: aa 01 c4 04 00 00 00 00 5c 00 9d 10 01 + return "SKIP"; + case 0x9d: // FIXME + // Network state? Sample: aa 01 9d 01 02 01 + return "SKIP"; + case 0x22: // FIXME + // bSaveMapSwitch. Sample: aa 01 22 00 + return "SKIP"; + default: { + Logger.warn( + `Unhandled SETTING packet with typeId '${payload[2]}'`, + packet.toHexString() + ); + } + } + + break; + } + case MSmartPacket.MESSAGE_TYPE.ACTION: { + // 0xaa 0x01 + switch (payload[2]) { + case MSmartConst.ACTION.GET_STATUS: { + const data = BEightParser._parse_status_payload(payload); + + return new dtos.MSmartStatusDTO(data); + } + case MSmartConst.ACTION.LIST_MAPS: { + if (payload.length < 9) { + Logger.warn("Received invalid LIST_MAPS response. Payload too short."); + return undefined; + } + + const data = { + currentMapId: payload[5], + savedMapIds: [] + }; + + const mapBitfield = payload.readUInt16LE(7); + + for (let i = 0; i < 16; i++) { + if ((mapBitfield >> i) & 1) { + data.savedMapIds.push(i + 1); + } + } + + return new dtos.MSmartMapListDTO(data); + } + case MSmartConst.ACTION.GET_ACTIVE_ZONES: { + const data = BEightParser._parse_active_zones_payload(payload); + + return new dtos.MSmartActiveZonesDTO(data); + } + case MSmartConst.ACTION.GET_DND: { + const data = BEightParser._parse_dnd_payload(payload); + + return new dtos.MSmartDndConfigurationDTO(data); + } + case MSmartConst.ACTION.GET_CLEANING_SETTINGS_1: { + const data = BEightParser._parse_cleaning_settings_1_payload(payload); + + return new dtos.MSmartCleaningSettings1DTO(data); + } + default: { + Logger.warn( + `Unhandled ACTION packet with typeId '${payload[2]}'`, + packet.toHexString() + ); + } + } + + break; + } + case MSmartPacket.MESSAGE_TYPE.EVENT: { + // 0xaa 0x01 + switch (payload[2]) { + case MSmartConst.EVENT.STATUS: { + const data = BEightParser._parse_status_payload(payload); + + return new dtos.MSmartStatusDTO(data); + } + case MSmartConst.EVENT.ACTIVE_ZONES: { + const data = BEightParser._parse_active_zones_payload(payload); + + return new dtos.MSmartActiveZonesDTO(data); + } + case MSmartConst.EVENT.ERROR: { + return new dtos.MSmartErrorDTO({ + error_type: payload[3], + error_desc: payload[4], + sta_index: payload[5], + }); + } + case MSmartConst.EVENT.CLEANING_SETTINGS_1: { + const data = BEightParser._parse_cleaning_settings_1_payload(payload); + + return new dtos.MSmartCleaningSettings1DTO(data); + } + case 0xA8: // FIXME + // This seems to be relating to the dock state and what it is doing + // There are also timers in here? + + // payload[3]; // Mode?. 0x00:Idle?, 0x01:Clean?, 0x02:Empty, 0x03:Dry, 0x05:Wash, possibly hair cuttting? + // payload.readUInt32LE(4); // unclear + // payload.readUInt32LE(8); // timer. Seconds counting up + // payload[12]; // unclear + // payload.readUInt32LE(13); // possibly expected duration of the timer in seconds + + return "SKIP"; + default: { + Logger.warn( + `Unhandled EVENT packet with typeId '${payload[2]}'`, + packet.toHexString() + ); + } + } + + break; + } + case MSmartPacket.MESSAGE_TYPE.DOCK: { + if ( + payload[0] === 0x66 && + payload[1] === 0x06 + ) { + return new dtos.MSmartDockStatusDTO({ + dust_collection_count: payload[2], + fluid_1_ok: !!payload[5], + fluid_2_ok: !!payload[6], + }); + } else { + Logger.warn("Unhandled DOCK packet", packet.toHexString()); + } + break; + } + default: { + Logger.warn( + `Unhandled packet with messageType '${packet.messageType}'. ${packet.payload.subarray(0, 3).toString("hex")}`, + packet.toHexString() + ); + } + } + + return undefined; + } + + /** + * + * @private + * @param {Buffer} payload + * @returns {object} + */ + static _parse_status_payload(payload) { + const data = {}; + + data.work_status = payload[3]; // 1 - 23 + data.function_type = payload[4]; // 1 - 6 + data.control_type = payload[5]; // 0 - 2 + data.move_direction = payload[6]; // 0 - 8 + data.work_mode = payload[7]; // 0 - 15 + data.fan_level = payload[8]; // 0 - 5 + + // work_area and work_time each have +4 additional bits in payload[22]. - INSANE + data.work_area = ((payload[22] & 0b00001111) << 8) + payload[9]; + + data.water_level = payload[10]; // 0 - 3, OR high-res starting at >= 100 + data.voice_level = payload[11]; // 0 - 100 + // 12 => have_reserve_tank ?? + data.battery_percent = payload[13]; // 0 - 100 + + // work_area and work_time each have +4 additional bits in payload[22]. - INSANE - TODO validate. It was [23] before the LLM suggested something else + data.work_time = (((payload[22] & 0b11110000) >> 4) << 8) + payload[14]; + + data.uv_switch = !!(payload[15] & 0b00000001); + data.wifi_switch = !!(payload[15] & 0b00000010); + data.voice_switch = !!(payload[15] & 0b00000100); + data.command_source = !!(payload[15] & 0b01000000); + data.device_error = !!(payload[15] & 0b10000000); + + data.error_type = payload[16]; + data.error_desc = payload[17]; + + const mopStatusByte = payload[18]; + data.has_mop = !!(mopStatusByte & 0b00000001); // Mops attached bool + data.has_vibrate_mop = !!(mopStatusByte & 0b00000010); + + data.carpet_switch = payload[19]; // bool, TODO: validate offset + + // 20 is unknown + + data.cleaning_type = payload[21]; // 0 - 6 + + data.vibrate_mode = payload[23] ? "careful" : "efficient"; // TODO: should this be string? + data.vibrate_switch = !!payload[24]; + data.electrolyzed_water = !!payload[25]; + data.electrolyzed_water_status = payload[26]; + + data.dustDragSwitch = !!((payload[27] & 0x01)); + data.dustDragStatus = !!((payload[27] & 0x02)); + data.dustTimes = payload[28]; + data.dustedTimes = payload[29]; + data.chargeDockType = payload[30]; + + const stationStatusBits = payload[33]; + data.fluid_1_ok = !!(stationStatusBits & 0b00000100); + data.fluid_2_ok = !!(stationStatusBits & 0b00001000); + data.dustbag_installed = !!(stationStatusBits & 0b00100000); + data.dustbag_full = !!(stationStatusBits & 0b00010000); + + data.mopMode = payload[34]; + data.station_work_status = payload[36]; // 0 - 89 but with holes + + data.job_state = payload[37]; + data.whole_process_state = payload[38]; + + data.continuous_clean_mode = !!payload[41]; + // 42 is unknown + data.clean_sequence_switch = !!payload[43]; + + const childLockBits = payload[45]; + data.child_lock_enabled = !!(childLockBits & 0b00000001); + data.child_lock_follows_dnd = !!(childLockBits & 0b00000010); + + const generalSwitchBits1 = payload[50]; + data.personal_clean_prefer_switch = !!(generalSwitchBits1 & 0b00000001); + data.station_inject_fluid_switch = !!(generalSwitchBits1 & 0b00000010); + data.station_inject_soft_fluid_switch = !!(generalSwitchBits1 & 0b00000100); + data.carpet_evade_switch = !!(generalSwitchBits1 & 0b00010000); + data.station_first_fast_wash_switch = !!(generalSwitchBits1 & 0b01000000); + data.pet_mode_switch = !!(generalSwitchBits1 & 0b10000000); + + data.station_capability_flags = payload[52]; + + const generalSwitchBits2 = payload[53]; + data.stain_clean_switch = !!(generalSwitchBits2 & 0b00000001); + data.ai_obstacle_switch = !!(generalSwitchBits2 & 0b00000010); + data.cross_bridge_switch = !!(generalSwitchBits2 & 0b00000100); + data.camera_led_switch = !!(generalSwitchBits2 & 0b00001000); + data.map_3d_switch = !!(generalSwitchBits2 & 0b00010000); + data.ai_recognition_switch = !!(generalSwitchBits2 & 0b00100000); + + data.test_mode_type = payload[54]; + data.hot_water_wash_mode = payload[55]; + + const generalSwitchBits3 = payload[56]; + data.station_self_fluid_2_switch = !!(generalSwitchBits3 & 0b00000001); + data.slam_version_switch = !!(generalSwitchBits3 & 0b00000010); + data.hot_dry_charge_plate_switch = !!(generalSwitchBits3 & 0b00000100); + data.telnet_switch = !!(generalSwitchBits3 & 0b00001000); + data.mop_auto_dry_switch = !!(generalSwitchBits3 & 0b00010000); + data.ai_grade_avoidance_mode = !!(generalSwitchBits3 & 0b00100000); + data.pound_sign_switch = !!(generalSwitchBits3 & 0b10000000); // TODO: naming - this is the criss cross pattern with multiple iterations + + data.stationCleanFrequency = payload[57]; + data.beautify_map_grade = payload[58]; + data.collect_dust_mode = payload[59]; + data.session_id = payload[61]; + data.transaction_id = payload[62]; + + const generalSwitchBits4 = payload[63]; + data.bridge_boost_switch = !!(generalSwitchBits4 & 0b00001000); + data.narrow_zone_recharge_switch = !!(generalSwitchBits4 & 0b00010000); + data.verification_map_switch = !!(generalSwitchBits4 & 0b00100000); + + const generalSwitchBits5 = payload[65]; + data.wake_up_switch = !!(generalSwitchBits5 & 0b00000001); + data.ai_carpet_avoid_switch = !!(generalSwitchBits5 & 0b00000010); + data.carpet_evade_adaptive_switch = !!(generalSwitchBits5 & 0b00000100); + data.stuck_mark_switch = !!(generalSwitchBits5 & 0b00001000); + data.mop_extend_switch = !!(generalSwitchBits5 & 0b00100000); + data.zigzag_to_end_switch = !!(generalSwitchBits5 & 0b01000000); + + data.remaining_area = payload.readUInt16LE(66); + + const generalSwitchBits6 = payload[68]; + data.ai_avoidance_switch = !!(generalSwitchBits6 & 0b00001000); + data.gap_deep_cleaning_switch = !!(generalSwitchBits6 & 0b00010000); + data.furniture_legs_cleaning_switch = !!(generalSwitchBits6 & 0b00100000); + data.edge_deep_vacuum_switch = !!(generalSwitchBits6 & 0b10000000); + + if (payload.length >= 71) { + const generalSwitchBits7 = payload[70]; + data.big_object_detect_switch = !!(generalSwitchBits7 & 0b00000001); + } + + return data; + } + + /** + * @private + * @param {Buffer} payload + * @returns {object} + */ + static _parse_active_zones_payload(payload) { + const zoneCount = payload.readUInt8(3); + const zones = []; + let offset = 4; + + for (let i = 0; i < zoneCount; i++) { + if (offset + 10 > payload.length) { + Logger.warn("Malformed ACTIVE_ZONES payload. Not enough data for all declared zones."); + break; + } + + zones.push({ + index: payload.readUInt8(offset), + passes: payload.readUInt8(offset + 1), + pA: { + x: payload.readUInt16LE(offset + 2), + y: payload.readUInt16LE(offset + 4), + }, + pC: { + x: payload.readUInt16LE(offset + 6), + y: payload.readUInt16LE(offset + 8), + } + }); + + offset += 10; + } + + return { zones: zones }; + } + + /** + * + * @private + * @param {Buffer} payload + * @returns {object} + */ + static _parse_dnd_payload(payload) { + return { + enabled: payload[3] !== 0, + start: { + hour: payload[4], + minute: payload[5], + }, + end: { + hour: payload[6], + minute: payload[7] + } + }; + } + + /** + * @private + * @param {Buffer} payload + * @returns {object} + */ + static _parse_cleaning_settings_1_payload(payload) { + const data = {}; + + data.route_type = payload[3]; + data.cut_hair_level = payload[4]; + data.collect_suction_level = payload[7]; + data.exhibition_switch = !!payload[8]; + data.ai_grade_avoidance_mode = payload[9]; + data.cut_hair_super_switch = !!payload[10]; + data.turbidity_re_mop_switch = payload[11]; + + return data; + } +} + +module.exports = BEightParser; diff --git a/backend/lib/msmart/MSmartConst.js b/backend/lib/msmart/MSmartConst.js new file mode 100644 index 00000000..5335e699 --- /dev/null +++ b/backend/lib/msmart/MSmartConst.js @@ -0,0 +1,46 @@ + +const SETTING = Object.freeze({ + SET_WORK_STATUS: 0x01, // This is the state, the high-level state machine is in. To get the robot to do things, you set it + DO_MANUAL_CONTROL_CMD: 0x02, + START_SEGMENT_CLEANUP: 0x03, + START_ZONE_CLEANUP: 0x05, + SET_VIRTUAL_WALLS: 0x20, + SET_RESTRICTED_ZONES: 0x21, + MAP_MANAGEMENT: 0x24, + JOIN_SEGMENTS: 0x26, + SPLIT_SEGMENT: 0x27, + SET_VALID_MAP_IDS: 0x2D, // Used by the cloud to sync cloud state with device state with cloud being higher prio + SET_FAN_SPEED: 0x50, + SET_WATER_GRADE: 0x51, + SET_DOCK_INTERVALS: 0x56, + SET_OPERATION_MODE: 0x58, + TRIGGER_STATION_ACTION: 0x5A, + SET_DND: 0x92, + SET_VOLUME: 0x93, + SET_VARIOUS_TOGGLES: 0x9C, + SET_CLEANING_SETTINGS_1: 0xC9, // FIXME: naming +}); + +const ACTION = Object.freeze({ + GET_STATUS: 0x01, + LIST_MAPS: 0x20, + POLL_MAP: 0x22, + GET_DOCK_POSITION: 0x24, + GET_ACTIVE_ZONES: 0x27, + LOCATE: 0x57, + GET_DND: 0x90, + GET_CLEANING_SETTINGS_1: 0xAA, // FIXME: naming +}); + +const EVENT = Object.freeze({ + STATUS: 0x01, + ACTIVE_ZONES: 0x22, + ERROR: 0xA3, + CLEANING_SETTINGS_1: 0xAA // FIXME: naming +}); + +module.exports = { + SETTING: SETTING, + ACTION: ACTION, + EVENT: EVENT +}; diff --git a/backend/lib/msmart/MSmartDummycloud.js b/backend/lib/msmart/MSmartDummycloud.js new file mode 100644 index 00000000..1459d9a8 --- /dev/null +++ b/backend/lib/msmart/MSmartDummycloud.js @@ -0,0 +1,650 @@ +const crypto = require("crypto"); +const express = require("express"); +const https = require("https"); +const Logger = require("../Logger"); +const MSmartPacket = require("./MSmartPacket"); +const MSmartTimeoutError = require("./MSmartTimeoutError"); +const Semaphore = require("semaphore"); +const tls = require("tls"); +const {createBroker} = require("aedes"); + +// For this to work in all situations, we need to patch out the forced timesync within the firmware, +// as otherwise it will never start up if access to the hardcoded NTP servers in the FW is blocked +// J15PU FW 490 also added an additional check that pings either google or baidu and refuses to start up otherwise +// That too needs to be patched out + +class MSmartDummycloud { + /** + * @param {object} options + * @param {import("../utils/DummyCloudCertManager")} options.dummyCloudCertManager + * @param {string} options.bindIP + * @param {number=} options.timeout timeout in milliseconds to wait for a response + * @param {(packet: import("./MSmartPacket")) => boolean} options.onIncomingCloudMessage + * @param {string} options.dummyClientCert + * @param {string} options.dummyClientKey + * @param {() => void} options.onConnected + * @param {(req: any, res: any) => boolean} options.onHttpRequest + * @param {(type: string, data: any) => void} options.onUpload - TODO naming + * @param {(type: string, value: any) => void} options.onEvent - TODO naming + */ + constructor(options) { + this.dummyCloudCertManager = options.dummyCloudCertManager; + this.bindIP = options.bindIP; + this.timeout = options.timeout ?? 5000; + this.onConnected = options.onConnected; + this.onIncomingCloudMessage = options.onIncomingCloudMessage; + this.onHttpRequest = options.onHttpRequest; + this.onUpload = options.onUpload; + this.onEvent = options.onEvent; + this.dummyClientCert = options.dummyClientCert; + this.dummyClientKey = options.dummyClientKey; + + this.sendCommandMutex = Semaphore(1); + + this.mqttBroker = createBroker(); + this.mqttServer = tls.createServer({ + SNICallback: (hostname, callback) => { + const { key, cert } = this.dummyCloudCertManager.getCertificate(hostname); + callback(null, tls.createSecureContext({ key: key, cert: cert })); + } + }); + this.httpServer = https.createServer({ + SNICallback: (hostname, callback) => { + const { key, cert } = this.dummyCloudCertManager.getCertificate(hostname); + callback(null, tls.createSecureContext({ key: key, cert: cert })); + } + }); + + this.commandTopic = "device/unknown/down"; + this.aiCommandTopic = "ai/unknown/down"; + + /** + * @type {Object. void, + * resolve: (result: any) => void, + * reject: (err: any) => void, + * command: string + * }>} + */ + this.pendingRequests = {}; + + this.setupMQTT(); + this.setupHTTP(); + } + + setupMQTT() { + this.mqttServer = tls.createServer({ + SNICallback: (hostname, callback) => { + const { key, cert } = this.dummyCloudCertManager.getCertificate(hostname); + callback(null, tls.createSecureContext({ key: key, cert: cert })); + } + }, this.mqttBroker.handle); + + this.mqttServer.listen(MSmartDummycloud.MQTT_PORT, this.bindIP, () => { + Logger.info(`MSmartDummycloud MQTT listening on ${this.bindIP}:${MSmartDummycloud.MQTT_PORT}`); + }); + + this.mqttServer.on("error", (err) => { + Logger.error("MSmartDummycloud MQTT Server Error:", err); + }); + + this.mqttBroker.on("client", (client) => { + Logger.info(`MSmartDummycloud MQTT client connected: ${client.id}`); + + this.onConnected(); + }); + + this.mqttBroker.on("subscribe", (subscriptions) => { + Logger.debug("Subscriptions", subscriptions); + + subscriptions.forEach(subscription => { + if (subscription.topic.endsWith("/down")) { + if (subscription.topic.startsWith("device/")) { + this.commandTopic = subscription.topic; + Logger.info(`MSmartDummycloud device command topic: ${this.commandTopic}`); + } else if (subscription.topic.startsWith("ai/")) { + this.aiCommandTopic = subscription.topic; + Logger.info(`MSmartDummycloud AI command topic: ${this.aiCommandTopic}`); + } + } + }); + }); + + this.mqttBroker.on("clientDisconnect", (client) => { + Logger.info(`MSmartDummycloud MQTT client disconnected: ${client.id}`); + }); + + this.mqttBroker.on("publish", async (packet, client) => { + if (!client) { + return; // messages without client are outgoing + } + + Logger.trace(`MSmartDummycloud MQTT Message on '${packet.topic}':`, packet.payload.toString()); + + try { + const message = JSON.parse(packet.payload.toString()); + this.handleIncomingCloudMessage({topic: packet.topic, payload: message}); + } catch (e) { + Logger.warn("MSmartDummycloud failed to parse incoming message", e); + } + }); + } + + setupHTTP() { + const app = express(); + app.use(express.json()); + + app.post("/acl/device/register", (req, res) => { + const incomingHostname = req.hostname; + Logger.info(`Handling provisioning request for hostname: ${incomingHostname}. ${JSON.stringify(req.body, null, 2)}`); + + const responsePayload = { + "errorCode": "0", + "msg": "success", + "reason": "success", + "data": { + "deviceId": req.body.uuid, + "uuid": req.body.uuid, + "productId": req.body.productId, + "mac": req.body.mac, + "sn": req.body.sn, + "ip": req.ip, + "key": "MOCK_DEVICE_KEY_" + Date.now(), + "bindToken": "MOCK_BIND_TOKEN_" + Date.now(), + "mqttInfo": { + "clientId": req.body.uuid, + "serverAddress": incomingHostname, + "port": MSmartDummycloud.MQTT_PORT, + "authType": 1, + "certificatePem": this.dummyClientCert, + "publicKey": this.dummyClientCert, + "privateKey": this.dummyClientKey + }, + "extra": { + "mapHost": `http://${incomingHostname}`, + "odmServiceHost": `https://${incomingHostname}`, + "otaHost": `http://${incomingHostname}`, + "logHost": `http://${incomingHostname}`, + "voiceHost": `http://${incomingHostname}`, + "videoHost": `https://${incomingHostname}` + } + } + }; + + Logger.info("Constructed Response Payload:", JSON.stringify(responsePayload, null, 2)); + res.status(200).json(responsePayload); + }); + + app.get("/m7-server/actuator/health/ping", (req, res) => { + Logger.trace("Handling /m7-server/actuator/health/ping"); + res.status(200).json({"status": "UP"}); + }); + + app.get("/", (req, res, next) => { + if (req.hostname.endsWith("ipify.org") || req.hostname.endsWith("ipify.cn")) { + Logger.info(`Handling IP lookup request for ${req.hostname}.`); + + res.status(200).send(req.ip); + } else { + next(); + } + }); + + app.post("/v1/dev2pro/m7/map/part/get", (req, res) => { + Logger.debug(`Handling part get for: ${req.body.mapPart}`); + Logger.debug(req.body); + + res.status(200).json({ "data": {} }); + }); + + app.post("/v1/dev2pro/m7/map/list/:part", (req, res) => { + if (req.body) { + Logger.trace(`${req.url}: `, JSON.stringify(req.body, null, 2)); + } + + this.onUpload(req.params.part, req.body.data); //TODO: perhaps validate types + + res.status(200).send(); + }); + + app.post("/v1/dev2pro/m7/map/list/mop/:part", (req, res) => { + if (req.body) { + Logger.trace(`${req.url}: `, JSON.stringify(req.body, null, 2)); + } + + this.onUpload(`mop_${req.params.part}`, req.body.data); //TODO: perhaps validate types + + res.status(200).send(); + }); + + app.post("/v1/dev2pro/m7/map/part/upload", (req, res) => { + if (req.body) { + Logger.trace(`${req.url}: `, JSON.stringify(req.body, null, 2)); + } + + this.onUpload(req.body.mapPart, req.body.data); + + res.status(200).send(); + }); + + app.post("/v1/dev2pro/cruise/list/points", (req, res) => { + if (req.body) { + Logger.trace(`${req.url}: `, JSON.stringify(req.body, null, 2)); + } + + this.onUpload("points", req.body.data); + + res.status(200).send(); + }); + + app.post("/v1/dev2pro/m7/work/status/upload", (req, res) => { + Logger.debug("Received a historical record for a finished or aborted cleanup."); + + res.status(200).json({ msg: "OK", code: "0" }); + }); + + /* + This is used to ask the cloud for a URL to a voice pack by id + I think it also regularly checks for updates to voicepacks? Not sure. + + Reference reply for request + { + "sn8": "750Y000R", + "id": "561", + "md5": "958386132015a99f300ee9a372273b4a" + } + + { + "code": "0", + "data": "https:///m7-voice-full/750Y000R-561-18-.zip", + "md5": "052e67dd8843cdfc8f89fc130ea21db5", + "msg": "OK", + "nonce": "", + "voiceId": "1198" + } + */ + + app.post("/v1/dev2pro/m7/voice/check", (req, res) => { + Logger.debug(`Handling request for Voice with ID '${req.body.id}'`); + + res.status(200).json({ + "code": "0", + "data": "", + "md5": "", + "msg": "OK", + "nonce": req.headers["nonce"] ?? "", + "voiceId": "" + }); + }); + + + /* + Always respond with "no update available". + For reference, example reply for an available update: + + { + "code":"0", + "msg":"OK", + "nonce":"", + "data":{ + "id":91, + "sn8":"750Y000R", + "moduleBranchCode":63, + "type":0, + "name":"V2.0.0.20240927_rc", + "md5":"9478bb7890c6cef753e484804c117e26", + "url":"https:///.zip", + "size":32044516, + "version":2, + "minModuleVersion":240, + "maxModuleVersion":9999, + "releaseMode":0, + "whitelistDeviceIds":[ + + ], + "historicalVersions":[ + + ], + "lastOperator":"lixin224", + "updateTime":"2025-05-23 19:25:48" + } + } + */ + app.post("/package-management/v1/dev2pro/check", (req, res) => { + Logger.debug(`Handling AI Model update check. Currently installed version: '${req.body.packageVersion}'`); + + res.status(200).json({ + "code": "0", + "msg": "OK", + "nonce": req.headers["nonce"] ?? "", + "data": null + }); + }); + + app.post("/v1/ota/version/check", (req, res) => { + const requestedModule = req.body.isModule || "0"; + Logger.debug(`Handling OTA check for module type: ${requestedModule}`); + + const responsePayload = { + "errorCode": "0", + "msg": "success", + "reason": "success", + "data": { + "isModule": requestedModule, + "hasNew": "0", + "md5": "", + "productName": "", + "sn8": "", + "url": "", + "version": "", + "forceUpdate": "", + "fwSign": "", + "sh256": "", + "rsaSign": "" + } + }; + + res.status(200).json(responsePayload); + }); + + app.post("/v1/ota/status/update", (req, res) => { + Logger.debug("Handling OTA status update request."); + if (req.body) { + Logger.debug("OTA Status Update Body:", JSON.stringify(req.body, null, 2)); + } + + res.status(200).json({ msg: "OK", code: "0" }); + }); + + app.post("/logService/v1/dev/event-tracking", (req, res) => { + if (req.body) { + Logger.trace(`${req.url}: `, JSON.stringify(req.body, null, 2)); + } + + this.onEvent(req.body.type, req.body.value); + + res.status(200).send(); + }); + + app.post("/v3/dev2pro/login", (req, res) => { + Logger.debug("Received login request"); + if (req.body) { + Logger.debug("Body:", JSON.stringify(req.body, null, 2)); + } + + res.status(200).json({ data: "i-am-not-a-token" }); + }); + + app.post("/v3/dev2pro/ability", (req, res) => { + Logger.debug("Received ability request"); + if (req.body) { + Logger.debug("Body:", JSON.stringify(req.body, null, 2)); + } + + res.status(200).json({ + data: { + videoImageEnc: false + // There could be more in this. So far, I just found the single key and didn't check what the cloud actually sends + // TBD: is false the correct thing to set here? + } + }); + }); + + /* + This seems to be used by the firmware to pull a temporary AES encryption key on boot to.. maybe encrypt pictures with? + The response itself, even though sent via HTTPS, is a json containing base64, which is the requested key + IV AES encrypted with a static one + + Persistent might mean image uploads perhaps? Compared with its RTC sibling + */ + app.post("/v3/dev2pro/enc/persistent/key", (req, res) => { + Logger.debug("Handling persistent key request"); + + const transportKey = "Midea@api-device"; + const algorithm = "aes-128-cbc"; + + // 16-byte key + 16-byte IV + const plaintextPayload = Buffer.alloc(32, 0); + + const transportIv = Buffer.alloc(16, 0); + const cipher = crypto.createCipheriv(algorithm, transportKey, transportIv); + const encryptedPayload = Buffer.concat([cipher.update(plaintextPayload), cipher.final()]); + + res.status(200).json({ + code: "0", + msg: "success", + requestId: transportIv.toString("hex"), + data: encryptedPayload.toString("base64"), + }); + }); + + + // FIXME: this is a duplicate of the persistent route. + // I am just guessing that this might be the correct response as well + // TODO: check internal logs of the firmware and see if anything complains + app.post("/v3/dev2pro/enc/rtc/key", (req, res) => { + Logger.debug("Handling rtc key request"); + + Logger.debug("Handling persistent key request"); + + const transportKey = "Midea@api-device"; + const algorithm = "aes-128-cbc"; + + // 16-byte key + 16-byte IV + const plaintextPayload = Buffer.alloc(32, 0); + + const transportIv = Buffer.alloc(16, 0); + const cipher = crypto.createCipheriv(algorithm, transportKey, transportIv); + const encryptedPayload = Buffer.concat([cipher.update(plaintextPayload), cipher.final()]); + + res.status(200).json({ + code: "0", + msg: "success", + requestId: transportIv.toString("hex"), + data: encryptedPayload.toString("base64"), + }); + }); + + // This route receives events that might be protobufs(?) as multipart/form-data. They do seem debug-only? Not sure. + // Let's see if we can get away without _actually_ handling them + app.post("/v3/dev2pro/robot/event", (req, res) => { + res.status(200).send(); + }); + + + app.all("*", (req, res) => { + if (this.onHttpRequest) { + const handled = this.onHttpRequest(req, res); + if (handled) { + return; + } + } + + Logger.info("Unhandled MSmartDummycloud HTTP Request", { + protocol: req.secure ? "HTTPS" : "HTTP", + host: req.headers.host, + method: req.method, + path: req.path, + headers: req.headers, + body: req.body ?? null + }); + + res.status(200).json({ msg: "OK", code: "0" }); + }); + + this.httpServer.on("request", app); + + this.httpServer.listen(MSmartDummycloud.HTTP_PORT, this.bindIP, () => { + Logger.info(`MSmartDummycloud HTTPS listening on ${this.bindIP}:${MSmartDummycloud.HTTP_PORT}`); + }); + + this.httpServer.on("error", (err) => { + Logger.error("MSmartDummycloud HTTPS Server Error:", err); + }); + } + + handleIncomingCloudMessage(msg) { + const { topic, payload } = msg; + + try { + // Parse the payload.data as MSmartPacket + const responseHex = payload.data; + const responseBuffer = Buffer.from(responseHex, "hex"); + const responsePacket = MSmartPacket.FROM_BYTES(responseBuffer); + + Logger.trace("Parsed incoming message:", { + topic: topic, + nonce: payload.nonce, + deviceType: responsePacket.deviceType, + messageType: responsePacket.messageType, + payload: responsePacket.payload, + payloadLength: responsePacket.payload.length + }); + + if (payload.nonce && this.pendingRequests[payload.nonce]) { + const pendingRequest = this.pendingRequests[payload.nonce]; + + clearTimeout(pendingRequest.timeout_id); + pendingRequest.resolve(responsePacket); + delete this.pendingRequests[payload.nonce]; + + Logger.debug(`MSmartDummycloud received response for nonce ${payload.nonce}`); + return; + } + + if (!this.onIncomingCloudMessage(responsePacket)) { + Logger.info("Unhandled message received:", responsePacket); + } + + } catch (parseError) { + Logger.warn("Failed to parse incoming message:", parseError); + + if (payload.nonce && this.pendingRequests[payload.nonce]) { + const pendingRequest = this.pendingRequests[payload.nonce]; + clearTimeout(pendingRequest.timeout_id); + + pendingRequest.reject(new Error(`Failed to parse MSmart response: ${parseError.message}`)); + delete this.pendingRequests[payload.nonce]; + } + } + } + + /** + * @param {string} command + * @param {object} [options] + * @param {number} [options.timeout] - milliseconds + * @param {"device"|"ai"} [options.target] - defaults to "device" + * @returns {Promise} + */ + sendCommand(command, options) { + return new Promise((resolve, reject) => { + this.sendCommandMutex.take(() => { + this.actualSendCommand(command, options).then(response => { + this.sendCommandMutex.leave(); + + resolve(response); + }).catch(err => { + this.sendCommandMutex.leave(); + + reject(err); + }); + }); + }); + } + + /** + * @private + * + * @param {string} command + * @param {object} [options] + * @param {number} [options.timeout] - milliseconds + * @param {"device"|"ai"} [options.target] - defaults to "device" + * @returns {Promise} + */ + actualSendCommand(command, options = {}) { + return new Promise((resolve, reject) => { + const nonce = crypto.randomUUID(); + const payload = JSON.stringify({ + data: command, + nonce: nonce, + version: 1, + timestamp: Math.round(Date.now() / 1000), + productId: "valetudo" + }); + + const target = options?.target ?? "device"; + const targetTopic = target === "ai" ? this.aiCommandTopic : this.commandTopic; + + this.pendingRequests[nonce] = { + resolve: resolve, + reject: reject, + command: command, + onTimeoutCallback: () => { + Logger.debug(`Request with nonce ${nonce} timed out`); + delete this.pendingRequests[nonce]; + + reject(new MSmartTimeoutError({nonce: nonce, command: command})); + } + }; + + this.pendingRequests[nonce].timeout_id = setTimeout( + () => { + this.pendingRequests[nonce].onTimeoutCallback(); + }, + options?.timeout ?? this.timeout + ); + + Logger.trace(`Sending command to ${targetTopic}`, payload); + + this.mqttBroker.publish( + { + cmd: "publish", + topic: targetTopic, + payload: Buffer.from(payload), + qos: 0, + retain: false, + dup: false, + }, + (error) => { + if (error) { + Logger.error(`Error publishing message: ${error}`); + + if (this.pendingRequests[nonce]) { + clearTimeout(this.pendingRequests[nonce].timeout_id); + delete this.pendingRequests[nonce]; + } + + reject(error); + } + } + ); + }); + } + + async shutdown() { + Logger.debug("MSmartDummycloud shutdown in progress..."); + + await new Promise((resolve) => { + this.httpServer.close(() => { + Logger.info("MSmartDummycloud HTTPS server shut down"); + resolve(); + }); + }); + + await new Promise((resolve) => { + this.mqttBroker.close(() => { + this.mqttServer.close(() => { + Logger.info("MSmartDummycloud MQTT server shut down"); + resolve(); + }); + }); + }); + + Logger.debug("MSmartDummycloud shutdown done"); + } +} + +MSmartDummycloud.MQTT_PORT = 8883; +MSmartDummycloud.HTTP_PORT = 443; + +module.exports = MSmartDummycloud; diff --git a/backend/lib/msmart/MSmartPacket.js b/backend/lib/msmart/MSmartPacket.js new file mode 100644 index 00000000..392dc618 --- /dev/null +++ b/backend/lib/msmart/MSmartPacket.js @@ -0,0 +1,127 @@ +class MSmartPacket { + /** + * @param {object} options + * @param {number} [options.deviceType] + * @param {number} [options.messageId] + * @param {number} [options.protocolVersion] + * @param {number} [options.deviceProtocolVersion] + * @param {number} options.messageType + * @param {Buffer} options.payload + */ + constructor(options) { + this.deviceType = options.deviceType ?? MSmartPacket.DEVICE_TYPE.VACUUM; + this.messageId = options.messageId ?? 0; + this.protocolVersion = options.protocolVersion ?? 0; + this.deviceProtocolVersion = options.deviceProtocolVersion ?? 0; + this.messageType = options.messageType; + this.payload = options.payload; + } + + /** + * Serializes the packet into a Buffer for transmission. + * @returns {Buffer} + */ + toBytes() { + const length = 10 + this.payload.length; + if (length > 255) { + throw new Error(`Invalid MSmartPacket! Length ${length} > 255)`); + } + + const header = Buffer.alloc(10); + header[0] = 0xAA; + header[1] = length; + header[2] = this.deviceType; + header[3] = 0x00; + header[4] = 0x00; + header[5] = 0x00; + header[6] = this.messageId; + header[7] = this.protocolVersion; + header[8] = this.deviceProtocolVersion; + header[9] = this.messageType; + + const dataToChecksum = Buffer.concat([header.subarray(1), this.payload]); + const checksum = MSmartPacket.calculateChecksum(dataToChecksum); + + return Buffer.concat([header, this.payload, Buffer.from([checksum])]); + } + + /** + * @returns {string} + */ + toHexString() { + return this.toBytes().toString("hex"); + } + + /** + * @param {Buffer} bytes + * @returns {MSmartPacket} + */ + static FROM_BYTES(bytes) { + if (bytes.length < 11) { + throw new Error(`Packet too short. Expected at least 11 bytes, got ${bytes.length}.`); + } + if (bytes[0] !== 0xAA) { + throw new Error(`Invalid Magic Byte. Expected 0xAA, got 0x${bytes[0].toString(16)}.`); + } + if (bytes[1] !== bytes.length - 1) { + throw new Error(`Length mismatch. Length byte is ${bytes[1]}, but packet length is ${bytes.length}.`); + } + + const dataToChecksum = bytes.subarray(1, bytes.length - 1); + const expectedChecksum = MSmartPacket.calculateChecksum(dataToChecksum); + const actualChecksum = bytes[bytes.length - 1]; + + if (actualChecksum !== expectedChecksum) { + throw new Error(`Checksum mismatch. Calculated 0x${expectedChecksum.toString(16)}, but got 0x${actualChecksum.toString(16)}.`); + } + + return new MSmartPacket({ + deviceType: bytes[2], + messageId: bytes[6], + protocolVersion: bytes[7], + deviceProtocolVersion: bytes[8], + messageType: bytes[9], + payload: bytes.subarray(10, bytes.length - 1) + }); + } + + /** + * @param {Buffer} data + * @returns {number} + */ + static calculateChecksum(data) { + const sum = data.reduce((acc, val) => acc + val, 0); + return (~sum + 1) & 0xFF; + } + + /** + * + * @param {number} commandId + * @param {Buffer} [actualPayload] + * @return {Buffer} + */ + static buildPayload(commandId, actualPayload) { + const header = Buffer.from([0xaa, 0x01, commandId]); + if (actualPayload === undefined) { + return header; + } + + return Buffer.concat([ + header, + actualPayload + ]); + } +} + +MSmartPacket.DEVICE_TYPE = Object.freeze({ + VACUUM: 0xb8 +}); + +MSmartPacket.MESSAGE_TYPE = Object.freeze({ + SETTING: 0x02, + ACTION: 0x03, + EVENT: 0x04, + DOCK: 0x06 +}); + +module.exports = MSmartPacket; diff --git a/backend/lib/msmart/MSmartProvisioningPacket.js b/backend/lib/msmart/MSmartProvisioningPacket.js new file mode 100644 index 00000000..1c190103 --- /dev/null +++ b/backend/lib/msmart/MSmartProvisioningPacket.js @@ -0,0 +1,93 @@ +class MSmartProvisioningPacket { + /** + * @param {object} options + * @param {number} options.commandId + * @param {Buffer} options.payload + */ + constructor(options) { + this.commandId = options.commandId; + this.payload = options.payload; + } + + /** + * @returns {Buffer} + */ + toBytes() { + const coreCommand = Buffer.alloc(4 + this.payload.length); + coreCommand.writeUInt16BE(this.commandId, 0); + coreCommand.writeUInt16BE(this.payload.length, 2); + this.payload.copy(coreCommand, 4); + + const checksum = MSmartProvisioningPacket.calculateChecksum(coreCommand); + + const finalPacket = Buffer.alloc(7 + this.payload.length); + finalPacket[0] = 0xEE; + finalPacket[1] = 0x01; + coreCommand.copy(finalPacket, 2); + finalPacket[finalPacket.length - 1] = checksum; + + return finalPacket; + } + + /** + * @param {Buffer} bytes + * @returns {MSmartProvisioningPacket} + */ + static FROM_BYTES(bytes) { + if (bytes.length < 7) { + throw new Error(`Packet too short. Expected at least 7 bytes, got ${bytes.length}.`); + } + if (bytes[0] !== 0xEE || bytes[1] !== 0x01) { + throw new Error(`Invalid Magic Header. Expected 0xEE01, got 0x${bytes.toString("hex", 0, 2)}.`); + } + + const coreCommand = bytes.subarray(2, bytes.length - 1); + const expectedChecksum = MSmartProvisioningPacket.calculateChecksum(coreCommand); + const actualChecksum = bytes[bytes.length - 1]; + + if (actualChecksum !== expectedChecksum) { + throw new Error(`Checksum mismatch. Calculated 0x${expectedChecksum.toString(16)}, but got 0x${actualChecksum.toString(16)}.`); + } + + const commandId = coreCommand.readUInt16BE(0); + const payloadLength = coreCommand.readUInt16BE(2); + + if (coreCommand.length !== 4 + payloadLength) { + throw new Error(`Payload length mismatch. Header says ${payloadLength}, but actual was ${coreCommand.length - 4}.`); + } + + return new MSmartProvisioningPacket({ + commandId: commandId, + payload: coreCommand.subarray(4) + }); + } + + /** + * @param {Buffer} data + * @returns {number} + */ + static calculateChecksum(data) { + const sum = data.reduce((acc, val) => acc + val, 0); + return sum & 0xFF; + } +} + +MSmartProvisioningPacket.RESPONSE_ID_OFFSET = 0b1000000000000000; // FIXME: naming + +MSmartProvisioningPacket.COMMAND_IDS = Object.freeze({ + CMD_ALL_INFO: 208, + CMD_UUID_INFO: 213, + + // The robot can also send "commands" + CMD_NOTIFY_PROGRESS: 222, + CMD_NOTIFY_RESULT: 223 +}); + +MSmartProvisioningPacket.RESPONSE_IDS = Object.freeze({ + CMD_ALL_INFO: MSmartProvisioningPacket.COMMAND_IDS.CMD_ALL_INFO + MSmartProvisioningPacket.RESPONSE_ID_OFFSET, + CMD_UUID_INFO: MSmartProvisioningPacket.COMMAND_IDS.CMD_UUID_INFO + MSmartProvisioningPacket.RESPONSE_ID_OFFSET, + CMD_NOTIFY_PROGRESS: MSmartProvisioningPacket.COMMAND_IDS.CMD_NOTIFY_PROGRESS + MSmartProvisioningPacket.RESPONSE_ID_OFFSET, + CMD_NOTIFY_RESULT: MSmartProvisioningPacket.COMMAND_IDS.CMD_NOTIFY_RESULT + MSmartProvisioningPacket.RESPONSE_ID_OFFSET +}); + +module.exports = MSmartProvisioningPacket; diff --git a/backend/lib/msmart/MSmartTimeoutError.js b/backend/lib/msmart/MSmartTimeoutError.js new file mode 100644 index 00000000..196f64fa --- /dev/null +++ b/backend/lib/msmart/MSmartTimeoutError.js @@ -0,0 +1,16 @@ +class MSmartTimeoutError extends Error { + /** + * @param {object} context + * @param {string} context.nonce + * @param {string} context.command + */ + constructor(context) { + super(`Request with nonce ${context.nonce} timed out`); + + this.name = "MSmartTimeoutError"; + this.nonce = context.nonce; + this.command = context.command; + } +} + +module.exports = MSmartTimeoutError; diff --git a/backend/lib/msmart/dtos/MSmartActiveZonesDTO.js b/backend/lib/msmart/dtos/MSmartActiveZonesDTO.js new file mode 100644 index 00000000..740a2f52 --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartActiveZonesDTO.js @@ -0,0 +1,41 @@ +const MSmartDTO = require("./MSmartDTO"); + +/** + * @typedef {object} MideaMapPoint + * @property {number} x + * @property {number} y + */ + +/** + * @typedef {object} MideaActiveZone + * @property {number} index + * @property {number} passes + * @property {MideaMapPoint} pA - top-left + * @property {MideaMapPoint} pC - bottom-right + */ + +/** + * @typedef {object} MideaActiveZonesData + * @property {MideaActiveZone[]} zones + */ + + +/** + * @class MSmartActiveZonesDTO + * @extends MSmartDTO + */ +class MSmartActiveZonesDTO extends MSmartDTO { + /** + * @param {MideaActiveZonesData} data + */ + constructor(data) { + super(); + + /** @type {MideaActiveZone[]} */ + this.zones = data.zones; + + Object.freeze(this); + } +} + +module.exports = MSmartActiveZonesDTO; diff --git a/backend/lib/msmart/dtos/MSmartCleaningSettings1DTO.js b/backend/lib/msmart/dtos/MSmartCleaningSettings1DTO.js new file mode 100644 index 00000000..d40643af --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartCleaningSettings1DTO.js @@ -0,0 +1,31 @@ +const MSmartDTO = require("./MSmartDTO"); + +// FIXME: naming + +class MSmartCleaningSettings1DTO extends MSmartDTO { + /** + * @param {object} data + * @param {number} data.route_type + * @param {number} data.cut_hair_level 0-2 + * @param {number} data.collect_suction_level 0-1 + * @param {boolean} data.exhibition_switch + * @param {number} data.ai_grade_avoidance_mode + * @param {boolean} data.cut_hair_super_switch + * @param {number} data.turbidity_re_mop_switch + */ + constructor(data) { + super(); + + this.route_type = data.route_type; + this.cut_hair_level = data.cut_hair_level; + this.collect_suction_level = data.collect_suction_level; + this.exhibition_switch = data.exhibition_switch; + this.ai_grade_avoidance_mode = data.ai_grade_avoidance_mode; + this.cut_hair_super_switch = data.cut_hair_super_switch; + this.turbidity_re_mop_switch = data.turbidity_re_mop_switch; + + Object.freeze(this); + } +} + +module.exports = MSmartCleaningSettings1DTO; diff --git a/backend/lib/msmart/dtos/MSmartDNDConfigurationDTO.js b/backend/lib/msmart/dtos/MSmartDNDConfigurationDTO.js new file mode 100644 index 00000000..11f6ad6d --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartDNDConfigurationDTO.js @@ -0,0 +1,27 @@ +const MSmartDTO = require("./MSmartDTO"); + +class MSmartDndConfigurationDTO extends MSmartDTO { + /** + * @param {object} data + * @param {boolean} data.enabled + * + * @param {object} data.start + * @param {number} data.start.hour + * @param {number} data.start.minute + * + * @param {object} data.end + * @param {number} data.end.hour + * @param {number} data.end.minute + */ + constructor(data) { + super(); + + this.enabled = data.enabled; + this.start = data.start; + this.end = data.end; + + Object.freeze(this); + } +} + +module.exports = MSmartDndConfigurationDTO; diff --git a/backend/lib/msmart/dtos/MSmartDTO.js b/backend/lib/msmart/dtos/MSmartDTO.js new file mode 100644 index 00000000..2b2b2d7b --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartDTO.js @@ -0,0 +1,8 @@ +/** + * @abstract + */ +class MSmartDTO { + +} + +module.exports = MSmartDTO; diff --git a/backend/lib/msmart/dtos/MSmartDockStatusDTO.js b/backend/lib/msmart/dtos/MSmartDockStatusDTO.js new file mode 100644 index 00000000..9d0d0d91 --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartDockStatusDTO.js @@ -0,0 +1,21 @@ +const MSmartDTO = require("./MSmartDTO"); + +class MSmartDockStatusDTO extends MSmartDTO { + /** + * @param {object} data + * @param {number} data.dust_collection_count + * @param {boolean} data.fluid_1_ok + * @param {boolean} data.fluid_2_ok + */ + constructor(data) { + super(); + + this.dust_collection_count = data.dust_collection_count; + this.fluid_1_ok = data.fluid_1_ok; + this.fluid_2_ok = data.fluid_2_ok; + + Object.freeze(this); + } +} + +module.exports = MSmartDockStatusDTO; diff --git a/backend/lib/msmart/dtos/MSmartErrorDTO.js b/backend/lib/msmart/dtos/MSmartErrorDTO.js new file mode 100644 index 00000000..b37e33b3 --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartErrorDTO.js @@ -0,0 +1,21 @@ +const MSmartDTO = require("./MSmartDTO"); + +class MSmartErrorDTO extends MSmartDTO { + /** + * @param {object} data + * @param {number} data.error_type + * @param {number} data.error_desc + * @param {number} data.sta_index - FIXME: figure out what this means + */ + constructor(data) { + super(); + + this.error_type = data.error_type; + this.error_desc = data.error_desc; + this.sta_index = data.sta_index; + + Object.freeze(this); + } +} + +module.exports = MSmartErrorDTO; diff --git a/backend/lib/msmart/dtos/MSmartMapListDTO.js b/backend/lib/msmart/dtos/MSmartMapListDTO.js new file mode 100644 index 00000000..b333cc37 --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartMapListDTO.js @@ -0,0 +1,19 @@ +const MSmartDTO = require("./MSmartDTO"); + +class MSmartMapListDTO extends MSmartDTO { + /** + * @param {object} data + * @param {number} data.currentMapId + * @param {Array} data.savedMapIds + */ + constructor(data) { + super(); + + this.currentMapId = data.currentMapId; + this.savedMapIds = data.savedMapIds; + + Object.freeze(this); + } +} + +module.exports = MSmartMapListDTO; diff --git a/backend/lib/msmart/dtos/MSmartStatusDTO.js b/backend/lib/msmart/dtos/MSmartStatusDTO.js new file mode 100644 index 00000000..06433d43 --- /dev/null +++ b/backend/lib/msmart/dtos/MSmartStatusDTO.js @@ -0,0 +1,187 @@ +const MSmartDTO = require("./MSmartDTO"); + +class MSmartStatusDTO extends MSmartDTO { + /** + * @param {object} data + * @param {number} [data.work_status] + * @param {number} [data.function_type] + * @param {number} [data.control_type] + * @param {number} [data.move_direction] + * @param {number} [data.work_mode] + * @param {number} [data.fan_level] + * @param {number} [data.work_area] + * @param {number} [data.water_level] + * @param {number} [data.voice_level] + * @param {number} [data.battery_percent] + * @param {number} [data.work_time] + * @param {boolean} [data.uv_switch] - TODO: VALIDATE + * @param {boolean} [data.wifi_switch] - TODO: VALIDATE + * @param {boolean} [data.voice_switch] - TODO: VALIDATE + * @param {boolean} [data.command_source] - TODO: VALIDATE + * @param {boolean} [data.device_error] - TODO: VALIDATE + * @param {number} [data.error_type] + * @param {number} [data.error_desc] + * @param {boolean} [data.has_mop] + * @param {boolean} [data.has_vibrate_mop] + * @param {number} [data.carpet_switch] + * @param {number} [data.district_status] + * @param {number} [data.cleaning_type] + * @param {string} [data.vibrate_mode] + * @param {boolean} [data.vibrate_switch] + * @param {boolean} [data.electrolyzed_water] + * @param {number} [data.electrolyzed_water_status] + * @param {boolean} [data.dustDragSwitch] + * @param {boolean} [data.dustDragStatus] + * @param {number} [data.dustTimes] + * @param {number} [data.dustedTimes] + * @param {number} [data.chargeDockType] + * @param {boolean} [data.fluid_1_ok] + * @param {boolean} [data.fluid_2_ok] + * @param {boolean} [data.dustbag_installed] + * @param {boolean} [data.dustbag_full] + * @param {number} [data.mopMode] + * @param {number} [data.station_work_status] + * @param {number} [data.job_state] + * @param {number} [data.whole_process_state] + * @param {boolean} [data.continuous_clean_mode] + * @param {boolean} [data.clean_sequence_switch] + * @param {boolean} [data.child_lock_enabled] + * @param {boolean} [data.child_lock_follows_dnd] + * @param {boolean} [data.personal_clean_prefer_switch] + * @param {boolean} [data.station_inject_fluid_switch] + * @param {boolean} [data.station_inject_soft_fluid_switch] + * @param {boolean} [data.carpet_evade_switch] + * @param {boolean} [data.station_first_fast_wash_switch] + * @param {boolean} [data.pet_mode_switch] + * @param {number} [data.station_capability_flags] + * @param {boolean} [data.stain_clean_switch] + * @param {boolean} [data.ai_obstacle_switch] + * @param {boolean} [data.cross_bridge_switch] + * @param {boolean} [data.camera_led_switch] + * @param {boolean} [data.map_3d_switch] + * @param {boolean} [data.ai_recognition_switch] + * @param {number} [data.test_mode_type] + * @param {number} [data.hot_water_wash_mode] + * @param {boolean} [data.station_self_fluid_2_switch] + * @param {boolean} [data.slam_version_switch] + * @param {boolean} [data.hot_dry_charge_plate_switch] + * @param {boolean} [data.telnet_switch] + * @param {boolean} [data.mop_auto_dry_switch] + * @param {boolean} [data.ai_grade_avoidance_mode] + * @param {boolean} [data.pound_sign_switch] + * @param {number} [data.stationCleanFrequency] + * @param {number} [data.beautify_map_grade] + * @param {number} [data.collect_dust_mode] + * @param {number} [data.session_id] + * @param {number} [data.transaction_id] + * @param {boolean} [data.bridge_boost_switch] + * @param {boolean} [data.narrow_zone_recharge_switch] + * @param {boolean} [data.verification_map_switch] + * @param {boolean} [data.wake_up_switch] + * @param {boolean} [data.ai_carpet_avoid_switch] + * @param {boolean} [data.carpet_evade_adaptive_switch] + * @param {boolean} [data.stuck_mark_switch] + * @param {boolean} [data.mop_extend_switch] + * @param {boolean} [data.zigzag_to_end_switch] + * @param {number} [data.remaining_area] + * @param {boolean} [data.ai_avoidance_switch] + * @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] + */ + constructor(data) { + super(); + + this.work_status = data.work_status; + this.function_type = data.function_type; + this.control_type = data.control_type; + this.move_direction = data.move_direction; + this.work_mode = data.work_mode; + this.fan_level = data.fan_level; + this.work_area = data.work_area; + this.water_level = data.water_level; + this.voice_level = data.voice_level; + this.battery_percent = data.battery_percent; + this.work_time = data.work_time; + this.uv_switch = data.uv_switch; + this.wifi_switch = data.wifi_switch; + this.voice_switch = data.voice_switch; + this.command_source = data.command_source; + this.device_error = data.device_error; + this.error_type = data.error_type; + this.error_desc = data.error_desc; + this.has_mop = data.has_mop; + this.has_vibrate_mop = data.has_vibrate_mop; + this.carpet_switch = data.carpet_switch; + this.district_status = data.district_status; + this.cleaning_type = data.cleaning_type; + this.vibrate_mode = data.vibrate_mode; + this.vibrate_switch = data.vibrate_switch; + this.electrolyzed_water = data.electrolyzed_water; + this.electrolyzed_water_status = data.electrolyzed_water_status; + this.dustDragSwitch = data.dustDragSwitch; + this.dustDragStatus = data.dustDragStatus; + this.dustTimes = data.dustTimes; + this.dustedTimes = data.dustedTimes; + this.chargeDockType = data.chargeDockType; + this.fluid_1_ok = data.fluid_1_ok; + this.fluid_2_ok = data.fluid_2_ok; + this.dustbag_installed = data.dustbag_installed; + this.dustbag_full = data.dustbag_full; + this.mopMode = data.mopMode; + this.station_work_status = data.station_work_status; + this.job_state = data.job_state; + this.whole_process_state = data.whole_process_state; + this.continuous_clean_mode = data.continuous_clean_mode; + this.clean_sequence_switch = data.clean_sequence_switch; + this.child_lock_enabled = data.child_lock_enabled; + this.child_lock_follows_dnd = data.child_lock_follows_dnd; + this.personal_clean_prefer_switch = data.personal_clean_prefer_switch; + this.station_inject_fluid_switch = data.station_inject_fluid_switch; + this.station_inject_soft_fluid_switch = data.station_inject_soft_fluid_switch; + this.carpet_evade_switch = data.carpet_evade_switch; + this.station_first_fast_wash_switch = data.station_first_fast_wash_switch; + this.pet_mode_switch = data.pet_mode_switch; + this.station_capability_flags = data.station_capability_flags; + this.stain_clean_switch = data.stain_clean_switch; + this.ai_obstacle_switch = data.ai_obstacle_switch; + this.cross_bridge_switch = data.cross_bridge_switch; + this.camera_led_switch = data.camera_led_switch; + this.map_3d_switch = data.map_3d_switch; + this.ai_recognition_switch = data.ai_recognition_switch; + this.test_mode_type = data.test_mode_type; + this.hot_water_wash_mode = data.hot_water_wash_mode; + this.station_self_fluid_2_switch = data.station_self_fluid_2_switch; + this.slam_version_switch = data.slam_version_switch; + this.hot_dry_charge_plate_switch = data.hot_dry_charge_plate_switch; + this.telnet_switch = data.telnet_switch; + this.mop_auto_dry_switch = data.mop_auto_dry_switch; + this.ai_grade_avoidance_mode = data.ai_grade_avoidance_mode; + this.pound_sign_switch = data.pound_sign_switch; + this.stationCleanFrequency = data.stationCleanFrequency; + this.beautify_map_grade = data.beautify_map_grade; + this.collect_dust_mode = data.collect_dust_mode; + this.session_id = data.session_id; + this.transaction_id = data.transaction_id; + this.bridge_boost_switch = data.bridge_boost_switch; + this.narrow_zone_recharge_switch = data.narrow_zone_recharge_switch; + this.verification_map_switch = data.verification_map_switch; + this.wake_up_switch = data.wake_up_switch; + this.ai_carpet_avoid_switch = data.ai_carpet_avoid_switch; + this.carpet_evade_adaptive_switch = data.carpet_evade_adaptive_switch; + this.stuck_mark_switch = data.stuck_mark_switch; + this.mop_extend_switch = data.mop_extend_switch; + this.zigzag_to_end_switch = data.zigzag_to_end_switch; + this.remaining_area = data.remaining_area; + this.ai_avoidance_switch = data.ai_avoidance_switch; + 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; + + Object.freeze(this); + } +} + +module.exports = MSmartStatusDTO; diff --git a/backend/lib/msmart/dtos/index.js b/backend/lib/msmart/dtos/index.js new file mode 100644 index 00000000..a5c34a70 --- /dev/null +++ b/backend/lib/msmart/dtos/index.js @@ -0,0 +1,10 @@ +module.exports = { + MSmartActiveZonesDTO: require("./MSmartActiveZonesDTO"), + MSmartCleaningSettings1DTO: require("./MSmartCleaningSettings1DTO"), + MSmartDTO: require("./MSmartDTO"), + MSmartDndConfigurationDTO: require("./MSmartDNDConfigurationDTO"), + MSmartDockStatusDTO: require("./MSmartDockStatusDTO"), + MSmartErrorDTO: require("./MSmartErrorDTO"), + MSmartMapListDTO: require("./MSmartMapListDTO"), + MSmartStatusDTO: require("./MSmartStatusDTO"), +}; diff --git a/backend/lib/res/roodkcab.js b/backend/lib/res/roodkcab.js new file mode 100644 index 00000000..9e989390 --- /dev/null +++ b/backend/lib/res/roodkcab.js @@ -0,0 +1,17 @@ +/* +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⢀⣀⡠⠤⠴⠚⣿⠃ +⠀⠸⣿⡭⣭⣿⣽⣿⣿⣿⣿⣿⣿⣿⣽⣿⡿⠓⠚⠉⣉⣀⣤⡤⣴⠀⣿⠀ +⠀⠀⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢰⠞⢩⠀⢻⡏⠀⡏⠀⣿⠄ +⠀⢠⣟⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠀⠃⠀⣿⠂ +⠀⢘⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠀⡇⠀⣿⡇ +⠀⠈⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠀⣷⠀⣿⡇ +⠀⣠⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠀⣿⣼⣿⡇ +⠀⡃⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠘⠛⠛⠒⠛⠓⠛⠛⣿⣿⡇ +⠀⠀⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢰⠦⢠⠀⢤⣤⣤⣄⠋⣿⡇ +⠀⠀⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠈⣿⠀⣿⡇ +⠀⢸⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⠀⢸⠀⢸⡇⠀⣿⠀⣿⡇ +⠀⠀⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⢸⣄⢸⠠⣼⡇⠀⣿⠀⣿⡇ +⠀⣸⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠉⠉⠀⠛⠚⠯⠿⠀⣿⡇ +⠠⢿⣿⣷⣶⣶⣶⠶⢶⡶⢶⣶⣶⣶⣶⢿⣶⣤⣄⣀⣀⠀⠀⠀⢨⠀⣿⡇ +⠀⠀⠀⠈⠀⠐⠒⠒⠀⠀⠀⠘⠁⠈⠀⠀⠀⠀⠉⠉⢛⠉⠑⠒⠠⠤⢿⠇ + */ diff --git a/backend/lib/robots/index.js b/backend/lib/robots/index.js index 350e7f74..c453e9fa 100644 --- a/backend/lib/robots/index.js +++ b/backend/lib/robots/index.js @@ -1,4 +1,5 @@ const dreame = require("./dreame"); +const midea = require("./midea"); const mock = require("./mock"); const roborock = require("./roborock"); const viomi = require("./viomi"); @@ -7,5 +8,6 @@ module.exports = Object.assign({}, roborock, viomi, dreame, + midea, mock ); diff --git a/backend/lib/robots/midea/MideaConst.js b/backend/lib/robots/midea/MideaConst.js new file mode 100644 index 00000000..e45373a9 --- /dev/null +++ b/backend/lib/robots/midea/MideaConst.js @@ -0,0 +1,59 @@ + +/* +openssl req -x509 -newkey rsa:2048 -nodes -keyout dummy_client.key -out dummy_client.crt -sha256 -days 36500 -subj "/CN=ValetudoDummyClient" + */ +const DUMMY_CLIENT_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDcTZAsDj4J2iVy +VJFEtz6xAZOLoAzAI02MRDgRhOb5Abbwg3GJ2nfM9GHBXGSyC1X5UPLFMSe4TI/C +Woajcf67Ru7xlPkDwb1i5QkzmVenJmflWH/vmvlKU421asFsvUXO2T80EPxoQ5B5 +viffA6dujcPFmvu4IN+oKp+TcdXXI/fAleiT3UMKOubD8drWXSVWTDNMpN5BNE1j +sPYN14rSCMCbcoPSa8tGzK1khMiClVWYRGXPz0Xk2D+4b1/fuFEunQ259pnneoa4 +ohe44NYfSo/iu92RLYHIia1SXQaU9MwqrsD5APT7zo+y9dMqVrLJSAXRJqCCCrsP +DT1BQ4TNAgMBAAECggEAD38dPxwZXRQNQkeUmGLTdBwKRu4RN4rEL7O0xfa1UJrA +RZbZa7sEZlRic/mN08BcYddB3IEirCImkqNPiTvBkWbh8/hos8zzB3vY89o7gjR/ +ZnCdPzuFgaby9un1hTKjMHOzsHPpbWQjS40GvPdC1dH/DW1je4ZEdU3aP8LoKePq +Y6QEI3Wb6LqG/qM7x2TiFgsRbChkJeZNo8c5rl5puLd4cxjyLifeERTxtAoM6mCJ +mD8gindVThFWxUY+O92DIX7T9JUwM059dBZfe9wSCXX84a5blMiBeq9Q7ilEwPUi +jwpudJwJJ/7Q/9FjgRrSyPr/L7aeVFG2Uw11C4N0eQKBgQDnaR5vREUveFlAKTyy +cttDo4jPriPpIjtG9OHSeyg5K/gQJ7X94IngroxW2qkKXR8hbStdTNRt0Rg7DQpS +HFJwGnD21SWE7FBT30p1XyVpABAmyOL3MhfW+hEMOw3vxc/1k8mzBcjg7YY1zNl7 +KLMyAaiKWiOknemirXVcqqgMywKBgQDztkq6T9SHnKFcWYBEyTRHDBrTR8Yh+WiO +0EY6eXfkvos86kYrlutYKT5LmYgroXA1PEW/dHAHM3pyQSmNJXsJcyIfz8Mn1ZZU +6coNg2fNN6kfClc4KVNK5KO+N/NM4l0sithPa1yhjFctqR2aZQWT5LvLR5k/663j +I75Mta1ZxwKBgGrZCohtiVRlyS/q2m+6wKr2c1ERItueRqh4oVxCKUxclOlArLNQ +Xdk0PvBLfgme/aS9d2xY8SzTgtChMMbA9P919frCZ9R8GIrhasvO5sMYmFyQHNvu +cTt9sylmiwTO3TqSxmq2nQ3eHj3xG+nV3QeV5HAdNp/nmdzXIn1q/rUJAoGAVPfs +S9LDVViNhYYKy3Ce0lptC9aNRJERHCGPKpno7A5muyEuv8nJWZ5fgroPmK6bUWQn +KR3uZQRUn3sKgpRbtiq27gJglwXHeOldsaJr0UejphfT2tfFm2nlkM8u+1I8i+gI +jH/w9r3YMyowEQFBlZN8yd23l2qS4Is4sMPyoUcCgYEAi4ukkRVRM/PsM2v+MW0U +uu0nXu44Lcq05RoO93f+LdcUibNRIBIpNDU7vH6RuPdmRVFRQCBvmhzslszusm4V +lxTSqhOpa5h/S+cFspmPbgIOq1mwM1VEGU6KLrcl5mnz60VkBsVDFfKE0Ni9sN2T +THzNXxIdC+dgFxqRGL6BELk= +-----END PRIVATE KEY----- +`; + +const DUMMY_CLIENT_CERT = `-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIUVboURMYBW4WYeT/IQhL2uf9bomUwDQYJKoZIhvcNAQEL +BQAwHjEcMBoGA1UEAwwTVmFsZXR1ZG9EdW1teUNsaWVudDAgFw0yNTA3MDkxODM2 +MjJaGA8yMTI1MDYxNTE4MzYyMlowHjEcMBoGA1UEAwwTVmFsZXR1ZG9EdW1teUNs +aWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANxNkCwOPgnaJXJU +kUS3PrEBk4ugDMAjTYxEOBGE5vkBtvCDcYnad8z0YcFcZLILVflQ8sUxJ7hMj8Ja +hqNx/rtG7vGU+QPBvWLlCTOZV6cmZ+VYf++a+UpTjbVqwWy9Rc7ZPzQQ/GhDkHm+ +J98Dp26Nw8Wa+7gg36gqn5Nx1dcj98CV6JPdQwo65sPx2tZdJVZMM0yk3kE0TWOw +9g3XitIIwJtyg9Jry0bMrWSEyIKVVZhEZc/PReTYP7hvX9+4US6dDbn2med6hrii +F7jg1h9Kj+K73ZEtgciJrVJdBpT0zCquwPkA9PvOj7L10ypWsslIBdEmoIIKuw8N +PUFDhM0CAwEAAaNTMFEwHQYDVR0OBBYEFG0w3IIqzW+Wm8Eul+uwm96t1fNHMB8G +A1UdIwQYMBaAFG0w3IIqzW+Wm8Eul+uwm96t1fNHMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggEBANJ4ejxINHp6Say0Q92ytpkHz27fwWWeCq1FRR2i +X2MCPaSl6djg+AdoQ/LOXuOJ516OofCNxyGlTDCfOxguD6CV8ttA8jh4EGo+Jmen +ezfmGR4I6DzMKTiOdu2Qd6EVajVXLMZZfBJUNNs1tmtWhtUk2P4IJqn/lqeib7un +zzq/W47Y0MGPL4UmGyp0mwaVjj9LMN0NL8EEQhhG6SkLGE4SUXfeiqAfzrYP/PdB +tkciowVpUEiXK0tg/t8EgHj+sW9vUqX6ovKDx5Hy2nczd2CruRxHUieSQKX+zajv +8REhl/azGTE4XmSqFfs5sdm2dWIFudNMZoT+tkxR/pQB1ts= +-----END CERTIFICATE----- +`; + +module.exports = { + DUMMY_CLIENT_CERT: DUMMY_CLIENT_CERT, + DUMMY_CLIENT_KEY: DUMMY_CLIENT_KEY +}; diff --git a/backend/lib/robots/midea/MideaJ15ValetudoRobot.js b/backend/lib/robots/midea/MideaJ15ValetudoRobot.js new file mode 100644 index 00000000..7857129f --- /dev/null +++ b/backend/lib/robots/midea/MideaJ15ValetudoRobot.js @@ -0,0 +1,28 @@ +const fs = require("node:fs"); +const Logger = require("../../Logger"); +const MideaValetudoRobot = require("./MideaValetudoRobot"); + +class MideaJ15ValetudoRobot extends MideaValetudoRobot { + getManufacturer() { + return "Eureka"; + } + + getModelName() { + return "J15 Pro Ultra"; // TODO: how do you distinguish the ultra from the pro ultra? And what about the max? + } + + static IMPLEMENTATION_AUTO_DETECTION_HANDLER() { + let productModel; + + try { + productModel = fs.readFileSync("/oem/product/product_model").toString().trim(); + } catch (e) { + //This is intentionally failing if we're the wrong implementation + Logger.trace("cannot read", "/oem/product/product_model", e); + } + + return !!(productModel && productModel === "j15_eureka"); + } +} + +module.exports = MideaJ15ValetudoRobot; diff --git a/backend/lib/robots/midea/MideaMapParser.js b/backend/lib/robots/midea/MideaMapParser.js new file mode 100644 index 00000000..825bb54b --- /dev/null +++ b/backend/lib/robots/midea/MideaMapParser.js @@ -0,0 +1,558 @@ +const Logger = require("../../Logger"); +const mapEntities = require("../../entities/map"); +const zlib = require("zlib"); + +class MideaMapParser { + constructor() { + this.reset(); + } + + reset() { + this.mapInfo = { + height: 0, + width: 0, + left: 0, + bottom: 0, + }; + this.dockPosition = { + x: 0, + y: 0, + angle: 0 + }; + + this.layers = []; + this.entities = []; + } + + /** + * + * @param {number} x + * @param {number} y + * @returns {{x: number, y: number}} + */ + convertToValetudoCoordinates(x, y) { + // TODO: throw when not initialized + + return { + x: (x - this.mapInfo.left) * MideaMapParser.PIXEL_SIZE, + y: (this.mapInfo.height - 1 - (y - this.mapInfo.bottom)) * MideaMapParser.PIXEL_SIZE + }; + } + + /** + * + * @param {number} x + * @param {number} y + * @returns {{x: number, y: number}} + */ + convertToMideaCoordinates(x, y) { + // TODO: throw when not initialized + + return { + x: Math.round(x / MideaMapParser.PIXEL_SIZE) + this.mapInfo.left, + y: this.mapInfo.bottom + (this.mapInfo.height - 1 - Math.round(y / MideaMapParser.PIXEL_SIZE)) + }; + } + + /** + * + * @param {string} type + * @param {any} data + * @return {Promise} + */ + async update(type, data) { + switch (type) { + case "map": + await this.handleInfoMapUpdate(data); + break; + case "track": + await this.handleTrackUpdate(data); + break; + case "dockPosition": + await this.handleDockPositionUpdate(data); + break; + case "virtual": + await this.handleVirtualWallUpdate(data); + break; + case "forbidden": + await this.handleVirtualRestrictionZoneUpdate(data, mapEntities.PolygonMapEntity.TYPE.NO_GO_AREA); + break; + case "mop_forbidden": + await this.handleVirtualRestrictionZoneUpdate(data, mapEntities.PolygonMapEntity.TYPE.NO_MOP_AREA); + break; + + case "evt_active_zones": + await this.handleActiveZonesUpdate(data); + break; + + case "threshold_area": + case "points": + case "bridge_data": + case "user_defined_carpet": + case "backup_map": + case "3d": + case "semantic_data": + case "stain_area": + case "partition": + case "adjacent": + case "user_deleted_detected_curtain": + case "displayed_curtain": + case "user_deleted_detected_door_sill": + case "displayed_door_sill": + // Ignored for now + break; + default: + Logger.warn(`Unknown map update type '${type}'`); + Logger.warn(data, data?.length); // TODO: remove + } + } + + getCurrentMap() { + const entities = [...this.entities]; + + const dockCoords = this.convertToValetudoCoordinates(this.dockPosition.x, this.dockPosition.y); + const dockAngle = (-this.dockPosition.angle + 360) % 360; + + entities.push(new mapEntities.PointMapEntity({ + points: [ + dockCoords.x, + dockCoords.y + ], + metaData: { + angle: dockAngle + }, + type: mapEntities.PointMapEntity.TYPE.CHARGER_LOCATION + })); + + // TODO: only when docked + const hasRobotPosition = entities.some(e => e.type === mapEntities.PointMapEntity.TYPE.ROBOT_POSITION); + if (!hasRobotPosition) { + entities.push(new mapEntities.PointMapEntity({ + points: [ // Offset by 1 unit so that they don't overlap 100% + dockCoords.x + MideaMapParser.PIXEL_SIZE, + dockCoords.y + MideaMapParser.PIXEL_SIZE + ], + metaData: { + angle: dockAngle + }, + type: mapEntities.PointMapEntity.TYPE.ROBOT_POSITION + })); + } + + return new mapEntities.ValetudoMap({ + size: { + x: this.mapInfo.width * MideaMapParser.PIXEL_SIZE, + y: this.mapInfo.height * MideaMapParser.PIXEL_SIZE + }, + pixelSize: MideaMapParser.PIXEL_SIZE, + layers: this.layers, + entities: entities + }); + } + + /** + * + * @param {string} data + * @return {Promise} + */ + async handleInfoMapUpdate(data) { + const parsed = MideaMapParser.INFO_MAP_REGEX.exec(data); + if (!parsed) { + if (data !== "") { + Logger.warn("Could not parse info_map."); + } + + return; + } + + const left = parseInt(parsed.groups.left); + const bottom = parseInt(parsed.groups.bottom); + const width = parseInt(parsed.groups.right) - left + 1; + const height = parseInt(parsed.groups.top) - bottom + 1; + + const payload = await MideaMapParser.DECOMPRESS_PAYLOAD(parsed.groups.payload); + + const pixels = { + floor: [], + wall: [], + segments: {} + }; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width) + x; + const val = payload[idx]; + + const coords = [ + x, + height - y - 1 + ]; + + switch (val) { + case 0: + // void + break; + case 255: + pixels.wall.push(coords); + break; + case 99: + case 100: + case 101: + pixels.floor.push(coords); + break; + case 170: + // This is the magic byte at the start of the data. The format is a bit broken, + // because this is treated as a pixel, otherwise the map is short by 1 byte + // Thus, we just ignore it without slicing it away + break; + + default: + if (val >= 1 && val <= 98) { + if (!Array.isArray(pixels.segments[val])) { + pixels.segments[val] = []; + } + + pixels.segments[val].push(coords); + } else { + Logger.warn(`Encountered unknown pixel type ${val}`); + } + } + } + } + + + const layers = []; + + if (pixels.floor.length > 0) { + layers.push(new mapEntities.MapLayer({ + pixels: pixels.floor.sort(mapEntities.MapLayer.COORDINATE_TUPLE_SORT).flat(), + type: mapEntities.MapLayer.TYPE.FLOOR + })); + } + + if (pixels.wall.length > 0) { + layers.push(new mapEntities.MapLayer({ + pixels: pixels.wall.sort(mapEntities.MapLayer.COORDINATE_TUPLE_SORT).flat(), + type: mapEntities.MapLayer.TYPE.WALL + })); + } + + Object.keys(pixels.segments).forEach((segmentId) => { + if (pixels.segments[segmentId].length > 0) { + layers.push(new mapEntities.MapLayer({ + pixels: pixels.segments[segmentId].sort(mapEntities.MapLayer.COORDINATE_TUPLE_SORT).flat(), + type: mapEntities.MapLayer.TYPE.SEGMENT, + metaData: { + segmentId: segmentId, + // Segment names appear to be stored in the cloud and in the cloud only :( + active: false, // This does not appear to be reported by the firmware ?? + } + })); + } + }); + + this.mapInfo.width = width; + this.mapInfo.height = height; + this.mapInfo.left = left; + this.mapInfo.bottom = bottom; + this.layers = layers; + } + + /** + * + * @param {string} data + * @return {Promise} + */ + async handleTrackUpdate(data) { + const payload = await MideaMapParser.DECOMPRESS_PAYLOAD(data); + + this.entities = this.entities.filter(e => { + return ![ + mapEntities.PathMapEntity.TYPE.PATH, + mapEntities.PathMapEntity.TYPE.PREDICTED_PATH, + + mapEntities.PointMapEntity.TYPE.ROBOT_POSITION + ].includes(e.type); + }); + + if (payload.length === 0) { + return; + } + + // First 4 payload bytes were observed to be 00 00 00 00 and 00 02 00 00 + // TODO: figure out what that means + let offset = 0; + let currentType = undefined; + let paths = []; + let points = []; + + + do { + const x = payload.readUInt16BE(offset); + const y = payload.readUInt16BE(offset + 2); + const type = payload.readUInt16BE(offset + 4); + + if (type !== currentType) { + // @ts-ignore + if (!Object.values(MideaMapParser.PATH_TYPES).includes(type)) { + Logger.info(`Encountered unknown path type ${type}`); // TODO: debug loglevel + } + + + paths.push({ points: points, type: currentType }); + points = []; + + currentType = type; + } + + points.push(x, y); + offset = offset + 6; + } while (offset < payload.length); + + // Final path + paths.push({ points: points, type: currentType }); + + + const entities = paths.filter(p => p.type !== 2400 && p.points.length > 0).map(path => { + const transformedPoints = []; + + for (let i = 0; i < path.points.length; i += 2) { + const x = path.points[i]; + const y = path.points[i + 1]; + + const coords = this.convertToValetudoCoordinates(x, y); + transformedPoints.push(coords.x, coords.y); + } + + return new mapEntities.PathMapEntity({ + points: transformedPoints, + type: mapEntities.PathMapEntity.TYPE.PATH, + metaData: { + vendorPathType: path.type // todo: remove + } + }); + }); + + + // Add the robot position entity based on the very last valid path + if (entities.length > 0) { + const lastPathEntity = entities[entities.length - 1]; + const lastPathPoints = lastPathEntity.points; + + if (lastPathPoints.length >= 2) { + const robotPositionCoordinates = [ + lastPathPoints[lastPathPoints.length - 2], + lastPathPoints[lastPathPoints.length - 1] + ]; + + let robotAngle = 0; + if (lastPathPoints.length >= 4) { + robotAngle = (Math.round(Math.atan2( + robotPositionCoordinates[1] - lastPathPoints[lastPathPoints.length - 3], + robotPositionCoordinates[0] - lastPathPoints[lastPathPoints.length - 4] + ) * 180 / Math.PI) + 90) % 360; //TODO: No idea why + } + + entities.push(new mapEntities.PointMapEntity({ + points: robotPositionCoordinates, + metaData: { + angle: robotAngle + }, + type: mapEntities.PointMapEntity.TYPE.ROBOT_POSITION + })); + } + } + + + this.entities.push(...entities.filter(e => { + // We do that quite late here, because we need them to calculate the robot position + return ![ + MideaMapParser.PATH_TYPES.MAPPING, + MideaMapParser.PATH_TYPES.MOVING, + MideaMapParser.PATH_TYPES.RETURNING, + MideaMapParser.PATH_TYPES.TAXIING, + MideaMapParser.PATH_TYPES.TAXIING_ZONES + ].includes(e.metaData.vendorPathType); + })); + } + + /** + * + * @param {object} data + * @param {number} data.x + * @param {number} data.y + * @param {number} data.angle + * @return {Promise} + */ + async handleDockPositionUpdate(data) { + this.dockPosition = data; + } + + /** + * Empty string means none. Otherwise, format: line 324 433 354 403 394 425 424 395 1 + * With 4 ints each being x,y; x,y and the final trailing number being unknown + * + * @param {string} data + * @return {Promise} + */ + async handleVirtualWallUpdate(data) { + this.entities = this.entities.filter(e => + e.type !== mapEntities.LineMapEntity.TYPE.VIRTUAL_WALL + ); + + if (!data.startsWith("line")) { + return; + } + + const coordinates = data.split(" ").slice(1, -1).map(coord => parseInt(coord, 10)); + + if (coordinates.length % 4 !== 0) { + Logger.warn("Invalid wall data format"); + + return; + } + + const entities = []; + + for (let i = 0; i < coordinates.length; i += 4) { + const [x1, y1, x2, y2] = coordinates.slice(i, i + 4); + + const points = [ + ...Object.values(this.convertToValetudoCoordinates(x1, y1)), + ...Object.values(this.convertToValetudoCoordinates(x2, y2)) + ]; + + entities.push(new mapEntities.LineMapEntity({ + points: points, + type: mapEntities.LineMapEntity.TYPE.VIRTUAL_WALL + })); + } + + this.entities.push(...entities); + } + + /** + * Empty string means none. Otherwise, format: forbid_zone 367 455 397 425 440 458 470 428 1 + * + * @param {string} data + * @param {string} entityType + * @return {Promise} + */ + async handleVirtualRestrictionZoneUpdate(data, entityType) { + this.entities = this.entities.filter(e => e.type !== entityType); + + if (!data.startsWith("forbid_zone")) { + return; + } + + const coordinates = data.split(" ").slice(1, -1).map(coord => parseInt(coord, 10)); + + if (coordinates.length % 4 !== 0) { + const zoneTypeName = entityType === mapEntities.PolygonMapEntity.TYPE.NO_GO_AREA ? "no-go" : "no-mop"; + Logger.warn(`Invalid ${zoneTypeName} zone data format`); + return; + } + + const entities = []; + + for (let i = 0; i < coordinates.length; i += 4) { + const [x1, y1, x2, y2] = coordinates.slice(i, i + 4); + + const pA = this.convertToValetudoCoordinates(x1, y1); + const pC = this.convertToValetudoCoordinates(x2, y2); + + const xCoords = [pA.x, pC.x].sort((a, b) => a - b); + const yCoords = [pA.y, pC.y].sort((a, b) => a - b); + + entities.push(new mapEntities.PolygonMapEntity({ + points: [ + xCoords[0], yCoords[0], + xCoords[1], yCoords[0], + xCoords[1], yCoords[1], + xCoords[0], yCoords[1] + ], + type: entityType + })); + } + + this.entities.push(...entities); + } + + /** + * + * @param {import("../../msmart/dtos/MSmartActiveZonesDTO")} data + * @return {Promise} + */ + async handleActiveZonesUpdate(data) { + this.entities = this.entities.filter(e => e.type !== mapEntities.PolygonMapEntity.TYPE.ACTIVE_ZONE); + + const entities = []; + + for (const zone of data.zones) { + const pA = this.convertToValetudoCoordinates(zone.pA.x, zone.pA.y); + const pC = this.convertToValetudoCoordinates(zone.pC.x, zone.pC.y); + + + const xCoords = [pA.x, pC.x].sort((a, b) => a - b); + const yCoords = [pA.y, pC.y].sort((a, b) => a - b); + + entities.push(new mapEntities.PolygonMapEntity({ + points: [ + xCoords[0], yCoords[0], + xCoords[1], yCoords[0], + xCoords[1], yCoords[1], + xCoords[0], yCoords[1] + ], + type: mapEntities.PolygonMapEntity.TYPE.ACTIVE_ZONE + })); + } + + this.entities.push(...entities); + } + + /** + * + * @param {string} data + * @return {Promise} + */ + static async DECOMPRESS_PAYLOAD(data) { + const compressedPayload = Buffer.from(data, "base64"); + + return new Promise((resolve, reject) => { + if (compressedPayload.length === 0) { + return resolve(compressedPayload); + } + + zlib.inflate(compressedPayload, (err, decompressed) => { + if (err) { + reject(err); + } else { + resolve(decompressed); + } + }); + }); + } +} + +MideaMapParser.PIXEL_SIZE = 5; +MideaMapParser.INFO_MAP_REGEX = /^info_map (?\d+) (?\d+) (?\d+) (?\d+) (?\d+) (?[a-zA-Z0-9+=/]+)$/; + +MideaMapParser.PATH_TYPES = Object.freeze({ + "NONE": 0, // Probably not a real type? + + "RETURNING": 10, + "OUTLINE": 30, + "TAXIING_ZONES": 40, + "TAXIING_SEGMENT_CLEANING": 50, + + "CLEANING_TURN": 80, + "CLEANING": 100, + + "MAPPING": 170, + "TAXIING": 180, + "MOVING": 190, + + "HEADER": 2400, // Not a real type. Just the format header +}); + +module.exports = MideaMapParser; diff --git a/backend/lib/robots/midea/MideaQuirkFactory.js b/backend/lib/robots/midea/MideaQuirkFactory.js new file mode 100644 index 00000000..c03a44dd --- /dev/null +++ b/backend/lib/robots/midea/MideaQuirkFactory.js @@ -0,0 +1,134 @@ +const BEightParser = require("../../msmart/BEightParser"); +const MSmartCleaningSettings1DTO = require("../../msmart/dtos/MSmartCleaningSettings1DTO"); +const MSmartConst = require("../../msmart/MSmartConst"); +const MSmartPacket = require("../../msmart/MSmartPacket"); +const Quirk = require("../../core/Quirk"); + +class MideaQuirkFactory { + /** + * + * @param {object} options + * @param {import("./MideaValetudoRobot")} options.robot + */ + constructor(options) { + this.robot = options.robot; + } + /** + * @param {string} id + */ + getQuirk(id) { + switch (id) { + case MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING: + return new Quirk({ + id: id, + title: "Hair Cutting", + description: "Control the hair cutting cycle that runs once docked after a cleanup.", + options: ["off", "normal", "strong"], + 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.cut_hair_level) { + case 0: + return "off"; + case 1: + return "normal"; + case 2: + return "strong"; + } + } else { + throw new Error("Invalid response from robot"); + } + }, + setter: async (value) => { + let val; + + switch (value) { + case "off": + val = 0; + break; + case "normal": + val = 1; + break; + case "strong": + val = 2; + break; + default: + throw new Error(`Invalid hair cutting value: ${value}`); + } + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_CLEANING_SETTINGS_1, + Buffer.from([0x01, val]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + }); + case MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING_ONE_TIME_TURBO: + return new Quirk({ + id: id, + title: "Hair Cutting Turbo", + description: "Enabling this will run an even stronger hair cutting cycle on the next cleanup. Disables itself afterwards.", + options: ["off", "on"], + 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) { + return parsedResponse.cut_hair_super_switch ? "on" : "off"; + } else { + throw new Error("Invalid response from robot"); + } + }, + setter: async (value) => { + let val; + + switch (value) { + case "off": + val = 0; + break; + case "on": + val = 1; + break; + default: + throw new Error(`Invalid super hair cutting value: ${value}`); + } + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_CLEANING_SETTINGS_1, + Buffer.from([0x06, val]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + }); + default: + throw new Error(`There's no quirk with id ${id}`); + } + } +} + +MideaQuirkFactory.KNOWN_QUIRKS = { + HAIR_CUTTING: "afa83002-87db-43bb-b8ff-e4b38863a5d3", + HAIR_CUTTING_ONE_TIME_TURBO: "224b6a0a-1a51-48d7-9d4d-61645399d368", +}; + +module.exports = MideaQuirkFactory; diff --git a/backend/lib/robots/midea/MideaValetudoRobot.js b/backend/lib/robots/midea/MideaValetudoRobot.js new file mode 100644 index 00000000..680db586 --- /dev/null +++ b/backend/lib/robots/midea/MideaValetudoRobot.js @@ -0,0 +1,1238 @@ +const forge = require("node-forge"); +const fs = require("fs"); + +const BEightParser = require("../../msmart/BEightParser"); +const dtos = require("../../msmart/dtos"); +const DummyCloudCertManager = require("../../utils/DummyCloudCertManager"); +const Logger = require("../../Logger"); +const MSmartConst = require("../../msmart/MSmartConst"); +const MSmartDummycloud = require("../../msmart/MSmartDummycloud"); +const MSmartPacket = require("../../msmart/MSmartPacket"); +const ValetudoRobot = require("../../core/ValetudoRobot"); + +const MideaMapParser = require("./MideaMapParser"); +const MideaQuirkFactory = require("./MideaQuirkFactory"); + +const capabilities = require("./capabilities"); +const QuirksCapability = require("../../core/capabilities/QuirksCapability"); + +const entities = require("../../entities"); +const stateAttrs = entities.state.attributes; +const MissingResourceValetudoEvent = require("../../valetudo_events/events/MissingResourceValetudoEvent"); +const ValetudoRobotError = require("../../entities/core/ValetudoRobotError"); +const ValetudoSelectionPreset = require("../../entities/core/ValetudoSelectionPreset"); + +const {DUMMY_CLIENT_CERT, DUMMY_CLIENT_KEY} = require("./MideaConst"); + +const CA_KEY_PATH = "/etc/ssl/valetudo/ca.key"; +const CA_CERT_PATH = "/etc/ssl/valetudo/ca.pem"; +const BIND_IP = "127.0.13.37"; + +/** + * @abstract + */ +class MideaValetudoRobot extends ValetudoRobot { + constructor(options) { + super(options); + + if (!fs.existsSync(CA_KEY_PATH) || !fs.existsSync(CA_CERT_PATH)) { + throw new Error("DustCA not found. Unable to mock the cloud."); + } + + const caKey = forge.pki.privateKeyFromPem(fs.readFileSync(CA_KEY_PATH, "utf8")); + const caCert = forge.pki.certificateFromPem(fs.readFileSync(CA_CERT_PATH, "utf8")); + + this.dummyCloudCertManager = new DummyCloudCertManager({ caKey: caKey, caCert: caCert }); + + this.mapParser = new MideaMapParser(); + this.mapUpdateDebounceTimeout = null; + + this.dummycloud = new MSmartDummycloud({ + dummyCloudCertManager: this.dummyCloudCertManager, + bindIP: BIND_IP, + timeout: 5000, + dummyClientCert: DUMMY_CLIENT_CERT, + dummyClientKey: DUMMY_CLIENT_KEY, + onIncomingCloudMessage: this.onIncomingCloudMessage.bind(this), + onConnected: () => { + // start polling the map after a brief delay of 3.5s + setTimeout(() => { + return this.pollMap(); + }, 3500); + }, + onHttpRequest: (req, res) => { + return false; // TODO: is this really needed anymore? + }, + onUpload: (type, data) => { + this.handleMapUpdate(type, data).catch(err => { + Logger.warn(`Error while handling map update of type ${type}`, err); + }); + }, + onEvent: (type, value) => { + switch (type) { + case "11": // Could be cleanup process in % + case "13": // Could be cleanup done + // ignored (for now?) + break; + default: + Logger.info(`Received unknown event. Type '${type}'. Value '${value}'`); + } + + } + }); + + if (this.config.get("embedded") === true) { + // On the J15, WiFi scanning takes multiple minutes. Therefore, the capability was omitted here + + this.registerCapability(new capabilities.MideaWifiConfigurationCapability({ + robot: this, + networkInterface: "wlan0" + })); + + + if (!fs.existsSync("/userdata/ai_models/cod-detect-large.bin")) { + this.valetudoEventStore.raise(new MissingResourceValetudoEvent({ + id: "midea_ai_model", + message: "The large obstacle detection AI model is missing." + })); + } + } + + const quirkFactory = new MideaQuirkFactory({ + robot: this + }); + + this.registerCapability(new capabilities.MideaFanSpeedControlCapability({ + robot: this, + presets: Object.keys(MideaValetudoRobot.FAN_SPEEDS).map(k => { + return new ValetudoSelectionPreset({name: k, value: MideaValetudoRobot.FAN_SPEEDS[k]}); + }) + })); + this.registerCapability(new capabilities.MideaOperationModeControlCapability({ + robot: this, + presets: Object.keys(MideaValetudoRobot.OPERATION_MODES).map(k => { + return new ValetudoSelectionPreset({name: k, value: MideaValetudoRobot.OPERATION_MODES[k]}); + }) + })); + this.registerCapability(new capabilities.MideaWaterUsageControlCapability({ + robot: this, + presets: Object.keys(MideaValetudoRobot.HIGH_RESOLUTION_WATER_GRADES).map(k => { + return new ValetudoSelectionPreset({name: k, value: MideaValetudoRobot.HIGH_RESOLUTION_WATER_GRADES[k]}); + }) + })); + + [ + capabilities.MideaCurrentStatisticsCapability, + capabilities.MideaLocateCapability, + capabilities.MideaBasicControlCapability, + capabilities.MideaDoNotDisturbCapability, + capabilities.MideaSpeakerVolumeControlCapability, + capabilities.MideaSpeakerTestCapability, + capabilities.MideaMapResetCapability, + capabilities.MideaMappingPassCapability, + capabilities.MideaMapSegmentEditCapability, + capabilities.MideaMapSegmentationCapability, + capabilities.MideaZoneCleaningCapability, + capabilities.MideaCombinedVirtualRestrictionsCapability, + capabilities.MideaKeyLockCapability, + capabilities.MideaCollisionAvoidantNavigationControlCapability, + capabilities.MideaAutoEmptyDockAutoEmptyControlCapability, + capabilities.MideaAutoEmptyDockManualTriggerCapability, + capabilities.MideaMopDockCleanManualTriggerCapability, + capabilities.MideaMopDockDryManualTriggerCapability, + capabilities.MideaMopExtensionControlCapability, + capabilities.MideaCameraLightControlCapability, + ].forEach(capability => { + this.registerCapability(new capability({robot: this})); + }); + + this.registerCapability(new QuirksCapability({ + robot: this, + quirks: [ + quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING), + quirkFactory.getQuirk(MideaQuirkFactory.KNOWN_QUIRKS.HAIR_CUTTING_ONE_TIME_TURBO) + ] + })); + + this.state.upsertFirstMatchingAttribute(new entities.state.attributes.DockStatusStateAttribute({ + value: entities.state.attributes.DockStatusStateAttribute.VALUE.IDLE + })); + + this.state.upsertFirstMatchingAttribute(new entities.state.attributes.AttachmentStateAttribute({ + type: entities.state.attributes.AttachmentStateAttribute.TYPE.MOP, + attached: false + })); + } + + async shutdown() { + await super.shutdown(); + + if (this.mapUpdateDebounceTimeout) { + clearTimeout(this.mapUpdateDebounceTimeout); + this.mapUpdateDebounceTimeout = null; + } + + if (this.dummycloud) { + await this.dummycloud.shutdown(); + } + } + + startup() { + super.startup(); + + if (this.config.get("embedded") === true) { + const firmwareVersion = this.getFirmwareVersion() ?? "unknown"; + let aiModelVersion = "unknown/none"; + try { + aiModelVersion = fs.readFileSync("/userdata/ai_models/version.txt").toString(); + } catch (e) { + /* intentional */ + } + + Logger.info("Firmware Version: " + firmwareVersion); + Logger.info(`Obstacle detection AI model version: ${aiModelVersion}`); + } + } + + /** + * @protected + * @param {import("../../msmart/MSmartPacket")} packet + * @returns {boolean} True if the message was handled. + */ + onIncomingCloudMessage(packet) { + // There was a redundant condition here checking the deviceType of the packet to be 0xB8, which is _all of them_ in context of valetudo + // It shall remain referenced as this comment to explain why it is called BEightParser + const data = BEightParser.PARSE(packet); + + if (data instanceof dtos.MSmartStatusDTO) { + this.parseAndUpdateState(data); + + return true; + } else if (data instanceof dtos.MSmartActiveZonesDTO) { + this.mapParser.update("evt_active_zones", data).catch(e => { + Logger.warn("Error while handling active zones event", e); + }); + + return true; + } else if (data instanceof dtos.MSmartErrorDTO) { + Logger.debug("Received ErrorDTO. Should we do anything with it?", data); + // FIXME: is it required to use it at all? Should we poll state when we see this? + + return true; + } else if (data instanceof dtos.MSmartDockStatusDTO) { + // FIXME: what to do with this? + + return true; + } else if (data === "SKIP") { + // FIXME: HACK! + return true; + } + + return false; + } + + /** + * @param {import("../../msmart/dtos/MSmartStatusDTO")} data + */ + parseAndUpdateState(data) { + if (data.battery_percent !== undefined) { + this.state.upsertFirstMatchingAttribute(new stateAttrs.BatteryStateAttribute({ + level: data.battery_percent + })); + } + + if (data.work_status !== undefined) { + if (MideaValetudoRobot.STATUS_MAP[data.work_status]) { + const statusValue = MideaValetudoRobot.STATUS_MAP[data.work_status]?.value ?? stateAttrs.StatusStateAttribute.VALUE.ERROR; + const statusFlag = MideaValetudoRobot.STATUS_MAP[data.work_status]?.flag ?? stateAttrs.StatusStateAttribute.FLAG.NONE; + let statusError; + + if (statusValue === stateAttrs.StatusStateAttribute.VALUE.ERROR) { + statusError = MideaValetudoRobot.MAP_ERROR_CODE(data.error_type, data.error_desc); + } + + const newState = new stateAttrs.StatusStateAttribute({ + value: statusValue, + flag: statusFlag, + metaData: {}, + error: statusError + }); + + this.state.upsertFirstMatchingAttribute(newState); + + if (newState.isActiveState) { + this.pollMap(); + } + } else { + Logger.warn(`Received unknown work_status ${data.work_status}`); + } + } + + if (data.fan_level !== undefined) { + let matchingFanSpeed = Object.keys(MideaValetudoRobot.FAN_SPEEDS).find(key => { + return MideaValetudoRobot.FAN_SPEEDS[key] === data.fan_level; + }); + + if (matchingFanSpeed) { + this.state.upsertFirstMatchingAttribute(new stateAttrs.PresetSelectionStateAttribute({ + type: stateAttrs.PresetSelectionStateAttribute.TYPE.FAN_SPEED, + value: matchingFanSpeed, + metaData: { + rawValue: data.fan_level + }, + })); + } else { + Logger.warn(`Received unknown fan_level ${data.fan_level}`); + } + } + + if (data.mopMode !== undefined) { // Mop mode 0 = vacuum & mop. Needs explicit check for !== undefined + let matchingOperationMode = Object.keys(MideaValetudoRobot.OPERATION_MODES).find(key => { + return MideaValetudoRobot.OPERATION_MODES[key] === data.mopMode; + }); + + if (matchingOperationMode) { + this.state.upsertFirstMatchingAttribute(new stateAttrs.PresetSelectionStateAttribute({ + type: stateAttrs.PresetSelectionStateAttribute.TYPE.OPERATION_MODE, + value: matchingOperationMode, + metaData: { + rawValue: data.mopMode + }, + })); + } else { + Logger.warn(`Received unknown mopMode ${data.mopMode}`); + } + } + + if (data.water_level !== undefined) { + let matchingWaterGrade = Object.keys(MideaValetudoRobot.HIGH_RESOLUTION_WATER_GRADES).find(key => { + return MideaValetudoRobot.HIGH_RESOLUTION_WATER_GRADES[key] === data.water_level; + }); + + if (matchingWaterGrade) { + this.state.upsertFirstMatchingAttribute(new stateAttrs.PresetSelectionStateAttribute({ + type: stateAttrs.PresetSelectionStateAttribute.TYPE.WATER_GRADE, + value: matchingWaterGrade, + metaData: { + rawValue: data.water_level + }, + })); + } else { + Logger.warn(`Received unknown water_level ${data.water_level}`); + } + } + + if (data.work_area !== undefined) { + this.capabilities[capabilities.MideaCurrentStatisticsCapability.TYPE].currentStatistics.area = data.work_area * 10000; + } + if (data.work_time !== undefined) { + this.capabilities[capabilities.MideaCurrentStatisticsCapability.TYPE].currentStatistics.time = data.work_time * 60; + } + + if (data.station_work_status !== undefined) { + this.state.upsertFirstMatchingAttribute(new entities.state.attributes.DockStatusStateAttribute({ + value: MideaValetudoRobot.DOCK_STATUS_MAP[data.station_work_status] ?? stateAttrs.DockStatusStateAttribute.VALUE.ERROR + })); + } + + if (data.has_mop !== undefined) { + this.state.upsertFirstMatchingAttribute(new entities.state.attributes.AttachmentStateAttribute({ + type: entities.state.attributes.AttachmentStateAttribute.TYPE.MOP, + attached: data.has_mop + })); + } + + // TODO: raise event when data.dustbag_full? + + // Emit state update event + this.emitStateAttributesUpdated(); + } + + /** + * @param {string} command + * @param {object} [options] + * @param {number} [options.timeout] - milliseconds + * @param {"device"|"ai"} [options.target] - defaults to "device" + * @returns {Promise} + */ + async sendCommand(command, options) { + return this.dummycloud.sendCommand(command, options); + } + + async pollState() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(0x01) + }); + + const response = await this.sendCommand(packet.toHexString()); + const parsedResponse = BEightParser.PARSE(response); + + if (parsedResponse instanceof dtos.MSmartStatusDTO) { + this.parseAndUpdateState(parsedResponse); + } + + return this.state; + } + + /** + * + * @param {string} type - TODO: ENUM + * @param {string} data + * @return {Promise} + */ + async handleMapUpdate(type, data) { + await this.mapParser.update(type, data); + + if (this.mapUpdateDebounceTimeout) { + clearTimeout(this.mapUpdateDebounceTimeout); + } + + this.mapUpdateDebounceTimeout = setTimeout(() => { + const newMap = this.mapParser.getCurrentMap(); + if (newMap.metaData.totalLayerArea > 0) { + this.state.map = newMap; + + this.emitMapUpdated(); + } + + this.mapUpdateDebounceTimeout = null; + }, 350); // TODO: what to pick here? + } + + async executeMapPoll() { + const mapPollPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.POLL_MAP) + }); + const dockPositionPollPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_DOCK_POSITION) + }); + const activeZonesPollPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_ACTIVE_ZONES) + }); + + await this.sendCommand(mapPollPacket.toHexString()); + + const dockPositionResponse = await this.sendCommand(dockPositionPollPacket.toHexString()); + + if (dockPositionResponse.payload[3] === 1) { + await this.mapParser.update("dockPosition", { // TODO: Move to BEightParser + x: dockPositionResponse.payload.readUInt16LE(4), + y: dockPositionResponse.payload.readUInt16LE(6), + angle: dockPositionResponse.payload.readUInt16LE(8) + }); + } + + const activeZonesResponse = await this.sendCommand(activeZonesPollPacket.toHexString()); + + if (activeZonesResponse instanceof dtos.MSmartActiveZonesDTO) { + await this.mapParser.update("evt_active_zones", activeZonesResponse); + } + } + + clearValetudoMap() { + this.mapParser.reset(); + + super.clearValetudoMap(); + } + + + getManufacturer() { + return "Midea"; + } + + getModelDetails() { // TODO: possibly belongs into the J15 implementation + return Object.assign( + {}, + super.getModelDetails(), + { + supportedAttachments: [ + stateAttrs.AttachmentStateAttribute.TYPE.MOP, + ] + } + ); + } + + /** + * @private + * @returns {string|undefined} + */ + getFirmwareVersion() { + try { + const version_conf = fs.readFileSync("/etc/version.conf").toString().trim(); + + if (version_conf) { + const splitVersionConf = version_conf.split("_"); + + return splitVersionConf[splitVersionConf.length - 1]; + } + } catch (e) { + Logger.warn("Unable to determine the Firmware Version", e); + } + } + + /** + * @return {object} + */ + getProperties() { + const superProps = super.getProperties(); + const ourProps = {}; + + if (this.config.get("embedded") === true) { + const firmwareVersion = this.getFirmwareVersion(); + + if (firmwareVersion) { + ourProps[MideaValetudoRobot.WELL_KNOWN_PROPERTIES.FIRMWARE_VERSION] = firmwareVersion; + } + } + + return Object.assign( + {}, + superProps, + ourProps + ); + } +} + +MideaValetudoRobot.FAN_SPEEDS = Object.freeze({ + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.OFF]: 0, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MIN]: 4, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.LOW]: 1, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MEDIUM]: 2, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.HIGH]: 3, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MAX]: 5 +}); + +MideaValetudoRobot.OPERATION_MODES = Object.freeze({ + [stateAttrs.PresetSelectionStateAttribute.MODE.VACUUM_AND_MOP]: 0, + [stateAttrs.PresetSelectionStateAttribute.MODE.VACUUM]: 1, + [stateAttrs.PresetSelectionStateAttribute.MODE.MOP]: 2, + [stateAttrs.PresetSelectionStateAttribute.MODE.VACUUM_THEN_MOP]: 3, +}); + +MideaValetudoRobot.WATER_GRADES = Object.freeze({ + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.LOW]: 1, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MEDIUM]: 2, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.HIGH]: 3, +}); + +MideaValetudoRobot.HIGH_RESOLUTION_WATER_GRADES = Object.freeze({ + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MIN]: 101, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.LOW]: 108, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MEDIUM]: 115, + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.HIGH]: 120, // J15pu default + [stateAttrs.PresetSelectionStateAttribute.INTENSITY.MAX]: 130, +}); + +MideaValetudoRobot.STATUS_MAP = Object.freeze({ + 1: { + value: stateAttrs.StatusStateAttribute.VALUE.RETURNING + }, + 2: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 3: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 4: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 5: { + value: stateAttrs.StatusStateAttribute.VALUE.CLEANING + }, + 6: { + value: stateAttrs.StatusStateAttribute.VALUE.PAUSED, + flag: stateAttrs.StatusStateAttribute.FLAG.RESUMABLE + }, + 7: { + value: stateAttrs.StatusStateAttribute.VALUE.IDLE + }, + 8: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 9: { + value: stateAttrs.StatusStateAttribute.VALUE.ERROR + }, + 10: { + value: stateAttrs.StatusStateAttribute.VALUE.IDLE + }, + 11: { + value: stateAttrs.StatusStateAttribute.VALUE.MOVING + }, + 12: { + value: stateAttrs.StatusStateAttribute.VALUE.MOVING, + flag: stateAttrs.StatusStateAttribute.FLAG.MAPPING + }, + 13: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 14: { + value: stateAttrs.StatusStateAttribute.VALUE.RETURNING + }, + 15: { + value: stateAttrs.StatusStateAttribute.VALUE.PAUSED, + flag: stateAttrs.StatusStateAttribute.FLAG.RESUMABLE + }, + 16: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 17: { + value: stateAttrs.StatusStateAttribute.VALUE.MANUAL_CONTROL + }, + 18: { + value: stateAttrs.StatusStateAttribute.VALUE.DOCKED + }, + 19: { + value: stateAttrs.StatusStateAttribute.VALUE.MOVING + }, + 20: { + value: stateAttrs.StatusStateAttribute.VALUE.PAUSED, + flag: stateAttrs.StatusStateAttribute.FLAG.RESUMABLE + }, + 21: { + value: stateAttrs.StatusStateAttribute.VALUE.PAUSED, + flag: stateAttrs.StatusStateAttribute.FLAG.RESUMABLE + }, + 22: { + value: stateAttrs.StatusStateAttribute.VALUE.MOVING + }, + 23: { + value: stateAttrs.StatusStateAttribute.VALUE.IDLE + } +}); + +MideaValetudoRobot.DOCK_STATUS_MAP = Object.freeze({ + 0: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "station_free" + 1: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "charging_on_dock" + 2: stateAttrs.DockStatusStateAttribute.VALUE.CLEANING, // "water_injection" + 3: stateAttrs.DockStatusStateAttribute.VALUE.CLEANING, // "cleaning_cloth" + 4: stateAttrs.DockStatusStateAttribute.VALUE.DRYING, // "rag_drying" + 5: stateAttrs.DockStatusStateAttribute.VALUE.DRYING, // "rag_air_drying" + 6: stateAttrs.DockStatusStateAttribute.VALUE.ERROR, // "station_error" + 7: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "charge_finish" + 8: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "sleep_in_station" + 9: stateAttrs.DockStatusStateAttribute.VALUE.CLEANING, // "auto_clean" + 10: stateAttrs.DockStatusStateAttribute.VALUE.EMPTYING, // "dusting" + 11: stateAttrs.DockStatusStateAttribute.VALUE.CLEANING, // "hair_cutting" + 12: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "wait_for_charging" + 13: stateAttrs.DockStatusStateAttribute.VALUE.CLEANING, // "drain_water" + 48: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "default_sleep" - TODO: think about all the pause ones + 49: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "work_pause_sleep" + 50: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "stop_sleep" + 51: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "charge_pause_sleep" + 52: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "back_pause_sleep" + 53: stateAttrs.DockStatusStateAttribute.VALUE.PAUSE, // "cruise_pause_sleep" + + 80: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "default_relocate" - TODO: why does the stationary dock even has relocate status? + 81: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "first_start_relocate" + 82: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "wheel_raised_relocate" + 83: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "odometer_data_changes_relocate" + 84: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "imu_data_changes_relocate" + 85: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "delete_current_map_relocate" + 86: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "enter_distress_relocate" + 87: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "map_exchage_relocate" + 88: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "after_manualcontorl_relocate" + 89: stateAttrs.DockStatusStateAttribute.VALUE.IDLE, // "outof_map_range_relocate" +}); + +/** + * + * @param {number} errorType - Error category (1=solvable, 2=restart, 3=alert) + * @param {number} errorDesc - Specific error code within the category + * + * @returns {ValetudoRobotError} + */ +MideaValetudoRobot.MAP_ERROR_CODE = (errorType, errorDesc) => { // TODO: review these by hand + const vendorErrorCode = `${errorType}-${errorDesc}`; + const parameters = { + severity: { + kind: ValetudoRobotError.SEVERITY_KIND.UNKNOWN, + level: ValetudoRobotError.SEVERITY_LEVEL.UNKNOWN, + }, + subsystem: ValetudoRobotError.SUBSYSTEM.UNKNOWN, + message: `Unknown error ${vendorErrorCode}`, + vendorErrorCode: vendorErrorCode + }; + + if (errorType === 1) { // Solvable errors + switch (errorDesc) { + case 1: // DUST_BOX_MISSING + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Dustbin missing"; + break; + case 2: // WHEELS_DANGLING + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.CORE; + parameters.message = "Wheel lost floor contact"; + break; + case 3: // WHEELS_OVERLOAD + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.MOTORS; + parameters.message = "Wheel jammed"; + break; + case 4: // SIDE_BRUSH_FAULT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.MOTORS; + parameters.message = "Side brush jammed"; + break; + case 5: // ROLLING_BRUSH_FAULT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.MOTORS; + parameters.message = "Main brush jammed"; + break; + case 6: // DUST_MOTOR_OVERLOAD + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.CATASTROPHIC; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.MOTORS; + parameters.message = "Fan speed abnormal"; + break; + case 7: // FRONT_PANEL_FAULT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Stuck front bumper"; + break; + case 8: // RADAR_MASK + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS jammed"; + break; + case 9: // FRONT_SENSOR_FAULT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Cliff sensor dirty or robot on the verge of falling"; + break; + case 10: // BATTERY_VERY_LOW_HIT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.INFO; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Low battery"; + break; + case 11: // LEAN_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Tilted robot"; + break; + case 12: // LASER_SENSOR_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS jammed"; + break; + case 13: // EDGE_SENSOR_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Sensor dirty"; + break; + case 14: // FIND_BARRIER + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot trapped by virtual restrictions"; + break; + case 15: // MAGNETIC_FIELD_DISTURB + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.INFO; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Magnetic interference"; + break; + case 16: // LASER_SENSOR_BLOCK_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS bumper jammed"; + break; + case 17: // MOP_LOSE_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Lost mop pad"; + break; + case 18: // MOP_SLIP_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot stuck or trapped"; + break; + case 19: // RECHARGE_FAIL + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Charging station without power"; + break; + case 20: // VIBRATION_DRAG_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot stuck or trapped"; + break; + case 21: // STERILIZATION_MODULE_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Sterilization module fault"; + break; + case 22: // ROBOT_WATER_BOX_UNINSTALL_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Water tank missing"; + break; + case 23: // WIPE_CHIP_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Wipe chip error"; + break; + case 24: // RADAR_HIGH_TEMPERATURE + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS temperature out of operating range"; + break; + case 32: // HAIR_CUT_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.MOTORS; + parameters.message = "Hair cutter jammed"; + break; + case 43: // RECHARGE_FAIL (specific variant) + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Charging error"; + break; + case 64: // MOP_DROP_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Lost mop pad"; + break; + case 65: // ROATE_TIME_OUT_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Rotation timeout"; + break; + case 66: // CANNOT_FIND_STATION + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot navigate to the dock"; + break; + case 67: // RECHARGE_FAIL (another variant) + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Charging error"; + break; + case 68: // RECHARGE_FAIL_NOSINGAL + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Charging station without power"; + break; + case 69: // RADAR_HIGH_TEMPERATURE_RESOLVE_FAILED + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS temperature control failed"; + break; + case 80: // CANNOT_ARRIVE_POINT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot reach target"; + break; + case 81: // PHS_FIND_BARRIER + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot trapped by virtual restrictions"; + break; + case 82: // FIND_BARRIER + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot trapped by virtual restrictions"; + break; + case 83: // WHEELS_DANGLING + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.CORE; + parameters.message = "Wheel lost floor contact"; + break; + case 84: // ROBOT_OUT_STATION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot failed to exit dock"; + break; + case 85: // ROBOT_BEING_TRAPPED + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot stuck or trapped"; + break; + case 87: // ROBOT_TIMEOUT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.CORE; + parameters.message = "Robot operation timeout"; + break; + case 88: // CARPET_START_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot start on carpet"; + break; + case 89: // VM_WALL_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Stuck inside restricted area"; + break; + case 90: // FORBIDDEN_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Stuck inside restricted area"; + break; + case 91: // MOP_FORBIDDEN_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Stuck inside restricted area"; + break; + case 92: // CHARGE_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot start while charging"; + break; + case 93: // ROBOT_BEING_TRAPPED + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot stuck or trapped"; + break; + case 94: // ROBOT_SLAM_LAYER_EMPTY + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Navigation map error"; + break; + case 96: // DEPART_FROM_NARROW_AREA_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot stuck in narrow area"; + break; + case 128: // ROBOT_CREATE_MAP_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Map creation error"; + break; + case 160: // RADAR_HIGH_TEMPERATURE_RESOLVE_FAILED + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS temperature control failed"; + break; + case 162: // ROBOT_CREATE_MAP_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Map creation error"; + break; + case 164: // SLAM_LOCATION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Localization error"; + break; + } + } else if (errorType === 2) { // Restart errors + switch (errorDesc) { + case 1: // LASER_COM_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.CATASTROPHIC; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "LDS communication error"; + break; + case 2: // MAINFRAME_COM_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.CATASTROPHIC; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.CORE; + parameters.message = "Mainframe communication error"; + break; + } + } else if (errorType === 3) { // Alert errors + switch (errorDesc) { + case 1: // LOCATION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Localization error"; + break; + case 2: // BATTERY_LOW_HIT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.INFO; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Low battery"; + break; + case 3: // DUST_BOX_FULL + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.INFO; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Dustbin full"; + break; + case 4: // WATER_BOX_LACK + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Water tank empty"; + break; + case 5: // RECHARGE_FAIL + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.POWER; + parameters.message = "Charging error"; + break; + case 6: // SENSOR_DIRTY_MSG + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.SENSORS; + parameters.message = "Sensor dirty"; + break; + case 8: // MOP_LIFT_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Mop lift error"; + break; + case 9: // DUST_INTERUPT_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock dust collection interrupted"; + break; + case 10: // DUST_BOX_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock dust collection error"; + break; + case 11: // DUST_OPENED_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock cover open or missing dust bag"; + break; + case 12: // DUST_UNINSTALL_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock cover open or missing dust bag"; + break; + case 13: // DUST_FULL_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock dust bag full or dust duct clogged"; + break; + case 32: // CANNOT_ARRIVE_POINT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot reach target"; + break; + case 33: // LOOK_DOWN_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot start in current position"; + break; + case 34: // ON_CARPET_START_MODE + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Cannot start on carpet"; + break; + case 35: // VM_WALL_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Stuck inside restricted area"; + break; + case 37: // WATER_FORBIDDEN_AREA_START + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Stuck inside restricted area"; + break; + case 100: // STATION_DISCONNECT + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock communication error"; + break; + case 101: // ROBOT_NOT_IN_STATION + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.NAVIGATION; + parameters.message = "Robot not in dock"; + break; + case 105: // WASH_WATER_BOX_UNINSTALL_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Mop Dock Clean Water Tank not installed"; + break; + case 106: // CLEAN_WATER_TANK_WITHOUT_WATER_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Mop Dock Clean Water Tank empty"; + break; + case 107: // STATION_CLOSE_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock close error"; + break; + case 109: // STATION_FAN_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock fan error"; + break; + case 111: // STATION_RAG_BOX_FULL_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Mop Dock Tray not installed"; + break; + case 112: // ROBOT_WATER_BOX_FULL_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Mop Dock Tray full of water"; + break; + case 114: // MOP_LOSE_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.ATTACHMENTS; + parameters.message = "Lost mop pad"; + break; + case 117: // W11PLUS_STATION_NO_WATER_MODULE_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock water module not installed"; + break; + case 121: // DUST_FULL_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock dust bag full or dust duct clogged"; + break; + case 125: // STATION_COMMUNICATION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock communication error"; + break; + case 128: // STATION_COVER_NOT_CLOSE + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock cover not closed"; + break; + case 129: // DUST_UNINSTALL_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Auto-Empty Dock cover open or missing dust bag"; + break; + case 131: // W11PLUS_CLEANING_LIQUID_LACK_ERROR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock cleaning liquid empty"; + break; + case 134: // WATER_INJECTION_TIMEOUT_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock water injection timeout"; + break; + case 135: // FORTIFIED_LIQUID_LACK_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock fortified liquid empty"; + break; + case 136: // WATER_LEVEL_SENSOR_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock water level sensor error"; + break; + case 148: // AIR_MOTOR_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock air motor error"; + break; + case 149: // MOTOR_RAISED_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock motor raise error"; + break; + case 150: // MOTOR_DOWN_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock motor down error"; + break; + case 151: // TEMPERATURE_COLLECTION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.ERROR; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock temperature sensor error"; + break; + case 152: // DIRTY_TANK_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.PERMANENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Mop Dock Wastewater Tank not installed or full"; + break; + case 204: // STATION_COMMUNICATION_ERR + parameters.severity.kind = ValetudoRobotError.SEVERITY_KIND.TRANSIENT; + parameters.severity.level = ValetudoRobotError.SEVERITY_LEVEL.WARNING; + parameters.subsystem = ValetudoRobotError.SUBSYSTEM.DOCK; + parameters.message = "Dock communication error"; + break; + } + } + + return new ValetudoRobotError(parameters); +}; + +module.exports = MideaValetudoRobot; diff --git a/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockAutoEmptyControlCapability.js b/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockAutoEmptyControlCapability.js new file mode 100644 index 00000000..6c12d83a --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockAutoEmptyControlCapability.js @@ -0,0 +1,69 @@ +const AutoEmptyDockAutoEmptyControlCapability = require("../../../core/capabilities/AutoEmptyDockAutoEmptyControlCapability"); +const BEightParser = require("../../../msmart/BEightParser"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends AutoEmptyDockAutoEmptyControlCapability + */ +class MideaAutoEmptyDockAutoEmptyControlCapability extends AutoEmptyDockAutoEmptyControlCapability { + + /** + * + * @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.dustTimes >= 1; // could also be every 3 or every 5, but not supported + } 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_DOCK_INTERVALS, + Buffer.from([ + 0x01, // Auto-empty + 0x01 // Every 1 cleanup + ]) + ) + }); + + 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_DOCK_INTERVALS, + Buffer.from([ + 0x01, // Auto-empty + 0x00 // Disabled + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaAutoEmptyDockAutoEmptyControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockManualTriggerCapability.js b/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockManualTriggerCapability.js new file mode 100644 index 00000000..14c6591a --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaAutoEmptyDockManualTriggerCapability.js @@ -0,0 +1,28 @@ +const AutoEmptyDockManualTriggerCapability = require("../../../core/capabilities/AutoEmptyDockManualTriggerCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +/** + * @extends AutoEmptyDockManualTriggerCapability + */ +class MideaAutoEmptyDockManualTriggerCapability extends AutoEmptyDockManualTriggerCapability { + /** + * @returns {Promise} + */ + async triggerAutoEmpty() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.TRIGGER_STATION_ACTION, + Buffer.from([ + 0x02, // Mode: Dust Collection + 0x01 // Start + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaAutoEmptyDockManualTriggerCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaBasicControlCapability.js b/backend/lib/robots/midea/capabilities/MideaBasicControlCapability.js new file mode 100644 index 00000000..dc0d7baf --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaBasicControlCapability.js @@ -0,0 +1,86 @@ +const BasicControlCapability = require("../../../core/capabilities/BasicControlCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +const entities = require("../../../entities"); + +const stateAttrs = entities.state.attributes; + +/** + * @extends BasicControlCapability + */ +class MideaBasicControlCapability extends BasicControlCapability { + /** + * @param {object} options + * @param {import("../MideaValetudoRobot")} options.robot + */ + constructor(options) { + super(options); + } + + async start() { + const StatusStateAttribute = this.robot.state.getFirstMatchingAttributeByConstructor(stateAttrs.StatusStateAttribute); + let command = 4; + + if ( + StatusStateAttribute && + StatusStateAttribute.value === stateAttrs.StatusStateAttribute.VALUE.PAUSED + ) { + command = 6; + } + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WORK_STATUS, + Buffer.from([ + command //4 for a new one, 6 when paused to resume + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + await this.robot.pollState(); // for good measure + } + + async stop() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WORK_STATUS, + Buffer.from([7]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + await this.robot.pollState(); // for good measure + } + + async pause() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WORK_STATUS, + Buffer.from([5]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + await this.robot.pollState(); // for good measure + } + + async home() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WORK_STATUS, + Buffer.from([1]) // TODO: perhaps pull into const or from const? These are the same as the status + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + await this.robot.pollState(); // for good measure + } +} + +module.exports = MideaBasicControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaCameraLightControlCapability.js b/backend/lib/robots/midea/capabilities/MideaCameraLightControlCapability.js new file mode 100644 index 00000000..89b0065c --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaCameraLightControlCapability.js @@ -0,0 +1,70 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const CameraLightControlCapability = require("../../../core/capabilities/CameraLightControlCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends CameraLightControlCapability + */ +class MideaCameraLightControlCapability extends CameraLightControlCapability { + + /** + * @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.camera_led_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([ + 0x0b, // LED + 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([ + 0x0b, // LED + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaCameraLightControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaCollisionAvoidantNavigationControlCapability.js b/backend/lib/robots/midea/capabilities/MideaCollisionAvoidantNavigationControlCapability.js new file mode 100644 index 00000000..28fbb677 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaCollisionAvoidantNavigationControlCapability.js @@ -0,0 +1,70 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const CollisionAvoidantNavigationControlCapability = require("../../../core/capabilities/CollisionAvoidantNavigationControlCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends CollisionAvoidantNavigationControlCapability + */ +class MideaCollisionAvoidantNavigationControlCapability extends CollisionAvoidantNavigationControlCapability { + + /** + * @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.edge_deep_vacuum_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([ + 0x31, // Edge Deep + 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([ + 0x31, // Edge Deep + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaCollisionAvoidantNavigationControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaCombinedVirtualRestrictionsCapability.js b/backend/lib/robots/midea/capabilities/MideaCombinedVirtualRestrictionsCapability.js new file mode 100644 index 00000000..8198cd66 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaCombinedVirtualRestrictionsCapability.js @@ -0,0 +1,97 @@ +/** + * @typedef {import("../../../entities/core/ValetudoVirtualRestrictions")} ValetudoVirtualRestrictions + */ + +const CombinedVirtualRestrictionsCapability = require("../../../core/capabilities/CombinedVirtualRestrictionsCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const ValetudoRestrictedZone = require("../../../entities/core/ValetudoRestrictedZone"); +const {sleep} = require("../../../utils/misc"); + +/** + * @extends CombinedVirtualRestrictionsCapability + */ +class MideaCombinedVirtualRestrictionsCapability extends CombinedVirtualRestrictionsCapability { + /** + * @param {ValetudoVirtualRestrictions} virtualRestrictions + * @returns {Promise} + */ + async setVirtualRestrictions(virtualRestrictions) { + if (virtualRestrictions.virtualWalls.length > 10) { + throw new Error("Can't store more than 10 virtual walls."); + } + if ( + virtualRestrictions.restrictedZones.filter(e => e.type === ValetudoRestrictedZone.TYPE.REGULAR).length > 10 + ) { + throw new Error("Can't store more than 10 no-go zones."); + } + if ( + virtualRestrictions.restrictedZones.filter(e => e.type === ValetudoRestrictedZone.TYPE.MOP).length > 10 + ) { + throw new Error("Can't store more than 10 no-mop zones."); + } + + + const wallDataPayload = Buffer.alloc(1 + (virtualRestrictions.virtualWalls.length * 9)); + + wallDataPayload[0] = virtualRestrictions.virtualWalls.length; + virtualRestrictions.virtualWalls.forEach((wall, i) => { + const offset = 1 + (i * 9); + const pA = this.robot.mapParser.convertToMideaCoordinates(wall.points.pA.x, wall.points.pA.y); + const pB = this.robot.mapParser.convertToMideaCoordinates(wall.points.pB.x, wall.points.pB.y); + + wallDataPayload[offset] = i + 1; // ID + wallDataPayload.writeUInt16LE(pA.x, offset + 1); + wallDataPayload.writeUInt16LE(pA.y, offset + 3); + wallDataPayload.writeUInt16LE(pB.x, offset + 5); + wallDataPayload.writeUInt16LE(pB.y, offset + 7); + }); + + + const zoneDataPayload = Buffer.alloc(1 + (virtualRestrictions.restrictedZones.length * 10)); + + zoneDataPayload[0] = virtualRestrictions.restrictedZones.length; + virtualRestrictions.restrictedZones.forEach((zone, i) => { + const offset = 1 + (i * 10); + const pA = this.robot.mapParser.convertToMideaCoordinates(zone.points.pA.x, zone.points.pA.y); + const pC = this.robot.mapParser.convertToMideaCoordinates(zone.points.pC.x, zone.points.pC.y); + + zoneDataPayload[offset] = zone.type === ValetudoRestrictedZone.TYPE.REGULAR ? 0 : 1; + zoneDataPayload[offset + 1] = i + 1; // ID + zoneDataPayload.writeUInt16LE(pA.x, offset + 2); + zoneDataPayload.writeUInt16LE(pA.y, offset + 4); + zoneDataPayload.writeUInt16LE(pC.x, offset + 6); + zoneDataPayload.writeUInt16LE(pC.y, offset + 8); + }); + + + const wallPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload(MSmartConst.SETTING.SET_VIRTUAL_WALLS, wallDataPayload) + }); + const zonePacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload(MSmartConst.SETTING.SET_RESTRICTED_ZONES, zoneDataPayload) + }); + + await this.robot.sendCommand(wallPacket.toHexString()); + await this.robot.sendCommand(zonePacket.toHexString()); + + this.robot.pollMap(); + await sleep(4_000); // TODO: is this enough? + } + + /** + * @returns {import("../../../core/capabilities/CombinedVirtualRestrictionsCapability").CombinedVirtualRestrictionsCapabilityProperties} + */ + getProperties() { + return { + supportedRestrictedZoneTypes: [ + ValetudoRestrictedZone.TYPE.REGULAR, + ValetudoRestrictedZone.TYPE.MOP, + ] + }; + } +} + +module.exports = MideaCombinedVirtualRestrictionsCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaCurrentStatisticsCapability.js b/backend/lib/robots/midea/capabilities/MideaCurrentStatisticsCapability.js new file mode 100644 index 00000000..e016782e --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaCurrentStatisticsCapability.js @@ -0,0 +1,49 @@ +const CurrentStatisticsCapability = require("../../../core/capabilities/CurrentStatisticsCapability"); +const ValetudoDataPoint = require("../../../entities/core/ValetudoDataPoint"); + +/** + * @extends CurrentStatisticsCapability + */ +class MideaCurrentStatisticsCapability extends CurrentStatisticsCapability { + /** + * @param {object} options + * @param {import("../MideaValetudoRobot")} options.robot + */ + constructor(options) { + super(options); + + this.currentStatistics = { + time: undefined, + area: undefined + }; + } + + /** + * @return {Promise>} + */ + async getStatistics() { + await this.robot.pollState(); //fetching robot state populates the capability's internal state. somewhat spaghetti :( + + return [ + new ValetudoDataPoint({ + type: ValetudoDataPoint.TYPES.TIME, + value: this.currentStatistics.time + }), + new ValetudoDataPoint({ + type: ValetudoDataPoint.TYPES.AREA, + value: this.currentStatistics.area + }) + ]; + } + + getProperties() { + return { + availableStatistics: [ + ValetudoDataPoint.TYPES.TIME, + ValetudoDataPoint.TYPES.AREA + ] + }; + } +} + +module.exports = MideaCurrentStatisticsCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaDoNotDisturbCapability.js b/backend/lib/robots/midea/capabilities/MideaDoNotDisturbCapability.js new file mode 100644 index 00000000..08c3caef --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaDoNotDisturbCapability.js @@ -0,0 +1,58 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const DoNotDisturbCapability = require("../../../core/capabilities/DoNotDisturbCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartDndConfigurationDTO = require("../../../msmart/dtos/MSmartDNDConfigurationDTO"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const ValetudoDNDConfiguration = require("../../../entities/core/ValetudoDNDConfiguration"); + +/** + * @extends DoNotDisturbCapability + */ +class MideaDoNotDisturbCapability extends DoNotDisturbCapability { + /** + * @returns {Promise} + */ + async getDndConfiguration() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.GET_DND) + }); + + const responsePacket = await this.robot.sendCommand(packet.toHexString()); + const parsedResponse = BEightParser.PARSE(responsePacket); + + if (!(parsedResponse instanceof MSmartDndConfigurationDTO)) { + throw new Error("Failed to parse DND configuration response."); + } + + return new ValetudoDNDConfiguration({ + enabled: parsedResponse.enabled, + start: parsedResponse.start, + end: parsedResponse.end + }); + } + + /** + * + * @param {ValetudoDNDConfiguration} dndConfig + * @returns {Promise} + */ + async setDndConfiguration(dndConfig) { + const payload = Buffer.from([ + dndConfig.enabled ? 1 : 0, + dndConfig.start.hour, + dndConfig.start.minute, + dndConfig.end.hour, + dndConfig.end.minute + ]); + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload(MSmartConst.SETTING.SET_DND, payload) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaDoNotDisturbCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaFanSpeedControlCapability.js b/backend/lib/robots/midea/capabilities/MideaFanSpeedControlCapability.js new file mode 100644 index 00000000..9604890e --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaFanSpeedControlCapability.js @@ -0,0 +1,47 @@ +const FanSpeedControlCapability = require("../../../core/capabilities/FanSpeedControlCapability"); + +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends FanSpeedControlCapability + */ +class MideaFanSpeedControlCapability extends FanSpeedControlCapability { + /** + * @param {string} preset + * @returns {Promise} + */ + async selectPreset(preset) { + const matchedPreset = this.presets.find(p => { + return p.name === preset; + }); + + if (matchedPreset) { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_FAN_SPEED, + Buffer.from([matchedPreset.value]) + ) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + + if (response?.payload?.[3] === 0x00) { + this.robot.parseAndUpdateState( + new MSmartStatusDTO({ + fan_level: matchedPreset.value + }) + ); + } else { + throw new Error("Fan speed change failed"); + } + } else { + throw new Error("Invalid Preset"); + } + } + +} + +module.exports = MideaFanSpeedControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaKeyLockCapability.js b/backend/lib/robots/midea/capabilities/MideaKeyLockCapability.js new file mode 100644 index 00000000..2f1ff365 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaKeyLockCapability.js @@ -0,0 +1,71 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const KeyLockCapability = require("../../../core/capabilities/KeyLockCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends KeyLockCapability + */ +class MideaKeyLockCapability extends KeyLockCapability { + + /** + * + * @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.child_lock_enabled; + } 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([ + 0x01, // Key Lock + 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([ + 0x01, // Key Lock + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaKeyLockCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaLocateCapability.js b/backend/lib/robots/midea/capabilities/MideaLocateCapability.js new file mode 100644 index 00000000..d0b8e3b8 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaLocateCapability.js @@ -0,0 +1,22 @@ +const LocateCapability = require("../../../core/capabilities/LocateCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +/** + * @extends LocateCapability + */ +class MideaLocateCapability extends LocateCapability { + /** + * @returns {Promise} + */ + async locate() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.LOCATE) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaLocateCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaManualControlCapability.js b/backend/lib/robots/midea/capabilities/MideaManualControlCapability.js new file mode 100644 index 00000000..f12359cf --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaManualControlCapability.js @@ -0,0 +1,166 @@ +const AttributeSubscriber = require("../../../entities/AttributeSubscriber"); +const CallbackAttributeSubscriber = require("../../../entities/CallbackAttributeSubscriber"); +const ManualControlCapability = require("../../../core/capabilities/ManualControlCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const StatusStateAttribute = require("../../../entities/state/attributes/StatusStateAttribute"); +const {sleep} = require("../../../utils/misc"); + +// FIXME: This doesn't feel good enough to be used + +/** + * @extends ManualControlCapability + */ +class MideaManualControlCapability extends ManualControlCapability { + /** + * @param {object} options + * @param {import("../MideaValetudoRobot")} options.robot + */ + constructor(options) { + super(Object.assign({}, options, { + supportedMovementCommands: [ + ManualControlCapability.MOVEMENT_COMMAND_TYPE.FORWARD, + ManualControlCapability.MOVEMENT_COMMAND_TYPE.ROTATE_CLOCKWISE, + ManualControlCapability.MOVEMENT_COMMAND_TYPE.ROTATE_COUNTERCLOCKWISE, + ] + })); + + this.active = false; + this.keepAliveTimeout = undefined; + this.lastCommand = new Date(0).getTime(); + + this.robot.state.subscribe( + new CallbackAttributeSubscriber((eventType, status, prevStatus) => { + if ( + eventType === AttributeSubscriber.EVENT_TYPE.CHANGE && + //@ts-ignore + status.value !== StatusStateAttribute.VALUE.MANUAL_CONTROL && + prevStatus && + //@ts-ignore + prevStatus.value === StatusStateAttribute.VALUE.MANUAL_CONTROL + ) { + this.disableManualControl().catch(() => {}); + } + }), + {attributeClass: StatusStateAttribute.name} + ); + } + + /** + * @private + * @param {number} direction The direction parameter byte (0x0D) + * @returns {Promise} + */ + async sendMovementCommand(direction) { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.DO_MANUAL_CONTROL_CMD, + Buffer.from([ + direction, + MIDEA_MANUAL_CONTROL_MODE.CRUISE + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + this.lastCommand = new Date().getTime(); + } + + /** + * @private + * @returns {Promise} + */ + async scheduleKeepAlive() { + clearTimeout(this.keepAliveTimeout); + + if (this.active) { + if (new Date().getTime() - this.lastCommand >= KEEP_ALIVE_INTERVAL) { + await this.sendMovementCommand(MIDEA_MANUAL_CONTROL_DIRECTION.STOP); + } + + this.keepAliveTimeout = setTimeout(() => { + this.scheduleKeepAlive().catch(() => { + /* intentional */ + }); + }, KEEP_ALIVE_INTERVAL); + } + } + + + /** + * @returns {Promise} + */ + async enableManualControl() { + if (!this.active) { + this.active = true; + + await this.sendMovementCommand(MIDEA_MANUAL_CONTROL_DIRECTION.STOP); + await this.robot.pollState(); + + await this.scheduleKeepAlive(); + } + } + + /** + * @returns {Promise} + */ + async disableManualControl() { + if (this.active) { + clearTimeout(this.keepAliveTimeout); + this.keepAliveTimeout = undefined; + this.active = false; + + await this.robot.pollState(); + } + } + + /** + * @returns {Promise} + */ + async manualControlActive() { + return this.active; + } + + /** + * @param {import("../../../core/capabilities/ManualControlCapability").ValetudoManualControlMovementCommandType} movementCommand + * @returns {Promise} + */ + async manualControl(movementCommand) { + let direction; + + switch (movementCommand) { + case ManualControlCapability.MOVEMENT_COMMAND_TYPE.FORWARD: + direction = MIDEA_MANUAL_CONTROL_DIRECTION.FORWARD; + break; + case ManualControlCapability.MOVEMENT_COMMAND_TYPE.ROTATE_CLOCKWISE: + direction = MIDEA_MANUAL_CONTROL_DIRECTION.RIGHT; + break; + case ManualControlCapability.MOVEMENT_COMMAND_TYPE.ROTATE_COUNTERCLOCKWISE: + direction = MIDEA_MANUAL_CONTROL_DIRECTION.LEFT; + break; + default: + throw new Error("Invalid movementCommand"); + } + + await this.sendMovementCommand(direction); + await sleep(500); + await this.sendMovementCommand(MIDEA_MANUAL_CONTROL_DIRECTION.STOP); // FIXME: this feels terrible but so does not having it + } +} + +const MIDEA_MANUAL_CONTROL_DIRECTION = Object.freeze({ + STOP: 0x00, + FORWARD: 0x01, + LEFT: 0x03, + RIGHT: 0x04 +}); + +const MIDEA_MANUAL_CONTROL_MODE = Object.freeze({ + CLEAN: 0x00, + CRUISE: 0x01 +}); + +const KEEP_ALIVE_INTERVAL = 3000; + +module.exports = MideaManualControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMapResetCapability.js b/backend/lib/robots/midea/capabilities/MideaMapResetCapability.js new file mode 100644 index 00000000..4b9f1838 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMapResetCapability.js @@ -0,0 +1,34 @@ +const MapResetCapability = require("../../../core/capabilities/MapResetCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const {sleep} = require("../../../utils/misc"); + +// After sending the bitset, the firmware will notice that it has maps the cloud doesn't know about, and, +// if more than 5 minutes have passed since the last map upload, it will delete all of those. + +// As that condition is not useful when using valetudo, we need to patch out that 5 minute check +// It is found in CCentralController::SetMapHouseIDSet in manager_node + +/** + * @extends MapResetCapability + */ +class MideaMapResetCapability extends MapResetCapability { + /** + * @returns {Promise} + */ + async reset() { + const setMapIndexPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VALID_MAP_IDS, + Buffer.from([0b00000000000000000000000000000000]) // bitset of all map IDs the cloud is aware of + ) + }); + await this.robot.sendCommand(setMapIndexPacket.toHexString()); + await sleep(4_000); // for good measure + + this.robot.clearValetudoMap(); + } +} + +module.exports = MideaMapResetCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMapSegmentEditCapability.js b/backend/lib/robots/midea/capabilities/MideaMapSegmentEditCapability.js new file mode 100644 index 00000000..886a35cf --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMapSegmentEditCapability.js @@ -0,0 +1,78 @@ +const MapSegmentEditCapability = require("../../../core/capabilities/MapSegmentEditCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const {sleep} = require("../../../utils/misc"); + +/** + * @extends MapSegmentEditCapability + */ +class MideaMapSegmentEditCapability extends MapSegmentEditCapability { + /** + * @param {import("../../../entities/core/ValetudoMapSegment")} segmentA + * @param {import("../../../entities/core/ValetudoMapSegment")} segmentB + * @returns {Promise} + */ + async joinSegments(segmentA, segmentB) { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.JOIN_SEGMENTS, + Buffer.from([ + 2, // count + parseInt(segmentA.id), + parseInt(segmentB.id) + ]) + ) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + if (response.payload[3] !== 0x00) { + throw new Error("Segment split failed."); + } // TODO: should I do this with every command? + + + this.robot.pollMap(); + await sleep(2_000); + } + + /** + * @param {import("../../../entities/core/ValetudoMapSegment")} segment + * @param {object} pA + * @param {number} pA.x + * @param {number} pA.y + * @param {object} pB + * @param {number} pB.x + * @param {number} pB.y + * @returns {Promise} + */ + async splitSegment(segment, pA, pB) { + const pAm = this.robot.mapParser.convertToMideaCoordinates(pA.x, pA.y); + const pBm = this.robot.mapParser.convertToMideaCoordinates(pB.x, pB.y); + + const payload = Buffer.alloc(9); + payload.writeUInt16LE(pAm.x, 0); + payload.writeUInt16LE(pAm.y, 2); + payload.writeUInt16LE(pBm.x, 4); + payload.writeUInt16LE(pBm.y, 6); + + payload[8] = parseInt(segment.id); + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SPLIT_SEGMENT, + payload + ) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + if (response.payload[3] !== 0x00) { + throw new Error("Segment split failed."); + } + + this.robot.pollMap(); + await sleep(2_000); + } +} + +module.exports = MideaMapSegmentEditCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMapSegmentationCapability.js b/backend/lib/robots/midea/capabilities/MideaMapSegmentationCapability.js new file mode 100644 index 00000000..84e45b27 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMapSegmentationCapability.js @@ -0,0 +1,87 @@ +const entities = require("../../../entities"); +const MapSegmentationCapability = require("../../../core/capabilities/MapSegmentationCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +/** + * @extends MapSegmentationCapability + */ +class MideaMapSegmentationCapability extends MapSegmentationCapability { + /** + * @param {Array} segments + * @param {object} [options] + * @param {number} [options.iterations] + * @param {boolean} [options.customOrder] + * @returns {Promise} + */ + async executeSegmentAction(segments, options) { + if (segments.length > 10) { + throw new Error("This robot can only clean a maximum of 10 segments at a time."); + } + + const FanSpeedStateAttribute = this.robot.state.getFirstMatchingAttribute({ + attributeClass: entities.state.attributes.PresetSelectionStateAttribute.name, + attributeType: entities.state.attributes.PresetSelectionStateAttribute.TYPE.FAN_SPEED + }); + const WaterGradeAttribute = this.robot.state.getFirstMatchingAttribute({ + attributeClass: entities.state.attributes.PresetSelectionStateAttribute.name, + attributeType: entities.state.attributes.PresetSelectionStateAttribute.TYPE.WATER_GRADE + }); + const OperationModeStateAttribute = this.robot.state.getFirstMatchingAttribute({ + attributeClass: entities.state.attributes.PresetSelectionStateAttribute.name, + attributeType: entities.state.attributes.PresetSelectionStateAttribute.TYPE.OPERATION_MODE + }); + + const fanSpeed = FanSpeedStateAttribute?.metaData?.rawValue ?? 1; + const waterGrade = WaterGradeAttribute?.metaData?.rawValue ?? 1; + const operationMode = OperationModeStateAttribute?.metaData?.rawValue ?? 0; + + /* + The J15 fw 413 expects a fixed-size payload for 10 room slots because it just ignores the length provided. + A shorter payload causes a buffer over-read, leading to garbage rooms being added to the plan. + + The same bug does not exist in the zone payload handling, which does adhere to the length passed. + */ + const segmentDataPayload = Buffer.alloc(1 + 10 * 10); // 1-byte count + 10 rooms * 10 bytes/room + + // On the J15 fw 413, this is being ignored by the robot :( + segmentDataPayload[0] = segments.length; + + segments.slice(0, 10).forEach((segment, i) => { + const offset = 1 + i * 10; + + segmentDataPayload[offset] = parseInt(segment.id); + segmentDataPayload[offset + 1] = typeof options?.iterations === "number" ? options.iterations : 1; + segmentDataPayload[offset + 2] = operationMode; + // offset + 3 unknown + segmentDataPayload[offset + 4] = fanSpeed; + segmentDataPayload[offset + 5] = waterGrade; + // remaining 4 bytes unknown + }); + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.START_SEGMENT_CLEANUP, + segmentDataPayload + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @returns {import("../../../core/capabilities/MapSegmentationCapability").MapSegmentationCapabilityProperties} + */ + getProperties() { + return { + iterationCount: { + min: 1, + max: 3 + }, + customOrderSupport: false + }; + } +} + +module.exports = MideaMapSegmentationCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMappingPassCapability.js b/backend/lib/robots/midea/capabilities/MideaMappingPassCapability.js new file mode 100644 index 00000000..1c43956b --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMappingPassCapability.js @@ -0,0 +1,43 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const MappingPassCapability = require("../../../core/capabilities/MappingPassCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartMapListDTO = require("../../../msmart/dtos/MSmartMapListDTO"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +/** + * @extends MappingPassCapability + */ +class MideaMappingPassCapability extends MappingPassCapability { + /** + * @returns {Promise} + */ + async startMapping() { + const listMapsPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.ACTION, + payload: MSmartPacket.buildPayload(MSmartConst.ACTION.LIST_MAPS) + }); + + const listMapsResponse = await this.robot.sendCommand(listMapsPacket.toHexString()); + const parsedListMapsResponse = BEightParser.PARSE(listMapsResponse); + + if (!(parsedListMapsResponse instanceof MSmartMapListDTO)) { + throw new Error("Failed to check for existing map."); + } + + if (parsedListMapsResponse.currentMapId !== 0) { + throw new Error("A map already exists."); + } + + const startMappingPacket = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WORK_STATUS, + Buffer.from([10]) + ) + }); + + await this.robot.sendCommand(startMappingPacket.toHexString()); + } +} + +module.exports = MideaMappingPassCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMopDockCleanManualTriggerCapability.js b/backend/lib/robots/midea/capabilities/MideaMopDockCleanManualTriggerCapability.js new file mode 100644 index 00000000..69b8c8fc --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMopDockCleanManualTriggerCapability.js @@ -0,0 +1,46 @@ +const MopDockCleanManualTriggerCapability = require("../../../core/capabilities/MopDockCleanManualTriggerCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +/** + * @extends MopDockCleanManualTriggerCapability + */ +class MideaMopDockCleanManualTriggerCapability extends MopDockCleanManualTriggerCapability { + /** + * @returns {Promise} + */ + async startCleaning() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.TRIGGER_STATION_ACTION, + Buffer.from([ + 0x01, // Mode: Mop Clean (basic) - TODO: there is also 0x03 for station cleaning + 0x01 // Control: Start + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @returns {Promise} + */ + async stopCleaning() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.TRIGGER_STATION_ACTION, + Buffer.from([ + 0x00, // Mode: Ignored when stopping + 0x00 // Control: Stop + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaMopDockCleanManualTriggerCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMopDockDryManualTriggerCapability.js b/backend/lib/robots/midea/capabilities/MideaMopDockDryManualTriggerCapability.js new file mode 100644 index 00000000..090ddcef --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMopDockDryManualTriggerCapability.js @@ -0,0 +1,48 @@ +const MopDockDryManualTriggerCapability = require("../../../core/capabilities/MopDockDryManualTriggerCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); + +// TODO: this doesn't seem to work? + +/** + * @extends MopDockDryManualTriggerCapability + */ +class MideaMopDockDryManualTriggerCapability extends MopDockDryManualTriggerCapability { + /** + * @returns {Promise} + */ + async startDrying() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.TRIGGER_STATION_ACTION, + Buffer.from([ + 0x04, // Drying Mode + 0x01 // Start + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @returns {Promise} + */ + async stopDrying() { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.TRIGGER_STATION_ACTION, + Buffer.from([ + 0x00, // Doesn't matter + 0x00 // Stop + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaMopDockDryManualTriggerCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaMopExtensionControlCapability.js b/backend/lib/robots/midea/capabilities/MideaMopExtensionControlCapability.js new file mode 100644 index 00000000..78b564f6 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaMopExtensionControlCapability.js @@ -0,0 +1,70 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const MopExtensionControlCapability = require("../../../core/capabilities/MopExtensionControlCapability"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + +/** + * @extends MopExtensionControlCapability + */ +class MideaMopExtensionControlCapability extends MopExtensionControlCapability { + + /** + * @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.mop_extend_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([ + 0x26, // Mop extension + 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([ + 0x26, // Mop extension + 0x00 // false + ]) + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } +} + +module.exports = MideaMopExtensionControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaOperationModeControlCapability.js b/backend/lib/robots/midea/capabilities/MideaOperationModeControlCapability.js new file mode 100644 index 00000000..fb3e9635 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaOperationModeControlCapability.js @@ -0,0 +1,47 @@ +const OperationModeControlCapability = require("../../../core/capabilities/OperationModeControlCapability"); + +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); + + +/** + * @extends OperationModeControlCapability + */ +class MideaOperationModeControlCapability extends OperationModeControlCapability { + /** + * @param {string} preset + * @returns {Promise} + */ + async selectPreset(preset) { + const matchedPreset = this.presets.find(p => { + return p.name === preset; + }); + + if (matchedPreset) { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_OPERATION_MODE, + Buffer.from([matchedPreset.value]) + ) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + + if (response?.payload?.[3] === 0x00) { + this.robot.parseAndUpdateState( + new MSmartStatusDTO({ + station_work_status: matchedPreset.value + }) + ); + } else { + throw new Error("Operation mode change failed"); + } + } else { + throw new Error("Invalid Preset"); + } + } +} + +module.exports = MideaOperationModeControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaSpeakerTestCapability.js b/backend/lib/robots/midea/capabilities/MideaSpeakerTestCapability.js new file mode 100644 index 00000000..1c47672f --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaSpeakerTestCapability.js @@ -0,0 +1,29 @@ +const Logger = require("../../../Logger"); +const SpeakerTestCapability = require("../../../core/capabilities/SpeakerTestCapability"); +const {exec} = require("child_process"); +const {promisify} = require("util"); + +const execAsync = promisify(exec); + +/** + * @extends SpeakerTestCapability + */ +class MideaSpeakerTestCapability extends SpeakerTestCapability { + /** + * @returns {Promise} + */ + async playTestSound() { + if (this.robot.config.get("embedded") === true) { + try { + await execAsync("mp3aplay /oem/sound/2.mp3"); + } catch (err) { + Logger.warn("Error during speaker test", err); + } + } else { + throw new Error("Only possible when embedded"); + } + } +} +//TODO: I think I saw a command for this somewhere in some _node + +module.exports = MideaSpeakerTestCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaSpeakerVolumeControlCapability.js b/backend/lib/robots/midea/capabilities/MideaSpeakerVolumeControlCapability.js new file mode 100644 index 00000000..3bfd277c --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaSpeakerVolumeControlCapability.js @@ -0,0 +1,57 @@ +const BEightParser = require("../../../msmart/BEightParser"); +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); +const SpeakerVolumeControlCapability = require("../../../core/capabilities/SpeakerVolumeControlCapability"); + +/** + * @extends SpeakerVolumeControlCapability + */ +class MideaSpeakerVolumeControlCapability extends SpeakerVolumeControlCapability { + + /** + * Returns the current voice volume as percentage + * + * @returns {Promise} + */ + async getVolume() { + 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.voice_level ?? 0; + } else { + throw new Error("Invalid response from robot"); + } + } + + /** + * Sets the speaker volume + * + * @param {number} value - Volume level (0-100) + * @returns {Promise} + */ + async setVolume(value) { + // TODO: validate 0-100? + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_VOLUME, + Buffer.from([value]) + ) + }); + + + this.robot.sendCommand(packet.toHexString()).catch(e => { + // FIXME: the robot never acknowledges this. Are we doing something wrong, or is it just like that? + }); + } +} + +module.exports = MideaSpeakerVolumeControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaWaterUsageControlCapability.js b/backend/lib/robots/midea/capabilities/MideaWaterUsageControlCapability.js new file mode 100644 index 00000000..41cc99d9 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaWaterUsageControlCapability.js @@ -0,0 +1,46 @@ +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const MSmartStatusDTO = require("../../../msmart/dtos/MSmartStatusDTO"); +const WaterUsageControlCapability = require("../../../core/capabilities/WaterUsageControlCapability"); + + +/** + * @extends WaterUsageControlCapability + */ +class MideaWaterUsageControlCapability extends WaterUsageControlCapability { + /** + * @param {string} preset + * @returns {Promise} + */ + async selectPreset(preset) { + const matchedPreset = this.presets.find(p => { + return p.name === preset; + }); + + if (matchedPreset) { + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.SET_WATER_GRADE, + Buffer.from([matchedPreset.value]) + ) + }); + + const response = await this.robot.sendCommand(packet.toHexString()); + + if (response?.payload?.[3] === 0x00) { + this.robot.parseAndUpdateState( + new MSmartStatusDTO({ + water_level: matchedPreset.value + }) + ); + } else { + throw new Error("Water grade change failed"); + } + } else { + throw new Error("Invalid Preset"); + } + } +} + +module.exports = MideaWaterUsageControlCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaWifiConfigurationCapability.js b/backend/lib/robots/midea/capabilities/MideaWifiConfigurationCapability.js new file mode 100644 index 00000000..54fe451c --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaWifiConfigurationCapability.js @@ -0,0 +1,188 @@ +const LinuxWifiConfigurationCapability = require("../../common/linuxCapabilities/LinuxWifiConfigurationCapability"); +const Logger = require("../../../Logger"); +const MSmartProvisioningPacket = require("../../../msmart/MSmartProvisioningPacket"); +const ValetudoWifiConfiguration = require("../../../entities/core/ValetudoWifiConfiguration"); + +const crypto = require("crypto"); +const net = require("net"); + +const ProvisioningState = Object.freeze({ + CONNECTING: "connecting", + GETTING_UUID: "getting_uuid", + PROVISIONING: "provisioning", + DONE: "done" +}); + +/** + * The robot firmware is super fragile and does not at all handle us behaving any different from the real app + * Hence, this got quite convoluted + * + * @extends LinuxWifiConfigurationCapability + */ +class MideaWifiConfigurationCapability extends LinuxWifiConfigurationCapability { + /** + * @param {import("../../../entities/core/ValetudoWifiConfiguration")} wifiConfig + * @returns {Promise} + */ + async setWifiConfiguration(wifiConfig) { + if ( + wifiConfig?.ssid !== undefined && + wifiConfig.credentials?.type === ValetudoWifiConfiguration.CREDENTIALS_TYPE.WPA2_PSK && + wifiConfig.credentials.typeSpecificSettings?.password !== undefined + ) { + await this.performFullProvisioningSequence(wifiConfig); + } else { + throw new Error("Invalid wifiConfig"); + } + } + + /** + * Not following this to the letter (including polling the UUID and waiting for the robot to close the connection) + * bricks the firmware. It is _super_ brittle + * + * @private + * @param {ValetudoWifiConfiguration} wifiConfig + * @returns {Promise} + */ + performFullProvisioningSequence(wifiConfig) { + const host = "127.0.0.1"; + const port = 9999; + + return new Promise((resolve, reject) => { + const client = new net.Socket(); + let timeout; + let state = ProvisioningState.CONNECTING; + let dataBuffer = Buffer.alloc(0); + + const cleanup = () => { + clearTimeout(timeout); + client.removeAllListeners(); + client.destroy(); + }; + + const fail = (err) => { + Logger.warn("Provisioning failed", err); + cleanup(); + reject(err); + }; + + const succeed = () => { + Logger.debug("Provisioning successful."); + + cleanup(); + resolve(); + }; + + client.on("error", (err) => { + fail(new Error(`Connection error to ${host}:${port}. Error: ${err.message}`)); + }); + + timeout = setTimeout(() => { + // If we're not already done, fail with a timeout. + if (state !== ProvisioningState.DONE) { + fail(new Error(`Operation timed out after 15 seconds. Current state: ${state}`)); + } + }, 15000); + + client.on("close", () => { + if (state !== ProvisioningState.DONE) { + fail(new Error(`Connection closed unexpectedly during state: ${state}`)); + } + }); + + client.connect(port, host, () => { + Logger.debug(`WifiConfig State ${state} - Connected to ${host}:${port}. Transitioning to 'getting_uuid'.`); + state = ProvisioningState.GETTING_UUID; + + const getUuidPacket = new MSmartProvisioningPacket({ + commandId: MSmartProvisioningPacket.COMMAND_IDS.CMD_UUID_INFO, + payload: Buffer.from([0x00]) + }); + + Logger.debug(`WifiConfig State ${state} - Sending 'Get UUID' request.`); + client.write(getUuidPacket.toBytes()); + }); + + client.on("data", (data) => { + if (state === ProvisioningState.DONE) { + return; + } // Don't process any further data after we're done + + dataBuffer = Buffer.concat([dataBuffer, data]); + + while (dataBuffer.length >= 7) { + if (dataBuffer[0] !== 0xEE || dataBuffer[1] !== 0x01) { + Logger.warn(`WifiConfig State ${state} - Desynchronized stream. Discarding byte ${dataBuffer[0].toString(16)}.`); + dataBuffer = dataBuffer.subarray(1); + continue; + } + + // FIXME: Not ideal here, as it is knowledge/logic that should be encapsulated by the MSmartProvisioningPacket + const coreCommandLength = dataBuffer.readUInt16BE(4); + const totalPacketLength = 7 + coreCommandLength; + + if (dataBuffer.length < totalPacketLength) { + break; + } + + const packetToProcess = dataBuffer.subarray(0, totalPacketLength); + dataBuffer = dataBuffer.subarray(totalPacketLength); + + try { + const responsePacket = MSmartProvisioningPacket.FROM_BYTES(packetToProcess); + Logger.debug(`WifiConfig State ${state} - Received and parsed packet with Command ID: ${responsePacket.commandId}`); + + if (state === ProvisioningState.GETTING_UUID && responsePacket.commandId === MSmartProvisioningPacket.RESPONSE_IDS.CMD_UUID_INFO) { + if (responsePacket.payload.length > 1) { + Logger.debug(`WifiConfig State ${state} - Received full UUID response. Transitioning to 'provisioning'.`); + state = ProvisioningState.PROVISIONING; + + const payloadString = [ + wifiConfig.ssid, + wifiConfig.credentials.typeSpecificSettings.password, + "https://euprod.mzrobo.com/", + "GMT+00:00", + BigInt(`0x${crypto.randomBytes(8).toString("hex")}`).toString(), + "1,13", // Wifi channel min-max + "DE" + ].join("\n"); + + const provisioningPacket = new MSmartProvisioningPacket({ + commandId: MSmartProvisioningPacket.COMMAND_IDS.CMD_ALL_INFO, + payload: Buffer.from(payloadString) + }); + + Logger.debug(`WifiConfig State ${state} - Sending provisioning data.`); + client.write(provisioningPacket.toBytes()); + } else { + Logger.debug(`WifiConfig State ${state} - Ignoring ACK for UUID response.`); + } + } else if (state === ProvisioningState.PROVISIONING && responsePacket.commandId === MSmartProvisioningPacket.RESPONSE_IDS.CMD_ALL_INFO) { + if (responsePacket.payload[0] === 0x00) { + Logger.debug(`WifiConfig State ${state} - Received provisioning ack.`); + state = ProvisioningState.DONE; + + // Resolve this ASAP, so that the UI can still receive feedback before the robot transitions from AP to STA mode + succeed(); + + return; + } else { + fail(new Error(`Robot acknowledged provisioning with error code: 0x${responsePacket.payload[0].toString(16)}`)); + + return; + } + } else { + Logger.debug(`WifiConfig State ${state} - Received unexpected but valid packet with ID ${responsePacket.commandId}. Ignoring.`); + } + } catch (e) { + fail(new Error(`Failed to parse response from robot. Error: ${e.message}`)); + + return; + } + } + }); + }); + } +} + +module.exports = MideaWifiConfigurationCapability; diff --git a/backend/lib/robots/midea/capabilities/MideaZoneCleaningCapability.js b/backend/lib/robots/midea/capabilities/MideaZoneCleaningCapability.js new file mode 100644 index 00000000..07ff6b95 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/MideaZoneCleaningCapability.js @@ -0,0 +1,65 @@ +const MSmartConst = require("../../../msmart/MSmartConst"); +const MSmartPacket = require("../../../msmart/MSmartPacket"); +const ZoneCleaningCapability = require("../../../core/capabilities/ZoneCleaningCapability"); + +/** + * @extends ZoneCleaningCapability + */ +class MideaZoneCleaningCapability extends ZoneCleaningCapability { + /** + * @param {object} options + * @param {Array} options.zones + * @param {number} [options.iterations] + * @returns {Promise} + */ + async start(options) { + if (options.zones.length > 5) { + throw new Error("Cannot clean more than 5 zones at once."); + } + + const zoneDataPayload = Buffer.alloc(1 + options.zones.length * 10); + + zoneDataPayload[0] = options.zones.length; + options.zones.forEach((zone, i) => { + const offset = 1 + i * 10; + + const pA = this.robot.mapParser.convertToMideaCoordinates(zone.points.pA.x, zone.points.pA.y); + const pC = this.robot.mapParser.convertToMideaCoordinates(zone.points.pC.x, zone.points.pC.y); + + zoneDataPayload[offset] = i + 1; + zoneDataPayload.writeUInt16LE(pA.x, offset + 1); // left + zoneDataPayload.writeUInt16LE(pA.y, offset + 3); // top + zoneDataPayload.writeUInt16LE(pC.x, offset + 5); // right + zoneDataPayload.writeUInt16LE(pC.y, offset + 7); // bottom + zoneDataPayload[offset + 9] = options.iterations ?? 1; + }); + + const packet = new MSmartPacket({ + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: MSmartPacket.buildPayload( + MSmartConst.SETTING.START_ZONE_CLEANUP, + zoneDataPayload + ) + }); + + await this.robot.sendCommand(packet.toHexString()); + } + + /** + * @returns {import("../../../core/capabilities/ZoneCleaningCapability").ZoneCleaningCapabilityProperties} + */ + getProperties() { + return { + zoneCount: { + min: 1, + max: 5 + }, + iterationCount: { + min: 1, + max: 3 + } + }; + } +} + +module.exports = MideaZoneCleaningCapability; diff --git a/backend/lib/robots/midea/capabilities/index.js b/backend/lib/robots/midea/capabilities/index.js new file mode 100644 index 00000000..ada4bf05 --- /dev/null +++ b/backend/lib/robots/midea/capabilities/index.js @@ -0,0 +1,27 @@ +module.exports = { + MideaAutoEmptyDockAutoEmptyControlCapability: require("./MideaAutoEmptyDockAutoEmptyControlCapability"), + MideaAutoEmptyDockManualTriggerCapability: require("./MideaAutoEmptyDockManualTriggerCapability"), + MideaBasicControlCapability: require("./MideaBasicControlCapability"), + MideaCameraLightControlCapability: require("./MideaCameraLightControlCapability"), + MideaCollisionAvoidantNavigationControlCapability: require("./MideaCollisionAvoidantNavigationControlCapability"), + MideaCombinedVirtualRestrictionsCapability: require("./MideaCombinedVirtualRestrictionsCapability"), + MideaCurrentStatisticsCapability: require("./MideaCurrentStatisticsCapability"), + MideaDoNotDisturbCapability: require("./MideaDoNotDisturbCapability"), + MideaFanSpeedControlCapability: require("./MideaFanSpeedControlCapability"), + MideaKeyLockCapability: require("./MideaKeyLockCapability"), + MideaLocateCapability: require("./MideaLocateCapability"), + MideaManualControlCapability: require("./MideaManualControlCapability"), + MideaMapResetCapability: require("./MideaMapResetCapability"), + MideaMapSegmentEditCapability: require("./MideaMapSegmentEditCapability"), + MideaMapSegmentationCapability: require("./MideaMapSegmentationCapability"), + MideaMappingPassCapability: require("./MideaMappingPassCapability"), + MideaMopDockCleanManualTriggerCapability: require("./MideaMopDockCleanManualTriggerCapability"), + MideaMopDockDryManualTriggerCapability: require("./MideaMopDockDryManualTriggerCapability"), + MideaMopExtensionControlCapability: require("./MideaMopExtensionControlCapability"), + MideaOperationModeControlCapability: require("./MideaOperationModeControlCapability"), + MideaSpeakerTestCapability: require("./MideaSpeakerTestCapability"), + MideaSpeakerVolumeControlCapability: require("./MideaSpeakerVolumeControlCapability"), + MideaWaterUsageControlCapability: require("./MideaWaterUsageControlCapability"), + MideaWifiConfigurationCapability: require("./MideaWifiConfigurationCapability"), + MideaZoneCleaningCapability: require("./MideaZoneCleaningCapability"), +}; diff --git a/backend/lib/robots/midea/index.js b/backend/lib/robots/midea/index.js new file mode 100644 index 00000000..a867b42e --- /dev/null +++ b/backend/lib/robots/midea/index.js @@ -0,0 +1,3 @@ +module.exports = { + "MideaJ15ValetudoRobot": require("./MideaJ15ValetudoRobot") +}; diff --git a/backend/lib/utils/DummyCloudCertManager.js b/backend/lib/utils/DummyCloudCertManager.js new file mode 100644 index 00000000..07282a72 --- /dev/null +++ b/backend/lib/utils/DummyCloudCertManager.js @@ -0,0 +1,70 @@ +const crypto = require("crypto"); +const forge = require("node-forge"); +const Logger = require("../Logger"); + +class DummyCloudCertManager { + /** + * @param {object} options + * @param {forge.pki.PrivateKey} options.caKey + * @param {forge.pki.Certificate} options.caCert + */ + constructor(options) { + this.caKey = options.caKey; + this.caCert = options.caCert; + this.certCache = new Map(); + } + + getCertificate(hostname) { + if (this.certCache.has(hostname)) { + const cachedEntry = this.certCache.get(hostname); + + if (cachedEntry) { + if (cachedEntry.validUntil > new Date(Date.now() + 60000)) { + return cachedEntry; + } else { + Logger.info(`Certificate for '${hostname}' has expired or is about to expire. Regenerating.`); + } + } else { + Logger.info(`No cached certificate available for '${hostname}'.`); + } + + } + + const newCertData = this.generateCertificate(hostname); + + this.certCache.set(hostname, newCertData); + + return newCertData; + } + + generateCertificate(hostname) { + Logger.info(`Generating new certificate for '${hostname}'.`); + + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + + cert.publicKey = keys.publicKey; + cert.serialNumber = "01" + crypto.randomBytes(19).toString("hex"); // rfc5280 4.1.2.2 + cert.validity.notBefore = new Date(new Date().setDate(new Date().getDate() - 1)); + cert.validity.notAfter = new Date(new Date().setFullYear(new Date().getFullYear() + 30)); // Midea doesn't handle the cert expiring gracefully or at all + + cert.setSubject([{ name: "commonName", value: hostname }]); + cert.setIssuer(this.caCert.subject.attributes); + cert.setExtensions([ + { name: "basicConstraints", cA: false }, + { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, + { name: "extKeyUsage", serverAuth: true }, + { name: "subjectAltName", altNames: [{ type: 2, value: hostname }] } + ]); + + cert.sign(this.caKey, forge.md.sha256.create()); + + return { + key: forge.pki.privateKeyToPem(keys.privateKey), + cert: forge.pki.certificateToPem(cert), + validUntil: cert.validity.notAfter + }; + } +} + +module.exports = DummyCloudCertManager; diff --git a/backend/lib/valetudo_events/events/MissingResourceValetudoEvent.js b/backend/lib/valetudo_events/events/MissingResourceValetudoEvent.js new file mode 100644 index 00000000..4e8d7828 --- /dev/null +++ b/backend/lib/valetudo_events/events/MissingResourceValetudoEvent.js @@ -0,0 +1,22 @@ +const DismissibleValetudoEvent = require("./DismissibleValetudoEvent"); + +class MissingResourceValetudoEvent extends DismissibleValetudoEvent { + /** + * + * @param {object} options + * @param {string} options.message + * + * @param {string} [options.id] + * @param {Date} [options.timestamp] + * @param {boolean} [options.processed] + * @param {object} [options.metaData] + * @class + */ + constructor(options) { + super(options); + + this.message = options.message; + } +} + +module.exports = MissingResourceValetudoEvent; diff --git a/backend/lib/valetudo_events/events/index.js b/backend/lib/valetudo_events/events/index.js index 014120e8..040b2014 100644 --- a/backend/lib/valetudo_events/events/index.js +++ b/backend/lib/valetudo_events/events/index.js @@ -3,6 +3,7 @@ module.exports = { DismissibleValetudoEvent: require("./DismissibleValetudoEvent"), DustBinFullValetudoEvent: require("./DustBinFullValetudoEvent"), ErrorStateValetudoEvent: require("./ErrorStateValetudoEvent"), + MissingResourceValetudoEvent: require("./MissingResourceValetudoEvent"), MopAttachmentReminderValetudoEvent: require("./MopAttachmentReminderValetudoEvent"), - PendingMapChangeValetudoEvent: require("./PendingMapChangeValetudoEvent") + PendingMapChangeValetudoEvent: require("./PendingMapChangeValetudoEvent"), }; diff --git a/backend/package.json b/backend/package.json index a68dff48..b69cd14c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "author": "", "dependencies": { "@destinationstransfers/ntp": "2.0.0", + "aedes": "0.51.3", "ajv": "8.17.1", "async-mqtt": "2.6.3", "axios": "0.27.2", @@ -50,6 +51,7 @@ "mqtt": "5.10.1", "nested-object-assign": "1.0.4", "nested-property": "4.0.0", + "node-forge": "1.3.1", "openapi-validator-middleware": "3.2.6", "semaphore": "1.1.0", "swagger-ui-express": "5.0.1", diff --git a/backend/test/lib/msmart/MSmartPacket_spec.js b/backend/test/lib/msmart/MSmartPacket_spec.js new file mode 100644 index 00000000..d1d6b401 --- /dev/null +++ b/backend/test/lib/msmart/MSmartPacket_spec.js @@ -0,0 +1,116 @@ +const MSmartPacket = require("../../../lib/msmart/MSmartPacket"); +const should = require("should"); + +should.config.checkProtoEql = false; + +describe("MSmartPacket", function () { + + describe("Static FROM_BYTES Parser", function() { + it("Should parse a short, valid B8 packet correctly", function() { + const hexString = "aa0eb800000000000002aa0122006b"; + const buffer = Buffer.from(hexString, "hex"); + + const packet = MSmartPacket.FROM_BYTES(buffer); + + packet.should.be.an.instanceOf(MSmartPacket); + packet.deviceType.should.equal(0xB8); + packet.messageId.should.equal(0x00); + packet.protocolVersion.should.equal(0x00); + packet.deviceProtocolVersion.should.equal(0x00); + packet.messageType.should.equal(0x02); + packet.payload.should.deepEqual(Buffer.from("aa012200", "hex")); + }); + + it("Should parse locate command correctly", function() { + const hexString = "aa0db800000000000003aa01018c"; + const buffer = Buffer.from(hexString, "hex"); + + const packet = MSmartPacket.FROM_BYTES(buffer); + + packet.should.be.an.instanceOf(MSmartPacket); + packet.deviceType.should.equal(0xB8); + packet.messageId.should.equal(0x00); + packet.protocolVersion.should.equal(0x00); + packet.deviceProtocolVersion.should.equal(0x00); + packet.messageType.should.equal(0x03); + packet.payload.should.deepEqual(Buffer.from("aa0101", "hex")); + }); + + it("Should parse a long, valid B8 packet correctly", function() { + const hexString = "aa51b800000000000004aa0101120002ff00010078000062000100000000000100000000740001000400002e0000010000000001010020000000000f02003518000297010300000000300021000000030836"; + const payloadHexString = "aa0101120002ff00010078000062000100000000000100000000740001000400002e0000010000000001010020000000000f020035180002970103000000003000210000000308"; + const buffer = Buffer.from(hexString, "hex"); + + const packet = MSmartPacket.FROM_BYTES(buffer); + + packet.should.be.an.instanceOf(MSmartPacket); + packet.deviceType.should.equal(0xB8); + packet.messageId.should.equal(0x00); + packet.protocolVersion.should.equal(0x00); + packet.deviceProtocolVersion.should.equal(0x00); + packet.messageType.should.equal(0x04); + packet.payload.should.deepEqual(Buffer.from(payloadHexString, "hex")); + }); + + it("Should throw an error for a packet that is too short", function() { + const invalidBuffer = Buffer.from("aa0102030405", "hex"); + + (() => { + MSmartPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Packet too short/); + }); + + it("Should throw an error for a packet with an invalid magic byte", function() { + const invalidBuffer = Buffer.from("bb0eb800000000000002aa0122006b", "hex"); + + (() => { + MSmartPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Invalid Magic Byte/); + }); + + it("Should throw an error for a packet with a length mismatch", function() { + // Length byte is 0x0D (13), but actual length is 15 bytes (so length byte should be 0x0E) + const invalidBuffer = Buffer.from("aa0db800000000000002aa0122006b", "hex"); + + (() => { + MSmartPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Length mismatch/); + }); + + it("Should throw an error for a packet with an invalid checksum", function() { + // Valid checksum is 0x6b, we use 0x00 + const invalidBuffer = Buffer.from("aa0eb800000000000002aa01220000", "hex"); + + (() => { + MSmartPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Checksum mismatch/); + }); + }); + + describe("Instance toBytes Serializer", function() { + it("Should correctly serialize a packet that can be parsed again (round-trip)", function() { + // 1. Create a packet from known data + const originalPacket = new MSmartPacket({ + deviceType: 0xAC, + messageId: 0x01, + protocolVersion: 0x00, + deviceProtocolVersion: 0x01, + messageType: MSmartPacket.MESSAGE_TYPE.SETTING, + payload: Buffer.from([0xB0, 0x01, 0x01]) + }); + + // 2. Serialize it to a buffer + const buffer = originalPacket.toBytes(); + + // 3. Check if the buffer matches a known-good hex string + const expectedHexString = "aa0dac00000001000102b0010191"; + buffer.toString("hex").should.equal(expectedHexString); + + // 4. Parse it back + const parsedPacket = MSmartPacket.FROM_BYTES(buffer); + + // 5. Ensure the parsed packet is identical to the original + parsedPacket.should.deepEqual(originalPacket); + }); + }); +}); diff --git a/backend/test/lib/msmart/MSmartProvisioningPacket_spec.js b/backend/test/lib/msmart/MSmartProvisioningPacket_spec.js new file mode 100644 index 00000000..6143bf0c --- /dev/null +++ b/backend/test/lib/msmart/MSmartProvisioningPacket_spec.js @@ -0,0 +1,107 @@ +const MSmartProvisioningPacket = require("../../../lib/msmart/MSmartProvisioningPacket"); +const should = require("should"); + +should.config.checkProtoEql = false; + +describe("MSmartProvisioningPacket", function () { + + describe("Static FROM_BYTES Parser", function() { + it("Should parse a valid 'Get UUID' response correctly", function() { + const hexString = "ee0180d500183138333239353934333937373939313337353561616161614c"; + const buffer = Buffer.from(hexString, "hex"); + + const packet = MSmartProvisioningPacket.FROM_BYTES(buffer); + + packet.should.be.an.instanceOf(MSmartProvisioningPacket); + packet.commandId.should.equal(MSmartProvisioningPacket.RESPONSE_IDS.CMD_UUID_INFO); + packet.payload.should.deepEqual(Buffer.from("313833323935393433393737393931333735356161616161", "hex")); + }); + + it("Should parse a valid 'Acknowledge Provisioning' response correctly", function() { + const hexString = "ee0180d000010051"; + const buffer = Buffer.from(hexString, "hex"); + + const packet = MSmartProvisioningPacket.FROM_BYTES(buffer); + + packet.should.be.an.instanceOf(MSmartProvisioningPacket); + packet.commandId.should.equal(MSmartProvisioningPacket.RESPONSE_IDS.CMD_ALL_INFO); + packet.payload.should.deepEqual(Buffer.from("00", "hex")); + }); + + it("Should throw an error for a packet that is too short", function() { + const invalidBuffer = Buffer.from("ee0100d5", "hex"); + + (() => { + MSmartProvisioningPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Packet too short/); + }); + + it("Should throw an error for a packet with an invalid magic header", function() { + const invalidBuffer = Buffer.from("ff0180d000010051", "hex"); + + (() => { + MSmartProvisioningPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Invalid Magic Header/); + }); + + it("Should throw an error for a packet with a length mismatch", function() { + // Length field is 0x0002, but actual payload length is 1 byte + const invalidBuffer = Buffer.from("ee0180d000020052", "hex"); + + (() => { + MSmartProvisioningPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Payload length mismatch/); + }); + + it("Should throw an error for a packet with an invalid checksum", function() { + // Valid checksum is 0x51, we use 0x00 + const invalidBuffer = Buffer.from("ee0180d000010000", "hex"); + + (() => { + MSmartProvisioningPacket.FROM_BYTES(invalidBuffer); + }).should.throw(/Checksum mismatch/); + }); + }); + + describe("Instance toBytes Serializer", function() { + it("Should correctly serialize a packet that can be parsed again (round-trip)", function() { + // 1. Create a packet for a "Get UUID" command + const originalPacket = new MSmartProvisioningPacket({ + commandId: MSmartProvisioningPacket.COMMAND_IDS.CMD_UUID_INFO, + payload: Buffer.from([0x00]) + }); + + // 2. Serialize it to a buffer + const buffer = originalPacket.toBytes(); + + // 3. Check if the buffer matches the known-good hex string (checksum may vary based on exact implementation details but should be consistent) + const expectedHexString = "ee0100d5000100d6"; + buffer.toString("hex").should.equal(expectedHexString); + + // 4. Parse it back + const parsedPacket = MSmartProvisioningPacket.FROM_BYTES(buffer); + + // 5. Ensure the parsed packet is identical to the original + parsedPacket.should.deepEqual(originalPacket); + }); + + it("Should correctly serialize a packet with a larger payload (round-trip)", function() { + const payload = Buffer.from("SSID\nPASSWORD\nhttps://server.com/\nGMT+00:00\nTOKEN\n-60\nDE"); + + // 1. Create a packet for a "Send All Info" command + const originalPacket = new MSmartProvisioningPacket({ + commandId: MSmartProvisioningPacket.COMMAND_IDS.CMD_ALL_INFO, + payload: payload + }); + + // 2. Serialize it to a buffer + const buffer = originalPacket.toBytes(); + + // 3. Parse it back + const parsedPacket = MSmartProvisioningPacket.FROM_BYTES(buffer); + + // 4. Ensure the parsed packet is identical to the original + parsedPacket.should.deepEqual(originalPacket); + }); + }); +}); diff --git a/docs/_pages/general/img/robots/eureka/eureka_j15pu.jpg b/docs/_pages/general/img/robots/eureka/eureka_j15pu.jpg new file mode 100644 index 00000000..db9e3bbb Binary files /dev/null and b/docs/_pages/general/img/robots/eureka/eureka_j15pu.jpg differ diff --git a/docs/_pages/general/supported-robots.md b/docs/_pages/general/supported-robots.md index f58f06b1..1d8bae84 100644 --- a/docs/_pages/general/supported-robots.md +++ b/docs/_pages/general/supported-robots.md @@ -103,15 +103,17 @@ You can use Ctrl + F to look for your model of robot.
5. [Viomi](#viomi) 1. [V6](#viomi_v6) 2. [SE](#viomi_se) -6. [Cecotec](#cecotec) +6. [Eureka](#eureka) + 1. [J15 Pro Ultra](#eureka_j15pu) +7. [Cecotec](#cecotec) 1. [Conga 3290](#conga_3290) 2. [Conga 3790](#conga_3790) -7. [Proscenic](#proscenic) +8. [Proscenic](#proscenic) 1. [M6 Pro](#proscenic_m6pro) -8. [Commodore](#commodore) +9. [Commodore](#commodore) 1. [CVR 200](#commodore_cvr200) -9. [IKOHS](#ikohs) - 1. [Netbot LS22](#ikohs_ls22) +10. [IKOHS](#ikohs) + 1. [Netbot LS22](#ikohs_ls22) ## Xiaomi @@ -1024,6 +1026,43 @@ It might be required to remove the battery but that can be done without touching - [ADB](https://github.com/Hypfer/valetudo-crl200s-root) +## Eureka + +Eureka is a brand of Midea. + + +### Eureka J15 Pro Ultra + + + +The Eureka J15 Pro Ultra might be similar to the J15 Ultra, but frankly I currently (2025-08-29) have no idea. + +#### Comments + +**WARNING**
+ +At the time of writing (2025-08-29), this robot is completely new on this list.
+I've been playing around with it for a few months, but the sample size is 1 and I have not used it extensively. + +Because of that, there is also no rooting guide yet, as I still lack both data and experience to come up with one that matches the quality of the other guides. + +**You should very likely not be buying this if you just want a robot that works.**
+**The experience will be worse for equal or more money than other supported robots.** + +For now, there is a Telegram Group for it, so if you have one or are interested, you can join that and we'll see if/how you can get it rooted (with no commitment to timeframes, suitability, success, usability, reliability, functionality, etc.).
+Anything beyond that I shall figure out as I go (or not, if I should lose interest) + + +Valetudo on Midea Telegram Group + +#### Details + +**Valetudo Binary**: `aarch64` +**Secure Boot**: `no` + + ## Cecotec Conga is a brand that uses existing robot designs with a slightly customized cloud.
diff --git a/frontend/src/components/ValetudoEventControls.tsx b/frontend/src/components/ValetudoEventControls.tsx index 5a7ddc89..d8dd089c 100644 --- a/frontend/src/components/ValetudoEventControls.tsx +++ b/frontend/src/components/ValetudoEventControls.tsx @@ -169,6 +169,36 @@ const CreateDismissableEventControl = (message: string) : FunctionComponent = + ({event, interact}) => { + const color = event.processed ? "textSecondary" : "textPrimary"; + const textStyle = event.processed ? {textDecoration: "line-through"} : {}; + + return ( + + + + + {event.message!} + + + + + ); + }; + const UnknownEventControl: FunctionComponent = ({event}) => { return ( @@ -184,5 +214,6 @@ export const eventControls: Record=8.9" } }, + "node_modules/aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "dependencies": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + } + }, + "node_modules/aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "dependencies": { + "mqtt-packet": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-packet/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/aedes-packet/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/aedes-packet/node_modules/mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/aedes-packet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "dependencies": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10128,6 +10235,26 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -10136,6 +10263,11 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==" + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -11438,6 +11570,47 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -14866,6 +15039,26 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mqemitter/node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "engines": { + "node": ">= 16" + } + }, "node_modules/mqtt": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.10.1.tgz", @@ -17287,6 +17480,14 @@ "teleport": ">=0.2.0" } }, + "node_modules/qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==", + "engines": { + "node": ">= 14" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -18363,6 +18564,14 @@ "node": ">=8" } }, + "node_modules/retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "dependencies": { + "worker-timers": "^7.0.75" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -21044,6 +21253,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -25795,6 +26009,83 @@ "regex-parser": "^2.2.11" } }, + "aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "requires": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + } + }, + "aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "requires": { + "mqtt-packet": "^7.0.0" + }, + "dependencies": { + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "requires": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "requires": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + } + }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -29066,6 +29357,23 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, + "fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "requires": { + "reusify": "^1.0.0" + } + }, + "fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "requires": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -29074,6 +29382,11 @@ "reusify": "^1.0.4" } }, + "fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==" + }, "faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -30012,6 +30325,32 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" }, + "hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "requires": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -32407,6 +32746,22 @@ } } }, + "mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "requires": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "dependencies": { + "qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==" + } + } + }, "mqtt": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.10.1.tgz", @@ -33959,6 +34314,11 @@ "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==" }, + "qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==" + }, "qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -34755,6 +35115,14 @@ "signal-exit": "^3.0.2" } }, + "retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "requires": { + "worker-timers": "^7.0.75" + } + }, "retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -36737,6 +37105,11 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" }, + "uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==" + }, "v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -36768,6 +37141,7 @@ "@types/semaphore": "1.1.4", "@types/uuid": "10.0.0", "@yao-pkg/pkg": "6.6.0", + "aedes": "0.51.3", "ajv": "8.17.1", "async-mqtt": "2.6.3", "axios": "0.27.2", @@ -36787,6 +37161,7 @@ "mqtt": "5.10.1", "nested-object-assign": "1.0.4", "nested-property": "4.0.0", + "node-forge": "1.3.1", "openapi-validator-middleware": "3.2.6", "semaphore": "1.1.0", "should": "13.2.3",