@switchbot/homebridge-switchbot 5.0.0-beta.112 → 5.0.0-beta.114

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 (82) hide show
  1. package/MIGRATION.md +10 -0
  2. package/README.md +43 -0
  3. package/TODO.md +263 -0
  4. package/config.schema.json +27 -10
  5. package/dist/device-types.d.ts.map +1 -1
  6. package/dist/device-types.js +93 -63
  7. package/dist/device-types.js.map +1 -1
  8. package/dist/deviceFactory.d.ts.map +1 -1
  9. package/dist/deviceFactory.js +55 -33
  10. package/dist/deviceFactory.js.map +1 -1
  11. package/dist/devices/genericDevice.d.ts +12 -1
  12. package/dist/devices/genericDevice.d.ts.map +1 -1
  13. package/dist/devices/genericDevice.js +121 -1
  14. package/dist/devices/genericDevice.js.map +1 -1
  15. package/dist/errors.d.ts +5 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +51 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -1
  20. package/dist/homebridge-ui/endpoints/devices.js +80 -1
  21. package/dist/homebridge-ui/endpoints/devices.js.map +1 -1
  22. package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -1
  23. package/dist/homebridge-ui/endpoints/discovery.js +73 -41
  24. package/dist/homebridge-ui/endpoints/discovery.js.map +1 -1
  25. package/dist/homebridge-ui/public/css/styles.css +98 -0
  26. package/dist/homebridge-ui/public/index.html +57 -0
  27. package/dist/homebridge-ui/public/js/api.d.ts +24 -1
  28. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  29. package/dist/homebridge-ui/public/js/api.js +33 -2
  30. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  31. package/dist/homebridge-ui/public/js/api.ts +52 -3
  32. package/dist/homebridge-ui/public/js/app.js +1560 -104
  33. package/dist/homebridge-ui/public/js/app.js.map +3 -3
  34. package/dist/homebridge-ui/public/js/app.ts +2 -1
  35. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  36. package/dist/homebridge-ui/public/js/devices.js +2 -0
  37. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  38. package/dist/homebridge-ui/public/js/devices.ts +13 -1
  39. package/dist/homebridge-ui/public/js/discovery.d.ts +1 -0
  40. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -1
  41. package/dist/homebridge-ui/public/js/discovery.js +1223 -126
  42. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/discovery.ts +1323 -145
  44. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  45. package/dist/homebridge-ui/public/js/modals.js +42 -0
  46. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  47. package/dist/homebridge-ui/public/js/modals.ts +50 -1
  48. package/dist/homebridge-ui/public/js/render.d.ts +3 -0
  49. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  50. package/dist/homebridge-ui/public/js/render.js +373 -1
  51. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/render.ts +405 -2
  53. package/dist/platform.d.ts +1 -0
  54. package/dist/platform.d.ts.map +1 -1
  55. package/dist/platform.js +7 -3
  56. package/dist/platform.js.map +1 -1
  57. package/dist/switchbotClient.d.ts.map +1 -1
  58. package/dist/switchbotClient.js +51 -5
  59. package/dist/switchbotClient.js.map +1 -1
  60. package/docs/assets/highlight.css +9 -2
  61. package/docs/index.html +28 -2
  62. package/docs/variables/default.html +1 -1
  63. package/package.json +2 -2
  64. package/src/device-types.ts +95 -64
  65. package/src/deviceFactory.ts +55 -33
  66. package/src/devices/genericDevice.ts +113 -1
  67. package/src/errors.ts +43 -0
  68. package/src/homebridge-ui/endpoints/devices.ts +82 -1
  69. package/src/homebridge-ui/endpoints/discovery.ts +78 -44
  70. package/src/homebridge-ui/public/css/styles.css +98 -0
  71. package/src/homebridge-ui/public/index.html +57 -0
  72. package/src/homebridge-ui/public/js/api.ts +52 -3
  73. package/src/homebridge-ui/public/js/app.ts +2 -1
  74. package/src/homebridge-ui/public/js/devices.ts +13 -1
  75. package/src/homebridge-ui/public/js/discovery.ts +1323 -145
  76. package/src/homebridge-ui/public/js/modals.ts +50 -1
  77. package/src/homebridge-ui/public/js/render.ts +405 -2
  78. package/src/platform.ts +7 -3
  79. package/src/switchbotClient.ts +44 -9
  80. package/test/matter-integration.spec.ts +21 -0
  81. package/todo/COMPLETED.md +300 -0
  82. package/todo/TODO.md +80 -66
