homebridge-roborock-vacuum 1.2.3 → 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.
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+
3
+ require("../dist/ui/index.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-roborock-vacuum",
3
- "version": "1.2.3",
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,18 +33,19 @@
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
- "axios": "^1.7.7",
38
+ "axios": "^1.11.0",
37
39
  "binary-parser": "^2.2.1",
38
40
  "chalk": "4.1.2",
39
41
  "crc-32": "^1.2.2",
40
42
  "debug": "4.3.5",
41
43
  "deep-equal": "2.2.3",
42
- "express": "^4.21.0",
44
+ "express": "^4.21.2",
43
45
  "jszip": "^3.10.1",
44
- "mqtt": "^5.10.1",
46
+ "mqtt": "^5.14.0",
45
47
  "node-forge": "^1.3.1",
46
- "rxjs": "^7.5.6",
48
+ "rxjs": "^7.8.2",
47
49
  "semver": "7.6.2",
48
50
  "tinkerhub-discovery": "0.3.1",
49
51
  "yargs": "15.4.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
 
@@ -192,6 +192,12 @@ class localConnector {
192
192
  server.on("message", (msg) => {
193
193
  const parsedMessage = localMessageParser.parse(msg);
194
194
  const decodedMessage = this.decryptECB(parsedMessage.payload, BROADCAST_TOKEN); // this might be decryptCBC for A01. Haven't checked this yet
195
+
196
+ if(decodedMessage == null){
197
+ this.adapter.log.debug(`getLocalDevices: decodedMessage is null`);
198
+ return;
199
+ }
200
+
195
201
  const parsedDecodedMessage = JSON.parse(decodedMessage);
196
202
  this.adapter.log.debug(`getLocalDevices parsedDecodedMessage: ${JSON.stringify(parsedDecodedMessage)}`);
197
203
 
@@ -224,15 +230,53 @@ class localConnector {
224
230
  });
225
231
  }
226
232
 
227
- decryptECB(encrypted, aesKey) {
228
- const decipher = crypto.createDecipheriv("aes-128-ecb", aesKey, null);
233
+ safeRemovePkcs7(buf) {
234
+ if (!buf || buf.length === 0) return Buffer.alloc(0);
235
+ const pad = buf[buf.length - 1];
236
+ // 僅在 1..16 且最後 pad 個 byte 都等於 pad 時才移除
237
+ if (pad > 0 && pad <= 16) {
238
+ for (let i = 0; i < pad; i++) {
239
+ if (buf[buf.length - 1 - i] !== pad) return buf; // padding 形狀不對,視為無 padding
240
+ }
241
+ return buf.slice(0, buf.length - pad);
242
+ }
243
+ return buf; // 看起來沒有標準 PKCS#7 padding
244
+ }
245
+
246
+ decryptECB(encrypted, aesKey) {
247
+ // --- 1) Key/輸入檢查 ---
248
+ const key = Buffer.isBuffer(aesKey) ? aesKey : Buffer.from(aesKey);
249
+ if (key.length !== 16) {
250
+ // AES-128 需要 16 bytes 的 key
251
+ return null;
252
+ }
253
+
254
+ const input = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted, "latin1"); // "binary" 等同 latin1
255
+ if (input.length === 0 || (input.length % 16) !== 0) {
256
+ // 密文長度不是 16 的倍數,多半是封包不完整;丟回 null 讓上層忽略本次
257
+ return null;
258
+ }
259
+
260
+ try {
261
+ // --- 2) 固定用 Buffer,關閉自動 padding(你要自己移除) ---
262
+ const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
229
263
  decipher.setAutoPadding(false);
230
- let decrypted = decipher.update(encrypted, "binary", "utf8");
231
- decrypted += decipher.final("utf8");
232
- return this.removePadding(decrypted);
264
+
265
+ const decryptedBuf = Buffer.concat([decipher.update(input), decipher.final()]);
266
+ const unpadded = safeRemovePkcs7(decryptedBuf);
267
+
268
+ // 若原協定內容是 UTF-8,這裡再轉字串;否則直接回傳 Buffer 讓上層處理
269
+ return unpadded.toString("utf8");
270
+ } catch (err) {
271
+ // 例如 wrong final block length、key 不對等情況
272
+ // 這裡不要讓程式炸掉,直接忽略這個封包
273
+ // 你也可以在這裡做一次 debug log
274
+ // console.debug("decryptECB error:", err);
275
+ return null;
233
276
  }
277
+ }
234
278
 
235
- removePadding(str) {
279
+ removePadding(str) {
236
280
  const paddingLength = str.charCodeAt(str.length - 1);
237
281
  return str.slice(0, -paddingLength);
238
282
  }