matterbridge-roborock-vacuum-plugin 1.1.1-rc08 → 1.1.1-rc09

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/dist/platform.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { MatterbridgeDynamicPlatform } from 'matterbridge';
2
2
  import * as axios from 'axios';
3
+ import crypto from 'node:crypto';
3
4
  import { debugStringify } from 'matterbridge/logger';
4
5
  import RoborockService from './roborockService.js';
5
6
  import { PLUGIN_NAME } from './settings.js';
@@ -46,8 +47,8 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
46
47
  await this.ready;
47
48
  await this.clearSelect();
48
49
  await this.persist.init();
49
- if (this.config.username === undefined || this.config.password === undefined) {
50
- this.log.error('"username" and "password" are required in the config');
50
+ if (this.config.username === undefined) {
51
+ this.log.error('"username" (email address) is required in the config');
51
52
  return;
52
53
  }
53
54
  const axiosInstance = axios.default ?? axios;
@@ -59,23 +60,43 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
59
60
  this.log.notice(`cleanModeSettings ${debugStringify(this.cleanModeSettings)}`);
60
61
  }
61
62
  this.platformRunner = new PlatformRunner(this);
62
- this.roborockService = new RoborockService(() => new RoborockAuthenticateApi(this.log, axiosInstance), (logger, ud) => new RoborockIoTApi(ud, logger), this.config.refreshInterval ?? 60, this.clientManager, this.log);
63
+ let deviceId = (await this.persist.getItem('deviceId'));
64
+ if (!deviceId) {
65
+ deviceId = crypto.randomUUID();
66
+ await this.persist.setItem('deviceId', deviceId);
67
+ this.log.debug('Generated new deviceId:', deviceId);
68
+ }
69
+ else {
70
+ this.log.debug('Using cached deviceId:', deviceId);
71
+ }
72
+ this.roborockService = new RoborockService(() => new RoborockAuthenticateApi(this.log, axiosInstance, deviceId), (logger, ud) => new RoborockIoTApi(ud, logger), this.config.refreshInterval ?? 60, this.clientManager, this.log);
63
73
  const username = this.config.username;
