feat(vendor.midea): Midea

This commit is contained in:
Sören Beye 2025-08-29 18:31:45 +02:00
parent eb213328de
commit ae1ac479d7
60 changed files with 6187 additions and 9 deletions

View File

@ -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"]);
}

View File

@ -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 <typeId>
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 <typeId>
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 <typeId>
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;

View File

@ -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
};

View File

@ -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.<string, {
* timeout_id?: NodeJS.Timeout,
* onTimeoutCallback: () => 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://<cdn_url>/m7-voice-full/750Y000R-561-18-<somestring>.zip",
"md5": "052e67dd8843cdfc8f89fc130ea21db5",
"msg": "OK",
"nonce": "<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":"<nonce>",
"data":{
"id":91,
"sn8":"750Y000R",
"moduleBranchCode":63,
"type":0,
"name":"V2.0.0.20240927_rc",
"md5":"9478bb7890c6cef753e484804c117e26",
"url":"https://<cdn_url>/<filename>.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<import("./MSmartPacket")>}
*/
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<import("./MSmartPacket")>}
*/
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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,8 @@
/**
* @abstract
*/
class MSmartDTO {
}
module.exports = MSmartDTO;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,19 @@
const MSmartDTO = require("./MSmartDTO");
class MSmartMapListDTO extends MSmartDTO {
/**
* @param {object} data
* @param {number} data.currentMapId
* @param {Array<number>} data.savedMapIds
*/
constructor(data) {
super();
this.currentMapId = data.currentMapId;
this.savedMapIds = data.savedMapIds;
Object.freeze(this);
}
}
module.exports = MSmartMapListDTO;

View File

@ -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;

View File

@ -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"),
};

View File

@ -0,0 +1,17 @@
/*
*/

View File

@ -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
);

View File

@ -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
};

View File

@ -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;

View File

