@switchbot/homebridge-switchbot 5.0.0-beta.99 → 5.0.0

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