@switchbot/homebridge-switchbot 5.0.0-beta.133 → 5.0.0-beta.135

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 (93) hide show
  1. package/config.schema.json +0 -11
  2. package/dist/deviceFactory.d.ts +3 -1
  3. package/dist/deviceFactory.d.ts.map +1 -1
  4. package/dist/deviceFactory.js +3 -18
  5. package/dist/deviceFactory.js.map +1 -1
  6. package/dist/devices/genericDevice.d.ts +1 -1
  7. package/dist/devices/genericDevice.d.ts.map +1 -1
  8. package/dist/devices/genericDevice.js +67 -19
  9. package/dist/devices/genericDevice.js.map +1 -1
  10. package/dist/homebridge-ui/public/index.html +107 -124
  11. package/dist/homebridge-ui/public/js/api.d.ts +1 -4
  12. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  13. package/dist/homebridge-ui/public/js/api.js +1 -7
  14. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  15. package/dist/homebridge-ui/public/js/api.ts +1 -8
  16. package/dist/homebridge-ui/public/js/app.js +201 -90
  17. package/dist/homebridge-ui/public/js/app.js.map +2 -2
  18. package/dist/homebridge-ui/public/js/app.ts +5 -6
  19. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
  20. package/dist/homebridge-ui/public/js/credentials.js +1 -1
  21. package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
  22. package/dist/homebridge-ui/public/js/credentials.ts +7 -3
  23. package/dist/homebridge-ui/public/js/devices-delete.js +1 -1
  24. package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -1
  25. package/dist/homebridge-ui/public/js/devices-delete.ts +1 -1
  26. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  27. package/dist/homebridge-ui/public/js/devices.js +0 -1
  28. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  29. package/dist/homebridge-ui/public/js/devices.ts +0 -2
  30. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
  31. package/dist/homebridge-ui/public/js/discovery.js +6 -3
  32. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  33. package/dist/homebridge-ui/public/js/discovery.ts +9 -3
  34. package/dist/homebridge-ui/public/js/logger.d.ts +1 -0
  35. package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -1
  36. package/dist/homebridge-ui/public/js/logger.js +8 -3
  37. package/dist/homebridge-ui/public/js/logger.js.map +1 -1
  38. package/dist/homebridge-ui/public/js/logger.ts +12 -6
  39. package/dist/homebridge-ui/public/js/modals.d.ts +4 -0
  40. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  41. package/dist/homebridge-ui/public/js/modals.js +152 -59
  42. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/modals.ts +187 -60
  44. package/dist/homebridge-ui/public/js/render.d.ts +3 -0
  45. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  46. package/dist/homebridge-ui/public/js/render.js +63 -24
  47. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  48. package/dist/homebridge-ui/public/js/render.ts +102 -51
  49. package/dist/homebridge-ui/utils/logger.js +1 -1
  50. package/dist/homebridge-ui/utils/logger.js.map +1 -1
  51. package/dist/platform.d.ts.map +1 -1
  52. package/dist/platform.js +38 -30
  53. package/dist/platform.js.map +1 -1
  54. package/dist/switchbotClient.d.ts +5 -5
  55. package/dist/switchbotClient.d.ts.map +1 -1
  56. package/dist/switchbotClient.js +21 -12
  57. package/dist/switchbotClient.js.map +1 -1
  58. package/docs/variables/default.html +1 -1
  59. package/package.json +1 -1
  60. package/scripts/build-ui.js +4 -3
  61. package/scripts/generate-matter-maps.js +34 -17
  62. package/scripts/sync-device-types.mjs +4 -1
  63. package/src/deviceFactory.ts +3 -18
  64. package/src/devices/genericDevice.ts +69 -21
  65. package/src/homebridge-ui/public/index.html +107 -124
  66. package/src/homebridge-ui/public/js/api.ts +1 -8
  67. package/src/homebridge-ui/public/js/app.ts +5 -6
  68. package/src/homebridge-ui/public/js/credentials.ts +7 -3
  69. package/src/homebridge-ui/public/js/devices-delete.ts +1 -1
  70. package/src/homebridge-ui/public/js/devices.ts +0 -2
  71. package/src/homebridge-ui/public/js/discovery.ts +9 -3
  72. package/src/homebridge-ui/public/js/logger.ts +12 -6
  73. package/src/homebridge-ui/public/js/modals.ts +187 -60
  74. package/src/homebridge-ui/public/js/render.ts +102 -51
  75. package/src/homebridge-ui/utils/logger.ts +1 -1
  76. package/src/platform.ts +38 -29
  77. package/src/switchbotClient.ts +42 -27
  78. package/test/accessory-restore.spec.ts +9 -8
  79. package/test/device-mapping.spec.ts +4 -3
  80. package/test/deviceFactory.spec.ts +4 -3
  81. package/test/e2e/run-e2e.spec.ts +6 -10
  82. package/test/fan-swing.spec.ts +7 -6
  83. package/test/helpers/matter-harness.ts +18 -7
  84. package/test/lock-users.spec.ts +5 -5
  85. package/test/matter-childbridge.spec.ts +10 -7
  86. package/test/matter-descriptors.spec.ts +8 -8
  87. package/test/matter-device-state.spec.ts +52 -36
  88. package/test/matter-integration.spec.ts +53 -6
  89. package/test/platform.integration.spec.ts +7 -5
  90. package/test/switchbot-client-debounce.spec.ts +36 -40
  91. package/test/switchbot-client-openapi.spec.ts +22 -15
  92. package/test/switchbotClient.spec.ts +4 -3
  93. package/test/utils.spec.ts +2 -1
