@switchbot/homebridge-switchbot 5.0.0-beta.112 → 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 (82) 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 +98 -0
  26. package/dist/homebridge-ui/public/index.html +57 -0
  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 +1503 -104
  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 +1223 -126
  42. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/discovery.ts +1323 -145
  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 +311 -1
  51. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/render.ts +341 -2
  53. package/dist/platform.d.ts +1 -0
  54. package/dist/platform.d.ts.map +1 -1
  55. package/dist/platform.js +6 -2
  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 +2 -2
  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 +98 -0
  71. package/src/homebridge-ui/public/index.html +57 -0
  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 +1323 -145
  76. package/src/homebridge-ui/public/js/modals.ts +50 -1
  77. package/src/homebridge-ui/public/js/render.ts +341 -2
  78. package/src/platform.ts +6 -2
  79. package/src/switchbotClient.ts +44 -9
  80. package/test/matter-integration.spec.ts +21 -0
  81. package/todo/COMPLETED.md +300 -0
  82. package/todo/TODO.md +80 -66
@@ -1,121 +1,580 @@
1
+ // Batch enable/disable helper (true module scope for UI access)
1
2
  import {
2
3
  addDevicesInBulk,
3
4
  discoverDevices as apiDiscoverDevices,
4
- fetchDevices,
5
+ fetchBluetoothStatus,
6
+ // fetchDevices, // removed unused import
5
7
  syncParentPluginConfigFromDisk,
6
8
  } from './api.js'
7
9
  import { loadConfiguredDevices } from './devices.js'
8
10
  import { uiLog } from './logger.js'
9
11
  import { hideBusyUi, showBusyUi } from './modal.js'
10
- import { renderDiscoveredDevices, filterDevices, sortDevices, getDiscoveryPreferences, setDiscoveryPreferences } from './render.js'
12
+ import { getDiscoveryPreferences, renderDiscoveredDevices, setDiscoveryPreferences } from './render.js'
11
13
  import { toastError, toastInfo, toastSuccess, toastWarning } from './toast.js'
12
14
 
