homebridge-roborock-vacuum 0.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 (45) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -0
  4. package/config.schema.json +31 -0
  5. package/dist/index.js +10 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/logger.js +39 -0
  8. package/dist/logger.js.map +1 -0
  9. package/dist/platform.js +167 -0
  10. package/dist/platform.js.map +1 -0
  11. package/dist/settings.js +8 -0
  12. package/dist/settings.js.map +1 -0
  13. package/dist/types.js +3 -0
  14. package/dist/types.js.map +1 -0
  15. package/dist/vacuum_accessory.js +152 -0
  16. package/dist/vacuum_accessory.js.map +1 -0
  17. package/package.json +66 -0
  18. package/roborockLib/data/UserData +4 -0
  19. package/roborockLib/data/clientID +4 -0
  20. package/roborockLib/i18n/de/translations.json +188 -0
  21. package/roborockLib/i18n/en/translations.json +208 -0
  22. package/roborockLib/i18n/es/translations.json +188 -0
  23. package/roborockLib/i18n/fr/translations.json +188 -0
  24. package/roborockLib/i18n/it/translations.json +188 -0
  25. package/roborockLib/i18n/nl/translations.json +188 -0
  26. package/roborockLib/i18n/pl/translations.json +188 -0
  27. package/roborockLib/i18n/pt/translations.json +188 -0
  28. package/roborockLib/i18n/ru/translations.json +188 -0
  29. package/roborockLib/i18n/uk/translations.json +188 -0
  30. package/roborockLib/i18n/zh-cn/translations.json +188 -0
  31. package/roborockLib/lib/RRMapParser.js +447 -0
  32. package/roborockLib/lib/deviceFeatures.js +995 -0
  33. package/roborockLib/lib/localConnector.js +249 -0
  34. package/roborockLib/lib/map/map.html +110 -0
  35. package/roborockLib/lib/map/zones.js +713 -0
  36. package/roborockLib/lib/mapCreator.js +692 -0
  37. package/roborockLib/lib/message.js +223 -0
  38. package/roborockLib/lib/messageQueueHandler.js +87 -0
  39. package/roborockLib/lib/roborockPackageHelper.js +116 -0
  40. package/roborockLib/lib/roborock_mqtt_connector.js +349 -0
  41. package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
  42. package/roborockLib/lib/vacuum.js +636 -0
  43. package/roborockLib/roborockAPI.js +1365 -0
  44. package/roborockLib/test.js +31 -0
  45. package/roborockLib/userdata.json +24 -0
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const CRC32 = require("crc-32");
5
+ const Parser = require("binary-parser").Parser;
6
+ const forge = require("node-forge");
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
+
14
+ const messageParser = new Parser()
15
+ .endianess("big")
16
+ .string("version", {
17
+ length: 3,
18
+ })
19
+ .uint32("seq")
20
+ .uint32("random")
21
+ .uint32("timestamp")
22
+ .uint16("protocol")
23
+ .uint16("payloadLen")
24
+ .buffer("payload", {
25
+ length: "payloadLen",
26
+ })
27
+ .uint32("crc32");
28
+
29
+ class message {
30
+ constructor(adapter) {
31
+ this.adapter = adapter;
32
+
33
+ const keypair = forge.pki.rsa.generateKeyPair(2048);
34
+ this.keys = {
35
+ public: { n: null, e: null },
36
+ private: {
37
+ n: null,
38
+ e: null,
39
+ d: null,
40
+ p: null,
41
+ q: null,
42
+ dmp1: null,
43
+ dmq1: null,
44
+ coeff: null,
45
+ },
46
+ };
47
+
48
+ // Convert the keys to the desired format
49
+ this.keys.public.n = keypair.publicKey.n.toString(16);
50
+ this.keys.public.e = keypair.publicKey.e.toString(16);
51
+ this.keys.private.n = keypair.privateKey.n.toString(16);
52
+ this.keys.private.e = keypair.privateKey.e.toString(16);
53
+ this.keys.private.d = keypair.privateKey.d.toString(16);
54
+ this.keys.private.p = keypair.privateKey.p.toString(16);
55
+ this.keys.private.q = keypair.privateKey.q.toString(16);
56
+ this.keys.private.dmp1 = keypair.privateKey.dP.toString(16);
57
+ this.keys.private.dmq1 = keypair.privateKey.dQ.toString(16);
58
+ this.keys.private.coeff = keypair.privateKey.qInv.toString(16);
59
+ }
60
+
61
+ buildPayload(protocol, messageID, method, params, secure = false, photo = false) {
62
+ const timestamp = Math.floor(Date.now() / 1000);
63
+ const endpoint = this.adapter.rr_mqtt_connector.getEndpoint();
64
+ // this.adapter.log.debug("sendRequest started with: " + requestId);
65
+
66
+ if (photo) {
67
+ params.endpoint = endpoint;
68
+ params.security = {
69
+ cipher_suite: 0,
70
+ pub_key: this.keys.public,
71
+ };
72
+ }
73
+
74
+ const inner = {
75
+ id: messageID,
76
+ method: method,
77
+ params: params,
78
+ };
79
+ if (secure) {
80
+ if (!photo) {
81
+ inner.security = {
82
+ endpoint: endpoint,
83
+ nonce: this.adapter.nonce.toString("hex").toUpperCase(),
84
+ };
85
+ }
86
+ }
87
+
88
+ const payload = JSON.stringify({
89
+ dps: {
90
+ [protocol]: JSON.stringify(inner),
91
+ },
92
+ t: timestamp,
93
+ });
94
+
95
+ return payload;
96
+ }
97
+
98
+ async buildRoborockMessage(duid, protocol, timestamp, payload) {
99
+ const version = await this.adapter.getRobotVersion(duid);
100
+
101
+ let encrypted;
102
+
103
+ if (version == "1.0") {
104
+ const localKey = this.adapter.localKeys.get(duid);
105
+ const aesKey = this.md5bin(this._encodeTimestamp(timestamp) + localKey + salt);
106
+ const cipher = crypto.createCipheriv("aes-128-ecb", aesKey, null);
107
+ encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
108
+
109
+ }
110
+ else if (version == "A01") {
111
+ const localKey = this.adapter.localKeys.get(duid);
112
+
113
+ const iv = this.md5hex(payload.random.toString(16).padStart(8, "0") + "726f626f726f636b2d67a6d6da").substring(8, 24); // 726f626f726f636b2d67a6d6da can be found in librrcodec.so of version 4.0 of the roborock app
114
+ const cipher = crypto.createCipheriv("aes-128-cbc", localKey, iv);
115
+ encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
116
+ }
117
+
118
+ if (encrypted) {
119
+ const msg = Buffer.alloc(23 + encrypted.length);
120
+ msg.write(version);
121
+ msg.writeUint32BE(seq++ & 0xffffffff, 3);
122
+ msg.writeUint32BE(random++ & 0xffffffff, 7);
123
+ msg.writeUint32BE(timestamp, 11);
124
+ msg.writeUint16BE(protocol, 15);
125
+ msg.writeUint16BE(encrypted.length, 17);
126
+ encrypted.copy(msg, 19);
127
+ const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
128
+ msg.writeUint32BE(crc32, msg.length - 4);
129
+
130
+ return msg;
131
+ }
132
+
133
+ return false;
134
+ }
135
+
136
+ _decodeMsg(message, duid) {
137
+ try {
138
+ // Do some checks before trying to decode the message.
139
+ const version = message.toString("latin1", 0, 3);
140
+
141
+ if (version !== "1.0" && version !== "A01") {
142
+ this.adapter.log.error(`_decodeMsg error: ${message.toString("latin1", 0, 3)}`);
143
+ // throw new Error(`Unknown protocol version ${msg} localKey: ${localKey}`);
144
+ }
145
+ const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
146
+ const expectedCrc32 = message.readUint32BE(message.length - 4);
147
+ if (crc32 != expectedCrc32) {
148
+ throw new Error(`Wrong CRC32 ${crc32}, expected ${expectedCrc32}`);
149
+ }
150
+
151
+ const data = this.getParsedData(message);
152
+ delete data.payloadLen;
153
+
154
+ const localKey = this.adapter.localKeys.get(duid);
155
+ if(version == "1.0") {
156
+ const aesKey = this.md5bin(this._encodeTimestamp(data.timestamp) + localKey + salt);
157
+ const decipher = crypto.createDecipheriv("aes-128-ecb", aesKey, null);
158
+ data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
159
+ }
160
+ else if (version == "A01") {
161
+ const iv = this.md5hex(data.random.toString(16).padStart(8, "0") + "726f626f726f636b2d67a6d6da").substring(8, 24);
162
+ const decipher = crypto.createDecipheriv("aes-128-cbc", localKey, iv);
163
+ data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
164
+ }
165
+
166
+ return data;
167
+ } catch (error) {
168
+ this.adapter.log.error(`failed to _decodeMsg: ${error} local binary data: ${message.toString("hex")}`, null, duid);
169
+ // this.adapter.catchError(error, "_decodeMessage", "none");
170
+ }
171
+ }
172
+
173
+ getParsedData(data) {
174
+ return messageParser.parse(data);
175
+ }
176
+
177
+ resolve102Message(messageID, message, secure = false) {
178
+ return new Promise((resolve, reject) => {
179
+ if (message?.code) {
180
+ reject(new Error(`There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`));
181
+ } else {
182
+ if (secure) {
183
+ if (message[0] !== "ok") {
184
+ reject(message);
185
+ }
186
+ } else {
187
+ resolve(message);
188
+ }
189
+ }
190
+ });
191
+ }
192
+
193
+ resolve301Message(messageID, message) {
194
+ return new Promise((resolve, reject) => {
195
+ this.adapter.clearTimeout(this.adapter.messageQueue.get(messageID)?.timeout301);
196
+ (this.adapter.messageQueue.get(messageID) || {}).timeout301 = null;
197
+ this.adapter.checkAndClearRequest(messageID);
198
+
199
+ if (message?.code) {
200
+ reject(new Error(`There was an error processing the request with id ${messageID} error: ${JSON.stringify(message)}`));
201
+ } else {
202
+ resolve(message);
203
+ }
204
+ });
205
+ }
206
+
207
+ _encodeTimestamp(timestamp) {
208
+ const hex = timestamp.toString(16).padStart(8, "0").split("");
209
+ return [5, 6, 3, 7, 1, 2, 0, 4].map((idx) => hex[idx]).join("");
210
+ }
211
+
212
+ md5bin(str) {
213
+ return crypto.createHash("md5").update(str).digest();
214
+ }
215
+
216
+ md5hex(str) {
217
+ return crypto.createHash("md5").update(str).digest("hex");
218
+ }
219
+ }
220
+
221
+ module.exports = {
222
+ message,
223
+ };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ const requestTimeout = 10000; // 10s
4
+
5
+ class messageQueueHandler {
6
+ constructor(adapter) {
7
+ this.adapter = adapter;
8
+ }
9
+
10
+ async sendRequest(duid, method, params, secure = false, photo = false) {
11
+ const remoteConnection = await this.adapter.isRemoteDevice(duid);
12
+
13
+ let messageID = this.adapter.getRequestId();
14
+ if (photo) messageID = messageID % 256; // this is a special case. Otherwise photo requests will not have the correct ID in the response.
15
+ const timestamp = Math.floor(Date.now() / 1000);
16
+
17
+ let protocol;
18
+ if (remoteConnection || secure || photo || method == "get_network_info") {
19
+ protocol = 101;
20
+ } else {
21
+ protocol = 4;
22
+ }
23
+ const payload = this.adapter.message.buildPayload(protocol, messageID, method, params, secure, photo);
24
+ const roborockMessage = await this.adapter.message.buildRoborockMessage(duid, protocol, timestamp, payload);
25
+
26
+ const deviceOnline = await this.adapter.onlineChecker(duid);
27
+ const mqttConnectionState = this.adapter.rr_mqtt_connector.isConnected();
28
+ const localConnectionState = this.adapter.localConnector.isConnected(duid);
29
+
30
+ if (roborockMessage) {
31
+ return new Promise((resolve, reject) => {
32
+ if (!deviceOnline) {
33
+ this.adapter.pendingRequests.delete(messageID);
34
+ this.adapter.log.debug(`Device ${duid} offline. Not sending for method ${method} request!`);
35
+ reject();
36
+ }
37
+ else if (!mqttConnectionState && remoteConnection) {
38
+ this.adapter.pendingRequests.delete(messageID);
39
+ this.adapter.log.debug(`Cloud connection not available. Not sending for method ${method} request!`);
40
+ reject();
41
+ }
42
+ else if (!localConnectionState && !remoteConnection) {
43
+ this.adapter.pendingRequests.delete(messageID);
44
+ this.adapter.log.debug(`Adapter not connect locally to robot ${duid}. Not sending for method ${method} request!`);
45
+ reject();
46
+ } else {
47
+ // setup Timeout
48
+ const timeout = this.adapter.setTimeout(() => {
49
+ this.adapter.pendingRequests.delete(messageID);
50
+ this.adapter.localConnector.clearChunkBuffer(duid);
51
+ if (remoteConnection) {
52
+ reject(new Error(`Cloud request with id ${messageID} with method ${method} timed out after 10 seconds. MQTT connection state: ${mqttConnectionState}`));
53
+ } else {
54
+ reject(new Error(`Local request with id ${messageID} with method ${method} timed out after 10 seconds Local connect state: ${localConnectionState}`));
55
+ }
56
+ }, requestTimeout);
57
+
58
+ // Store request with resolve and reject functions
59
+ this.adapter.pendingRequests.set(messageID, { resolve, reject, timeout });
60
+
61
+ if (remoteConnection || secure || photo || method == "get_network_info") {
62
+ this.adapter.rr_mqtt_connector.sendMessage(duid, roborockMessage);
63
+ this.adapter.log.debug(`Sent payload for ${duid} with ${payload} using cloud connection`);
64
+ //client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${duid}`, roborockMessage, { qos: 1 });
65
+ // this.adapter.log.debug(`Promise for messageID ${messageID} created. ${this.adapter.message._decodeMsg(roborockMessage, duid).payload}`);
66
+ } else {
67
+ const lengthBuffer = Buffer.alloc(4);
68
+ lengthBuffer.writeUInt32BE(roborockMessage.length, 0);
69
+
70
+ const fullMessage = Buffer.concat([lengthBuffer, roborockMessage]);
71
+ this.adapter.localConnector.sendMessage(duid, fullMessage);
72
+ // this.adapter.log.debug(`sent fullMessage: ${fullMessage.toString("hex")}`);
73
+ this.adapter.log.debug(`Sent payload for ${duid} with ${payload} using local connection`);
74
+ }
75
+ }
76
+ }).finally(() => {
77
+ this.adapter.log.debug(`Size of message queue: ${this.adapter.pendingRequests.size}`);
78
+ });
79
+ } else {
80
+ this.adapter.catchError("Failed to build buildRoborockMessage!", "function sendRequest", duid);
81
+ }
82
+ }
83
+ }
84
+
85
+ module.exports = {
86
+ messageQueueHandler,
87
+ };
@@ -0,0 +1,116 @@
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(productID, vacuum);
25
+
26
+ if (productIDinPackage) {
27
+ appPluginRequest.productids.push(vacuum.id);
28
+ vacuumIDs[vacuum.id] = vacuum.model;
29
+ }
30
+ }
31
+ }
32
+
33
+ const packageData = await loginApi.post("api/v1/appplugin", appPluginRequest);
34
+
35
+ const packages = packageData.data.data;
36
+ for (const rr_package in packages) {
37
+ const vacuum = vacuumIDs[packages[rr_package].productid];
38
+ const zipUrl = packages[rr_package].url;
39
+ const version = packages[rr_package].version;
40
+ const imagePath = `./images/products/${vacuum}`;
41
+ const objectPath = `Devices.${duid}.images`;
42
+ const versionFilePath = imagePath + "/version";
43
+
44
+ if (!fs.existsSync("./lib/roborockPackage/")) fs.mkdirSync("./lib/roborockPackage/");
45
+
46
+ try {
47
+ // Create missing vacuum folders
48
+ if (!fs.existsSync(`./images/products/`)) fs.mkdirSync(`./images/products/`);
49
+ if (!fs.existsSync(imagePath)) fs.mkdirSync(imagePath);
50
+
51
+ this.adapter.setObjectAsync(objectPath, {
52
+ type: "folder",
53
+ common: {
54
+ name: "images",
55
+ },
56
+ native: {},
57
+ });
58
+
59
+ if (!fs.existsSync(versionFilePath)) {
60
+ fs.writeFileSync(versionFilePath, "0");
61
+ }
62
+ const currentVersion = fs.readFileSync(versionFilePath, "utf8");
63
+
64
+ if (packages[rr_package].version > currentVersion) {
65
+ this.adapter.log.debug(`New version roborock package available: ${version}`);
66
+
67
+ const response = await loginApi.get(zipUrl, { responseType: "arraybuffer" });
68
+ const zip = await JSZip.loadAsync(response.data);
69
+ const folder = zip.folder("drawable-mdpi");
70
+
71
+ if (folder) {
72
+ let i = 0;
73
+ folder.forEach(async (relativePath, file) => {
74
+ if (!file.dir) {
75
+ const fileContent = await file.async("nodebuffer");
76
+ if (fileContent) {
77
+ fs.writeFileSync(`${imagePath}/${relativePath}`, fileContent);
78
+
79
+ const fileContentBase64 = fileContent.toString("base64");
80
+ const fileNameWithoutExtension = relativePath.slice(0, relativePath.lastIndexOf("."));
81
+ const formattedNumber = (i).toString().padStart(3, "0"); // "001", "002", "003", etc.
82
+
83
+ this.adapter.setObjectAsync(`${objectPath}.${formattedNumber}`, {
84
+ type: "state",
85
+ common: {
86
+ name: fileNameWithoutExtension,
87
+ type: "string",
88
+ role: "value",
89
+ read: true,
90
+ write: false
91
+ },
92
+ native: {},
93
+ });
94
+ this.adapter.setStateAsync(`${objectPath}.${formattedNumber}`, { val: fileContentBase64, ack: true });
95
+ }
96
+ i++;
97
+ }
98
+ });
99
+ }
100
+
101
+ fs.writeFileSync(versionFilePath, version.toString());
102
+ }
103
+ } catch (err) {
104
+ this.adapter.catchError(err, "roborockPackageHelper.updateProduct", null, productID);
105
+ }
106
+ }
107
+ }
108
+
109
+ findProductIDinPackage(productID, list) {
110
+ if (list.model === productID) {
111
+ return list.id;
112
+ }
113
+ }
114
+ }
115
+
116
+ exports.roborockPackageHelper = roborockPackageHelper;