@@ -1,7 +1,15 @@
1
1
  import { DEVICE_TYPES } from './constants.js'
2
2
  import { uiLog } from './logger.js'
3
3
 
4
- export async function importDiscoveredDevice(device: any): Promise<{ configDeviceName: string, configDeviceType: string, address?: string } | null> {
4
+ export async function importDiscoveredDevice(device: any): Promise<{
5
+ configDeviceName: string
6
+ configDeviceType: string
7
+ address?: string
8
+ connectionPreference?: string
9
+ room?: string
10
+ encryptionKey?: string
11
+ keyId?: string
12
+ } | null> {
5
13
  return new Promise((resolve) => {
6
14
  const div = document.createElement('div')
7
15
  div.style.position = 'fixed'
@@ -91,6 +99,51 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
91
99
  typeSelect.appendChild(optgroup)
92
100
  })
93
101
 
102
+ // --- Connection Preference ---
103
+ const connectionPrefLabel = document.createElement('label')
104
+ connectionPrefLabel.textContent = 'Connection Preference'
105
+ connectionPrefLabel.style.display = 'block'
106
+ connectionPrefLabel.style.marginBottom = '6px'
107
+ connectionPrefLabel.style.fontWeight = '500'
108
+ connectionPrefLabel.style.fontSize = '12px'
109
+ connectionPrefLabel.style.color = '#6b7280'
110
+
111
+ const connectionPrefSelect = document.createElement('select')
112
+ connectionPrefSelect.style.width = '100%'
113
+ connectionPrefSelect.style.marginBottom = '12px'
114
+ connectionPrefSelect.style.padding = '8px 10px'
115
+ connectionPrefSelect.style.borderRadius = '6px'
116
+ connectionPrefSelect.style.fontSize = '14px'
117
+ connectionPrefSelect.style.boxSizing = 'border-box';
118
+ ['auto', 'ble', 'openapi'].forEach((val) => {
119
+ const opt = document.createElement('option')
120
+ opt.value = val
121
+ opt.text = val.charAt(0).toUpperCase() + val.slice(1)
122
+ opt.selected = (device.connectionPreference || 'auto') === val
123
+ connectionPrefSelect.appendChild(opt)
124
+ })
125
+
126
+ // --- Room ---
127
+ const roomLabel = document.createElement('label')
128
+ roomLabel.textContent = 'Room'
129
+ roomLabel.style.display = 'block'
130
+ roomLabel.style.marginBottom = '6px'
131
+ roomLabel.style.fontWeight = '500'
132
+ roomLabel.style.fontSize = '12px'
133
+ roomLabel.style.color = '#6b7280'
134
+
135
+ const roomInput = document.createElement('input')
136
+ roomInput.type = 'text'
137
+ roomInput.value = device.room || ''
138
+ roomInput.placeholder = 'Optional room/location metadata'
139
+ roomInput.style.width = '100%'
140
+ roomInput.style.marginBottom = '12px'
141
+ roomInput.style.padding = '8px 10px'
142
+ roomInput.style.borderRadius = '6px'
143
+ roomInput.style.fontSize = '14px'
144
+ roomInput.style.boxSizing = 'border-box'
145
+
146
+ // --- BLE MAC Address ---
94
147
  const macLabel = document.createElement('label')
95
148
  macLabel.textContent = 'BLE MAC Address (optional)'
96
149
  macLabel.style.display = 'block'
@@ -110,7 +163,7 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
110
163
  macInput.style.fontSize = '14px'
111
164
  macInput.style.boxSizing = 'border-box'
112
165
 
113
- // BLE Encryption Key
166
+ // --- Encryption Key ---
114
167
  const encryptionKeyLabel = document.createElement('label')
115
168
  encryptionKeyLabel.textContent = 'BLE Encryption Key (optional)'
116
169
  encryptionKeyLabel.style.display = 'block'
@@ -130,7 +183,7 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
130
183
  encryptionKeyInput.style.fontSize = '14px'
131
184
  encryptionKeyInput.style.boxSizing = 'border-box'
132
185
 
133
- // BLE Key ID
186
+ // --- Key ID ---
134
187
  const keyIdLabel = document.createElement('label')
135
188
  keyIdLabel.textContent = 'BLE Key ID (optional)'
136
189
  keyIdLabel.style.display = 'block'
@@ -171,7 +224,15 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
171
224
  importBtn.style.padding = '8px 20px'
172
225
  importBtn.style.fontSize = '13px'
173
226
 
174
- const cleanup = (result: { configDeviceName: string, configDeviceType: string, address?: string, encryptionKey?: string, keyId?: string } | null) => {
227
+ const cleanup = (result: {
228
+ configDeviceName: string
229
+ configDeviceType: string
230
+ address?: string
231
+ connectionPreference?: string
232
+ room?: string
233
+ encryptionKey?: string
234
+ keyId?: string
235
+ } | null) => {
175
236
  div.remove()
176
237
  resolve(result)
177
238
  }
@@ -181,6 +242,8 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
181
242
  configDeviceName: nameInput.value || device.name || device.id,
182
243
  configDeviceType: typeSelect.value || device.type,
183
244
  address: macInput.value || undefined,
245
+ connectionPreference: connectionPrefSelect.value || undefined,
246
+ room: roomInput.value || undefined,
184
247
  encryptionKey: encryptionKeyInput.value || undefined,
185
248
  keyId: keyIdInput.value || undefined,
186
249
  })
@@ -197,6 +260,10 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
197
260
  contentDiv.appendChild(nameInput)
198
261
  contentDiv.appendChild(typeLabel)
199
262
  contentDiv.appendChild(typeSelect)
263
+ contentDiv.appendChild(connectionPrefLabel)
264
+ contentDiv.appendChild(connectionPrefSelect)
265
+ contentDiv.appendChild(roomLabel)
266
+ contentDiv.appendChild(roomInput)
200
267
  contentDiv.appendChild(macLabel)
201
268
  contentDiv.appendChild(macInput)
202
269
  contentDiv.appendChild(encryptionKeyLabel)
@@ -250,58 +317,6 @@ export async function editDevice(device: any): Promise<void> {
250
317
  typeSelect.appendChild(optgroup)
251
318
  })
