homebridge-roborock-vacuum 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Plan.md +54 -0
- package/config.schema.json +15 -6
- package/dist/crypto.js +62 -0
- package/dist/crypto.js.map +1 -0
- package/dist/platform.js +16 -3
- package/dist/platform.js.map +1 -1
- package/dist/ui/index.js +161 -0
- package/dist/ui/index.js.map +1 -0
- package/homebridge-ui/public/index.html +58 -0
- package/homebridge-ui/public/index.js +250 -0
- package/homebridge-ui/public/styles.css +184 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +4 -2
- package/roborockLib/lib/deviceFeatures.js +40 -0
- package/roborockLib/lib/roborockAuth.js +147 -0
- package/roborockLib/roborockAPI.js +186 -29
- package/roborockLib/test.js +2 -1
- package/roborockLib/data/UserData +0 -4
- package/roborockLib/data/clientID +0 -4
- package/roborockLib/userdata.json +0 -24
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
const elements = {
|
|
2
|
+
email: document.getElementById('email'),
|
|
3
|
+
password: document.getElementById('password'),
|
|
4
|
+
passwordRow: document.getElementById('password-row'),
|
|
5
|
+
baseUrl: document.getElementById('base-url'),
|
|
6
|
+
skipDevices: document.getElementById('skip-devices'),
|
|
7
|
+
debugMode: document.getElementById('debug-mode'),
|
|
8
|
+
code: document.getElementById('two-factor-code'),
|
|
9
|
+
login: document.getElementById('login'),
|
|
10
|
+
logout: document.getElementById('logout'),
|
|
11
|
+
send2fa: document.getElementById('send-2fa'),
|
|
12
|
+
verify2fa: document.getElementById('verify-2fa'),
|
|
13
|
+
twoFactorSection: document.getElementById('two-factor-section'),
|
|
14
|
+
toastContainer: document.getElementById('toast-container'),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function showToast(type, message) {
|
|
18
|
+
if (window.homebridge && window.homebridge.toast && typeof window.homebridge.toast[type] === 'function') {
|
|
19
|
+
window.homebridge.toast[type](message);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toast = document.createElement('div');
|
|
24
|
+
toast.className = `toast ${type}`;
|
|
25
|
+
toast.textContent = message;
|
|
26
|
+
elements.toastContainer.appendChild(toast);
|
|
27
|
+
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
toast.remove();
|
|
30
|
+
}, 4000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function request(path, body) {
|
|
34
|
+
try {
|
|
35
|
+
return await window.homebridge.request(path, body);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { ok: false, message: error.message || 'Request failed.' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadConfig() {
|
|
42
|
+
if (!window.homebridge || typeof window.homebridge.getPluginConfig !== 'function') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const configs = await window.homebridge.getPluginConfig();
|
|
47
|
+
const config = configs.find((entry) => entry.platform === 'RoborockVacuumPlatform');
|
|
48
|
+
if (!config) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (config.email) {
|
|
53
|
+
elements.email.value = config.email;
|
|
54
|
+
}
|
|
55
|
+
elements.baseUrl.value = normalizeBaseUrl(config.baseURL || 'https://usiot.roborock.com');
|
|
56
|
+
if (config.skipDevices) {
|
|
57
|
+
elements.skipDevices.value = config.skipDevices;
|
|
58
|
+
}
|
|
59
|
+
elements.debugMode.checked = Boolean(config.debugMode);
|
|
60
|
+
|
|
61
|
+
setLoggedInState(Boolean(config.encryptedToken));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEmail() {
|
|
65
|
+
return elements.email.value.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getPassword() {
|
|
69
|
+
return elements.password.value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getBaseUrl() {
|
|
73
|
+
return elements.baseUrl.value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getSkipDevices() {
|
|
77
|
+
return elements.skipDevices.value.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getDebugMode() {
|
|
81
|
+
return Boolean(elements.debugMode.checked);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getCode() {
|
|
85
|
+
return elements.code.value.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function saveCredentials() {
|
|
89
|
+
const email = getEmail();
|
|
90
|
+
const baseURL = getBaseUrl();
|
|
91
|
+
const skipDevices = getSkipDevices();
|
|
92
|
+
const debugMode = getDebugMode();
|
|
93
|
+
if (!email) {
|
|
94
|
+
showToast('error', 'Email is required.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await updatePluginConfig({
|
|
99
|
+
email,
|
|
100
|
+
baseURL,
|
|
101
|
+
skipDevices,
|
|
102
|
+
debugMode,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function login() {
|
|
107
|
+
const email = getEmail();
|
|
108
|
+
const password = getPassword();
|
|
109
|
+
const baseURL = getBaseUrl();
|
|
110
|
+
const result = await request('/auth/login', { email, password, baseURL });
|
|
111
|
+
|
|
112
|
+
if (result.ok) {
|
|
113
|
+
await updatePluginConfig({
|
|
114
|
+
email,
|
|
115
|
+
password,
|
|
116
|
+
baseURL,
|
|
117
|
+
encryptedToken: result.encryptedToken,
|
|
118
|
+
});
|
|
119
|
+
showToast('success', result.message || 'Login successful.');
|
|
120
|
+
setLoggedInState(true);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (result.twoFactorRequired) {
|
|
125
|
+
showToast('warning', result.message || 'Two-factor authentication required.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
showToast('error', result.message || 'Login failed.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function sendTwoFactorEmail() {
|
|
133
|
+
const email = getEmail();
|
|
134
|
+
const baseURL = getBaseUrl();
|
|
135
|
+
if (!email) {
|
|
136
|
+
showToast('error', 'Email is required.');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result = await request('/auth/send-2fa-email', { email, baseURL });
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
showToast('success', result.message || 'Verification email sent.');
|
|
143
|
+
} else {
|
|
144
|
+
showToast('error', result.message || 'Failed to send verification email.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function verifyTwoFactorCode() {
|
|
149
|
+
const email = getEmail();
|
|
150
|
+
const code = getCode();
|
|
151
|
+
const baseURL = getBaseUrl();
|
|
152
|
+
if (!email) {
|
|
153
|
+
showToast('error', 'Email is required.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!code) {
|
|
157
|
+
showToast('error', 'Verification code is required.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await request('/auth/verify-2fa-code', { email, code, baseURL });
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
await updatePluginConfig({
|
|
164
|
+
email,
|
|
165
|
+
baseURL,
|
|
166
|
+
encryptedToken: result.encryptedToken,
|
|
167
|
+
});
|
|
168
|
+
showToast('success', result.message || 'Verification successful.');
|
|
169
|
+
setLoggedInState(true);
|
|
170
|
+
} else {
|
|
171
|
+
showToast('error', result.message || 'Verification failed.');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function logout() {
|
|
176
|
+
const result = await request('/auth/logout');
|
|
177
|
+
if (result.ok) {
|
|
178
|
+
await updatePluginConfig({ encryptedToken: undefined });
|
|
179
|
+
showToast('success', result.message || 'Logged out.');
|
|
180
|
+
setLoggedInState(false);
|
|
181
|
+
} else {
|
|
182
|
+
showToast('error', result.message || 'Logout failed.');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeBaseUrl(value) {
|
|
187
|
+
if (!value) {
|
|
188
|
+
return 'https://usiot.roborock.com';
|
|
189
|
+
}
|
|
190
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
191
|
+
return value.replace(/\/+$/, '');
|
|
192
|
+
}
|
|
193
|
+
return `https://${value.replace(/\/+$/, '')}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setLoggedInState(isLoggedIn) {
|
|
197
|
+
elements.logout.classList.toggle('hidden', !isLoggedIn);
|
|
198
|
+
elements.login.classList.toggle('hidden', isLoggedIn);
|
|
199
|
+
elements.passwordRow.classList.toggle('hidden', isLoggedIn);
|
|
200
|
+
elements.twoFactorSection.classList.toggle('hidden', isLoggedIn);
|
|
201
|
+
elements.email.readOnly = isLoggedIn;
|
|
202
|
+
elements.email.parentElement.classList.toggle('readonly', isLoggedIn);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function updatePluginConfig(patch) {
|
|
206
|
+
if (!window.homebridge || typeof window.homebridge.getPluginConfig !== 'function') {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const configs = await window.homebridge.getPluginConfig();
|
|
211
|
+
let config = configs.find((entry) => entry.platform === 'RoborockVacuumPlatform');
|
|
212
|
+
if (!config) {
|
|
213
|
+
config = { platform: 'RoborockVacuumPlatform', name: 'Roborock Vacuum' };
|
|
214
|
+
configs.push(config);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
Object.keys(patch).forEach((key) => {
|
|
218
|
+
const value = patch[key];
|
|
219
|
+
if (value === undefined) {
|
|
220
|
+
delete config[key];
|
|
221
|
+
} else {
|
|
222
|
+
config[key] = value;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await window.homebridge.updatePluginConfig(configs);
|
|
227
|
+
await window.homebridge.savePluginConfig();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function init() {
|
|
231
|
+
loadConfig().catch(() => {
|
|
232
|
+
showToast('error', 'Failed to load current config.');
|
|
233
|
+
});
|
|
234
|
+
elements.login.addEventListener('click', login);
|
|
235
|
+
elements.send2fa.addEventListener('click', sendTwoFactorEmail);
|
|
236
|
+
elements.verify2fa.addEventListener('click', verifyTwoFactorCode);
|
|
237
|
+
elements.logout.addEventListener('click', logout);
|
|
238
|
+
elements.baseUrl.addEventListener('change', saveCredentials);
|
|
239
|
+
elements.skipDevices.addEventListener('change', saveCredentials);
|
|
240
|
+
elements.debugMode.addEventListener('change', saveCredentials);
|
|
241
|
+
elements.email.addEventListener('change', saveCredentials);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (window.homebridge) {
|
|
245
|
+
window.homebridge.addEventListener('ready', () => {
|
|
246
|
+
init();
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
250
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap');
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #0f172a;
|
|
5
|
+
--card: #111c33;
|
|
6
|
+
--text: #e2e8f0;
|
|
7
|
+
--muted: #94a3b8;
|
|
8
|
+
--primary: #38bdf8;
|
|
9
|
+
--secondary: #1e293b;
|
|
10
|
+
--border: #1f2a44;
|
|
11
|
+
--shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
|
21
|
+
background: radial-gradient(circle at top, #1e3a8a, #0b1120 55%, #020617);
|
|
22
|
+
color: var(--text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.container {
|
|
26
|
+
max-width: 860px;
|
|
27
|
+
margin: 0 auto;
|
|
28
|
+
padding: 32px 24px 24px;
|
|
29
|
+
display: grid;
|
|
30
|
+
gap: 24px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
header h1 {
|
|
34
|
+
font-size: 2.6rem;
|
|
35
|
+
margin-bottom: 8px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.subtitle {
|
|
39
|
+
margin: 0;
|
|
40
|
+
color: var(--muted);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.card {
|
|
44
|
+
background: var(--card);
|
|
45
|
+
border: 1px solid var(--border);
|
|
46
|
+
border-radius: 18px;
|
|
47
|
+
padding: 24px;
|
|
48
|
+
box-shadow: var(--shadow);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.card h2 {
|
|
52
|
+
margin-top: 0;
|
|
53
|
+
font-size: 1.4rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.help {
|
|
57
|
+
color: var(--muted);
|
|
58
|
+
margin-top: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
label {
|
|
62
|
+
display: block;
|
|
63
|
+
margin-top: 16px;
|
|
64
|
+
font-size: 0.95rem;
|
|
65
|
+
color: var(--muted);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
select,
|
|
69
|
+
input {
|
|
70
|
+
width: 100%;
|
|
71
|
+
margin-top: 8px;
|
|
72
|
+
padding: 12px 14px;
|
|
73
|
+
border-radius: 10px;
|
|
74
|
+
border: 1px solid var(--border);
|
|
75
|
+
background: #0b1328;
|
|
76
|
+
color: var(--text);
|
|
77
|
+
font-size: 1rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
input::placeholder {
|
|
81
|
+
color: #64748b;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.checkbox {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 10px;
|
|
88
|
+
margin-top: 18px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.checkbox input {
|
|
92
|
+
width: auto;
|
|
93
|
+
margin: 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.hidden {
|
|
97
|
+
display: none;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.readonly input {
|
|
101
|
+
background: #111827;
|
|
102
|
+
opacity: 0.75;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.actions {
|
|
106
|
+
display: flex;
|
|
107
|
+
gap: 12px;
|
|
108
|
+
flex-wrap: wrap;
|
|
109
|
+
margin-top: 20px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
button {
|
|
113
|
+
border: none;
|
|
114
|
+
border-radius: 999px;
|
|
115
|
+
padding: 12px 20px;
|
|
116
|
+
font-size: 0.95rem;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
button:hover {
|
|
122
|
+
transform: translateY(-1px);
|
|
123
|
+
box-shadow: 0 12px 24px rgba(56, 189, 248, 0.18);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.primary {
|
|
127
|
+
background: var(--primary);
|
|
128
|
+
color: #041826;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.secondary {
|
|
132
|
+
background: var(--secondary);
|
|
133
|
+
color: var(--text);
|
|
134
|
+
border: 1px solid var(--border);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.toast-container {
|
|
138
|
+
position: fixed;
|
|
139
|
+
right: 24px;
|
|
140
|
+
bottom: 24px;
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
gap: 12px;
|
|
144
|
+
z-index: 9999;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.toast {
|
|
148
|
+
background: #0f172a;
|
|
149
|
+
border: 1px solid var(--border);
|
|
150
|
+
border-radius: 12px;
|
|
151
|
+
padding: 12px 16px;
|
|
152
|
+
box-shadow: var(--shadow);
|
|
153
|
+
min-width: 220px;
|
|
154
|
+
animation: slide-in 0.2s ease-out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.toast.success {
|
|
158
|
+
border-color: #22c55e;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.toast.error {
|
|
162
|
+
border-color: #ef4444;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.toast.warning {
|
|
166
|
+
border-color: #f59e0b;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@keyframes slide-in {
|
|
170
|
+
from {
|
|
171
|
+
transform: translateY(10px);
|
|
172
|
+
opacity: 0;
|
|
173
|
+
}
|
|
174
|
+
to {
|
|
175
|
+
transform: translateY(0);
|
|
176
|
+
opacity: 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@media (max-width: 640px) {
|
|
181
|
+
header h1 {
|
|
182
|
+
font-size: 2.1rem;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-roborock-vacuum",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Roborock Vacuum Cleaner - plugin for Homebridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"main": "dist/index.js",
|
|
21
21
|
"engines": {
|
|
22
22
|
"homebridge": "^1.6.0 || ^2.0.0-beta.0",
|
|
23
|
-
"node": "^18.20.4 || ^20.15.1 || ^22"
|
|
23
|
+
"node": "^18.20.4 || ^20.15.1 || ^22 || ^24"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"clean": "rimraf ./dist",
|
|
27
27
|
"build": "rimraf ./dist && tsc",
|
|
28
|
+
"prepare": "npm run build",
|
|
28
29
|
"prepublishOnly": "npm run build",
|
|
29
30
|
"postpublish": "npm run clean",
|
|
30
31
|
"lint": "prettier --check .",
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
"test": "jest"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
36
|
+
"@homebridge/plugin-ui-utils": "^2.0.0",
|
|
35
37
|
"abstract-things": "0.9.0",
|
|
36
38
|
"axios": "^1.11.0",
|
|
37
39
|
"binary-parser": "^2.2.1",
|
|
@@ -595,6 +595,8 @@ class deviceFeatures {
|
|
|
595
595
|
"roborock.vacuum.a117", // Qrevo Master
|
|
596
596
|
"roborock.vacuum.a21", // Qrevo Slim
|
|
597
597
|
"roborock.vacuum.a144", // Saros 10R
|
|
598
|
+
"roborock.vacuum.a140",
|
|
599
|
+
"roborock.vacuum.ss07",
|
|
598
600
|
].includes(robotModel),
|
|
599
601
|
// isShakeMopStrengthSupported: p.DMM.currentProduct == p.Products.TanosS || p.DMM.currentProduct == p.Products.TanosSPlus || p.DMM.isGarnet || p.DMM.isTopazSV || p.DMM.isPearlPlus || p.DMM.isCoral || p.DMM.isTopazS || p.DMM.isTopazSPlus || p.DMM.isTopazSC || p.DMM.isTopazSV || p.DMM.isPearlPlus || p.DMM.isTanosSMax || p.DMM.isUltron || p.DMM.isUltronSPlus || p.DMM.isUltronSMop || p.DMM.isUltronSV || p.DMM.isPearl
|
|
600
602
|
isShakeMopStrengthSupported: [
|
|
@@ -615,6 +617,8 @@ class deviceFeatures {
|
|
|
615
617
|
"roborock.vacuum.a87", // Qrevo MaxV
|
|
616
618
|
"roborock.vacuum.a101", // Q Revo Pro
|
|
617
619
|
"roborock.vacuum.a144", // Saros 10R
|
|
620
|
+
"roborock.vacuum.a140",
|
|
621
|
+
"roborock.vacuum.ss07",
|
|
618
622
|
].includes(robotModel),
|
|
619
623
|
// isWaterBoxSupported: [p.Products.Tanos_CE, p.Products.Tanos_CN].hasElement(p.DMM.currentProduct)
|
|
620
624
|
isWaterBoxSupported: [
|
|
@@ -641,6 +645,8 @@ class deviceFeatures {
|
|
|
641
645
|
"roborock.vacuum.a117", // Qrevo Master
|
|
642
646
|
"roborock.vacuum.a21", // Qrevo Slim
|
|
643
647
|
"roborock.vacuum.a144", // Saros 10R
|
|
648
|
+
"roborock.vacuum.a140",
|
|
649
|
+
"roborock.vacuum.ss07",
|
|
644
650
|
|
|
645
651
|
].includes(robotModel),
|
|
646
652
|
isCustomWaterBoxDistanceSupported: !!(2147483648 & this.features),
|
|
@@ -665,6 +671,8 @@ class deviceFeatures {
|
|
|
665
671
|
"roborock.vacuum.a135", // Qrevo Curv
|
|
666
672
|
"roborock.vacuum.a117", // Qrevo Master
|
|
667
673
|
"roborock.vacuum.a144", // Saros 10R
|
|
674
|
+
"roborock.vacuum.a140",
|
|
675
|
+
"roborock.vacuum.ss07",
|
|
668
676
|
|
|
669
677
|
].includes(robotModel),
|
|
670
678
|
// this isn't the correct way to use this. This code must be from a different robot
|
|
@@ -965,6 +973,38 @@ class deviceFeatures {
|
|
|
965
973
|
"set_switch_status",
|
|
966
974
|
"set_last_clean_t",
|
|
967
975
|
],
|
|
976
|
+
"roborock.vacuum.a140": [
|
|
977
|
+
"setCleaningRecordsString",
|
|
978
|
+
"setConsumablesInt",
|
|
979
|
+
"set_common_status",
|
|
980
|
+
"set_dss",
|
|
981
|
+
"set_rss",
|
|
982
|
+
"set_kct",
|
|
983
|
+
"set_in_warmup",
|
|
984
|
+
"set_last_clean_t",
|
|
985
|
+
"set_map_flag",
|
|
986
|
+
"set_back_type",
|
|
987
|
+
"set_charge_status",
|
|
988
|
+
"set_clean_percent",
|
|
989
|
+
"set_cleaned_area",
|
|
990
|
+
"set_switch_status",
|
|
991
|
+
],
|
|
992
|
+
"roborock.vacuum.ss07": [
|
|
993
|
+
"setCleaningRecordsString",
|
|
994
|
+
"setConsumablesInt",
|
|
995
|
+
"set_common_status",
|
|
996
|
+
"set_dss",
|
|
997
|
+
"set_rss",
|
|
998
|
+
"set_kct",
|
|
999
|
+
"set_in_warmup",
|
|
1000
|
+
"set_last_clean_t",
|
|
1001
|
+
"set_map_flag",
|
|
1002
|
+
"set_back_type",
|
|
1003
|
+
"set_charge_status",
|
|
1004
|
+
"set_clean_percent",
|
|
1005
|
+
"set_cleaned_area",
|
|
1006
|
+
"set_switch_status",
|
|
1007
|
+
],
|
|
968
1008
|
|
|
969
1009
|
};
|
|
970
1010
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const axios = require("axios");
|
|
5
|
+
|
|
6
|
+
const API_V3_SIGN = "api/v3/key/sign";
|
|
7
|
+
const API_V4_LOGIN_CODE = "api/v4/auth/email/login/code";
|
|
8
|
+
const API_V4_LOGIN_PASSWORD = "api/v4/auth/email/login/pwd";
|
|
9
|
+
const API_V4_EMAIL_CODE = "api/v4/email/code/send";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_HEADERS = {
|
|
12
|
+
header_appversion: "4.54.02",
|
|
13
|
+
header_clientlang: "en",
|
|
14
|
+
header_phonemodel: "Pixel 7",
|
|
15
|
+
header_phonesystem: "Android",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function normalizeBaseURL(baseURL) {
|
|
19
|
+
if (!baseURL) {
|
|
20
|
+
return "usiot.roborock.com";
|
|
21
|
+
}
|
|
22
|
+
return baseURL.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getRegionConfig(baseURL) {
|
|
26
|
+
const lower = normalizeBaseURL(baseURL).toLowerCase();
|
|
27
|
+
if (lower.includes("euiot")) {
|
|
28
|
+
return { country: "DE", countryCode: "49" };
|
|
29
|
+
}
|
|
30
|
+
if (lower.includes("usiot")) {
|
|
31
|
+
return { country: "US", countryCode: "1" };
|
|
32
|
+
}
|
|
33
|
+
if (lower.includes("cniot")) {
|
|
34
|
+
return { country: "CN", countryCode: "86" };
|
|
35
|
+
}
|
|
36
|
+
if (lower.includes("api.roborock.com")) {
|
|
37
|
+
return { country: "SG", countryCode: "65" };
|
|
38
|
+
}
|
|
39
|
+
return { country: "US", countryCode: "1" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encryptPassword(password, k) {
|
|
43
|
+
const derivedKey = k.slice(4) + k.slice(0, 4);
|
|
44
|
+
const cipher = crypto.createCipheriv("aes-128-ecb", Buffer.from(derivedKey, "utf-8"), null);
|
|
45
|
+
cipher.setAutoPadding(true);
|
|
46
|
+
let encrypted = cipher.update(password, "utf8", "base64");
|
|
47
|
+
encrypted += cipher.final("base64");
|
|
48
|
+
return encrypted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createLoginApi({ baseURL, username, clientID, language }) {
|
|
52
|
+
return axios.create({
|
|
53
|
+
baseURL: `https://${normalizeBaseURL(baseURL)}`,
|
|
54
|
+
headers: {
|
|
55
|
+
header_clientid: crypto.createHash("md5").update(username).update(clientID).digest().toString("base64"),
|
|
56
|
+
header_clientlang: language || DEFAULT_HEADERS.header_clientlang,
|
|
57
|
+
header_appversion: DEFAULT_HEADERS.header_appversion,
|
|
58
|
+
header_phonemodel: DEFAULT_HEADERS.header_phonemodel,
|
|
59
|
+
header_phonesystem: DEFAULT_HEADERS.header_phonesystem,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function signRequest(loginApi, s) {
|
|
65
|
+
const res = await loginApi.post(`${API_V3_SIGN}?s=${s}`);
|
|
66
|
+
return res.data && res.data.data ? res.data.data : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function requestEmailCode(loginApi, email) {
|
|
70
|
+
const params = new URLSearchParams();
|
|
71
|
+
params.append("type", "login");
|
|
72
|
+
params.append("email", email);
|
|
73
|
+
params.append("platform", "");
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const res = await loginApi.post(API_V4_EMAIL_CODE, params.toString());
|
|
77
|
+
if (res.data && res.data.code !== 200) {
|
|
78
|
+
throw new Error(`Send code failed: ${res.data.msg || "Unknown error"} (Code: ${res.data.code})`);
|
|
79
|
+
}
|
|
80
|
+
return res.data;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.response && error.response.data) {
|
|
83
|
+
throw new Error(`Send code failed: ${JSON.stringify(error.response.data)}`);
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loginWithCode(loginApi, { email, code, country, countryCode, k, s }) {
|
|
90
|
+
const headers = {
|
|
91
|
+
"x-mercy-k": k,
|
|
92
|
+
"x-mercy-ks": s,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const params = new URLSearchParams({
|
|
96
|
+
country,
|
|
97
|
+
countryCode,
|
|
98
|
+
email,
|
|
99
|
+
code,
|
|
100
|
+
majorVersion: "14",
|
|
101
|
+
minorVersion: "0",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const res = await loginApi.post(API_V4_LOGIN_CODE, params.toString(), { headers });
|
|
106
|
+
return res.data;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.response && error.response.data) {
|
|
109
|
+
return error.response.data;
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loginByPassword(loginApi, { email, password, k, s }) {
|
|
116
|
+
const headers = {
|
|
117
|
+
"x-mercy-k": k,
|
|
118
|
+
"x-mercy-ks": s,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const params = new URLSearchParams({
|
|
122
|
+
email,
|
|
123
|
+
password: encryptPassword(password, k),
|
|
124
|
+
majorVersion: "14",
|
|
125
|
+
minorVersion: "0",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const res = await loginApi.post(API_V4_LOGIN_PASSWORD, params.toString(), { headers });
|
|
130
|
+
return res.data;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.response && error.response.data) {
|
|
133
|
+
return error.response.data;
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
createLoginApi,
|
|
141
|
+
getRegionConfig,
|
|
142
|
+
normalizeBaseURL,
|
|
143
|
+
signRequest,
|
|
144
|
+
requestEmailCode,
|
|
145
|
+
loginWithCode,
|
|
146
|
+
loginByPassword,
|
|
147
|
+
};
|