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