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.
Files changed (59) hide show
  1. package/.gitattributes +13 -0
  2. package/CHANGELOG.md +469 -0
  3. package/LICENSE +23 -0
  4. package/README.md +45 -0
  5. package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
  6. package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
  7. package/config.schema.json +118 -0
  8. package/dist/crypto.js +65 -0
  9. package/dist/crypto.js.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/live_message.js +25 -0
  13. package/dist/live_message.js.map +1 -0
  14. package/dist/logger.js +37 -0
  15. package/dist/logger.js.map +1 -0
  16. package/dist/matter_vacuum_accessory.js +1877 -0
  17. package/dist/matter_vacuum_accessory.js.map +1 -0
  18. package/dist/platform.js +441 -0
  19. package/dist/platform.js.map +1 -0
  20. package/dist/settings.js +8 -0
  21. package/dist/settings.js.map +1 -0
  22. package/dist/types.js +3 -0
  23. package/dist/types.js.map +1 -0
  24. package/dist/ui/index.js +741 -0
  25. package/dist/ui/index.js.map +1 -0
  26. package/docs/matter-rvc-updating-homebridge-report.md +120 -0
  27. package/homebridge-roborock-vacuum.code-workspace +7 -0
  28. package/homebridge-ui/public/index.html +257 -0
  29. package/homebridge-ui/public/index.js +1276 -0
  30. package/homebridge-ui/public/styles.css +611 -0
  31. package/homebridge-ui/server.js +3 -0
  32. package/package.json +91 -0
  33. package/roborockLib/i18n/de/translations.json +188 -0
  34. package/roborockLib/i18n/en/translations.json +208 -0
  35. package/roborockLib/i18n/es/translations.json +188 -0
  36. package/roborockLib/i18n/fr/translations.json +188 -0
  37. package/roborockLib/i18n/it/translations.json +188 -0
  38. package/roborockLib/i18n/nl/translations.json +188 -0
  39. package/roborockLib/i18n/pl/translations.json +188 -0
  40. package/roborockLib/i18n/pt/translations.json +188 -0
  41. package/roborockLib/i18n/ru/translations.json +188 -0
  42. package/roborockLib/i18n/uk/translations.json +188 -0
  43. package/roborockLib/i18n/zh-cn/translations.json +188 -0
  44. package/roborockLib/lib/RRMapParser.js +459 -0
  45. package/roborockLib/lib/b01Q7Adapter.js +468 -0
  46. package/roborockLib/lib/deviceFeatures.js +1349 -0
  47. package/roborockLib/lib/localConnector.js +629 -0
  48. package/roborockLib/lib/map/map.html +137 -0
  49. package/roborockLib/lib/map/zones.js +851 -0
  50. package/roborockLib/lib/message.js +402 -0
  51. package/roborockLib/lib/messageQueueHandler.js +411 -0
  52. package/roborockLib/lib/roborockAuth.js +178 -0
  53. package/roborockLib/lib/roborockCrypto.js +55 -0
  54. package/roborockLib/lib/roborockPackageHelper.js +142 -0
  55. package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
  56. package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
  57. package/roborockLib/lib/vacuum.js +992 -0
  58. package/roborockLib/roborockAPI.js +3635 -0
  59. package/tsconfig.roborockLib.json +17 -0
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const CRC32 = require("crc-32");
5
+ const Parser = require("binary-parser").Parser;
6
+ const roborockCrypto = require("./roborockCrypto");
7
+
8
+ let seq = 1;
9
+ let random = 4711; // Should be initialized with a number 0 - 1999?
10
+
11
+ // This value is stored hardcoded in librrcodec.so, encrypted by the value of "com.roborock.iotsdk.appsecret" from AndroidManifest.xml.
12
+ const salt = "TXdfu$jyZ#TZHsg4";
13
+ const b01Salt = "5wwh9ikChRjASpMU8cxg7o1d2E";
14
+
15
+ const messageParser = new Parser()
16
+ .endianess("big")
17
+ .string("version", {
18
+ length: 3,
19
+ })
20
+ .uint32("seq")
21
+ .uint32("random")
22
+ .uint32("timestamp")
23
+ .uint16("protocol")
24
+ .uint16("payloadLen")
25
+ .buffer("payload", {
26
+ length: "payloadLen",
27
+ })
28
+ .uint32("crc32");
29
+
30
+ class message {
31
+ constructor(adapter) {
32
+ this.adapter = adapter;
33
+ this.missingLocalKeyWarnings = new Set();
34
+
35
+ this.keys = roborockCrypto.generateRsaKeyPair();
36
+ }
37
+
38
+ async buildPayload(
39
+ duid,
40
+ protocol,
41
+ messageID,
42
+ method,
43
+ params,
44
+ secure = false,
45
+ photo = false
46
+ ) {
47
+ const timestamp = Math.floor(Date.now() / 1000);
48
+ const endpoint = this.adapter.rr_mqtt_connector.getEndpoint();
49
+ const version = await this.adapter.getRobotVersion(duid);
50
+ // this.adapter.log.debug("sendRequest started with: " + requestId);
51
+
52
+ if (photo) {
53
+ params.endpoint = endpoint;
54
+ params.security = {
55
+ cipher_suite: 0,
56
+ pub_key: this.keys.public,
57
+ };
58
+ }
59
+
60
+ const inner = {
61
+ id: messageID,
62
+ method: method,
63
+ params: params,
64
+ };
65
+ if (secure) {
66
+ if (!photo) {
67
+ inner.security = {
68
+ endpoint: endpoint,
69
+ nonce: this.adapter.nonce.toString("hex").toUpperCase(),
70
+ };
71
+ }
72
+ }
73
+
74
+ let payload;
75
+ if (version == "B01" || version == "\x81S\x19") {
76
+ // Q7/B01 wire format (verified against the python-roborock reference
77
+ // fixtures): a single object on dps 10000 carrying method/msgId/params
78
+ // only. No top-level "t", no numeric "id", no security block; method
79
+ // translation happens in b01Q7Adapter before this point.
80
+ payload = JSON.stringify({
81
+ dps: {
82
+ 10000: {
83
+ method: method,
84
+ msgId: String(messageID),
85
+ params: params !== undefined && params !== null ? params : [],
86
+ },
87
+ },
88
+ });
89
+ } else {
90
+ payload = JSON.stringify({
91
+ dps: {
92
+ [protocol]: JSON.stringify(inner),
93
+ },
94
+ t: timestamp,
95
+ });
96
+ }
97
+
98
+ return payload;
99
+ }
100
+
101
+ async buildRoborockMessage(duid, protocol, timestamp, payload) {
102
+ const version = await this.adapter.getRobotVersion(duid);
103
+
104
+ let encrypted;
105
+
106
+ const currentSeq = seq & 0xffffffff;
107
+ const currentRandom = random & 0xffffffff;
108
+
109
+ if (protocol == 1) {
110
+ const msg = Buffer.alloc(23);
111
+ msg.write(version);
112
+ msg.writeUint32BE(currentSeq, 3);
113
+ msg.writeUint32BE(currentRandom, 7);
114
+ msg.writeUint32BE(timestamp, 11);
115
+ msg.writeUint16BE(protocol, 15);
116
+ msg.writeUint16BE(0, 17);
117
+ const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
118
+ msg.writeUint32BE(crc32, msg.length - 4);
119
+ seq++;
120
+ random++;
121
+
122
+ return msg;
123
+ }
124
+
125
+ if (version == "1.0") {
126
+ const localKey =
127
+ this.adapter.localKeys instanceof Map
128
+ ? this.adapter.localKeys.get(duid)
129
+ : null;
130
+ const aesKey = roborockCrypto.md5bin(
131
+ roborockCrypto.encodeTimestamp(timestamp) + localKey + salt
132
+ );
133
+ const cipher = crypto.createCipheriv("aes-128-ecb", aesKey, null);
134
+ encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
135
+ } else if (version == "A01") {
136
+ const localKey = this.adapter.localKeys.get(duid);
137
+
138
+ const iv = roborockCrypto
139
+ .md5hex(
140
+ currentRandom.toString(16).padStart(8, "0") +
141
+ "726f626f726f636b2d67a6d6da"
142
+ )
143
+ .substring(8, 24); // 726f626f726f636b2d67a6d6da can be found in librrcodec.so of version 4.0 of the roborock app
144
+ const cipher = crypto.createCipheriv("aes-128-cbc", localKey, iv);
145
+ encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
146
+ } else if (version == "L01") {
147
+ const localKey = this.adapter.localKeys.get(duid);
148
+ const { connectNonce, ackNonce } = this._getL01Nonces(duid);
149
+ const aesKey = crypto
150
+ .createHash("sha256")
151
+ .update(roborockCrypto.encodeTimestamp(timestamp) + localKey + salt)
152
+ .digest();
153
+ const iv = this._deriveL01Iv(currentSeq, currentRandom, timestamp);
154
+ const aad = this._deriveL01Aad(
155
+ currentSeq,
156
+ connectNonce,
157
+ ackNonce,
158
+ currentRandom,
159
+ timestamp
160
+ );
161
+ const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
162
+ cipher.setAAD(aad);
163
+ const encryptedPayload = Buffer.concat([
164
+ cipher.update(payload),
165
+ cipher.final(),
166
+ ]);
167
+ encrypted = Buffer.concat([encryptedPayload, cipher.getAuthTag()]);
168
+ } else if (version == "B01" || version == "\x81S\x19") {
169
+ const localKey = this.adapter.localKeys.get(duid);
170
+ const iv = this._deriveB01Iv(currentRandom);
171
+ const cipher = crypto.createCipheriv("aes-128-cbc", localKey, iv);
172
+ encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
173
+ }
174
+
175
+ if (encrypted) {
176
+ const msg = Buffer.alloc(23 + encrypted.length);
177
+ msg.write(version);
178
+ msg.writeUint32BE(currentSeq, 3);
179
+ msg.writeUint32BE(currentRandom, 7);
180
+ msg.writeUint32BE(timestamp, 11);
181
+ msg.writeUint16BE(protocol, 15);
182
+ msg.writeUint16BE(encrypted.length, 17);
183
+ encrypted.copy(msg, 19);
184
+ const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
185
+ msg.writeUint32BE(crc32, msg.length - 4);
186
+ seq++;
187
+ random++;
188
+
189
+ return msg;
190
+ }
191
+
192
+ return false;
193
+ }
194
+
195
+ _decodeMsg(message, duid) {
196
+ try {
197
+ // Do some checks before trying to decode the message.
198
+ const version = message.toString("latin1", 0, 3);
199
+
200
+ if (
201
+ version !== "1.0" &&
202
+ version !== "A01" &&
203
+ version !== "L01" &&
204
+ version !== "B01" &&
205
+ version !== "\x81S\x19"
206
+ ) {
207
+ throw new Error(`Unknown protocol version ${version}`);
208
+ }
209
+ const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
210
+ const expectedCrc32 = message.readUint32BE(message.length - 4);
211
+ if (crc32 != expectedCrc32) {
212
+ throw new Error(`Wrong CRC32 ${crc32}, expected ${expectedCrc32}`);
213
+ }
214
+
215
+ const data = this.getParsedData(message);
216
+ delete data.payloadLen;
217
+
218
+ const localKey = this.adapter.localKeys.get(duid);
219
+ if (!localKey) {
220
+ if (!this.missingLocalKeyWarnings.has(duid)) {
221
+ this.missingLocalKeyWarnings.add(duid);
222
+ this.adapter.log.warn(
223
+ `Skipping MQTT message for device ${duid}: no localKey available.`
224
+ );
225
+ }
226
+
227
+ const error = new Error(`No localKey found for device ${duid}`);
228
+ error.code = "ERR_MISSING_LOCAL_KEY";
229
+ throw error;
230
+ }
231
+
232
+ if (version == "1.0") {
233
+ const aesKey = roborockCrypto.md5bin(
234
+ roborockCrypto.encodeTimestamp(data.timestamp) + localKey + salt
235
+ );
236
+ const decipher = crypto.createDecipheriv("aes-128-ecb", aesKey, null);
237
+ data.payload = Buffer.concat([
238
+ decipher.update(data.payload),
239
+ decipher.final(),
240
+ ]);
241
+ } else if (version == "A01") {
242
+ const iv = roborockCrypto
243
+ .md5hex(
244
+ data.random.toString(16).padStart(8, "0") +
245
+ "726f626f726f636b2d67a6d6da"
246
+ )
247
+ .substring(8, 24);
248
+ const decipher = crypto.createDecipheriv("aes-128-cbc", localKey, iv);
249
+ data.payload = Buffer.concat([
250
+ decipher.update(data.payload),
251
+ decipher.final(),
252
+ ]);
253
+ } else if (version == "L01") {
254
+ const { connectNonce, ackNonce } = this._getL01Nonces(duid);
255
+ const aesKey = crypto
256
+ .createHash("sha256")
257
+ .update(
258
+ roborockCrypto.encodeTimestamp(data.timestamp) + localKey + salt
259
+ )
260
+ .digest();
261
+ const iv = this._deriveL01Iv(data.seq, data.random, data.timestamp);
262
+ const aad = this._deriveL01Aad(
263
+ data.seq,
264
+ connectNonce,
265
+ ackNonce,
266
+ data.random,
267
+ data.timestamp
268
+ );
269
+ const authTag = data.payload.subarray(data.payload.length - 16);
270
+ const encryptedPayload = data.payload.subarray(
271
+ 0,
272
+ data.payload.length - 16
273
+ );
274
+ const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
275
+ decipher.setAAD(aad);
276
+ decipher.setAuthTag(authTag);
277
+ data.payload = Buffer.concat([
278
+ decipher.update(encryptedPayload),
279
+ decipher.final(),
280
+ ]);
281
+ } else if (version == "B01" || version == "\x81S\x19") {
282
+ const iv = this._deriveB01Iv(data.random);
283
+ const decipher = crypto.createDecipheriv("aes-128-cbc", localKey, iv);
284
+ data.payload = Buffer.concat([
285
+ decipher.update(data.payload),
286
+ decipher.final(),
287
+ ]);
288
+ }
289
+
290
+ return data;
291
+ } catch (error) {
292
+ if (error && error.code === "ERR_MISSING_LOCAL_KEY") {
293
+ return null;
294
+ }
295
+
296
+ const preview = message
297
+ .subarray(0, Math.min(message.length, 12))
298
+ .toString("hex");
299
+ const reason = error && error.message ? error.message : String(error);
300
+ this.adapter.log.error(
301
+ `failed to _decodeMsg for ${duid}: ${reason} (len=${message.length}, preview=${preview})`
302
+ );
303
+ // this.adapter.catchError(error, "_decodeMessage", "none");
304
+ return null;
305
+ }
306
+ }
307
+
308
+ getParsedData(data) {
309
+ return messageParser.parse(data);
310
+ }
311
+
312
+ resolve102Message(messageID, message, secure = false) {
313
+ return new Promise((resolve, reject) => {
314
+ if (message?.code) {
315
+ reject(
316
+ new Error(
317
+ `There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`
318
+ )
319
+ );
320
+ } else {
321
+ if (secure) {
322
+ if (message[0] !== "ok") {
323
+ reject(message);
324
+ }
325
+ } else {
326
+ resolve(message);
327
+ }
328
+ }
329
+ });
330
+ }
331
+
332
+ resolve301Message(messageID, message) {
333
+ return new Promise((resolve, reject) => {
334
+ this.adapter.clearTimeout(
335
+ this.adapter.messageQueue.get(messageID)?.timeout301
336
+ );
337
+ (this.adapter.messageQueue.get(messageID) || {}).timeout301 = null;
338
+ this.adapter.checkAndClearRequest(messageID);
339
+
340
+ if (message?.code) {
341
+ reject(
342
+ new Error(
343
+ `There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`
344
+ )
345
+ );
346
+ } else {
347
+ resolve(message);
348
+ }
349
+ });
350
+ }
351
+
352
+ _deriveB01Iv(randomSeed) {
353
+ const randomBuffer = Buffer.alloc(4);
354
+ randomBuffer.writeUInt32BE(randomSeed >>> 0, 0);
355
+
356
+ const randomHex = randomBuffer.toString("hex").toLowerCase();
357
+ const hash = roborockCrypto.md5hex(randomHex + b01Salt);
358
+ const iv = hash.substring(9, 25);
359
+
360
+ return Buffer.from(iv, "utf8");
361
+ }
362
+
363
+ _deriveL01Iv(sequence, randomSeed, timestamp) {
364
+ const digestInput = Buffer.alloc(12);
365
+ digestInput.writeUInt32BE(sequence >>> 0, 0);
366
+ digestInput.writeUInt32BE(randomSeed >>> 0, 4);
367
+ digestInput.writeUInt32BE(timestamp >>> 0, 8);
368
+ return crypto
369
+ .createHash("sha256")
370
+ .update(digestInput)
371
+ .digest()
372
+ .subarray(0, 12);
373
+ }
374
+
375
+ _deriveL01Aad(sequence, connectNonce, ackNonce, randomSeed, timestamp) {
376
+ const aad = Buffer.alloc(20);
377
+ aad.writeUInt32BE(sequence >>> 0, 0);
378
+ aad.writeUInt32BE(connectNonce >>> 0, 4);
379
+ aad.writeUInt32BE(ackNonce >>> 0, 8);
380
+ aad.writeUInt32BE(randomSeed >>> 0, 12);
381
+ aad.writeUInt32BE(timestamp >>> 0, 16);
382
+ return aad;
383
+ }
384
+
385
+ _getL01Nonces(duid) {
386
+ const nonces =
387
+ this.adapter.localL01Nonces && this.adapter.localL01Nonces.get(duid);
388
+ if (
389
+ !nonces ||
390
+ typeof nonces.connectNonce !== "number" ||
391
+ typeof nonces.ackNonce !== "number"
392
+ ) {
393
+ throw new Error(`Missing L01 nonces for device ${duid}`);
394
+ }
395
+
396
+ return nonces;
397
+ }
398
+ }
399
+
400
+ module.exports = {
401
+ message,
402
+ };