64
- const password = this.config.password;
65
- const userData = await this.roborockService.loginWithPassword(username, password, async () => {
66
- if (this.enableExperimentalFeature?.enableExperimentalFeature && this.enableExperimentalFeature.advancedFeature?.alwaysExecuteAuthentication) {
67
- this.log.debug('Always execute authentication on startup');
68
- return undefined;
74
+ this.log.debug(`config: ${debugStringify(this.config)}`);
75
+ const authenticationPayload = this.config.authentication;
76
+ const password = authenticationPayload.password ?? '';
77
+ const verificationCode = authenticationPayload.verificationCode ?? '';
78
+ const authenticationMethod = authenticationPayload.authenticationMethod;
79
+ this.log.debug(`Authentication method: ${authenticationMethod}`);
80
+ this.log.debug(`Username: ${username}`);
81
+ this.log.debug(`Password provided: ${password !== ''}`);
82
+ this.log.debug(`Verification code provided: ${verificationCode !== ''}`);
83
+ let userData;
84
+ try {
85
+ if (authenticationMethod === 'VerificationCode') {
86
+ this.log.debug('Using verification code from config for authentication');
87
+ userData = await this.authenticate2FA(username, verificationCode);
69
88
  }
70
- const savedUserData = (await this.persist.getItem('userData'));
71
- if (savedUserData) {
72
- this.log.debug('Loading saved userData:', debugStringify(savedUserData));
73
- return savedUserData;
89
+ else {
90
+ userData = await this.authenticateWithPassword(username, password);
74
91
  }
75
- return undefined;
76
- }, async (userData) => {
77
- await this.persist.setItem('userData', userData);
78
- });
92
+ }
93
+ catch (error) {
94
+ this.log.error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`);
95
+ return;
96
+ }
97
+ if (!userData) {
98
+ return;
99
+ }
79
100
  this.log.debug('Initializing - userData:', debugStringify(userData));
80
101
  const devices = await this.roborockService.listDevices(username);
81
102
  this.log.notice('Initializing - devices: ', debugStringify(devices));
@@ -200,4 +221,88 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
200
221
  this.log.logLevel = logLevel;
201
222
  return Promise.resolve();
202
223
  }
224
+ async authenticateWithPassword(username, password) {
225
+ if (!this.roborockService) {
226
+ throw new Error('RoborockService is not initialized');
227
+ }
228
+ this.log.notice('Attempting login with password...');
229
+ const userData = await this.roborockService.loginWithPassword(username, password, async () => {
230
+ if (this.enableExperimentalFeature?.enableExperimentalFeature && this.enableExperimentalFeature.advancedFeature?.alwaysExecuteAuthentication) {
231
+ this.log.debug('Always execute authentication on startup');
232
+ return undefined;
233
+ }
234
+ const savedUserData = (await this.persist.getItem('userData'));
235
+ if (savedUserData) {
236
+ this.log.debug('Loading saved userData:', debugStringify(savedUserData));
237
+ return savedUserData;
238
+ }
239
+ return undefined;
240
+ }, async (userData) => {
241
+ await this.persist.setItem('userData', userData);
242
+ });
243
+ this.log.notice('Authentication successful!');
244
+ return userData;
245
+ }
246
+ async authenticate2FA(username, verificationCode) {
247
+ if (!this.roborockService) {
248
+ throw new Error('RoborockService is not initialized');
249
+ }
250
+ if (!this.enableExperimentalFeature?.advancedFeature?.alwaysExecuteAuthentication) {
251
+ const savedUserData = (await this.persist.getItem('userData'));
252
+ if (savedUserData) {
253
+ this.log.debug('Found saved userData, attempting to use cached token');
254
+ try {
255
+ const userData = await this.roborockService.loginWithCachedToken(username, savedUserData);
256
+ this.log.notice('Successfully authenticated with cached token');
257
+ return userData;
258
+ }
259
+ catch (error) {
260
+ this.log.warn(`Cached token invalid or expired: ${error instanceof Error ? error.message : String(error)}`);
261
+ await this.persist.removeItem('userData');
262
+ }
263
+ }
264
+ }
265
+ if (!verificationCode || verificationCode.trim() === '') {
266
+ const authState = (await this.persist.getItem('authenticateFlowState'));
267
+ const now = Date.now();
268
+ const RATE_LIMIT_MS = 60000;
269
+ if (authState?.codeRequestedAt && now - authState.codeRequestedAt < RATE_LIMIT_MS) {
270
+ const waitSeconds = Math.ceil((RATE_LIMIT_MS - (now - authState.codeRequestedAt)) / 1000);
271
+ this.log.warn(`Please wait ${waitSeconds} seconds before requesting another code.`);
272
+ this.log.notice('============================================');
273
+ this.log.notice('ACTION REQUIRED: Enter verification code');
274
+ this.log.notice(`A verification code was previously sent to: ${username}`);
275
+ this.log.notice('Enter the 6-digit code in the plugin configuration');
276
+ this.log.notice('under the "verificationCode" field, then restart the plugin.');
277
+ this.log.notice('============================================');
278
+ return undefined;
279
+ }
280
+ try {
281
+ this.log.notice(`Requesting verification code for: ${username}`);
282
+ await this.roborockService.requestVerificationCode(username);
283
+ await this.persist.setItem('authenticateFlowState', {
284
+ email: username,
285
+ codeRequestedAt: now,
286
+ });
287
+ this.log.notice('============================================');
288
+ this.log.notice('ACTION REQUIRED: Enter verification code');
289
+ this.log.notice(`A verification code has been sent to: ${username}`);
290
+ this.log.notice('Enter the 6-digit code in the plugin configuration');
291
+ this.log.notice('under the "verificationCode" field, then restart the plugin.');
292
+ this.log.notice('============================================');
293
+ }
294
+ catch (error) {
295
+ this.log.error(`Failed to request verification code: ${error instanceof Error ? error.message : String(error)}`);
296
+ throw error;
297
+ }
298
+ return undefined;
299
+ }
300
+ this.log.notice('Attempting login with verification code...');
301
+ const userData = await this.roborockService.loginWithVerificationCode(username, verificationCode.trim(), async (data) => {
302
+ await this.persist.setItem('userData', data);
303
+ await this.persist.removeItem('authenticateFlowState');
304
+ });
305
+ this.log.notice('Authentication successful!');
306
+ return userData;
307
+ }
203
308
  }
@@ -1,14 +1,18 @@
1
1
  import axios from 'axios';
2
2
  import crypto from 'node:crypto';
3
3
  import { URLSearchParams } from 'node:url';
4
+ import { AuthenticateResponseCode } from '../Zenum/authenticateResponseCode.js';
4
5
  export class RoborockAuthenticateApi {
5
6
  logger;
6
7
  axiosFactory;
7
8
  deviceId;
8
9
  username;
9
10
  authToken;
10
- constructor(logger, axiosFactory = axios) {
11
- this.deviceId = crypto.randomUUID();
11
+ cachedBaseUrl;
12
+ cachedCountry;
13
+ cachedCountryCode;
14
+ constructor(logger, axiosFactory = axios, deviceId) {
15
+ this.deviceId = deviceId ?? crypto.randomUUID();
12
16
  this.axiosFactory = axiosFactory;
13
17
  this.logger = logger;
14
18
  }
@@ -25,6 +29,53 @@ export class RoborockAuthenticateApi {
25
29
  }).toString());
26
30
  return this.auth(username, response.data);
27
31
  }
32
+ async requestCodeV4(email) {
33
+ const api = await this.getAPIFor(email);
34
+ const response = await api.post('api/v4/email/code/send', new URLSearchParams({
35
+ email: email,
36
+ type: 'login',
37
+ platform: '',
38
+ }), {
39
+ headers: {
40
+ 'Content-Type': 'application/x-www-form-urlencoded',
41
+ },
42
+ });
43
+ const apiResponse = response.data;
44
+ if (apiResponse.code === AuthenticateResponseCode.AccountNotFound) {
45
+ throw new Error(`Account not found for email: ${email}`);
46
+ }
47
+ if (apiResponse.code === AuthenticateResponseCode.RateLimited) {
48
+ throw new Error('Rate limited. Please wait before requesting another code.');
49
+ }
50
+ if (apiResponse.code !== AuthenticateResponseCode.Success && apiResponse.code !== undefined) {
51
+ throw new Error(`Failed to send verification code: ${apiResponse.msg} (code: ${apiResponse.code})`);
52
+ }
53
+ this.logger.debug('Verification code requested successfully');
54
+ }
55
+ async loginWithCodeV4(email, code) {
56
+ const api = await this.getAPIFor(email);
57
+ const xMercyKs = this.generateRandomString(16);
58
+ const xMercyK = await this.signKeyV3(api, xMercyKs);
59
+ const response = await api.post('api/v4/auth/email/login/code', null, {
60
+ params: {
61
+ email: email,
62
+ code: code,
63
+ country: this.cachedCountry ?? '',
64
+ countryCode: this.cachedCountryCode ?? '',
65
+ majorVersion: '14',
66
+ minorVersion: '0',
67
+ },
68
+ headers: {
69
+ 'Content-Type': 'application/x-www-form-urlencoded',
70
+ 'x-mercy-ks': xMercyKs,
71
+ 'x-mercy-k': xMercyK,
72
+ header_appversion: '4.54.02',
73
+ header_phonesystem: 'iOS',
74
+ header_phonemodel: 'iPhone16,1',
75
+ },
76
+ });
77
+ return this.authV4(email, response.data);
78
+ }
28
79
  async getHomeDetails() {
29
80
  if (!this.username || !this.authToken) {
30
81
  return undefined;
@@ -37,11 +88,20 @@ export class RoborockAuthenticateApi {
37
88
  }
38
89
  return apiResponse.data;
39
90
  }
91
+ getCachedCountryInfo() {
92
+ return {
93
+ country: this.cachedCountry,
94
+ countryCode: this.cachedCountryCode,
95
+ };
96
+ }
40
97
  async getAPIFor(username) {
41
98
  const baseUrl = await this.getBaseUrl(username);
42
99
  return this.apiForUser(username, baseUrl);
43
100
  }
44
101
  async getBaseUrl(username) {
102
+ if (this.cachedBaseUrl && this.username === username) {
103
+ return this.cachedBaseUrl;
104
+ }
45
105
  const api = await this.apiForUser(username);
46
106
  const response = await api.post('api/v1/getUrlByEmail', new URLSearchParams({
47
107
  email: username,
@@ -51,16 +111,41 @@ export class RoborockAuthenticateApi {
51
111
  if (!apiResponse.data) {
52
112
  throw new Error('Failed to retrieve base URL: ' + apiResponse.msg);
53
113
  }
114
+ this.cachedBaseUrl = apiResponse.data.url;
115
+ this.cachedCountry = apiResponse.data.country;
116
+ this.cachedCountryCode = apiResponse.data.countrycode;
117
+ this.username = username;
54
118
  return apiResponse.data.url;
55
119
  }
56
120
  async apiForUser(username, baseUrl = 'https://usiot.roborock.com') {
57
- return this.axiosFactory.create({
121
+ const instance = this.axiosFactory.create({
58
122
  baseURL: baseUrl,
59
123
  headers: {
60
124
  header_clientid: crypto.createHash('md5').update(username).update(this.deviceId).digest('base64'),
61
125
  Authorization: this.authToken,
126
+ header_clientlang: 'en',
62
127
  },
63
128
  });
129
+ instance.interceptors.request.use((config) => {
130
+ this.logger.debug('=== HTTP Request ===');
131
+ this.logger.debug(`URL: ${config.baseURL}/${config.url}`);
132
+ this.logger.debug(`Method: ${config.method?.toUpperCase()}`);
133
+ this.logger.debug(`Params: ${JSON.stringify(config.params)}`);
134
+ this.logger.debug(`Data: ${JSON.stringify(config.data)}`);
135
+ this.logger.debug(`Headers: ${JSON.stringify(config.headers)}`);
136
+ return config;
137
+ });
138
+ instance.interceptors.response.use((response) => {
139
+ this.logger.debug('=== HTTP Response ===');
140
+ this.logger.debug(`Status: ${response.status}`);
141
+ this.logger.debug(`Data: ${JSON.stringify(response.data)}`);
142
+ return response;
143
+ }, (error) => {
144
+ this.logger.debug('=== HTTP Error ===');
145
+ this.logger.debug(`Error: ${JSON.stringify(error.response?.data ?? error.message)}`);
146
+ return Promise.reject(error);
147
+ });
148
+ return instance;
64
149
  }
65
150
  auth(username, response) {
66
151
  const userdata = response.data;
@@ -70,8 +155,41 @@ export class RoborockAuthenticateApi {
70
155
  this.loginWithAuthToken(username, userdata.token);
71
156
  return userdata;
72
157
  }
158
+ authV4(email, response) {
159
+ if (response.code === AuthenticateResponseCode.InvalidCode) {
160
+ throw new Error('Invalid verification code. Please check and try again.');
161
+ }
162
+ if (response.code === AuthenticateResponseCode.RateLimited) {
163
+ throw new Error('Rate limited. Please wait before trying again.');
164
+ }
165
+ const userdata = response.data;
166
+ if (!userdata || !userdata.token) {
167
+ throw new Error('Authentication failed: ' + response.msg + ' code: ' + response.code);
168
+ }
169
+ this.loginWithAuthToken(email, userdata.token);
170
+ return userdata;
171
+ }
73
172
  loginWithAuthToken(username, token) {
74
173
  this.username = username;
75
174
  this.authToken = token;
76
175
  }
176
+ generateRandomString(length) {
177
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
178
+ let result = '';
179
+ const randomBytes = crypto.randomBytes(length);
180
+ for (let i = 0; i < length; i++) {
181
+ result += chars[randomBytes[i] % chars.length];
182
+ }
183
+ return result;
184
+ }
185
+ async signKeyV3(api, s) {
186
+ const response = await api.post('api/v3/key/sign', null, {
187
+ params: { s },
188
+ });
189
+ const apiResponse = response.data;
190
+ if (!apiResponse.data?.k) {
191
+ throw new Error('Failed to sign key: ' + apiResponse.msg);
192
+ }
193
+ return apiResponse.data.k;
194
+ }
77
195
  }
@@ -0,0 +1,7 @@
1
+ export var AuthenticateResponseCode;
2
+ (function (AuthenticateResponseCode) {
3
+ AuthenticateResponseCode[AuthenticateResponseCode["Success"] = 200] = "Success";
4
+ AuthenticateResponseCode[AuthenticateResponseCode["AccountNotFound"] = 2008] = "AccountNotFound";
5
+ AuthenticateResponseCode[AuthenticateResponseCode["InvalidCode"] = 2018] = "InvalidCode";
6
+ AuthenticateResponseCode[AuthenticateResponseCode["RateLimited"] = 9002] = "RateLimited";
7
+ })(AuthenticateResponseCode || (AuthenticateResponseCode = {}));
@@ -9,4 +9,5 @@ export { DeviceStatus } from './Zmodel/deviceStatus.js';
9
9
  export { ResponseMessage } from './broadcast/model/responseMessage.js';
10
10
  export { MapInfo } from './Zmodel/mapInfo.js';
11
11
  export { AdditionalPropCode } from './Zenum/additionalPropCode.js';
12
+ export { AuthenticateResponseCode } from './Zenum/authenticateResponseCode.js';
12
13
  export { Scene } from './Zmodel/scene.js';
@@ -44,6 +44,18 @@ export default class RoborockService {
44
44
  }
45
45
  return this.auth(userdata);
46
46
  }
47
+ async requestVerificationCode(email) {
48
+ return this.loginApi.requestCodeV4(email);
49
+ }
50
+ async loginWithVerificationCode(email, code, savedUserData) {
51
+ const userdata = await this.loginApi.loginWithCodeV4(email, code);
52
+ await savedUserData(userdata);
53
+ return this.auth(userdata);
54
+ }
55
+ async loginWithCachedToken(username, userData) {
56
+ const validatedUserData = await this.loginApi.loginWithUserData(username, userData);
57
+ return this.auth(validatedUserData);
58
+ }
47
59
  getMessageProcessor(duid) {
48
60
  const messageProcessor = this.messageProcessorMap.get(duid);
49
61
  if (!messageProcessor) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "matterbridge-roborock-vacuum-plugin",
3
3
  "type": "DynamicPlatform",
4
- "version": "1.1.1-rc08",
4
+ "version": "1.1.1-rc09",
5
5
  "whiteList": [],
6
6
  "blackList": [],
7
7
  "useInterval": true,
@@ -35,6 +35,11 @@
35
35
  }
36
36
  }
37
37
  },
38
+ "authentication": {
39
+ "authenticationMethod": "VerificationCode",
40
+ "verificationCode": "",
41
+ "password": ""
42
+ },
38
43
  "debug": true,
39
44
  "unregisterOnShutdown": false,
40
45
  "enableExperimentalFeature": false
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "title": "Matterbridge Roborock Vacuum Plugin",
3
- "description": "matterbridge-roborock-vacuum-plugin v. 1.1.1-rc08 by https://github.com/RinDevJunior",
3
+ "description": "matterbridge-roborock-vacuum-plugin v. 1.1.1-rc09 by https://github.com/RinDevJunior",
4
4
  "type": "object",
5
- "required": ["username", "password"],
5
+ "required": ["username"],
6
6
  "properties": {
7
7
  "name": {
8
8
  "description": "Plugin name",
@@ -26,14 +26,51 @@
26
26
  "selectFrom": "name"
27
27
  },
28
28
  "username": {
29
- "description": "Roborock username (required for the plugin to work)",
29
+ "description": "Roborock account email address",
30
30
  "type": "string"
31
31
  },
32
- "password": {
33
- "description": "Roborock password (required for the plugin to work)",
34
- "type": "string",
35
- "ui:widget": "password"
32
+ "authentication": {
33
+ "description": "Authentication method to use",
34
+ "type": "object",
35
+ "oneOf": [
36
+ {
37
+ "title": "Verification Code",
38
+ "type": "object",
39
+ "properties": {
40
+ "authenticationMethod": {
41
+ "const": "VerificationCode",
42
+ "type": "string",
43
+ "default": "VerificationCode",
44
+ "readOnly": true
45
+ },
46
+ "verificationCode": {
47
+ "description": "6-digit verification code sent to your email. Leave empty to request a new code, then restart the plugin after entering the code.",
48
+ "type": "string",
49
+ "maxLength": 6
50
+ }
51
+ },
52
+ "required": ["authenticationMethod", "verificationCode"]
53
+ },
54
+ {
55
+ "title": "Password",
56
+ "type": "object",
57
+ "properties": {
58
+ "authenticationMethod": {
59
+ "const": "Password",
60
+ "type": "string",
61
+ "readOnly": true
62
+ },
63
+ "password": {
64
+ "description": "[DEPRECATED] Password login is no longer supported. Use verification code instead.",
65
+ "type": "string",
66
+ "ui:widget": "password"
67
+ }
68
+ },
69
+ "required": ["authenticationMethod", "password"]
70
+ }
71
+ ]
36
72
  },
73
+
37
74
  "refreshInterval": {
38
75
  "description": "Refresh interval in seconds (default: 60)",
39
76
  "type": "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matterbridge-roborock-vacuum-plugin",
3
- "version": "1.1.1-rc08",
3
+ "version": "1.1.1-rc09",
4
4
  "description": "Matterbridge Roborock Vacuum Plugin",
5
5
  "author": "https://github.com/RinDevJunior",
6
6
  "license": "MIT",
@@ -8,6 +8,12 @@ export interface AdvancedFeature {
8
8
  enableMultipleMap: boolean;
9
9
  }
10
10
 
11
+ export interface AuthenticationPayload {
12
+ authenticationMethod: 'VerificationCode' | 'Password';
13
+ verificationCode?: string;
14
+ password?: string;
15
+ }
16
+
11
17
  export interface ExperimentalFeatureSetting {
12
18
  enableExperimentalFeature: boolean;
13
19
  advancedFeature: AdvancedFeature;
package/src/platform.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { PlatformMatterbridge, MatterbridgeDynamicPlatform, PlatformConfig } from 'matterbridge';
2
2
  import * as axios from 'axios';
3
+ import crypto from 'node:crypto';
3
4
  import { AnsiLogger, debugStringify, LogLevel } from 'matterbridge/logger';
4
5
  import RoborockService from './roborockService.js';
5
6
  import { PLUGIN_NAME } from './settings.js';
@@ -9,9 +10,9 @@ import { PlatformRunner } from './platformRunner.js';
9
10
  import { RoborockVacuumCleaner } from './rvc.js';
10
11
  import { configurateBehavior } from './behaviorFactory.js';
11
12
  import { NotifyMessageTypes } from './notifyMessageTypes.js';
12
- import { Device, RoborockAuthenticateApi, RoborockIoTApi, UserData } from './roborockCommunication/index.js';
13
+ import { Device, RoborockAuthenticateApi, RoborockIoTApi, UserData, AuthenticateFlowState } from './roborockCommunication/index.js';
13
14
  import { getSupportedAreas, getSupportedScenes } from './initialData/index.js';
14
- import { CleanModeSettings, createDefaultExperimentalFeatureSetting, ExperimentalFeatureSetting } from './model/ExperimentalFeatureSetting.js';
15
+ import { AuthenticationPayload, CleanModeSettings, createDefaultExperimentalFeatureSetting, ExperimentalFeatureSetting } from './model/ExperimentalFeatureSetting.js';
15
16
  import { ServiceArea } from 'matterbridge/matter/clusters';
16
17
  import NodePersist from 'node-persist';
17
18
  import Path from 'node:path';
@@ -61,8 +62,8 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
61
62
  await this.persist.init();
62
63
 
63
64
  // Verify that the config is correct
64
- if (this.config.username === undefined || this.config.password === undefined) {
65
- this.log.error('"username" and "password" are required in the config');
65
+ if (this.config.username === undefined) {
66
+ this.log.error('"username" (email address) is required in the config');
66
67
  return;
67
68
  }
68
69
 
@@ -80,8 +81,18 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
80
81
 
81
82
  this.platformRunner = new PlatformRunner(this);
82
83
 
84
+ // Load or generate deviceId for consistent authentication
85
+ let deviceId = (await this.persist.getItem('deviceId')) as string | undefined;
86
+ if (!deviceId) {
87
+ deviceId = crypto.randomUUID();
88
+ await this.persist.setItem('deviceId', deviceId);
89
+ this.log.debug('Generated new deviceId:', deviceId);
90
+ } else {
91
+ this.log.debug('Using cached deviceId:', deviceId);
92
+ }
93
+
83
94
  this.roborockService = new RoborockService(
84
- () => new RoborockAuthenticateApi(this.log, axiosInstance),
95
+ () => new RoborockAuthenticateApi(this.log, axiosInstance, deviceId),
85
96
  (logger, ud) => new RoborockIoTApi(ud, logger),
86
97
  (this.config.refreshInterval as number) ?? 60,
87
98
  this.clientManager,
@@ -89,28 +100,37 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
89
100
  );
90
101
 
91
102
  const username = this.config.username as string;
92
- const password = this.config.password as string;
93
103
 
94
- const userData = await this.roborockService.loginWithPassword(
95
- username,
96
- password,
97
- async () => {
98
- if (this.enableExperimentalFeature?.enableExperimentalFeature && this.enableExperimentalFeature.advancedFeature?.alwaysExecuteAuthentication) {
99
- this.log.debug('Always execute authentication on startup');
100
- return undefined;
101
- }
104
+ this.log.debug(`config: ${debugStringify(this.config)}`);
105
+
106
+ const authenticationPayload = this.config.authentication as AuthenticationPayload;
107
+ const password = authenticationPayload.password ?? '';
108
+ const verificationCode = authenticationPayload.verificationCode ?? '';
109
+ const authenticationMethod = authenticationPayload.authenticationMethod as 'VerificationCode' | 'Password';
110
+
111
+ this.log.debug(`Authentication method: ${authenticationMethod}`);
112
+ this.log.debug(`Username: ${username}`);
113
+ this.log.debug(`Password provided: ${password !== ''}`);
114
+ this.log.debug(`Verification code provided: ${verificationCode !== ''}`);
115
+
116
+ // Authenticate using 2FA flow
117
+ let userData: UserData | undefined;
118
+ try {
119
+ if (authenticationMethod === 'VerificationCode') {
120
+ this.log.debug('Using verification code from config for authentication');
121
+ userData = await this.authenticate2FA(username, verificationCode);
122
+ } else {
123
+ userData = await this.authenticateWithPassword(username, password);
124
+ }
125
+ } catch (error) {
126
+ this.log.error(`Authentication failed: ${error instanceof Error ? error.message : String(error)}`);
127
+ return;
128
+ }
102
129
 
103
- const savedUserData = (await this.persist.getItem('userData')) as UserData | undefined;
104
- if (savedUserData) {
105
- this.log.debug('Loading saved userData:', debugStringify(savedUserData));
106
- return savedUserData;
107
- }
108
- return undefined;
109
- },
110
- async (userData: UserData) => {
111
- await this.persist.setItem('userData', userData);
112
- },
113
- );
130
+ if (!userData) {
131
+ // Code was requested, waiting for user to enter it in config
132
+ return;
133
+ }
114
134
 
115
135
  this.log.debug('Initializing - userData:', debugStringify(userData));
116
136
  const devices = await this.roborockService.listDevices(username);
@@ -287,4 +307,113 @@ export class RoborockMatterbridgePlatform extends MatterbridgeDynamicPlatform {
287
307
  this.log.logLevel = logLevel;
288
308
  return Promise.resolve();
289
309
  }
310
+
311
+ private async authenticateWithPassword(username: string, password: string): Promise<UserData> {
312
+ if (!this.roborockService) {
313
+ throw new Error('RoborockService is not initialized');
314
+ }
315
+
316
+ this.log.notice('Attempting login with password...');
317
+
318
+ const userData = await this.roborockService.loginWithPassword(
319
+ username,
320
+ password,
321
+ async () => {
322
+ if (this.enableExperimentalFeature?.enableExperimentalFeature && this.enableExperimentalFeature.advancedFeature?.alwaysExecuteAuthentication) {
323
+ this.log.debug('Always execute authentication on startup');
324
+ return undefined;
325
+ }
326
+
327
+ const savedUserData = (await this.persist.getItem('userData')) as UserData | undefined;
328
+ if (savedUserData) {
329
+ this.log.debug('Loading saved userData:', debugStringify(savedUserData));
330
+ return savedUserData;
331
+ }
332
+ return undefined;
333
+ },
334
+ async (userData: UserData) => {
335
+ await this.persist.setItem('userData', userData);
336
+ },
337
+ );
338
+ this.log.notice('Authentication successful!');
339
+ return userData;
340
+ }
341
+
342
+ /**
343
+ * Authenticate using 2FA verification code flow
344
+ * @param username - The user's email address
345
+ * @param verificationCode - The verification code from config (if provided)
346
+ * @returns UserData on successful authentication, undefined if waiting for code
347
+ */
348
+ private async authenticate2FA(username: string, verificationCode: string | undefined): Promise<UserData | undefined> {
349
+ if (!this.roborockService) {
350
+ throw new Error('RoborockService is not initialized');
351
+ }
352
+
353
+ if (!this.enableExperimentalFeature?.advancedFeature?.alwaysExecuteAuthentication) {
354
+ const savedUserData = (await this.persist.getItem('userData')) as UserData | undefined;
355
+ if (savedUserData) {
356
+ this.log.debug('Found saved userData, attempting to use cached token');
357
+ try {
358
+ const userData = await this.roborockService.loginWithCachedToken(username, savedUserData);
359
+ this.log.notice('Successfully authenticated with cached token');
360
+ return userData;
361
+ } catch (error) {
362
+ this.log.warn(`Cached token invalid or expired: ${error instanceof Error ? error.message : String(error)}`);
363
+ await this.persist.removeItem('userData');
364
+ // Continue to request new code
365
+ }
366
+ }
367
+ }
368
+
369
+ if (!verificationCode || verificationCode.trim() === '') {
370
+ const authState = (await this.persist.getItem('authenticateFlowState')) as AuthenticateFlowState | undefined;
371
+ const now = Date.now();
372
+ const RATE_LIMIT_MS = 60000; // 1 minute between code requests
373
+
374
+ if (authState?.codeRequestedAt && now - authState.codeRequestedAt < RATE_LIMIT_MS) {
375
+ const waitSeconds = Math.ceil((RATE_LIMIT_MS - (now - authState.codeRequestedAt)) / 1000);
376
+ this.log.warn(`Please wait ${waitSeconds} seconds before requesting another code.`);
377
+ this.log.notice('============================================');
378
+ this.log.notice('ACTION REQUIRED: Enter verification code');
379
+ this.log.notice(`A verification code was previously sent to: ${username}`);
380
+ this.log.notice('Enter the 6-digit code in the plugin configuration');
381
+ this.log.notice('under the "verificationCode" field, then restart the plugin.');
382
+ this.log.notice('============================================');
383
+ return undefined;
384
+ }
385
+
386
+ try {
387
+ this.log.notice(`Requesting verification code for: ${username}`);
388
+ await this.roborockService.requestVerificationCode(username);
389
+
390
+ await this.persist.setItem('authenticateFlowState', {
391
+ email: username,
392
+ codeRequestedAt: now,
393
+ } as AuthenticateFlowState);
394
+
395
+ this.log.notice('============================================');
396
+ this.log.notice('ACTION REQUIRED: Enter verification code');
397
+ this.log.notice(`A verification code has been sent to: ${username}`);
398
+ this.log.notice('Enter the 6-digit code in the plugin configuration');
399
+ this.log.notice('under the "verificationCode" field, then restart the plugin.');
400
+ this.log.notice('============================================');
401
+ } catch (error) {
402
+ this.log.error(`Failed to request verification code: ${error instanceof Error ? error.message : String(error)}`);
403
+ throw error;
404
+ }
405
+
406
+ return undefined;
407
+ }
408
+
409
+ this.log.notice('Attempting login with verification code...');
410
+
411
+ const userData = await this.roborockService.loginWithVerificationCode(username, verificationCode.trim(), async (data: UserData) => {
412
+ await this.persist.setItem('userData', data);
413
+ await this.persist.removeItem('authenticateFlowState');
414
+ });
415
+
416
+ this.log.notice('Authentication successful!');
417
+ return userData;
418
+ }
290
419
  }
@@ -6,6 +6,7 @@ import { AuthenticateResponse } from '../Zmodel/authenticateResponse.js';
6
6
  import { BaseUrl } from '../Zmodel/baseURL.js';
7
7
  import { HomeInfo } from '../Zmodel/homeInfo.js';
8
8
  import { UserData } from '../Zmodel/userData.js';
9
+ import { AuthenticateResponseCode } from '../Zenum/authenticateResponseCode.js';
9
10
 
10
11
  export class RoborockAuthenticateApi {
11
12
  private readonly logger: AnsiLogger;
@@ -13,9 +14,13 @@ export class RoborockAuthenticateApi {
13
14
  private deviceId: string;
14
15
  private username?: string;
15
16
  private authToken?: string;
17
+ // Cached values from base URL lookup for v4 login
18
+ private cachedBaseUrl?: string;
19
+ private cachedCountry?: string;
20
+ private cachedCountryCode?: string;
16
21
 
17
- constructor(logger: AnsiLogger, axiosFactory: AxiosStatic = axios) {
18
- this.deviceId = crypto.randomUUID();
22
+ constructor(logger: AnsiLogger, axiosFactory: AxiosStatic = axios, deviceId?: string) {
23
+ this.deviceId = deviceId ?? crypto.randomUUID();
19
24
  this.axiosFactory = axiosFactory;
20
25
  this.logger = logger;
21
26
  }
@@ -25,6 +30,9 @@ export class RoborockAuthenticateApi {
25
30
  return userData;
26
31
  }
27
32
 
33
+ /**
34
+ * @deprecated Use requestCodeV4 and loginWithCodeV4 instead
35
+ */
28
36
  public async loginWithPassword(username: string, password: string): Promise<UserData> {
29
37
  const api = await this.getAPIFor(username);
30
38
  const response = await api.post(
@@ -38,6 +46,80 @@ export class RoborockAuthenticateApi {
38
46
  return this.auth(username, response.data);
39
47
  }
40
48
 
49
+ /**
50
+ * Request a verification code to be sent to the user's email
51
+ * @param email - The user's email address
52
+ * @throws Error if the account is not found, rate limited, or other API error
53
+ */
54
+ public async requestCodeV4(email: string): Promise<void> {
55
+ const api = await this.getAPIFor(email);
56
+ const response = await api.post(
57
+ 'api/v4/email/code/send',
58
+ new URLSearchParams({
59
+ email: email,
60
+ type: 'login',
61
+ platform: '',
62
+ }),
63
+ {
64
+ headers: {
65
+ 'Content-Type': 'application/x-www-form-urlencoded',
66
+ },
67
+ },
68
+ );
69
+
70
+ const apiResponse: AuthenticateResponse<unknown> = response.data;
71
+
72
+ if (apiResponse.code === AuthenticateResponseCode.AccountNotFound) {
73
+ throw new Error(`Account not found for email: ${email}`);
74
+ }
75
+ if (apiResponse.code === AuthenticateResponseCode.RateLimited) {
76
+ throw new Error('Rate limited. Please wait before requesting another code.');
77
+ }
78
+ if (apiResponse.code !== AuthenticateResponseCode.Success && apiResponse.code !== undefined) {
79
+ throw new Error(`Failed to send verification code: ${apiResponse.msg} (code: ${apiResponse.code})`);
80
+ }
81
+
82
+ this.logger.debug('Verification code requested successfully');
83
+ }
84
+
85
+ /**
86
+ * Login with a verification code received via email
87
+ * @param email - The user's email address
88
+ * @param code - The 6-digit verification code
89
+ * @returns UserData on successful authentication
90
+ * @throws Error if the code is invalid, rate limited, or other API error
91
+ */
92
+ public async loginWithCodeV4(email: string, code: string): Promise<UserData> {
93
+ const api = await this.getAPIFor(email);
94
+
95
+ // Generate x_mercy_ks (random 16-char alphanumeric string)
96
+ const xMercyKs = this.generateRandomString(16);
97
+
98
+ // Get signed key from API
99
+ const xMercyK = await this.signKeyV3(api, xMercyKs);
100
+
101
+ const response = await api.post('api/v4/auth/email/login/code', null, {
102
+ params: {
103
+ email: email,
104
+ code: code,
105
+ country: this.cachedCountry ?? '',
106
+ countryCode: this.cachedCountryCode ?? '',
107
+ majorVersion: '14',
108
+ minorVersion: '0',
109
+ },
110
+ headers: {
111
+ 'Content-Type': 'application/x-www-form-urlencoded',
112
+ 'x-mercy-ks': xMercyKs,
113
+ 'x-mercy-k': xMercyK,
114
+ header_appversion: '4.54.02',
115
+ header_phonesystem: 'iOS',
116
+ header_phonemodel: 'iPhone16,1',
117
+ },
118
+ });
119
+
120
+ return this.authV4(email, response.data);
121
+ }
122
+
41
123
  public async getHomeDetails(): Promise<HomeInfo | undefined> {
42
124
  if (!this.username || !this.authToken) {
43
125
  return undefined;
@@ -53,12 +135,26 @@ export class RoborockAuthenticateApi {
53
135
  return apiResponse.data;
54
136
  }
55
137
 
138
+ /**
139
+ * Get cached country info from the last base URL lookup
140
+ */
141
+ public getCachedCountryInfo(): { country?: string; countryCode?: string } {
142
+ return {
143
+ country: this.cachedCountry,
144
+ countryCode: this.cachedCountryCode,
145
+ };
146
+ }
147
+
56
148
  private async getAPIFor(username: string): Promise<AxiosInstance> {
57
149
  const baseUrl = await this.getBaseUrl(username);
58
150
  return this.apiForUser(username, baseUrl);
59
151
  }
60
152
 
61
153
  private async getBaseUrl(username: string): Promise<string> {
154
+ if (this.cachedBaseUrl && this.username === username) {
155
+ return this.cachedBaseUrl;
156
+ }
157
+
62
158
  const api = await this.apiForUser(username);
63
159
  const response = await api.post(
64
160
  'api/v1/getUrlByEmail',
@@ -73,17 +169,49 @@ export class RoborockAuthenticateApi {
73
169
  throw new Error('Failed to retrieve base URL: ' + apiResponse.msg);
74
170
  }
75
171
 
172
+ this.cachedBaseUrl = apiResponse.data.url;
173
+ this.cachedCountry = apiResponse.data.country;
174
+ this.cachedCountryCode = apiResponse.data.countrycode;
175
+ this.username = username;
176
+
76
177
  return apiResponse.data.url;
77
178
  }
78
179
 
79
180
  private async apiForUser(username: string, baseUrl = 'https://usiot.roborock.com'): Promise<AxiosInstance> {
80
- return this.axiosFactory.create({
181
+ const instance = this.axiosFactory.create({
81
182
  baseURL: baseUrl,
82
183
  headers: {
83
184
  header_clientid: crypto.createHash('md5').update(username).update(this.deviceId).digest('base64'),
84
185
  Authorization: this.authToken,
186
+ header_clientlang: 'en',
85
187
  },
86
188
  });
189
+
190
+ instance.interceptors.request.use((config) => {
191
+ this.logger.debug('=== HTTP Request ===');
192
+ this.logger.debug(`URL: ${config.baseURL}/${config.url}`);
193
+ this.logger.debug(`Method: ${config.method?.toUpperCase()}`);
194
+ this.logger.debug(`Params: ${JSON.stringify(config.params)}`);
195
+ this.logger.debug(`Data: ${JSON.stringify(config.data)}`);
196
+ this.logger.debug(`Headers: ${JSON.stringify(config.headers)}`);
197
+ return config;
198
+ });
199
+
200
+ instance.interceptors.response.use(
201
+ (response) => {
202
+ this.logger.debug('=== HTTP Response ===');
203
+ this.logger.debug(`Status: ${response.status}`);
204
+ this.logger.debug(`Data: ${JSON.stringify(response.data)}`);
205
+ return response;
206
+ },
207
+ (error) => {
208
+ this.logger.debug('=== HTTP Error ===');
209
+ this.logger.debug(`Error: ${JSON.stringify(error.response?.data ?? error.message)}`);
210
+ return Promise.reject(error);
211
+ },
212
+ );
213
+
214
+ return instance;
87
215
  }
88
216
 
89
217
  private auth(username: string, response: AuthenticateResponse<UserData>): UserData {
@@ -96,8 +224,57 @@ export class RoborockAuthenticateApi {
96
224
  return userdata;
97
225
  }
98
226
 
227
+ /**
228
+ * Handle v4 authentication response with specific error code handling
229
+ */
230
+ private authV4(email: string, response: AuthenticateResponse<UserData>): UserData {
231
+ if (response.code === AuthenticateResponseCode.InvalidCode) {
232
+ throw new Error('Invalid verification code. Please check and try again.');
233
+ }
234
+ if (response.code === AuthenticateResponseCode.RateLimited) {
235
+ throw new Error('Rate limited. Please wait before trying again.');
236
+ }
237
+
238
+ const userdata = response.data;
239
+ if (!userdata || !userdata.token) {
240
+ throw new Error('Authentication failed: ' + response.msg + ' code: ' + response.code);
241
+ }
242
+
243
+ this.loginWithAuthToken(email, userdata.token);
244
+ return userdata;
245
+ }
246
+
99
247
  private loginWithAuthToken(username: string, token: string): void {
100
248
  this.username = username;
101
249
  this.authToken = token;
102
250
  }
251
+
252
+ /**
253
+ * Generate a random alphanumeric string of specified length
254
+ */
255
+ private generateRandomString(length: number): string {
256
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
257
+ let result = '';
258
+ const randomBytes = crypto.randomBytes(length);
259
+ for (let i = 0; i < length; i++) {
260
+ result += chars[randomBytes[i] % chars.length];
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /**
266
+ * Sign a key using the v3 API endpoint
267
+ */
268
+ private async signKeyV3(api: AxiosInstance, s: string): Promise<string> {
269
+ const response = await api.post('api/v3/key/sign', null, {
270
+ params: { s },
271
+ });
272
+
273
+ const apiResponse: AuthenticateResponse<{ k: string }> = response.data;
274
+ if (!apiResponse.data?.k) {
275
+ throw new Error('Failed to sign key: ' + apiResponse.msg);
276
+ }
277
+
278
+ return apiResponse.data.k;
279
+ }
103
280
  }
@@ -0,0 +1,6 @@
1
+ export enum AuthenticateResponseCode {
2
+ Success = 200,
3
+ AccountNotFound = 2008,
4
+ InvalidCode = 2018,
5
+ RateLimited = 9002,
6
+ }
@@ -0,0 +1,6 @@
1
+ export interface AuthenticateFlowState {
2
+ email: string;
3
+ codeRequestedAt?: number;
4
+ country?: string;
5
+ countryCode?: string;
6
+ }
@@ -9,6 +9,7 @@ export { DeviceStatus } from './Zmodel/deviceStatus.js';
9
9
  export { ResponseMessage } from './broadcast/model/responseMessage.js';
10
10
  export { MapInfo } from './Zmodel/mapInfo.js';
11
11
  export { AdditionalPropCode } from './Zenum/additionalPropCode.js';
12
+ export { AuthenticateResponseCode } from './Zenum/authenticateResponseCode.js';
12
13
 
13
14
  export { Scene } from './Zmodel/scene.js';
14
15
 
@@ -21,3 +22,4 @@ export type { Client } from './broadcast/client.js';
21
22
  export type { SceneParam } from './Zmodel/scene.js';
22
23
  export type { BatteryMessage, DeviceErrorMessage, DeviceStatusNotify } from './Zmodel/batteryMessage.js';
23
24
  export type { MultipleMap } from './Zmodel/multipleMap.js';
25
+ export type { AuthenticateFlowState } from './Zmodel/authenticateFlowState.js';
@@ -77,6 +77,9 @@ export default class RoborockService {
77
77
  this.clientManager = clientManager;
78
78
  }
79
79
 
80
+ /**
81
+ * @deprecated Use requestVerificationCode and loginWithVerificationCode instead
82
+ */
80
83
  public async loginWithPassword(
81
84
  username: string,
82
85
  password: string,
@@ -97,6 +100,38 @@ export default class RoborockService {
97
100
  return this.auth(userdata);
98
101
  }
99
102
 
103
+ /**
104
+ * Request a verification code to be sent to the user's email
105
+ * @param email - The user's email address
106
+ */
107
+ public async requestVerificationCode(email: string): Promise<void> {
108
+ return this.loginApi.requestCodeV4(email);
109
+ }
110
+
111
+ /**
112
+ * Login with a verification code received via email
113
+ * @param email - The user's email address
114
+ * @param code - The 6-digit verification code
115
+ * @param savedUserData - Callback to save the user data after successful login
116
+ * @returns UserData on successful authentication
117
+ */
118
+ public async loginWithVerificationCode(email: string, code: string, savedUserData: (userData: UserData) => Promise<void>): Promise<UserData> {
119
+ const userdata = await this.loginApi.loginWithCodeV4(email, code);
120
+ await savedUserData(userdata);
121
+ return this.auth(userdata);
122
+ }
123
+
124
+ /**
125
+ * Login with cached user data (token reuse)
126
+ * @param username - The user's email address
127
+ * @param userData - The cached user data with token
128
+ * @returns UserData on successful validation
129
+ */
130
+ public async loginWithCachedToken(username: string, userData: UserData): Promise<UserData> {
131
+ const validatedUserData = await this.loginApi.loginWithUserData(username, userData);
132
+ return this.auth(validatedUserData);
133
+ }
134
+
100
135
  public getMessageProcessor(duid: string): MessageProcessor | undefined {
101
136
  const messageProcessor = this.messageProcessorMap.get(duid);
102
137
  if (!messageProcessor) {
@@ -11,6 +11,14 @@ describe('RoborockAuthenticateApi', () => {
11
11
  mockAxiosInstance = {
12
12
  post: jest.fn(),
13
13
  get: jest.fn(),
14
+ interceptors: {
15
+ request: {
16
+ use: jest.fn(),
17
+ },
18
+ response: {
19
+ use: jest.fn(),
20
+ },
21
+ },
14
22
  };
15
23
  mockAxiosFactory = {
16
24
  create: jest.fn(() => mockAxiosInstance),