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,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
+ };