@switchbot/homebridge-switchbot 5.0.0-beta.111 → 5.0.0-beta.113

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 (85) hide show
  1. package/MIGRATION.md +10 -0
  2. package/README.md +43 -0
  3. package/TODO.md +263 -0
  4. package/config.schema.json +27 -10
  5. package/dist/device-types.d.ts.map +1 -1
  6. package/dist/device-types.js +93 -63
  7. package/dist/device-types.js.map +1 -1
  8. package/dist/deviceFactory.d.ts.map +1 -1
  9. package/dist/deviceFactory.js +55 -33
  10. package/dist/deviceFactory.js.map +1 -1
  11. package/dist/devices/genericDevice.d.ts +12 -1
  12. package/dist/devices/genericDevice.d.ts.map +1 -1
  13. package/dist/devices/genericDevice.js +121 -1
  14. package/dist/devices/genericDevice.js.map +1 -1
  15. package/dist/errors.d.ts +5 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +51 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -1
  20. package/dist/homebridge-ui/endpoints/devices.js +80 -1
  21. package/dist/homebridge-ui/endpoints/devices.js.map +1 -1
  22. package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -1
  23. package/dist/homebridge-ui/endpoints/discovery.js +73 -41
  24. package/dist/homebridge-ui/endpoints/discovery.js.map +1 -1
  25. package/dist/homebridge-ui/public/css/styles.css +209 -16
  26. package/dist/homebridge-ui/public/index.html +87 -56
  27. package/dist/homebridge-ui/public/js/api.d.ts +24 -1
  28. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  29. package/dist/homebridge-ui/public/js/api.js +33 -2
  30. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  31. package/dist/homebridge-ui/public/js/api.ts +52 -3
  32. package/dist/homebridge-ui/public/js/app.js +1604 -143
  33. package/dist/homebridge-ui/public/js/app.js.map +3 -3
  34. package/dist/homebridge-ui/public/js/app.ts +2 -1
  35. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  36. package/dist/homebridge-ui/public/js/devices.js +2 -0
  37. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  38. package/dist/homebridge-ui/public/js/devices.ts +13 -1
  39. package/dist/homebridge-ui/public/js/discovery.d.ts +1 -0
  40. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
  41. package/dist/homebridge-ui/public/js/discovery.js +1244 -124
  42. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/discovery.ts +1344 -142
  44. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  45. package/dist/homebridge-ui/public/js/modals.js +42 -0
  46. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  47. package/dist/homebridge-ui/public/js/modals.ts +50 -1
  48. package/dist/homebridge-ui/public/js/render.d.ts +3 -0
  49. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  50. package/dist/homebridge-ui/public/js/render.js +389 -36
  51. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/render.ts +421 -38
  53. package/dist/platform.d.ts +1 -0
  54. package/dist/platform.d.ts.map +1 -1
  55. package/dist/platform.js +3 -4
  56. package/dist/platform.js.map +1 -1
  57. package/dist/switchbotClient.d.ts.map +1 -1
  58. package/dist/switchbotClient.js +51 -5
  59. package/dist/switchbotClient.js.map +1 -1
  60. package/docs/assets/highlight.css +9 -2
  61. package/docs/index.html +28 -2
  62. package/docs/variables/default.html +1 -1
  63. package/package.json +9 -8
  64. package/src/device-types.ts +95 -64
  65. package/src/deviceFactory.ts +55 -33
  66. package/src/devices/genericDevice.ts +113 -1
  67. package/src/errors.ts +43 -0
  68. package/src/homebridge-ui/endpoints/devices.ts +82 -1
  69. package/src/homebridge-ui/endpoints/discovery.ts +78 -44
  70. package/src/homebridge-ui/public/css/styles.css +209 -16
  71. package/src/homebridge-ui/public/index.html +87 -56
  72. package/src/homebridge-ui/public/js/api.ts +52 -3
  73. package/src/homebridge-ui/public/js/app.ts +2 -1
  74. package/src/homebridge-ui/public/js/devices.ts +13 -1
  75. package/src/homebridge-ui/public/js/discovery.ts +1344 -142
  76. package/src/homebridge-ui/public/js/modals.ts +50 -1
  77. package/src/homebridge-ui/public/js/render.ts +421 -38
  78. package/src/platform.ts +3 -4
  79. package/src/switchbotClient.ts +44 -9
  80. package/test/e2e/run-e2e.spec.ts +12 -10
  81. package/test/helpers/matter-harness.ts +2 -2
  82. package/test/matter-integration.spec.ts +21 -0
  83. package/todo/COMPLETED.md +313 -0
  84. package/todo/TODO.md +80 -66
  85. package/switchbot-homebridge-switchbot-5.0.0.tgz +0 -0
@@ -42,10 +42,12 @@ __export(api_exports, {
42
42
  deleteAllDevices: () => deleteAllDevices,
43
43
  deleteDevice: () => deleteDevice,
44
44
  discoverDevices: () => discoverDevices,
45
+ fetchBluetoothStatus: () => fetchBluetoothStatus,
45
46
  fetchCredentialStatus: () => fetchCredentialStatus,
46
47
  fetchDevices: () => fetchDevices,
47
48
  saveCredentials: () => saveCredentials,
48
49
  syncParentPluginConfigFromDisk: () => syncParentPluginConfigFromDisk,
50
+ testDeviceConnection: () => testDeviceConnection,
49
51
  updateDevice: () => updateDevice
50
52
  });
51
53
  function isSwitchBotPlatformConfig(block) {
@@ -125,9 +127,9 @@ async function fetchDevices() {
125
127
  return [];
126
128
  }
127
129
  }