252
319
 
253
- // --- Matter Type Advanced Field (only for window covering types) ---
254
- const windowCoveringTypes = ['Roller Shade', 'Curtain', 'Curtain3', 'Blind Tilt']
255
- const showMatterType = windowCoveringTypes.includes(device.configDeviceType || device.deviceType || device.type || '')
256
- const matterTypeLabel = document.createElement('label')
257
- matterTypeLabel.textContent = 'Matter Window Covering Type (Advanced)'
258
- matterTypeLabel.style.display = showMatterType ? 'block' : 'none'
259
- matterTypeLabel.style.marginBottom = '6px'
260
- matterTypeLabel.style.fontWeight = '500'
261
- matterTypeLabel.style.fontSize = '12px'
262
- matterTypeLabel.style.color = '#6b7280'
263
- matterTypeLabel.style.marginTop = '2px'
264
- const matterTypes = [
265
- { value: '', label: 'Auto-detect (recommended)' },
266
- { value: 0, label: 'Roller Shade (0)' },
267
- { value: 1, label: 'Roman Shade (1)' },
268
- { value: 2, label: 'Balloon Shade (2)' },
269
- { value: 3, label: 'Woven Wood (3)' },
270
- { value: 4, label: 'Drapery (4)' },
271
- { value: 5, label: 'Awning (5)' },
272
- { value: 6, label: 'Shutter (6)' },
273
- { value: 7, label: 'Tilt Blind (7)' },
274
- { value: 8, label: 'Blind Tilt (8)' },
275
- { value: 9, label: 'Projector Screen (9)' },
276
- { value: 15, label: 'Other (15)' },
277
- ]
278
- const matterTypeSelect = document.createElement('select')
279
- matterTypeSelect.style.width = '100%'
280
- matterTypeSelect.style.marginBottom = '12px'
281
- matterTypeSelect.style.padding = '8px 10px'
282
- matterTypeSelect.style.borderRadius = '6px'
283
- matterTypeSelect.style.fontSize = '14px'
284
- matterTypeSelect.style.boxSizing = 'border-box'
285
- matterTypes.forEach((opt) => {
286
- const option = document.createElement('option')
287
- option.value = String(opt.value)
288
- option.textContent = opt.label
289
- if (device.matterType != null && String(opt.value) === String(device.matterType)) {
290
- option.selected = true
291
- }
292
- matterTypeSelect.appendChild(option)
293
- })
294
- function updateMatterTypeVisibility() {
295
- if (windowCoveringTypes.includes(typeSelect.value)) {
296
- matterTypeLabel.style.display = 'block'
297
- matterTypeSelect.style.display = 'block'
298
- } else {
299
- matterTypeLabel.style.display = 'none'
300
- matterTypeSelect.style.display = 'none'
301
- }
302
- }
303
- typeSelect.addEventListener('change', updateMatterTypeVisibility)
304
- updateMatterTypeVisibility()
305
320
  // Create modal dialog for editing
