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

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 (287) hide show
  1. package/.changeset/config.json +14 -0
  2. package/.github/copilot-instructions.md +39 -0
  3. package/.github/scripts/normalize-changelog-header.cjs +86 -0
  4. package/.github/scripts/npm-version-script-esm-auto.js +201 -0
  5. package/.github/scripts/resolve-version-bump-esm.js +118 -0
  6. package/.github/workflows/ci.yml +4 -1
  7. package/.github/workflows/manual-e2e.yml +6 -3
  8. package/.github/workflows/release.yml +120 -22
  9. package/.github/workflows/stale.yml +2 -4
  10. package/.husky/pre-push +15 -0
  11. package/CHANGELOG.md +133 -137
  12. package/MIGRATION.md +16 -6
  13. package/README.md +84 -3
  14. package/TODO.md +263 -0
  15. package/config.schema.json +229 -36
  16. package/dist/SwitchBotHAPPlatform.d.ts +133 -0
  17. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
  18. package/dist/SwitchBotHAPPlatform.js +555 -0
  19. package/dist/SwitchBotHAPPlatform.js.map +1 -0
  20. package/dist/SwitchBotMatterPlatform.d.ts +141 -0
  21. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
  22. package/dist/SwitchBotMatterPlatform.js +536 -0
  23. package/dist/SwitchBotMatterPlatform.js.map +1 -0
  24. package/dist/device-types.d.ts +31 -0
  25. package/dist/device-types.d.ts.map +1 -0
  26. package/dist/device-types.js +246 -0
  27. package/dist/device-types.js.map +1 -0
  28. package/dist/deviceCommandMapper.d.ts +10 -0
  29. package/dist/deviceCommandMapper.d.ts.map +1 -0
  30. package/dist/deviceCommandMapper.js +319 -0
  31. package/dist/deviceCommandMapper.js.map +1 -0
  32. package/dist/deviceFactory.d.ts +3 -2
  33. package/dist/deviceFactory.d.ts.map +1 -1
  34. package/dist/deviceFactory.js +107 -29
  35. package/dist/deviceFactory.js.map +1 -1
  36. package/dist/devices/genericDevice.d.ts +59 -37
  37. package/dist/devices/genericDevice.d.ts.map +1 -1
  38. package/dist/devices/genericDevice.js +376 -78
  39. package/dist/devices/genericDevice.js.map +1 -1
  40. package/dist/errors.d.ts +38 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/errors.js +32 -0
  43. package/dist/errors.js.map +1 -0
  44. package/dist/homebridge-ui/device-types.js +246 -0
  45. package/dist/homebridge-ui/device-types.js.map +1 -0
  46. package/dist/homebridge-ui/deviceCommandMapper.js +319 -0
  47. package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -0
  48. package/dist/homebridge-ui/endpoints/config.d.ts +3 -0
  49. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -0
  50. package/dist/homebridge-ui/endpoints/config.js +90 -0
  51. package/dist/homebridge-ui/endpoints/config.js.map +1 -0
  52. package/dist/homebridge-ui/endpoints/devices.d.ts +6 -0
  53. package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -0
  54. package/dist/homebridge-ui/endpoints/devices.js +144 -0
  55. package/dist/homebridge-ui/endpoints/devices.js.map +1 -0
  56. package/dist/homebridge-ui/endpoints/discovery.d.ts +7 -0
  57. package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -0
  58. package/dist/homebridge-ui/endpoints/discovery.js +219 -0
  59. package/dist/homebridge-ui/endpoints/discovery.js.map +1 -0
  60. package/dist/homebridge-ui/errors.js +32 -0
  61. package/dist/homebridge-ui/errors.js.map +1 -0
  62. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +90 -0
  63. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -0
  64. package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js +144 -0
  65. package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js.map +1 -0
  66. package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js +219 -0
  67. package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js.map +1 -0
  68. package/dist/homebridge-ui/homebridge-ui/server.js +11 -0
  69. package/dist/homebridge-ui/homebridge-ui/server.js.map +1 -0
  70. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +108 -0
  71. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -0
  72. package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js +111 -0
  73. package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js.map +1 -0
  74. package/dist/homebridge-ui/homebridge-ui/utils/logger.js +17 -0
  75. package/dist/homebridge-ui/homebridge-ui/utils/logger.js.map +1 -0
  76. package/dist/homebridge-ui/public/css/styles.css +483 -0
  77. package/dist/homebridge-ui/public/index.html +197 -621
  78. package/dist/homebridge-ui/public/js/advanced-settings.d.ts +3 -0
  79. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -0
  80. package/dist/homebridge-ui/public/js/advanced-settings.js +95 -0
  81. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -0
  82. package/dist/homebridge-ui/public/js/advanced-settings.ts +94 -0
  83. package/dist/homebridge-ui/public/js/api.d.ts +67 -0
  84. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -0
  85. package/dist/homebridge-ui/public/js/api.js +308 -0
  86. package/dist/homebridge-ui/public/js/api.js.map +1 -0
  87. package/dist/homebridge-ui/public/js/api.ts +370 -0
  88. package/dist/homebridge-ui/public/js/app.d.ts +2 -0
  89. package/dist/homebridge-ui/public/js/app.d.ts.map +1 -0
  90. package/dist/homebridge-ui/public/js/app.js +3723 -0
  91. package/dist/homebridge-ui/public/js/app.js.map +7 -0
  92. package/dist/homebridge-ui/public/js/app.ts +22 -0
  93. package/dist/homebridge-ui/public/js/constants.d.ts +2 -0
  94. package/dist/homebridge-ui/public/js/constants.d.ts.map +1 -0
  95. package/dist/homebridge-ui/public/js/constants.js +2 -0
  96. package/dist/homebridge-ui/public/js/constants.js.map +1 -0
  97. package/dist/homebridge-ui/public/js/constants.ts +1 -0
  98. package/dist/homebridge-ui/public/js/credentials.d.ts +3 -0
  99. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -0
  100. package/dist/homebridge-ui/public/js/credentials.js +99 -0
  101. package/dist/homebridge-ui/public/js/credentials.js.map +1 -0
  102. package/dist/homebridge-ui/public/js/credentials.ts +105 -0
  103. package/dist/homebridge-ui/public/js/devices-delete.d.ts +3 -0
  104. package/dist/homebridge-ui/public/js/devices-delete.d.ts.map +1 -0
  105. package/dist/homebridge-ui/public/js/devices-delete.js +199 -0
  106. package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -0
  107. package/dist/homebridge-ui/public/js/devices-delete.ts +227 -0
  108. package/dist/homebridge-ui/public/js/devices.d.ts +9 -0
  109. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -0
  110. package/dist/homebridge-ui/public/js/devices.js +98 -0
  111. package/dist/homebridge-ui/public/js/devices.js.map +1 -0
  112. package/dist/homebridge-ui/public/js/devices.ts +106 -0
  113. package/dist/homebridge-ui/public/js/discovery.d.ts +9 -0
  114. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -0
  115. package/dist/homebridge-ui/public/js/discovery.js +1201 -0
  116. package/dist/homebridge-ui/public/js/discovery.js.map +1 -0
  117. package/dist/homebridge-ui/public/js/discovery.ts +1335 -0
  118. package/dist/homebridge-ui/public/js/logger.d.ts +7 -0
  119. package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -0
  120. package/dist/homebridge-ui/public/js/logger.js +17 -0
  121. package/dist/homebridge-ui/public/js/logger.js.map +1 -0
  122. package/dist/homebridge-ui/public/js/logger.ts +17 -0
  123. package/dist/homebridge-ui/public/js/modal.d.ts +5 -0
  124. package/dist/homebridge-ui/public/js/modal.d.ts.map +1 -0
  125. package/dist/homebridge-ui/public/js/modal.js +35 -0
  126. package/dist/homebridge-ui/public/js/modal.js.map +1 -0
  127. package/dist/homebridge-ui/public/js/modal.ts +35 -0
  128. package/dist/homebridge-ui/public/js/modals.d.ts +15 -0
  129. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -0
  130. package/dist/homebridge-ui/public/js/modals.js +675 -0
  131. package/dist/homebridge-ui/public/js/modals.js.map +1 -0
  132. package/dist/homebridge-ui/public/js/modals.ts +765 -0
  133. package/dist/homebridge-ui/public/js/render.d.ts +71 -0
  134. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -0
  135. package/dist/homebridge-ui/public/js/render.js +948 -0
  136. package/dist/homebridge-ui/public/js/render.js.map +1 -0
  137. package/dist/homebridge-ui/public/js/render.ts +1070 -0
  138. package/dist/homebridge-ui/public/js/toast.d.ts +6 -0
  139. package/dist/homebridge-ui/public/js/toast.d.ts.map +1 -0
  140. package/dist/homebridge-ui/public/js/toast.js +37 -0
  141. package/dist/homebridge-ui/public/js/toast.js.map +1 -0
  142. package/dist/homebridge-ui/public/js/toast.ts +43 -0
  143. package/dist/homebridge-ui/public/js/types.d.ts +23 -0
  144. package/dist/homebridge-ui/public/js/types.d.ts.map +1 -0
  145. package/dist/homebridge-ui/public/js/types.js +2 -0
  146. package/dist/homebridge-ui/public/js/types.js.map +1 -0
  147. package/dist/homebridge-ui/public/js/types.ts +26 -0
  148. package/dist/homebridge-ui/server.d.ts +1 -3
  149. package/dist/homebridge-ui/server.d.ts.map +1 -1
  150. package/dist/homebridge-ui/server.js +8 -471
  151. package/dist/homebridge-ui/server.js.map +1 -1
  152. package/dist/homebridge-ui/settings.js +8 -0
  153. package/dist/homebridge-ui/settings.js.map +1 -0
  154. package/dist/homebridge-ui/switchbotClient.js +247 -0
  155. package/dist/homebridge-ui/switchbotClient.js.map +1 -0
  156. package/dist/homebridge-ui/utils/config-parser.d.ts +39 -0
  157. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -0
  158. package/dist/homebridge-ui/utils/config-parser.js +108 -0
  159. package/dist/homebridge-ui/utils/config-parser.js.map +1 -0
  160. package/dist/homebridge-ui/utils/device-migration.d.ts +35 -0
  161. package/dist/homebridge-ui/utils/device-migration.d.ts.map +1 -0
  162. package/dist/homebridge-ui/utils/device-migration.js +111 -0
  163. package/dist/homebridge-ui/utils/device-migration.js.map +1 -0
  164. package/dist/homebridge-ui/utils/logger.d.ts +7 -0
  165. package/dist/homebridge-ui/utils/logger.d.ts.map +1 -0
  166. package/dist/homebridge-ui/utils/logger.js +17 -0
  167. package/dist/homebridge-ui/utils/logger.js.map +1 -0
  168. package/dist/index.d.ts +10 -0
  169. package/dist/index.d.ts.map +1 -1
  170. package/dist/index.js +12 -2
  171. package/dist/index.js.map +1 -1
  172. package/dist/settings.d.ts +1 -0
  173. package/dist/settings.d.ts.map +1 -1
  174. package/dist/settings.js +1 -0
  175. package/dist/settings.js.map +1 -1
  176. package/dist/switchbotClient.d.ts +12 -10
  177. package/dist/switchbotClient.d.ts.map +1 -1
  178. package/dist/switchbotClient.js +156 -103
  179. package/dist/switchbotClient.js.map +1 -1
  180. package/dist/utils.d.ts +76 -1
  181. package/dist/utils.d.ts.map +1 -1
  182. package/dist/utils.js +1121 -4
  183. package/dist/utils.js.map +1 -1
  184. package/docs/assets/highlight.css +16 -2
  185. package/docs/assets/icons.js +1 -1
  186. package/docs/assets/icons.svg +1 -1
  187. package/docs/assets/main.js +3 -3
  188. package/docs/index.html +82 -5
  189. package/docs/variables/default.html +3 -1
  190. package/eslint.config.js +9 -5
  191. package/nodemon.json +2 -2
  192. package/package.json +35 -22
  193. package/scripts/build-ui.js +37 -0
  194. package/scripts/free-dev-ports.mjs +105 -0
  195. package/scripts/generate-matter-maps.js +34 -17
  196. package/scripts/sync-device-types.mjs +31 -0
  197. package/src/SwitchBotHAPPlatform.ts +558 -0
  198. package/src/SwitchBotMatterPlatform.ts +538 -0
  199. package/src/device-types.js +246 -0
  200. package/src/device-types.js.map +1 -0
  201. package/src/device-types.ts +261 -0
  202. package/src/deviceCommandMapper.js +319 -0
  203. package/src/deviceCommandMapper.js.map +1 -0
  204. package/src/deviceCommandMapper.ts +333 -0
  205. package/src/deviceFactory.ts +125 -45
  206. package/src/devices/genericDevice.ts +411 -69
  207. package/src/errors.js +32 -0
  208. package/src/errors.js.map +1 -0
  209. package/src/errors.ts +35 -0
  210. package/src/homebridge-ui/endpoints/config.ts +110 -0
  211. package/src/homebridge-ui/endpoints/devices.ts +153 -0
  212. package/src/homebridge-ui/endpoints/discovery.ts +240 -0
  213. package/src/homebridge-ui/public/css/styles.css +483 -0
  214. package/src/homebridge-ui/public/index.html +197 -621
  215. package/src/homebridge-ui/public/js/advanced-settings.ts +94 -0
  216. package/src/homebridge-ui/public/js/api.ts +370 -0
  217. package/src/homebridge-ui/public/js/app.ts +22 -0
  218. package/src/homebridge-ui/public/js/constants.ts +1 -0
  219. package/src/homebridge-ui/public/js/credentials.ts +105 -0
  220. package/src/homebridge-ui/public/js/devices-delete.ts +227 -0
  221. package/src/homebridge-ui/public/js/devices.ts +106 -0
  222. package/src/homebridge-ui/public/js/discovery.ts +1335 -0
  223. package/src/homebridge-ui/public/js/logger.ts +17 -0
  224. package/src/homebridge-ui/public/js/modal.ts +35 -0
  225. package/src/homebridge-ui/public/js/modals.ts +765 -0
  226. package/src/homebridge-ui/public/js/render.ts +1070 -0
  227. package/src/homebridge-ui/public/js/toast.ts +43 -0
  228. package/src/homebridge-ui/public/js/types.ts +26 -0
  229. package/src/homebridge-ui/server.ts +9 -554
  230. package/src/homebridge-ui/utils/config-parser.ts +125 -0
  231. package/src/homebridge-ui/utils/device-migration.ts +144 -0
  232. package/src/homebridge-ui/utils/logger.ts +17 -0
  233. package/src/index.ts +12 -2
  234. package/src/settings.js +8 -0
  235. package/src/settings.js.map +1 -0
  236. package/src/settings.ts +2 -0
  237. package/src/switchbotClient.js +247 -0
  238. package/src/switchbotClient.js.map +1 -0
  239. package/src/switchbotClient.ts +177 -114
  240. package/src/utils.ts +1133 -5
  241. package/test/client/switchbot-client-debounce.spec.ts +35 -0
  242. package/test/client/switchbot-client-openapi.spec.ts +19 -0
  243. package/test/client/switchbotClient.spec.ts +64 -0
  244. package/test/device/device-mapping.spec.ts +23 -0
  245. package/test/device/deviceBase.spec.ts +26 -0
  246. package/test/device/deviceFactory-edge.spec.ts +15 -0
  247. package/test/device/deviceFactory.spec.ts +33 -0
  248. package/test/device/fan-swing.spec.ts +34 -0
  249. package/test/device/genericDevice-blepoll.spec.ts +47 -0
  250. package/test/device/irdevice.spec.ts +9 -0
  251. package/test/device/lock-users.spec.ts +35 -0
  252. package/test/device/matter-descriptors.spec.ts +22 -0
  253. package/test/device/matter-device-state.spec.ts +37 -0
  254. package/test/e2e/run-e2e.spec.ts +18 -19
  255. package/test/errors/errors.spec.ts +10 -0
  256. package/test/helpers/matter-harness.ts +20 -9
  257. package/test/homebridge-ui/server.spec.ts +9 -0
  258. package/test/platform/accessory-restore.spec.ts +37 -0
  259. package/test/platform/matter-childbridge.spec.ts +34 -0
  260. package/test/platform/matter-integration.spec.ts +33 -0
  261. package/test/platform/platform-edge.spec.ts +73 -0
  262. package/test/platform/platform.integration.spec.ts +34 -0
  263. package/test/utils/utils-extra.spec.ts +10 -0
  264. package/test/utils/utils.spec.ts +53 -0
  265. package/todo/TODO.md +80 -0
  266. package/tsconfig.ui.json +11 -0
  267. package/.github/npm-version-script-esm.js +0 -97
  268. package/.github/workflows/beta-release.yml +0 -52
  269. package/dist/platform.d.ts +0 -35
  270. package/dist/platform.d.ts.map +0 -1
  271. package/dist/platform.js +0 -945
  272. package/dist/platform.js.map +0 -1
  273. package/src/platform.ts +0 -963
  274. package/test/accessory-restore.spec.ts +0 -73
  275. package/test/device-mapping.spec.ts +0 -37
  276. package/test/deviceFactory.spec.ts +0 -18
  277. package/test/fan-swing.spec.ts +0 -29
  278. package/test/lock-users.spec.ts +0 -44
  279. package/test/matter-childbridge.spec.ts +0 -55
  280. package/test/matter-descriptors.spec.ts +0 -97
  281. package/test/matter-device-state.spec.ts +0 -101
  282. package/test/matter-integration.spec.ts +0 -70
  283. package/test/platform.integration.spec.ts +0 -55
  284. package/test/switchbot-client-debounce.spec.ts +0 -131
  285. package/test/switchbot-client-openapi.spec.ts +0 -56
  286. package/test/switchbotClient.spec.ts +0 -10
  287. package/test/utils.spec.ts +0 -20
