homebridge-roborock-vacuum 1.2.4 → 1.3.1

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.
@@ -8,6 +8,8 @@ const express = require("express");
8
8
  const { debug } = require("console");
9
9
  const { get } = require("http");
10
10
 
11
+ const roborockAuth = require("./lib/roborockAuth");
12
+
11
13
  const rrLocalConnector = require("./lib/localConnector").localConnector;
12
14
  const roborock_mqtt_connector = require("./lib/roborock_mqtt_connector").roborock_mqtt_connector;
13
15
  const rrMessage = require("./lib/message").message;
@@ -67,6 +69,13 @@ class Roborock {
67
69
  this.name = "roborock";
68
70
  this.deviceNotify = null;
69
71
  this.baseURL = options.baseURL || "usiot.roborock.com";
72
+
73
+ this.userData = options.userData || null;
74
+ this.authState = {
75
+ twoFactorRequired: false,
76
+ statusMessage: "",
77
+ };
78
+ this.pendingAuth = null;
70
79
  }
71
80
 
72
81
  isInited() {
@@ -113,7 +122,7 @@ class Roborock {
113
122
 
114
123
  try {
115
124
  if(id == "UserData" || id == "clientID"){
116
- return JSON.parse(fs.readFileSync(path.resolve(__dirname, `./data/${id}`), 'utf8'));
125
+ return JSON.parse(fs.readFileSync(this.getPersistPath(id), 'utf8'));
117
126
  }
118
127
 
119
128
  return this.states[id];
@@ -129,7 +138,8 @@ class Roborock {
129
138
  try {
130
139
 
131
140
  if(id == "UserData" || id == "clientID"){
132
- fs.writeFileSync(path.resolve(__dirname, `./data/${id}`), JSON.stringify(state, null, 2, 'utf8'));
141
+ fs.mkdirSync(path.dirname(this.getPersistPath(id)), { recursive: true });
142
+ fs.writeFileSync(this.getPersistPath(id), JSON.stringify(state, null, 2, 'utf8'));
133
143
  }
134
144
 
135
145
  this.states[id] = state;
@@ -151,7 +161,7 @@ class Roborock {
151
161
  try {
152
162
 
153
163
  if(id == "UserData" || id == "clientID"){
154
- fs.unlinkSync(path.resolve(__dirname, `./data/${id}`));
164
+ fs.unlinkSync(this.getPersistPath(id));
155
165
  }
156
166
 
157
167
  delete this.states[id];
@@ -166,6 +176,30 @@ class Roborock {
166
176
  this.log.debug(`subscribeStates: ${id}`);
167
177
  }
168
178
 
179
+ getPersistPath(id) {
180
+ const storagePath = this.config.storagePath;
181
+ if (storagePath) {
182
+ return path.join(storagePath, `roborock.${id}`);
183
+ }
184
+ return path.resolve(__dirname, `./data/${id}`);
185
+ }
186
+
187
+ parseSkipDevices(value) {
188
+ if (!value) {
189
+ return [];
190
+ }
191
+ if (Array.isArray(value)) {
192
+ return value.map((entry) => `${entry}`.trim()).filter((entry) => entry);
193
+ }
194
+ if (typeof value === "string") {
195
+ return value
196
+ .split(",")
197
+ .map((entry) => entry.trim())
198
+ .filter((entry) => entry);
199
+ }
200
+ return [];
201
+ }
202
+
169
203
  /**
170
204
  * Is called when databases are connected and adapter received configuration.
171
205
  */
@@ -190,24 +224,33 @@ class Roborock {
190
224
  this.log.error(`Error while retrieving or setting clientID: ${error.message}`);
191
225
  }
192
226
 
193
- if (!this.config.username || !this.config.password) {
194
- this.log.error("Username or password missing!");
227
+ if (!this.config.username) {
228
+ this.log.error("Email is missing!");
229
+ return;
230
+ }
231
+ if (!this.config.password && !this.isValidUserData(this.userData)) {
232
+ this.log.error("Password or valid token is missing!");
195
233
  return;
196
234
  }
197
235
 
198
236
  this.instance = clientID;
199
237
 
200
238
  // Initialize the login API (which is needed to get access to the real API).
201
- this.loginApi = axios.create({
202
- baseURL: 'https://' + this.baseURL,
203
- headers: {
204
- header_clientid: crypto.createHash("md5").update(this.config.username).update(clientID).digest().toString("base64"),
205
- },
239
+ this.loginApi = roborockAuth.createLoginApi({
240
+ baseURL: this.baseURL,
241
+ username: this.config.username,
242
+ clientID,
243
+ language: this.language,
206
244
  });
207
245
  await this.setStateAsync("info.connection", { val: true, ack: true });
208
246
  // api/v1/getUrlByEmail(email = ...)
209
247
 
210
248
  const userdata = await this.getUserData(this.loginApi);
249
+ if (!userdata) {
250
+ this.log.error("Login failed or requires 2FA. Please complete authentication in the Config UI.");
251
+ await this.setStateAsync("info.connection", { val: false, ack: true });
252
+ return;
253
+ }
211
254
 
212
255
  try {
213
256
  this.loginApi.defaults.headers.common["Authorization"] = userdata.token;
@@ -258,12 +301,14 @@ class Roborock {
258
301
  ack: true,
259
302
  });
260
303
 
261
- // skip devices that sn in ingoredDevices
304
+ // skip devices that sn in ignoredDevices or skipDevices
262
305
  const ignoredDevices = this.config.ignoredDevices || [];
306
+ const skipDevices = this.parseSkipDevices(this.config.skipDevices);
307
+ const ignoredSet = new Set([...ignoredDevices, ...skipDevices]);
263
308
  // create devices and set states
264
309
  this.products = homedataResult.products;
265
310
  this.devices = homedataResult.devices;
266
- this.devices = this.devices.filter((device) => !ignoredDevices.includes(device.sn));
311
+ this.devices = this.devices.filter((device) => !ignoredSet.has(device.sn));
267
312
  this.localKeys = new Map(this.devices.map((device) => [device.duid, device.localKey]));
268
313
 
269
314
  // this.adapter.log.debug(`initUser test: ${JSON.stringify(Array.from(this.adapter.localKeys.entries()))}`);
@@ -352,26 +397,62 @@ class Roborock {
352
397
 
353
398
  async getUserData(loginApi) {
354
399
  try {
355
- const response = await loginApi.post(
356
- "api/v1/login",
357
- new URLSearchParams({
358
- username: this.config.username,
359
- password: this.config.password,
360
- needtwostepauth: "false",
361
- }).toString()
362
- );
363
- const userdata = response.data.data;
364
-
365
- if (!userdata) {
366
- throw new Error("Login returned empty userdata.");
400
+ if (this.isValidUserData(this.userData)) {
401
+ this.log.info("Using session from config.");
402
+ return this.userData;
367
403
  }
368
404
 
369
- await this.setStateAsync("UserData", {
370
- val: JSON.stringify(userdata),
371
- ack: true,
405
+ const cachedState = await this.getStateAsync("UserData");
406
+ if (cachedState && cachedState.val) {
407
+ try {
408
+ const cached = JSON.parse(cachedState.val);
409
+ if (this.isValidUserData(cached)) {
410
+ this.userData = cached;
411
+ this.log.info("Using cached session from disk.");
412
+ return cached;
413
+ }
414
+ } catch (error) {
415
+ this.log.warn("Cached session is invalid and will be ignored.");
416
+ }
417
+ }
418
+
419
+ if (!this.config.password) {
420
+ this.log.error("Password is missing and no valid token is available.");
421
+ return null;
422
+ }
423
+
424
+ const signData = await this.ensureAuthSignature();
425
+ if (!signData) {
426
+ throw new Error("Failed to obtain login signature.");
427
+ }
428
+
429
+ const loginResult = await roborockAuth.loginByPassword(loginApi, {
430
+ email: this.config.username,
431
+ password: this.config.password,
432
+ k: signData.k,
433
+ s: signData.s,
372
434
  });
373
435
 
374
- return userdata;
436
+ if (loginResult && loginResult.code === 200 && loginResult.data) {
437
+ this.userData = loginResult.data;
438
+ this.pendingAuth = null;
439
+ await this.setStateAsync("UserData", {
440
+ val: JSON.stringify(this.userData),
441
+ ack: true,
442
+ });
443
+ this.authState.twoFactorRequired = false;
444
+ this.authState.statusMessage = "";
445
+ return this.userData;
446
+ }
447
+
448
+ if (loginResult && loginResult.code === 2031) {
449
+ this.authState.twoFactorRequired = true;
450
+ this.authState.statusMessage = "Two-factor authentication required.";
451
+ this.log.error("Two-factor authentication required. Use the Config UI to continue.");
452
+ return null;
453
+ }
454
+
455
+ throw new Error(`Login failed: ${JSON.stringify(loginResult)}`);
375
456
  } catch (error) {
376
457
  this.log.error(`Error in getUserData: ${error.message}`);
377
458
  await this.deleteStateAsync("HomeData");
@@ -380,6 +461,81 @@ class Roborock {
380
461
  }
381
462
  }
382
463
 
464
+ isValidUserData(userdata) {
465
+ return userdata && userdata.token && userdata.rriot;
466
+ }
467
+
468
+ async ensureAuthSignature() {
469
+ if (this.pendingAuth && this.pendingAuth.k && this.pendingAuth.s) {
470
+ return this.pendingAuth;
471
+ }
472
+
473
+ if (!this.loginApi) {
474
+ throw new Error("Login API is not initialized.");
475
+ }
476
+
477
+ const s = crypto.randomBytes(12).toString("base64").substring(0, 16).replace(/\+/g, "X").replace(/\//g, "Y");
478
+ const signData = await roborockAuth.signRequest(this.loginApi, s);
479
+ if (!signData || !signData.k) {
480
+ return null;
481
+ }
482
+
483
+ this.pendingAuth = { k: signData.k, s };
484
+ return this.pendingAuth;
485
+ }
486
+
487
+ async sendTwoFactorEmail() {
488
+ if (!this.loginApi) {
489
+ throw new Error("Login API is not initialized.");
490
+ }
491
+
492
+ try {
493
+ await roborockAuth.requestEmailCode(this.loginApi, this.config.username);
494
+ } catch (error) {
495
+ this.log.error(`2FA email request failed: ${error.message}`);
496
+ throw error;
497
+ }
498
+ this.authState.twoFactorRequired = true;
499
+ this.authState.statusMessage = "Verification email sent.";
500
+ return { ok: true };
501
+ }
502
+
503
+ async verifyTwoFactorCode(code) {
504
+ if (!this.loginApi) {
505
+ throw new Error("Login API is not initialized.");
506
+ }
507
+
508
+ const signData = await this.ensureAuthSignature();
509
+ if (!signData) {
510
+ throw new Error("Missing login signature.");
511
+ }
512
+
513
+ const region = roborockAuth.getRegionConfig(this.baseURL);
514
+ const loginResult = await roborockAuth.loginWithCode(this.loginApi, {
515
+ email: this.config.username,
516
+ code,
517
+ country: region.country,
518
+ countryCode: region.countryCode,
519
+ k: signData.k,
520
+ s: signData.s,
521
+ });
522
+
523
+ if (loginResult && loginResult.code === 200 && loginResult.data) {
524
+ this.userData = loginResult.data;
525
+ this.pendingAuth = null;
526
+ await this.setStateAsync("UserData", {
527
+ val: JSON.stringify(this.userData),
528
+ ack: true,
529
+ });
530
+ this.authState.twoFactorRequired = false;
531
+ this.authState.statusMessage = "Two-factor authentication completed.";
532
+ return this.userData;
533
+ }
534
+
535
+ this.log.error(`2FA verification failed: ${JSON.stringify(loginResult)}`);
536
+ throw new Error(`2FA verification failed: ${loginResult?.msg || "Unknown error"}`);
537
+ }
538
+
383
539
  async getNetworkInfo() {
384
540
  const devices = this.devices;
385
541
  for (const device in devices) {
@@ -761,6 +917,8 @@ class Roborock {
761
917
  case "roborock.vacuum.a08":
762
918
  case "roborock.vacuum.a10":
763
919
  case "roborock.vacuum.a40":
920
+ case "roborock.vacuum.a140":
921
+ case "roborock.vacuum.ss07":
764
922
  //do nothing
765
923
  break;
766
924
  case "roborock.vacuum.s6":
@@ -1439,4 +1597,3 @@ class Roborock {
1439
1597
  module.exports = {Roborock};
1440
1598
 
1441
1599
  ////////////////////////////////////////////////////////////////////////////////////////////////////
1442
-
@@ -1,6 +1,7 @@
1
1
  const Roborock = require('./roborockAPI.js').Roborock;
2
2
 
3
- var roborock = new Roborock({username: "tasict@gmail.com", password: "tasict690629", debug: true});
3
+ var roborock = new Roborock({username: "tasict@gmail.com", debug: true});
4
+
4
5
 
5
6
  roborock.setDeviceNotify(function(id, homeData){
6
7
  console.info(`${id} deviceNotify:${JSON.stringify(homeData)}`);
@@ -1,4 +0,0 @@
1
- {
2
- "val": "{\"uid\":1466357,\"tokentype\":\"\",\"token\":\"rr6238c899155830:1JZxT5AINkUhb5JDyL8S+w==:0198e43aaf3174e3af11c0741cb4d6bc\",\"rruid\":\"rr6238c899155830\",\"region\":\"us\",\"countrycode\":\"886\",\"country\":\"TW\",\"nickname\":\"tasict\",\"rriot\":{\"u\":\"1RQsQJ2o8bxMCo6F45pICu\",\"s\":\"hSvNfL\",\"h\":\"FqZ6lwhKjH\",\"k\":\"HM375uvZ\",\"r\":{\"r\":\"US\",\"a\":\"https://api-us.roborock.com\",\"m\":\"ssl://mqtt-us.roborock.com:8883\",\"l\":\"https://wood-us.roborock.com\"}},\"tuyaDeviceState\":0,\"avatarurl\":\"https://files.roborock.com/iot/default_avatar.png\"}",
3
- "ack": true
4
- }
@@ -1,4 +0,0 @@
1
- {
2
- "val": "bc3fcaca-177e-4d92-97ad-84cdb8eac2d5",
3
- "ack": true
4
- }
@@ -1,24 +0,0 @@
1
- {
2
- "uid": 1466357,
3
- "tokentype": "",
4
- "token": "d23f4a8b14d34fb9b1dbae6c36778b7c-6DLl6x7HW8TjEYdXLipfLA==",
5
- "rruid": "rr6238c899155830",
6
- "region": "us",
7
- "countrycode": "886",
8
- "country": "TW",
9
- "nickname": "tasict",
10
- "rriot": {
11
- "u": "1RQsQJ2o8bxMCo6F45pICu",
12
- "s": "RHsYyP",
13
- "h": "XeItWF64Ik",
14
- "k": "VR1VI17T",
15
- "r": {
16
- "r": "US",
17
- "a": "https://api-us.roborock.com",
18
- "m": "ssl://mqtt-us.roborock.com:8883",
19
- "l": "https://wood-us.roborock.com"
20
- }
21
- },
22
- "tuyaDeviceState": 0,
23
- "avatarurl": "https://files.roborock.com/iottest/default_avatar.png"
24
- }