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