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