306
321
  const div = document.createElement('div')
307
322
  div.style.position = 'fixed'
@@ -402,6 +417,90 @@ export async function editDevice(device: any): Promise<void> {
402
417
  typeSelect.appendChild(optgroup)
403
418
  })
404
419
 
420
+ // --- Connection Preference ---
421
+ const connectionPrefLabel = document.createElement('label')
422
+ connectionPrefLabel.textContent = 'Connection Preference'
423
+ connectionPrefLabel.style.display = 'block'
424
+ connectionPrefLabel.style.marginBottom = '6px'
425
+ connectionPrefLabel.style.fontWeight = '500'
426
+ connectionPrefLabel.style.fontSize = '12px'
427
+ connectionPrefLabel.style.color = '#6b7280'
428
+
429
+ const connectionPrefSelect = document.createElement('select')
430
+ connectionPrefSelect.style.width = '100%'
431
+ connectionPrefSelect.style.marginBottom = '12px'
432
+ connectionPrefSelect.style.padding = '8px 10px'
433
+ connectionPrefSelect.style.borderRadius = '6px'
434
+ connectionPrefSelect.style.fontSize = '14px'
435
+ connectionPrefSelect.style.boxSizing = 'border-box'
436
+ ;['auto', 'ble', 'openapi'].forEach((val) => {
437
+ const opt = document.createElement('option')
438
+ opt.value = val
439
+ opt.text = val.charAt(0).toUpperCase() + val.slice(1)
440
+ opt.selected = (device.connectionPreference || 'auto') === val
441
+ connectionPrefSelect.appendChild(opt)
442
+ })
443
+
444
+ // --- Room ---
445
+ const roomLabel = document.createElement('label')
446
+ roomLabel.textContent = 'Room'
447
+ roomLabel.style.display = 'block'
448
+ roomLabel.style.marginBottom = '6px'
449
+ roomLabel.style.fontWeight = '500'
450
+ roomLabel.style.fontSize = '12px'
451
+ roomLabel.style.color = '#6b7280'
452
+
453
+ const roomInput = document.createElement('input')
454
+ roomInput.type = 'text'
455
+ roomInput.value = device.room || ''
456
+ roomInput.placeholder = 'Optional room/location metadata'
457
+ roomInput.style.width = '100%'
458
+ roomInput.style.marginBottom = '12px'
459
+ roomInput.style.padding = '8px 10px'
460
+ roomInput.style.borderRadius = '6px'
461
+ roomInput.style.fontSize = '14px'
462
+ roomInput.style.boxSizing = 'border-box'
463
+
464
+ // --- Encryption Key ---
465
+ const encryptionKeyLabel = document.createElement('label')
466
+ encryptionKeyLabel.textContent = 'BLE Encryption Key (optional)'
467
+ encryptionKeyLabel.style.display = 'block'
468
+ encryptionKeyLabel.style.marginBottom = '6px'
469
+ encryptionKeyLabel.style.fontWeight = '500'
470
+ encryptionKeyLabel.style.fontSize = '12px'
471
+ encryptionKeyLabel.style.color = '#6b7280'
472
+
473
+ const encryptionKeyInput = document.createElement('input')
474
+ encryptionKeyInput.type = 'password'
475
+ encryptionKeyInput.value = device.encryptionKey || ''
476
+ encryptionKeyInput.placeholder = 'Paste device BLE encryption key'
477
+ encryptionKeyInput.style.width = '100%'
478
+ encryptionKeyInput.style.marginBottom = '12px'
479
+ encryptionKeyInput.style.padding = '8px 10px'
480
+ encryptionKeyInput.style.borderRadius = '6px'
481
+ encryptionKeyInput.style.fontSize = '14px'
482
+ encryptionKeyInput.style.boxSizing = 'border-box'
483
+
484
+ // --- Key ID ---
485
+ const keyIdLabel = document.createElement('label')
486
+ keyIdLabel.textContent = 'BLE Key ID (optional)'
487
+ keyIdLabel.style.display = 'block'
488
+ keyIdLabel.style.marginBottom = '6px'
489
+ keyIdLabel.style.fontWeight = '500'
490
+ keyIdLabel.style.fontSize = '12px'
491
+ keyIdLabel.style.color = '#6b7280'
492
+
493
+ const keyIdInput = document.createElement('input')
494
+ keyIdInput.type = 'text'
495
+ keyIdInput.value = device.keyId || ''
496
+ keyIdInput.placeholder = 'e.g. ff'
497
+ keyIdInput.style.width = '100%'
498
+ keyIdInput.style.marginBottom = '12px'
499
+ keyIdInput.style.padding = '8px 10px'
500
+ keyIdInput.style.borderRadius = '6px'
501
+ keyIdInput.style.fontSize = '14px'
502
+ keyIdInput.style.boxSizing = 'border-box'
503
+
405
504
  const errorMessage = document.createElement('div')