128
- async function discoverDevices() {
130
+ async function discoverDevices(mode = "all", options) {
129
131
  try {
130
- const resp = await homebridge.request("/discover", {});
132
+ const resp = await homebridge.request("/discover", { mode, ...options });
131
133
  uiLog.info("Discover response:", resp);
132
134
  if (!resp || resp.success === false) {
133
135
  throw new Error(resp?.data?.message || "Discovery failed");
@@ -137,6 +139,30 @@ async function discoverDevices() {
137
139
  throw e;
138
140
  }
139
141
  }
142
+ async function fetchBluetoothStatus() {
143
+ try {
144
+ const resp = await homebridge.request("/ble-status", {});
145
+ if (!resp || resp.success === false) {
146
+ return { available: false, message: "Bluetooth status unavailable" };
147
+ }
148
+ return resp.data || { available: false, message: "Bluetooth status unavailable" };
149
+ } catch (_e) {
150
+ return { available: false, message: "Bluetooth status unavailable" };
151
+ }
152
+ }
153
+ async function testDeviceConnection(payload) {
154
+ const resp = await homebridge.request("/test-connection", payload);
155
+ if (!resp || resp.success === false) {
156
+ throw new Error(resp?.data?.message || "Connection test failed");
157
+ }
158
+ return resp.data || {
159
+ success: false,
160
+ deviceId: payload.deviceId,
161
+ method: "Auto",
162
+ latencyMs: 0,
163
+ message: "Connection test failed"
164
+ };
165
+ }
140
166
  async function addDevice(deviceId, name, type, options) {
141
167
  try {
142
168
  const payload = { deviceId, name, type };
@@ -149,6 +175,12 @@ async function addDevice(deviceId, name, type, options) {
149
175
  if (options?.rssi !== void 0 && options?.rssi !== null && options?.rssi !== 0) {
150
176
  payload.rssi = options.rssi;
151
177
  }
178
+ if (options?.encryptionKey) {
179
+ payload.encryptionKey = options.encryptionKey;
180
+ }
181
+ if (options?.keyId) {
182
+ payload.keyId = options.keyId;
183
+ }
152
184
  uiLog.info("Adding device to config:", payload);
153
185
  const resp = await homebridge.request("/add-device", payload);
154
186
  uiLog.info("Add device response:", resp);
@@ -292,83 +324,466 @@ var init_toast = __esm({
292
324
  var discovery_exports = {};
293
325
  __export(discovery_exports, {
294
326
  addDeviceToConfig: () => addDeviceToConfig,
295
- discoverDevices: () => discoverDevices2
327
+ discoverDevices: () => discoverDevices2,
328
+ initializeDiscoverySettings: () => initializeDiscoverySettings
296
329
  });
330
+ async function batchSetDeviceEnabled(selectedIds, enabled) {
331
+ const resp = await homebridge.request("/platform-config", {});
332
+ if (!resp || resp.success === false || !resp.data) {
333
+ throw new Error("Failed to load config");
334
+ }
335
+ const config = resp.data;
336
+ const configArr = Array.isArray(config) ? config : [config];
337
+ const platformIdx = configArr.findIndex((c) => (c.platform || c.name || "").toLowerCase().includes("switchbot"));
338
+ if (platformIdx === -1) {
339
+ throw new Error("SwitchBot platform config not found");
340
+ }
341
+ const platformConfig = configArr[platformIdx];
342
+ if (!Array.isArray(platformConfig.devices)) {
343
+ throw new TypeError("No devices array in config");
344
+ }
345
+ let changed = false;
346
+ for (const dev of platformConfig.devices) {
347
+ const id = String(dev.deviceId || dev.id || "").trim().toLowerCase();
348
+ if (selectedIds.has(id)) {
349
+ if (dev.enabled !== enabled) {
350
+ dev.enabled = enabled;
351
+ changed = true;
352
+ }
353
+ }
354
+ }
355
+ if (changed) {
356
+ if (typeof homebridge.updatePluginConfig === "function") {
357
+ await homebridge.updatePluginConfig(configArr);
358
+ } else {
359
+ throw new TypeError("homebridge.updatePluginConfig is not available");
360
+ }
361
+ if (typeof homebridge.savePluginConfig === "function") {
362
+ await homebridge.savePluginConfig();
363
+ }
364
+ }
365
+ }
297
366
  function normalizeId(value) {
298
367
  return String(value ?? "").trim().toLowerCase();
299
368
  }
369
+ function dedupeById(devices) {
370
+ return devices.filter((d, index, arr) => !!d?.id && arr.findIndex((x) => x?.id === d.id) === index);
371
+ }
372
+ function mergeDiscoveredDevices(existingDevices, incomingDevices) {
373
+ const deviceMap = /* @__PURE__ */ new Map();
374
+ for (const d of dedupeById(existingDevices)) {
375
+ deviceMap.set(d.id, { ...d });
376
+ }
377
+ for (const d of dedupeById(incomingDevices)) {
378
+ const current = deviceMap.get(d.id);
379
+ if (current) {
380
+ const nextConnectionType = current.connectionType === d.connectionType ? current.connectionType : "Both";
381
+ deviceMap.set(d.id, {
382
+ ...current,
383
+ ...d,
384
+ connectionType: nextConnectionType
385
+ });
386
+ } else {
387
+ deviceMap.set(d.id, { ...d });
388
+ }
389
+ }
390
+ return [...deviceMap.values()];
391
+ }
392
+ function setDiscoveryCache(devices) {
393
+ try {
394
+ const payload = { timestamp: Date.now(), devices };
395
+ localStorage.setItem(DISCOVERY_CACHE_KEY, JSON.stringify(payload));
396
+ } catch (_e) {
397
+ }
398
+ }
399
+ function clearDiscoveryCache() {
400
+ try {
401
+ localStorage.removeItem(DISCOVERY_CACHE_KEY);
402
+ } catch (_e) {
403
+ }
404
+ }
405
+ function getDiscoveryCache(validOnly = true) {
406
+ try {
407
+ const stored = localStorage.getItem(DISCOVERY_CACHE_KEY);
408
+ if (!stored) {
409
+ return null;
410
+ }
411
+ const payload = JSON.parse(stored);
412
+ if (!payload || !Array.isArray(payload.devices) || typeof payload.timestamp !== "number") {
413
+ return null;
414
+ }
415
+ const age = Date.now() - payload.timestamp;
416
+ if (validOnly && age > DISCOVERY_CACHE_TTL_MS) {
417
+ return null;
418
+ }
419
+ return payload;
420
+ } catch (_e) {
421
+ return null;
422
+ }
423
+ }
424
+ function getDiscoveryAutoRefreshSeconds() {
425
+ try {
426
+ const stored = localStorage.getItem(DISCOVERY_AUTO_REFRESH_KEY);
427
+ const value = Number(stored || 0);
428
+ return Number.isFinite(value) && value >= 0 ? value : 0;
429
+ } catch (_e) {
430
+ return 0;
431
+ }
432
+ }
433
+ function setDiscoveryAutoRefreshSeconds(value) {
434
+ try {
435
+ localStorage.setItem(DISCOVERY_AUTO_REFRESH_KEY, String(Math.max(0, value)));
436
+ } catch (_e) {
437
+ }
438
+ }
439
+ function getDiscoveryHideAddedPreference() {
440
+ try {
441
+ return localStorage.getItem(DISCOVERY_HIDE_ADDED_KEY) === "true";
442
+ } catch (_e) {
443
+ return false;
444
+ }
445
+ }
446
+ function setDiscoveryHideAddedPreference(value) {
447
+ try {
448
+ localStorage.setItem(DISCOVERY_HIDE_ADDED_KEY, String(value));
449
+ } catch (_e) {
450
+ }
451
+ }
452
+ function formatElapsedShort(ms) {
453
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
454
+ if (totalSeconds < 60) {
455
+ return `${totalSeconds}s ago`;
456
+ }
457
+ const minutes = Math.floor(totalSeconds / 60);
458
+ if (minutes < 60) {
459
+ return `${minutes}m ago`;
460
+ }
461
+ const hours = Math.floor(minutes / 60);
462
+ if (hours < 24) {
463
+ return `${hours}h ago`;
464
+ }
465
+ const days = Math.floor(hours / 24);
466
+ return `${days}d ago`;
467
+ }
468
+ function updateLastScannedStatus() {
469
+ const lastScannedStatus = document.getElementById("lastScannedStatus");
470
+ if (!lastScannedStatus) {
471
+ return;
472
+ }
473
+ const cache = getDiscoveryCache(false);
474
+ if (!cache) {
475
+ lastScannedStatus.textContent = "Last scanned: never";
476
+ return;
477
+ }
478
+ const ageMs = Date.now() - cache.timestamp;
479
+ const timestampText = new Date(cache.timestamp).toLocaleString();
480
+ const stale = ageMs > DISCOVERY_CACHE_TTL_MS;
481
+ lastScannedStatus.textContent = stale ? `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText}, cache expired)` : `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText})`;
482
+ }
483
+ async function renderCachedDiscoveryResults() {
484
+ const cache = getDiscoveryCache(true);
485
+ const list = document.getElementById("discoveredList");
486
+ if (!cache || !list || !cache.devices.length) {
487
+ return;
488
+ }
489
+ list.style.display = "block";
490
+ if (!window._discoverySelectedIds) {
491
+ window._discoverySelectedIds = /* @__PURE__ */ new Set();
492
+ }
493
+ await updateDiscoveryView(
494
+ cache.devices,
495
+ getDiscoveryPreferences(),
496
+ getDiscoveryGroupByPreference(),
497
+ getDiscoveryHideAddedPreference(),
498
+ window._discoverySelectedIds
499
+ );
500
+ }
501
+ function getDiscoveryBleSettings() {
502
+ try {
503
+ const stored = localStorage.getItem(DISCOVERY_BLE_SETTINGS_KEY);
504
+ if (!stored) {
505
+ return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 };
506
+ }
507
+ const parsed = JSON.parse(stored);
508
+ return {
509
+ bleEnabled: parsed?.bleEnabled !== false,
510
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(parsed?.bleScanDurationSeconds || 5))),
511
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(parsed?.bleTimeoutSeconds || 8)))
512
+ };
513
+ } catch (_e) {
514
+ return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 };
515
+ }
516
+ }
517
+ function setDiscoveryBleSettings(settings) {
518
+ try {
519
+ localStorage.setItem(DISCOVERY_BLE_SETTINGS_KEY, JSON.stringify(settings));
520
+ } catch (_e) {
521
+ }
522
+ }
523
+ async function initializeDiscoverySettings() {
524
+ const scanSelect = document.getElementById("bleScanDurationSelect");
525
+ const timeoutInput = document.getElementById("bleTimeoutInput");
526
+ const disableBleCheckbox = document.getElementById("disableBleScanCheckbox");
527
+ const scanSetting = document.getElementById("bleScanSetting");
528
+ const timeoutSetting = document.getElementById("bleTimeoutSetting");
529
+ const bluetoothStatus = document.getElementById("bluetoothStatus");
530
+ const autoRefreshSelect = document.getElementById("autoRefreshIntervalSelect");
531
+ const refreshBtn = document.getElementById("refreshDiscoverBtn");
532
+ const current = getDiscoveryBleSettings();
533
+ if (scanSelect) {
534
+ scanSelect.value = String(current.bleScanDurationSeconds);
535
+ }
536
+ if (timeoutInput) {
537
+ timeoutInput.value = String(current.bleTimeoutSeconds);
538
+ }
539
+ if (disableBleCheckbox) {
540
+ disableBleCheckbox.checked = !current.bleEnabled;
541
+ }
542
+ if (autoRefreshSelect) {
543
+ autoRefreshSelect.value = String(getDiscoveryAutoRefreshSeconds());
544
+ }
545
+ const updateBleSettingVisibility = () => {
546
+ const disabled = !!disableBleCheckbox?.checked;
547
+ if (scanSetting) {
548
+ scanSetting.style.display = disabled ? "none" : "inline-flex";
549
+ }
550
+ if (timeoutSetting) {
551
+ timeoutSetting.style.display = disabled ? "none" : "inline-flex";
552
+ }
553
+ };
554
+ const persistFromControls = () => {
555
+ const next = {
556
+ bleEnabled: !(disableBleCheckbox?.checked ?? false),
557
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))),
558
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8)))
559
+ };
560
+ setDiscoveryBleSettings(next);
561
+ };
562
+ scanSelect?.addEventListener("change", persistFromControls);
563
+ timeoutInput?.addEventListener("change", persistFromControls);
564
+ disableBleCheckbox?.addEventListener("change", () => {
565
+ persistFromControls();
566
+ updateBleSettingVisibility();
567
+ });
568
+ updateBleSettingVisibility();
569
+ if (bluetoothStatus) {
570
+ const status = await fetchBluetoothStatus();
571
+ bluetoothStatus.textContent = status.available ? `Bluetooth: available (${status.message})` : `Bluetooth: unavailable (${status.message})`;
572
+ }
573
+ updateLastScannedStatus();
574
+ if (discoveryLastScannedTimer) {
575
+ clearInterval(discoveryLastScannedTimer);
576
+ }
577
+ discoveryLastScannedTimer = setInterval(updateLastScannedStatus, 15e3);
578
+ refreshBtn?.addEventListener("click", () => {
579
+ void discoverDevices2();
580
+ });
581
+ const applyAutoRefresh = () => {
582
+ const seconds = Math.max(0, Number(autoRefreshSelect?.value || 0));
583
+ setDiscoveryAutoRefreshSeconds(seconds);
584
+ if (discoveryAutoRefreshTimer) {
585
+ clearInterval(discoveryAutoRefreshTimer);
586
+ discoveryAutoRefreshTimer = null;
587
+ }
588
+ if (seconds > 0) {
589
+ discoveryAutoRefreshTimer = setInterval(() => {
590
+ const discoverBtn = document.getElementById("discoverBtn");
591
+ if (!discoverBtn || discoverBtn.disabled || document.hidden) {
592
+ return;
593
+ }
594
+ void discoverDevices2();
595
+ }, seconds * 1e3);
596
+ }
597
+ };
598
+ autoRefreshSelect?.addEventListener("change", applyAutoRefresh);
599
+ applyAutoRefresh();
600
+ await renderCachedDiscoveryResults();
601
+ }
602
+ function getDiscoveryGroupByPreference() {
603
+ try {
604
+ const stored = localStorage.getItem(DISCOVERY_GROUP_BY_KEY);
605
+ if (stored === "hub" || stored === "type") {
606
+ return stored;
607
+ }
608
+ return "connection";
609
+ } catch (_e) {
610
+ return "connection";
611
+ }
612
+ }
613
+ function setDiscoveryGroupByPreference(groupBy) {
614
+ try {
615
+ localStorage.setItem(DISCOVERY_GROUP_BY_KEY, groupBy);
616
+ } catch (_e) {
617
+ }
618
+ }
619
+ function getDiscoveryGroupExpandedState() {
620
+ try {
621
+ const stored = localStorage.getItem(DISCOVERY_GROUP_EXPANDED_KEY);
622
+ if (!stored) {
623
+ return {};
624
+ }
625
+ const parsed = JSON.parse(stored);
626
+ return typeof parsed === "object" && parsed ? parsed : {};
627
+ } catch (_e) {
628
+ return {};
629
+ }
630
+ }
631
+ function setDiscoveryGroupExpandedState(state) {
632
+ try {
633
+ localStorage.setItem(DISCOVERY_GROUP_EXPANDED_KEY, JSON.stringify(state));
634
+ } catch (_e) {
635
+ }
636
+ }
637
+ function isDiscoveryGroupExpanded(groupKey) {
638
+ const state = getDiscoveryGroupExpandedState();
639
+ return state[groupKey] !== false;
640
+ }
641
+ function setDiscoveryGroupExpanded(groupKey, expanded) {
642
+ const state = getDiscoveryGroupExpandedState();
643
+ state[groupKey] = expanded;
644
+ setDiscoveryGroupExpandedState(state);
645
+ }
300
646
  async function discoverDevices2() {
301
647
  const btn = document.getElementById("discoverBtn");
648
+ const cancelBtn = document.getElementById("cancelDiscoverBtn");
302
649
  const status = document.getElementById("discoverStatus");
650
+ const phaseProgress = document.getElementById("discoverPhaseProgress");
651
+ const phaseFill = document.getElementById("discoverPhaseFill");
652
+ const phaseLabel = document.getElementById("discoverPhaseLabel");
303
653
  const list = document.getElementById("discoveredList");
304
654
  const autoAddAll = document.getElementById("autoAddAllCheckbox")?.checked;
655
+ const scanSelect = document.getElementById("bleScanDurationSelect");
656
+ const timeoutInput = document.getElementById("bleTimeoutInput");
657
+ const disableBleCheckbox = document.getElementById("disableBleScanCheckbox");
305
658
  if (!btn || !status || !list) {
306
659
  return;
307
660
  }
308
- try {
309
- showBusyUi();
310
- btn.disabled = true;
311
- btn.textContent = "\u{1F50D} Discovering...";
312
- status.textContent = "Searching SwitchBot account...";
313
- status.classList.remove("error");
314
- const devicesRaw = await discoverDevices();
315
- uiLog.info("Discover response:", devicesRaw);
316
- const devices = devicesRaw.filter((d, index, arr) => !!d?.id && arr.findIndex((x) => x?.id === d.id) === index);
317
- if (!devices.length) {
318
- status.textContent = "No devices found in your SwitchBot account";
319
- toastInfo("No devices found in your SwitchBot account");
320
- list.style.display = "none";
321
- return;
661
+ const spinnerFrames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
662
+ let spinnerIndex = 0;
663
+ const startedAt = Date.now();
664
+ let phaseStartedAt = startedAt;
665
+ let phase = "Preparing discovery...";
666
+ let cancelled = false;
667
+ const bleScanDurationSeconds = scanSelect ? Number(scanSelect.value) : 5;
668
+ const pollIntervalMs = 1e3;
669
+ const scanEndTime = Date.now() + bleScanDurationSeconds * 1e3;
670
+ const setPhase = (nextPhase) => {
671
+ phase = nextPhase;
672
+ phaseStartedAt = Date.now();
673
+ };
674
+ const getPhasePercent = (phaseName) => {
675
+ if (phaseName.includes("Scanning BLE")) {
676
+ return 35;
322
677
  }
323
- if (autoAddAll) {
324
- status.textContent = `Auto-adding ${devices.length} device(s)...`;
678
+ if (phaseName.includes("Fetching OpenAPI")) {
679
+ return 75;
680
+ }
681
+ if (phaseName.includes("Complete")) {
682
+ return 100;
683
+ }
684
+ return 10;
685
+ };
686
+ const renderProgress = () => {
687
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1e3));
688
+ const phaseSeconds = Math.max(0, Math.floor((Date.now() - phaseStartedAt) / 1e3));
689
+ const frame = spinnerFrames[spinnerIndex % spinnerFrames.length];
690
+ spinnerIndex += 1;
691
+ status.textContent = `${frame} ${phase} (${phaseSeconds}s, ${totalSeconds}s total)`;
692
+ if (phaseProgress) {
693
+ phaseProgress.style.display = "block";
694
+ }
695
+ if (phaseFill) {
696
+ phaseFill.style.width = `${getPhasePercent(phase)}%`;
697
+ }
698
+ if (phaseLabel) {
699
+ phaseLabel.textContent = phase;
700
+ }
701
+ };
702
+ const progressTimer = setInterval(renderProgress, 250);
703
+ let discoveredDevices = [];
704
+ const preferences = getDiscoveryPreferences();
705
+ let groupBy = getDiscoveryGroupByPreference();
706
+ let hideAdded = getDiscoveryHideAddedPreference();
707
+ let controlsInitialized = false;
708
+ if (!window._discoverySelectedIds) {
709
+ window._discoverySelectedIds = /* @__PURE__ */ new Set();
710
+ }
711
+ const selectedIds = window._discoverySelectedIds;
712
+ setPhase("Scanning BLE (real-time RSSI)");
713
+ let pollTimer = null;
714
+ const pollDiscovery = async () => {
715
+ if (cancelled || Date.now() > scanEndTime) {
716
+ if (pollTimer) {
717
+ clearInterval(pollTimer);
718
+ }
719
+ setPhase("Scan Complete");
325
720
  try {
326
- const bulkResult = await addDevicesInBulk(
327
- devices.map((d) => ({
328
- deviceId: d.id,
329
- name: d.name,
330
- type: d.type,
331
- rssi: d.rssi,
332
- address: d.address,
333
- model: d.model
334
- }))
335
- );
336
- uiLog.info("Bulk add response:", bulkResult);
337
- if (!bulkResult || bulkResult.success === false) {
338
- throw new Error(bulkResult?.data?.message || "Bulk add failed");
339
- }
340
- const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0;
341
- const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0;
342
- status.textContent = `\u2713 Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`;
343
- if (addedCount > 0) {
344
- toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`);
345
- } else if (skippedCount > 0) {
346
- toastWarning(`No new devices were added (${skippedCount} skipped)`);
347
- }
348
- status.classList.remove("error");
349
- list.style.display = "none";
350
- if (devices.length > 0) {
351
- const synced = await syncParentPluginConfigFromDisk(true);
352
- status.textContent += synced ? " - Config saved automatically." : " - Warning: config may not persist until you close/reopen settings.";
353
- if (synced) {
354
- toastSuccess("Configuration synced and saved automatically");
355
- } else {
356
- toastWarning("Configuration sync failed; close and reopen settings before Save");
357
- }
358
- }
721
+ const devices = await discoverDevices("ble");
722
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, devices);
723
+ setDiscoveryCache(discoveredDevices);
724
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds);
359
725
  } catch (e) {
360
- uiLog.error("Bulk add error:", e);
361
- status.textContent = `\u2717 Error: ${e instanceof Error ? e.message : "Failed to add devices"}`;
362
- status.classList.add("error");
363
- toastError(e instanceof Error ? e.message : "Failed to add devices");
726
+ uiLog.error("Final BLE poll failed:", e);
364
727
  }
365
- await loadConfiguredDevices();
366
728
  return;
367
729
  }
368
- status.textContent = `Found ${devices.length} device(s)`;
730
+ try {
731
+ const devices = await discoverDevices("ble");
732
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, devices);
733
+ setDiscoveryCache(discoveredDevices);
734
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds);
735
+ } catch (e) {
736
+ uiLog.error("BLE poll failed:", e);
737
+ }
738
+ };
739
+ pollTimer = setInterval(pollDiscovery, pollIntervalMs);
740
+ pollDiscovery();
741
+ async function batchSetDeviceEnabled2(selectedIds2, enabled) {
742
+ const resp = await homebridge.request("/platform-config", {});
743
+ if (!resp || resp.success === false || !resp.data) {
744
+ throw new Error("Failed to load config");
745
+ }
746
+ const config = resp.data;
747
+ const configArr = Array.isArray(config) ? config : [config];
748
+ const platformIdx = configArr.findIndex((c) => (c.platform || c.name || "").toLowerCase().includes("switchbot"));
749
+ if (platformIdx === -1) {
750
+ throw new Error("SwitchBot platform config not found");
751
+ }
752
+ const platformConfig = configArr[platformIdx];
753
+ if (!Array.isArray(platformConfig.devices)) {
754
+ throw new TypeError("No devices array in config");
755
+ }
756
+ let changed = false;
757
+ for (const dev of platformConfig.devices) {
758
+ const id = String(dev.deviceId || dev.id || "").trim().toLowerCase();
759
+ if (selectedIds2.has(id)) {
760
+ if (dev.enabled !== enabled) {
761
+ dev.enabled = enabled;
762
+ changed = true;
763
+ }
764
+ }
765
+ }
766
+ if (changed) {
767
+ if (typeof homebridge.updatePluginConfig === "function") {
768
+ await homebridge.updatePluginConfig(configArr);
769
+ } else {
770
+ throw new TypeError("homebridge.updatePluginConfig is not available");
771
+ }
772
+ if (typeof homebridge.savePluginConfig === "function") {
773
+ await homebridge.savePluginConfig();
774
+ }
775
+ }
776
+ }
777
+ const ensureDiscoveryControls = async () => {
778
+ if (controlsInitialized) {
779
+ return;
780
+ }
781
+ if (!window._discoverySelectedIds) {
782
+ window._discoverySelectedIds = /* @__PURE__ */ new Set();
783
+ }
784
+ const selectedIds2 = window._discoverySelectedIds;
369
785
  const controlsDiv = document.createElement("div");
370
786
  controlsDiv.style.cssText = "margin-bottom: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;";
371
- const preferences = getDiscoveryPreferences();
372
787
  const filterLabel = document.createElement("label");
373
788
  filterLabel.style.fontSize = "12px";
374
789
  filterLabel.style.fontWeight = "500";
@@ -384,30 +799,29 @@ async function discoverDevices2() {
384
799
  { label: "IR", value: "ir" }
385
800
  ];
386
801
  for (const option of filterOptions) {
387
- const btn2 = document.createElement("button");
388
- btn2.textContent = option.label;
389
- btn2.style.padding = "4px 8px";
390
- btn2.style.fontSize = "11px";
391
- btn2.style.borderRadius = "3px";
392
- btn2.style.cursor = "pointer";
393
- btn2.style.border = preferences.connectionType === option.value ? "2px solid #007AFF" : "1px solid #ccc";
394
- btn2.style.backgroundColor = preferences.connectionType === option.value ? "#f0f7ff" : "#fff";
395
- btn2.style.color = preferences.connectionType === option.value ? "#1d4ed8" : "#374151";
396
- btn2.onclick = () => {
802
+ const filterBtn = document.createElement("button");
803
+ filterBtn.textContent = option.label;
804
+ filterBtn.style.padding = "4px 8px";
805
+ filterBtn.style.fontSize = "11px";
806
+ filterBtn.style.borderRadius = "3px";
807
+ filterBtn.style.cursor = "pointer";
808
+ filterBtn.style.border = preferences.connectionType === option.value ? "2px solid #007AFF" : "1px solid #ccc";
809
+ filterBtn.style.backgroundColor = preferences.connectionType === option.value ? "#f0f7ff" : "#fff";
810
+ filterBtn.style.color = preferences.connectionType === option.value ? "#1d4ed8" : "#374151";
811
+ filterBtn.onclick = () => {
397
812
  preferences.connectionType = option.value;
398
813
  setDiscoveryPreferences(preferences);
399
- updateDiscoveryView(devices, preferences);
400
- for (const b of Array.from(filterGroup.querySelectorAll("button"))) {
401
- ;
814
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
815
+ Array.prototype.forEach.call(filterGroup.querySelectorAll("button"), (b) => {
402
816
  b.style.border = "1px solid #ccc";
403
817
  b.style.backgroundColor = "#fff";
404
818
  b.style.color = "#374151";
405
- }
406
- btn2.style.border = "2px solid #007AFF";
407
- btn2.style.backgroundColor = "#f0f7ff";
408
- btn2.style.color = "#1d4ed8";
819
+ });
820
+ filterBtn.style.border = "2px solid #007AFF";
821
+ filterBtn.style.backgroundColor = "#f0f7ff";
822
+ filterBtn.style.color = "#1d4ed8";
409
823
  };
410
- filterGroup.appendChild(btn2);
824
+ filterGroup.appendChild(filterBtn);
411
825
  }
412
826
  const sortLabel = document.createElement("label");
413
827
  sortLabel.style.fontSize = "12px";
@@ -426,26 +840,73 @@ async function discoverDevices2() {
426
840
  { label: "Connection", value: "connection" }
427
841
  ];
428
842
  for (const opt of sortOptions) {
429
- const option = document.createElement("option");
430
- option.value = opt.value;
431
- option.textContent = opt.label;
432
- sortSelect.appendChild(option);
843
+ const sortOption = document.createElement("option");
844
+ sortOption.value = opt.value;
845
+ sortOption.textContent = opt.label;
846
+ sortSelect.appendChild(sortOption);
433
847
  }
434
848
  sortSelect.onchange = () => {
435
849
  preferences.sortBy = sortSelect.value;
436
850
  setDiscoveryPreferences(preferences);
437
- updateDiscoveryView(devices, preferences);
851
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
852
+ };
853
+ const groupLabel = document.createElement("label");
854
+ groupLabel.style.fontSize = "12px";
855
+ groupLabel.style.fontWeight = "500";
856
+ groupLabel.style.marginLeft = "8px";
857
+ groupLabel.textContent = "Group:";
858
+ const groupSelect = document.createElement("select");
859
+ groupSelect.style.fontSize = "11px";
860
+ groupSelect.style.padding = "4px 8px";
861
+ groupSelect.style.borderRadius = "3px";
862
+ groupSelect.value = groupBy;
863
+ const groupOptions = [
864
+ { label: "Connection", value: "connection" },
865
+ { label: "Hub", value: "hub" },
866
+ { label: "Device Type", value: "type" }
867
+ ];
868
+ for (const opt of groupOptions) {
869
+ const groupOption = document.createElement("option");
870
+ groupOption.value = opt.value;
871
+ groupOption.textContent = opt.label;
872
+ groupSelect.appendChild(groupOption);
873
+ }
874
+ groupSelect.onchange = () => {
875
+ groupBy = groupSelect.value;
876
+ setDiscoveryGroupByPreference(groupBy);
877
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
878
+ };
879
+ const hideAddedLabel = document.createElement("label");
880
+ hideAddedLabel.style.display = "inline-flex";
881
+ hideAddedLabel.style.alignItems = "center";
882
+ hideAddedLabel.style.gap = "4px";
883
+ hideAddedLabel.style.fontSize = "11px";
884
+ hideAddedLabel.style.marginLeft = "8px";
885
+ const hideAddedCheckbox = document.createElement("input");
886
+ hideAddedCheckbox.type = "checkbox";
887
+ hideAddedCheckbox.checked = hideAdded;
888
+ hideAddedCheckbox.style.margin = "0";
889
+ hideAddedCheckbox.style.width = "auto";
890
+ const hideAddedText = document.createElement("span");
891
+ hideAddedText.textContent = "Hide Added";
892
+ hideAddedLabel.appendChild(hideAddedCheckbox);
893
+ hideAddedLabel.appendChild(hideAddedText);
894
+ hideAddedCheckbox.onchange = () => {
895
+ hideAdded = hideAddedCheckbox.checked;
896
+ setDiscoveryHideAddedPreference(hideAdded);
897
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
438
898
  };
439
899
  const searchInput = document.createElement("input");
440
900
  searchInput.type = "text";
441
901
  searchInput.placeholder = "Search by name, ID, or type...";
442
- searchInput.style.fontSize = "11px";
443
- searchInput.style.padding = "4px 8px";
444
- searchInput.style.borderRadius = "3px";
902
+ searchInput.style.fontSize = "13px";
903
+ searchInput.style.padding = "8px 16px";
904
+ searchInput.style.borderRadius = "6px";
445
905
  searchInput.style.border = "1px solid #ccc";
446
- searchInput.style.flex = "1";
447
- searchInput.style.minWidth = "200px";
448
- searchInput.style.maxWidth = "300px";
906
+ searchInput.style.flex = "1 1 0%";
907
+ searchInput.style.minWidth = "120px";
908
+ searchInput.style.maxWidth = "100%";
909
+ searchInput.style.width = "100%";
449
910
  searchInput.value = preferences.searchQuery;
450
911
  let searchTimeout;
451
912
  searchInput.oninput = () => {
@@ -453,44 +914,644 @@ async function discoverDevices2() {
453
914
  searchTimeout = setTimeout(() => {
454
915
  preferences.searchQuery = searchInput.value;
455
916
  setDiscoveryPreferences(preferences);
456
- updateDiscoveryView(devices, preferences);
917
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
457
918
  }, 300);
458
919
  };
920
+ const actionBtnStyle = {
921
+ fontSize: "16px",
922
+ padding: "10px 0",
923
+ borderRadius: "10px",
924
+ margin: "0 12px 0 0",
925
+ width: "100%",
926
+ maxWidth: "220px",
927
+ fontWeight: "bold",
928
+ background: "#ef4444",
929
+ color: "#fff",
930
+ border: "none",
931
+ cursor: "pointer",
932
+ boxShadow: "0 2px 8px #0001",
933
+ transition: "background 0.2s",
934
+ outline: "none",
935
+ display: "block"
936
+ };
937
+ const addSelectedBtn = document.createElement("button");
938
+ addSelectedBtn.textContent = "Add Selected to Config";
939
+ Object.assign(addSelectedBtn.style, actionBtnStyle);
940
+ addSelectedBtn.disabled = true;
941
+ addSelectedBtn.onclick = async () => {
942
+ if (!selectedIds2.size) {
943
+ return;
944
+ }
945
+ addSelectedBtn.disabled = true;
946
+ addSelectedBtn.textContent = "Adding...";
947
+ try {
948
+ showBusyUi();
949
+ const selectedDevices = discoveredDevices.filter((d) => selectedIds2.has(normalizeId(d.id)));
950
+ const bulkResult = await addDevicesInBulk(selectedDevices.map((d) => ({
951
+ deviceId: d.id,
952
+ name: d.name,
953
+ type: d.type,
954
+ rssi: d.rssi,
955
+ address: d.address,
956
+ model: d.model
957
+ })));
958
+ uiLog.info("Batch add response:", bulkResult);
959
+ if (!bulkResult || bulkResult.success === false) {
960
+ throw new Error(bulkResult?.data?.message || "Batch add failed");
961
+ }
962
+ const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0;
963
+ const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0;
964
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`);
965
+ await loadConfiguredDevices();
966
+ selectedIds2.clear();
967
+ addSelectedBtn.disabled = true;
968
+ addSelectedBtn.textContent = "Add Selected";
969
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
970
+ } catch (e) {
971
+ uiLog.error("Batch add error:", e);
972
+ toastError(e instanceof Error ? e.message : "Failed to add devices");
973
+ addSelectedBtn.disabled = false;
974
+ addSelectedBtn.textContent = "Add Selected";
975
+ } finally {
976
+ hideBusyUi();
977
+ }
978
+ };
979
+ const enableSelectedBtn = document.createElement("button");
980
+ enableSelectedBtn.textContent = "Enable Selected";
981
+ Object.assign(enableSelectedBtn.style, actionBtnStyle);
982
+ enableSelectedBtn.disabled = true;
983
+ enableSelectedBtn.onclick = async () => {
984
+ if (!selectedIds2.size) {
985
+ return;
986
+ }
987
+ enableSelectedBtn.disabled = true;
988
+ enableSelectedBtn.textContent = "Enabling...";
989
+ try {
990
+ showBusyUi();
991
+ await batchSetDeviceEnabled2(selectedIds2, true);
992
+ toastSuccess("Selected devices enabled");
993
+ await loadConfiguredDevices();
994
+ enableSelectedBtn.disabled = true;
995
+ enableSelectedBtn.textContent = "Enable Selected";
996
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
997
+ } catch (e) {
998
+ uiLog.error("Batch enable error:", e);
999
+ toastError(e instanceof Error ? e.message : "Failed to enable devices");
1000
+ enableSelectedBtn.disabled = false;
1001
+ enableSelectedBtn.textContent = "Enable Selected";
1002
+ } finally {
1003
+ hideBusyUi();
1004
+ }
1005
+ };
1006
+ const disableSelectedBtn = document.createElement("button");
1007
+ disableSelectedBtn.textContent = "Disable Selected";
1008
+ Object.assign(disableSelectedBtn.style, actionBtnStyle);
1009
+ disableSelectedBtn.disabled = true;
1010
+ disableSelectedBtn.onclick = async () => {
1011
+ if (!selectedIds2.size) {
1012
+ return;
1013
+ }
1014
+ disableSelectedBtn.disabled = true;
1015
+ disableSelectedBtn.textContent = "Disabling...";
1016
+ try {
1017
+ showBusyUi();
1018
+ await batchSetDeviceEnabled2(selectedIds2, false);
1019
+ toastSuccess("Selected devices disabled");
1020
+ await loadConfiguredDevices();
1021
+ disableSelectedBtn.disabled = true;
1022
+ disableSelectedBtn.textContent = "Disable Selected";
1023
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds2);
1024
+ } catch (e) {
1025
+ uiLog.error("Batch disable error:", e);
1026
+ toastError(e instanceof Error ? e.message : "Failed to disable devices");
1027
+ disableSelectedBtn.disabled = false;
1028
+ disableSelectedBtn.textContent = "Disable Selected";
1029
+ } finally {
1030
+ hideBusyUi();
1031
+ }
1032
+ };
459
1033
  controlsDiv.appendChild(filterLabel);
460
1034
  controlsDiv.appendChild(filterGroup);
461
1035
  controlsDiv.appendChild(sortLabel);
462
1036
  controlsDiv.appendChild(sortSelect);
1037
+ controlsDiv.appendChild(groupLabel);
1038
+ controlsDiv.appendChild(groupSelect);
1039
+ controlsDiv.appendChild(hideAddedLabel);
463
1040
  controlsDiv.appendChild(searchInput);
1041
+ const topActionRow = document.createElement("div");
1042
+ topActionRow.style.display = "flex";
1043
+ topActionRow.style.gap = "20px";
1044
+ topActionRow.style.margin = "18px 0 10px 0";
1045
+ topActionRow.style.justifyContent = "flex-start";
1046
+ topActionRow.appendChild(addSelectedBtn);
1047
+ topActionRow.appendChild(enableSelectedBtn);
1048
+ topActionRow.appendChild(disableSelectedBtn);
1049
+ list.insertBefore(topActionRow, controlsDiv);
464
1050
  list.innerHTML = "";
465
1051
  list.appendChild(controlsDiv);
1052
+ let deviceListContainer = document.getElementById("discoveredDevices");
1053
+ if (!deviceListContainer) {
1054
+ deviceListContainer = document.createElement("ul");
1055
+ deviceListContainer.id = "discoveredDevices";
1056
+ deviceListContainer.style.maxHeight = "400px";
1057
+ deviceListContainer.style.overflowY = "auto";
1058
+ deviceListContainer.style.marginTop = "12px";
1059
+ deviceListContainer.style.padding = "0";
1060
+ deviceListContainer.style.listStyle = "none";
1061
+ list.appendChild(deviceListContainer);
1062
+ }
466
1063
  list.style.display = "block";
467
- await updateDiscoveryView(devices, preferences);
1064
+ controlsInitialized = true;
1065
+ const updateActionButtons = () => {
1066
+ const hasSelection = selectedIds2.size > 0;
1067
+ addSelectedBtn.disabled = !hasSelection;
1068
+ enableSelectedBtn.disabled = !hasSelection;
1069
+ disableSelectedBtn.disabled = !hasSelection;
1070
+ };
1071
+ setInterval(updateActionButtons, 300);
1072
+ };
1073
+ try {
1074
+ const bleSettings = {
1075
+ bleEnabled: !(disableBleCheckbox?.checked ?? false),
1076
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))),
1077
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8)))
1078
+ };
1079
+ setDiscoveryBleSettings(bleSettings);
1080
+ showBusyUi();
1081
+ btn.disabled = true;
1082
+ btn.textContent = "\u{1F50D} Discovering...";
1083
+ setPhase(bleSettings.bleEnabled ? "Scanning BLE..." : "Skipping BLE scan...");
1084
+ renderProgress();
1085
+ status.classList.remove("error");
1086
+ const devicesFoundDisplay = document.getElementById("discoverDevicesFound");
1087
+ if (devicesFoundDisplay) {
1088
+ devicesFoundDisplay.style.display = "none";
1089
+ devicesFoundDisplay.classList.remove("discovery-scanning-pulse");
1090
+ }
1091
+ if (cancelBtn) {
1092
+ cancelBtn.style.display = "inline-block";
1093
+ cancelBtn.disabled = false;
1094
+ cancelBtn.onclick = () => {
1095
+ cancelled = true;
1096
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1e3));
1097
+ status.textContent = `Discovery cancelled (${totalSeconds}s total)`;
1098
+ if (phaseProgress) {
1099
+ phaseProgress.style.display = "none";
1100
+ }
1101
+ if (devicesFoundDisplay) {
1102
+ devicesFoundDisplay.style.display = "none";
1103
+ devicesFoundDisplay.classList.remove("discovery-scanning-pulse");
1104
+ }
1105
+ cancelBtn.style.display = "none";
1106
+ btn.disabled = false;
1107
+ btn.textContent = "\u{1F50D} Discover Devices";
1108
+ hideBusyUi();
1109
+ };
1110
+ }
1111
+ if (bleSettings.bleEnabled) {
1112
+ const bleDevicesRaw = await discoverDevices("ble", bleSettings);
1113
+ if (cancelled) {
1114
+ return;
1115
+ }
1116
+ discoveredDevices = dedupeById(bleDevicesRaw);
1117
+ uiLog.info("BLE discover response:", bleDevicesRaw);
1118
+ if (devicesFoundDisplay && bleDevicesRaw.length > 0) {
1119
+ devicesFoundDisplay.style.display = "inline";
1120
+ devicesFoundDisplay.classList.add("discovery-scanning-pulse");
1121
+ devicesFoundDisplay.textContent = `\u{1F4CA} ${bleDevicesRaw.length} device(s) found (scanning...)`;
1122
+ }
1123
+ } else {
1124
+ discoveredDevices = [];
1125
+ uiLog.info("BLE discovery skipped by user setting");
1126
+ }
1127
+ if (!autoAddAll && discoveredDevices.length > 0) {
1128
+ await ensureDiscoveryControls();
1129
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds);
1130
+ status.textContent = `Showing ${discoveredDevices.length} device(s) from BLE, fetching OpenAPI...`;
1131
+ }
1132
+ setPhase("Fetching OpenAPI...");
1133
+ renderProgress();
1134
+ try {
1135
+ const openApiDevicesRaw = await discoverDevices("openapi");
1136
+ if (cancelled) {
1137
+ return;
1138
+ }
1139
+ uiLog.info("OpenAPI discover response:", openApiDevicesRaw);
1140
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, openApiDevicesRaw);
1141
+ if (devicesFoundDisplay && discoveredDevices.length > 0) {
1142
+ devicesFoundDisplay.textContent = `\u{1F4CA} ${discoveredDevices.length} device(s) found (complete)`;
1143
+ }
1144
+ } catch (openApiError) {
1145
+ uiLog.warn("OpenAPI phase failed during discovery:", openApiError);
1146
+ if (!discoveredDevices.length) {
1147
+ throw openApiError;
1148
+ }
1149
+ if (devicesFoundDisplay) {
1150
+ devicesFoundDisplay.classList.remove("discovery-scanning-pulse");
1151
+ }
1152
+ }
1153
+ setPhase("Complete");
1154
+ renderProgress();
1155
+ uiLog.info("Final merged discover response:", discoveredDevices);
1156
+ if (!discoveredDevices.length) {
1157
+ status.textContent = "No devices found in your SwitchBot account";
1158
+ toastInfo("No devices found in your SwitchBot account");
1159
+ list.style.display = "none";
1160
+ if (devicesFoundDisplay) {
1161
+ devicesFoundDisplay.style.display = "none";
1162
+ devicesFoundDisplay.classList.remove("discovery-scanning-pulse");
1163
+ }
1164
+ clearDiscoveryCache();
1165
+ updateLastScannedStatus();
1166
+ return;
1167
+ }
1168
+ if (autoAddAll) {
1169
+ status.textContent = `Auto-adding ${discoveredDevices.length} device(s)...`;
1170
+ try {
1171
+ const bulkResult = await addDevicesInBulk(
1172
+ discoveredDevices.map((d) => ({
1173
+ deviceId: d.id,
1174
+ name: d.name,
1175
+ type: d.type,
1176
+ rssi: d.rssi,
1177
+ address: d.address,
1178
+ model: d.model
1179
+ }))
1180
+ );
1181
+ uiLog.info("Bulk add response:", bulkResult);
1182
+ if (!bulkResult || bulkResult.success === false) {
1183
+ throw new Error(bulkResult?.data?.message || "Bulk add failed");
1184
+ }
1185
+ const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0;
1186
+ const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0;
1187
+ status.textContent = `\u2713 Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`;
1188
+ if (addedCount > 0) {
1189
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`);
1190
+ } else if (skippedCount > 0) {
1191
+ toastWarning(`No new devices were added (${skippedCount} skipped)`);
1192
+ }
1193
+ status.classList.remove("error");
1194
+ list.style.display = "none";
1195
+ if (discoveredDevices.length > 0) {
1196
+ const synced = await syncParentPluginConfigFromDisk(true);
1197
+ status.textContent += synced ? " - Config saved automatically." : " - Warning: config may not persist until you close/reopen settings.";
1198
+ if (synced) {
1199
+ toastSuccess("Configuration synced and saved automatically");
1200
+ } else {
1201
+ toastWarning("Configuration sync failed; close and reopen settings before Save");
1202
+ }
1203
+ }
1204
+ } catch (e) {
1205
+ uiLog.error("Bulk add error:", e);
1206
+ status.textContent = `\u2717 Error: ${e instanceof Error ? e.message : "Failed to add devices"}`;
1207
+ status.classList.add("error");
1208
+ toastError(e instanceof Error ? e.message : "Failed to add devices");
1209
+ }
1210
+ await loadConfiguredDevices();
1211
+ return;
1212
+ }
1213
+ await ensureDiscoveryControls();
1214
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds);
1215
+ setDiscoveryCache(discoveredDevices);
1216
+ updateLastScannedStatus();
468
1217
  } catch (e) {
1218
+ if (cancelled) {
1219
+ return;
1220
+ }
469
1221
  uiLog.error("Discovery error:", e);
470
1222
  status.textContent = `Error: ${e instanceof Error ? e.message : "Discovery failed"}`;
471
1223
  status.classList.add("error");
472
1224
  toastError(e instanceof Error ? e.message : "Discovery failed");
473
1225
  list.style.display = "none";
474
1226
  } finally {
1227
+ clearInterval(progressTimer);
475
1228
  hideBusyUi();
1229
+ if (phaseProgress) {
1230
+ phaseProgress.style.display = "none";
1231
+ }
1232
+ if (phaseFill) {
1233
+ phaseFill.style.width = "0%";
1234
+ }
1235
+ if (phaseLabel) {
1236
+ phaseLabel.textContent = "";
1237
+ }
1238
+ const devicesFoundDisplay = document.getElementById("discoverDevicesFound");
1239
+ if (devicesFoundDisplay) {
1240
+ devicesFoundDisplay.style.display = "none";
1241
+ devicesFoundDisplay.classList.remove("discovery-scanning-pulse");
1242
+ }
476
1243
  btn.disabled = false;
477
1244
  btn.textContent = "\u{1F50D} Discover Devices";
1245
+ if (cancelBtn) {
1246
+ cancelBtn.disabled = false;
1247
+ cancelBtn.style.display = "none";
1248
+ cancelBtn.onclick = null;
1249
+ }
478
1250
  }
479
1251
  }
480
- async function updateDiscoveryView(allDevices, preferences) {
481
- const filtered = filterDevices(allDevices, preferences.connectionType, preferences.searchQuery);
482
- const sorted = sortDevices(filtered, preferences.sortBy);
483
- const configured = await fetchDevices();
484
- const configuredIds = new Set((configured || []).map((d) => normalizeId(d?.id || d?.deviceId)));
485
- const ul = await renderDiscoveredDevices(sorted, {
486
- configuredIds
1252
+ async function updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds) {
1253
+ const visibleDevices = allDevices.filter((d) => {
1254
+ if (hideAdded && d.added) {
1255
+ return false;
1256
+ }
1257
+ return true;
487
1258
  });
488
- document.getElementById("discoveredDevices")?.replaceWith(ul);
489
- ul.id = "discoveredDevices";
1259
+ const configuredIds = new Set(
1260
+ allDevices.filter((d) => d.added).map((d) => normalizeId(d.id))
1261
+ );
1262
+ const getConnectionGroup = (device) => {
1263
+ if (device?.isIR) {
1264
+ return "IR";
1265
+ }
1266
+ const connectionType = String(device?.connectionType || "").toLowerCase();
1267
+ if (connectionType.includes("both")) {
1268
+ return "Both";
1269
+ }
1270
+ if (connectionType.includes("ble")) {
1271
+ return "BLE";
1272
+ }
1273
+ if (connectionType.includes("api")) {
1274
+ return "OpenAPI";
1275
+ }
1276
+ return "Unknown";
1277
+ };
1278
+ const getHubGroup = (device) => {
1279
+ const hub = String(device?.hubDeviceId || "").trim();
1280
+ return hub ? `Hub ${hub}` : "No Hub";
1281
+ };
1282
+ const getTypeGroup = (device) => {
1283
+ const type = String(device?.type || "").trim();
1284
+ return type || "Unknown Type";
1285
+ };
1286
+ const groupedDevices = /* @__PURE__ */ new Map();
1287
+ for (const d of visibleDevices) {
1288
+ let group = getConnectionGroup(d);
1289
+ if (groupBy === "hub") {
1290
+ group = getHubGroup(d);
1291
+ } else if (groupBy === "type") {
1292
+ group = getTypeGroup(d);
1293
+ }
1294
+ const groupDevices = groupedDevices.get(group) || [];
1295
+ groupDevices.push(d);
1296
+ groupedDevices.set(group, groupDevices);
1297
+ }
1298
+ let orderedGroups = [];
1299
+ if (groupBy === "hub") {
1300
+ const hubGroups = [...groupedDevices.keys()].filter((group) => group !== "No Hub").sort((a, b) => a.localeCompare(b));
1301
+ orderedGroups = groupedDevices.has("No Hub") ? [...hubGroups, "No Hub"] : hubGroups;
1302
+ } else if (groupBy === "type") {
1303
+ const typeGroups = [...groupedDevices.keys()].filter((group) => group !== "Unknown Type").sort((a, b) => a.localeCompare(b));
1304
+ orderedGroups = groupedDevices.has("Unknown Type") ? [...typeGroups, "Unknown Type"] : typeGroups;
1305
+ } else {
1306
+ const groupOrder = ["Both", "BLE", "OpenAPI", "IR", "Unknown"];
1307
+ orderedGroups = groupOrder.filter((group) => groupedDevices.has(group));
1308
+ }
1309
+ const container = document.createElement("div");
1310
+ container.id = "discoveredDevices";
1311
+ container.className = "discovery-groups";
1312
+ if (!visibleDevices.length) {
1313
+ const empty = document.createElement("div");
1314
+ empty.className = "discovery-group-empty";
1315
+ empty.textContent = hideAdded ? "No devices match current filters (or all are already added)." : "No devices match current filters.";
1316
+ container.appendChild(empty);
1317
+ } else {
1318
+ for (const groupName of orderedGroups) {
1319
+ const groupItems = groupedDevices.get(groupName);
1320
+ if (!groupItems?.length) {
1321
+ continue;
1322
+ }
1323
+ const groupSection = document.createElement("section");
1324
+ groupSection.className = "discovery-group";
1325
+ const groupStorageKey = `${groupBy}:${groupName}`;
1326
+ let expanded = isDiscoveryGroupExpanded(groupStorageKey);
1327
+ const groupHeader = document.createElement("button");
1328
+ groupHeader.className = "discovery-group-header-btn";
1329
+ groupHeader.type = "button";
1330
+ const setGroupHeaderText = () => {
1331
+ const marker = expanded ? "\u25BE" : "\u25B8";
1332
+ groupHeader.textContent = `${marker} ${groupName} (${groupItems.length})`;
1333
+ };
1334
+ setGroupHeaderText();
1335
+ groupSection.appendChild(groupHeader);
1336
+ const groupList = await renderDiscoveredDevices(groupItems, {
1337
+ configuredIds,
1338
+ selectedIds,
1339
+ onToggleSelect: (device, selected) => {
1340
+ const id = normalizeId(device.id);
1341
+ if (selected) {
1342
+ selectedIds.add(id);
1343
+ } else {
1344
+ selectedIds.delete(id);
1345
+ }
1346
+ const btn = document.querySelector("button")?.parentElement?.querySelector("button");
1347
+ if (btn && btn.textContent?.includes("Add Selected")) {
1348
+ btn.disabled = selectedIds.size === 0;
1349
+ }
1350
+ }
1351
+ });
1352
+ if (!expanded) {
1353
+ groupList.style.display = "none";
1354
+ }
1355
+ groupHeader.onclick = () => {
1356
+ expanded = !expanded;
1357
+ setDiscoveryGroupExpanded(groupStorageKey, expanded);
1358
+ setGroupHeaderText();
1359
+ groupList.style.display = expanded ? "grid" : "none";
1360
+ };
1361
+ groupSection.appendChild(groupList);
1362
+ container.appendChild(groupSection);
1363
+ }
1364
+ }
1365
+ const existingList = document.getElementById("discoveredDevices");
1366
+ if (existingList && existingList.parentNode) {
1367
+ existingList.replaceWith(container);
1368
+ } else {
1369
+ const listContainer = document.getElementById("discoveredList");
1370
+ if (listContainer) {
1371
+ listContainer.appendChild(container);
1372
+ }
1373
+ }
1374
+ const legacyAddSelectedBtns = [];
1375
+ const allBtns = document.querySelectorAll("button");
1376
+ for (let i = 0; i < allBtns.length; i++) {
1377
+ const btn = allBtns[i];
1378
+ if (btn.textContent && btn.textContent.trim() === "Add Selected") {
1379
+ legacyAddSelectedBtns.push(btn);
1380
+ }
1381
+ }
1382
+ for (let i = 0; i < legacyAddSelectedBtns.length; i++) {
1383
+ legacyAddSelectedBtns[i].remove();
1384
+ }
1385
+ function updateBatchButtonStates() {
1386
+ const addSelectedBtn = document.getElementById("addSelectedBtn");
1387
+ const enableSelectedBtn = document.querySelector("button")?.parentElement?.querySelector('button[aria-label="Enable Selected"]');
1388
+ const disableSelectedBtn = document.querySelector("button")?.parentElement?.querySelector('button[aria-label="Disable Selected"]');
1389
+ const hasSelection = selectedIds.size > 0;
1390
+ if (addSelectedBtn) addSelectedBtn.disabled = !hasSelection;
1391
+ if (enableSelectedBtn) enableSelectedBtn.disabled = !hasSelection;
1392
+ if (disableSelectedBtn) disableSelectedBtn.disabled = !hasSelection;
1393
+ }
1394
+ window.removeEventListener("discovery-selection-changed", updateBatchButtonStates);
1395
+ window.addEventListener("discovery-selection-changed", updateBatchButtonStates);
1396
+ let batchControls = document.getElementById("batchImportControls");
1397
+ if (!batchControls) {
1398
+ batchControls = document.createElement("div");
1399
+ batchControls.id = "batchImportControls";
1400
+ batchControls.style.display = "flex";
1401
+ batchControls.style.flexWrap = "wrap";
1402
+ batchControls.style.alignItems = "center";
1403
+ batchControls.style.gap = "18px";
1404
+ batchControls.style.margin = "8px 0 18px 0";
1405
+ batchControls.style.width = "100%";
1406
+ const buttonRow = document.createElement("div");
1407
+ buttonRow.style.display = "flex";
1408
+ buttonRow.style.flexWrap = "nowrap";
1409
+ buttonRow.style.alignItems = "center";
1410
+ buttonRow.style.justifyContent = "center";
1411
+ buttonRow.style.gap = "16px";
1412
+ buttonRow.style.width = "100%";
1413
+ const addSelectedBtn = document.createElement("button");
1414
+ addSelectedBtn.id = "addSelectedBtn";
1415
+ addSelectedBtn.textContent = "Add Selected to Config";
1416
+ addSelectedBtn.disabled = selectedIds.size === 0;
1417
+ addSelectedBtn.style.fontWeight = "600";
1418
+ const redButtonStyle = {
1419
+ width: "220px",
1420
+ padding: "12px 0",
1421
+ fontSize: "17px",
1422
+ marginBottom: "0",
1423
+ boxShadow: "0 2px 8px 0 rgba(220,38,38,0.10)",
1424
+ borderRadius: "8px"
1425
+ };
1426
+ Object.assign(addSelectedBtn.style, redButtonStyle);
1427
+ addSelectedBtn.style.background = "#ef4444";
1428
+ addSelectedBtn.style.color = "white";
1429
+ addSelectedBtn.style.transition = "background 0.2s, box-shadow 0.2s";
1430
+ addSelectedBtn.onmouseenter = function() {
1431
+ addSelectedBtn.style.background = "#dc2626";
1432
+ };
1433
+ addSelectedBtn.onmouseleave = function() {
1434
+ addSelectedBtn.style.background = "#ef4444";
1435
+ };
1436
+ addSelectedBtn.onclick = async () => {
1437
+ if (!selectedIds.size) {
1438
+ return;
1439
+ }
1440
+ addSelectedBtn.disabled = true;
1441
+ addSelectedBtn.textContent = "Adding...";
1442
+ try {
1443
+ const selectedDevices = visibleDevices.filter((d) => selectedIds.has(normalizeId(d.id)));
1444
+ const bulkResult = await addDevicesInBulk(selectedDevices.map((d) => ({
1445
+ deviceId: d.id,
1446
+ name: d.name,
1447
+ type: d.type,
1448
+ rssi: d.rssi,
1449
+ address: d.address,
1450
+ model: d.model
1451
+ })));
1452
+ uiLog.info("Batch add response:", bulkResult);
1453
+ if (!bulkResult || bulkResult.success === false) {
1454
+ throw new Error(bulkResult?.data?.message || "Batch add failed");
1455
+ }
1456
+ const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0;
1457
+ const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0;
1458
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ""}`);
1459
+ await loadConfiguredDevices();
1460
+ selectedIds.clear();
1461
+ addSelectedBtn.disabled = true;
1462
+ addSelectedBtn.textContent = "Add Selected to Config";
1463
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds);
1464
+ } catch (e) {
1465
+ uiLog.error("Batch add error:", e);
1466
+ toastError(e instanceof Error ? e.message : "Failed to add devices");
1467
+ addSelectedBtn.disabled = false;
1468
+ addSelectedBtn.textContent = "Add Selected to Config";
1469
+ }
1470
+ };
1471
+ const enableSelectedBtn = document.createElement("button");
1472
+ enableSelectedBtn.textContent = "Enable Selected";
1473
+ enableSelectedBtn.style.fontWeight = "600";
1474
+ Object.assign(enableSelectedBtn.style, redButtonStyle);
1475
+ enableSelectedBtn.style.background = "#ef4444";
1476
+ enableSelectedBtn.style.color = "white";
1477
+ enableSelectedBtn.style.transition = "background 0.2s, box-shadow 0.2s";
1478
+ enableSelectedBtn.onmouseenter = function() {
1479
+ enableSelectedBtn.style.background = "#dc2626";
1480
+ };
1481
+ enableSelectedBtn.onmouseleave = function() {
1482
+ enableSelectedBtn.style.background = "#ef4444";
1483
+ };
1484
+ enableSelectedBtn.disabled = selectedIds.size === 0;
1485
+ enableSelectedBtn.onclick = async () => {
1486
+ if (!selectedIds.size) {
1487
+ return;
1488
+ }
1489
+ enableSelectedBtn.disabled = true;
1490
+ try {
1491
+ showBusyUi();
1492
+ await batchSetDeviceEnabled(selectedIds, true);
1493
+ toastSuccess("Selected devices enabled");
1494
+ await loadConfiguredDevices();
1495
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds);
1496
+ } catch (e) {
1497
+ uiLog.error("Batch enable error:", e);
1498
+ toastError(e instanceof Error ? e.message : "Failed to enable devices");
1499
+ } finally {
1500
+ hideBusyUi();
1501
+ enableSelectedBtn.disabled = false;
1502
+ }
1503
+ };
1504
+ const disableSelectedBtn = document.createElement("button");
1505
+ disableSelectedBtn.textContent = "Disable Selected";
1506
+ disableSelectedBtn.style.fontWeight = "600";
1507
+ Object.assign(disableSelectedBtn.style, redButtonStyle);
1508
+ disableSelectedBtn.style.background = "#ef4444";
1509
+ disableSelectedBtn.style.color = "white";
1510
+ disableSelectedBtn.style.transition = "background 0.2s, box-shadow 0.2s";
1511
+ disableSelectedBtn.onmouseenter = function() {
1512
+ disableSelectedBtn.style.background = "#dc2626";
1513
+ };
1514
+ disableSelectedBtn.onmouseleave = function() {
1515
+ disableSelectedBtn.style.background = "#ef4444";
1516
+ };
1517
+ disableSelectedBtn.disabled = selectedIds.size === 0;
1518
+ disableSelectedBtn.onclick = async () => {
1519
+ if (!selectedIds.size) {
1520
+ return;
1521
+ }
1522
+ disableSelectedBtn.disabled = true;
1523
+ try {
1524
+ showBusyUi();
1525
+ await batchSetDeviceEnabled(selectedIds, false);
1526
+ toastSuccess("Selected devices disabled");
1527
+ await loadConfiguredDevices();
1528
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds);
1529
+ } catch (e) {
1530
+ uiLog.error("Batch disable error:", e);
1531
+ toastError(e instanceof Error ? e.message : "Failed to disable devices");
1532
+ } finally {
1533
+ hideBusyUi();
1534
+ disableSelectedBtn.disabled = false;
1535
+ }
1536
+ };
1537
+ buttonRow.appendChild(addSelectedBtn);
1538
+ buttonRow.appendChild(enableSelectedBtn);
1539
+ buttonRow.appendChild(disableSelectedBtn);
1540
+ batchControls.appendChild(buttonRow);
1541
+ const listContainer = document.getElementById("discoveredList");
1542
+ if (listContainer) {
1543
+ listContainer.insertBefore(batchControls, listContainer.firstChild);
1544
+ }
1545
+ } else {
1546
+ const addSelectedBtn = document.getElementById("addSelectedBtn");
1547
+ if (addSelectedBtn) {
1548
+ addSelectedBtn.disabled = selectedIds.size === 0;
1549
+ }
1550
+ }
490
1551
  const status = document.getElementById("discoverStatus");
