homebridge-roborock-matter 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +13 -0
- package/CHANGELOG.md +469 -0
- package/LICENSE +23 -0
- package/README.md +45 -0
- package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
- package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
- package/config.schema.json +118 -0
- package/dist/crypto.js +65 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live_message.js +25 -0
- package/dist/live_message.js.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/matter_vacuum_accessory.js +1877 -0
- package/dist/matter_vacuum_accessory.js.map +1 -0
- package/dist/platform.js +441 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +8 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/index.js +741 -0
- package/dist/ui/index.js.map +1 -0
- package/docs/matter-rvc-updating-homebridge-report.md +120 -0
- package/homebridge-roborock-vacuum.code-workspace +7 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/index.js +1276 -0
- package/homebridge-ui/public/styles.css +611 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +91 -0
- package/roborockLib/i18n/de/translations.json +188 -0
- package/roborockLib/i18n/en/translations.json +208 -0
- package/roborockLib/i18n/es/translations.json +188 -0
- package/roborockLib/i18n/fr/translations.json +188 -0
- package/roborockLib/i18n/it/translations.json +188 -0
- package/roborockLib/i18n/nl/translations.json +188 -0
- package/roborockLib/i18n/pl/translations.json +188 -0
- package/roborockLib/i18n/pt/translations.json +188 -0
- package/roborockLib/i18n/ru/translations.json +188 -0
- package/roborockLib/i18n/uk/translations.json +188 -0
- package/roborockLib/i18n/zh-cn/translations.json +188 -0
- package/roborockLib/lib/RRMapParser.js +459 -0
- package/roborockLib/lib/b01Q7Adapter.js +468 -0
- package/roborockLib/lib/deviceFeatures.js +1349 -0
- package/roborockLib/lib/localConnector.js +629 -0
- package/roborockLib/lib/map/map.html +137 -0
- package/roborockLib/lib/map/zones.js +851 -0
- package/roborockLib/lib/message.js +402 -0
- package/roborockLib/lib/messageQueueHandler.js +411 -0
- package/roborockLib/lib/roborockAuth.js +178 -0
- package/roborockLib/lib/roborockCrypto.js +55 -0
- package/roborockLib/lib/roborockPackageHelper.js +142 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
- package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
- package/roborockLib/lib/vacuum.js +992 -0
- package/roborockLib/roborockAPI.js +3635 -0
- package/tsconfig.roborockLib.json +17 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const JSZip = require("jszip");
|
|
3
|
+
|
|
4
|
+
class roborockPackageHelper {
|
|
5
|
+
constructor(adapter) {
|
|
6
|
+
this.adapter = adapter;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async updateProduct(loginApi, productID, duid) {
|
|
10
|
+
const products = await loginApi.get("api/v3/product");
|
|
11
|
+
const list = products.data.data.categoryDetailList;
|
|
12
|
+
|
|
13
|
+
let appPluginRequest = {};
|
|
14
|
+
appPluginRequest = {
|
|
15
|
+
apilevel: 99999, // sniffed 10016 and 10019 from the app but it's subject to change so we use a high number
|
|
16
|
+
productids: [],
|
|
17
|
+
type: 2,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const vacuumIDs = {};
|
|
21
|
+
for (const array in list) {
|
|
22
|
+
for (const product in list[array]["productList"]) {
|
|
23
|
+
const vacuum = list[array]["productList"][product];
|
|
24
|
+
const productIDinPackage = this.findProductIDinPackage(
|
|
25
|
+
productID,
|
|
26
|
+
vacuum
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (productIDinPackage) {
|
|
30
|
+
appPluginRequest.productids.push(vacuum.id);
|
|
31
|
+
vacuumIDs[vacuum.id] = vacuum.model;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const packageData = await loginApi.post(
|
|
37
|
+
"api/v1/appplugin",
|
|
38
|
+
appPluginRequest
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const packages = packageData.data.data;
|
|
42
|
+
for (const rr_package in packages) {
|
|
43
|
+
const vacuum = vacuumIDs[packages[rr_package].productid];
|
|
44
|
+
const zipUrl = packages[rr_package].url;
|
|
45
|
+
const version = packages[rr_package].version;
|
|
46
|
+
const imagePath = `./images/products/${vacuum}`;
|
|
47
|
+
const objectPath = `Devices.${duid}.images`;
|
|
48
|
+
const versionFilePath = imagePath + "/version";
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync("./lib/roborockPackage/"))
|
|
51
|
+
fs.mkdirSync("./lib/roborockPackage/");
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Create missing vacuum folders
|
|
55
|
+
if (!fs.existsSync(`./images/products/`))
|
|
56
|
+
fs.mkdirSync(`./images/products/`);
|
|
57
|
+
if (!fs.existsSync(imagePath)) fs.mkdirSync(imagePath);
|
|
58
|
+
|
|
59
|
+
this.adapter.setObjectAsync(objectPath, {
|
|
60
|
+
type: "folder",
|
|
61
|
+
common: {
|
|
62
|
+
name: "images",
|
|
63
|
+
},
|
|
64
|
+
native: {},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(versionFilePath)) {
|
|
68
|
+
fs.writeFileSync(versionFilePath, "0");
|
|
69
|
+
}
|
|
70
|
+
const currentVersion = fs.readFileSync(versionFilePath, "utf8");
|
|
71
|
+
|
|
72
|
+
if (packages[rr_package].version > currentVersion) {
|
|
73
|
+
this.adapter.log.debug(
|
|
74
|
+
`New version roborock package available: ${version}`
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const response = await loginApi.get(zipUrl, {
|
|
78
|
+
responseType: "arraybuffer",
|
|
79
|
+
});
|
|
80
|
+
const zip = await JSZip.loadAsync(response.data);
|
|
81
|
+
const folder = zip.folder("drawable-mdpi");
|
|
82
|
+
|
|
83
|
+
if (folder) {
|
|
84
|
+
let i = 0;
|
|
85
|
+
folder.forEach(async (relativePath, file) => {
|
|
86
|
+
if (!file.dir) {
|
|
87
|
+
const fileContent = await file.async("nodebuffer");
|
|
88
|
+
if (fileContent) {
|
|
89
|
+
fs.writeFileSync(`${imagePath}/${relativePath}`, fileContent);
|
|
90
|
+
|
|
91
|
+
const fileContentBase64 = fileContent.toString("base64");
|
|
92
|
+
const fileNameWithoutExtension = relativePath.slice(
|
|
93
|
+
0,
|
|
94
|
+
relativePath.lastIndexOf(".")
|
|
95
|
+
);
|
|
96
|
+
const formattedNumber = i.toString().padStart(3, "0"); // "001", "002", "003", etc.
|
|
97
|
+
|
|
98
|
+
this.adapter.setObjectAsync(
|
|
99
|
+
`${objectPath}.${formattedNumber}`,
|
|
100
|
+
{
|
|
101
|
+
type: "state",
|
|
102
|
+
common: {
|
|
103
|
+
name: fileNameWithoutExtension,
|
|
104
|
+
type: "string",
|
|
105
|
+
role: "value",
|
|
106
|
+
read: true,
|
|
107
|
+
write: false,
|
|
108
|
+
},
|
|
109
|
+
native: {},
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
this.adapter.setStateAsync(
|
|
113
|
+
`${objectPath}.${formattedNumber}`,
|
|
114
|
+
{ val: fileContentBase64, ack: true }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(versionFilePath, version.toString());
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
this.adapter.catchError(
|
|
126
|
+
err,
|
|
127
|
+
"roborockPackageHelper.updateProduct",
|
|
128
|
+
null,
|
|
129
|
+
productID
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
findProductIDinPackage(productID, list) {
|
|
136
|
+
if (list.model === productID) {
|
|
137
|
+
return list.id;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exports.roborockPackageHelper = roborockPackageHelper;
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const mqtt = require("mqtt");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const Parser = require("binary-parser").Parser;
|
|
6
|
+
const zlib = require("zlib");
|
|
7
|
+
const roborockCrypto = require("./roborockCrypto");
|
|
8
|
+
|
|
9
|
+
const PHOTO_MAGIC = "ROBOROCK";
|
|
10
|
+
const PHOTO_HEADER_MIN_LENGTH = 9;
|
|
11
|
+
const PROTOCOL_301_HEADER_LENGTH = 24;
|
|
12
|
+
|
|
13
|
+
const protocol301Parser = new Parser()
|
|
14
|
+
.endianess("little")
|
|
15
|
+
.string("endpoint", {
|
|
16
|
+
length: 15,
|
|
17
|
+
stripNull: true,
|
|
18
|
+
})
|
|
19
|
+
.uint8("unknown1")
|
|
20
|
+
.uint16("id")
|
|
21
|
+
.buffer("unknown2", {
|
|
22
|
+
length: 6,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const photoParser = new Parser()
|
|
26
|
+
.endianess("little")
|
|
27
|
+
.string("roborock", {
|
|
28
|
+
length: 8,
|
|
29
|
+
stripNull: true,
|
|
30
|
+
})
|
|
31
|
+
.uint8("id");
|
|
32
|
+
|
|
33
|
+
let mqttUser;
|
|
34
|
+
let mqttPassword;
|
|
35
|
+
let client;
|
|
36
|
+
let endpoint;
|
|
37
|
+
let rriot;
|
|
38
|
+
|
|
39
|
+
let photoGzipChunks = [];
|
|
40
|
+
let photoChunkID = 0;
|
|
41
|
+
|
|
42
|
+
function payloadStartsWith(payload, value) {
|
|
43
|
+
return (
|
|
44
|
+
Buffer.isBuffer(payload) &&
|
|
45
|
+
payload.length >= value.length &&
|
|
46
|
+
payload.subarray(0, value.length).toString("utf8") === value
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parsePhotoPayload(payload) {
|
|
51
|
+
if (
|
|
52
|
+
!payloadStartsWith(payload, PHOTO_MAGIC) ||
|
|
53
|
+
payload.length < PHOTO_HEADER_MIN_LENGTH
|
|
54
|
+
) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return photoParser.parse(payload);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseProtocol301Header(payload) {
|
|
62
|
+
if (
|
|
63
|
+
!Buffer.isBuffer(payload) ||
|
|
64
|
+
payload.length < PROTOCOL_301_HEADER_LENGTH
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return protocol301Parser.parse(
|
|
70
|
+
payload.subarray(0, PROTOCOL_301_HEADER_LENGTH)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class roborock_mqtt_connector {
|
|
75
|
+
constructor(adapter) {
|
|
76
|
+
this.adapter = adapter;
|
|
77
|
+
|
|
78
|
+
this.connected = false;
|
|
79
|
+
|
|
80
|
+
this.keys = roborockCrypto.generateRsaKeyPair();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async initUser(userdata) {
|
|
84
|
+
rriot = userdata.rriot;
|
|
85
|
+
|
|
86
|
+
endpoint = roborockCrypto
|
|
87
|
+
.md5bin(rriot.k)
|
|
88
|
+
.subarray(8, 14)
|
|
89
|
+
.toString("base64"); // Could be a random but rather static string. The app generates it on first run.
|
|
90
|
+
mqttUser = roborockCrypto.md5hex(rriot.u + ":" + rriot.k).substring(2, 10);
|
|
91
|
+
mqttPassword = roborockCrypto.md5hex(rriot.s + ":" + rriot.k).substring(16);
|
|
92
|
+
client = mqtt.connect(rriot.r.m, {
|
|
93
|
+
clientId: mqttUser,
|
|
94
|
+
username: mqttUser,
|
|
95
|
+
password: mqttPassword,
|
|
96
|
+
keepalive: 30,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async initMQTT_Subscribe() {
|
|
101
|
+
const timeout = setTimeout(async () => {
|
|
102
|
+
this.adapter.restart();
|
|
103
|
+
}, 30000);
|
|
104
|
+
|
|
105
|
+
await client.on("connect", (result) => {
|
|
106
|
+
if (typeof result != "undefined") {
|
|
107
|
+
client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
|
|
108
|
+
if (err) {
|
|
109
|
+
this.adapter.catchError(
|
|
110
|
+
`Failed to subscribe to Roborock MQTT Server! Error: ${err}, granted: ${JSON.stringify(granted)}`,
|
|
111
|
+
`client.on("connect")`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
|
|
117
|
+
this.connected = true;
|
|
118
|
+
}
|
|
119
|
+
this.adapter.log.debug(
|
|
120
|
+
`MQTT connection connected ${JSON.stringify(result)}.`
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await client.on("error", (result) => {
|
|
125
|
+
this.adapter.catchError(
|
|
126
|
+
`MQTT connection error: ${result}`,
|
|
127
|
+
`client.on("error")`
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
this.connected = false;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await client.on("close", () => {
|
|
134
|
+
this.adapter.log.info(`MQTT connection close.`);
|
|
135
|
+
|
|
136
|
+
this.connected = false;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await client.on("reconnect", (error) => {
|
|
140
|
+
if (error) {
|
|
141
|
+
this.adapter.catchError(
|
|
142
|
+
`Failed to reconnect to MQTT server.`,
|
|
143
|
+
`mqtt client reconnect`
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, (err, granted) => {
|
|
147
|
+
if (err) {
|
|
148
|
+
this.adapter.catchError(
|
|
149
|
+
`Failed to subscribe to Roborock MQTT Server! Error: ${err}, granted: ${JSON.stringify(granted)}`,
|
|
150
|
+
`client.on("reconnect")`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
}
|
|
156
|
+
this.adapter.log.info(`MQTT connection reconnect.`);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await client.on("offline", (result) => {
|
|
160
|
+
this.adapter.catchError(
|
|
161
|
+
`MQTT connection offline: ${result}`,
|
|
162
|
+
`client.on("offline")`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
this.connected = false;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getKnownDeviceDuids() {
|
|
170
|
+
const knownDuids = new Set();
|
|
171
|
+
|
|
172
|
+
if (this.adapter.localKeys instanceof Map) {
|
|
173
|
+
for (const duid of this.adapter.localKeys.keys()) {
|
|
174
|
+
knownDuids.add(duid);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (this.adapter.devices && Array.isArray(this.adapter.devices)) {
|
|
179
|
+
for (const device of this.adapter.devices) {
|
|
180
|
+
if (device && device.duid) {
|
|
181
|
+
knownDuids.add(device.duid);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return knownDuids;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
resolveDuidFromTopic(topic) {
|
|
190
|
+
const topicSegments = topic
|
|
191
|
+
.split("/")
|
|
192
|
+
.filter((segment) => segment && segment.length > 0);
|
|
193
|
+
if (topicSegments.length === 0) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const knownDuids = this.getKnownDeviceDuids();
|
|
198
|
+
const topicTail = topicSegments[topicSegments.length - 1];
|
|
199
|
+
|
|
200
|
+
if (knownDuids.has(topicTail)) {
|
|
201
|
+
return topicTail;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (let index = topicSegments.length - 2; index >= 0; index--) {
|
|
205
|
+
if (knownDuids.has(topicSegments[index])) {
|
|
206
|
+
return topicSegments[index];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (knownDuids.size === 0) {
|
|
211
|
+
return topicTail;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async initMQTT_Message() {
|
|
218
|
+
this.adapter.log.info(`MQTT initialized`);
|
|
219
|
+
|
|
220
|
+
client.on("message", (topic, message) => {
|
|
221
|
+
try {
|
|
222
|
+
const duid = this.resolveDuidFromTopic(topic);
|
|
223
|
+
if (!duid) {
|
|
224
|
+
this.adapter.log.debug(
|
|
225
|
+
`Skipping MQTT message with unmatched topic '${topic}'.`
|
|
226
|
+
);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const data = this.adapter.message._decodeMsg(message, duid);
|
|
231
|
+
if (!data) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)} raw: ${JSON.stringify(mqttMessageParser.parse(message))} message: ${message}`);
|
|
235
|
+
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data.toString("hex"))} message: ${message}`);
|
|
236
|
+
// this.adapter.log.debug(`MESSAGE RECEIVED for duid ${duid} with key: ${this.adapter.localKeys.get(duid)} data: ${JSON.stringify(data)}`);
|
|
237
|
+
|
|
238
|
+
// this.adapter.log.debug("Protocol: " + data.protocol);
|
|
239
|
+
if (data.protocol == 102) {
|
|
240
|
+
const parsedPayload = JSON.parse(data.payload);
|
|
241
|
+
let dps;
|
|
242
|
+
if (typeof parsedPayload.dps["102"] != "undefined") {
|
|
243
|
+
dps = JSON.parse(parsedPayload.dps["102"]);
|
|
244
|
+
} else if (typeof parsedPayload.dps["10001"] != "undefined") {
|
|
245
|
+
if (typeof parsedPayload.dps["10001"] == "string") {
|
|
246
|
+
dps = JSON.parse(parsedPayload.dps["10001"]);
|
|
247
|
+
} else {
|
|
248
|
+
dps = parsedPayload.dps["10001"];
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
dps = parsedPayload.dps;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (resolveB01PendingResponse(this.adapter, duid, dps)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (dps.id !== undefined) {
|
|
259
|
+
this.adapter.log.debug(
|
|
260
|
+
`Cloud message with protocol 102 and id ${dps.id} received. Result: ${JSON.stringify(dps.result)}`
|
|
261
|
+
);
|
|
262
|
+
if (typeof dps.result !== "undefined") {
|
|
263
|
+
this.adapter.setStateAsync("CloudMessage", {
|
|
264
|
+
duid,
|
|
265
|
+
payload: dps.result,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
this.adapter.log.debug(
|
|
270
|
+
`Cloud message with protocol 102 received. Result: ${data.payload}`
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (this.adapter.deviceNotify !== undefined) {
|
|
274
|
+
this.adapter.deviceNotify("CloudMessage", {
|
|
275
|
+
duid,
|
|
276
|
+
payload: JSON.parse(data.payload),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// special check for secure request like get_map_v1 etc. Don't process if result is OK. Instead wait for the actual response for protocol 301
|
|
282
|
+
if (dps.result != "ok") {
|
|
283
|
+
if (this.adapter.pendingRequests.has(dps.id)) {
|
|
284
|
+
const { resolve, timeout } = this.adapter.pendingRequests.get(
|
|
285
|
+
dps.id
|
|
286
|
+
);
|
|
287
|
+
this.adapter.clearTimeout(timeout);
|
|
288
|
+
this.adapter.pendingRequests.delete(dps.id);
|
|
289
|
+
resolve(dps.result);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// protocol 300 seems to be for get_photo 0 only. get_photo 0 is for large images. 1 is for small images.
|
|
293
|
+
} else if (data.protocol == 300) {
|
|
294
|
+
const photoData = parsePhotoPayload(data.payload);
|
|
295
|
+
if (photoData) {
|
|
296
|
+
if (this.adapter.pendingRequests.has(photoData.id)) {
|
|
297
|
+
this.adapter.log.debug(`First photo gzip chunk detected!`);
|
|
298
|
+
|
|
299
|
+
photoGzipChunks.push(data.payload.slice(56));
|
|
300
|
+
photoChunkID = photoData.id;
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
this.adapter.log.debug(
|
|
304
|
+
`Skipping protocol 300 MQTT message for ${duid} because the payload is not a complete Roborock photo header.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} else if (data.protocol == 301) {
|
|
308
|
+
// B01/Q7 map upload responses arrive on protocol 301 as an opaque
|
|
309
|
+
// base64 blob. Resolve the per-device pending map request first;
|
|
310
|
+
// classic v1 photo/map chunk handling continues below otherwise.
|
|
311
|
+
const pendingMap = this.adapter.pendingB01MapRequests?.get(duid);
|
|
312
|
+
if (pendingMap) {
|
|
313
|
+
this.adapter.clearTimeout(pendingMap.timeout);
|
|
314
|
+
this.adapter.pendingB01MapRequests.delete(duid);
|
|
315
|
+
pendingMap.resolve(data.payload);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (data.seq == 2 && photoGzipChunks != [] && photoChunkID != 0) {
|
|
320
|
+
this.adapter.log.debug(`Second photo gzip chunk detected!`);
|
|
321
|
+
photoGzipChunks.push(data.payload);
|
|
322
|
+
|
|
323
|
+
if (this.adapter.pendingRequests.has(photoChunkID)) {
|
|
324
|
+
const { resolve, timeout } =
|
|
325
|
+
this.adapter.pendingRequests.get(photoChunkID);
|
|
326
|
+
this.adapter.clearTimeout(timeout);
|
|
327
|
+
this.adapter.pendingRequests.delete(photoChunkID);
|
|
328
|
+
|
|
329
|
+
const finalPhotoGzip = Buffer.concat(photoGzipChunks);
|
|
330
|
+
|
|
331
|
+
photoGzipChunks = [];
|
|
332
|
+
photoChunkID = 0;
|
|
333
|
+
|
|
334
|
+
resolve(finalPhotoGzip);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
const photoData = parsePhotoPayload(data.payload);
|
|
338
|
+
if (photoData) {
|
|
339
|
+
this.adapter.log.debug(
|
|
340
|
+
`Cloud message with protocol 301 and photo id ${photoData.id} received.`
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
if (this.adapter.pendingRequests.has(photoData.id)) {
|
|
344
|
+
const { resolve, timeout } = this.adapter.pendingRequests.get(
|
|
345
|
+
photoData.id
|
|
346
|
+
);
|
|
347
|
+
this.adapter.clearTimeout(timeout);
|
|
348
|
+
this.adapter.pendingRequests.delete(photoData.id);
|
|
349
|
+
this.adapter.log.debug(
|
|
350
|
+
`Cloud message with protocol 301 and photo id ${photoData.id} received.`
|
|
351
|
+
);
|
|
352
|
+
resolve(data.payload.slice(56));
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const data2 = parseProtocol301Header(data.payload);
|
|
356
|
+
if (!data2) {
|
|
357
|
+
this.adapter.log.debug(
|
|
358
|
+
`Skipping protocol 301 MQTT message for ${duid} because the payload is shorter than ${PROTOCOL_301_HEADER_LENGTH} bytes.`
|
|
359
|
+
);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!endpoint.startsWith(data2.endpoint)) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const iv = Buffer.alloc(16, 0);
|
|
368
|
+
const decipher = crypto.createDecipheriv(
|
|
369
|
+
"aes-128-cbc",
|
|
370
|
+
this.adapter.nonce,
|
|
371
|
+
iv
|
|
372
|
+
);
|
|
373
|
+
let decrypted = Buffer.concat([
|
|
374
|
+
decipher.update(data.payload.subarray(24)),
|
|
375
|
+
decipher.final(),
|
|
376
|
+
]);
|
|
377
|
+
decrypted = zlib.gunzipSync(decrypted);
|
|
378
|
+
// this.adapter.log.debug("raw 301: " + decrypted);
|
|
379
|
+
|
|
380
|
+
if (this.adapter.pendingRequests.has(data2.id)) {
|
|
381
|
+
const { resolve, timeout } = this.adapter.pendingRequests.get(
|
|
382
|
+
data2.id
|
|
383
|
+
);
|
|
384
|
+
this.adapter.clearTimeout(timeout);
|
|
385
|
+
this.adapter.pendingRequests.delete(data2.id);
|
|
386
|
+
// this.adapter.log.debug("protocol 301 OK check: " + JSON.stringify(decrypted));
|
|
387
|
+
this.adapter.log.debug(
|
|
388
|
+
`Cloud message with protocol 301 and id ${data2.id} received.`
|
|
389
|
+
);
|
|
390
|
+
resolve(decrypted);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
} else if (data.protocol == 500) {
|
|
395
|
+
// 500 is for general information
|
|
396
|
+
const dataString = data.payload.toString("utf8");
|
|
397
|
+
let parsedData;
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
parsedData = JSON.parse(dataString);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
// If parsing fails, the data might be corrupted or in an unexpected format
|
|
403
|
+
this.adapter.log.warn(
|
|
404
|
+
`Unable to parse message for ${duid}. Error: ${error.message}. Data: ${dataString}`
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check if the device is online
|
|
410
|
+
if (parsedData.online == false) {
|
|
411
|
+
this.adapter.log.info(
|
|
412
|
+
`Couldn't process message. The device ${duid} is offline.`
|
|
413
|
+
);
|
|
414
|
+
} else if (parsedData.online == true) {
|
|
415
|
+
// this.adapter.log.info(`Device ${duid} is online.`);
|
|
416
|
+
} else if (
|
|
417
|
+
// Check for firmware update information
|
|
418
|
+
parsedData.mqttOtaData
|
|
419
|
+
) {
|
|
420
|
+
const otaStatus = parsedData.mqttOtaData.mqttOtaStatus?.status;
|
|
421
|
+
const otaProgress =
|
|
422
|
+
parsedData.mqttOtaData.mqttOtaProgress?.progress;
|
|
423
|
+
|
|
424
|
+
if (otaStatus) {
|
|
425
|
+
this.adapter.log.info(
|
|
426
|
+
`Device ${duid} firmware update status: ${otaStatus}`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (otaProgress !== undefined) {
|
|
431
|
+
this.adapter.log.info(
|
|
432
|
+
`Device ${duid} firmware update progress: ${otaProgress}%`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
// Received an unrecognized message
|
|
437
|
+
this.adapter.log.warn(
|
|
438
|
+
`Received an unrecognized message for ${duid}. Data: ${dataString}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
this.adapter.log.debug(
|
|
443
|
+
`Received message with unknown protocol ${data.protocol} data: ${JSON.stringify(data)}.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
this.adapter.log.error(
|
|
448
|
+
`client.on message failed for topic '${topic}': ${error.stack || error}`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
getEndpoint() {
|
|
455
|
+
return endpoint;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
sendMessage(duid, roborockMessage) {
|
|
459
|
+
client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${duid}`, roborockMessage, {
|
|
460
|
+
qos: 1,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
isConnected() {
|
|
465
|
+
return this.connected;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async ensureConnected() {
|
|
469
|
+
if (client && this.connected) {
|
|
470
|
+
this.adapter.log.debug("MQTT health check passed. Reconnect skipped.");
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await this.reconnectClient(true);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async reconnectClient(force = false) {
|
|
479
|
+
if (client) {
|
|
480
|
+
try {
|
|
481
|
+
if (!force && this.connected) {
|
|
482
|
+
this.adapter.log.debug(
|
|
483
|
+
"MQTT reconnect skipped because client is already connected."
|
|
484
|
+
);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.adapter.log.info("Reconnecting mqtt client!");
|
|
489
|
+
await client.end();
|
|
490
|
+
client.reconnect();
|
|
491
|
+
return true;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
this.adapter.catchError(
|
|
494
|
+
`Failed to reconnect with error: ${error}`,
|
|
495
|
+
`reconnectClient`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
decryptWithPrivateKey(privateKeyPem, encryptedData) {
|
|
504
|
+
const privateKey = crypto.createPrivateKey({
|
|
505
|
+
key: privateKeyPem,
|
|
506
|
+
format: "pem",
|
|
507
|
+
type: "pkcs8",
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const decryptedData = crypto.privateDecrypt(
|
|
511
|
+
{
|
|
512
|
+
key: privateKey,
|
|
513
|
+
padding: crypto.constants.RSA_PKCS1_PADDING,
|
|
514
|
+
},
|
|
515
|
+
encryptedData
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
return decryptedData;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Correlate a Q7/B01 RPC response (dps 10001 payload) to its pending request
|
|
524
|
+
* by msgId. Returns true when the dps object was a B01 message and has been
|
|
525
|
+
* fully handled; false when the caller should continue v1 processing.
|
|
526
|
+
* Robot-initiated B01 pushes (no matching request) trigger a status refresh
|
|
527
|
+
* instead of guessing at undocumented event payload formats.
|
|
528
|
+
*/
|
|
529
|
+
function resolveB01PendingResponse(adapter, duid, dps) {
|
|
530
|
+
if (!dps || dps.msgId === undefined || dps.id !== undefined) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const b01Key = String(dps.msgId);
|
|
535
|
+
const pendingB01 = adapter.pendingRequests.get(b01Key);
|
|
536
|
+
|
|
537
|
+
if (pendingB01) {
|
|
538
|
+
adapter.clearTimeout(pendingB01.timeout);
|
|
539
|
+
adapter.pendingRequests.delete(b01Key);
|
|
540
|
+
if (dps.code !== undefined && dps.code !== 0) {
|
|
541
|
+
pendingB01.reject(
|
|
542
|
+
new Error(
|
|
543
|
+
`B01 command ${dps.method || "(unknown method)"} failed with code ${dps.code} for ${duid}.`
|
|
544
|
+
)
|
|
545
|
+
);
|
|
546
|
+
} else {
|
|
547
|
+
pendingB01.resolve(dps.data !== undefined ? dps.data : null);
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
adapter.log.debug(
|
|
551
|
+
`Unsolicited B01 message for ${duid} (${dps.method || "no method"}); scheduling a status refresh.`
|
|
552
|
+
);
|
|
553
|
+
if (typeof adapter.getStatus === "function") {
|
|
554
|
+
void adapter.getStatus(duid, { force: true }).catch(() => undefined);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
module.exports = {
|
|
562
|
+
resolveB01PendingResponse,
|
|
563
|
+
roborock_mqtt_connector,
|
|
564
|
+
parseProtocol301Header,
|
|
565
|
+
parsePhotoPayload,
|
|
566
|
+
payloadStartsWith,
|
|
567
|
+
};
|