406
505
  errorMessage.style.color = 'var(--switchbot-red, #ef4444)'
407
506
  errorMessage.style.marginBottom = '12px'
@@ -442,10 +541,32 @@ export async function editDevice(device: any): Promise<void> {
442
541
  deviceId: device.id,
443
542
  configDeviceName: nameInput.value || undefined,
444
543
  configDeviceType: typeSelect.value,
445
- matterType: windowCoveringTypes.includes(typeSelect.value) && matterTypeSelect.value !== '' ? Number(matterTypeSelect.value) : undefined,
544
+ connectionPreference: connectionPrefSelect.value,
545
+ room: roomInput.value || undefined,
546
+ encryptionKey: encryptionKeyInput.value || undefined,
547
+ keyId: keyIdInput.value || undefined,
548
+ }
549
+
550
+ // Only include defined properties in the options object
551
+ const options: any = {}
552
+ if (params.connectionPreference !== undefined) {
553
+ options.connectionPreference = params.connectionPreference
554
+ }
555
+ if (params.room !== undefined) {
556
+ options.room = params.room
557
+ }
558
+ if (params.encryptionKey !== undefined) {
559
+ options.encryptionKey = params.encryptionKey
560
+ }
561
+ if (params.keyId !== undefined) {
562
+ options.keyId = params.keyId
446
563
  }
447
564
 
448
- await updateDevice(params.deviceId, params.configDeviceName, params.configDeviceType, { matterType: params.matterType })
565
+ await updateDevice(
566
+ params.deviceId,
567
+ params.configDeviceName,
568
+ params.configDeviceType,
569
+ )
449
570
  await syncParentPluginConfigFromDisk()
450
571
 
451
572
  // Refresh device list
