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

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