homebridge-roborock-matter 2.1.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.
Files changed (59) hide show
  1. package/.gitattributes +13 -0
  2. package/CHANGELOG.md +469 -0
  3. package/LICENSE +23 -0
  4. package/README.md +45 -0
  5. package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
  6. package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
  7. package/config.schema.json +118 -0
  8. package/dist/crypto.js +65 -0
  9. package/dist/crypto.js.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/live_message.js +25 -0
  13. package/dist/live_message.js.map +1 -0
  14. package/dist/logger.js +37 -0
  15. package/dist/logger.js.map +1 -0
  16. package/dist/matter_vacuum_accessory.js +1877 -0
  17. package/dist/matter_vacuum_accessory.js.map +1 -0
  18. package/dist/platform.js +441 -0
  19. package/dist/platform.js.map +1 -0
  20. package/dist/settings.js +8 -0
  21. package/dist/settings.js.map +1 -0
  22. package/dist/types.js +3 -0
  23. package/dist/types.js.map +1 -0
  24. package/dist/ui/index.js +741 -0
  25. package/dist/ui/index.js.map +1 -0
  26. package/docs/matter-rvc-updating-homebridge-report.md +120 -0
  27. package/homebridge-roborock-vacuum.code-workspace +7 -0
  28. package/homebridge-ui/public/index.html +257 -0
  29. package/homebridge-ui/public/index.js +1276 -0
  30. package/homebridge-ui/public/styles.css +611 -0
  31. package/homebridge-ui/server.js +3 -0
  32. package/package.json +91 -0
  33. package/roborockLib/i18n/de/translations.json +188 -0
  34. package/roborockLib/i18n/en/translations.json +208 -0
  35. package/roborockLib/i18n/es/translations.json +188 -0
  36. package/roborockLib/i18n/fr/translations.json +188 -0
  37. package/roborockLib/i18n/it/translations.json +188 -0
  38. package/roborockLib/i18n/nl/translations.json +188 -0
  39. package/roborockLib/i18n/pl/translations.json +188 -0
  40. package/roborockLib/i18n/pt/translations.json +188 -0
  41. package/roborockLib/i18n/ru/translations.json +188 -0
  42. package/roborockLib/i18n/uk/translations.json +188 -0
  43. package/roborockLib/i18n/zh-cn/translations.json +188 -0
  44. package/roborockLib/lib/RRMapParser.js +459 -0
  45. package/roborockLib/lib/b01Q7Adapter.js +468 -0
  46. package/roborockLib/lib/deviceFeatures.js +1349 -0
  47. package/roborockLib/lib/localConnector.js +629 -0
  48. package/roborockLib/lib/map/map.html +137 -0
  49. package/roborockLib/lib/map/zones.js +851 -0
  50. package/roborockLib/lib/message.js +402 -0
  51. package/roborockLib/lib/messageQueueHandler.js +411 -0
  52. package/roborockLib/lib/roborockAuth.js +178 -0
  53. package/roborockLib/lib/roborockCrypto.js +55 -0
  54. package/roborockLib/lib/roborockPackageHelper.js +142 -0
  55. package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
  56. package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
  57. package/roborockLib/lib/vacuum.js +992 -0
  58. package/roborockLib/roborockAPI.js +3635 -0
  59. package/tsconfig.roborockLib.json +17 -0