@@ -111,6 +111,47 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
111
111
  macInput.style.fontSize = '14px'
112
112
  macInput.style.boxSizing = 'border-box'
113
113
 
114
+
115
+ // BLE Encryption Key
116
+ const encryptionKeyLabel = document.createElement('label')
117
+ encryptionKeyLabel.textContent = 'BLE Encryption Key (optional)'
118
+ encryptionKeyLabel.style.display = 'block'
119
+ encryptionKeyLabel.style.marginBottom = '6px'
120
+ encryptionKeyLabel.style.fontWeight = '500'
121
+ encryptionKeyLabel.style.fontSize = '12px'
122
+ encryptionKeyLabel.style.color = '#6b7280'
123
+
124
+ const encryptionKeyInput = document.createElement('input')
125
+ encryptionKeyInput.type = 'password'
126
+ encryptionKeyInput.value = device.encryptionKey || ''
127
+ encryptionKeyInput.placeholder = 'Paste device BLE encryption key'
128
+ encryptionKeyInput.style.width = '100%'
129
+ encryptionKeyInput.style.marginBottom = '12px'
130
+ encryptionKeyInput.style.padding = '8px 10px'
131
+ encryptionKeyInput.style.borderRadius = '6px'
132
+ encryptionKeyInput.style.fontSize = '14px'
133
+ encryptionKeyInput.style.boxSizing = 'border-box'
134
+
135
+ // BLE Key ID
136
+ const keyIdLabel = document.createElement('label')
137
+ keyIdLabel.textContent = 'BLE Key ID (optional)'
138
+ keyIdLabel.style.display = 'block'
139
+ keyIdLabel.style.marginBottom = '6px'
140
+ keyIdLabel.style.fontWeight = '500'
141
+ keyIdLabel.style.fontSize = '12px'
142
+ keyIdLabel.style.color = '#6b7280'
143
+
144
+ const keyIdInput = document.createElement('input')
145
+ keyIdInput.type = 'text'
146
+ keyIdInput.value = device.keyId || ''
147
+ keyIdInput.placeholder = 'e.g. ff'
148
+ keyIdInput.style.width = '100%'
149
+ keyIdInput.style.marginBottom = '12px'
150
+ keyIdInput.style.padding = '8px 10px'
151
+ keyIdInput.style.borderRadius = '6px'
152
+ keyIdInput.style.fontSize = '14px'
153
+ keyIdInput.style.boxSizing = 'border-box'
154
+
114
155
  const buttons = document.createElement('div')
115
156
  buttons.style.display = 'flex'
116
157
  buttons.style.gap = '10px'
@@ -132,7 +173,8 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
132
173
  importBtn.style.padding = '8px 20px'
133
174
  importBtn.style.fontSize = '13px'
134
175
 
135
- const cleanup = (result: { configDeviceName: string, configDeviceType: string, address?: string } | null) => {
176
+
177
+ const cleanup = (result: { configDeviceName: string, configDeviceType: string, address?: string, encryptionKey?: string, keyId?: string } | null) => {
136
178
  div.remove()
137
179
  resolve(result)
138
180
  }
@@ -142,6 +184,8 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
142
184
  configDeviceName: nameInput.value || device.name || device.id,
143
185
  configDeviceType: typeSelect.value || device.type,
144
186
  address: macInput.value || undefined,
187
+ encryptionKey: encryptionKeyInput.value || undefined,
188
+ keyId: keyIdInput.value || undefined,
145
189
  })
146
190
 
147
191
  div.addEventListener('click', (event) => {
@@ -150,6 +194,7 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
150
194
  }
151
195
  })
152
196
 
197
+
153
198
  buttons.appendChild(cancelBtn)
154
199
  buttons.appendChild(importBtn)
155
200
  contentDiv.appendChild(nameLabel)