@@ -0,0 +1,1070 @@
1
+ // Move regexes to module scope to avoid re-compilation on every call
2
+ // import type { DEVICE_TYPES } from './constants.js' // Removed unused import
3
+
4
+ const SPACES_REGEX = /\s/g
5
+ const CAMELCASE_REGEX = /([A-Z])/g
6
+ const FIRST_CHAR_REGEX = /^./
7
+
8
+ /**
9
+ * Get RSSI signal quality level and color based on dBm value
10
+ * @param rssi Signal strength in dBm (typically -30 to -90)
11
+ * @returns Object with quality level, color, and description
12
+ */
13
+ export function getRssiSignalQuality(rssi: number | undefined): {
14
+ level: 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
15
+ color: string
16
+ bgColor: string
17
+ description: string
18
+ bars: number
19
+ } {
20
+ if (!rssi || rssi === 0) {
21
+ return {
22
+ level: 'unknown',
23
+ color: '#999',
24
+ bgColor: '#f5f5f5',
25
+ description: 'Signal strength unknown',
26
+ bars: 0,
27
+ }
28
+ }
29
+
30
+ const dbm = Math.floor(rssi)
31
+
32
+ if (dbm > -60) {
33
+ return {
34
+ level: 'excellent',
35
+ color: '#34a853',
36
+ bgColor: '#e8f5e9',
37
+ description: `Excellent (${dbm} dBm)`,
38
+ bars: 4,
39
+ }
40
+ } else if (dbm > -75) {
41
+ return {
42
+ level: 'good',
43
+ color: '#fbbc04',
44
+ bgColor: '#fffde7',
45
+ description: `Good (${dbm} dBm)`,
46
+ bars: 3,
47
+ }
48
+ } else if (dbm > -85) {
49
+ return {
50
+ level: 'fair',
51
+ color: '#ff9800',
52
+ bgColor: '#fff3e0',
53
+ description: `Fair (${dbm} dBm)`,
54
+ bars: 2,
55
+ }
56
+ } else {
57
+ return {
58
+ level: 'poor',
59
+ color: '#ea4335',
60
+ bgColor: '#ffebee',
61
+ description: `Poor (${dbm} dBm) - unreliable`,
62
+ bars: 1,
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Create visual signal strength indicator bars
69
+ * @param rssi Signal strength in dBm
70
+ * @returns HTML element showing filled bars
71
+ */
72
+ export function renderSignalBars(rssi: number | undefined): HTMLElement {
73
+ const quality = getRssiSignalQuality(rssi)
74
+
75
+ const container = document.createElement('span')
76
+ container.style.display = 'inline-flex'
77
+ container.style.gap = '2px'
78
+ container.style.alignItems = 'center'
79
+ container.style.marginLeft = '8px'
80
+ container.style.fontSize = '12px'
81
+
82
+ // Create 4 bars
83
+ for (let i = 1; i <= 4; i++) {
84
+ const bar = document.createElement('span')
85
+ bar.style.height = `${i * 3}px`
86
+ bar.style.width = '3px'
87
+ bar.style.borderRadius = '1px'
88
+ bar.style.border = `1px solid ${quality.color}`
89
+
90
+ if (i <= quality.bars) {
91
+ bar.style.backgroundColor = quality.color
92
+ } else {
93
+ bar.style.backgroundColor = 'transparent'
94
+ }
95
+
96
+ container.appendChild(bar)
97
+ }
98
+
99
+ // Add tooltip
100
+ container.title = quality.description
101
+
102
+ return container
103
+ }
104
+
105
+ /**
106
+ * Create signal quality badge with color
107
+ * @param rssi Signal strength in dBm
108
+ * @returns HTML element showing quality level
109
+ */
110
+ export function renderSignalQualityBadge(rssi: number | undefined): HTMLElement {
111
+ const quality = getRssiSignalQuality(rssi)
112
+
113
+ const badge = document.createElement('span')
114
+ badge.textContent = quality.level.charAt(0).toUpperCase() + quality.level.slice(1)
115
+ badge.style.cssText = `
116
+ background: ${quality.color};
117
+ color: white;
118
+ padding: 2px 6px;
119
+ border-radius: 3px;
120
+ font-size: 10px;
121
+ font-weight: 600;
122
+ `
123
+ badge.title = quality.description
124
+
125
+ return badge
126
+ }
127
+
128
+ export function renderBadge(text: string, style: string): HTMLElement {
129
+ const badge = document.createElement('span')
130
+ badge.textContent = text
131
+ badge.style.cssText = style
132
+ return badge
133
+ }
134
+
135
+ export function renderConnectionBadge(connectionType: string): HTMLElement | null {
136
+ if (!connectionType) {
137
+ return null
138
+ }
139
+
140
+ const badge = renderBadge(connectionType, '')
141
+
142
+ if (connectionType === 'BLE') {
143
+ badge.style.cssText
144
+ = 'background: #4285f4; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'
145
+ } else if (connectionType === 'Both') {
146
+ badge.style.cssText
147
+ = 'background: #34a853; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'
148
+ } else {
149
+ badge.style.cssText
150
+ = 'background: #9e9e9e; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'
151
+ }
152
+
153
+ return badge
154
+ }
155
+
156
+ export function renderIRBadge(): HTMLElement {
157
+ return renderBadge(
158
+ 'IR',
159
+ 'background: #ff6b35; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;',
160
+ )
161
+ }
162
+
163
+ function normalizeId(value: any): string {
164
+ return String(value ?? '').trim().toLowerCase()
165
+ }
166
+
167
+ function scrollToConfiguredDevice(deviceId: string): void {
168
+ const normalizedId = normalizeId(deviceId)
169
+ const target = document.querySelector(`[data-device-id="${normalizedId}"]`) as HTMLElement | null
170
+ if (!target) {
171
+ return
172
+ }
173
+
174
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' })
175
+ const originalOutline = target.style.outline
176
+ const originalBackground = target.style.background
177
+ target.style.outline = '2px solid var(--switchbot-red, #ef4444)'
178
+ target.style.background = 'rgba(239, 68, 68, 0.08)'
179
+ setTimeout(() => {
180
+ target.style.outline = originalOutline
181
+ target.style.background = originalBackground
182
+ }, 1800)
183
+ }
184
+
185
+ function createConnectionTestControls(device: any): HTMLElement {
186
+ const controls = document.createElement('div')
187
+ controls.style.display = 'inline-flex'
188
+ controls.style.alignItems = 'center'
189
+ controls.style.gap = '6px'
190
+
191
+ const button = document.createElement('button')
192
+ button.textContent = 'Test Connection'
193
+ button.className = 'secondary'
194
+ button.style.padding = '4px 9px'
195
+ button.style.fontSize = '11px'
196
+
197
+ const status = document.createElement('span')
198
+ status.style.fontSize = '10px'
199
+ status.style.opacity = '0.85'
200
+ status.style.whiteSpace = 'normal'
201
+ status.style.overflowWrap = 'anywhere'
202
+
203
+ button.onclick = async () => {
204
+ const startedAt = Date.now()
205
+ button.disabled = true
206
+ button.textContent = 'Testing...'
207
+ status.textContent = 'Checking...'
208
+ status.style.color = '#6b7280'
209
+
210
+ try {
211
+ const { testDeviceConnection } = await import('./api.js')
212
+ const result = await testDeviceConnection({
213
+ deviceId: String(device?.id || device?.deviceId || ''),
214
+ connectionType: device?.connectionType,
215
+ address: device?.address,
216
+ })
217
+
218
+ const measuredLatency = Number(result?.latencyMs) > 0
219
+ ? Number(result.latencyMs)
220
+ : Date.now() - startedAt
221
+
222
+ if (result?.success) {
223
+ const method = result?.method || 'Auto'
224
+ status.textContent = `✓ ${method} · ${measuredLatency}ms`
225
+ status.style.color = '#16a34a'
226
+ } else {
227
+ const detail = result?.message ? ` · ${result.message}` : ''
228
+ status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`
229
+ status.style.color = '#dc2626'
230
+ }
231
+ } catch (e) {
232
+ status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`
233
+ status.style.color = '#dc2626'
234
+ } finally {
235
+ button.disabled = false
236
+ button.textContent = 'Test Connection'
237
+ }
238
+ }
239
+
240
+ controls.appendChild(button)
241
+ controls.appendChild(status)
242
+ return controls
243
+ }
244
+
245
+ function formatLastSeen(value: any): string {
246
+ if (!value) {
247
+ return 'N/A'
248
+ }
249
+ try {
250
+ const date = new Date(value)
251
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString()
252
+ } catch (_e) {
253
+ return String(value)
254
+ }
255
+ }
256
+
257
+ export function renderDeviceDetailsPanel(device: any): HTMLElement {
258
+ const details = document.createElement('div')
259
+ details.className = 'device-details-panel'
260
+ details.style.borderTop = '1px solid #ddd'
261
+ details.style.padding = '8px'
262
+ details.style.borderRadius = '4px'
263
+ details.style.fontSize = '12px'
264
+ details.style.marginTop = '4px'
265
+
266
+ // --- Battery history trending ---
267
+ // Persist battery readings in localStorage per device
268
+ const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`
269
+ let batteryHistory: Array<{ value: number, ts: number }> = []
270
+ try {
271
+ const raw = localStorage.getItem(batteryHistoryKey)
272
+ if (raw) {
273
+ batteryHistory = JSON.parse(raw)
274
+ }
275
+ } catch (e) {
276
+ // Optionally log or handle error
277
+ }
278
+ const now = Date.now()
279
+ if (typeof device?.battery === 'number') {
280
+ // Only add if different from last or >1h since last
281
+ const last = batteryHistory.at(-1)
282
+ if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
283
+ batteryHistory.push({ value: device.battery, ts: now })
284
+ // Keep only last 30 entries (about a month if daily)
285
+ if (batteryHistory.length > 30) {
286
+ batteryHistory = batteryHistory.slice(-30)
287
+ }
288
+ try {
289
+ localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory))
290
+ } catch (e) {
291
+ // Optionally log or handle error
292
+ }
293
+ }
294
+ }
295
+
296
+ const rows: Array<{ label: string, value: string, copyable?: boolean }> = [
297
+ { label: 'Name', value: String(device?.name || device?.configDeviceName || 'N/A') },
298
+ { label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) },
299
+ { label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address },
300
+ { label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') },
301
+ { label: 'Model', value: String(device?.model || 'N/A') },
302
+ { label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') },
303
+ { label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' },
304
+ { label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') },
305
+ { label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' },
306
+ { label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
307
+ ]
308
+
309
+ for (const row of rows) {
310
+ const line = document.createElement('div')
311
+ line.style.display = 'flex'
312
+ line.style.alignItems = 'center'
313
+ line.style.justifyContent = 'space-between'
314
+ line.style.gap = '8px'
315
+ line.style.padding = '2px 0'
316
+
317
+ const label = document.createElement('span')
318
+ label.style.fontWeight = '600'
319
+ label.style.minWidth = '110px'
320
+ label.textContent = `${row.label}:`
321
+
322
+ const valueWrap = document.createElement('span')
323
+ valueWrap.style.display = 'inline-flex'
324
+ valueWrap.style.alignItems = 'center'
325
+ valueWrap.style.gap = '6px'
326
+ valueWrap.style.flex = '1'
327
+ valueWrap.style.justifyContent = 'flex-end'
328
+ valueWrap.style.minWidth = '0'
329
+
330
+ const value = document.createElement('span')
331
+ value.style.fontFamily = 'monospace'
332
+ value.style.fontSize = '11px'
333
+ value.style.opacity = '0.9'
334
+ value.style.whiteSpace = 'normal'
335
+ value.style.overflowWrap = 'anywhere'
336
+ value.style.wordBreak = 'break-word'
337
+ value.style.textAlign = 'right'
338
+ value.textContent = row.value
339
+
340
+ valueWrap.appendChild(value)
341
+
342
+ if (row.copyable && row.value && row.value !== 'N/A') {
343
+ const copyBtn = document.createElement('button')
344
+ copyBtn.textContent = '📋'
345
+ copyBtn.title = `Copy ${row.label}`
346
+ copyBtn.style.padding = '2px 6px'
347
+ copyBtn.style.fontSize = '10px'
348
+ copyBtn.style.lineHeight = '1'
349
+ copyBtn.style.background = '#e5e7eb'
350
+ copyBtn.style.color = '#111827'
351
+
352
+ copyBtn.onclick = async () => {
353
+ try {
354
+ await navigator.clipboard.writeText(row.value)
355
+ copyBtn.textContent = '✓'
356
+ setTimeout(() => {
357
+ copyBtn.textContent = '📋'
358
+ }, 1200)
359
+ } catch (_e) {
360
+ copyBtn.textContent = '!'
361
+ setTimeout(() => {
362
+ copyBtn.textContent = '📋'
363
+ }, 1200)
364
+ }
365
+ }
366
+
367
+ valueWrap.appendChild(copyBtn)
368
+ }
369
+
370
+ line.appendChild(label)
371
+ line.appendChild(valueWrap)
372
+ details.appendChild(line)
373
+
374
+ // If this is the Battery row, add a sparkline chart below
375
+ if (row.label === 'Battery' && Array.isArray(batteryHistory) && batteryHistory.length > 1) {
376
+ const chart = document.createElement('div')
377
+ chart.style.margin = '2px 0 8px 0'
378
+ chart.style.width = '100%'
379
+ chart.style.height = '28px'
380
+ chart.style.display = 'flex'
381
+ // SVG sparkline
382
+ const w = 120
383
+ const h = 24
384
+ const pad = 2
385
+ const min = Math.min(...batteryHistory.map(b => b.value), 100)
386
+ const max = Math.max(...batteryHistory.map(b => b.value), 0)
387
+ const range = max - min || 1
388
+ const points = batteryHistory.map((b, i) => {
389
+ const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
390
+ const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
391
+ return `${x},${y}`
392
+ }).join(' ')
393
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
394
+ svg.setAttribute('width', String(w))
395
+ svg.setAttribute('height', String(h))
396
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
397
+ svg.style.display = 'block'
398
+ svg.style.background = '#f3f4f6'
399
+ svg.style.borderRadius = '3px'
400
+ svg.style.marginTop = '2px'
401
+ svg.style.boxShadow = '0 1px 2px #0001'
402
+ // Polyline for trend
403
+ const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
404
+ polyline.setAttribute('points', points)
405
+ polyline.setAttribute('fill', 'none')
406
+ polyline.setAttribute('stroke', '#2563eb')
407
+ polyline.setAttribute('stroke-width', '2')
408
+ svg.appendChild(polyline)
409
+ // Dots for each point
410
+ batteryHistory.forEach((b, i) => {
411
+ const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
412
+ const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
413
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
414
+ circle.setAttribute('cx', String(x))
415
+ circle.setAttribute('cy', String(y))
416
+ circle.setAttribute('r', '2.5')
417
+ circle.setAttribute('fill', '#2563eb')
418
+ svg.appendChild(circle)
419
+ })
420
+ // Min/max labels
421
+ const minLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
422
+ minLabel.setAttribute('x', '2')
423
+ minLabel.setAttribute('y', String(h - 2))
424
+ minLabel.setAttribute('font-size', '9')
425
+ minLabel.setAttribute('fill', '#888')
426
+ minLabel.textContent = `${min}%`
427
+ svg.appendChild(minLabel)
428
+ const maxLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
429
+ maxLabel.setAttribute('x', String(w - 18))
430
+ maxLabel.setAttribute('y', '10')
431
+ maxLabel.setAttribute('font-size', '9')
432
+ maxLabel.setAttribute('fill', '#888')
433
+ maxLabel.textContent = `${max}%`
434
+ svg.appendChild(maxLabel)
435
+ chart.appendChild(svg)
436
+ details.appendChild(chart)
437
+ }
438
+ }
439
+
440
+ // --- Expose advanced/extra features dynamically ---
441
+ const featureKeys = [
442
+ 'airQuality',
443
+ 'pm25',
444
+ 'pm10',
445
+ 'voc',
446
+ 'co2',
447
+ 'humidity',
448
+ 'temperature',
449
+ 'preset',
450
+ 'mode',
451
+ 'presetMode',
452
+ 'direction',
453
+ 'calibration',
454
+ 'multiCommand',
455
+ 'extendedInfo',
456
+ 'segmentedControl',
457
+ 'features',
458
+ 'capabilities',
459
+ 'state',
460
+ ]
461
+ const shown = new Set(rows.map(r => r.label.toLowerCase().replace(SPACES_REGEX, '')))
462
+ for (const key of featureKeys) {
463
+ if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
464
+ const line = document.createElement('div')
465
+ line.style.display = 'flex'
466
+ line.style.alignItems = 'center'
467
+ line.style.justifyContent = 'space-between'
468
+ line.style.gap = '8px'
469
+ line.style.padding = '2px 0'
470
+
471
+ const label = document.createElement('span')
472
+ label.style.fontWeight = '600'
473
+ label.style.minWidth = '110px'
474
+ label.textContent = `${key.replace(CAMELCASE_REGEX, ' $1').replace(FIRST_CHAR_REGEX, s => s.toUpperCase())}:`
475
+
476
+ const value = document.createElement('span')
477
+ value.style.fontFamily = 'monospace'
478
+ value.style.fontSize = '11px'
479
+ value.style.opacity = '0.9'
480
+ value.style.whiteSpace = 'normal'
481
+ value.style.overflowWrap = 'anywhere'
482
+ value.style.wordBreak = 'break-word'
483
+ value.style.textAlign = 'right'
484
+ value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key])
485
+
486
+ line.appendChild(label)
487
+ line.appendChild(value)
488
+ details.appendChild(line)
489
+ }
490
+ }
491
+
492
+ return details
493
+ }
494
+
495
+ export async function renderDiscoveredDevices(
496
+ devices: any[],
497
+ options: {
498
+ configuredIds?: Set<string>
499
+ selectedIds?: Set<string>
500
+ onToggleSelect?: (device: any, selected: boolean) => void
501
+ } = {},
502
+ ): Promise<HTMLElement> {
503
+ const ul = document.createElement('ul')
504
+ ul.className = 'device-grid'
505
+ ul.style.maxHeight = '400px'
506
+ ul.style.overflowY = 'auto'
507
+ ul.style.marginTop = '12px'
508
+ ul.style.padding = '0'
509
+ ul.style.listStyle = 'none'
510
+
511
+ const { addDeviceToConfig } = await import('./discovery.js')
512
+ const { loadConfiguredDevices } = await import('./devices.js')
513
+ const configuredIds = options.configuredIds ?? new Set<string>()
514
+ const selectedIds = options.selectedIds ?? new Set<string>()
515
+ const onToggleSelect = options.onToggleSelect
516
+
517
+ for (const d of devices) {
518
+ // Defensive check: warn if device is missing id, name, or type
519
+ if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) {
520
+ console.warn('[SwitchBot][Discovery][renderDiscoveredDevices] Device missing required fields:', d)
521
+ }
522
+ const deviceId = normalizeId(d.id)
523
+ const alreadyAdded = configuredIds.has(deviceId)
524
+
525
+ const li = document.createElement('li')
526
+ li.className = 'device-item'
527
+ li.style.display = 'flex'
528
+ li.style.flexDirection = 'column'
529
+ li.style.alignItems = 'stretch'
530
+ li.style.justifyContent = 'flex-start'
531
+ li.style.padding = '5px 8px'
532
+ li.style.marginBottom = '0'
533
+ li.style.borderRadius = '5px'
534
+ li.style.transition = 'all 0.2s ease'
535
+
536
+ const info = document.createElement('div')
537
+ info.style.flex = '1 1 auto'
538
+ info.style.width = '100%'
539
+ info.style.minWidth = '0'
540
+
541
+ const nameContainer = document.createElement('div')
542
+ nameContainer.style.display = 'flex'
543
+ nameContainer.style.alignItems = 'center'
544
+ nameContainer.style.marginBottom = '0'
545
+ nameContainer.style.flexWrap = 'wrap'
546
+ nameContainer.style.gap = '4px'
547
+
548
+ const name = document.createElement('div')
549
+ name.style.fontWeight = '500'
550
+ name.style.fontSize = '13px'
551
+ name.textContent = d.name || d.id
552
+
553
+ const selectCheckbox = document.createElement('input')
554
+ selectCheckbox.type = 'checkbox'
555
+ selectCheckbox.style.width = 'auto'
556
+ selectCheckbox.style.margin = '0 2px 0 0'
557
+ selectCheckbox.checked = selectedIds.has(deviceId)
558
+ if (alreadyAdded) {
559
+ selectCheckbox.disabled = true
560
+ selectCheckbox.title = 'Already configured'
561
+ }
562
+ selectCheckbox.onchange = () => {
563
+ onToggleSelect?.(d, selectCheckbox.checked)
564
+ // Notify listeners (e.g., batch buttons) of selection change
565
+ window.dispatchEvent(new CustomEvent('discovery-selection-changed'))
566
+ }
567
+
568
+ nameContainer.appendChild(selectCheckbox)
569
+
570
+ nameContainer.appendChild(name)
571
+
572
+ // Show firmware update available indicator if present
573
+ if (d.firmwareUpdateAvailable) {
574
+ const fwBadge = document.createElement('span')
575
+ fwBadge.textContent = 'Update Available'
576
+ fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
577
+ fwBadge.title = 'A firmware update is available for this device.'
578
+ nameContainer.appendChild(fwBadge)
579
+ }
580
+
581
+ // Show offline/unreachable indicator if device is offline
582
+ let offline = false
583
+ const lastSeen = d.lastSeen || d.lastseen || d.updatedAt
584
+ if (typeof d.offline === 'boolean') {
585
+ offline = d.offline
586
+ } else if (lastSeen) {
587
+ try {
588
+ const last = new Date(lastSeen).getTime()
589
+ if (!Number.isNaN(last)) {
590
+ if (Date.now() - last > 1000 * 60 * 60) { // 1 hour
591
+ offline = true
592
+ }
593
+ }
594
+ } catch {}
595
+ }
596
+ if (offline) {
597
+ const offlineBadge = document.createElement('span')
598
+ offlineBadge.textContent = 'Offline'
599
+ offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
600
+ offlineBadge.title = 'Device is offline or unreachable.'
601
+ nameContainer.appendChild(offlineBadge)
602
+ }
603
+
604
+ const expandedDetails = document.createElement('div')
605
+ expandedDetails.style.display = 'none'
606
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
607
+
608
+ const expandBtn = document.createElement('button')
609
+ expandBtn.textContent = '▾'
610
+ expandBtn.title = 'Show details'
611
+ expandBtn.style.padding = '2px 6px'
612
+ expandBtn.style.fontSize = '11px'
613
+ expandBtn.style.marginLeft = '4px'
614
+ expandBtn.style.background = '#e5e7eb'
615
+ expandBtn.style.color = '#111827'
616
+ expandBtn.style.transition = 'transform 0.2s ease'
617
+ expandBtn.onclick = () => {
618
+ const isHidden = expandedDetails.style.display === 'none'
619
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
620
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
621
+ }
622
+ nameContainer.appendChild(expandBtn)
623
+
624
+ const duplicateBadge = document.createElement('span')
625
+ duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
626
+ duplicateBadge.style.cssText = alreadyAdded
627
+ ? 'background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
628
+ : 'background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
629
+ nameContainer.appendChild(duplicateBadge)
630
+
631
+ // Add connection type badge
632
+ if (d.connectionType) {
633
+ const badge = renderConnectionBadge(d.connectionType)
634
+ if (badge) {
635
+ nameContainer.appendChild(badge)
636
+ }
637
+ }
638
+
639
+ // Add IR badge if it's an IR device
640
+ if (d.isIR) {
641
+ nameContainer.appendChild(renderIRBadge())
642
+ }
643
+
644
+ // Add signal strength visualization (only for BLE/wireless devices)
645
+ if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) {
646
+ nameContainer.appendChild(renderSignalBars(d.rssi))
647
+ nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
648
+ }
649
+
650
+ // Add battery warning indicator if battery < 20%
651
+ if (typeof d.battery === 'number' && d.battery < 20) {
652
+ const batteryWarn = document.createElement('span')
653
+ batteryWarn.textContent = `⚠️ ${d.battery}%`
654
+ batteryWarn.style.cssText
655
+ = d.battery < 10
656
+ ? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
657
+ : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
658
+ batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low'
659
+ nameContainer.appendChild(batteryWarn)
660
+ }
661
+
662
+ // Defensive check: warn if device is missing id, name, or type (for details panel)
663
+ if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) {
664
+ console.warn('[SwitchBot][Discovery][renderDeviceDetailsPanel] Device missing required fields:', d)
665
+ }
666
+ const details = document.createElement('div')
667
+ details.style.fontSize = '10px'
668
+ details.style.opacity = '0.7'
669
+ details.style.marginTop = '0'
670
+ details.style.fontFamily = 'monospace'
671
+ details.style.whiteSpace = 'normal'
672
+ details.style.overflowWrap = 'anywhere'
673
+ details.style.wordBreak = 'break-word'
674
+
675
+ let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`
676
+ if (d.hubDeviceId) {
677
+ detailsText += ` | Hub: ${d.hubDeviceId}`
678
+ }
679
+ if (d.address) {
680
+ detailsText += ` | MAC: ${d.address}`
681
+ }
682
+ details.textContent = detailsText
683
+
684
+ info.appendChild(nameContainer)
685
+ info.appendChild(details)
686
+ info.appendChild(expandedDetails)
687
+
688
+ const addBtn = document.createElement('button')
689
+ addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
690
+ addBtn.style.marginLeft = '0'
691
+ addBtn.style.marginTop = '2px'
692
+ addBtn.style.padding = '4px 9px'
693
+ addBtn.style.fontSize = '11px'
694
+ addBtn.style.whiteSpace = 'nowrap'
695
+ addBtn.style.flexShrink = '0'
696
+ addBtn.disabled = alreadyAdded
697
+ if (alreadyAdded) {
698
+ addBtn.style.opacity = '0.65'
699
+ addBtn.style.cursor = 'not-allowed'
700
+ addBtn.style.background = '#6b7280'
701
+ }
702
+ addBtn.onclick = async () => {
703
+ if (alreadyAdded) {
704
+ return
705
+ }
706
+ await addDeviceToConfig(d)
707
+ }
708
+
709
+ if (alreadyAdded) {
710
+ const viewBtn = document.createElement('button')
711
+ viewBtn.textContent = 'View in Config'
712
+ viewBtn.className = 'secondary'
713
+ viewBtn.style.marginLeft = '0'
714
+ viewBtn.style.padding = '4px 9px'
715
+ viewBtn.style.fontSize = '11px'
716
+ viewBtn.onclick = async () => {
717
+ await loadConfiguredDevices()
718
+ scrollToConfiguredDevice(d.id)
719
+ }
720
+ li.appendChild(info)
721
+ const actions = document.createElement('div')
722
+ actions.className = 'device-actions'
723
+ actions.style.display = 'flex'
724
+ actions.style.alignItems = 'center'
725
+ actions.style.flexWrap = 'wrap'
726
+ actions.style.justifyContent = 'flex-start'
727
+ actions.style.marginLeft = '0'
728
+ actions.style.width = '100%'
729
+ actions.style.marginTop = '2px'
730
+ actions.style.gap = '5px'
731
+ actions.appendChild(viewBtn)
732
+ actions.appendChild(addBtn)
733
+ actions.appendChild(createConnectionTestControls(d))
734
+ li.appendChild(actions)
735
+ ul.appendChild(li)
736
+ continue
737
+ }
738
+
739
+ const actions = document.createElement('div')
740
+ actions.className = 'device-actions'
741
+ actions.style.display = 'flex'
742
+ actions.style.flexWrap = 'wrap'
743
+ actions.style.justifyContent = 'flex-start'
744
+ actions.style.marginLeft = '0'
745
+ actions.style.width = '100%'
746
+ actions.style.marginTop = '2px'
747
+ actions.style.gap = '5px'
748
+ actions.appendChild(addBtn)
749
+ actions.appendChild(createConnectionTestControls(d))
750
+
751
+ li.appendChild(info)
752
+ li.appendChild(actions)
753
+ ul.appendChild(li)
754
+ }
755
+
756
+ return ul
757
+ }
758
+
759
+ /**
760
+ * Filter devices by connection type and search query
761
+ * @param devices Discovered devices array
762
+ * @param connectionType Filter: 'all' | 'ble' | 'api' | 'both' | 'ir'
763
+ * @param searchQuery Search term to match against name/id/type
764
+ * @returns Filtered devices array
765
+ */
766
+ export function filterDevices(
767
+ devices: any[],
768
+ connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir' = 'all',
769
+ searchQuery = '',
770
+ ): any[] {
771
+ let filtered = [...devices]
772
+
773
+ // Filter by connection type
774
+ if (connectionType !== 'all') {
775
+ filtered = filtered.filter((d) => {
776
+ if (connectionType === 'ir') {
777
+ return d.isIR === true
778
+ }
779
+ if (connectionType === 'ble') {
780
+ return d.connectionType === 'BLE' || d.connectionType?.includes('BLE')
781
+ }
782
+ if (connectionType === 'api') {
783
+ return d.connectionType === 'OpenAPI' || d.connectionType === 'API' || d.connectionType?.includes('API')
784
+ }
785
+ if (connectionType === 'both') {
786
+ return d.connectionType === 'Both' || d.connectionType?.includes('Both')
787
+ }
788
+ return true
789
+ })
790
+ }
791
+
792
+ // Filter by search query
793
+ if (searchQuery.trim()) {
794
+ const query = searchQuery.toLowerCase()
795
+ filtered = filtered.filter((d) => {
796
+ const name = (d.name || '').toLowerCase()
797
+ const id = (d.id || '').toLowerCase()
798
+ const type = (d.type || '').toLowerCase()
799
+ const model = (d.model || '').toLowerCase()
800
+ return name.includes(query) || id.includes(query) || type.includes(query) || model.includes(query)
801
+ })
802
+ }
803
+
804
+ return filtered
805
+ }
806
+
807
+ /**
808
+ * Sort devices by specified criteria
809
+ * @param devices Devices array to sort
810
+ * @param sortBy Sort criterion: 'name' | 'signal' | 'type' | 'connection'
811
+ * @returns Sorted devices array
812
+ */
813
+ export function sortDevices(
814
+ devices: any[],
815
+ sortBy: 'name' | 'signal' | 'type' | 'connection' = 'name',
816
+ ): any[] {
817
+ const sorted = [...devices]
818
+
819
+ switch (sortBy) {
820
+ case 'signal': {
821
+ // Sort by RSSI descending (strongest signal first)
822
+ sorted.sort((a, b) => {
823
+ const aRssi = a.rssi || 0
824
+ const bRssi = b.rssi || 0
825
+ return bRssi - aRssi // Descending order (higher is stronger)
826
+ })
827
+ break
828
+ }
829
+ case 'type': {
830
+ // Sort by device type alphabetically
831
+ sorted.sort((a, b) => {
832
+ const aType = (a.type || '').localeCompare(b.type || '')
833
+ return aType
834
+ })
835
+ break
836
+ }
837
+ case 'connection': {
838
+ // Sort by connection type: Both > BLE > OpenAPI > Others
839
+ const connectionOrder: Record<string, number> = {
840
+ Both: 0,
841
+ BLE: 1,
842
+ OpenAPI: 2,
843
+ API: 2,
844
+ Unknown: 3,
845
+ }
846
+ sorted.sort((a, b) => {
847
+ const aOrder = connectionOrder[a.connectionType || 'Unknown'] ?? 3
848
+ const bOrder = connectionOrder[b.connectionType || 'Unknown'] ?? 3
849
+ return aOrder - bOrder
850
+ })
851
+ break
852
+ }
853
+ case 'name':
854
+ default: {
855
+ // Sort by name alphabetically
856
+ sorted.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
857
+ break
858
+ }
859
+ }
860
+
861
+ return sorted
862
+ }
863
+
864
+ /**
865
+ * Get filter/sort preferences from localStorage
866
+ * @returns Object with current filter and sort preferences
867
+ */
868
+ export function getDiscoveryPreferences(): {
869
+ connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir'
870
+ sortBy: 'name' | 'signal' | 'type' | 'connection'
871
+ searchQuery: string
872
+ } {
873
+ try {
874
+ const stored = localStorage.getItem('discoveryPreferences')
875
+ if (stored) {
876
+ return JSON.parse(stored)
877
+ }
878
+ } catch (_e) {
879
+ // Ignore parse errors
880
+ }
881
+
882
+ return {
883
+ connectionType: 'all',
884
+ sortBy: 'name',
885
+ searchQuery: '',
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Save filter/sort preferences to localStorage
891
+ * @param preferences Preferences object to save
892
+ * @param preferences.connectionType Connection type filter
893
+ * @param preferences.sortBy Sort criterion
894
+ * @param preferences.searchQuery Search query string
895
+ */
896
+ export function setDiscoveryPreferences(preferences: {
897
+ connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir'
898
+ sortBy: 'name' | 'signal' | 'type' | 'connection'
899
+ searchQuery: string
900
+ }): void {
901
+ try {
902
+ localStorage.setItem('discoveryPreferences', JSON.stringify(preferences))
903
+ } catch (_e) {
904
+ // Ignore storage errors
905
+ }
906
+ }
907
+
908
+ export function renderDeviceList(list: any[]): void {
909
+ const ul = document.getElementById('devices')
910
+ const status = document.getElementById('status')
911
+ const removeAllContainer = document.getElementById('removeAllContainer')
912
+
913
+ if (!ul || !status) {
914
+ return
915
+ }
916
+
917
+ if (!list.length) {
918
+ status.textContent = 'No devices found in config.'
919
+ ul.innerHTML = ''
920
+ // Hide remove all button when no devices
921
+ if (removeAllContainer) {
922
+ removeAllContainer.style.display = 'none'
923
+ }
924
+ return
925
+ }
926
+
927
+ status.textContent = `Found ${list.length} device(s)`
928
+ ul.classList.add('device-grid')
929
+ ul.style.padding = '0'
930
+ ul.innerHTML = ''
931
+
932
+ // Show remove all button when devices exist
933
+ if (removeAllContainer) {
934
+ removeAllContainer.style.display = 'block'
935
+ }
936
+
937
+ for (const d of list) {
938
+ const li = document.createElement('li')
939
+ li.className = 'device-item'
940
+ li.setAttribute('data-device-id', normalizeId(d.id))
941
+ li.style.display = 'flex'
942
+ li.style.flexDirection = 'column'
943
+ li.style.alignItems = 'stretch'
944
+ li.style.padding = '5px 8px'
945
+ li.style.marginBottom = '0'
946
+
947
+ const info = document.createElement('div')
948
+ info.style.flex = '1 1 auto'
949
+ info.style.width = '100%'
950
+ info.style.minWidth = '0'
951
+
952
+ const nameContainer = document.createElement('div')
953
+ nameContainer.style.display = 'flex'
954
+ nameContainer.style.alignItems = 'center'
955
+ nameContainer.style.marginBottom = '0'
956
+ nameContainer.style.flexWrap = 'wrap'
957
+ nameContainer.style.gap = '4px'
958
+
959
+ const name = document.createElement('div')
960
+ name.style.fontWeight = '500'
961
+ name.style.fontSize = '13px'
962
+ name.textContent = d.configDeviceName || d.name || d.id
963
+
964
+ const expandedDetails = document.createElement('div')
965
+ expandedDetails.style.display = 'none'
966
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
967
+
968
+ const expandBtn = document.createElement('button')
969
+ expandBtn.textContent = '▾'
970
+ expandBtn.title = 'Show details'
971
+ expandBtn.style.padding = '2px 6px'
972
+ expandBtn.style.fontSize = '11px'
973
+ expandBtn.style.marginLeft = '4px'
974
+ expandBtn.style.background = '#e5e7eb'
975
+ expandBtn.style.color = '#111827'
976
+ expandBtn.style.transition = 'transform 0.2s ease'
977
+ expandBtn.onclick = () => {
978
+ const isHidden = expandedDetails.style.display === 'none'
979
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
980
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
981
+ }
982
+
983
+ nameContainer.appendChild(name)
984
+ nameContainer.appendChild(expandBtn)
985
+
986
+ // Add signal strength visualization if RSSI is available
987
+ if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) {
988
+ nameContainer.appendChild(renderSignalBars(d.rssi))
989
+ nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
990
+ }
991
+
992
+ const meta = document.createElement('div')
993
+ meta.style.opacity = '0.7'
994
+ meta.style.fontSize = '10px'
995
+ meta.style.fontFamily = 'monospace'
996
+
997
+ const id = `ID: ${d.deviceId || d.id}`
998
+ const typeText = d.configDeviceType || d.type ? `Type: ${d.configDeviceType || d.type}` : ''
999
+ const connText = d.connectionPreference ? `Conn: ${d.connectionPreference}` : ''
1000
+ const roomText = d.room ? `Room: ${d.room}` : ''
1001
+ meta.textContent = [id, typeText, connText, roomText].filter(Boolean).join(' | ')
1002
+
1003
+ info.appendChild(nameContainer)
1004
+ info.appendChild(meta)
1005
+ info.appendChild(expandedDetails)
1006
+
1007
+ const buttons = document.createElement('div')
1008
+ buttons.className = 'device-actions'
1009
+ buttons.style.display = 'flex'
1010
+ buttons.style.flexWrap = 'wrap'
1011
+ buttons.style.justifyContent = 'flex-start'
1012
+ buttons.style.marginLeft = '0'
1013
+ buttons.style.width = '100%'
1014
+ buttons.style.marginTop = '2px'
1015
+ buttons.style.gap = '5px'
1016
+
1017
+ const editBtn = document.createElement('button')
1018
+ editBtn.textContent = '✏️ Edit'
1019
+ editBtn.style.padding = '4px 9px'
1020
+ editBtn.style.fontSize = '11px'
1021
+ editBtn.onclick = async () => {
1022
+ const { editDevice } = await import('./modals.js')
1023
+ await editDevice(d)
1024
+ }
1025
+
1026
+ const copyBtn = document.createElement('button')
1027
+ copyBtn.textContent = 'Copy ID'
1028
+ copyBtn.style.padding = '4px 9px'
1029
+ copyBtn.style.fontSize = '11px'
1030
+ copyBtn.addEventListener('click', async () => {
1031
+ try {
1032
+ await navigator.clipboard.writeText(d.id)
1033
+ copyBtn.textContent = 'Copied'
1034
+ copyBtn.classList.add('success')
1035
+ setTimeout(() => {
1036
+ copyBtn.textContent = 'Copy ID'
1037
+ copyBtn.classList.remove('success')
1038
+ }, 1200)
1039
+ } catch (e) {
1040
+ copyBtn.textContent = 'Failed'
1041
+ copyBtn.classList.add('error')
1042
+ setTimeout(() => {
1043
+ copyBtn.textContent = 'Copy ID'
1044
+ copyBtn.classList.remove('error')
1045
+ }, 1200)
1046
+ }
1047
+ })
1048
+
1049
+ const deleteBtn = document.createElement('button')
1050
+ deleteBtn.textContent = '🗑️ Delete'
1051
+ deleteBtn.style.padding = '4px 9px'
1052
+ deleteBtn.style.fontSize = '11px'
1053
+ deleteBtn.style.background = '#ef4444'
1054
+ deleteBtn.onclick = async () => {
1055
+ const { deleteDeviceFromConfig } = await import('./devices-delete.js')
1056
+ await deleteDeviceFromConfig(d.id || d.deviceId, d.name || d.id || d.deviceId)
1057
+ }
1058
+
1059
+ buttons.appendChild(editBtn)
1060
+ buttons.appendChild(copyBtn)
1061
+ buttons.appendChild(createConnectionTestControls(d))
1062
+ buttons.appendChild(deleteBtn)
1063
+
1064
+ li.appendChild(info)
1065
+ li.appendChild(buttons)
1066
+ ul.appendChild(li)
1067
+ }
1068
+
1069
+ // No return value needed for void function
1070
+ }