@@ -0,0 +1,1276 @@
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
+ devicesList: document.getElementById("devices-list"),
8
+ devicesEmpty: document.getElementById("devices-empty"),
9
+ refreshDevices: document.getElementById("refresh-devices"),
10
+ debugMode: document.getElementById("debug-mode"),
11
+ enableMatterChargingDocked: document.getElementById(
12
+ "enable-matter-charging-docked"
13
+ ),
14
+ matterChargedBatteryThreshold: document.getElementById(
15
+ "matter-charged-battery-threshold"
16
+ ),
17
+ advancedSettings: document.getElementById("advanced-settings"),
18
+ preferCloudForMatterCommands: document.getElementById(
19
+ "prefer-cloud-for-matter-commands"
20
+ ),
21
+ cloudOnlyMode: document.getElementById("cloud-only-mode"),
22
+ transientWarningThrottleHours: document.getElementById(
23
+ "transient-warning-throttle-hours"
24
+ ),
25
+ code: document.getElementById("two-factor-code"),
26
+ saveSettings: document.getElementById("save-settings"),
27
+ login: document.getElementById("login"),
28
+ logout: document.getElementById("logout"),
29
+ send2fa: document.getElementById("send-2fa"),
30
+ verify2fa: document.getElementById("verify-2fa"),
31
+ twoFactorSection: document.getElementById("two-factor-section"),
32
+ authStatus: document.getElementById("auth-status"),
33
+ toastContainer: document.getElementById("toast-container"),
34
+ testLocal: document.getElementById("test-local"),
35
+ copyDiagnostics: document.getElementById("copy-diagnostics"),
36
+ refreshDiagnostics: document.getElementById("refresh-diagnostics"),
37
+ refreshMatterPairing: document.getElementById("refresh-matter-pairing"),
38
+ matterPairingSummary: document.getElementById("matter-pairing-summary"),
39
+ matterPairingEmpty: document.getElementById("matter-pairing-empty"),
40
+ matterPairingList: document.getElementById("matter-pairing-list"),
41
+ diagnosticsSummary: document.getElementById("diagnostics-summary"),
42
+ diagnosticsEmpty: document.getElementById("diagnostics-empty"),
43
+ localTestResults: document.getElementById("local-test-results"),
44
+ diagnosticsList: document.getElementById("diagnostics-list"),
45
+ };
46
+
47
+ const state = {
48
+ hasEncryptedToken: false,
49
+ hasPassword: false,
50
+ lastDiagnostics: null,
51
+ lastLocalTest: null,
52
+ diagnosticsRefreshTimer: null,
53
+ diagnosticsAutoRefreshAttempts: 0,
54
+ };
55
+
56
+ const DIAGNOSTICS_AUTO_REFRESH_DELAY_MS = 3000;
57
+ const DIAGNOSTICS_AUTO_REFRESH_LIMIT = 2;
58
+ const DEFAULT_TRANSIENT_WARNING_THROTTLE_HOURS = 6;
59
+
60
+ function showToast(type, message) {
61
+ if (
62
+ window.homebridge &&
63
+ window.homebridge.toast &&
64
+ typeof window.homebridge.toast[type] === "function"
65
+ ) {
66
+ window.homebridge.toast[type](message);
67
+ return;
68
+ }
69
+
70
+ const toast = document.createElement("div");
71
+ toast.className = `toast ${type}`;
72
+ toast.textContent = message;
73
+ elements.toastContainer.appendChild(toast);
74
+
75
+ setTimeout(() => {
76
+ toast.remove();
77
+ }, 4000);
78
+ }
79
+
80
+ async function request(path, body, options = {}) {
81
+ try {
82
+ const requestPromise = window.homebridge.request(path, body);
83
+ if (!options.timeoutMs) {
84
+ return await requestPromise;
85
+ }
86
+
87
+ return await Promise.race([
88
+ requestPromise,
89
+ new Promise((resolve) => {
90
+ setTimeout(() => {
91
+ resolve({
92
+ ok: false,
93
+ message: `Request timed out after ${Math.round(options.timeoutMs / 1000)} seconds.`,
94
+ });
95
+ }, options.timeoutMs);
96
+ }),
97
+ ]);
98
+ } catch (error) {
99
+ return { ok: false, message: error.message || "Request failed." };
100
+ }
101
+ }
102
+
103
+ async function loadConfig() {
104
+ const canLoadConfig =
105
+ window.homebridge &&
106
+ typeof window.homebridge.getPluginConfig === "function";
107
+ const configs = canLoadConfig
108
+ ? await window.homebridge.getPluginConfig()
109
+ : null;
110
+ const config = configs
111
+ ? configs.find((entry) => entry.platform === "RoborockVacuumPlatform")
112
+ : null;
113
+
114
+ if (!config) {
115
+ updateAuthStatus(false, false);
116
+ } else {
117
+ if (config.email) {
118
+ elements.email.value = config.email;
119
+ }
120
+ elements.baseUrl.value = normalizeBaseUrl(
121
+ config.baseURL || "https://usiot.roborock.com"
122
+ );
123
+ if (config.skipDevices) {
124
+ elements.skipDevices.value = config.skipDevices;
125
+ }
126
+ elements.debugMode.checked = Boolean(config.debugMode);
127
+ if (elements.enableMatterChargingDocked) {
128
+ elements.enableMatterChargingDocked.checked = Boolean(
129
+ config.enableMatterChargingDockedStates
130
+ );
131
+ }
132
+ if (elements.matterChargedBatteryThreshold) {
133
+ elements.matterChargedBatteryThreshold.value =
134
+ config.matterChargedBatteryThreshold != null
135
+ ? String(config.matterChargedBatteryThreshold)
136
+ : "";
137
+ }
138
+ elements.preferCloudForMatterCommands.checked = Boolean(
139
+ config.preferCloudForMatterCommands
140
+ );
141
+ elements.cloudOnlyMode.checked = Boolean(config.cloudOnlyMode);
142
+ elements.advancedSettings.open = Boolean(
143
+ config.debugMode ||
144
+ config.preferCloudForMatterCommands ||
145
+ config.cloudOnlyMode
146
+ );
147
+ elements.transientWarningThrottleHours.value =
148
+ config.transientWarningThrottleHours ??
149
+ DEFAULT_TRANSIENT_WARNING_THROTTLE_HOURS;
150
+
151
+ state.hasEncryptedToken = Boolean(config.encryptedToken);
152
+ state.hasPassword = Boolean(config.password);
153
+ setLoggedInState(state.hasEncryptedToken, state.hasPassword);
154
+ }
155
+
156
+ await loadMatterPairing();
157
+
158
+ if (config) {
159
+ await loadDiagnostics({ scheduleFollowUp: true });
160
+ }
161
+ }
162
+
163
+ function getEmail() {
164
+ return elements.email.value.trim();
165
+ }
166
+
167
+ function getPassword() {
168
+ return elements.password.value;
169
+ }
170
+
171
+ function getBaseUrl() {
172
+ return elements.baseUrl.value;
173
+ }
174
+
175
+ function getSkipDevices() {
176
+ return elements.skipDevices.value.trim();
177
+ }
178
+
179
+ function getDebugMode() {
180
+ return Boolean(elements.debugMode.checked);
181
+ }
182
+
183
+
184
+ function getPreferCloudForMatterCommands() {
185
+ return Boolean(elements.preferCloudForMatterCommands.checked);
186
+ }
187
+
188
+ function getCloudOnlyMode() {
189
+ return Boolean(elements.cloudOnlyMode.checked);
190
+ }
191
+
192
+ function getTransientWarningThrottleHours() {
193
+ const value = elements.transientWarningThrottleHours.value.trim();
194
+ if (value === "") {
195
+ return DEFAULT_TRANSIENT_WARNING_THROTTLE_HOURS;
196
+ }
197
+
198
+ const parsed = Number(value);
199
+ if (!Number.isFinite(parsed) || parsed < 0) {
200
+ return DEFAULT_TRANSIENT_WARNING_THROTTLE_HOURS;
201
+ }
202
+
203
+ return parsed;
204
+ }
205
+
206
+ function getCode() {
207
+ return elements.code.value.trim();
208
+ }
209
+
210
+ function getFormValues() {
211
+ return {
212
+ email: getEmail(),
213
+ password: getPassword(),
214
+ baseURL: getBaseUrl(),
215
+ skipDevices: getSkipDevices(),
216
+ debugMode: getDebugMode(),
217
+ enableMatterChargingDockedStates: Boolean(
218
+ elements.enableMatterChargingDocked?.checked
219
+ ),
220
+ matterChargedBatteryThreshold: getMatterChargedBatteryThreshold(),
221
+ preferCloudForMatterCommands: getPreferCloudForMatterCommands(),
222
+ cloudOnlyMode: getCloudOnlyMode(),
223
+ transientWarningThrottleHours: getTransientWarningThrottleHours(),
224
+ };
225
+ }
226
+
227
+ function getMatterChargedBatteryThreshold() {
228
+ const raw = elements.matterChargedBatteryThreshold?.value?.trim();
229
+ if (!raw) {
230
+ return undefined;
231
+ }
232
+ const value = Number(raw);
233
+ if (!Number.isFinite(value)) {
234
+ return undefined;
235
+ }
236
+ return Math.min(100, Math.max(1, Math.round(value)));
237
+ }
238
+
239
+ function getSkipTokenSet() {
240
+ return new Set(
241
+ (elements.skipDevices.value || "")
242
+ .split(",")
243
+ .map((token) => token.trim())
244
+ .filter((token) => token.length > 0)
245
+ );
246
+ }
247
+
248
+ function setSkipTokens(tokens) {
249
+ elements.skipDevices.value = [...tokens].join(", ");
250
+ }
251
+
252
+ let managedDevicesCache = [];
253
+
254
+ async function loadManagedDevices() {
255
+ try {
256
+ const result = await request("/diagnostics/state", {});
257
+ const devices =
258
+ result && result.ok && Array.isArray(result.devices)
259
+ ? result.devices
260
+ : [];
261
+ managedDevicesCache = devices;
262
+ renderManagedDevices();
263
+ } catch (error) {
264
+ managedDevicesCache = [];
265
+ renderManagedDevices();
266
+ }
267
+ }
268
+
269
+ function renderManagedDevices() {
270
+ const container = elements.devicesList;
271
+ const empty = elements.devicesEmpty;
272
+ if (!container || !empty) {
273
+ return;
274
+ }
275
+
276
+ container.textContent = "";
277
+ if (!managedDevicesCache.length) {
278
+ empty.classList.remove("hidden");
279
+ return;
280
+ }
281
+ empty.classList.add("hidden");
282
+
283
+ const skipTokens = getSkipTokenSet();
284
+
285
+ for (const device of managedDevicesCache) {
286
+ const duid = device.duid || "";
287
+ const serial = device.serialNumber || "";
288
+ const skipped =
289
+ (duid && skipTokens.has(duid)) || (serial && skipTokens.has(serial));
290
+
291
+ const row = document.createElement("label");
292
+ row.className = "device-row";
293
+
294
+ const checkbox = document.createElement("input");
295
+ checkbox.type = "checkbox";
296
+ checkbox.checked = !skipped;
297
+ checkbox.addEventListener("change", () =>
298
+ onManagedDeviceToggle(device, checkbox.checked)
299
+ );
300
+
301
+ const text = document.createElement("div");
302
+ text.className = "device-text";
303
+ const title = document.createElement("div");
304
+ title.className = "device-title";
305
+ title.textContent = `${device.name || duid || "Unknown device"} — ${device.resolvedModel || "unknown model"}`;
306
+ const detail = document.createElement("small");
307
+ const onlineText =
308
+ device.online === true
309
+ ? "online"
310
+ : device.online === false
311
+ ? "offline"
312
+ : "status unknown";
313
+ detail.textContent = `${duid}${serial ? ` · SN ${serial}` : ""} · ${onlineText}`;
314
+ text.appendChild(title);
315
+ text.appendChild(detail);
316
+
317
+ row.appendChild(checkbox);
318
+ row.appendChild(text);
319
+
320
+ if (skipped) {
321
+ const chip = document.createElement("span");
322
+ chip.className = "pill warn";
323
+ chip.textContent = "Disabled";
324
+ row.appendChild(chip);
325
+ }
326
+
327
+ container.appendChild(row);
328
+ }
329
+ }
330
+
331
+ function onManagedDeviceToggle(device, managed) {
332
+ const tokens = getSkipTokenSet();
333
+ const duid = device.duid || "";
334
+ const serial = device.serialNumber || "";
335
+
336
+ if (managed) {
337
+ if (duid) tokens.delete(duid);
338
+ if (serial) tokens.delete(serial);
339
+ } else if (duid || serial) {
340
+ tokens.add(duid || serial);
341
+ // Avoid double entries when both identifiers were present already.
342
+ if (duid && serial) tokens.delete(serial);
343
+ }
344
+
345
+ setSkipTokens(tokens);
346
+ rerenderMatterPairing();
347
+ saveCredentials(false)
348
+ .then(() => {
349
+ showToast(
350
+ "success",
351
+ managed
352
+ ? `${device.name || duid} will be managed after the next bridge restart.`
353
+ : `${device.name || duid} disabled. Restart the Roborock bridge to unpublish it.`
354
+ );
355
+ })
356
+ .catch(() => {
357
+ showToast("error", "Could not save the device selection.");
358
+ });
359
+ renderManagedDevices();
360
+ }
361
+
362
+ async function saveCredentials(showSuccess = false) {
363
+ const formValues = getFormValues();
364
+ const { email, password } = formValues;
365
+ if (!email) {
366
+ showToast("error", "Email is required.");
367
+ return;
368
+ }
369
+
370
+ const patch = {
371
+ ...formValues,
372
+ enableMatterServiceAreaBeta: undefined,
373
+ };
374
+
375
+ if (!password) {
376
+ delete patch.password;
377
+ }
378
+
379
+ await updatePluginConfig(patch);
380
+
381
+ if (password) {
382
+ state.hasPassword = true;
383
+ }
384
+
385
+ if (showSuccess) {
386
+ showToast("success", "Settings saved.");
387
+ }
388
+
389
+ updateAuthStatus(state.hasEncryptedToken, state.hasPassword);
390
+ }
391
+
392
+ async function login() {
393
+ const formValues = getFormValues();
394
+ const { email, password, baseURL } = formValues;
395
+
396
+ if (!email || !password) {
397
+ showToast("error", "Email and password are required.");
398
+ return;
399
+ }
400
+
401
+ const result = await request("/auth/login", { email, password, baseURL });
402
+
403
+ if (result.ok) {
404
+ await updatePluginConfig({
405
+ ...formValues,
406
+ enableMatterServiceAreaBeta: undefined,
407
+ encryptedToken: result.encryptedToken,
408
+ });
409
+ showToast("success", result.message || "Login successful.");
410
+ state.hasEncryptedToken = true;
411
+ state.hasPassword = true;
412
+ setLoggedInState(true, true);
413
+ return;
414
+ }
415
+
416
+ if (result.twoFactorRequired) {
417
+ setTwoFactorVisible(true);
418
+ showToast(
419
+ "warning",
420
+ result.message || "Two-factor authentication required."
421
+ );
422
+ elements.code.focus();
423
+ return;
424
+ }
425
+
426
+ showToast("error", result.message || "Login failed.");
427
+ }
428
+
429
+ async function sendTwoFactorEmail() {
430
+ const email = getEmail();
431
+ const baseURL = getBaseUrl();
432
+ if (!email) {
433
+ showToast("error", "Email is required.");
434
+ return;
435
+ }
436
+
437
+ const result = await request("/auth/send-2fa-email", { email, baseURL });
438
+ if (result.ok) {
439
+ showToast("success", result.message || "Verification email sent.");
440
+ } else {
441
+ showToast("error", result.message || "Failed to send verification email.");
442
+ }
443
+ }
444
+
445
+ async function verifyTwoFactorCode() {
446
+ const formValues = getFormValues();
447
+ const { email, baseURL } = formValues;
448
+ const code = getCode();
449
+ if (!email) {
450
+ showToast("error", "Email is required.");
451
+ return;
452
+ }
453
+ if (!code) {
454
+ showToast("error", "Verification code is required.");
455
+ return;
456
+ }
457
+
458
+ const result = await request("/auth/verify-2fa-code", {
459
+ email,
460
+ code,
461
+ baseURL,
462
+ });
463
+ if (result.ok) {
464
+ const patch = {
465
+ ...formValues,
466
+ enableMatterServiceAreaBeta: undefined,
467
+ encryptedToken: result.encryptedToken,
468
+ };
469
+ delete patch.password;
470
+
471
+ await updatePluginConfig(patch);
472
+ showToast("success", result.message || "Verification successful.");
473
+ state.hasEncryptedToken = true;
474
+ setLoggedInState(true, state.hasPassword);
475
+ } else {
476
+ showToast("error", result.message || "Verification failed.");
477
+ }
478
+ }
479
+
480
+ async function logout() {
481
+ const result = await request("/auth/logout");
482
+ if (result.ok) {
483
+ await updatePluginConfig({ encryptedToken: undefined });
484
+ showToast("success", result.message || "Logged out.");
485
+ state.hasEncryptedToken = false;
486
+ setLoggedInState(false, state.hasPassword);
487
+ resetDiagnosticsAutoRefresh();
488
+ renderLocalTestResults(null);
489
+ renderDiagnostics(null);
490
+ } else {
491
+ showToast("error", result.message || "Logout failed.");
492
+ }
493
+ }
494
+
495
+ async function loadDiagnostics({ scheduleFollowUp = false } = {}) {
496
+ const result = await request("/diagnostics/state", {});
497
+ if (!result.ok) {
498
+ renderDiagnostics(null, result.message || "Failed to load diagnostics.");
499
+ return null;
500
+ }
501
+
502
+ renderDiagnostics(result);
503
+ if (scheduleFollowUp) {
504
+ maybeScheduleDiagnosticsRefresh(result);
505
+ }
506
+
507
+ return result;
508
+ }
509
+
510
+ async function loadMatterPairing() {
511
+ const result = await request("/matter/pairing", {});
512
+ if (!result.ok) {
513
+ renderMatterPairing(
514
+ null,
515
+ result.message || "Failed to load Matter pairing codes."
516
+ );
517
+ return null;
518
+ }
519
+
520
+ renderMatterPairing(result);
521
+ return result;
522
+ }
523
+
524
+ let lastMatterPairingResult = null;
525
+ let showDisabledPairingEntries = false;
526
+
527
+ function isPairingEntryForDisabledRobot(entry) {
528
+ const tokens = getSkipTokenSet();
529
+ if (!tokens.size) {
530
+ return false;
531
+ }
532
+ return [entry.matchedDuid, entry.matchedSerial, entry.serialNumber].some(
533
+ (identifier) => identifier && tokens.has(identifier)
534
+ );
535
+ }
536
+
537
+ function rerenderMatterPairing() {
538
+ if (lastMatterPairingResult) {
539
+ renderMatterPairing(lastMatterPairingResult);
540
+ }
541
+ }
542
+
543
+ function renderMatterPairing(result, errorMessage) {
544
+ elements.matterPairingList.innerHTML = "";
545
+
546
+ if (errorMessage) {
547
+ elements.matterPairingSummary.textContent = errorMessage;
548
+ elements.matterPairingEmpty.classList.remove("hidden");
549
+ return;
550
+ }
551
+
552
+ if (!result || !Array.isArray(result.entries)) {
553
+ elements.matterPairingSummary.textContent =
554
+ "Matter pairing codes are not available yet.";
555
+ elements.matterPairingEmpty.classList.remove("hidden");
556
+ return;
557
+ }
558
+
559
+ lastMatterPairingResult = result;
560
+
561
+ const allEntries = result.entries;
562
+ const disabledEntries = allEntries.filter((entry) =>
563
+ isPairingEntryForDisabledRobot(entry)
564
+ );
565
+ const activeEntries = allEntries.filter(
566
+ (entry) => !disabledEntries.includes(entry)
567
+ );
568
+
569
+ const hiddenNote = disabledEntries.length
570
+ ? ` ${disabledEntries.length} entr${disabledEntries.length === 1 ? "y" : "ies"} for disabled robots ${showDisabledPairingEntries ? "shown below" : "hidden"}.`
571
+ : "";
572
+ elements.matterPairingSummary.textContent = `${activeEntries.length} Matter pairing item(s), last checked ${formatTimestamp(result.generatedAt)}.${hiddenNote}`;
573
+
574
+ if (allEntries.length === 0) {
575
+ elements.matterPairingEmpty.classList.remove("hidden");
576
+ return;
577
+ }
578
+
579
+ elements.matterPairingEmpty.classList.add("hidden");
580
+
581
+ const visibleEntries = showDisabledPairingEntries
582
+ ? [...activeEntries, ...disabledEntries]
583
+ : activeEntries;
584
+ elements.matterPairingList.innerHTML = visibleEntries
585
+ .map((entry) => renderMatterPairingCard(entry))
586
+ .join("");
587
+
588
+ if (disabledEntries.length) {
589
+ const note = document.createElement("div");
590
+ note.className = "help";
591
+
592
+ const text = document.createElement("span");
593
+ text.textContent = showDisabledPairingEntries
594
+ ? "Pairing records for disabled robots are shown above. They are leftover storage; the accessories are no longer registered. Remove them from Apple Home if they still appear there. "
595
+ : `${disabledEntries.length} pairing record${disabledEntries.length === 1 ? "" : "s"} for disabled robots hidden. The accessories are no longer registered; the records are inert leftovers. `;
596
+
597
+ const toggle = document.createElement("a");
598
+ toggle.href = "#";
599
+ toggle.textContent = showDisabledPairingEntries ? "Hide" : "Show anyway";
600
+ toggle.addEventListener("click", (event) => {
601
+ event.preventDefault();
602
+ showDisabledPairingEntries = !showDisabledPairingEntries;
603
+ rerenderMatterPairing();
604
+ });
605
+
606
+ note.appendChild(text);
607
+ note.appendChild(toggle);
608
+ elements.matterPairingList.appendChild(note);
609
+ }
610
+ }
611
+
612
+ function renderMatterPairingCard(entry) {
613
+ const isBridge = entry.kind === "bridge";
614
+ const codeLabel = isBridge ? "Manual pairing code" : "11-digit setup code";
615
+ const rawCodeValue = isBridge
616
+ ? entry.manualPairingCode
617
+ : entry.setupCode || entry.manualPairingCode;
618
+ const rawFormattedCode = entry.manualPairingCode || entry.setupCode;
619
+ const codeValue = rawCodeValue || "n/a";
620
+ const formattedCode = rawFormattedCode || "n/a";
621
+ const hasCodeValue = Boolean(rawCodeValue);
622
+ const hasFormattedCode = Boolean(rawFormattedCode);
623
+ const qrMarkup = entry.qrCodeDataUrl
624
+ ? `<img class="qr-image" src="${escapeHtml(entry.qrCodeDataUrl)}" alt="${escapeHtml(entry.name || "Matter")} QR code" />`
625
+ : `<div class="qr-placeholder">No QR code available</div>`;
626
+ const commissionedText = entry.commissioned ? "Commissioned" : "Not paired";
627
+ const statusClass = entry.commissioned ? "good" : "warn";
628
+
629
+ return `
630
+ <article class="pairing-card">
631
+ <div class="device-header">
632
+ <h3>${escapeHtml(entry.name || "Matter accessory")}</h3>
633
+ <span class="pill ${statusClass}">${escapeHtml(commissionedText)}</span>
634
+ </div>
635
+ <p class="connection-hint">${escapeHtml(entry.hint || "Use this Matter pairing information in Apple Home.")}</p>
636
+ <div class="pairing-content">
637
+ <div class="qr-wrap">
638
+ ${qrMarkup}
639
+ <button class="secondary compact" data-copy-value="${escapeHtml(entry.qrCode || "")}" ${entry.qrCode ? "" : "disabled"}>Copy QR Payload</button>
640
+ </div>
641
+ <dl class="pairing-details">
642
+ <div>
643
+ <dt>${escapeHtml(codeLabel)}</dt>
644
+ <dd class="setup-code">${escapeHtml(codeValue)}</dd>
645
+ </div>
646
+ <div>
647
+ <dt>Formatted manual code</dt>
648
+ <dd>${escapeHtml(formattedCode)}</dd>
649
+ </div>
650
+ <div>
651
+ <dt>Type</dt>
652
+ <dd>${escapeHtml(isBridge ? "Roborock child/daughter bridge" : "Roborock vacuum accessory")}</dd>
653
+ </div>
654
+ <div>
655
+ <dt>Serial</dt>
656
+ <dd>${escapeHtml(maskIdentifier(entry.serialNumber))}</dd>
657
+ </div>
658
+ <div>
659
+ <dt>Updated</dt>
660
+ <dd>${escapeHtml(entry.updatedAt ? formatTimestamp(entry.updatedAt) : "n/a")}</dd>
661
+ </div>
662
+ </dl>
663
+ </div>
664
+ <div class="pairing-actions">
665
+ <button class="primary compact" data-copy-value="${escapeHtml(hasCodeValue ? codeValue : "")}" ${hasCodeValue ? "" : "disabled"}>Copy ${escapeHtml(codeLabel)}</button>
666
+ <button class="secondary compact" data-copy-value="${escapeHtml(hasFormattedCode ? formattedCode : "")}" ${hasFormattedCode ? "" : "disabled"}>Copy Manual Code</button>
667
+ </div>
668
+ </article>
669
+ `;
670
+ }
671
+
672
+ async function handleMatterPairingClick(event) {
673
+ const button = event.target.closest("[data-copy-value]");
674
+ if (!button) {
675
+ return;
676
+ }
677
+
678
+ const value = button.getAttribute("data-copy-value");
679
+ if (!value) {
680
+ showToast("warning", "No pairing value is available to copy.");
681
+ return;
682
+ }
683
+
684
+ await writeClipboard(value);
685
+ showToast("success", "Matter pairing value copied.");
686
+ }
687
+
688
+ function maybeScheduleDiagnosticsRefresh(result) {
689
+ if (!shouldAutoRefreshDiagnostics(result)) {
690
+ resetDiagnosticsAutoRefresh();
691
+ return;
692
+ }
693
+
694
+ if (
695
+ state.diagnosticsAutoRefreshAttempts >= DIAGNOSTICS_AUTO_REFRESH_LIMIT ||
696
+ state.diagnosticsRefreshTimer
697
+ ) {
698
+ return;
699
+ }
700
+
701
+ state.diagnosticsAutoRefreshAttempts += 1;
702
+ state.diagnosticsRefreshTimer = setTimeout(() => {
703
+ state.diagnosticsRefreshTimer = null;
704
+ loadDiagnostics({ scheduleFollowUp: true }).catch(() => {
705
+ showToast("error", "Failed to refresh diagnostics.");
706
+ });
707
+ }, DIAGNOSTICS_AUTO_REFRESH_DELAY_MS);
708
+ }
709
+
710
+ function getConnectionStatus(device) {
711
+ return device.connectionStatus || device.localConnectivityState;
712
+ }
713
+
714
+ function shouldAutoRefreshDiagnostics(result) {
715
+ if (!result || !result.hasHomeData || !Array.isArray(result.devices)) {
716
+ return false;
717
+ }
718
+
719
+ return result.devices.some((device) => {
720
+ const status = getConnectionStatus(device);
721
+ return (
722
+ status !== "Local connected" || device.tcpConnectionState !== "connected"
723
+ );
724
+ });
725
+ }
726
+
727
+ function resetDiagnosticsAutoRefresh() {
728
+ if (state.diagnosticsRefreshTimer) {
729
+ clearTimeout(state.diagnosticsRefreshTimer);
730
+ state.diagnosticsRefreshTimer = null;
731
+ }
732
+
733
+ state.diagnosticsAutoRefreshAttempts = 0;
734
+ }
735
+
736
+ function renderDiagnostics(result, errorMessage) {
737
+ elements.diagnosticsList.innerHTML = "";
738
+ state.lastDiagnostics = result || null;
739
+
740
+ if (errorMessage) {
741
+ elements.diagnosticsSummary.textContent = errorMessage;
742
+ elements.diagnosticsEmpty.classList.remove("hidden");
743
+ return;
744
+ }
745
+
746
+ if (!result || !result.hasHomeData) {
747
+ elements.diagnosticsSummary.textContent = "No cached HomeData found yet.";
748
+ elements.diagnosticsEmpty.classList.remove("hidden");
749
+ return;
750
+ }
751
+
752
+ const hasToken = Boolean(result.hasEncryptedToken || state.hasEncryptedToken);
753
+ const tokenSummary = hasToken ? "token saved" : "no saved token";
754
+ elements.diagnosticsSummary.textContent = `${result.deviceCount} device(s), ${tokenSummary}, last snapshot ${formatTimestamp(result.generatedAt)}.`;
755
+
756
+ if (!result.devices || result.devices.length === 0) {
757
+ elements.diagnosticsEmpty.classList.remove("hidden");
758
+ return;
759
+ }
760
+
761
+ elements.diagnosticsEmpty.classList.add("hidden");
762
+
763
+ result.devices.forEach((device) => {
764
+ const card = document.createElement("article");
765
+ card.className = "diagnostic-device";
766
+ const localClass = device.connectionHealth || "warn";
767
+ const onlineText =
768
+ device.online === null ? "unknown" : String(device.online);
769
+ card.innerHTML = `
770
+ <div class="device-header">
771
+ <h3>${escapeHtml(device.name || "Unknown device")}</h3>
772
+ <span class="pill ${localClass}">${escapeHtml(getConnectionStatus(device) || "Unknown")}</span>
773
+ </div>
774
+ <p class="connection-hint">${escapeHtml(device.connectionHint || "No additional transport details are available yet.")}</p>
775
+ <dl>
776
+ <div><dt>DUID</dt><dd>${escapeHtml(device.duid || "unknown")}</dd></div>
777
+ <div><dt>Serial Number</dt><dd>${escapeHtml(device.serialNumber || "n/a")}</dd></div>
778
+ <div><dt>Resolved Model</dt><dd>${escapeHtml(device.resolvedModel || "unknown")}</dd></div>
779
+ <div><dt>Device Model</dt><dd>${escapeHtml(device.deviceModel || "n/a")}</dd></div>
780
+ <div><dt>Product Model</dt><dd>${escapeHtml(device.productModel || "n/a")}</dd></div>
781
+ <div><dt>Product ID</dt><dd>${escapeHtml(device.productId == null ? "n/a" : String(device.productId))}</dd></div>
782
+ <div><dt>HomeData Source</dt><dd>${escapeHtml(device.homeDataSource || "unknown")}</dd></div>
783
+ <div><dt>Online</dt><dd>${escapeHtml(onlineText)}</dd></div>
784
+ <div><dt>Local IP</dt><dd>${escapeHtml(device.localIp || "n/a")}</dd></div>
785
+ <div><dt>Discovery</dt><dd>${escapeHtml(device.localDiscoveryState || "n/a")}</dd></div>
786
+ <div><dt>TCP State</dt><dd>${escapeHtml(device.tcpConnectionState || "n/a")}</dd></div>
787
+ <div><dt>Marked Remote</dt><dd>${escapeHtml(device.isRemote === null ? "unknown" : String(device.isRemote))}</dd></div>
788
+ <div><dt>Remote Reason</dt><dd>${escapeHtml(device.remoteReason || "n/a")}</dd></div>
789
+ <div><dt>Last Transport</dt><dd>${escapeHtml(device.lastTransport || "n/a")}</dd></div>
790
+ <div><dt>Last Reason</dt><dd>${escapeHtml(device.lastTransportReason || "n/a")}</dd></div>
791
+ <div><dt>Last Method</dt><dd>${escapeHtml(device.lastCommandMethod || "n/a")}</dd></div>
792
+ <div><dt>Transport Updated</dt><dd>${escapeHtml(device.transportUpdatedAt ? formatTimestamp(device.transportUpdatedAt) : "n/a")}</dd></div>
793
+ </dl>
794
+ `;
795
+ elements.diagnosticsList.appendChild(card);
796
+ });
797
+ }
798
+
799
+ async function testLocalConnections() {
800
+ resetDiagnosticsAutoRefresh();
801
+ elements.testLocal.disabled = true;
802
+ const previousLabel = elements.testLocal.textContent;
803
+ elements.testLocal.textContent = "Testing...";
804
+
805
+ try {
806
+ const result = await request(
807
+ "/diagnostics/test-local",
808
+ { cloudOnlyMode: getCloudOnlyMode() },
809
+ { timeoutMs: 15000 }
810
+ );
811
+ if (!result.ok) {
812
+ renderLocalTestResults(null, result.message || "Local test failed.");
813
+ showToast("error", result.message || "Local test failed.");
814
+ return null;
815
+ }
816
+
817
+ renderLocalTestResults(result);
818
+ const { failedCount, skippedCount } = (result.devices || []).reduce(
819
+ (counts, device) => {
820
+ if (device.status === "failed") {
821
+ counts.failedCount += 1;
822
+ } else if (device.status === "skipped") {
823
+ counts.skippedCount += 1;
824
+ }
825
+ return counts;
826
+ },
827
+ { failedCount: 0, skippedCount: 0 }
828
+ );
829
+ if (failedCount > 0) {
830
+ showToast("warning", "Local connection test found a TCP problem.");
831
+ } else if (skippedCount > 0) {
832
+ showToast("warning", "Local connection test was skipped for a device.");
833
+ } else {
834
+ showToast("success", "Local connection test passed.");
835
+ }
836
+
837
+ return result;
838
+ } finally {
839
+ elements.testLocal.disabled = false;
840
+ elements.testLocal.textContent = previousLabel;
841
+ }
842
+ }
843
+
844
+ function renderLocalTestResults(result, errorMessage) {
845
+ state.lastLocalTest = result || null;
846
+ elements.localTestResults.innerHTML = "";
847
+
848
+ if (errorMessage) {
849
+ elements.localTestResults.classList.remove("hidden");
850
+ elements.localTestResults.innerHTML = `
851
+ <article class="local-test-card warn">
852
+ <div class="device-header">
853
+ <h3>Local Connection Test</h3>
854
+ <span class="pill warn">Failed</span>
855
+ </div>
856
+ <p class="connection-hint">${escapeHtml(errorMessage)}</p>
857
+ </article>
858
+ `;
859
+ return;
860
+ }
861
+
862
+ if (
863
+ !result ||
864
+ !Array.isArray(result.devices) ||
865
+ result.devices.length === 0
866
+ ) {
867
+ elements.localTestResults.classList.add("hidden");
868
+ return;
869
+ }
870
+
871
+ elements.localTestResults.classList.remove("hidden");
872
+ const testedAt = formatTimestamp(result.generatedAt);
873
+ const deviceCards = result.devices
874
+ .map((device) => {
875
+ const health = device.health || "warn";
876
+ const status = device.status || "unknown";
877
+ const latencyText =
878
+ device.latencyMs === null || device.latencyMs === undefined
879
+ ? "n/a"
880
+ : `${device.latencyMs} ms`;
881
+ return `
882
+ <article class="local-test-card ${health}">
883
+ <div class="device-header">
884
+ <h3>${escapeHtml(device.name || "Unknown device")}</h3>
885
+ <span class="pill ${health}">${escapeHtml(status)}</span>
886
+ </div>
887
+ <p class="connection-hint">${escapeHtml(device.message || "No local test details were returned.")}</p>
888
+ <dl>
889
+ <div><dt>Latency</dt><dd>${escapeHtml(latencyText)}</dd></div>
890
+ <div><dt>Local IP</dt><dd>${escapeHtml(device.localIp || "n/a")}</dd></div>
891
+ <div><dt>Port</dt><dd>${escapeHtml(device.port || "n/a")}</dd></div>
892
+ <div><dt>Cached Status</dt><dd>${escapeHtml(device.cachedConnectionStatus || "unknown")}</dd></div>
893
+ <div><dt>Cached TCP State</dt><dd>${escapeHtml(device.cachedTcpState || "n/a")}</dd></div>
894
+ <div><dt>Cached Transport</dt><dd>${escapeHtml(device.cachedLastTransport || "n/a")}</dd></div>
895
+ <div><dt>Cached Reason</dt><dd>${escapeHtml(device.cachedLastReason || "n/a")}</dd></div>
896
+ <div><dt>Test Source</dt><dd>${escapeHtml(device.connectionSource || "n/a")}</dd></div>
897
+ <div><dt>Cloud Fallback Likely</dt><dd>${escapeHtml(String(Boolean(device.cloudFallbackLikely)))}</dd></div>
898
+ <div><dt>Transport Updated</dt><dd>${escapeHtml(device.cachedTransportUpdatedAt ? formatTimestamp(device.cachedTransportUpdatedAt) : "n/a")}</dd></div>
899
+ </dl>
900
+ </article>
901
+ `;
902
+ })
903
+ .join("");
904
+
905
+ elements.localTestResults.innerHTML = `
906
+ <div class="local-test-heading">
907
+ <h3>Local Connection Test</h3>
908
+ <span class="muted">Tested ${escapeHtml(testedAt)} in ${escapeHtml(String(result.durationMs ?? "n/a"))} ms.</span>
909
+ </div>
910
+ ${deviceCards}
911
+ `;
912
+ }
913
+
914
+ async function copyDiagnosticsReport() {
915
+ let diagnostics = state.lastDiagnostics;
916
+ if (!diagnostics) {
917
+ diagnostics = await loadDiagnostics();
918
+ }
919
+
920
+ if (!diagnostics || !diagnostics.hasHomeData) {
921
+ showToast("warning", "No diagnostics are available to copy yet.");
922
+ return;
923
+ }
924
+
925
+ await writeClipboard(buildDiagnosticsReport(diagnostics));
926
+ showToast("success", "Redacted diagnostic report copied.");
927
+ }
928
+
929
+ function buildDiagnosticsReport(result) {
930
+ const hasToken = Boolean(result.hasEncryptedToken || state.hasEncryptedToken);
931
+ const lines = [
932
+ "homebridge-roborock-vacuum2 diagnostic report",
933
+ `generatedAt: ${result.generatedAt || "unknown"}`,
934
+ `pluginVersion: ${result.pluginVersion || "unknown"}`,
935
+ `nodeVersion: ${result.nodeVersion || "unknown"}`,
936
+ `token: ${hasToken ? "present" : "missing"}`,
937
+ `homeData: ${result.hasHomeData ? "present" : "missing"}`,
938
+ `cloudOnlyMode: ${getCloudOnlyMode() ? "enabled" : "disabled"}`,
939
+ `deviceCount: ${result.deviceCount ?? "unknown"}`,
940
+ "",
941
+ ];
942
+
943
+ (result.devices || []).forEach((device, index) => {
944
+ lines.push(`device ${index + 1}: ${device.name || "Unknown device"}`);
945
+ lines.push(` duid: ${maskIdentifier(device.duid)}`);
946
+ lines.push(` serialNumber: ${maskIdentifier(device.serialNumber)}`);
947
+ lines.push(` resolvedModel: ${device.resolvedModel || "unknown"}`);
948
+ lines.push(` productId: ${device.productId || "n/a"}`);
949
+ lines.push(
950
+ ` online: ${device.online === null ? "unknown" : String(device.online)}`
951
+ );
952
+ lines.push(` connectionStatus: ${device.connectionStatus || "unknown"}`);
953
+ lines.push(` connectionHint: ${device.connectionHint || "n/a"}`);
954
+ lines.push(` localIp: ${maskLocalIp(device.localIp)}`);
955
+ lines.push(` discovery: ${device.localDiscoveryState || "n/a"}`);
956
+ lines.push(` tcpState: ${device.tcpConnectionState || "n/a"}`);
957
+ lines.push(
958
+ ` markedRemote: ${device.isRemote === null ? "unknown" : String(device.isRemote)}`
959
+ );
960
+ lines.push(` remoteReason: ${device.remoteReason || "n/a"}`);
961
+ lines.push(` lastTransport: ${device.lastTransport || "n/a"}`);
962
+ lines.push(` lastReason: ${device.lastTransportReason || "n/a"}`);
963
+ lines.push(` lastMethod: ${device.lastCommandMethod || "n/a"}`);
964
+ lines.push(` transportUpdatedAt: ${device.transportUpdatedAt || "n/a"}`);
965
+ lines.push(
966
+ ` roborockDiagnosticUpdatedAt: ${device.roborockDiagnosticUpdatedAt || "n/a"}`
967
+ );
968
+ appendRoborockDiagnosticReport(lines, device);
969
+ lines.push("");
970
+ });
971
+
972
+ appendLocalTestReport(lines);
973
+
974
+ return lines.join("\n").trim();
975
+ }
976
+
977
+ function appendRoborockDiagnosticReport(lines, device) {
978
+ const entries = [
979
+ ["lastStatus", device.lastStatusDiagnostic],
980
+ ["lastServerTimer", device.lastServerTimerDiagnostic],
981
+ ["lastTimer", device.lastTimerDiagnostic],
982
+ ["lastCloudMessage", device.lastCloudMessageDiagnostic],
983
+ ["lastLocalMessage", device.lastLocalMessageDiagnostic],
984
+ ].filter(([, value]) => value !== null && value !== undefined);
985
+
986
+ if (entries.length === 0) {
987
+ return;
988
+ }
989
+
990
+ lines.push(" roborockDiagnostics:");
991
+ for (const [label, value] of entries) {
992
+ lines.push(` ${label}: ${formatDiagnosticPayload(value)}`);
993
+ }
994
+ }
995
+
996
+ function formatDiagnosticPayload(value) {
997
+ try {
998
+ const text = JSON.stringify(value);
999
+ const masked = maskLocalIpsInText(text);
1000
+ return masked.length > 1500 ? `${masked.slice(0, 1500)}...` : masked;
1001
+ } catch {
1002
+ return "unavailable";
1003
+ }
1004
+ }
1005
+
1006
+ function appendLocalTestReport(lines) {
1007
+ const result = state.lastLocalTest;
1008
+ if (
1009
+ !result ||
1010
+ !Array.isArray(result.devices) ||
1011
+ result.devices.length === 0
1012
+ ) {
1013
+ return;
1014
+ }
1015
+
1016
+ lines.push("latestLocalConnectionTest:");
1017
+ lines.push(` generatedAt: ${result.generatedAt || "unknown"}`);
1018
+ lines.push(` durationMs: ${result.durationMs ?? "unknown"}`);
1019
+
1020
+ result.devices.forEach((device, index) => {
1021
+ lines.push(` device ${index + 1}: ${device.name || "Unknown device"}`);
1022
+ lines.push(` duid: ${maskIdentifier(device.duid)}`);
1023
+ lines.push(` status: ${device.status || "unknown"}`);
1024
+ lines.push(` message: ${maskLocalIpsInText(device.message || "n/a")}`);
1025
+ lines.push(` latencyMs: ${device.latencyMs ?? "n/a"}`);
1026
+ lines.push(` localIp: ${maskLocalIp(device.localIp)}`);
1027
+ lines.push(
1028
+ ` cachedStatus: ${device.cachedConnectionStatus || "unknown"}`
1029
+ );
1030
+ lines.push(` cachedTcpState: ${device.cachedTcpState || "n/a"}`);
1031
+ lines.push(
1032
+ ` cachedLastTransport: ${device.cachedLastTransport || "n/a"}`
1033
+ );
1034
+ lines.push(` cachedLastReason: ${device.cachedLastReason || "n/a"}`);
1035
+ lines.push(
1036
+ ` cloudFallbackLikely: ${String(Boolean(device.cloudFallbackLikely))}`
1037
+ );
1038
+ lines.push(
1039
+ ` cachedTransportUpdatedAt: ${device.cachedTransportUpdatedAt || "n/a"}`
1040
+ );
1041
+ });
1042
+
1043
+ lines.push("");
1044
+ }
1045
+
1046
+ async function writeClipboard(text) {
1047
+ if (
1048
+ navigator.clipboard &&
1049
+ typeof navigator.clipboard.writeText === "function"
1050
+ ) {
1051
+ try {
1052
+ await navigator.clipboard.writeText(text);
1053
+ return;
1054
+ } catch {
1055
+ // Fall back to the textarea copy path below.
1056
+ }
1057
+ }
1058
+
1059
+ const textarea = document.createElement("textarea");
1060
+ textarea.value = text;
1061
+ textarea.setAttribute("readonly", "");
1062
+ textarea.style.position = "fixed";
1063
+ textarea.style.top = "-9999px";
1064
+ document.body.appendChild(textarea);
1065
+ textarea.select();
1066
+ document.execCommand("copy");
1067
+ textarea.remove();
1068
+ }
1069
+
1070
+ function maskIdentifier(value) {
1071
+ if (!value) {
1072
+ return "n/a";
1073
+ }
1074
+
1075
+ const normalized = String(value);
1076
+ if (normalized.length <= 8) {
1077
+ return "[redacted]";
1078
+ }
1079
+
1080
+ return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
1081
+ }
1082
+
1083
+ function maskLocalIpsInText(value) {
1084
+ return String(value).replace(
1085
+ /\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b/g,
1086
+ "$1.x"
1087
+ );
1088
+ }
1089
+
1090
+ function maskLocalIp(value) {
1091
+ if (!value) {
1092
+ return "n/a";
1093
+ }
1094
+
1095
+ const normalized = String(value);
1096
+ const ipv4Parts = normalized.split(".");
1097
+ if (ipv4Parts.length === 4) {
1098
+ return `${ipv4Parts.slice(0, 3).join(".")}.x`;
1099
+ }
1100
+
1101
+ return "present (redacted)";
1102
+ }
1103
+
1104
+ function formatTimestamp(value) {
1105
+ if (!value) {
1106
+ return "unknown time";
1107
+ }
1108
+
1109
+ const date = new Date(value);
1110
+ if (Number.isNaN(date.getTime())) {
1111
+ return "unknown time";
1112
+ }
1113
+
1114
+ return date.toLocaleString();
1115
+ }
1116
+
1117
+ function escapeHtml(value) {
1118
+ return String(value)
1119
+ .replace(/&/g, "&amp;")
1120
+ .replace(/</g, "&lt;")
1121
+ .replace(/>/g, "&gt;")
1122
+ .replace(/"/g, "&quot;")
1123
+ .replace(/'/g, "&#39;");
1124
+ }
1125
+
1126
+ function normalizeBaseUrl(value) {
1127
+ if (!value) {
1128
+ return "https://usiot.roborock.com";
1129
+ }
1130
+ if (value.startsWith("http://") || value.startsWith("https://")) {
1131
+ return value.replace(/\/+$/, "");
1132
+ }
1133
+ return `https://${value.replace(/\/+$/, "")}`;
1134
+ }
1135
+
1136
+ function updateAuthStatus(hasToken, hasPassword = false) {
1137
+ elements.authStatus.classList.remove("good", "warn");
1138
+ if (hasToken) {
1139
+ elements.authStatus.textContent = "Token saved";
1140
+ elements.authStatus.classList.add("good");
1141
+ return;
1142
+ }
1143
+
1144
+ if (hasPassword) {
1145
+ elements.authStatus.textContent = "Password fallback";
1146
+ elements.authStatus.classList.add("warn");
1147
+ return;
1148
+ }
1149
+
1150
+ elements.authStatus.textContent = "Login needed";
1151
+ elements.authStatus.classList.add("warn");
1152
+ }
1153
+
1154
+ function setTwoFactorVisible(isVisible) {
1155
+ elements.twoFactorSection.classList.toggle("hidden", !isVisible);
1156
+ }
1157
+
1158
+ function setLoggedInState(isLoggedIn, hasPassword = false) {
1159
+ elements.logout.classList.toggle("hidden", !isLoggedIn);
1160
+ elements.login.classList.toggle("hidden", isLoggedIn);
1161
+ elements.passwordRow.classList.toggle("hidden", isLoggedIn);
1162
+ setTwoFactorVisible(false);
1163
+ elements.email.readOnly = isLoggedIn;
1164
+ elements.email.parentElement.classList.toggle("readonly", isLoggedIn);
1165
+ elements.baseUrl.disabled = isLoggedIn;
1166
+ elements.baseUrl.parentElement.classList.toggle("readonly", isLoggedIn);
1167
+ updateAuthStatus(isLoggedIn, hasPassword);
1168
+ }
1169
+
1170
+ async function updatePluginConfig(patch) {
1171
+ if (
1172
+ !window.homebridge ||
1173
+ typeof window.homebridge.getPluginConfig !== "function"
1174
+ ) {
1175
+ return;
1176
+ }
1177
+
1178
+ const configs = await window.homebridge.getPluginConfig();
1179
+ let config = configs.find(
1180
+ (entry) => entry.platform === "RoborockVacuumPlatform"
1181
+ );
1182
+ if (!config) {
1183
+ config = { platform: "RoborockVacuumPlatform", name: "Roborock Vacuum" };
1184
+ configs.push(config);
1185
+ }
1186
+
1187
+ Object.keys(patch).forEach((key) => {
1188
+ const value = patch[key];
1189
+ if (value === undefined) {
1190
+ delete config[key];
1191
+ } else {
1192
+ config[key] = value;
1193
+ }
1194
+ });
1195
+
1196
+ await window.homebridge.updatePluginConfig(configs);
1197
+ await window.homebridge.savePluginConfig();
1198
+ }
1199
+
1200
+ function init() {
1201
+ loadManagedDevices().catch(() => {});
1202
+ if (elements.refreshDevices) {
1203
+ elements.refreshDevices.addEventListener("click", () =>
1204
+ loadManagedDevices()
1205
+ );
1206
+ }
1207
+ loadConfig().catch(() => {
1208
+ showToast("error", "Failed to load current config.");
1209
+ });
1210
+ elements.saveSettings.addEventListener("click", () => saveCredentials(true));
1211
+ elements.login.addEventListener("click", login);
1212
+ elements.send2fa.addEventListener("click", sendTwoFactorEmail);
1213
+ elements.verify2fa.addEventListener("click", verifyTwoFactorCode);
1214
+ elements.logout.addEventListener("click", logout);
1215
+ elements.testLocal.addEventListener("click", () => {
1216
+ testLocalConnections().catch(() => {
1217
+ showToast("error", "Failed to run local connection test.");
1218
+ });
1219
+ });
1220
+ elements.copyDiagnostics.addEventListener("click", () => {
1221
+ copyDiagnosticsReport().catch(() => {
1222
+ showToast("error", "Failed to copy diagnostics.");
1223
+ });
1224
+ });
1225
+ elements.refreshMatterPairing.addEventListener("click", () => {
1226
+ loadMatterPairing().catch(() => {
1227
+ showToast("error", "Failed to load Matter pairing codes.");
1228
+ });
1229
+ });
1230
+ elements.matterPairingList.addEventListener("click", (event) => {
1231
+ handleMatterPairingClick(event).catch(() => {
1232
+ showToast("error", "Failed to copy Matter pairing value.");
1233
+ });
1234
+ });
1235
+ elements.baseUrl.addEventListener("change", () => saveCredentials(false));
1236
+ elements.skipDevices.addEventListener("change", () => {
1237
+ saveCredentials(false);
1238
+ renderManagedDevices();
1239
+ rerenderMatterPairing();
1240
+ });
1241
+ elements.debugMode.addEventListener("change", () => saveCredentials(false));
1242
+ if (elements.enableMatterChargingDocked) {
1243
+ elements.enableMatterChargingDocked.addEventListener("change", () =>
1244
+ saveCredentials(false)
1245
+ );
1246
+ }
1247
+ if (elements.matterChargedBatteryThreshold) {
1248
+ elements.matterChargedBatteryThreshold.addEventListener("change", () =>
1249
+ saveCredentials(false)
1250
+ );
1251
+ }
1252
+ elements.preferCloudForMatterCommands.addEventListener("change", () =>
1253
+ saveCredentials(false)
1254
+ );
1255
+ elements.cloudOnlyMode.addEventListener("change", () =>
1256
+ saveCredentials(false)
1257
+ );
1258
+ elements.transientWarningThrottleHours.addEventListener("change", () =>
1259
+ saveCredentials(false)
1260
+ );
1261
+ elements.email.addEventListener("change", () => saveCredentials(false));
1262
+ elements.refreshDiagnostics.addEventListener("click", () => {
1263
+ resetDiagnosticsAutoRefresh();
1264
+ loadDiagnostics().catch(() => {
1265
+ showToast("error", "Failed to load diagnostics.");
1266
+ });
1267
+ });
1268
+ }
1269
+
1270
+ if (window.homebridge) {
1271
+ window.homebridge.addEventListener("ready", () => {
1272
+ init();
1273
+ });
1274
+ } else {
1275
+ document.addEventListener("DOMContentLoaded", init);
1276
+ }