@@ -469,8 +590,14 @@ export async function editDevice(device: any): Promise<void> {
469
590
  contentDiv.appendChild(apiTypeInput)
470
591
  contentDiv.appendChild(typeLabel)
471
592
  contentDiv.appendChild(typeSelect)
472
- contentDiv.appendChild(matterTypeLabel)
473
- contentDiv.appendChild(matterTypeSelect)
593
+ contentDiv.appendChild(connectionPrefLabel)
594
+ contentDiv.appendChild(connectionPrefSelect)
595
+ contentDiv.appendChild(roomLabel)
596
+ contentDiv.appendChild(roomInput)
597
+ contentDiv.appendChild(encryptionKeyLabel)
598
+ contentDiv.appendChild(encryptionKeyInput)
599
+ contentDiv.appendChild(keyIdLabel)
600
+ contentDiv.appendChild(keyIdInput)
474
601
  contentDiv.appendChild(errorMessage)
475
602
  contentDiv.appendChild(buttons)
476
603
 
@@ -1,4 +1,9 @@
1
- import type { DEVICE_TYPES } from './constants.js'
1
+ // Move regexes to module scope to avoid re-compilation on every call
2
+ // import type { DEVICE_TYPES } from './constants.js' // Removed unused import
3
+
4
+ const SPACES_REGEX = /\s/g
5
+ const CAMELCASE_REGEX = /([A-Z])/g
6
+ const FIRST_CHAR_REGEX = /^./
2
7
 
3
8
  /**
4
9
  * Get RSSI signal quality level and color based on dBm value
@@ -129,7 +134,9 @@ export function renderBadge(text: string, style: string): HTMLElement {
129
134
  }
130
135
 
131
136
  export function renderConnectionBadge(connectionType: string): HTMLElement | null {
132
- if (!connectionType) { return null }
137
+ if (!connectionType) {
138
+ return null
139
+ }
133
140
 
134
141
  const badge = renderBadge(connectionType, '')
135
142
 
@@ -265,17 +272,27 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
265
272
  let batteryHistory: Array<{ value: number, ts: number }> = []
266
273
  try {
267
274
  const raw = localStorage.getItem(batteryHistoryKey)
268
- if (raw) batteryHistory = JSON.parse(raw)
269
- } catch {}
275
+ if (raw) {
276
+ batteryHistory = JSON.parse(raw)
277
+ }
278
+ } catch (e) {
279
+ // Optionally log or handle error
280
+ }
270
281
  const now = Date.now()
271
282
  if (typeof device?.battery === 'number') {
272
283
  // Only add if different from last or >1h since last
273
- const last = batteryHistory[batteryHistory.length - 1]
284
+ const last = batteryHistory.at(-1)
274
285
  if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
275
286
  batteryHistory.push({ value: device.battery, ts: now })
276
287
  // Keep only last 30 entries (about a month if daily)
277
- if (batteryHistory.length > 30) batteryHistory = batteryHistory.slice(-30)
278
- try { localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory)) } catch {}
288
+ if (batteryHistory.length > 30) {
289
+ batteryHistory = batteryHistory.slice(-30)
290
+ }
291
+ try {
292
+ localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory))
293
+ } catch (e) {
294
+ // Optionally log or handle error
295
+ }
279
296
  }
280
297
  }
281
298
 
@@ -291,8 +308,6 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
291
308
  { label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
292
309
  ]
293
310
 
294
-
295
-
296
311
  for (const row of rows) {
297
312
  const line = document.createElement('div')
298
313
  line.style.display = 'flex'
@@ -366,7 +381,9 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
366
381
  chart.style.height = '28px'
367
382
  chart.style.display = 'flex'
368
383
  // SVG sparkline
369
- const w = 120, h = 24, pad = 2
384
+ const w = 120
385
+ const h = 24
386
+ const pad = 2
370
387
  const min = Math.min(...batteryHistory.map(b => b.value), 100)
371
388
  const max = Math.max(...batteryHistory.map(b => b.value), 0)
372
389
  const range = max - min || 1
@@ -424,11 +441,26 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
424
441
 
425
442
  // --- Expose advanced/extra features dynamically ---
426
443
  const featureKeys = [
427
- 'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
428
- 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
429
- 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
444
+ 'airQuality',
445
+ 'pm25',
446
+ 'pm10',
447
+ 'voc',
448
+ 'co2',
449
+ 'humidity',
450
+ 'temperature',
451
+ 'preset',
452
+ 'mode',
453
+ 'presetMode',
454
+ 'direction',
455
+ 'calibration',
456
+ 'multiCommand',
457
+ 'extendedInfo',
458
+ 'segmentedControl',
459
+ 'features',
460
+ 'capabilities',
461
+ 'state',
430
462
  ]
431
- const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')))
463
+ const shown = new Set(rows.map(r => r.label.toLowerCase().replace(SPACES_REGEX, '')))
432
464
  for (const key of featureKeys) {
433
465
  if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
434
466
  const line = document.createElement('div')
@@ -441,7 +473,7 @@ export function renderDeviceDetailsPanel(device: any): HTMLElement {
441
473
  const label = document.createElement('span')
442
474
  label.style.fontWeight = '600'
443
475
  label.style.minWidth = '110px'
444
- label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
476
+ label.textContent = `${key.replace(CAMELCASE_REGEX, ' $1').replace(FIRST_CHAR_REGEX, s => s.toUpperCase())}:`
445
477
 
446
478
  const value = document.createElement('span')
447
479
  value.style.fontFamily = 'monospace'
@@ -533,37 +565,38 @@ export async function renderDiscoveredDevices(
533
565
 
534
566
  nameContainer.appendChild(selectCheckbox)
535
567
 
536
-
537
568
  nameContainer.appendChild(name)
538
569
 
539
570
  // Show firmware update available indicator if present
540
571
  if (d.firmwareUpdateAvailable) {
541
- const fwBadge = document.createElement('span');
542
- fwBadge.textContent = 'Update Available';
543
- fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
544
- fwBadge.title = 'A firmware update is available for this device.';
545
- nameContainer.appendChild(fwBadge);
572
+ const fwBadge = document.createElement('span')
573
+ fwBadge.textContent = 'Update Available'
574
+ fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
575
+ fwBadge.title = 'A firmware update is available for this device.'
576
+ nameContainer.appendChild(fwBadge)
546
577
  }
547
578
 
548
579
  // Show offline/unreachable indicator if device is offline
549
- let offline = false;
550
- let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
580
+ let offline = false
581
+ const lastSeen = d.lastSeen || d.lastseen || d.updatedAt
551
582
  if (typeof d.offline === 'boolean') {
552
- offline = d.offline;
583
+ offline = d.offline
553
584
  } else if (lastSeen) {
554
585
  try {
555
- const last = new Date(lastSeen).getTime();
556
- if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
557
- offline = true;
586
+ const last = new Date(lastSeen).getTime()
587
+ if (!Number.isNaN(last)) {
588
+ if (Date.now() - last > 1000 * 60 * 60) { // 1 hour
589
+ offline = true
590
+ }
558
591
  }
559
592
  } catch {}
560
593
  }
561
594
  if (offline) {
562
- const offlineBadge = document.createElement('span');
563
- offlineBadge.textContent = 'Offline';
564
- offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
565
- offlineBadge.title = 'Device is offline or unreachable.';
566
- nameContainer.appendChild(offlineBadge);
595
+ const offlineBadge = document.createElement('span')
596
+ offlineBadge.textContent = 'Offline'
597
+ offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
598
+ offlineBadge.title = 'Device is offline or unreachable.'
599
+ nameContainer.appendChild(offlineBadge)
567
600
  }
568
601
 
569
602
  const expandedDetails = document.createElement('div')
@@ -596,7 +629,9 @@ export async function renderDiscoveredDevices(
596
629
  // Add connection type badge
597
630
  if (d.connectionType) {
598
631
  const badge = renderConnectionBadge(d.connectionType)
599
- if (badge) { nameContainer.appendChild(badge) }
632
+ if (badge) {
633
+ nameContainer.appendChild(badge)
634
+ }
600
635
  }
601
636
 
602
637
  // Add IR badge if it's an IR device
@@ -612,14 +647,14 @@ export async function renderDiscoveredDevices(
612
647
 
613
648
  // Add battery warning indicator if battery < 20%
614
649
  if (typeof d.battery === 'number' && d.battery < 20) {
615
- const batteryWarn = document.createElement('span');
616
- batteryWarn.textContent = `⚠️ ${d.battery}%`;
617
- batteryWarn.style.cssText =
618
- d.battery < 10
650
+ const batteryWarn = document.createElement('span')
651
+ batteryWarn.textContent = `⚠️ ${d.battery}%`
652
+ batteryWarn.style.cssText
653
+ = d.battery < 10
619
654
  ? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
620
- : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
621
- batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
622
- nameContainer.appendChild(batteryWarn);
655
+ : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
656
+ batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low'
657
+ nameContainer.appendChild(batteryWarn)
623
658
  }
624
659
 
625
660
  const details = document.createElement('div')
@@ -632,8 +667,12 @@ export async function renderDiscoveredDevices(
632
667
  details.style.wordBreak = 'break-word'
633
668
 
634
669
  let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`
635
- if (d.hubDeviceId) { detailsText += ` | Hub: ${d.hubDeviceId}` }
636
- if (d.address) { detailsText += ` | MAC: ${d.address}` }
670
+ if (d.hubDeviceId) {
671
+ detailsText += ` | Hub: ${d.hubDeviceId}`
672
+ }
673
+ if (d.address) {
674
+ detailsText += ` | MAC: ${d.address}`
675
+ }
637
676
  details.textContent = detailsText
638
677
 
639
678
  info.appendChild(nameContainer)
@@ -727,7 +766,7 @@ export function filterDevices(
727
766
 
728
767
  // Filter by connection type
729
768
  if (connectionType !== 'all') {
730
- filtered = filtered.filter(d => {
769
+ filtered = filtered.filter((d) => {
731
770
  if (connectionType === 'ir') {
732
771
  return d.isIR === true
733
772
  }
@@ -747,7 +786,7 @@ export function filterDevices(
747
786
  // Filter by search query
748
787
  if (searchQuery.trim()) {
749
788
  const query = searchQuery.toLowerCase()
750
- filtered = filtered.filter(d => {
789
+ filtered = filtered.filter((d) => {
751
790
  const name = (d.name || '').toLowerCase()
752
791
  const id = (d.id || '').toLowerCase()
753
792
  const type = (d.type || '').toLowerCase()
@@ -792,11 +831,11 @@ export function sortDevices(
792
831
  case 'connection': {
793
832
  // Sort by connection type: Both > BLE > OpenAPI > Others
794
833
  const connectionOrder: Record<string, number> = {
795
- 'Both': 0,
796
- 'BLE': 1,
797
- 'OpenAPI': 2,
798
- 'API': 2,
799
- 'Unknown': 3,
834
+ Both: 0,
835
+ BLE: 1,
836
+ OpenAPI: 2,
837
+ API: 2,
838
+ Unknown: 3,
800
839
  }
801
840
  sorted.sort((a, b) => {
802
841
  const aOrder = connectionOrder[a.connectionType || 'Unknown'] ?? 3
@@ -844,6 +883,9 @@ export function getDiscoveryPreferences(): {
844
883
  /**
845
884
  * Save filter/sort preferences to localStorage
846
885
  * @param preferences Preferences object to save
886
+ * @param preferences.connectionType Connection type filter
887
+ * @param preferences.sortBy Sort criterion
888
+ * @param preferences.searchQuery Search query string
847
889
  */
848
890
  export function setDiscoveryPreferences(preferences: {
849
891
  connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir'
@@ -862,7 +904,9 @@ export function renderDeviceList(list: any[]): void {
862
904
  const status = document.getElementById('status')
863
905
  const removeAllContainer = document.getElementById('removeAllContainer')
864
906
 
865
- if (!ul || !status) { return }
907
+ if (!ul || !status) {
908
+ return
909
+ }
866
910
 
867
911
  if (!list.length) {
868
912
  status.textContent = 'No devices found in config.'
@@ -1000,7 +1044,12 @@ export function renderDeviceList(list: any[]): void {
1000
1044
  copyBtn.classList.remove('success')
1001
1045
  }, 1200)
1002
1046
  } catch (e) {
1003
- alert('Failed to copy')
1047
+ copyBtn.textContent = 'Failed'
1048
+ copyBtn.classList.add('error')
1049
+ setTimeout(() => {
1050
+ copyBtn.textContent = 'Copy ID'
1051
+ copyBtn.classList.remove('error')
1052
+ }, 1200)
1004
1053
  }
1005
1054
  })
1006
1055
 
@@ -1023,4 +1072,6 @@ export function renderDeviceList(list: any[]): void {
1023
1072
  li.appendChild(buttons)
1024
1073
  ul.appendChild(li)
1025
1074
  }
1075
+
1076
+ // No return value needed for void function
1026
1077
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable no-console */
2
- const PREFIX = '[SwitchBot UI]'
2
+ const PREFIX = '[SwitchBot UI/Server]'
3
3
 
4
4
  export const uiLog = {
5
5
  info: (message: string, ...parameters: any[]) => {