@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<Buffer>}
*/
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 (?<id>\d+) (?<left>\d+) (?<bottom>\d+) (?<right>\d+) (?<top>\d+) (?<payload>[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;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaAutoEmptyDockAutoEmptyControlCapability extends AutoEmptyDockAutoEmptyControlCapability {
/**
*
* @returns {Promise<boolean>}
*/
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<void>}
*/
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<void>}
*/
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;

View File

@ -0,0 +1,28 @@
const AutoEmptyDockManualTriggerCapability = require("../../../core/capabilities/AutoEmptyDockManualTriggerCapability");
const MSmartConst = require("../../../msmart/MSmartConst");
const MSmartPacket = require("../../../msmart/MSmartPacket");
/**
* @extends AutoEmptyDockManualTriggerCapability<import("../MideaValetudoRobot")>
*/
class MideaAutoEmptyDockManualTriggerCapability extends AutoEmptyDockManualTriggerCapability {
/**
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaCameraLightControlCapability extends CameraLightControlCapability {
/**
* @returns {Promise<boolean>}
*/
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<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaCollisionAvoidantNavigationControlCapability extends CollisionAvoidantNavigationControlCapability {
/**
* @returns {Promise<boolean>}
*/
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<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaCombinedVirtualRestrictionsCapability extends CombinedVirtualRestrictionsCapability {
/**
* @param {ValetudoVirtualRestrictions} virtualRestrictions
* @returns {Promise<void>}
*/
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;

View File

@ -0,0 +1,49 @@
const CurrentStatisticsCapability = require("../../../core/capabilities/CurrentStatisticsCapability");
const ValetudoDataPoint = require("../../../entities/core/ValetudoDataPoint");
/**
* @extends CurrentStatisticsCapability<import("../MideaValetudoRobot")>
*/
class MideaCurrentStatisticsCapability extends CurrentStatisticsCapability {
/**
* @param {object} options
* @param {import("../MideaValetudoRobot")} options.robot
*/
constructor(options) {
super(options);
this.currentStatistics = {
time: undefined,
area: undefined
};
}
/**
* @return {Promise<Array<ValetudoDataPoint>>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaDoNotDisturbCapability extends DoNotDisturbCapability {
/**
* @returns {Promise<ValetudoDNDConfiguration>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaFanSpeedControlCapability extends FanSpeedControlCapability {
/**
* @param {string} preset
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaKeyLockCapability extends KeyLockCapability {
/**
*
* @returns {Promise<boolean>}
*/
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<void>}
*/
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<void>}
*/
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;

View File

@ -0,0 +1,22 @@
const LocateCapability = require("../../../core/capabilities/LocateCapability");
const MSmartConst = require("../../../msmart/MSmartConst");
const MSmartPacket = require("../../../msmart/MSmartPacket");
/**
* @extends LocateCapability<import("../MideaValetudoRobot")>
*/
class MideaLocateCapability extends LocateCapability {
/**
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
async disableManualControl() {
if (this.active) {
clearTimeout(this.keepAliveTimeout);
this.keepAliveTimeout = undefined;
this.active = false;
await this.robot.pollState();
}
}
/**
* @returns {Promise<boolean>}
*/
async manualControlActive() {
return this.active;
}
/**
* @param {import("../../../core/capabilities/ManualControlCapability").ValetudoManualControlMovementCommandType} movementCommand
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMapResetCapability extends MapResetCapability {
/**
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMapSegmentEditCapability extends MapSegmentEditCapability {
/**
* @param {import("../../../entities/core/ValetudoMapSegment")} segmentA
* @param {import("../../../entities/core/ValetudoMapSegment")} segmentB
* @returns {Promise<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMapSegmentationCapability extends MapSegmentationCapability {
/**
* @param {Array<import("../../../entities/core/ValetudoMapSegment")>} segments
* @param {object} [options]
* @param {number} [options.iterations]
* @param {boolean} [options.customOrder]
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMappingPassCapability extends MappingPassCapability {
/**
* @returns {Promise<void>}
*/
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;

View File

@ -0,0 +1,46 @@
const MopDockCleanManualTriggerCapability = require("../../../core/capabilities/MopDockCleanManualTriggerCapability");
const MSmartConst = require("../../../msmart/MSmartConst");
const MSmartPacket = require("../../../msmart/MSmartPacket");
/**
* @extends MopDockCleanManualTriggerCapability<import("../MideaValetudoRobot")>
*/
class MideaMopDockCleanManualTriggerCapability extends MopDockCleanManualTriggerCapability {
/**
* @returns {Promise<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMopDockDryManualTriggerCapability extends MopDockDryManualTriggerCapability {
/**
* @returns {Promise<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaMopExtensionControlCapability extends MopExtensionControlCapability {
/**
* @returns {Promise<boolean>}
*/
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<void>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaOperationModeControlCapability extends OperationModeControlCapability {
/**
* @param {string} preset
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaSpeakerTestCapability extends SpeakerTestCapability {
/**
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaSpeakerVolumeControlCapability extends SpeakerVolumeControlCapability {
/**
* Returns the current voice volume as percentage
*
* @returns {Promise<number>}
*/
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<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaWaterUsageControlCapability extends WaterUsageControlCapability {
/**
* @param {string} preset
* @returns {Promise<void>}
*/
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;

View File

@ -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<import("../MideaValetudoRobot")>
*/
class MideaWifiConfigurationCapability extends LinuxWifiConfigurationCapability {
/**
* @param {import("../../../entities/core/ValetudoWifiConfiguration")} wifiConfig
* @returns {Promise<void>}
*/
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<void>}
*/
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;

View File

@ -0,0 +1,65 @@
const MSmartConst = require("../../../msmart/MSmartConst");
const MSmartPacket = require("../../../msmart/MSmartPacket");
const ZoneCleaningCapability = require("../../../core/capabilities/ZoneCleaningCapability");
/**
* @extends ZoneCleaningCapability<import("../MideaValetudoRobot")>
*/
class MideaZoneCleaningCapability extends ZoneCleaningCapability {
/**
* @param {object} options
* @param {Array<import("../../../entities/core/ValetudoZone")>} options.zones
* @param {number} [options.iterations]
* @returns {Promise<void>}
*/
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;

View File

@ -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"),
};

View File

@ -0,0 +1,3 @@
module.exports = {
"MideaJ15ValetudoRobot": require("./MideaJ15ValetudoRobot")
};

View File

@ -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;

View File

@ -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;

View File

@ -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"),
};

View File

@ -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",

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -103,15 +103,17 @@ You can use Ctrl + F to look for your model of robot.<br/>
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<a id="xiaomi"></a>
@ -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<a id="eureka"></a>
Eureka is a brand of Midea.
<div class="alert alert-warning" role="alert">
As of now (2025-08-29), support for these is experimental, beta, barely tested and full of dragons.
</div>
### Eureka J15 Pro Ultra<a id="eureka_j15pu"></a>
<img src="./img/robots/eureka/eureka_j15pu.jpg" width="1300" height="325"/>
The Eureka J15 Pro Ultra might be similar to the J15 Ultra, but frankly I currently (2025-08-29) have no idea.
#### Comments
**WARNING**<br/>
At the time of writing (2025-08-29), this robot is completely new on this list.<br/>
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.**<br/>
**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.).<br/>
Anything beyond that I shall figure out as I go (or not, if I should lose interest)
<a href="https://t.me/+F00lFE1NVUc2NTAy" data-si="34097f03527c7c0375540b07132a652161373c7c0c2f29446177627c62615d705318331a1b1c0b7a">Valetudo on Midea Telegram Group</a>
#### Details
**Valetudo Binary**: `aarch64`
**Secure Boot**: `no`
## Cecotec<a id="cecotec"></a>
Conga is a brand that uses existing robot designs with a slightly customized cloud.<br/>

View File

@ -169,6 +169,36 @@ const CreateDismissableEventControl = (message: string) : FunctionComponent<Vale
};
};
const MissingResourceEventControl: FunctionComponent<ValetudoEventRenderProps> =
({event, interact}) => {
const color = event.processed ? "textSecondary" : "textPrimary";
const textStyle = event.processed ? {textDecoration: "line-through"} : {};
return (
<EventRow>
<Stack>
<EventTimestamp timestamp={event.timestamp}/>
<Typography color={color} style={textStyle} sx={{mr: 1}}>
{event.message!}
</Typography>
</Stack>
<Button
size="small"
variant={"contained"}
disabled={event.processed}
onClick={() => {
interact({
interaction: "ok"
});
}}
color="warning"
>
Dismiss
</Button>
</EventRow>
);
};
const UnknownEventControl: FunctionComponent<ValetudoEventRenderProps> =
({event}) => {
return (
@ -184,5 +214,6 @@ export const eventControls: Record<string, React.ComponentType<ValetudoEventRend
PendingMapChangeValetudoEvent: PendingMapChangeEventControl,
DustBinFullValetudoEvent: CreateDismissableEventControl("The dust bin is full. Please empty it."),
MopAttachmentReminderValetudoEvent: CreateDismissableEventControl("The mop is still attached to the robot."),
MissingResourceValetudoEvent: MissingResourceEventControl,
Default: UnknownEventControl,
};

375
package-lock.json generated
View File

@ -39,6 +39,7 @@
"license": "Apache-2.0",
"dependencies": {
"@destinationstransfers/ntp": "2.0.0",
"aedes": "0.51.3",
"ajv": "8.17.1",
"async-mqtt": "2.6.3",
"axios": "0.27.2",
@ -56,6 +57,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",
@ -5517,6 +5519,111 @@
"node": ">=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",