@@ -158,6 +203,10 @@ export async function importDiscoveredDevice(device: any): Promise<{ configDevic
158
203
  contentDiv.appendChild(typeSelect)
159
204
  contentDiv.appendChild(macLabel)
160
205
  contentDiv.appendChild(macInput)
206
+ contentDiv.appendChild(encryptionKeyLabel)
207
+ contentDiv.appendChild(encryptionKeyInput)
208
+ contentDiv.appendChild(keyIdLabel)
209
+ contentDiv.appendChild(keyIdInput)
161
210
  contentDiv.appendChild(buttons)
162
211
 
163
212
  modal.appendChild(title)
@@ -176,9 +176,299 @@ function scrollToConfiguredDevice(deviceId: string): void {
176
176
  }, 1800)
177
177
  }
178
178
 
179
+ function createConnectionTestControls(device: any): HTMLElement {
180
+ const controls = document.createElement('div')
181
+ controls.style.display = 'inline-flex'
182
+ controls.style.alignItems = 'center'
183
+ controls.style.gap = '6px'
184
+
185
+ const button = document.createElement('button')
186
+ button.textContent = 'Test Connection'
187
+ button.className = 'secondary'
188
+ button.style.padding = '4px 9px'
189
+ button.style.fontSize = '11px'
190
+
191
+ const status = document.createElement('span')
192
+ status.style.fontSize = '10px'
193
+ status.style.opacity = '0.85'
194
+ status.style.whiteSpace = 'normal'
195
+ status.style.overflowWrap = 'anywhere'
196
+
197
+ button.onclick = async () => {
198
+ const startedAt = Date.now()
199
+ button.disabled = true
200
+ button.textContent = 'Testing...'
201
+ status.textContent = 'Checking...'
202
+ status.style.color = '#6b7280'
203
+
204
+ try {
205
+ const { testDeviceConnection } = await import('./api.js')
206
+ const result = await testDeviceConnection({
207
+ deviceId: String(device?.id || device?.deviceId || ''),
208
+ connectionType: device?.connectionType,
209
+ address: device?.address,
210
+ })
211
+
212
+ const measuredLatency = Number(result?.latencyMs) > 0
213
+ ? Number(result.latencyMs)
214
+ : Date.now() - startedAt
215
+
216
+ if (result?.success) {
217
+ const method = result?.method || 'Auto'
218
+ status.textContent = `✓ ${method} · ${measuredLatency}ms`
219
+ status.style.color = '#16a34a'
220
+ } else {
221
+ const detail = result?.message ? ` · ${result.message}` : ''
222
+ status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`
223
+ status.style.color = '#dc2626'
224
+ }
225
+ } catch (e) {
226
+ status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`
227
+ status.style.color = '#dc2626'
228
+ } finally {
229
+ button.disabled = false
230
+ button.textContent = 'Test Connection'
231
+ }
232
+ }
233
+
234
+ controls.appendChild(button)
235
+ controls.appendChild(status)
236
+ return controls
237
+ }
238
+
239
+ function formatLastSeen(value: any): string {
240
+ if (!value) {
241
+ return 'N/A'
242
+ }
243
+ try {
244
+ const date = new Date(value)
245
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString()
246
+ } catch (_e) {
247
+ return String(value)
248
+ }
249
+ }
250
+
251
+ export function renderDeviceDetailsPanel(device: any): HTMLElement {
252
+ const details = document.createElement('div')
253
+ details.style.cssText = `
254
+ border-top: 1px solid #ddd;
255
+ padding: 8px;
256
+ background: #f9fafb;
257
+ border-radius: 4px;
258
+ font-size: 12px;
259
+ margin-top: 4px;
260
+ `
261
+
262
+ // --- Battery history trending ---
263
+ // Persist battery readings in localStorage per device
264
+ const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`
265
+ let batteryHistory: Array<{ value: number, ts: number }> = []
266
+ try {
267
+ const raw = localStorage.getItem(batteryHistoryKey)
268
+ if (raw) batteryHistory = JSON.parse(raw)
269
+ } catch {}
270
+ const now = Date.now()
271
+ if (typeof device?.battery === 'number') {
272
+ // Only add if different from last or >1h since last
273
+ const last = batteryHistory[batteryHistory.length - 1]
274
+ if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
275
+ batteryHistory.push({ value: device.battery, ts: now })
276
+ // 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 {}
279
+ }
280
+ }
281
+
282
+ const rows: Array<{ label: string, value: string, copyable?: boolean }> = [
283
+ { label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) },
284
+ { label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address },
285
+ { label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') },
286
+ { label: 'Model', value: String(device?.model || 'N/A') },
287
+ { label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') },
288
+ { label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' },
289
+ { label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') },
290
+ { label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' },
291
+ { label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
292
+ ]
293
+
294
+
295
+
296
+ for (const row of rows) {
297
+ const line = document.createElement('div')
298
+ line.style.display = 'flex'
299
+ line.style.alignItems = 'center'
300
+ line.style.justifyContent = 'space-between'
301
+ line.style.gap = '8px'
302
+ line.style.padding = '2px 0'
303
+
304
+ const label = document.createElement('span')
305
+ label.style.fontWeight = '600'
306
+ label.style.minWidth = '110px'
307
+ label.textContent = `${row.label}:`
308
+
309
+ const valueWrap = document.createElement('span')
310
+ valueWrap.style.display = 'inline-flex'
311
+ valueWrap.style.alignItems = 'center'
312
+ valueWrap.style.gap = '6px'
313
+ valueWrap.style.flex = '1'
314
+ valueWrap.style.justifyContent = 'flex-end'
315
+ valueWrap.style.minWidth = '0'
316
+
317
+ const value = document.createElement('span')
318
+ value.style.fontFamily = 'monospace'
319
+ value.style.fontSize = '11px'
320
+ value.style.opacity = '0.9'
321
+ value.style.whiteSpace = 'normal'
322
+ value.style.overflowWrap = 'anywhere'
323
+ value.style.wordBreak = 'break-word'
324
+ value.style.textAlign = 'right'
325
+ value.textContent = row.value
326
+
327
+ valueWrap.appendChild(value)
328
+
329
+ if (row.copyable && row.value && row.value !== 'N/A') {
330
+ const copyBtn = document.createElement('button')
331
+ copyBtn.textContent = '📋'
332
+ copyBtn.title = `Copy ${row.label}`
333
+ copyBtn.style.padding = '2px 6px'
334
+ copyBtn.style.fontSize = '10px'
335
+ copyBtn.style.lineHeight = '1'
336
+ copyBtn.style.background = '#e5e7eb'
337
+ copyBtn.style.color = '#111827'
338
+
339
+ copyBtn.onclick = async () => {
340
+ try {
341
+ await navigator.clipboard.writeText(row.value)
342
+ copyBtn.textContent = '✓'
343
+ setTimeout(() => {
344
+ copyBtn.textContent = '📋'
345
+ }, 1200)
346
+ } catch (_e) {
347
+ copyBtn.textContent = '!'
348
+ setTimeout(() => {
349
+ copyBtn.textContent = '📋'
350
+ }, 1200)
351
+ }
352
+ }
353
+
354
+ valueWrap.appendChild(copyBtn)
355
+ }
356
+
357
+ line.appendChild(label)
358
+ line.appendChild(valueWrap)
359
+ details.appendChild(line)
360
+
361
+ // If this is the Battery row, add a sparkline chart below
362
+ if (row.label === 'Battery' && Array.isArray(batteryHistory) && batteryHistory.length > 1) {
363
+ const chart = document.createElement('div')
364
+ chart.style.margin = '2px 0 8px 0'
365
+ chart.style.width = '100%'
366
+ chart.style.height = '28px'
367
+ chart.style.display = 'flex'
368
+ // SVG sparkline
369
+ const w = 120, h = 24, pad = 2
370
+ const min = Math.min(...batteryHistory.map(b => b.value), 100)
371
+ const max = Math.max(...batteryHistory.map(b => b.value), 0)
372
+ const range = max - min || 1
373
+ const points = batteryHistory.map((b, i) => {
374
+ const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
375
+ const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
376
+ return `${x},${y}`
377
+ }).join(' ')
378
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
379
+ svg.setAttribute('width', String(w))
380
+ svg.setAttribute('height', String(h))
381
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
382
+ svg.style.display = 'block'
383
+ svg.style.background = '#f3f4f6'
384
+ svg.style.borderRadius = '3px'
385
+ svg.style.marginTop = '2px'
386
+ svg.style.boxShadow = '0 1px 2px #0001'
387
+ // Polyline for trend
388
+ const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
389
+ polyline.setAttribute('points', points)
390
+ polyline.setAttribute('fill', 'none')
391
+ polyline.setAttribute('stroke', '#2563eb')
392
+ polyline.setAttribute('stroke-width', '2')
393
+ svg.appendChild(polyline)
394
+ // Dots for each point
395
+ batteryHistory.forEach((b, i) => {
396
+ const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1)
397
+ const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range)
398
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
399
+ circle.setAttribute('cx', String(x))
400
+ circle.setAttribute('cy', String(y))
401
+ circle.setAttribute('r', '2.5')
402
+ circle.setAttribute('fill', '#2563eb')
403
+ svg.appendChild(circle)
404
+ })
405
+ // Min/max labels
406
+ const minLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
407
+ minLabel.setAttribute('x', '2')
408
+ minLabel.setAttribute('y', String(h - 2))
409
+ minLabel.setAttribute('font-size', '9')
410
+ minLabel.setAttribute('fill', '#888')
411
+ minLabel.textContent = `${min}%`
412
+ svg.appendChild(minLabel)
413
+ const maxLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')
414
+ maxLabel.setAttribute('x', String(w - 18))
415
+ maxLabel.setAttribute('y', '10')
416
+ maxLabel.setAttribute('font-size', '9')
417
+ maxLabel.setAttribute('fill', '#888')
418
+ maxLabel.textContent = `${max}%`
419
+ svg.appendChild(maxLabel)
420
+ chart.appendChild(svg)
421
+ details.appendChild(chart)
422
+ }
423
+ }
424
+
425
+ // --- Expose advanced/extra features dynamically ---
426
+ const featureKeys = [
427
+ 'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
428
+ 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
429
+ 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
430
+ ]
431
+ const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')))
432
+ for (const key of featureKeys) {
433
+ if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
434
+ const line = document.createElement('div')
435
+ line.style.display = 'flex'
436
+ line.style.alignItems = 'center'
437
+ line.style.justifyContent = 'space-between'
438
+ line.style.gap = '8px'
439
+ line.style.padding = '2px 0'
440
+
441
+ const label = document.createElement('span')
442
+ label.style.fontWeight = '600'
443
+ label.style.minWidth = '110px'
444
+ label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
445
+
446
+ const value = document.createElement('span')
447
+ value.style.fontFamily = 'monospace'
448
+ value.style.fontSize = '11px'
449
+ value.style.opacity = '0.9'
450
+ value.style.whiteSpace = 'normal'
451
+ value.style.overflowWrap = 'anywhere'
452
+ value.style.wordBreak = 'break-word'
453
+ value.style.textAlign = 'right'
454
+ value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key])
455
+
456
+ line.appendChild(label)
457
+ line.appendChild(value)
458
+ details.appendChild(line)
459
+ }
460
+ }
461
+
462
+ return details
463
+ }
464
+
179
465
  export async function renderDiscoveredDevices(
180
466
  devices: any[],
181
- options: { configuredIds?: Set<string> } = {},
467
+ options: {
468
+ configuredIds?: Set<string>
469
+ selectedIds?: Set<string>
470
+ onToggleSelect?: (device: any, selected: boolean) => void
471
+ } = {},
182
472
  ): Promise<HTMLElement> {
183
473
  const ul = document.createElement('ul')
184
474
  ul.className = 'device-grid'
@@ -191,6 +481,8 @@ export async function renderDiscoveredDevices(
191
481
  const { addDeviceToConfig } = await import('./discovery.js')
192
482
  const { loadConfiguredDevices } = await import('./devices.js')
193
483
  const configuredIds = options.configuredIds ?? new Set<string>()
484
+ const selectedIds = options.selectedIds ?? new Set<string>()
485
+ const onToggleSelect = options.onToggleSelect
194
486
 
195
487
  for (const d of devices) {
196
488
  const deviceId = normalizeId(d.id)
@@ -224,8 +516,76 @@ export async function renderDiscoveredDevices(
224
516
  name.style.fontSize = '13px'
225
517
  name.textContent = d.name || d.id
226
518
 
519
+ const selectCheckbox = document.createElement('input')
520
+ selectCheckbox.type = 'checkbox'
521
+ selectCheckbox.style.width = 'auto'
522
+ selectCheckbox.style.margin = '0 2px 0 0'
523
+ selectCheckbox.checked = selectedIds.has(deviceId)
524
+ if (alreadyAdded) {
525
+ selectCheckbox.disabled = true
526
+ selectCheckbox.title = 'Already configured'
527
+ }
528
+ selectCheckbox.onchange = () => {
529
+ onToggleSelect?.(d, selectCheckbox.checked)
530
+ // Notify listeners (e.g., batch buttons) of selection change
531
+ window.dispatchEvent(new CustomEvent('discovery-selection-changed'))
532
+ }
533
+
534
+ nameContainer.appendChild(selectCheckbox)
535
+
536
+
227
537
  nameContainer.appendChild(name)
228
538
 
539
+ // Show firmware update available indicator if present
540
+ 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);
546
+ }
547
+
548
+ // Show offline/unreachable indicator if device is offline
549
+ let offline = false;
550
+ let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
551
+ if (typeof d.offline === 'boolean') {
552
+ offline = d.offline;
553
+ } else if (lastSeen) {
554
+ try {
555
+ const last = new Date(lastSeen).getTime();
556
+ if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
557
+ offline = true;
558
+ }
559
+ } catch {}
560
+ }
561
+ 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);
567
+ }
568
+
569
+ const expandedDetails = document.createElement('div')
570
+ expandedDetails.style.display = 'none'
571
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
572
+
573
+ const expandBtn = document.createElement('button')
574
+ expandBtn.textContent = '▾'
575
+ expandBtn.title = 'Show details'
576
+ expandBtn.style.padding = '2px 6px'
577
+ expandBtn.style.fontSize = '11px'
578
+ expandBtn.style.marginLeft = '4px'
579
+ expandBtn.style.background = '#e5e7eb'
580
+ expandBtn.style.color = '#111827'
581
+ expandBtn.style.transition = 'transform 0.2s ease'
582
+ expandBtn.onclick = () => {
583
+ const isHidden = expandedDetails.style.display === 'none'
584
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
585
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
586
+ }
587
+ nameContainer.appendChild(expandBtn)
588
+
229
589
  const duplicateBadge = document.createElement('span')
230
590
  duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
231
591
  duplicateBadge.style.cssText = alreadyAdded
@@ -250,6 +610,18 @@ export async function renderDiscoveredDevices(
250
610
  nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
251
611
  }
252
612
 
613
+ // Add battery warning indicator if battery < 20%
614
+ 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
619
+ ? '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);
623
+ }
624
+
253
625
  const details = document.createElement('div')
254
626
  details.style.fontSize = '10px'
255
627
  details.style.opacity = '0.7'
@@ -266,6 +638,7 @@ export async function renderDiscoveredDevices(
266
638
 
267
639
  info.appendChild(nameContainer)
268
640
  info.appendChild(details)
641
+ info.appendChild(expandedDetails)
269
642
 
270
643
  const addBtn = document.createElement('button')
271
644
  addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
@@ -312,6 +685,7 @@ export async function renderDiscoveredDevices(
312
685
  actions.style.gap = '5px'
313
686
  actions.appendChild(viewBtn)
314
687
  actions.appendChild(addBtn)
688
+ actions.appendChild(createConnectionTestControls(d))
315
689
  li.appendChild(actions)
316
690
  ul.appendChild(li)
317
691
  continue
@@ -327,6 +701,7 @@ export async function renderDiscoveredDevices(
327
701
  actions.style.marginTop = '2px'
328
702
  actions.style.gap = '5px'
329
703
  actions.appendChild(addBtn)
704
+ actions.appendChild(createConnectionTestControls(d))
330
705
 
331
706
  li.appendChild(info)
332
707
  li.appendChild(actions)
@@ -533,6 +908,25 @@ export function renderDeviceList(list: any[]): void {
533
908
  name.style.fontSize = '13px'
534
909
  name.textContent = d.name || d.id
535
910
 
911
+ const expandedDetails = document.createElement('div')
912
+ expandedDetails.style.display = 'none'
913
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
914
+
915
+ const expandBtn = document.createElement('button')
916
+ expandBtn.textContent = '▾'
917
+ expandBtn.title = 'Show details'
918
+ expandBtn.style.padding = '2px 6px'
919
+ expandBtn.style.fontSize = '11px'
920
+ expandBtn.style.marginLeft = '4px'
921
+ expandBtn.style.background = '#e5e7eb'
922
+ expandBtn.style.color = '#111827'
923
+ expandBtn.style.transition = 'transform 0.2s ease'
924
+ expandBtn.onclick = () => {
925
+ const isHidden = expandedDetails.style.display === 'none'
926
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
927
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
928
+ }
929
+
536
930
  const code = document.createElement('code')
537
931
  code.textContent = d.id
538
932
  code.style.fontSize = '10px'
@@ -543,7 +937,14 @@ export function renderDeviceList(list: any[]): void {
543
937
  code.style.wordBreak = 'break-word'
544
938
  code.style.maxWidth = '100%'
545
939
 
546
- nameContainer.appendChild(name)
940
+ const headerRow = document.createElement('div')
941
+ headerRow.style.display = 'inline-flex'
942
+ headerRow.style.alignItems = 'center'
943
+ headerRow.style.gap = '4px'
944
+ headerRow.appendChild(name)
945
+ headerRow.appendChild(expandBtn)
946
+
947
+ nameContainer.appendChild(headerRow)
547
948
  nameContainer.appendChild(code)
548
949
 
549
950
  // Add signal strength visualization if RSSI is available
@@ -564,6 +965,7 @@ export function renderDeviceList(list: any[]): void {
564
965
 
565
966
  info.appendChild(nameContainer)
566
967
  info.appendChild(meta)
968
+ info.appendChild(expandedDetails)
567
969
 
568
970
  const buttons = document.createElement('div')
569
971
  buttons.className = 'device-actions'
@@ -614,6 +1016,7 @@ export function renderDeviceList(list: any[]): void {
614
1016
 
615
1017
  buttons.appendChild(editBtn)
616
1018
  buttons.appendChild(copyBtn)
1019
+ buttons.appendChild(createConnectionTestControls(d))
617
1020
  buttons.appendChild(deleteBtn)
618
1021
 
619
1022
  li.appendChild(info)
package/src/platform.ts CHANGED
@@ -65,7 +65,7 @@ const DEVICE_MATTER_SUPPORTED: Record<string, boolean> = {
65
65
  // Default Matter cluster configurations by device type
66
66
  // Maps device types to their Matter cluster states (used when device doesn't provide clusters)
67
67
  // Note: wosweeper/curtain/plug variants are normalized before cluster lookup (see loadDevices)
68
- const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
68
+ export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
69
69
  // Core devices - aligned with HAP service implementations
70
70
  bot: { onOff: { onOff: false } }, // Switch → OnOff
71
71
  vacuum: {
@@ -97,7 +97,7 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
97
97
  }, // Switch in HAP, RobotVacuumCleaner in Matter
98
98
  curtain: {
99
99
  windowCovering: {
100
- // Use a lift-only/non-tilt covering type to satisfy Matter conformance rules.
100
+ // Use Rollershade (0) for lift-only/non-tilt covering to match test expectation.
101
101
  // Drapery (4) can fail validation in some stacks when TL (tilt) is not present.
102
102
  type: 0, // Rollershade
103
103
  },
@@ -132,7 +132,9 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
132
132
  minLevel: 0,
133
133
  maxLevel: 254,
134
134
  },
135
- colorControl: {},
135
+ colorControl: {
136
+ colorMode: 0,
137
+ },
136
138
  }, // Lightbulb with color → OnOff + LevelControl + ColorControl
137
139
  lock: {
138
140
  doorLock: {
@@ -1094,6 +1096,8 @@ export class SwitchBotHAPPlatform {
1094
1096
  id: raw.deviceId ?? raw.id,
1095
1097
  name: raw.configDeviceName ?? raw.name,
1096
1098
  type: raw.configDeviceType ?? raw.type ?? raw.deviceType ?? 'unknown',
1099
+ encryptionKey: raw.encryptionKey,
1100
+ keyId: raw.keyId,
1097
1101
  _raw: raw,
1098
1102
  }
1099
1103