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 +121 -16
- package/dist/roborockCommunication/RESTAPI/roborockAuthenticateApi.js +121 -3
- package/dist/roborockCommunication/Zenum/authenticateResponseCode.js +7 -0
- package/dist/roborockCommunication/Zmodel/authenticateFlowState.js +1 -0
- package/dist/roborockCommunication/index.js +1 -0
- package/dist/roborockService.js +12 -0
- package/matterbridge-roborock-vacuum-plugin.config.json +6 -1
- package/matterbridge-roborock-vacuum-plugin.schema.json +44 -7
- package/package.json +1 -1
- package/src/model/ExperimentalFeatureSetting.ts +6 -0
- package/src/platform.ts +154 -25
- package/src/roborockCommunication/RESTAPI/roborockAuthenticateApi.ts +180 -3
- package/src/roborockCommunication/Zenum/authenticateResponseCode.ts +6 -0
- package/src/roborockCommunication/Zmodel/authenticateFlowState.ts +6 -0
- package/src/roborockCommunication/index.ts +2 -0
- package/src/roborockService.ts +35 -0
- package/src/tests/roborockCommunication/RESTAPI/roborockAuthenticateApi.test.ts +8 -0
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
|
|
50
|
-
this.log.error('"username"
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
this.log.debug('Loading saved userData:', debugStringify(savedUserData));
|
|
73
|
-
return savedUserData;
|
|
89
|
+
else {
|
|
90
|
+
userData = await this.authenticateWithPassword(username, password);
|
|
74
91
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 = {}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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';
|
package/dist/roborockService.js
CHANGED
|
@@ -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-
|
|
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-
|
|
3
|
+
"description": "matterbridge-roborock-vacuum-plugin v. 1.1.1-rc09 by https://github.com/RinDevJunior",
|
|
4
4
|
"type": "object",
|
|
5
|
-
"required": ["username"
|
|
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
|
|
29
|
+
"description": "Roborock account email address",
|
|
30
30
|
"type": "string"
|
|
31
31
|
},
|
|
32
|
-
"
|
|
33
|
-
"description": "
|
|
34
|
-
"type": "
|
|
35
|
-
"
|
|
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
|
@@ -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
|
|
65
|
-
this.log.error('"username"
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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';
|
package/src/roborockService.ts
CHANGED
|
@@ -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),
|