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,411 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const b01Q7Adapter = require("./b01Q7Adapter");
|
|
5
|
+
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT = 10000; // 10s
|
|
7
|
+
// Some commands legitimately take longer to acknowledge. Switching the active
|
|
8
|
+
// saved map (load_multi_map) can take well over the default timeout on older
|
|
9
|
+
// models such as the S6 Pure, so give it more headroom before timing out.
|
|
10
|
+
/** @type {Record<string, number>} */
|
|
11
|
+
const METHOD_REQUEST_TIMEOUTS = {
|
|
12
|
+
load_multi_map: 30000, // 30s
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} method
|
|
17
|
+
* @param {number} [requestTimeoutMs]
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
function getRequestTimeout(method, requestTimeoutMs) {
|
|
21
|
+
const override = Number(requestTimeoutMs);
|
|
22
|
+
if (Number.isFinite(override) && override > 0) {
|
|
23
|
+
return Math.round(override);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return METHOD_REQUEST_TIMEOUTS[method] || DEFAULT_REQUEST_TIMEOUT;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} PendingRequest
|
|
31
|
+
* @property {(value: unknown) => void} resolve
|
|
32
|
+
* @property {(reason?: unknown) => void} reject
|
|
33
|
+
* @property {ReturnType<typeof setTimeout>} timeout
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} TransportDiagnosticsUpdate
|
|
38
|
+
* @property {"cloud" | "local" | "local-pending"} [lastTransport]
|
|
39
|
+
* @property {string} [lastTransportReason]
|
|
40
|
+
* @property {string} [lastCommandMethod]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} MessageBuilder
|
|
45
|
+
* @property {(duid: string, protocol: number, messageID: number, method: string, params: unknown[], secure: boolean, photo: boolean) => Promise<unknown>} buildPayload
|
|
46
|
+
* @property {(duid: string, protocol: number, timestamp: number, payload: unknown) => Promise<Buffer | null | undefined>} buildRoborockMessage
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} LocalConnector
|
|
51
|
+
* @property {(duid: string) => boolean} isConnected
|
|
52
|
+
* @property {(duid: string, message: Buffer) => void} sendMessage
|
|
53
|
+
* @property {(duid: string) => void} clearChunkBuffer
|
|
54
|
+
* @property {(duid: string) => Promise<void>} [ensureL01Handshake]
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {Object} MqttConnector
|
|
59
|
+
* @property {() => boolean} isConnected
|
|
60
|
+
* @property {(duid: string, message: Buffer) => void} sendMessage
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} LoggerLike
|
|
65
|
+
* @property {(message: string) => void} debug
|
|
66
|
+
* @property {(message: string) => void} info
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} RoborockConfig
|
|
71
|
+
* @property {boolean} [cloudOnlyMode]
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} MessageQueueAdapter
|
|
76
|
+
* @property {RoborockConfig} [config]
|
|
77
|
+
* @property {(duid: string) => Promise<boolean>} isRemoteDevice
|
|
78
|
+
* @property {(duid: string) => Promise<string>} getRobotVersion
|
|
79
|
+
* @property {(duid: string) => Promise<boolean>} onlineChecker
|
|
80
|
+
* @property {MqttConnector} rr_mqtt_connector
|
|
81
|
+
* @property {LocalConnector} localConnector
|
|
82
|
+
* @property {MessageBuilder} message
|
|
83
|
+
* @property {() => number} getRequestId
|
|
84
|
+
* @property {Map<number, PendingRequest>} pendingRequests
|
|
85
|
+
* @property {(callback: () => void, timeout: number) => ReturnType<typeof setTimeout>} setTimeout
|
|
86
|
+
* @property {(timeout: ReturnType<typeof setTimeout>) => void} clearTimeout
|
|
87
|
+
* @property {LoggerLike} log
|
|
88
|
+
* @property {(duid: string, update: TransportDiagnosticsUpdate) => Promise<void>} updateTransportDiagnostics
|
|
89
|
+
* @property {(duid: string) => Promise<boolean>} [ensureLocalConnection]
|
|
90
|
+
* @property {(message: string, location: string, duid?: string) => void} catchError
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {Object} RequestOptions
|
|
95
|
+
* @property {boolean} [preferCloud]
|
|
96
|
+
* @property {boolean} [preferLocal]
|
|
97
|
+
* @property {boolean} [allowOfflineCloudSend]
|
|
98
|
+
* @property {number} [requestTimeoutMs]
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
class messageQueueHandler {
|
|
102
|
+
/**
|
|
103
|
+
* @param {MessageQueueAdapter} adapter
|
|
104
|
+
*/
|
|
105
|
+
constructor(adapter) {
|
|
106
|
+
this.adapter = adapter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} duid
|
|
111
|
+
* @param {string} method
|
|
112
|
+
* @param {unknown[]} params
|
|
113
|
+
* @param {boolean} [secure=false]
|
|
114
|
+
* @param {boolean} [photo=false]
|
|
115
|
+
* @param {RequestOptions} [options]
|
|
116
|
+
* @returns {Promise<unknown | undefined>}
|
|
117
|
+
*/
|
|
118
|
+
async sendRequest(
|
|
119
|
+
duid,
|
|
120
|
+
method,
|
|
121
|
+
params,
|
|
122
|
+
secure = false,
|
|
123
|
+
photo = false,
|
|
124
|
+
options = {}
|
|
125
|
+
) {
|
|
126
|
+
const remoteConnection = await this.adapter.isRemoteDevice(duid);
|
|
127
|
+
const version = await this.adapter.getRobotVersion(duid);
|
|
128
|
+
|
|
129
|
+
const deviceOnline = await this.adapter.onlineChecker(duid);
|
|
130
|
+
const mqttConnectionState = this.adapter.rr_mqtt_connector.isConnected();
|
|
131
|
+
let localConnectionState = this.adapter.localConnector.isConnected(duid);
|
|
132
|
+
const cloudOnlyConnection = Boolean(this.adapter.config?.cloudOnlyMode);
|
|
133
|
+
const preferCloudConnection =
|
|
134
|
+
Boolean(options.preferCloud) && mqttConnectionState;
|
|
135
|
+
const preferLocalConnection =
|
|
136
|
+
Boolean(options.preferLocal) &&
|
|
137
|
+
!cloudOnlyConnection &&
|
|
138
|
+
!preferCloudConnection &&
|
|
139
|
+
!remoteConnection &&
|
|
140
|
+
!secure &&
|
|
141
|
+
!photo &&
|
|
142
|
+
method != "get_network_info";
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
preferLocalConnection &&
|
|
146
|
+
!localConnectionState &&
|
|
147
|
+
typeof this.adapter.ensureLocalConnection == "function"
|
|
148
|
+
) {
|
|
149
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
150
|
+
lastTransport: "local-pending",
|
|
151
|
+
lastTransportReason: "preferred-local-reconnect",
|
|
152
|
+
lastCommandMethod: method,
|
|
153
|
+
});
|
|
154
|
+
await this.adapter.ensureLocalConnection(duid);
|
|
155
|
+
localConnectionState = this.adapter.localConnector.isConnected(duid);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// B01 (Q7-series) devices are cloud/MQTT-only and speak a different RPC
|
|
159
|
+
// dialect. Translate the v1-shaped method to the Q7 equivalent here so a
|
|
160
|
+
// single choke point covers every caller (Matter, polling, UI).
|
|
161
|
+
if (b01Q7Adapter.isB01Protocol(version)) {
|
|
162
|
+
const neutral = b01Q7Adapter.neutralResponse(method);
|
|
163
|
+
const translated = b01Q7Adapter.translateOutgoing(method, params);
|
|
164
|
+
|
|
165
|
+
if (!translated) {
|
|
166
|
+
if (neutral) {
|
|
167
|
+
this.adapter.log.debug(
|
|
168
|
+
`Method ${method} has no B01/Q7 equivalent for ${duid}; returning a neutral response.`
|
|
169
|
+
);
|
|
170
|
+
return neutral.value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const unsupported = /** @type {Error & {code?: string}} */ (
|
|
174
|
+
new Error(
|
|
175
|
+
`Method ${method} is not supported on B01/Q7 devices yet (${duid}).`
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
unsupported.code = "B01_METHOD_UNSUPPORTED";
|
|
179
|
+
throw unsupported;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
method = translated.method;
|
|
183
|
+
params = /** @type {any} */ (translated.params);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let useCloudConnection =
|
|
187
|
+
b01Q7Adapter.isB01Protocol(version) ||
|
|
188
|
+
cloudOnlyConnection ||
|
|
189
|
+
preferCloudConnection ||
|
|
190
|
+
remoteConnection ||
|
|
191
|
+
secure ||
|
|
192
|
+
photo ||
|
|
193
|
+
method == "get_network_info";
|
|
194
|
+
if (!useCloudConnection && !localConnectionState && mqttConnectionState) {
|
|
195
|
+
useCloudConnection = true;
|
|
196
|
+
await this.adapter.updateTransportDiagnostics(duid, {
|
|
197
|
+
lastTransport: "cloud",
|
|
198
|
+
lastTransportReason: "local-unavailable-fallback",
|
|
199
|
+
lastCommandMethod: method,
|
|
200
|
+
});
|
|
201
|
+
this.adapter.log.debug(
|
|
202
|
+
`Local connection unavailable for ${duid}. Falling back to cloud connection for method ${method}.`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!useCloudConnection && version == "L01") {
|
|
207
|
+
try {
|
|
208
|
+
if (this.adapter.localConnector.ensureL01Handshake) {
|
|
209
|
+
await this.adapter.localConnector.ensureL01Handshake(duid);
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const errorMessage =
|
|
213
|
+
error instanceof Error ? error.message : String(error);
|
|
214
|
+
this.adapter.log.debug(
|
|
215
|
+
`L01 handshake before request failed for ${duid}: ${errorMessage}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let messageID = /** @type {any} */ (
|
|
221
|
+
b01Q7Adapter.isB01Protocol(version)
|
|
222
|
+
? b01Q7Adapter.createB01MessageId()
|
|
223
|
+
: this.adapter.getRequestId()
|
|
224
|
+
);
|
|
225
|
+
if (photo && typeof messageID === "number") {
|
|
226
|
+
messageID = messageID % 256; // this is a special case. Otherwise photo requests will not have the correct ID in the response.
|
|
227
|
+
}
|
|
228
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
229
|
+
|
|
230
|
+
const protocol = useCloudConnection ? 101 : 4;
|
|
231
|
+
const allowOfflineCloudSend =
|
|
232
|
+
Boolean(options.allowOfflineCloudSend) && useCloudConnection;
|
|
233
|
+
const payload = await this.adapter.message.buildPayload(
|
|
234
|
+
duid,
|
|
235
|
+
protocol,
|
|
236
|
+
messageID,
|
|
237
|
+
method,
|
|
238
|
+
params,
|
|
239
|
+
secure,
|
|
240
|
+
photo
|
|
241
|
+
);
|
|
242
|
+
const roborockMessage = await this.adapter.message.buildRoborockMessage(
|
|
243
|
+
duid,
|
|
244
|
+
protocol,
|
|
245
|
+
timestamp,
|
|
246
|
+
payload
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (roborockMessage) {
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
if (
|
|
252
|
+
!deviceOnline &&
|
|
253
|
+
(useCloudConnection || !localConnectionState) &&
|
|
254
|
+
!allowOfflineCloudSend
|
|
255
|
+
) {
|
|
256
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
257
|
+
lastCommandMethod: method,
|
|
258
|
+
lastTransportReason: "device-offline",
|
|
259
|
+
});
|
|
260
|
+
this.adapter.pendingRequests.delete(messageID);
|
|
261
|
+
this.adapter.log.debug(
|
|
262
|
+
`Device ${duid} offline. Not sending for method ${method} request!`
|
|
263
|
+
);
|
|
264
|
+
reject(
|
|
265
|
+
new Error(
|
|
266
|
+
`Device ${duid} is offline. Not sending method ${method} request.`
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
} else if (!mqttConnectionState && useCloudConnection) {
|
|
270
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
271
|
+
lastTransport: "cloud",
|
|
272
|
+
lastCommandMethod: method,
|
|
273
|
+
lastTransportReason: cloudOnlyConnection
|
|
274
|
+
? "cloud-only-mqtt-unavailable"
|
|
275
|
+
: "mqtt-unavailable",
|
|
276
|
+
});
|
|
277
|
+
this.adapter.pendingRequests.delete(messageID);
|
|
278
|
+
this.adapter.log.debug(
|
|
279
|
+
`Cloud connection not available. Not sending for method ${method} request!`
|
|
280
|
+
);
|
|
281
|
+
reject(
|
|
282
|
+
new Error(
|
|
283
|
+
`Cloud connection not available. Not sending method ${method} request.`
|
|
284
|
+
)
|
|
285
|
+
);
|
|
286
|
+
} else if (!localConnectionState && !useCloudConnection) {
|
|
287
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
288
|
+
lastCommandMethod: method,
|
|
289
|
+
lastTransportReason: "local-socket-unavailable",
|
|
290
|
+
});
|
|
291
|
+
this.adapter.pendingRequests.delete(messageID);
|
|
292
|
+
this.adapter.log.debug(
|
|
293
|
+
`Adapter not connect locally to robot ${duid}. Not sending for method ${method} request!`
|
|
294
|
+
);
|
|
295
|
+
reject(
|
|
296
|
+
new Error(
|
|
297
|
+
`Local connection not available for ${duid}. Not sending method ${method} request.`
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
} else {
|
|
301
|
+
// setup Timeout
|
|
302
|
+
const requestTimeout = getRequestTimeout(
|
|
303
|
+
method,
|
|
304
|
+
options.requestTimeoutMs
|
|
305
|
+
);
|
|
306
|
+
const timeoutSeconds = Math.round(requestTimeout / 1000);
|
|
307
|
+
const timeout = this.adapter.setTimeout(() => {
|
|
308
|
+
this.adapter.pendingRequests.delete(messageID);
|
|
309
|
+
this.adapter.localConnector.clearChunkBuffer(duid);
|
|
310
|
+
if (useCloudConnection) {
|
|
311
|
+
reject(
|
|
312
|
+
new Error(
|
|
313
|
+
`Cloud request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds. MQTT connection state: ${mqttConnectionState}`
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
reject(
|
|
318
|
+
new Error(
|
|
319
|
+
`Local request with id ${messageID} with method ${method} timed out after ${timeoutSeconds} seconds Local connect state: ${localConnectionState}`
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}, requestTimeout);
|
|
324
|
+
|
|
325
|
+
// Store request with resolve and reject functions
|
|
326
|
+
this.adapter.pendingRequests.set(messageID, {
|
|
327
|
+
resolve,
|
|
328
|
+
reject,
|
|
329
|
+
timeout,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (useCloudConnection) {
|
|
333
|
+
if (!deviceOnline && allowOfflineCloudSend) {
|
|
334
|
+
this.adapter.log.debug(
|
|
335
|
+
`Device ${duid} is marked offline, but sending method ${method} via cloud because the command explicitly allows offline cloud delivery.`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
this.adapter.rr_mqtt_connector.sendMessage(duid, roborockMessage);
|
|
339
|
+
const lastTransportReason =
|
|
340
|
+
[
|
|
341
|
+
{
|
|
342
|
+
condition: !deviceOnline && allowOfflineCloudSend,
|
|
343
|
+
reason: "offline-cloud-command",
|
|
344
|
+
},
|
|
345
|
+
{ condition: secure, reason: "secure-command" },
|
|
346
|
+
{ condition: photo, reason: "photo-command" },
|
|
347
|
+
{ condition: cloudOnlyConnection, reason: "cloud-only-mode" },
|
|
348
|
+
{
|
|
349
|
+
condition: preferCloudConnection,
|
|
350
|
+
reason: "preferred-cloud-command",
|
|
351
|
+
},
|
|
352
|
+
{ condition: remoteConnection, reason: "remote-device" },
|
|
353
|
+
{
|
|
354
|
+
condition: method == "get_network_info",
|
|
355
|
+
reason: "network-info-cloud-only",
|
|
356
|
+
},
|
|
357
|
+
].find((entry) => entry.condition)?.reason ?? "cloud-request";
|
|
358
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
359
|
+
lastTransport: "cloud",
|
|
360
|
+
lastTransportReason,
|
|
361
|
+
lastCommandMethod: method,
|
|
362
|
+
});
|
|
363
|
+
this.adapter.log.debug(
|
|
364
|
+
`Sent payload for ${duid} with ${payload} using cloud connection`
|
|
365
|
+
);
|
|
366
|
+
//client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${duid}`, roborockMessage, { qos: 1 });
|
|
367
|
+
// this.adapter.log.debug(`Promise for messageID ${messageID} created. ${this.adapter.message._decodeMsg(roborockMessage, duid).payload}`);
|
|
368
|
+
} else {
|
|
369
|
+
const lengthBuffer = Buffer.alloc(4);
|
|
370
|
+
lengthBuffer.writeUInt32BE(roborockMessage.length, 0);
|
|
371
|
+
|
|
372
|
+
const fullMessage = Buffer.concat([lengthBuffer, roborockMessage]);
|
|
373
|
+
this.adapter.localConnector.sendMessage(duid, fullMessage);
|
|
374
|
+
this.adapter.updateTransportDiagnostics(duid, {
|
|
375
|
+
lastTransport: "local",
|
|
376
|
+
lastTransportReason: "local-request",
|
|
377
|
+
lastCommandMethod: method,
|
|
378
|
+
});
|
|
379
|
+
// this.adapter.log.debug(`sent fullMessage: ${fullMessage.toString("hex")}`);
|
|
380
|
+
this.adapter.log.debug(
|
|
381
|
+
`Sent payload for ${duid} with ${payload} using local connection`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}).finally(() => {
|
|
386
|
+
this.adapter.log.debug(
|
|
387
|
+
`Size of message queue: ${this.adapter.pendingRequests.size}`
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
} else {
|
|
391
|
+
// Never resolve successfully when nothing was sent. Callers that wait
|
|
392
|
+
// for acknowledgement (Matter commands, HomeKit switches) would
|
|
393
|
+
// otherwise log the command as acknowledged even though the robot never
|
|
394
|
+
// received it.
|
|
395
|
+
this.adapter.catchError(
|
|
396
|
+
"Failed to build buildRoborockMessage!",
|
|
397
|
+
"function sendRequest",
|
|
398
|
+
duid
|
|
399
|
+
);
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Failed to build Roborock message for method ${method} on ${duid}; the command was not sent.`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
module.exports = {
|
|
408
|
+
messageQueueHandler,
|
|
409
|
+
getRequestTimeout,
|
|
410
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
411
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const axios = require("axios");
|
|
5
|
+
|
|
6
|
+
const API_V3_SIGN = "api/v3/key/sign";
|
|
7
|
+
const API_V4_LOGIN_CODE = "api/v4/auth/email/login/code";
|
|
8
|
+
const API_V4_LOGIN_PASSWORD = "api/v4/auth/email/login/pwd";
|
|
9
|
+
const API_V4_EMAIL_CODE = "api/v4/email/code/send";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_HEADERS = {
|
|
12
|
+
header_appversion: "4.54.02",
|
|
13
|
+
header_clientlang: "en",
|
|
14
|
+
header_phonemodel: "Pixel 7",
|
|
15
|
+
header_phonesystem: "Android",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function normalizeBaseURL(baseURL) {
|
|
19
|
+
if (!baseURL) {
|
|
20
|
+
return "usiot.roborock.com";
|
|
21
|
+
}
|
|
22
|
+
return baseURL.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseHostname(baseURL) {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(
|
|
28
|
+
`https://${normalizeBaseURL(baseURL)}`
|
|
29
|
+
).hostname.toLowerCase();
|
|
30
|
+
} catch {
|
|
31
|
+
return normalizeBaseURL(baseURL).split("/")[0].split(":")[0].toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getRegionConfig(baseURL) {
|
|
36
|
+
const hostname = parseHostname(baseURL);
|
|
37
|
+
const labels = hostname.split(".").filter(Boolean);
|
|
38
|
+
if (labels.includes("euiot")) {
|
|
39
|
+
return { country: "DE", countryCode: "49" };
|
|
40
|
+
}
|
|
41
|
+
if (labels.includes("usiot")) {
|
|
42
|
+
return { country: "US", countryCode: "1" };
|
|
43
|
+
}
|
|
44
|
+
if (labels.includes("cniot")) {
|
|
45
|
+
return { country: "CN", countryCode: "86" };
|
|
46
|
+
}
|
|
47
|
+
if (hostname === "api.roborock.com") {
|
|
48
|
+
return { country: "SG", countryCode: "65" };
|
|
49
|
+
}
|
|
50
|
+
return { country: "US", countryCode: "1" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function encryptPassword(password, k) {
|
|
54
|
+
const derivedKey = k.slice(4) + k.slice(0, 4);
|
|
55
|
+
const cipher = crypto.createCipheriv(
|
|
56
|
+
"aes-128-ecb",
|
|
57
|
+
Buffer.from(derivedKey, "utf-8"),
|
|
58
|
+
null
|
|
59
|
+
);
|
|
60
|
+
cipher.setAutoPadding(true);
|
|
61
|
+
let encrypted = cipher.update(password, "utf8", "base64");
|
|
62
|
+
encrypted += cipher.final("base64");
|
|
63
|
+
return encrypted;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createLoginApi({ baseURL, username, clientID, language }) {
|
|
67
|
+
return axios.create({
|
|
68
|
+
baseURL: `https://${normalizeBaseURL(baseURL)}`,
|
|
69
|
+
headers: {
|
|
70
|
+
header_clientid: crypto
|
|
71
|
+
.createHash("md5")
|
|
72
|
+
.update(username)
|
|
73
|
+
.update(clientID)
|
|
74
|
+
.digest()
|
|
75
|
+
.toString("base64"),
|
|
76
|
+
header_clientlang: language || DEFAULT_HEADERS.header_clientlang,
|
|
77
|
+
header_appversion: DEFAULT_HEADERS.header_appversion,
|
|
78
|
+
header_phonemodel: DEFAULT_HEADERS.header_phonemodel,
|
|
79
|
+
header_phonesystem: DEFAULT_HEADERS.header_phonesystem,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function signRequest(loginApi, s) {
|
|
85
|
+
const res = await loginApi.post(`${API_V3_SIGN}?s=${s}`);
|
|
86
|
+
return res.data && res.data.data ? res.data.data : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function requestEmailCode(loginApi, email) {
|
|
90
|
+
const params = new URLSearchParams();
|
|
91
|
+
params.append("type", "login");
|
|
92
|
+
params.append("email", email);
|
|
93
|
+
params.append("platform", "");
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const res = await loginApi.post(API_V4_EMAIL_CODE, params.toString());
|
|
97
|
+
if (res.data && res.data.code !== 200) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Send code failed: ${res.data.msg || "Unknown error"} (Code: ${res.data.code})`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return res.data;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.response && error.response.data) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Send code failed: ${JSON.stringify(error.response.data)}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loginWithCode(
|
|
114
|
+
loginApi,
|
|
115
|
+
{ email, code, country, countryCode, k, s }
|
|
116
|
+
) {
|
|
117
|
+
const headers = {
|
|
118
|
+
"x-mercy-k": k,
|
|
119
|
+
"x-mercy-ks": s,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const params = new URLSearchParams({
|
|
123
|
+
country,
|
|
124
|
+
countryCode,
|
|
125
|
+
email,
|
|
126
|
+
code,
|
|
127
|
+
majorVersion: "14",
|
|
128
|
+
minorVersion: "0",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const res = await loginApi.post(API_V4_LOGIN_CODE, params.toString(), {
|
|
133
|
+
headers,
|
|
134
|
+
});
|
|
135
|
+
return res.data;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.response && error.response.data) {
|
|
138
|
+
return error.response.data;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function loginByPassword(loginApi, { email, password, k, s }) {
|
|
145
|
+
const headers = {
|
|
146
|
+
"x-mercy-k": k,
|
|
147
|
+
"x-mercy-ks": s,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const params = new URLSearchParams({
|
|
151
|
+
email,
|
|
152
|
+
password: encryptPassword(password, k),
|
|
153
|
+
majorVersion: "14",
|
|
154
|
+
minorVersion: "0",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const res = await loginApi.post(API_V4_LOGIN_PASSWORD, params.toString(), {
|
|
159
|
+
headers,
|
|
160
|
+
});
|
|
161
|
+
return res.data;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error.response && error.response.data) {
|
|
164
|
+
return error.response.data;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
createLoginApi,
|
|
172
|
+
getRegionConfig,
|
|
173
|
+
normalizeBaseURL,
|
|
174
|
+
signRequest,
|
|
175
|
+
requestEmailCode,
|
|
176
|
+
loginWithCode,
|
|
177
|
+
loginByPassword,
|
|
178
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const forge = require("node-forge");
|
|
5
|
+
|
|
6
|
+
function generateRsaKeyPair() {
|
|
7
|
+
const keypair = forge.pki.rsa.generateKeyPair(2048);
|
|
8
|
+
const keys = {
|
|
9
|
+
public: { n: null, e: null },
|
|
10
|
+
private: {
|
|
11
|
+
n: null,
|
|
12
|
+
e: null,
|
|
13
|
+
d: null,
|
|
14
|
+
p: null,
|
|
15
|
+
q: null,
|
|
16
|
+
dmp1: null,
|
|
17
|
+
dmq1: null,
|
|
18
|
+
coeff: null,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Convert the keys to the desired format
|
|
23
|
+
keys.public.n = keypair.publicKey.n.toString(16);
|
|
24
|
+
keys.public.e = keypair.publicKey.e.toString(16);
|
|
25
|
+
keys.private.n = keypair.privateKey.n.toString(16);
|
|
26
|
+
keys.private.e = keypair.privateKey.e.toString(16);
|
|
27
|
+
keys.private.d = keypair.privateKey.d.toString(16);
|
|
28
|
+
keys.private.p = keypair.privateKey.p.toString(16);
|
|
29
|
+
keys.private.q = keypair.privateKey.q.toString(16);
|
|
30
|
+
keys.private.dmp1 = keypair.privateKey.dP.toString(16);
|
|
31
|
+
keys.private.dmq1 = keypair.privateKey.dQ.toString(16);
|
|
32
|
+
keys.private.coeff = keypair.privateKey.qInv.toString(16);
|
|
33
|
+
|
|
34
|
+
return keys;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function md5bin(str) {
|
|
38
|
+
return crypto.createHash("md5").update(str).digest();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function md5hex(str) {
|
|
42
|
+
return crypto.createHash("md5").update(str).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function encodeTimestamp(timestamp) {
|
|
46
|
+
const hex = timestamp.toString(16).padStart(8, "0").split("");
|
|
47
|
+
return [5, 6, 3, 7, 1, 2, 0, 4].map((idx) => hex[idx]).join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
generateRsaKeyPair,
|
|
52
|
+
md5bin,
|
|
53
|
+
md5hex,
|
|
54
|
+
encodeTimestamp,
|
|
55
|
+
};
|