491
1552
  if (status) {
492
1553
  const totalCount = allDevices.length;
493
- const filteredCount = sorted.length;
1554
+ const filteredCount = visibleDevices.length;
494
1555
  status.textContent = filteredCount === totalCount ? `Found ${totalCount} device(s)` : `Showing ${filteredCount} of ${totalCount} device(s)`;
495
1556
  }
496
1557
  }
@@ -498,6 +1559,7 @@ async function addDeviceToConfig(device) {
498
1559
  const { addDeviceToConfig: addDevice2 } = await Promise.resolve().then(() => (init_devices(), devices_exports));
499
1560
  await addDevice2(device);
500
1561
  }
1562
+ var DISCOVERY_GROUP_BY_KEY, DISCOVERY_GROUP_EXPANDED_KEY, DISCOVERY_BLE_SETTINGS_KEY, DISCOVERY_HIDE_ADDED_KEY, DISCOVERY_CACHE_KEY, DISCOVERY_AUTO_REFRESH_KEY, DISCOVERY_CACHE_TTL_MS, discoveryAutoRefreshTimer, discoveryLastScannedTimer;
501
1563
  var init_discovery = __esm({
502
1564
  "src/homebridge-ui/public/js/discovery.ts"() {
503
1565
  "use strict";
@@ -507,6 +1569,15 @@ var init_discovery = __esm({
507
1569
  init_modal();
508
1570
  init_render();
509
1571
  init_toast();
1572
+ DISCOVERY_GROUP_BY_KEY = "discoveryGroupBy";
1573
+ DISCOVERY_GROUP_EXPANDED_KEY = "discoveryGroupExpanded";
1574
+ DISCOVERY_BLE_SETTINGS_KEY = "discoveryBleSettings";
1575
+ DISCOVERY_HIDE_ADDED_KEY = "discoveryHideAdded";
1576
+ DISCOVERY_CACHE_KEY = "discoveryCache";
1577
+ DISCOVERY_AUTO_REFRESH_KEY = "discoveryAutoRefreshSeconds";
1578
+ DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
1579
+ discoveryAutoRefreshTimer = null;
1580
+ discoveryLastScannedTimer = null;
510
1581
  }
511
1582
  });
512
1583
 
@@ -724,6 +1795,40 @@ async function importDiscoveredDevice(device) {
724
1795
  macInput.style.borderRadius = "6px";
725
1796
  macInput.style.fontSize = "14px";
726
1797
  macInput.style.boxSizing = "border-box";
1798
+ const encryptionKeyLabel = document.createElement("label");
1799
+ encryptionKeyLabel.textContent = "BLE Encryption Key (optional)";
1800
+ encryptionKeyLabel.style.display = "block";
1801
+ encryptionKeyLabel.style.marginBottom = "6px";
1802
+ encryptionKeyLabel.style.fontWeight = "500";
1803
+ encryptionKeyLabel.style.fontSize = "12px";
1804
+ encryptionKeyLabel.style.color = "#6b7280";
1805
+ const encryptionKeyInput = document.createElement("input");
1806
+ encryptionKeyInput.type = "password";
1807
+ encryptionKeyInput.value = device.encryptionKey || "";
1808
+ encryptionKeyInput.placeholder = "Paste device BLE encryption key";
1809
+ encryptionKeyInput.style.width = "100%";
1810
+ encryptionKeyInput.style.marginBottom = "12px";
1811
+ encryptionKeyInput.style.padding = "8px 10px";
1812
+ encryptionKeyInput.style.borderRadius = "6px";
1813
+ encryptionKeyInput.style.fontSize = "14px";
1814
+ encryptionKeyInput.style.boxSizing = "border-box";
1815
+ const keyIdLabel = document.createElement("label");
1816
+ keyIdLabel.textContent = "BLE Key ID (optional)";
1817
+ keyIdLabel.style.display = "block";
1818
+ keyIdLabel.style.marginBottom = "6px";
1819
+ keyIdLabel.style.fontWeight = "500";
1820
+ keyIdLabel.style.fontSize = "12px";
1821
+ keyIdLabel.style.color = "#6b7280";
1822
+ const keyIdInput = document.createElement("input");
1823
+ keyIdInput.type = "text";
1824
+ keyIdInput.value = device.keyId || "";
1825
+ keyIdInput.placeholder = "e.g. ff";
1826
+ keyIdInput.style.width = "100%";
1827
+ keyIdInput.style.marginBottom = "12px";
1828
+ keyIdInput.style.padding = "8px 10px";
1829
+ keyIdInput.style.borderRadius = "6px";
1830
+ keyIdInput.style.fontSize = "14px";
1831
+ keyIdInput.style.boxSizing = "border-box";
727
1832
  const buttons = document.createElement("div");
728
1833
  buttons.style.display = "flex";
729
1834
  buttons.style.gap = "10px";
@@ -750,7 +1855,9 @@ async function importDiscoveredDevice(device) {
750
1855
  importBtn.onclick = () => cleanup({
751
1856
  configDeviceName: nameInput.value || device.name || device.id,
752
1857
  configDeviceType: typeSelect.value || device.type,
753
- address: macInput.value || void 0
1858
+ address: macInput.value || void 0,
1859
+ encryptionKey: encryptionKeyInput.value || void 0,
1860
+ keyId: keyIdInput.value || void 0
754
1861
  });
755
1862
  div.addEventListener("click", (event) => {
756
1863
  if (event.target === div) {
@@ -765,6 +1872,10 @@ async function importDiscoveredDevice(device) {
765
1872
  contentDiv.appendChild(typeSelect);
766
1873
  contentDiv.appendChild(macLabel);
767
1874
  contentDiv.appendChild(macInput);
1875
+ contentDiv.appendChild(encryptionKeyLabel);
1876
+ contentDiv.appendChild(encryptionKeyInput);
1877
+ contentDiv.appendChild(keyIdLabel);
1878
+ contentDiv.appendChild(keyIdInput);
768
1879
  contentDiv.appendChild(buttons);
769
1880
  modal.appendChild(title);
770
1881
  modal.appendChild(contentDiv);
@@ -1167,6 +2278,7 @@ __export(render_exports, {
1167
2278
  getRssiSignalQuality: () => getRssiSignalQuality,
1168
2279
  renderBadge: () => renderBadge,
1169
2280
  renderConnectionBadge: () => renderConnectionBadge,
2281
+ renderDeviceDetailsPanel: () => renderDeviceDetailsPanel,
1170
2282
  renderDeviceList: () => renderDeviceList,
1171
2283
  renderDiscoveredDevices: () => renderDiscoveredDevices,
1172
2284
  renderIRBadge: () => renderIRBadge,
@@ -1305,8 +2417,216 @@ function scrollToConfiguredDevice(deviceId) {
1305
2417
  target.style.background = originalBackground;
1306
2418
  }, 1800);
1307
2419
  }
2420
+ function createConnectionTestControls(device) {
2421
+ const controls = document.createElement("div");
2422
+ controls.style.display = "inline-flex";
2423
+ controls.style.alignItems = "center";
2424
+ controls.style.gap = "6px";
2425
+ const button = document.createElement("button");
2426
+ button.textContent = "Test Connection";
2427
+ button.className = "secondary";
2428
+ button.style.padding = "4px 9px";
2429
+ button.style.fontSize = "11px";
2430
+ const status = document.createElement("span");
2431
+ status.style.fontSize = "10px";
2432
+ status.style.opacity = "0.85";
2433
+ status.style.whiteSpace = "normal";
2434
+ status.style.overflowWrap = "anywhere";
2435
+ button.onclick = async () => {
2436
+ const startedAt = Date.now();
2437
+ button.disabled = true;
2438
+ button.textContent = "Testing...";
2439
+ status.textContent = "Checking...";
2440
+ status.style.color = "#6b7280";
2441
+ try {
2442
+ const { testDeviceConnection: testDeviceConnection2 } = await Promise.resolve().then(() => (init_api(), api_exports));
2443
+ const result = await testDeviceConnection2({
2444
+ deviceId: String(device?.id || device?.deviceId || ""),
2445
+ connectionType: device?.connectionType,
2446
+ address: device?.address
2447
+ });
2448
+ const measuredLatency = Number(result?.latencyMs) > 0 ? Number(result.latencyMs) : Date.now() - startedAt;
2449
+ if (result?.success) {
2450
+ const method = result?.method || "Auto";
2451
+ status.textContent = `\u2713 ${method} \xB7 ${measuredLatency}ms`;
2452
+ status.style.color = "#16a34a";
2453
+ } else {
2454
+ const detail = result?.message ? ` \xB7 ${result.message}` : "";
2455
+ status.textContent = `\u2717 Failed \xB7 ${measuredLatency}ms${detail}`;
2456
+ status.style.color = "#dc2626";
2457
+ }
2458
+ } catch (e) {
2459
+ status.textContent = `\u2717 Failed \xB7 ${Date.now() - startedAt}ms`;
2460
+ status.style.color = "#dc2626";
2461
+ } finally {
2462
+ button.disabled = false;
2463
+ button.textContent = "Test Connection";
2464
+ }
2465
+ };
2466
+ controls.appendChild(button);
2467
+ controls.appendChild(status);
2468
+ return controls;
2469
+ }
2470
+ function formatLastSeen(value) {
2471
+ if (!value) {
2472
+ return "N/A";
2473
+ }
2474
+ try {
2475
+ const date = new Date(value);
2476
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
2477
+ } catch (_e) {
2478
+ return String(value);
2479
+ }
2480
+ }
2481
+ function renderDeviceDetailsPanel(device) {
2482
+ const details = document.createElement("div");
2483
+ details.style.cssText = `
2484
+ border-top: 1px solid #ddd;
2485
+ padding: 8px;
2486
+ background: #f9fafb;
2487
+ border-radius: 4px;
2488
+ font-size: 12px;
2489
+ margin-top: 4px;
2490
+ `;
2491
+ const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`;
2492
+ let batteryHistory = [];
2493
+ try {
2494
+ const raw = localStorage.getItem(batteryHistoryKey);
2495
+ if (raw) batteryHistory = JSON.parse(raw);
2496
+ } catch {
2497
+ }
2498
+ const now = Date.now();
2499
+ if (typeof device?.battery === "number") {
2500
+ const last = batteryHistory[batteryHistory.length - 1];
2501
+ if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1e3) {
2502
+ batteryHistory.push({ value: device.battery, ts: now });
2503
+ if (batteryHistory.length > 30) batteryHistory = batteryHistory.slice(-30);
2504
+ try {
2505
+ localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory));
2506
+ } catch {
2507
+ }
2508
+ }
2509
+ }
2510
+ const rows = [
2511
+ { label: "Device ID", value: String(device?.id || device?.deviceId || "N/A"), copyable: !!(device?.id || device?.deviceId) },
2512
+ { label: "MAC Address", value: String(device?.address || "N/A"), copyable: !!device?.address },
2513
+ { label: "Device Type", value: String(device?.type || device?.configDeviceType || "N/A") },
2514
+ { label: "Model", value: String(device?.model || "N/A") },
2515
+ { label: "Hub ID", value: String(device?.hubDeviceId || "N/A") },
2516
+ { label: "Battery", value: device?.battery !== void 0 && device?.battery !== null ? `${device.battery}%` : "N/A" },
2517
+ { label: "Firmware", value: String(device?.version || device?.firmware || "N/A") },
2518
+ { label: "Cloud Service", value: device?.enabled === false ? "Disabled" : "Enabled" },
2519
+ { label: "Last Seen", value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) }
2520
+ ];
2521
+ for (const row of rows) {
2522
+ const line = document.createElement("div");
2523
+ line.style.display = "flex";
2524
+ line.style.alignItems = "center";
2525
+ line.style.justifyContent = "space-between";
2526
+ line.style.gap = "8px";
2527
+ line.style.padding = "2px 0";
2528
+ const label = document.createElement("span");
2529
+ label.style.fontWeight = "600";
2530
+ label.style.minWidth = "110px";
2531
+ label.textContent = `${row.label}:`;
2532
+ const valueWrap = document.createElement("span");
2533
+ valueWrap.style.display = "inline-flex";
2534
+ valueWrap.style.alignItems = "center";
2535
+ valueWrap.style.gap = "6px";
2536
+ valueWrap.style.flex = "1";
2537
+ valueWrap.style.justifyContent = "flex-end";
2538
+ valueWrap.style.minWidth = "0";
2539
+ const value = document.createElement("span");
2540
+ value.style.fontFamily = "monospace";
2541
+ value.style.fontSize = "11px";
2542
+ value.style.opacity = "0.9";
2543
+ value.style.whiteSpace = "normal";
2544
+ value.style.overflowWrap = "anywhere";
2545
+ value.style.wordBreak = "break-word";
2546
+ value.style.textAlign = "right";
2547
+ value.textContent = row.value;
2548
+ valueWrap.appendChild(value);
2549
+ if (row.copyable && row.value && row.value !== "N/A") {
2550
+ const copyBtn = document.createElement("button");
2551
+ copyBtn.textContent = "\u{1F4CB}";
2552
+ copyBtn.title = `Copy ${row.label}`;
2553
+ copyBtn.style.padding = "2px 6px";
2554
+ copyBtn.style.fontSize = "10px";
2555
+ copyBtn.style.lineHeight = "1";
2556
+ copyBtn.style.background = "#e5e7eb";
2557
+ copyBtn.style.color = "#111827";
2558
+ copyBtn.onclick = async () => {
2559
+ try {
2560
+ await navigator.clipboard.writeText(row.value);
2561
+ copyBtn.textContent = "\u2713";
2562
+ setTimeout(() => {
2563
+ copyBtn.textContent = "\u{1F4CB}";
2564
+ }, 1200);
2565
+ } catch (_e) {
2566
+ copyBtn.textContent = "!";
2567
+ setTimeout(() => {
2568
+ copyBtn.textContent = "\u{1F4CB}";
2569
+ }, 1200);
2570
+ }
2571
+ };
2572
+ valueWrap.appendChild(copyBtn);
2573
+ }
2574
+ line.appendChild(label);
2575
+ line.appendChild(valueWrap);
2576
+ details.appendChild(line);
2577
+ }
2578
+ const featureKeys = [
2579
+ "airQuality",
2580
+ "pm25",
2581
+ "pm10",
2582
+ "voc",
2583
+ "co2",
2584
+ "humidity",
2585
+ "temperature",
2586
+ "preset",
2587
+ "mode",
2588
+ "presetMode",
2589
+ "direction",
2590
+ "calibration",
2591
+ "multiCommand",
2592
+ "extendedInfo",
2593
+ "segmentedControl",
2594
+ "features",
2595
+ "capabilities",
2596
+ "state"
2597
+ ];
2598
+ const shown = new Set(rows.map((r) => r.label.toLowerCase().replace(/\s/g, "")));
2599
+ for (const key of featureKeys) {
2600
+ if (device && device[key] !== void 0 && !shown.has(key.toLowerCase())) {
2601
+ const line = document.createElement("div");
2602
+ line.style.display = "flex";
2603
+ line.style.alignItems = "center";
2604
+ line.style.justifyContent = "space-between";
2605
+ line.style.gap = "8px";
2606
+ line.style.padding = "2px 0";
2607
+ const label = document.createElement("span");
2608
+ label.style.fontWeight = "600";
2609
+ label.style.minWidth = "110px";
2610
+ label.textContent = key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()) + ":";
2611
+ const value = document.createElement("span");
2612
+ value.style.fontFamily = "monospace";
2613
+ value.style.fontSize = "11px";
2614
+ value.style.opacity = "0.9";
2615
+ value.style.whiteSpace = "normal";
2616
+ value.style.overflowWrap = "anywhere";
2617
+ value.style.wordBreak = "break-word";
2618
+ value.style.textAlign = "right";
2619
+ value.textContent = typeof device[key] === "object" ? JSON.stringify(device[key]) : String(device[key]);
2620
+ line.appendChild(label);
2621
+ line.appendChild(value);
2622
+ details.appendChild(line);
2623
+ }
2624
+ }
2625
+ return details;
2626
+ }
1308
2627
  async function renderDiscoveredDevices(devices, options = {}) {
1309
2628
  const ul = document.createElement("ul");
2629
+ ul.className = "device-grid";
1310
2630
  ul.style.maxHeight = "400px";
1311
2631
  ul.style.overflowY = "auto";
1312
2632
  ul.style.marginTop = "12px";
@@ -1315,34 +2635,98 @@ async function renderDiscoveredDevices(devices, options = {}) {
1315
2635
  const { addDeviceToConfig: addDeviceToConfig3 } = await Promise.resolve().then(() => (init_discovery(), discovery_exports));
1316
2636
  const { loadConfiguredDevices: loadConfiguredDevices2 } = await Promise.resolve().then(() => (init_devices(), devices_exports));
1317
2637
  const configuredIds = options.configuredIds ?? /* @__PURE__ */ new Set();
2638
+ const selectedIds = options.selectedIds ?? /* @__PURE__ */ new Set();
2639
+ const onToggleSelect = options.onToggleSelect;
1318
2640
  for (const d of devices) {
1319
2641
  const deviceId = normalizeId2(d.id);
1320
2642
  const alreadyAdded = configuredIds.has(deviceId);
1321
2643
  const li = document.createElement("li");
1322
2644
  li.className = "device-item";
1323
2645
  li.style.display = "flex";
1324
- li.style.alignItems = "center";
1325
- li.style.justifyContent = "space-between";
1326
- li.style.padding = "10px 12px";
1327
- li.style.marginBottom = "6px";
1328
- li.style.borderRadius = "6px";
2646
+ li.style.flexDirection = "column";
2647
+ li.style.alignItems = "stretch";
2648
+ li.style.justifyContent = "flex-start";
2649
+ li.style.padding = "5px 8px";
2650
+ li.style.marginBottom = "0";
2651
+ li.style.borderRadius = "5px";
1329
2652
  li.style.transition = "all 0.2s ease";
1330
2653
  const info = document.createElement("div");
1331
- info.style.flex = "1";
2654
+ info.style.flex = "1 1 auto";
2655
+ info.style.width = "100%";
2656
+ info.style.minWidth = "0";
1332
2657
  const nameContainer = document.createElement("div");
1333
2658
  nameContainer.style.display = "flex";
1334
2659
  nameContainer.style.alignItems = "center";
1335
- nameContainer.style.marginBottom = "4px";
2660
+ nameContainer.style.marginBottom = "0";
1336
2661
  nameContainer.style.flexWrap = "wrap";
1337
- nameContainer.style.gap = "6px";
2662
+ nameContainer.style.gap = "4px";
1338
2663
  const name = document.createElement("div");
1339
2664
  name.style.fontWeight = "500";
1340
- name.style.fontSize = "14px";
2665
+ name.style.fontSize = "13px";
1341
2666
  name.textContent = d.name || d.id;
2667
+ const selectCheckbox = document.createElement("input");
2668
+ selectCheckbox.type = "checkbox";
2669
+ selectCheckbox.style.width = "auto";
2670
+ selectCheckbox.style.margin = "0 2px 0 0";
2671
+ selectCheckbox.checked = selectedIds.has(deviceId);
2672
+ if (alreadyAdded) {
2673
+ selectCheckbox.disabled = true;
2674
+ selectCheckbox.title = "Already configured";
2675
+ }
2676
+ selectCheckbox.onchange = () => {
2677
+ onToggleSelect?.(d, selectCheckbox.checked);
2678
+ window.dispatchEvent(new CustomEvent("discovery-selection-changed"));
2679
+ };
2680
+ nameContainer.appendChild(selectCheckbox);
1342
2681
  nameContainer.appendChild(name);
2682
+ if (d.firmwareUpdateAvailable) {
2683
+ const fwBadge = document.createElement("span");
2684
+ fwBadge.textContent = "Update Available";
2685
+ fwBadge.style.cssText = "background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;";
2686
+ fwBadge.title = "A firmware update is available for this device.";
2687
+ nameContainer.appendChild(fwBadge);
2688
+ }
2689
+ let offline = false;
2690
+ let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
2691
+ if (typeof d.offline === "boolean") {
2692
+ offline = d.offline;
2693
+ } else if (lastSeen) {
2694
+ try {
2695
+ const last = new Date(lastSeen).getTime();
2696
+ if (!isNaN(last) && Date.now() - last > 1e3 * 60 * 60) {
2697
+ offline = true;
2698
+ }
2699
+ } catch {
2700
+ }
2701
+ }
2702
+ if (offline) {
2703
+ const offlineBadge = document.createElement("span");
2704
+ offlineBadge.textContent = "Offline";
2705
+ offlineBadge.style.cssText = "background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;";
2706
+ offlineBadge.title = "Device is offline or unreachable.";
2707
+ nameContainer.appendChild(offlineBadge);
2708
+ }
2709
+ const expandedDetails = document.createElement("div");
2710
+ expandedDetails.style.display = "none";
2711
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d));
2712
+ const expandBtn = document.createElement("button");
2713
+ expandBtn.textContent = "\u25BE";
2714
+ expandBtn.title = "Show details";
2715
+ expandBtn.style.padding = "2px 6px";
2716
+ expandBtn.style.fontSize = "11px";
2717
+ expandBtn.style.marginLeft = "4px";
2718
+ expandBtn.style.background = "#e5e7eb";
2719
+ expandBtn.style.color = "#111827";
2720
+ expandBtn.style.transition = "transform 0.2s ease";
2721
+ expandBtn.onclick = () => {
2722
+ const isHidden = expandedDetails.style.display === "none";
2723
+ expandedDetails.style.display = isHidden ? "block" : "none";
2724
+ expandBtn.style.transform = isHidden ? "rotate(180deg)" : "rotate(0deg)";
2725
+ };
2726
+ nameContainer.appendChild(expandBtn);
1343
2727
  const duplicateBadge = document.createElement("span");
1344
2728
  duplicateBadge.textContent = alreadyAdded ? "\u2713 Already Added" : "\u2795 New Device";
1345
- duplicateBadge.style.cssText = alreadyAdded ? "background: #16a34a; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;" : "background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;";
2729
+ duplicateBadge.style.cssText = alreadyAdded ? "background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;" : "background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;";
1346
2730
  nameContainer.appendChild(duplicateBadge);
1347
2731
  if (d.connectionType) {
1348
2732
  const badge = renderConnectionBadge(d.connectionType);
@@ -1357,11 +2741,21 @@ async function renderDiscoveredDevices(devices, options = {}) {
1357
2741
  nameContainer.appendChild(renderSignalBars(d.rssi));
1358
2742
  nameContainer.appendChild(renderSignalQualityBadge(d.rssi));
1359
2743
  }
2744
+ if (typeof d.battery === "number" && d.battery < 20) {
2745
+ const batteryWarn = document.createElement("span");
2746
+ batteryWarn.textContent = `\u26A0\uFE0F ${d.battery}%`;
2747
+ batteryWarn.style.cssText = d.battery < 10 ? "background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;" : "background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;";
2748
+ batteryWarn.title = d.battery < 10 ? "Battery critically low" : "Battery low";
2749
+ nameContainer.appendChild(batteryWarn);
2750
+ }
1360
2751
  const details = document.createElement("div");
1361
- details.style.fontSize = "11px";
2752
+ details.style.fontSize = "10px";
1362
2753
  details.style.opacity = "0.7";
1363
- details.style.marginTop = "4px";
2754
+ details.style.marginTop = "0";
1364
2755
  details.style.fontFamily = "monospace";
2756
+ details.style.whiteSpace = "normal";
2757
+ details.style.overflowWrap = "anywhere";
2758
+ details.style.wordBreak = "break-word";
1365
2759
  let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || "N/A"}`;
1366
2760
  if (d.hubDeviceId) {
1367
2761
  detailsText += ` | Hub: ${d.hubDeviceId}`;
@@ -1372,11 +2766,13 @@ async function renderDiscoveredDevices(devices, options = {}) {
1372
2766
  details.textContent = detailsText;
1373
2767
  info.appendChild(nameContainer);
1374
2768
  info.appendChild(details);
2769
+ info.appendChild(expandedDetails);
1375
2770
  const addBtn = document.createElement("button");
1376
2771
  addBtn.textContent = alreadyAdded ? "Already Added" : "Add to Config";
1377
- addBtn.style.marginLeft = "8px";
1378
- addBtn.style.padding = "6px 14px";
1379
- addBtn.style.fontSize = "12px";
2772
+ addBtn.style.marginLeft = "0";
2773
+ addBtn.style.marginTop = "2px";
2774
+ addBtn.style.padding = "4px 9px";
2775
+ addBtn.style.fontSize = "11px";
1380
2776
  addBtn.style.whiteSpace = "nowrap";
1381
2777
  addBtn.style.flexShrink = "0";
1382
2778
  addBtn.disabled = alreadyAdded;
@@ -1395,25 +2791,44 @@ async function renderDiscoveredDevices(devices, options = {}) {
1395
2791
  const viewBtn = document.createElement("button");
1396
2792
  viewBtn.textContent = "View in Config";
1397
2793
  viewBtn.className = "secondary";
1398
- viewBtn.style.marginLeft = "8px";
1399
- viewBtn.style.padding = "6px 12px";
1400
- viewBtn.style.fontSize = "12px";
2794
+ viewBtn.style.marginLeft = "0";
2795
+ viewBtn.style.padding = "4px 9px";
2796
+ viewBtn.style.fontSize = "11px";
1401
2797
  viewBtn.onclick = async () => {
1402
2798
  await loadConfiguredDevices2();
1403
2799
  scrollToConfiguredDevice(d.id);
1404
2800
  };
1405
2801
  li.appendChild(info);
1406
- const actions = document.createElement("div");
1407
- actions.style.display = "flex";
1408
- actions.style.alignItems = "center";
1409
- actions.appendChild(viewBtn);
1410
- actions.appendChild(addBtn);
1411
- li.appendChild(actions);
2802
+ const actions2 = document.createElement("div");
2803
+ actions2.className = "device-actions";
2804
+ actions2.style.display = "flex";
2805
+ actions2.style.alignItems = "center";
2806
+ actions2.style.flexWrap = "wrap";
2807
+ actions2.style.justifyContent = "flex-start";
2808
+ actions2.style.marginLeft = "0";
2809
+ actions2.style.width = "100%";
2810
+ actions2.style.marginTop = "2px";
2811
+ actions2.style.gap = "5px";
2812
+ actions2.appendChild(viewBtn);
2813
+ actions2.appendChild(addBtn);
2814
+ actions2.appendChild(createConnectionTestControls(d));
2815
+ li.appendChild(actions2);
1412
2816
  ul.appendChild(li);
1413
2817
  continue;
1414
2818
  }
2819
+ const actions = document.createElement("div");
2820
+ actions.className = "device-actions";
2821
+ actions.style.display = "flex";
2822
+ actions.style.flexWrap = "wrap";
2823
+ actions.style.justifyContent = "flex-start";
2824
+ actions.style.marginLeft = "0";
2825
+ actions.style.width = "100%";
2826
+ actions.style.marginTop = "2px";
2827
+ actions.style.gap = "5px";
2828
+ actions.appendChild(addBtn);
2829
+ actions.appendChild(createConnectionTestControls(d));
1415
2830
  li.appendChild(info);
1416
- li.appendChild(addBtn);
2831
+ li.appendChild(actions);
1417
2832
  ul.appendChild(li);
1418
2833
  }
1419
2834
  return ul;
@@ -1526,6 +2941,7 @@ function renderDeviceList(list) {
1526
2941
  return;
1527
2942
  }
1528
2943
  status.textContent = `Found ${list.length} device(s)`;
2944
+ ul.classList.add("device-grid");
1529
2945
  ul.innerHTML = "";
1530
2946
  if (removeAllContainer) {
1531
2947
  removeAllContainer.style.display = "block";
@@ -1534,22 +2950,56 @@ function renderDeviceList(list) {
1534
2950
  const li = document.createElement("li");
1535
2951
  li.className = "device-item";
1536
2952
  li.setAttribute("data-device-id", normalizeId2(d.id));
2953
+ li.style.display = "flex";
2954
+ li.style.flexDirection = "column";
2955
+ li.style.alignItems = "stretch";
2956
+ li.style.marginBottom = "0";
1537
2957
  const info = document.createElement("div");
1538
- info.style.flex = "1";
2958
+ info.style.flex = "1 1 auto";
2959
+ info.style.width = "100%";
2960
+ info.style.minWidth = "0";
1539
2961
  const nameContainer = document.createElement("div");
1540
2962
  nameContainer.style.display = "flex";
1541
- nameContainer.style.alignItems = "center";
1542
- nameContainer.style.flexWrap = "wrap";
1543
- nameContainer.style.gap = "6px";
2963
+ nameContainer.style.flexDirection = "column";
2964
+ nameContainer.style.alignItems = "flex-start";
2965
+ nameContainer.style.gap = "0";
1544
2966
  const name = document.createElement("div");
1545
2967
  name.style.fontWeight = "500";
2968
+ name.style.fontSize = "13px";
1546
2969
  name.textContent = d.name || d.id;
2970
+ const expandedDetails = document.createElement("div");
2971
+ expandedDetails.style.display = "none";
2972
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d));
2973
+ const expandBtn = document.createElement("button");
2974
+ expandBtn.textContent = "\u25BE";
2975
+ expandBtn.title = "Show details";
2976
+ expandBtn.style.padding = "2px 6px";
2977
+ expandBtn.style.fontSize = "11px";
2978
+ expandBtn.style.marginLeft = "4px";
2979
+ expandBtn.style.background = "#e5e7eb";
2980
+ expandBtn.style.color = "#111827";
2981
+ expandBtn.style.transition = "transform 0.2s ease";
2982
+ expandBtn.onclick = () => {
2983
+ const isHidden = expandedDetails.style.display === "none";
2984
+ expandedDetails.style.display = isHidden ? "block" : "none";
2985
+ expandBtn.style.transform = isHidden ? "rotate(180deg)" : "rotate(0deg)";
2986
+ };
1547
2987
  const code = document.createElement("code");
1548
2988
  code.textContent = d.id;
1549
- code.style.fontSize = "12px";
2989
+ code.style.fontSize = "10px";
1550
2990
  code.style.opacity = "0.75";
1551
- code.style.marginLeft = "8px";
1552
- nameContainer.appendChild(name);
2991
+ code.style.marginLeft = "0";
2992
+ code.style.whiteSpace = "normal";
2993
+ code.style.overflowWrap = "anywhere";
2994
+ code.style.wordBreak = "break-word";
2995
+ code.style.maxWidth = "100%";
2996
+ const headerRow = document.createElement("div");
2997
+ headerRow.style.display = "inline-flex";
2998
+ headerRow.style.alignItems = "center";
2999
+ headerRow.style.gap = "4px";
3000
+ headerRow.appendChild(name);
3001
+ headerRow.appendChild(expandBtn);
3002
+ nameContainer.appendChild(headerRow);
1553
3003
  nameContainer.appendChild(code);
1554
3004
  if (d.rssi !== void 0 && d.rssi !== null && d.rssi !== 0) {
1555
3005
  nameContainer.appendChild(renderSignalBars(d.rssi));
@@ -1557,29 +3007,36 @@ function renderDeviceList(list) {
1557
3007
  }
1558
3008
  const meta = document.createElement("div");
1559
3009
  meta.style.opacity = "0.75";
1560
- meta.style.marginTop = "4px";
1561
- meta.style.fontSize = "12px";
3010
+ meta.style.marginTop = "0";
3011
+ meta.style.fontSize = "11px";
1562
3012
  const typeText = d.type ? `type: ${d.type}` : "";
1563
3013
  const connText = d.connectionPreference ? `conn: ${d.connectionPreference}` : "";
1564
3014
  const roomText = d.room ? `room: ${d.room}` : "";
1565
3015
  meta.textContent = [typeText, connText, roomText].filter(Boolean).join(" | ");
1566
3016
  info.appendChild(nameContainer);
1567
3017
  info.appendChild(meta);
3018
+ info.appendChild(expandedDetails);
1568
3019
  const buttons = document.createElement("div");
3020
+ buttons.className = "device-actions";
1569
3021
  buttons.style.display = "flex";
1570
- buttons.style.gap = "8px";
3022
+ buttons.style.flexWrap = "wrap";
3023
+ buttons.style.justifyContent = "flex-start";
3024
+ buttons.style.marginLeft = "0";
3025
+ buttons.style.width = "100%";
3026
+ buttons.style.marginTop = "2px";
3027
+ buttons.style.gap = "5px";
1571
3028
  const editBtn = document.createElement("button");
1572
3029
  editBtn.textContent = "\u270F\uFE0F Edit";
1573
- editBtn.style.padding = "6px 12px";
1574
- editBtn.style.fontSize = "12px";
3030
+ editBtn.style.padding = "4px 9px";
3031
+ editBtn.style.fontSize = "11px";
1575
3032
  editBtn.onclick = async () => {
1576
3033
  const { editDevice: editDevice2 } = await Promise.resolve().then(() => (init_modals(), modals_exports));
1577
3034
  await editDevice2(d);
1578
3035
  };
1579
3036
  const copyBtn = document.createElement("button");
1580
3037
  copyBtn.textContent = "Copy ID";
1581
- copyBtn.style.padding = "6px 12px";
1582
- copyBtn.style.fontSize = "12px";
3038
+ copyBtn.style.padding = "4px 9px";
3039
+ copyBtn.style.fontSize = "11px";
1583
3040
  copyBtn.addEventListener("click", async () => {
1584
3041
  try {
1585
3042
  await navigator.clipboard.writeText(d.id);
@@ -1595,8 +3052,8 @@ function renderDeviceList(list) {
1595
3052
  });
1596
3053
  const deleteBtn = document.createElement("button");
1597
3054
  deleteBtn.textContent = "\u{1F5D1}\uFE0F Delete";
1598
- deleteBtn.style.padding = "6px 12px";
1599
- deleteBtn.style.fontSize = "12px";
3055
+ deleteBtn.style.padding = "4px 9px";
3056
+ deleteBtn.style.fontSize = "11px";
1600
3057
  deleteBtn.style.background = "#ef4444";
1601
3058
  deleteBtn.onclick = async () => {
1602
3059
  const { deleteDeviceFromConfig: deleteDeviceFromConfig2 } = await Promise.resolve().then(() => (init_devices_delete(), devices_delete_exports));
@@ -1604,6 +3061,7 @@ function renderDeviceList(list) {
1604
3061
  };
1605
3062
  buttons.appendChild(editBtn);
1606
3063
  buttons.appendChild(copyBtn);
3064
+ buttons.appendChild(createConnectionTestControls(d));
1607
3065
  buttons.appendChild(deleteBtn);
1608
3066
  li.appendChild(info);
1609
3067
  li.appendChild(buttons);
@@ -1636,7 +3094,9 @@ async function addDeviceToConfig2(device, options = {}) {
1636
3094
  const resp = await addDevice(device.id, importValues.configDeviceName, importValues.configDeviceType, {
1637
3095
  address: importValues.address,
1638
3096
  model: device.model,
1639
- rssi: device.rssi
3097
+ rssi: device.rssi,
3098
+ encryptionKey: importValues.encryptionKey,
3099
+ keyId: importValues.keyId
1640
3100
  });
1641
3101
  uiLog.info("Add device response:", resp);
1642
3102
  if (!resp || resp.success === false) {
@@ -1804,6 +3264,7 @@ window.saveCredentials = saveCredentials2;
1804
3264
  window.discoverDevices = discoverDevices2;
1805
3265
  async function init() {
1806
3266
  await loadCredentialStatus();
3267
+ await initializeDiscoverySettings();
1807
3268
  const list = await loadConfiguredDevices();
1808
3269
  await initRemoveAllButton();
1809
3270
  }