15
+ async function batchSetDeviceEnabled(selectedIds: Set<string>, enabled: boolean): Promise<void> {
16
+ // Fetch current config
17
+ const resp = await homebridge.request('/platform-config', {})
18
+ if (!resp || resp.success === false || !resp.data) {
19
+ throw new Error('Failed to load config')
20
+ }
21
+ const config = resp.data
22
+ // Homebridge config may be an array of platforms, find SwitchBot
23
+ const configArr = Array.isArray(config) ? config : [config]
24
+ const platformIdx = configArr.findIndex((c: any) => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
25
+ if (platformIdx === -1) {
26
+ throw new Error('SwitchBot platform config not found')
27
+ }
28
+ const platformConfig = configArr[platformIdx]
29
+ if (!Array.isArray(platformConfig.devices)) {
30
+ throw new TypeError('No devices array in config')
31
+ }
32
+ let changed = false
33
+ for (const dev of platformConfig.devices) {
34
+ const id = String(dev.deviceId || dev.id || '').trim().toLowerCase()
35
+ if (selectedIds.has(id)) {
36
+ if (dev.enabled !== enabled) {
37
+ dev.enabled = enabled
38
+ changed = true
39
+ }
40
+ }
41
+ }
42
+ if (changed) {
43
+ if (typeof homebridge.updatePluginConfig === 'function') {
44
+ await homebridge.updatePluginConfig(configArr)
45
+ } else {
46
+ throw new TypeError('homebridge.updatePluginConfig is not available')
47
+ }
48
+ if (typeof homebridge.savePluginConfig === 'function') {
49
+ await homebridge.savePluginConfig()
50
+ }
51
+ }
52
+ }
53
+
13
54
  function normalizeId(value: any): string {
14
55
  return String(value ?? '').trim().toLowerCase()
15
56
  }
16
57
 
58
+ function dedupeById(devices: any[]): any[] {
59
+ return devices.filter((d, index, arr) => !!d?.id && arr.findIndex(x => x?.id === d.id) === index)
60
+ }
61
+
62
+ function mergeDiscoveredDevices(existingDevices: any[], incomingDevices: any[]): any[] {
63
+ const deviceMap = new Map<string, any>()
64
+
65
+ for (const d of dedupeById(existingDevices)) {
66
+ deviceMap.set(d.id, { ...d })
67
+ }
68
+
69
+ for (const d of dedupeById(incomingDevices)) {
70
+ const current = deviceMap.get(d.id)
71
+ if (current) {
72
+ const nextConnectionType = current.connectionType === d.connectionType
73
+ ? current.connectionType
74
+ : 'Both'
75
+ deviceMap.set(d.id, {
76
+ ...current,
77
+ ...d,
78
+ connectionType: nextConnectionType,
79
+ })
80
+ } else {
81
+ deviceMap.set(d.id, { ...d })
82
+ }
83
+ }
84
+
85
+ return [...deviceMap.values()]
86
+ }
87
+
88
+ type DiscoveryGroupBy = 'connection' | 'hub' | 'type'
89
+
90
+ interface DiscoveryBleSettings {
91
+ bleEnabled: boolean
92
+ bleScanDurationSeconds: number
93
+ bleTimeoutSeconds: number
94
+ }
95
+
96
+ const DISCOVERY_GROUP_BY_KEY = 'discoveryGroupBy'
97
+ const DISCOVERY_GROUP_EXPANDED_KEY = 'discoveryGroupExpanded'
98
+ const DISCOVERY_BLE_SETTINGS_KEY = 'discoveryBleSettings'
99
+ const DISCOVERY_HIDE_ADDED_KEY = 'discoveryHideAdded'
100
+ const DISCOVERY_CACHE_KEY = 'discoveryCache'
101
+ const DISCOVERY_AUTO_REFRESH_KEY = 'discoveryAutoRefreshSeconds'
102
+ const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000
103
+
104
+ let discoveryAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
105
+ let discoveryLastScannedTimer: ReturnType<typeof setInterval> | null = null
106
+
107
+ interface DiscoveryCachePayload {
108
+ timestamp: number
109
+ devices: any[]
110
+ }
111
+
112
+ function setDiscoveryCache(devices: any[]): void {
113
+ try {
114
+ const payload: DiscoveryCachePayload = { timestamp: Date.now(), devices }
115
+ localStorage.setItem(DISCOVERY_CACHE_KEY, JSON.stringify(payload))
116
+ } catch (_e) {
117
+ // Ignore storage errors
118
+ }
119
+ }
120
+
121
+ function clearDiscoveryCache(): void {
122
+ try {
123
+ localStorage.removeItem(DISCOVERY_CACHE_KEY)
124
+ } catch (_e) {
125
+ // Ignore storage errors
126
+ }
127
+ }
128
+
129
+ function getDiscoveryCache(validOnly = true): DiscoveryCachePayload | null {
130
+ try {
131
+ const stored = localStorage.getItem(DISCOVERY_CACHE_KEY)
132
+ if (!stored) {
133
+ return null
134
+ }
135
+ const payload = JSON.parse(stored) as DiscoveryCachePayload
136
+ if (!payload || !Array.isArray(payload.devices) || typeof payload.timestamp !== 'number') {
137
+ return null
138
+ }
139
+ const age = Date.now() - payload.timestamp
140
+ if (validOnly && age > DISCOVERY_CACHE_TTL_MS) {
141
+ return null
142
+ }
143
+ return payload
144
+ } catch (_e) {
145
+ return null
146
+ }
147
+ }
148
+
149
+ function getDiscoveryAutoRefreshSeconds(): number {
150
+ try {
151
+ const stored = localStorage.getItem(DISCOVERY_AUTO_REFRESH_KEY)
152
+ const value = Number(stored || 0)
153
+ return Number.isFinite(value) && value >= 0 ? value : 0
154
+ } catch (_e) {
155
+ return 0
156
+ }
157
+ }
158
+
159
+ function setDiscoveryAutoRefreshSeconds(value: number): void {
160
+ try {
161
+ localStorage.setItem(DISCOVERY_AUTO_REFRESH_KEY, String(Math.max(0, value)))
162
+ } catch (_e) {
163
+ // Ignore storage errors
164
+ }
165
+ }
166
+
167
+ function getDiscoveryHideAddedPreference(): boolean {
168
+ try {
169
+ return localStorage.getItem(DISCOVERY_HIDE_ADDED_KEY) === 'true'
170
+ } catch (_e) {
171
+ return false
172
+ }
173
+ }
174
+
175
+ function setDiscoveryHideAddedPreference(value: boolean): void {
176
+ try {
177
+ localStorage.setItem(DISCOVERY_HIDE_ADDED_KEY, String(value))
178
+ } catch (_e) {
179
+ // Ignore storage errors
180
+ }
181
+ }
182
+
183
+ function formatElapsedShort(ms: number): string {
184
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000))
185
+ if (totalSeconds < 60) {
186
+ return `${totalSeconds}s ago`
187
+ }
188
+ const minutes = Math.floor(totalSeconds / 60)
189
+ if (minutes < 60) {
190
+ return `${minutes}m ago`
191
+ }
192
+ const hours = Math.floor(minutes / 60)
193
+ if (hours < 24) {
194
+ return `${hours}h ago`
195
+ }
196
+ const days = Math.floor(hours / 24)
197
+ return `${days}d ago`
198
+ }
199
+
200
+ function updateLastScannedStatus(): void {
201
+ const lastScannedStatus = document.getElementById('lastScannedStatus')
202
+ if (!lastScannedStatus) {
203
+ return
204
+ }
205
+
206
+ const cache = getDiscoveryCache(false)
207
+ if (!cache) {
208
+ lastScannedStatus.textContent = 'Last scanned: never'
209
+ return
210
+ }
211
+
212
+ const ageMs = Date.now() - cache.timestamp
213
+ const timestampText = new Date(cache.timestamp).toLocaleString()
214
+ const stale = ageMs > DISCOVERY_CACHE_TTL_MS
215
+ lastScannedStatus.textContent = stale
216
+ ? `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText}, cache expired)`
217
+ : `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText})`
218
+ }
219
+
220
+ async function renderCachedDiscoveryResults(): Promise<void> {
221
+ const cache = getDiscoveryCache(true)
222
+ const list = document.getElementById('discoveredList')
223
+ if (!cache || !list || !cache.devices.length) {
224
+ return
225
+ }
226
+
227
+ list.style.display = 'block'
228
+ // Use type-safe property for window._discoverySelectedIds
229
+ if (!(window as any)._discoverySelectedIds) {
230
+ (window as any)._discoverySelectedIds = new Set()
231
+ }
232
+ await updateDiscoveryView(
233
+ cache.devices,
234
+ getDiscoveryPreferences(),
235
+ getDiscoveryGroupByPreference(),
236
+ getDiscoveryHideAddedPreference(),
237
+ (window as any)._discoverySelectedIds,
238
+ )
239
+ }
240
+
241
+ function getDiscoveryBleSettings(): DiscoveryBleSettings {
242
+ try {
243
+ const stored = localStorage.getItem(DISCOVERY_BLE_SETTINGS_KEY)
244
+ if (!stored) {
245
+ return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 }
246
+ }
247
+ const parsed = JSON.parse(stored)
248
+ return {
249
+ bleEnabled: parsed?.bleEnabled !== false,
250
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(parsed?.bleScanDurationSeconds || 5))),
251
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(parsed?.bleTimeoutSeconds || 8))),
252
+ }
253
+ } catch (_e) {
254
+ return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 }
255
+ }
256
+ }
257
+
258
+ function setDiscoveryBleSettings(settings: DiscoveryBleSettings): void {
259
+ try {
260
+ localStorage.setItem(DISCOVERY_BLE_SETTINGS_KEY, JSON.stringify(settings))
261
+ } catch (_e) {
262
+ // Ignore storage errors
263
+ }
264
+ }
265
+
266
+ export async function initializeDiscoverySettings(): Promise<void> {
267
+ const scanSelect = document.getElementById('bleScanDurationSelect') as HTMLSelectElement | null
268
+ const timeoutInput = document.getElementById('bleTimeoutInput') as HTMLInputElement | null
269
+ const disableBleCheckbox = document.getElementById('disableBleScanCheckbox') as HTMLInputElement | null
270
+ const scanSetting = document.getElementById('bleScanSetting')
271
+ const timeoutSetting = document.getElementById('bleTimeoutSetting')
272
+ const bluetoothStatus = document.getElementById('bluetoothStatus')
273
+ const autoRefreshSelect = document.getElementById('autoRefreshIntervalSelect') as HTMLSelectElement | null
274
+ const refreshBtn = document.getElementById('refreshDiscoverBtn') as HTMLButtonElement | null
275
+
276
+ const current = getDiscoveryBleSettings()
277
+ if (scanSelect) {
278
+ scanSelect.value = String(current.bleScanDurationSeconds)
279
+ }
280
+ if (timeoutInput) {
281
+ timeoutInput.value = String(current.bleTimeoutSeconds)
282
+ }
283
+ if (disableBleCheckbox) {
284
+ disableBleCheckbox.checked = !current.bleEnabled
285
+ }
286
+
287
+ if (autoRefreshSelect) {
288
+ autoRefreshSelect.value = String(getDiscoveryAutoRefreshSeconds())
289
+ }
290
+
291
+ const updateBleSettingVisibility = (): void => {
292
+ const disabled = !!disableBleCheckbox?.checked
293
+ if (scanSetting) {
294
+ scanSetting.style.display = disabled ? 'none' : 'inline-flex'
295
+ }
296
+ if (timeoutSetting) {
297
+ timeoutSetting.style.display = disabled ? 'none' : 'inline-flex'
298
+ }
299
+ }
300
+
301
+ const persistFromControls = (): void => {
302
+ const next: DiscoveryBleSettings = {
303
+ bleEnabled: !(disableBleCheckbox?.checked ?? false),
304
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))),
305
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8))),
306
+ }
307
+ setDiscoveryBleSettings(next)
308
+ }
309
+
310
+ scanSelect?.addEventListener('change', persistFromControls)
311
+ timeoutInput?.addEventListener('change', persistFromControls)
312
+ disableBleCheckbox?.addEventListener('change', () => {
313
+ persistFromControls()
314
+ updateBleSettingVisibility()
315
+ })
316
+
317
+ updateBleSettingVisibility()
318
+
319
+ if (bluetoothStatus) {
320
+ const status = await fetchBluetoothStatus()
321
+ bluetoothStatus.textContent = status.available
322
+ ? `Bluetooth: available (${status.message})`
323
+ : `Bluetooth: unavailable (${status.message})`
324
+ }
325
+
326
+ updateLastScannedStatus()
327
+ if (discoveryLastScannedTimer) {
328
+ clearInterval(discoveryLastScannedTimer)
329
+ }
330
+ discoveryLastScannedTimer = setInterval(updateLastScannedStatus, 15000)
331
+
332
+ refreshBtn?.addEventListener('click', () => {
333
+ void discoverDevices()
334
+ })
335
+
336
+ const applyAutoRefresh = (): void => {
337
+ const seconds = Math.max(0, Number(autoRefreshSelect?.value || 0))
338
+ setDiscoveryAutoRefreshSeconds(seconds)
339
+
340
+ if (discoveryAutoRefreshTimer) {
341
+ clearInterval(discoveryAutoRefreshTimer)
342
+ discoveryAutoRefreshTimer = null
343
+ }
344
+
345
+ if (seconds > 0) {
346
+ discoveryAutoRefreshTimer = setInterval(() => {
347
+ const discoverBtn = document.getElementById('discoverBtn') as HTMLButtonElement | null
348
+ if (!discoverBtn || discoverBtn.disabled || document.hidden) {
349
+ return
350
+ }
351
+ void discoverDevices()
352
+ }, seconds * 1000)
353
+ }
354
+ }
355
+
356
+ autoRefreshSelect?.addEventListener('change', applyAutoRefresh)
357
+ applyAutoRefresh()
358
+
359
+ await renderCachedDiscoveryResults()
360
+ }
361
+
362
+ function getDiscoveryGroupByPreference(): DiscoveryGroupBy {
363
+ try {
364
+ const stored = localStorage.getItem(DISCOVERY_GROUP_BY_KEY)
365
+ if (stored === 'hub' || stored === 'type') {
366
+ return stored
367
+ }
368
+ return 'connection'
369
+ } catch (_e) {
370
+ return 'connection'
371
+ }
372
+ }
373
+
374
+ function setDiscoveryGroupByPreference(groupBy: DiscoveryGroupBy): void {
375
+ try {
376
+ localStorage.setItem(DISCOVERY_GROUP_BY_KEY, groupBy)
377
+ } catch (_e) {
378
+ // Ignore storage errors
379
+ }
380
+ }
381
+
382
+ function getDiscoveryGroupExpandedState(): Record<string, boolean> {
383
+ try {
384
+ const stored = localStorage.getItem(DISCOVERY_GROUP_EXPANDED_KEY)
385
+ if (!stored) {
386
+ return {}
387
+ }
388
+ const parsed = JSON.parse(stored)
389
+ return typeof parsed === 'object' && parsed ? parsed : {}
390
+ } catch (_e) {
391
+ return {}
392
+ }
393
+ }
394
+
395
+ function setDiscoveryGroupExpandedState(state: Record<string, boolean>): void {
396
+ try {
397
+ localStorage.setItem(DISCOVERY_GROUP_EXPANDED_KEY, JSON.stringify(state))
398
+ } catch (_e) {
399
+ // Ignore storage errors
400
+ }
401
+ }
402
+
403
+ function isDiscoveryGroupExpanded(groupKey: string): boolean {
404
+ const state = getDiscoveryGroupExpandedState()
405
+ return state[groupKey] !== false
406
+ }
407
+
408
+ function setDiscoveryGroupExpanded(groupKey: string, expanded: boolean): void {
409
+ const state = getDiscoveryGroupExpandedState()
410
+ state[groupKey] = expanded
411
+ setDiscoveryGroupExpandedState(state)
412
+ }
413
+
17
414
  export async function discoverDevices(): Promise<void> {
18
415
  const btn = document.getElementById('discoverBtn') as HTMLButtonElement
416
+ const cancelBtn = document.getElementById('cancelDiscoverBtn') as HTMLButtonElement | null
19
417
  const status = document.getElementById('discoverStatus')
418
+ const phaseProgress = document.getElementById('discoverPhaseProgress') as HTMLElement | null
419
+ const phaseFill = document.getElementById('discoverPhaseFill') as HTMLElement | null
420
+ const phaseLabel = document.getElementById('discoverPhaseLabel') as HTMLElement | null
20
421
  const list = document.getElementById('discoveredList')
21
422
  const autoAddAll = (document.getElementById('autoAddAllCheckbox') as HTMLInputElement)?.checked
423
+ const scanSelect = document.getElementById('bleScanDurationSelect') as HTMLSelectElement | null
424
+ const timeoutInput = document.getElementById('bleTimeoutInput') as HTMLInputElement | null
425
+ const disableBleCheckbox = document.getElementById('disableBleScanCheckbox') as HTMLInputElement | null
22
426
 
23
- if (!btn || !status || !list) { return }
427
+ if (!btn || !status || !list) {
428
+ return
429
+ }
24
430
 
25
- try {
26
- showBusyUi()
27
- btn.disabled = true
28
- btn.textContent = '🔍 Discovering...'
29
- status.textContent = 'Searching SwitchBot account...'
30
- status.classList.remove('error')
431
+ const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
432
+ let spinnerIndex = 0
433
+ const startedAt = Date.now()
434
+ let phaseStartedAt = startedAt
435
+ let phase = 'Preparing discovery...'
436
+ let cancelled = false
31
437
 
32
- const devicesRaw = await apiDiscoverDevices()
33
- uiLog.info('Discover response:', devicesRaw)
438
+ // --- Real-time RSSI polling additions ---
439
+ // Get BLE scan duration from settings or UI
440
+ const bleScanDurationSeconds = scanSelect ? Number(scanSelect.value) : 5
441
+ const pollIntervalMs = 1000 // 1s polling
442
+ const scanEndTime = Date.now() + bleScanDurationSeconds * 1000
34
443
 
35
- // Deduplicate by device ID
36
- const devices = devicesRaw.filter((d, index, arr) => !!d?.id && arr.findIndex(x => x?.id === d.id) === index)
444
+ const setPhase = (nextPhase: string): void => {
445
+ phase = nextPhase
446
+ phaseStartedAt = Date.now()
447
+ }
37
448
 
38
- if (!devices.length) {
39
- status.textContent = 'No devices found in your SwitchBot account'
40
- toastInfo('No devices found in your SwitchBot account')
41
- list.style.display = 'none'
42
- return
449
+ const getPhasePercent = (phaseName: string): number => {
450
+ if (phaseName.includes('Scanning BLE')) {
451
+ return 35
452
+ }
453
+ if (phaseName.includes('Fetching OpenAPI')) {
454
+ return 75
43
455
  }
456
+ if (phaseName.includes('Complete')) {
457
+ return 100
458
+ }
459
+ return 10
460
+ }
44
461
 
45
- // If auto-add is enabled, add all devices immediately using bulk endpoint
46
- if (autoAddAll) {
47
- status.textContent = `Auto-adding ${devices.length} device(s)...`
462
+ const renderProgress = (): void => {
463
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
464
+ const phaseSeconds = Math.max(0, Math.floor((Date.now() - phaseStartedAt) / 1000))
465
+ const frame = spinnerFrames[spinnerIndex % spinnerFrames.length]
466
+ spinnerIndex += 1
467
+ status.textContent = `${frame} ${phase} (${phaseSeconds}s, ${totalSeconds}s total)`
48
468
 
49
- try {
50
- const bulkResult = await addDevicesInBulk(
51
- devices.map(d => ({
52
- deviceId: d.id,
53
- name: d.name,
54
- type: d.type,
55
- rssi: d.rssi,
56
- address: d.address,
57
- model: d.model,
58
- })),
59
- )
60
- uiLog.info('Bulk add response:', bulkResult)
61
-
62
- if (!bulkResult || bulkResult.success === false) {
63
- throw new Error(bulkResult?.data?.message || 'Bulk add failed')
64
- }
469
+ if (phaseProgress) {
470
+ phaseProgress.style.display = 'block'
471
+ }
472
+ if (phaseFill) {
473
+ phaseFill.style.width = `${getPhasePercent(phase)}%`
474
+ }
475
+ if (phaseLabel) {
476
+ phaseLabel.textContent = phase
477
+ }
478
+ }
65
479
 
66
- const addedCount
67
- = bulkResult?.addedCount
68
- ?? bulkResult?.data?.addedCount
69
- ?? 0
70
- const skippedCount
71
- = bulkResult?.skippedCount
72
- ?? bulkResult?.data?.skippedCount
73
- ?? 0
74
- status.textContent = `✓ Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
75
- if (addedCount > 0) {
76
- toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`)
77
- } else if (skippedCount > 0) {
78
- toastWarning(`No new devices were added (${skippedCount} skipped)`)
79
- }
80
- status.classList.remove('error')
81
- list.style.display = 'none'
480
+ const progressTimer = setInterval(renderProgress, 250)
481
+ let discoveredDevices: any[] = []
482
+ const preferences = getDiscoveryPreferences()
483
+ let groupBy: DiscoveryGroupBy = getDiscoveryGroupByPreference()
484
+ let hideAdded = getDiscoveryHideAddedPreference()
485
+ let controlsInitialized = false
486
+ // Use persistent selection state across renders
487
+ if (!(window as any)._discoverySelectedIds) {
488
+ (window as any)._discoverySelectedIds = new Set<string>()
489
+ }
490
+ const selectedIds: Set<string> = (window as any)._discoverySelectedIds
82
491
 
83
- // Sync parent Homebridge form cache and auto-save to prevent cache overwrite
84
- // Run sync when discovery returned devices (even if backend response shape changes)
85
- if (devices.length > 0) {
86
- const synced = await syncParentPluginConfigFromDisk(true)
87
- status.textContent += synced
88
- ? ' - Config saved automatically.'
89
- : ' - Warning: config may not persist until you close/reopen settings.'
492
+ // --- Real-time RSSI polling loop ---
493
+ setPhase('Scanning BLE (real-time RSSI)')
494
+ let pollTimer: ReturnType<typeof setInterval> | null = null
495
+ const pollDiscovery = async () => {
496
+ if (cancelled || Date.now() > scanEndTime) {
497
+ if (pollTimer) {
498
+ clearInterval(pollTimer)
499
+ }
500
+ setPhase('Scan Complete')
501
+ // Final update
502
+ try {
503
+ const devices = await apiDiscoverDevices('ble')
504
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, devices)
505
+ setDiscoveryCache(discoveredDevices)
506
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
507
+ } catch (e) {
508
+ uiLog.error('Final BLE poll failed:', e)
509
+ }
510
+ return
511
+ }
512
+ try {
513
+ const devices = await apiDiscoverDevices('ble')
514
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, devices)
515
+ setDiscoveryCache(discoveredDevices)
516
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
517
+ } catch (e) {
518
+ uiLog.error('BLE poll failed:', e)
519
+ }
520
+ }
521
+ pollTimer = setInterval(pollDiscovery, pollIntervalMs)
522
+ // Initial poll
523
+ pollDiscovery()
90
524
 
91
- if (synced) {
92
- toastSuccess('Configuration synced and saved automatically')
93
- } else {
94
- toastWarning('Configuration sync failed; close and reopen settings before Save')
95
- }
525
+ // Batch enable/disable helper (moved to module scope for UI access)
526
+ async function batchSetDeviceEnabled(selectedIds: Set<string>, enabled: boolean): Promise<void> {
527
+ // Fetch current config
528
+ const resp = await homebridge.request('/platform-config', {})
529
+ if (!resp || resp.success === false || !resp.data) {
530
+ throw new Error('Failed to load config')
531
+ }
532
+ const config = resp.data
533
+ // Homebridge config may be an array of platforms, find SwitchBot
534
+ const configArr = Array.isArray(config) ? config : [config]
535
+ const platformIdx = configArr.findIndex((c: any) => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
536
+ if (platformIdx === -1) {
537
+ throw new Error('SwitchBot platform config not found')
538
+ }
539
+ const platformConfig = configArr[platformIdx]
540
+ if (!Array.isArray(platformConfig.devices)) {
541
+ throw new TypeError('No devices array in config')
542
+ }
543
+ let changed = false
544
+ for (const dev of platformConfig.devices) {
545
+ const id = String(dev.deviceId || dev.id || '').trim().toLowerCase()
546
+ if (selectedIds.has(id)) {
547
+ if (dev.enabled !== enabled) {
548
+ dev.enabled = enabled
549
+ changed = true
96
550
  }
97
- } catch (e) {
98
- uiLog.error('Bulk add error:', e)
99
- status.textContent = `✗ Error: ${e instanceof Error ? e.message : 'Failed to add devices'}`
100
- status.classList.add('error')
101
- toastError(e instanceof Error ? e.message : 'Failed to add devices')
102
551
  }
552
+ }
553
+ if (changed) {
554
+ if (typeof homebridge.updatePluginConfig === 'function') {
555
+ await homebridge.updatePluginConfig(configArr)
556
+ } else {
557
+ throw new TypeError('homebridge.updatePluginConfig is not available')
558
+ }
559
+ if (typeof homebridge.savePluginConfig === 'function') {
560
+ await homebridge.savePluginConfig()
561
+ }
562
+ }
563
+ }
103
564
 
104
- // Refresh the configured devices list
105
- await loadConfiguredDevices()
565
+ const ensureDiscoveryControls = async (): Promise<void> => {
566
+ if (controlsInitialized) {
106
567
  return
107
568
  }
569
+ // Always use persistent selectedIds
570
+ if (!(window as any)._discoverySelectedIds) {
571
+ (window as any)._discoverySelectedIds = new Set<string>()
572
+ }
573
+ const selectedIds: Set<string> = (window as any)._discoverySelectedIds
108
574
 
109
- status.textContent = `Found ${devices.length} device(s)`
110
-
111
- // Create filter/sort controls
112
575
  const controlsDiv = document.createElement('div')
113
576
  controlsDiv.style.cssText = 'margin-bottom: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;'
114
577
 
115
- // Load saved preferences
116
- const preferences = getDiscoveryPreferences()
117
-
118
- // Filter buttons
119
578
  const filterLabel = document.createElement('label')
120
579
  filterLabel.style.fontSize = '12px'
121
580
  filterLabel.style.fontWeight = '500'
@@ -134,35 +593,34 @@ export async function discoverDevices(): Promise<void> {
134
593
  ]
135
594
 
136
595
  for (const option of filterOptions) {
137
- const btn = document.createElement('button')
138
- btn.textContent = option.label
139
- btn.style.padding = '4px 8px'
140
- btn.style.fontSize = '11px'
141
- btn.style.borderRadius = '3px'
142
- btn.style.cursor = 'pointer'
143
- btn.style.border = preferences.connectionType === option.value ? '2px solid #007AFF' : '1px solid #ccc'
144
- btn.style.backgroundColor = preferences.connectionType === option.value ? '#f0f7ff' : '#fff'
145
- btn.style.color = preferences.connectionType === option.value ? '#1d4ed8' : '#374151'
146
-
147
- btn.onclick = () => {
596
+ const filterBtn = document.createElement('button')
597
+ filterBtn.textContent = option.label
598
+ filterBtn.style.padding = '4px 8px'
599
+ filterBtn.style.fontSize = '11px'
600
+ filterBtn.style.borderRadius = '3px'
601
+ filterBtn.style.cursor = 'pointer'
602
+ filterBtn.style.border = preferences.connectionType === option.value ? '2px solid #007AFF' : '1px solid #ccc'
603
+ filterBtn.style.backgroundColor = preferences.connectionType === option.value ? '#f0f7ff' : '#fff'
604
+ filterBtn.style.color = preferences.connectionType === option.value ? '#1d4ed8' : '#374151'
605
+
606
+ filterBtn.onclick = () => {
148
607
  preferences.connectionType = option.value
149
608
  setDiscoveryPreferences(preferences)
150
- updateDiscoveryView(devices, preferences)
151
- // Update button styles
152
- for (const b of Array.from(filterGroup.querySelectorAll('button'))) {
153
- ;(b as HTMLButtonElement).style.border = '1px solid #ccc'
154
- ;(b as HTMLButtonElement).style.backgroundColor = '#fff'
155
- ;(b as HTMLButtonElement).style.color = '#374151'
156
- }
157
- btn.style.border = '2px solid #007AFF'
158
- btn.style.backgroundColor = '#f0f7ff'
159
- btn.style.color = '#1d4ed8'
609
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
610
+
611
+ Array.prototype.forEach.call(filterGroup.querySelectorAll('button'), (b) => {
612
+ (b as HTMLButtonElement).style.border = '1px solid #ccc';
613
+ (b as HTMLButtonElement).style.backgroundColor = '#fff';
614
+ (b as HTMLButtonElement).style.color = '#374151'
615
+ })
616
+ filterBtn.style.border = '2px solid #007AFF'
617
+ filterBtn.style.backgroundColor = '#f0f7ff'
618
+ filterBtn.style.color = '#1d4ed8'
160
619
  }
161
620
 
162
- filterGroup.appendChild(btn)
621
+ filterGroup.appendChild(filterBtn)
163
622
  }
164
623
 
165
- // Sort dropdown
166
624
  const sortLabel = document.createElement('label')
167
625
  sortLabel.style.fontSize = '12px'
168
626
  sortLabel.style.fontWeight = '500'
@@ -183,29 +641,85 @@ export async function discoverDevices(): Promise<void> {
183
641
  ]
184
642
 
185
643
  for (const opt of sortOptions) {
186
- const option = document.createElement('option')
187
- option.value = opt.value
188
- option.textContent = opt.label
189
- sortSelect.appendChild(option)
644
+ const sortOption = document.createElement('option')
645
+ sortOption.value = opt.value
646
+ sortOption.textContent = opt.label
647
+ sortSelect.appendChild(sortOption)
190
648
  }
191
649
 
192
650
  sortSelect.onchange = () => {
193
651
  preferences.sortBy = sortSelect.value as any
194
652
  setDiscoveryPreferences(preferences)
195
- updateDiscoveryView(devices, preferences)
653
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
654
+ }
655
+
656
+ const groupLabel = document.createElement('label')
657
+ groupLabel.style.fontSize = '12px'
658
+ groupLabel.style.fontWeight = '500'
659
+ groupLabel.style.marginLeft = '8px'
660
+ groupLabel.textContent = 'Group:'
661
+
662
+ const groupSelect = document.createElement('select')
663
+ groupSelect.style.fontSize = '11px'
664
+ groupSelect.style.padding = '4px 8px'
665
+ groupSelect.style.borderRadius = '3px'
666
+ groupSelect.value = groupBy
667
+
668
+ const groupOptions: Array<{ label: string, value: DiscoveryGroupBy }> = [
669
+ { label: 'Connection', value: 'connection' },
670
+ { label: 'Hub', value: 'hub' },
671
+ { label: 'Device Type', value: 'type' },
672
+ ]
673
+
674
+ for (const opt of groupOptions) {
675
+ const groupOption = document.createElement('option')
676
+ groupOption.value = opt.value
677
+ groupOption.textContent = opt.label
678
+ groupSelect.appendChild(groupOption)
679
+ }
680
+
681
+ groupSelect.onchange = () => {
682
+ groupBy = groupSelect.value as DiscoveryGroupBy
683
+ setDiscoveryGroupByPreference(groupBy)
684
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
685
+ }
686
+
687
+ const hideAddedLabel = document.createElement('label')
688
+ hideAddedLabel.style.display = 'inline-flex'
689
+ hideAddedLabel.style.alignItems = 'center'
690
+ hideAddedLabel.style.gap = '4px'
691
+ hideAddedLabel.style.fontSize = '11px'
692
+ hideAddedLabel.style.marginLeft = '8px'
693
+
694
+ const hideAddedCheckbox = document.createElement('input')
695
+ hideAddedCheckbox.type = 'checkbox'
696
+ hideAddedCheckbox.checked = hideAdded
697
+ hideAddedCheckbox.style.margin = '0'
698
+ hideAddedCheckbox.style.width = 'auto'
699
+
700
+ const hideAddedText = document.createElement('span')
701
+ hideAddedText.textContent = 'Hide Added'
702
+
703
+ hideAddedLabel.appendChild(hideAddedCheckbox)
704
+ hideAddedLabel.appendChild(hideAddedText)
705
+
706
+ hideAddedCheckbox.onchange = () => {
707
+ hideAdded = hideAddedCheckbox.checked
708
+ setDiscoveryHideAddedPreference(hideAdded)
709
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
196
710
  }
197
711
 
198
- // Search box
199
712
  const searchInput = document.createElement('input')
200
713
  searchInput.type = 'text'
201
714
  searchInput.placeholder = 'Search by name, ID, or type...'
202
- searchInput.style.fontSize = '11px'
203
- searchInput.style.padding = '4px 8px'
204
- searchInput.style.borderRadius = '3px'
715
+ searchInput.style.fontSize = '13px'
716
+ searchInput.style.padding = '8px 16px'
717
+ searchInput.style.borderRadius = '6px'
205
718
  searchInput.style.border = '1px solid #ccc'
206
- searchInput.style.flex = '1'
207
- searchInput.style.minWidth = '200px'
208
- searchInput.style.maxWidth = '300px'
719
+ searchInput.style.flex = '1 1 0%'
720
+ searchInput.style.minWidth = '120px'
721
+ searchInput.style.maxWidth = '100%'
722
+ searchInput.style.width = '100%'
209
723
  searchInput.value = preferences.searchQuery
210
724
 
211
725
  let searchTimeout: NodeJS.Timeout
@@ -214,22 +728,155 @@ export async function discoverDevices(): Promise<void> {
214
728
  searchTimeout = setTimeout(() => {
215
729
  preferences.searchQuery = searchInput.value
216
730
  setDiscoveryPreferences(preferences)
217
- updateDiscoveryView(devices, preferences)
218
- }, 300) // Debounce search
731
+ void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
732
+ }, 300)
733
+ }
734
+
735
+ // Shared style for all top action buttons (smaller, more compact)
736
+ const actionBtnStyle = {
737
+ fontSize: '16px',
738
+ padding: '10px 0',
739
+ borderRadius: '10px',
740
+ margin: '0 12px 0 0',
741
+ width: '100%',
742
+ maxWidth: '220px',
743
+ fontWeight: 'bold',
744
+ background: '#ef4444',
745
+ color: '#fff',
746
+ border: 'none',
747
+ cursor: 'pointer',
748
+ boxShadow: '0 2px 8px #0001',
749
+ transition: 'background 0.2s',
750
+ outline: 'none',
751
+ display: 'block',
752
+ }
753
+
754
+ // Add Selected button
755
+ const addSelectedBtn = document.createElement('button')
756
+ addSelectedBtn.textContent = 'Add Selected to Config'
757
+ Object.assign(addSelectedBtn.style, actionBtnStyle)
758
+ addSelectedBtn.disabled = true
759
+ addSelectedBtn.onclick = async () => {
760
+ if (!selectedIds.size) {
761
+ return
762
+ }
763
+ addSelectedBtn.disabled = true
764
+ addSelectedBtn.textContent = 'Adding...'
765
+ try {
766
+ showBusyUi()
767
+ const selectedDevices = discoveredDevices.filter(d => selectedIds.has(normalizeId(d.id)))
768
+ const bulkResult = await addDevicesInBulk(selectedDevices.map(d => ({
769
+ deviceId: d.id,
770
+ name: d.name,
771
+ type: d.type,
772
+ rssi: d.rssi,
773
+ address: d.address,
774
+ model: d.model,
775
+ })))
776
+ uiLog.info('Batch add response:', bulkResult)
777
+ if (!bulkResult || bulkResult.success === false) {
778
+ throw new Error(bulkResult?.data?.message || 'Batch add failed')
779
+ }
780
+ const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0
781
+ const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0
782
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`)
783
+ await loadConfiguredDevices()
784
+ selectedIds.clear()
785
+ addSelectedBtn.disabled = true
786
+ addSelectedBtn.textContent = 'Add Selected'
787
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
788
+ } catch (e) {
789
+ uiLog.error('Batch add error:', e)
790
+ toastError(e instanceof Error ? e.message : 'Failed to add devices')
791
+ addSelectedBtn.disabled = false
792
+ addSelectedBtn.textContent = 'Add Selected'
793
+ } finally {
794
+ hideBusyUi()
795
+ }
796
+ }
797
+
798
+ // Enable Selected button
799
+ const enableSelectedBtn = document.createElement('button')
800
+ enableSelectedBtn.textContent = 'Enable Selected'
801
+ Object.assign(enableSelectedBtn.style, actionBtnStyle)
802
+ enableSelectedBtn.disabled = true
803
+ enableSelectedBtn.onclick = async () => {
804
+ if (!selectedIds.size) {
805
+ return
806
+ }
807
+ enableSelectedBtn.disabled = true
808
+ enableSelectedBtn.textContent = 'Enabling...'
809
+ try {
810
+ showBusyUi()
811
+ await batchSetDeviceEnabled(selectedIds, true)
812
+ toastSuccess('Selected devices enabled')
813
+ await loadConfiguredDevices()
814
+ enableSelectedBtn.disabled = true
815
+ enableSelectedBtn.textContent = 'Enable Selected'
816
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
817
+ } catch (e) {
818
+ uiLog.error('Batch enable error:', e)
819
+ toastError(e instanceof Error ? e.message : 'Failed to enable devices')
820
+ enableSelectedBtn.disabled = false
821
+ enableSelectedBtn.textContent = 'Enable Selected'
822
+ } finally {
823
+ hideBusyUi()
824
+ }
219
825
  }
220
826
 
221
- // Assemble controls
827
+ // Disable Selected button
828
+ const disableSelectedBtn = document.createElement('button')
829
+ disableSelectedBtn.textContent = 'Disable Selected'
830
+ Object.assign(disableSelectedBtn.style, actionBtnStyle)
831
+ disableSelectedBtn.disabled = true
832
+ disableSelectedBtn.onclick = async () => {
833
+ if (!selectedIds.size) {
834
+ return
835
+ }
836
+ disableSelectedBtn.disabled = true
837
+ disableSelectedBtn.textContent = 'Disabling...'
838
+ try {
839
+ showBusyUi()
840
+ await batchSetDeviceEnabled(selectedIds, false)
841
+ toastSuccess('Selected devices disabled')
842
+ await loadConfiguredDevices()
843
+ disableSelectedBtn.disabled = true
844
+ disableSelectedBtn.textContent = 'Disable Selected'
845
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
846
+ } catch (e) {
847
+ uiLog.error('Batch disable error:', e)
848
+ toastError(e instanceof Error ? e.message : 'Failed to disable devices')
849
+ disableSelectedBtn.disabled = false
850
+ disableSelectedBtn.textContent = 'Disable Selected'
851
+ } finally {
852
+ hideBusyUi()
853
+ }
854
+ }
855
+
856
+ // Only add the filter/search controls
222
857
  controlsDiv.appendChild(filterLabel)
223
858
  controlsDiv.appendChild(filterGroup)
224
859
  controlsDiv.appendChild(sortLabel)
225
860
  controlsDiv.appendChild(sortSelect)
861
+ controlsDiv.appendChild(groupLabel)
862
+ controlsDiv.appendChild(groupSelect)
863
+ controlsDiv.appendChild(hideAddedLabel)
226
864
  controlsDiv.appendChild(searchInput)
227
865
 
228
- // Clear list and add controls
866
+ // Top row action buttons container
867
+ const topActionRow = document.createElement('div')
868
+ topActionRow.style.display = 'flex'
869
+ topActionRow.style.gap = '20px'
870
+ topActionRow.style.margin = '18px 0 10px 0'
871
+ topActionRow.style.justifyContent = 'flex-start'
872
+ topActionRow.appendChild(addSelectedBtn)
873
+ topActionRow.appendChild(enableSelectedBtn)
874
+ topActionRow.appendChild(disableSelectedBtn)
875
+ list.insertBefore(topActionRow, controlsDiv)
876
+
229
877
  list.innerHTML = ''
230
878
  list.appendChild(controlsDiv)
231
879
 
232
- // Create or get the device list container
233
880
  let deviceListContainer = document.getElementById('discoveredDevices')
234
881
  if (!deviceListContainer) {
235
882
  deviceListContainer = document.createElement('ul')
@@ -242,21 +889,233 @@ export async function discoverDevices(): Promise<void> {
242
889
  list.appendChild(deviceListContainer)
243
890
  }
244
891
 
245
- // Display discovery list
246
892
  list.style.display = 'block'
893
+ controlsInitialized = true
894
+
895
+ // Update action button enabled state based on selection
896
+ const updateActionButtons = () => {
897
+ const hasSelection = selectedIds.size > 0
898
+ addSelectedBtn.disabled = !hasSelection
899
+ enableSelectedBtn.disabled = !hasSelection
900
+ disableSelectedBtn.disabled = !hasSelection
901
+ }
902
+ // Listen for selection changes (selection is managed elsewhere, so poll)
903
+ setInterval(updateActionButtons, 300)
904
+ }
905
+
906
+ try {
907
+ const bleSettings: DiscoveryBleSettings = {
908
+ bleEnabled: !(disableBleCheckbox?.checked ?? false),
909
+ bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))),
910
+ bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8))),
911
+ }
912
+ setDiscoveryBleSettings(bleSettings)
913
+
914
+ showBusyUi()
915
+ btn.disabled = true
916
+ btn.textContent = '🔍 Discovering...'
917
+ setPhase(bleSettings.bleEnabled ? 'Scanning BLE...' : 'Skipping BLE scan...')
918
+ renderProgress()
919
+ status.classList.remove('error')
920
+
921
+ const devicesFoundDisplay = document.getElementById('discoverDevicesFound')
922
+ if (devicesFoundDisplay) {
923
+ devicesFoundDisplay.style.display = 'none'
924
+ devicesFoundDisplay.classList.remove('discovery-scanning-pulse')
925
+ }
926
+
927
+ if (cancelBtn) {
928
+ cancelBtn.style.display = 'inline-block'
929
+ cancelBtn.disabled = false
930
+ cancelBtn.onclick = () => {
931
+ cancelled = true
932
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
933
+ status.textContent = `Discovery cancelled (${totalSeconds}s total)`
934
+ if (phaseProgress) {
935
+ phaseProgress.style.display = 'none'
936
+ }
937
+ if (devicesFoundDisplay) {
938
+ devicesFoundDisplay.style.display = 'none'
939
+ devicesFoundDisplay.classList.remove('discovery-scanning-pulse')
940
+ }
941
+ cancelBtn.style.display = 'none'
942
+ btn.disabled = false
943
+ btn.textContent = '🔍 Discover Devices'
944
+ hideBusyUi()
945
+ }
946
+ }
947
+
948
+ if (bleSettings.bleEnabled) {
949
+ const bleDevicesRaw = await apiDiscoverDevices('ble', bleSettings)
950
+ if (cancelled) {
951
+ return
952
+ }
953
+
954
+ discoveredDevices = dedupeById(bleDevicesRaw)
955
+ uiLog.info('BLE discover response:', bleDevicesRaw)
956
+
957
+ // Update real-time device counter
958
+ if (devicesFoundDisplay && bleDevicesRaw.length > 0) {
959
+ devicesFoundDisplay.style.display = 'inline'
960
+ devicesFoundDisplay.classList.add('discovery-scanning-pulse')
961
+ devicesFoundDisplay.textContent = `📊 ${bleDevicesRaw.length} device(s) found (scanning...)`
962
+ }
963
+ } else {
964
+ discoveredDevices = []
965
+ uiLog.info('BLE discovery skipped by user setting')
966
+ }
967
+
968
+ if (!autoAddAll && discoveredDevices.length > 0) {
969
+ await ensureDiscoveryControls()
970
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
971
+ status.textContent = `Showing ${discoveredDevices.length} device(s) from BLE, fetching OpenAPI...`
972
+ }
973
+
974
+ setPhase('Fetching OpenAPI...')
975
+ renderProgress()
976
+
977
+ try {
978
+ const openApiDevicesRaw = await apiDiscoverDevices('openapi')
979
+ if (cancelled) {
980
+ return
981
+ }
982
+
983
+ uiLog.info('OpenAPI discover response:', openApiDevicesRaw)
984
+ discoveredDevices = mergeDiscoveredDevices(discoveredDevices, openApiDevicesRaw)
985
+
986
+ // Update device counter with merged count
987
+ if (devicesFoundDisplay && discoveredDevices.length > 0) {
988
+ devicesFoundDisplay.textContent = `📊 ${discoveredDevices.length} device(s) found (complete)`
989
+ }
990
+ } catch (openApiError) {
991
+ uiLog.warn('OpenAPI phase failed during discovery:', openApiError)
992
+ // Keep BLE results if available
993
+ if (!discoveredDevices.length) {
994
+ throw openApiError
995
+ }
996
+ if (devicesFoundDisplay) {
997
+ devicesFoundDisplay.classList.remove('discovery-scanning-pulse')
998
+ }
999
+ }
1000
+
1001
+ setPhase('Complete')
1002
+ renderProgress()
1003
+ uiLog.info('Final merged discover response:', discoveredDevices)
1004
+
1005
+ if (!discoveredDevices.length) {
1006
+ status.textContent = 'No devices found in your SwitchBot account'
1007
+ toastInfo('No devices found in your SwitchBot account')
1008
+ list.style.display = 'none'
1009
+ if (devicesFoundDisplay) {
1010
+ devicesFoundDisplay.style.display = 'none'
1011
+ devicesFoundDisplay.classList.remove('discovery-scanning-pulse')
1012
+ }
1013
+ clearDiscoveryCache()
1014
+ updateLastScannedStatus()
1015
+ return
1016
+ }
1017
+
1018
+ // If auto-add is enabled, add all devices immediately using bulk endpoint
1019
+ if (autoAddAll) {
1020
+ status.textContent = `Auto-adding ${discoveredDevices.length} device(s)...`
1021
+
1022
+ try {
1023
+ const bulkResult = await addDevicesInBulk(
1024
+ discoveredDevices.map(d => ({
1025
+ deviceId: d.id,
1026
+ name: d.name,
1027
+ type: d.type,
1028
+ rssi: d.rssi,
1029
+ address: d.address,
1030
+ model: d.model,
1031
+ })),
1032
+ )
1033
+ uiLog.info('Bulk add response:', bulkResult)
1034
+
1035
+ if (!bulkResult || bulkResult.success === false) {
1036
+ throw new Error(bulkResult?.data?.message || 'Bulk add failed')
1037
+ }
1038
+
1039
+ const addedCount
1040
+ = bulkResult?.addedCount
1041
+ ?? bulkResult?.data?.addedCount
1042
+ ?? 0
1043
+ const skippedCount
1044
+ = bulkResult?.skippedCount
1045
+ ?? bulkResult?.data?.skippedCount
1046
+ ?? 0
1047
+ status.textContent = `✓ Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
1048
+ if (addedCount > 0) {
1049
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`)
1050
+ } else if (skippedCount > 0) {
1051
+ toastWarning(`No new devices were added (${skippedCount} skipped)`)
1052
+ }
1053
+ status.classList.remove('error')
1054
+ list.style.display = 'none'
247
1055
 
248
- // Initial render with saved preferences
249
- await updateDiscoveryView(devices, preferences)
1056
+ // Sync parent Homebridge form cache and auto-save to prevent cache overwrite
1057
+ // Run sync when discovery returned devices (even if backend response shape changes)
1058
+ if (discoveredDevices.length > 0) {
1059
+ const synced = await syncParentPluginConfigFromDisk(true)
1060
+ status.textContent += synced
1061
+ ? ' - Config saved automatically.'
1062
+ : ' - Warning: config may not persist until you close/reopen settings.'
1063
+
1064
+ if (synced) {
1065
+ toastSuccess('Configuration synced and saved automatically')
1066
+ } else {
1067
+ toastWarning('Configuration sync failed; close and reopen settings before Save')
1068
+ }
1069
+ }
1070
+ } catch (e) {
1071
+ uiLog.error('Bulk add error:', e)
1072
+ status.textContent = `✗ Error: ${e instanceof Error ? e.message : 'Failed to add devices'}`
1073
+ status.classList.add('error')
1074
+ toastError(e instanceof Error ? e.message : 'Failed to add devices')
1075
+ }
1076
+
1077
+ // Refresh the configured devices list
1078
+ await loadConfiguredDevices()
1079
+ return
1080
+ }
1081
+
1082
+ await ensureDiscoveryControls()
1083
+ await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds)
1084
+ setDiscoveryCache(discoveredDevices)
1085
+ updateLastScannedStatus()
250
1086
  } catch (e) {
1087
+ if (cancelled) {
1088
+ return
1089
+ }
251
1090
  uiLog.error('Discovery error:', e)
252
1091
  status.textContent = `Error: ${e instanceof Error ? e.message : 'Discovery failed'}`
253
1092
  status.classList.add('error')
254
1093
  toastError(e instanceof Error ? e.message : 'Discovery failed')
255
1094
  list.style.display = 'none'
256
1095
  } finally {
1096
+ clearInterval(progressTimer)
257
1097
  hideBusyUi()
1098
+ if (phaseProgress) {
1099
+ phaseProgress.style.display = 'none'
1100
+ }
1101
+ if (phaseFill) {
1102
+ phaseFill.style.width = '0%'
1103
+ }
1104
+ if (phaseLabel) {
1105
+ phaseLabel.textContent = ''
1106
+ }
1107
+ const devicesFoundDisplay = document.getElementById('discoverDevicesFound')
1108
+ if (devicesFoundDisplay) {
1109
+ devicesFoundDisplay.style.display = 'none'
1110
+ devicesFoundDisplay.classList.remove('discovery-scanning-pulse')
1111
+ }
258
1112
  btn.disabled = false
259
1113
  btn.textContent = '🔍 Discover Devices'
1114
+ if (cancelBtn) {
1115
+ cancelBtn.disabled = false
1116
+ cancelBtn.style.display = 'none'
1117
+ cancelBtn.onclick = null
1118
+ }
260
1119
  }
261
1120
  }
262
1121
 
@@ -265,43 +1124,362 @@ export async function discoverDevices(): Promise<void> {
265
1124
  */
266
1125
  async function updateDiscoveryView(
267
1126
  allDevices: any[],
268
- preferences: {
269
- connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir'
270
- sortBy: 'name' | 'signal' | 'type' | 'connection'
271
- searchQuery: string
272
- },
273
- ): Promise<void> {
274
- // Apply filters and sort
275
- const filtered = filterDevices(allDevices, preferences.connectionType, preferences.searchQuery)
276
- const sorted = sortDevices(filtered, preferences.sortBy)
277
-
278
- // Build configured device lookup for duplicate detection
279
- const configured = await fetchDevices()
280
- const configuredIds = new Set((configured || []).map(d => normalizeId(d?.id || d?.deviceId)))
281
-
282
- // Render updated list
283
- const ul = await renderDiscoveredDevices(sorted, {
284
- configuredIds,
285
- })
286
-
1127
+ preferences: any,
1128
+ groupBy: any,
1129
+ hideAdded: any,
1130
+ selectedIds: Set<string>,
1131
+ ) {
1132
+ // Compute visibleDevices based on filters and preferences
1133
+ const visibleDevices = allDevices
1134
+ .filter((d) => {
1135
+ // Hide already added devices if hideAdded is true
1136
+ if (hideAdded && d.added) {
1137
+ return false
1138
+ }
1139
+ // Additional filtering logic can be added here based on preferences
1140
+ return true
1141
+ })
1142
+ // Optionally sort devices if needed (sortDevices is imported but not used)
1143
+ // .sort((a, b) => a.name.localeCompare(b.name))
1144
+
1145
+ // Set of already added device IDs
1146
+ const configuredIds = new Set(
1147
+ allDevices.filter(d => d.added).map(d => normalizeId(d.id)),
1148
+ )
1149
+ // Batch import controls and selection logic are handled later in the file. Removed broken/duplicated code.
1150
+
1151
+ const getConnectionGroup = (device: any): string => {
1152
+ if (device?.isIR) {
1153
+ return 'IR'
1154
+ }
1155
+ const connectionType = String(device?.connectionType || '').toLowerCase()
1156
+ if (connectionType.includes('both')) {
1157
+ return 'Both'
1158
+ }
1159
+ if (connectionType.includes('ble')) {
1160
+ return 'BLE'
1161
+ }
1162
+ if (connectionType.includes('api')) {
1163
+ return 'OpenAPI'
1164
+ }
1165
+ return 'Unknown'
1166
+ }
1167
+
1168
+ const getHubGroup = (device: any): string => {
1169
+ const hub = String(device?.hubDeviceId || '').trim()
1170
+ return hub ? `Hub ${hub}` : 'No Hub'
1171
+ }
1172
+
1173
+ const getTypeGroup = (device: any): string => {
1174
+ const type = String(device?.type || '').trim()
1175
+ return type || 'Unknown Type'
1176
+ }
1177
+
1178
+ const groupedDevices = new Map<string, any[]>()
1179
+ for (const d of visibleDevices) {
1180
+ let group = getConnectionGroup(d)
1181
+ if (groupBy === 'hub') {
1182
+ group = getHubGroup(d)
1183
+ } else if (groupBy === 'type') {
1184
+ group = getTypeGroup(d)
1185
+ }
1186
+ const groupDevices = groupedDevices.get(group) || []
1187
+ groupDevices.push(d)
1188
+ groupedDevices.set(group, groupDevices)
1189
+ }
1190
+
1191
+ let orderedGroups: string[] = []
1192
+ if (groupBy === 'hub') {
1193
+ const hubGroups = [...groupedDevices.keys()].filter(group => group !== 'No Hub').sort((a, b) => a.localeCompare(b))
1194
+ orderedGroups = groupedDevices.has('No Hub') ? [...hubGroups, 'No Hub'] : hubGroups
1195
+ } else if (groupBy === 'type') {
1196
+ const typeGroups = [...groupedDevices.keys()].filter(group => group !== 'Unknown Type').sort((a, b) => a.localeCompare(b))
1197
+ orderedGroups = groupedDevices.has('Unknown Type') ? [...typeGroups, 'Unknown Type'] : typeGroups
1198
+ } else {
1199
+ const groupOrder = ['Both', 'BLE', 'OpenAPI', 'IR', 'Unknown']
1200
+ orderedGroups = groupOrder.filter(group => groupedDevices.has(group))
1201
+ }
1202
+
1203
+ const container = document.createElement('div')
1204
+ container.id = 'discoveredDevices'
1205
+ container.className = 'discovery-groups'
1206
+
1207
+ if (!visibleDevices.length) {
1208
+ const empty = document.createElement('div')
1209
+ empty.className = 'discovery-group-empty'
1210
+ empty.textContent = hideAdded
1211
+ ? 'No devices match current filters (or all are already added).'
1212
+ : 'No devices match current filters.'
1213
+ container.appendChild(empty)
1214
+ } else {
1215
+ for (const groupName of orderedGroups) {
1216
+ const groupItems = groupedDevices.get(groupName)
1217
+ if (!groupItems?.length) {
1218
+ continue
1219
+ }
1220
+
1221
+ const groupSection = document.createElement('section')
1222
+ groupSection.className = 'discovery-group'
1223
+
1224
+ const groupStorageKey = `${groupBy}:${groupName}`
1225
+ let expanded = isDiscoveryGroupExpanded(groupStorageKey)
1226
+
1227
+ const groupHeader = document.createElement('button')
1228
+ groupHeader.className = 'discovery-group-header-btn'
1229
+ groupHeader.type = 'button'
1230
+
1231
+ const setGroupHeaderText = () => {
1232
+ const marker = expanded ? '▾' : '▸'
1233
+ groupHeader.textContent = `${marker} ${groupName} (${groupItems.length})`
1234
+ }
1235
+ setGroupHeaderText()
1236
+ groupSection.appendChild(groupHeader)
1237
+
1238
+ const groupList = await renderDiscoveredDevices(groupItems, {
1239
+ configuredIds,
1240
+ selectedIds,
1241
+ onToggleSelect: (device, selected) => {
1242
+ const id = normalizeId(device.id)
1243
+ if (selected) {
1244
+ selectedIds.add(id)
1245
+ } else {
1246
+ selectedIds.delete(id)
1247
+ }
1248
+ // Update Add Selected button state
1249
+ const btn = document.querySelector('button')?.parentElement?.querySelector('button')
1250
+ if (btn && btn.textContent?.includes('Add Selected')) {
1251
+ (btn as HTMLButtonElement).disabled = selectedIds.size === 0
1252
+ }
1253
+ },
1254
+ })
1255
+ if (!expanded) {
1256
+ groupList.style.display = 'none'
1257
+ }
1258
+
1259
+ groupHeader.onclick = () => {
1260
+ expanded = !expanded
1261
+ setDiscoveryGroupExpanded(groupStorageKey, expanded)
1262
+ setGroupHeaderText()
1263
+ groupList.style.display = expanded ? 'grid' : 'none'
1264
+ }
1265
+
1266
+ groupSection.appendChild(groupList)
1267
+ container.appendChild(groupSection)
1268
+ }
1269
+ }
1270
+
287
1271
  // Replace or append the rendered list
288
1272
  const existingList = document.getElementById('discoveredDevices')
289
1273
  if (existingList && existingList.parentNode) {
290
- existingList.replaceWith(ul)
1274
+ existingList.replaceWith(container)
291
1275
  } else {
292
1276
  // Fallback: append to discovery list container
293
1277
  const listContainer = document.getElementById('discoveredList')
294
1278
  if (listContainer) {
295
- listContainer.appendChild(ul)
1279
+ listContainer.appendChild(container)
1280
+ }
1281
+ }
1282
+
1283
+ // --- Batch Import Controls ---
1284
+ // Remove any duplicate 'Add Selected' buttons from legacy UI
1285
+ // Robust NodeList iteration: classic for loop to avoid formatter and transpiler issues
1286
+ const legacyAddSelectedBtns: HTMLButtonElement[] = []
1287
+ const allBtns = document.querySelectorAll('button')
1288
+ for (let i = 0; i < allBtns.length; i++) {
1289
+ const btn = allBtns[i]
1290
+ if (btn.textContent && btn.textContent.trim() === 'Add Selected') {
1291
+ legacyAddSelectedBtns.push(btn)
1292
+ }
1293
+ }
1294
+ for (let i = 0; i < legacyAddSelectedBtns.length; i++) {
1295
+ legacyAddSelectedBtns[i].remove()
1296
+ }
1297
+
1298
+ // Listen for selection changes to update batch button states
1299
+ function updateBatchButtonStates() {
1300
+ const addSelectedBtn = document.getElementById('addSelectedBtn') as HTMLButtonElement | null
1301
+ const enableSelectedBtn = document.querySelector('button')?.parentElement?.querySelector('button[aria-label="Enable Selected"]') as HTMLButtonElement | null
1302
+ const disableSelectedBtn = document.querySelector('button')?.parentElement?.querySelector('button[aria-label="Disable Selected"]') as HTMLButtonElement | null
1303
+ const hasSelection = selectedIds.size > 0
1304
+ if (addSelectedBtn) addSelectedBtn.disabled = !hasSelection
1305
+ if (enableSelectedBtn) enableSelectedBtn.disabled = !hasSelection
1306
+ if (disableSelectedBtn) disableSelectedBtn.disabled = !hasSelection
1307
+ }
1308
+ window.removeEventListener('discovery-selection-changed', updateBatchButtonStates)
1309
+ window.addEventListener('discovery-selection-changed', updateBatchButtonStates)
1310
+ // Add 'Add Selected to Config' button if not present, and align all red action buttons in a row
1311
+ let batchControls = document.getElementById('batchImportControls')
1312
+ if (!batchControls) {
1313
+ batchControls = document.createElement('div')
1314
+ batchControls.id = 'batchImportControls'
1315
+ batchControls.style.display = 'flex'
1316
+ batchControls.style.flexWrap = 'wrap'
1317
+ batchControls.style.alignItems = 'center'
1318
+ batchControls.style.gap = '18px'
1319
+ batchControls.style.margin = '8px 0 18px 0'
1320
+ batchControls.style.width = '100%'
1321
+
1322
+ // Button container for horizontal alignment
1323
+ const buttonRow = document.createElement('div')
1324
+ buttonRow.style.display = 'flex'
1325
+ buttonRow.style.flexWrap = 'nowrap'
1326
+ buttonRow.style.alignItems = 'center'
1327
+ buttonRow.style.justifyContent = 'center'
1328
+ buttonRow.style.gap = '16px'
1329
+ buttonRow.style.width = '100%'
1330
+
1331
+ // Add Selected to Config button
1332
+ const addSelectedBtn = document.createElement('button')
1333
+ addSelectedBtn.id = 'addSelectedBtn'
1334
+ addSelectedBtn.textContent = 'Add Selected to Config'
1335
+ addSelectedBtn.disabled = selectedIds.size === 0
1336
+ addSelectedBtn.style.fontWeight = '600'
1337
+ // Shared style for all red action buttons
1338
+ const redButtonStyle = {
1339
+ width: '220px',
1340
+ padding: '12px 0',
1341
+ fontSize: '17px',
1342
+ marginBottom: '0',
1343
+ boxShadow: '0 2px 8px 0 rgba(220,38,38,0.10)',
1344
+ borderRadius: '8px',
1345
+ }
1346
+ Object.assign(addSelectedBtn.style, redButtonStyle)
1347
+ addSelectedBtn.style.background = '#ef4444'
1348
+ addSelectedBtn.style.color = 'white'
1349
+ addSelectedBtn.style.transition = 'background 0.2s, box-shadow 0.2s'
1350
+ addSelectedBtn.onmouseenter = function () {
1351
+ addSelectedBtn.style.background = '#dc2626'
1352
+ }
1353
+ addSelectedBtn.onmouseleave = function () {
1354
+ addSelectedBtn.style.background = '#ef4444'
1355
+ }
1356
+ addSelectedBtn.onclick = async () => {
1357
+ if (!selectedIds.size) {
1358
+ return
1359
+ }
1360
+ addSelectedBtn.disabled = true
1361
+ addSelectedBtn.textContent = 'Adding...'
1362
+ try {
1363
+ const selectedDevices = visibleDevices.filter(d => selectedIds.has(normalizeId(d.id)))
1364
+ const bulkResult = await addDevicesInBulk(selectedDevices.map(d => ({
1365
+ deviceId: d.id,
1366
+ name: d.name,
1367
+ type: d.type,
1368
+ rssi: d.rssi,
1369
+ address: d.address,
1370
+ model: d.model,
1371
+ })))
1372
+ uiLog.info('Batch add response:', bulkResult)
1373
+ if (!bulkResult || bulkResult.success === false) {
1374
+ throw new Error(bulkResult?.data?.message || 'Batch add failed')
1375
+ }
1376
+ const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0
1377
+ const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0
1378
+ toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`)
1379
+ await loadConfiguredDevices()
1380
+ selectedIds.clear()
1381
+ addSelectedBtn.disabled = true
1382
+ addSelectedBtn.textContent = 'Add Selected to Config'
1383
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds)
1384
+ } catch (e) {
1385
+ uiLog.error('Batch add error:', e)
1386
+ toastError(e instanceof Error ? e.message : 'Failed to add devices')
1387
+ addSelectedBtn.disabled = false
1388
+ addSelectedBtn.textContent = 'Add Selected to Config'
1389
+ }
1390
+ }
1391
+
1392
+ // Enable Selected button
1393
+ const enableSelectedBtn = document.createElement('button')
1394
+ enableSelectedBtn.textContent = 'Enable Selected'
1395
+ enableSelectedBtn.style.fontWeight = '600'
1396
+ Object.assign(enableSelectedBtn.style, redButtonStyle)
1397
+ enableSelectedBtn.style.background = '#ef4444'
1398
+ enableSelectedBtn.style.color = 'white'
1399
+ enableSelectedBtn.style.transition = 'background 0.2s, box-shadow 0.2s'
1400
+ enableSelectedBtn.onmouseenter = function () {
1401
+ enableSelectedBtn.style.background = '#dc2626'
1402
+ }
1403
+ enableSelectedBtn.onmouseleave = function () {
1404
+ enableSelectedBtn.style.background = '#ef4444'
1405
+ }
1406
+ enableSelectedBtn.disabled = selectedIds.size === 0
1407
+ enableSelectedBtn.onclick = async () => {
1408
+ if (!selectedIds.size) {
1409
+ return
1410
+ }
1411
+ enableSelectedBtn.disabled = true
1412
+ try {
1413
+ showBusyUi()
1414
+ await batchSetDeviceEnabled(selectedIds, true)
1415
+ toastSuccess('Selected devices enabled')
1416
+ await loadConfiguredDevices()
1417
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds)
1418
+ } catch (e) {
1419
+ uiLog.error('Batch enable error:', e)
1420
+ toastError(e instanceof Error ? e.message : 'Failed to enable devices')
1421
+ } finally {
1422
+ hideBusyUi()
1423
+ enableSelectedBtn.disabled = false
1424
+ }
1425
+ }
1426
+
1427
+ // Disable Selected button
1428
+ const disableSelectedBtn = document.createElement('button')
1429
+ disableSelectedBtn.textContent = 'Disable Selected'
1430
+ disableSelectedBtn.style.fontWeight = '600'
1431
+ Object.assign(disableSelectedBtn.style, redButtonStyle)
1432
+ disableSelectedBtn.style.background = '#ef4444'
1433
+ disableSelectedBtn.style.color = 'white'
1434
+ disableSelectedBtn.style.transition = 'background 0.2s, box-shadow 0.2s'
1435
+ disableSelectedBtn.onmouseenter = function () {
1436
+ disableSelectedBtn.style.background = '#dc2626'
1437
+ }
1438
+ disableSelectedBtn.onmouseleave = function () {
1439
+ disableSelectedBtn.style.background = '#ef4444'
1440
+ }
1441
+ disableSelectedBtn.disabled = selectedIds.size === 0
1442
+ disableSelectedBtn.onclick = async () => {
1443
+ if (!selectedIds.size) {
1444
+ return
1445
+ }
1446
+ disableSelectedBtn.disabled = true
1447
+ try {
1448
+ showBusyUi()
1449
+ await batchSetDeviceEnabled(selectedIds, false)
1450
+ toastSuccess('Selected devices disabled')
1451
+ await loadConfiguredDevices()
1452
+ await updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds)
1453
+ } catch (e) {
1454
+ uiLog.error('Batch disable error:', e)
1455
+ toastError(e instanceof Error ? e.message : 'Failed to disable devices')
1456
+ } finally {
1457
+ hideBusyUi()
1458
+ disableSelectedBtn.disabled = false
1459
+ }
1460
+ }
1461
+
1462
+ buttonRow.appendChild(addSelectedBtn)
1463
+ buttonRow.appendChild(enableSelectedBtn)
1464
+ buttonRow.appendChild(disableSelectedBtn)
1465
+ batchControls.appendChild(buttonRow)
1466
+ const listContainer = document.getElementById('discoveredList')
1467
+ if (listContainer) {
1468
+ listContainer.insertBefore(batchControls, listContainer.firstChild)
1469
+ }
1470
+ } else {
1471
+ // Update button state if already present
1472
+ const addSelectedBtn = document.getElementById('addSelectedBtn') as HTMLButtonElement | null
1473
+ if (addSelectedBtn) {
1474
+ addSelectedBtn.disabled = selectedIds.size === 0
296
1475
  }
297
1476
  }
298
- ul.id = 'discoveredDevices'
299
1477
 
300
1478
  // Update status with count
301
1479
  const status = document.getElementById('discoverStatus')
302
1480
  if (status) {
303
1481
  const totalCount = allDevices.length
304
- const filteredCount = sorted.length
1482
+ const filteredCount = visibleDevices.length
305
1483
  status.textContent = filteredCount === totalCount
306
1484
  ? `Found ${totalCount} device(s)`
307
1485
  : `Showing ${filteredCount} of ${totalCount} device(s)`