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

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 +1503 -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 +311 -1
  51. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/render.ts +341 -2
  53. package/dist/platform.d.ts +1 -0
  54. package/dist/platform.d.ts.map +1 -1
  55. package/dist/platform.js +6 -2
  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 +341 -2
  78. package/src/platform.ts +6 -2
  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,235 @@ 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
+ for (const row of rows) {
296
+ const line = document.createElement('div')
297
+ line.style.display = 'flex'
298
+ line.style.alignItems = 'center'
299
+ line.style.justifyContent = 'space-between'
300
+ line.style.gap = '8px'
301
+ line.style.padding = '2px 0'
302
+
303
+ const label = document.createElement('span')
304
+ label.style.fontWeight = '600'
305
+ label.style.minWidth = '110px'
306
+ label.textContent = `${row.label}:`
307
+
308
+ const valueWrap = document.createElement('span')
309
+ valueWrap.style.display = 'inline-flex'
310
+ valueWrap.style.alignItems = 'center'
311
+ valueWrap.style.gap = '6px'
312
+ valueWrap.style.flex = '1'
313
+ valueWrap.style.justifyContent = 'flex-end'
314
+ valueWrap.style.minWidth = '0'
315
+
316
+ const value = document.createElement('span')
317
+ value.style.fontFamily = 'monospace'
318
+ value.style.fontSize = '11px'
319
+ value.style.opacity = '0.9'
320
+ value.style.whiteSpace = 'normal'
321
+ value.style.overflowWrap = 'anywhere'
322
+ value.style.wordBreak = 'break-word'
323
+ value.style.textAlign = 'right'
324
+ value.textContent = row.value
325
+
326
+ valueWrap.appendChild(value)
327
+
328
+ if (row.copyable && row.value && row.value !== 'N/A') {
329
+ const copyBtn = document.createElement('button')
330
+ copyBtn.textContent = '📋'
331
+ copyBtn.title = `Copy ${row.label}`
332
+ copyBtn.style.padding = '2px 6px'
333
+ copyBtn.style.fontSize = '10px'
334
+ copyBtn.style.lineHeight = '1'
335
+ copyBtn.style.background = '#e5e7eb'
336
+ copyBtn.style.color = '#111827'
337
+
338
+ copyBtn.onclick = async () => {
339
+ try {
340
+ await navigator.clipboard.writeText(row.value)
341
+ copyBtn.textContent = '✓'
342
+ setTimeout(() => {
343
+ copyBtn.textContent = '📋'
344
+ }, 1200)
345
+ } catch (_e) {
346
+ copyBtn.textContent = '!'
347
+ setTimeout(() => {
348
+ copyBtn.textContent = '📋'
349
+ }, 1200)
350
+ }
351
+ }
352
+
353
+ valueWrap.appendChild(copyBtn)
354
+ }
355
+
356
+ line.appendChild(label)
357
+ line.appendChild(valueWrap)
358
+ details.appendChild(line)
359
+ }
360
+
361
+ // --- Expose advanced/extra features dynamically ---
362
+ const featureKeys = [
363
+ 'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
364
+ 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
365
+ 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
366
+ ]
367
+ const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')))
368
+ for (const key of featureKeys) {
369
+ if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
370
+ const line = document.createElement('div')
371
+ line.style.display = 'flex'
372
+ line.style.alignItems = 'center'
373
+ line.style.justifyContent = 'space-between'
374
+ line.style.gap = '8px'
375
+ line.style.padding = '2px 0'
376
+
377
+ const label = document.createElement('span')
378
+ label.style.fontWeight = '600'
379
+ label.style.minWidth = '110px'
380
+ label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':'
381
+
382
+ const value = document.createElement('span')
383
+ value.style.fontFamily = 'monospace'
384
+ value.style.fontSize = '11px'
385
+ value.style.opacity = '0.9'
386
+ value.style.whiteSpace = 'normal'
387
+ value.style.overflowWrap = 'anywhere'
388
+ value.style.wordBreak = 'break-word'
389
+ value.style.textAlign = 'right'
390
+ value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key])
391
+
392
+ line.appendChild(label)
393
+ line.appendChild(value)
394
+ details.appendChild(line)
395
+ }
396
+ }
397
+
398
+ return details
399
+ }
400
+
179
401
  export async function renderDiscoveredDevices(
180
402
  devices: any[],
181
- options: { configuredIds?: Set<string> } = {},
403
+ options: {
404
+ configuredIds?: Set<string>
405
+ selectedIds?: Set<string>
406
+ onToggleSelect?: (device: any, selected: boolean) => void
407
+ } = {},
182
408
  ): Promise<HTMLElement> {
183
409
  const ul = document.createElement('ul')
184
410
  ul.className = 'device-grid'
@@ -191,6 +417,8 @@ export async function renderDiscoveredDevices(
191
417
  const { addDeviceToConfig } = await import('./discovery.js')
192
418
  const { loadConfiguredDevices } = await import('./devices.js')
193
419
  const configuredIds = options.configuredIds ?? new Set<string>()
420
+ const selectedIds = options.selectedIds ?? new Set<string>()
421
+ const onToggleSelect = options.onToggleSelect
194
422
 
195
423
  for (const d of devices) {
196
424
  const deviceId = normalizeId(d.id)
@@ -224,8 +452,76 @@ export async function renderDiscoveredDevices(
224
452
  name.style.fontSize = '13px'
225
453
  name.textContent = d.name || d.id
226
454
 
455
+ const selectCheckbox = document.createElement('input')
456
+ selectCheckbox.type = 'checkbox'
457
+ selectCheckbox.style.width = 'auto'
458
+ selectCheckbox.style.margin = '0 2px 0 0'
459
+ selectCheckbox.checked = selectedIds.has(deviceId)
460
+ if (alreadyAdded) {
461
+ selectCheckbox.disabled = true
462
+ selectCheckbox.title = 'Already configured'
463
+ }
464
+ selectCheckbox.onchange = () => {
465
+ onToggleSelect?.(d, selectCheckbox.checked)
466
+ // Notify listeners (e.g., batch buttons) of selection change
467
+ window.dispatchEvent(new CustomEvent('discovery-selection-changed'))
468
+ }
469
+
470
+ nameContainer.appendChild(selectCheckbox)
471
+
472
+
227
473
  nameContainer.appendChild(name)
228
474
 
475
+ // Show firmware update available indicator if present
476
+ if (d.firmwareUpdateAvailable) {
477
+ const fwBadge = document.createElement('span');
478
+ fwBadge.textContent = 'Update Available';
479
+ fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
480
+ fwBadge.title = 'A firmware update is available for this device.';
481
+ nameContainer.appendChild(fwBadge);
482
+ }
483
+
484
+ // Show offline/unreachable indicator if device is offline
485
+ let offline = false;
486
+ let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
487
+ if (typeof d.offline === 'boolean') {
488
+ offline = d.offline;
489
+ } else if (lastSeen) {
490
+ try {
491
+ const last = new Date(lastSeen).getTime();
492
+ if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
493
+ offline = true;
494
+ }
495
+ } catch {}
496
+ }
497
+ if (offline) {
498
+ const offlineBadge = document.createElement('span');
499
+ offlineBadge.textContent = 'Offline';
500
+ offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
501
+ offlineBadge.title = 'Device is offline or unreachable.';
502
+ nameContainer.appendChild(offlineBadge);
503
+ }
504
+
505
+ const expandedDetails = document.createElement('div')
506
+ expandedDetails.style.display = 'none'
507
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
508
+
509
+ const expandBtn = document.createElement('button')
510
+ expandBtn.textContent = '▾'
511
+ expandBtn.title = 'Show details'
512
+ expandBtn.style.padding = '2px 6px'
513
+ expandBtn.style.fontSize = '11px'
514
+ expandBtn.style.marginLeft = '4px'
515
+ expandBtn.style.background = '#e5e7eb'
516
+ expandBtn.style.color = '#111827'
517
+ expandBtn.style.transition = 'transform 0.2s ease'
518
+ expandBtn.onclick = () => {
519
+ const isHidden = expandedDetails.style.display === 'none'
520
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
521
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
522
+ }
523
+ nameContainer.appendChild(expandBtn)
524
+
229
525
  const duplicateBadge = document.createElement('span')
230
526
  duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
231
527
  duplicateBadge.style.cssText = alreadyAdded
@@ -250,6 +546,18 @@ export async function renderDiscoveredDevices(
250
546
  nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
251
547
  }
252
548
 
549
+ // Add battery warning indicator if battery < 20%
550
+ if (typeof d.battery === 'number' && d.battery < 20) {
551
+ const batteryWarn = document.createElement('span');
552
+ batteryWarn.textContent = `⚠️ ${d.battery}%`;
553
+ batteryWarn.style.cssText =
554
+ d.battery < 10
555
+ ? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
556
+ : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
557
+ batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
558
+ nameContainer.appendChild(batteryWarn);
559
+ }
560
+
253
561
  const details = document.createElement('div')
254
562
  details.style.fontSize = '10px'
255
563
  details.style.opacity = '0.7'
@@ -266,6 +574,7 @@ export async function renderDiscoveredDevices(
266
574
 
267
575
  info.appendChild(nameContainer)
268
576
  info.appendChild(details)
577
+ info.appendChild(expandedDetails)
269
578
 
270
579
  const addBtn = document.createElement('button')
271
580
  addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
@@ -312,6 +621,7 @@ export async function renderDiscoveredDevices(
312
621
  actions.style.gap = '5px'
313
622
  actions.appendChild(viewBtn)
314
623
  actions.appendChild(addBtn)
624
+ actions.appendChild(createConnectionTestControls(d))
315
625
  li.appendChild(actions)
316
626
  ul.appendChild(li)
317
627
  continue
@@ -327,6 +637,7 @@ export async function renderDiscoveredDevices(
327
637
  actions.style.marginTop = '2px'
328
638
  actions.style.gap = '5px'
329
639
  actions.appendChild(addBtn)
640
+ actions.appendChild(createConnectionTestControls(d))
330
641
 
331
642
  li.appendChild(info)
332
643
  li.appendChild(actions)
@@ -533,6 +844,25 @@ export function renderDeviceList(list: any[]): void {
533
844
  name.style.fontSize = '13px'
534
845
  name.textContent = d.name || d.id
535
846
 
847
+ const expandedDetails = document.createElement('div')
848
+ expandedDetails.style.display = 'none'
849
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d))
850
+
851
+ const expandBtn = document.createElement('button')
852
+ expandBtn.textContent = '▾'
853
+ expandBtn.title = 'Show details'
854
+ expandBtn.style.padding = '2px 6px'
855
+ expandBtn.style.fontSize = '11px'
856
+ expandBtn.style.marginLeft = '4px'
857
+ expandBtn.style.background = '#e5e7eb'
858
+ expandBtn.style.color = '#111827'
859
+ expandBtn.style.transition = 'transform 0.2s ease'
860
+ expandBtn.onclick = () => {
861
+ const isHidden = expandedDetails.style.display === 'none'
862
+ expandedDetails.style.display = isHidden ? 'block' : 'none'
863
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'
864
+ }
865
+
536
866
  const code = document.createElement('code')
537
867
  code.textContent = d.id
538
868
  code.style.fontSize = '10px'
@@ -543,7 +873,14 @@ export function renderDeviceList(list: any[]): void {
543
873
  code.style.wordBreak = 'break-word'
544
874
  code.style.maxWidth = '100%'
545
875
 
546
- nameContainer.appendChild(name)
876
+ const headerRow = document.createElement('div')
877
+ headerRow.style.display = 'inline-flex'
878
+ headerRow.style.alignItems = 'center'
879
+ headerRow.style.gap = '4px'
880
+ headerRow.appendChild(name)
881
+ headerRow.appendChild(expandBtn)
882
+
883
+ nameContainer.appendChild(headerRow)
547
884
  nameContainer.appendChild(code)
548
885
 
549
886
  // Add signal strength visualization if RSSI is available
@@ -564,6 +901,7 @@ export function renderDeviceList(list: any[]): void {
564
901
 
565
902
  info.appendChild(nameContainer)
566
903
  info.appendChild(meta)
904
+ info.appendChild(expandedDetails)
567
905
 
568
906
  const buttons = document.createElement('div')
569
907
  buttons.className = 'device-actions'
@@ -614,6 +952,7 @@ export function renderDeviceList(list: any[]): void {
614
952
 
615
953
  buttons.appendChild(editBtn)
616
954
  buttons.appendChild(copyBtn)
955
+ buttons.appendChild(createConnectionTestControls(d))
617
956
  buttons.appendChild(deleteBtn)
618
957
 
619
958
  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: {
@@ -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
 
@@ -1,4 +1,5 @@
1
1
  import type { SwitchBotPluginConfig } from './settings.js'
2
+ import { SwitchbotOperationError, SwitchbotAuthenticationError, CharacteristicMissingError } from './errors.js'
2
3
 
3
4
  export interface ISwitchBotClient {
4
5
  init(): Promise<void>
@@ -93,8 +94,17 @@ export class SwitchBotClient implements ISwitchBotClient {
93
94
  if (this.client?.getDevice) {
94
95
  try {
95
96
  return await this.client.getDevice(id)
96
- } catch (e) {
97
- this.logger?.warn?.(`Client getDevice failed for ${id}, trying OpenAPI fallback:`, e)
97
+ } catch (e: any) {
98
+ if (e instanceof SwitchbotAuthenticationError) {
99
+ this.logger?.error?.(`Authentication error for getDevice(${id}):`, e.message)
100
+ throw e
101
+ } else if (e instanceof SwitchbotOperationError) {
102
+ this.logger?.warn?.(`Operation error for getDevice(${id}):`, e.message, e.code)
103
+ } else if (e instanceof CharacteristicMissingError) {
104
+ this.logger?.warn?.(`Characteristic missing for getDevice(${id}):`, e.characteristic)
105
+ } else {
106
+ this.logger?.warn?.(`Client getDevice failed for ${id}, trying OpenAPI fallback:`, e)
107
+ }
98
108
  }
99
109
  }
100
110
  // Fallback: call OpenAPI via HTTP
@@ -104,7 +114,7 @@ export class SwitchBotClient implements ISwitchBotClient {
104
114
  const resp = await this.fetchWithTimeoutAndRetry(url, opts)
105
115
  return resp.json()
106
116
  }
107
- throw new Error('No SwitchBot client available')
117
+ throw new SwitchbotOperationError('No SwitchBot client available', 'no_client')
108
118
  }
109
119
 
110
120
  async getDevices(): Promise<any[]> {
@@ -149,7 +159,14 @@ export class SwitchBotClient implements ISwitchBotClient {
149
159
  try {
150
160
  const out = await this._doSetDeviceState(id, entry.body)
151
161
  for (const r of entry.resolvers) r.resolve(out)
152
- } catch (e) {
162
+ } catch (e: any) {
163
+ if (e instanceof SwitchbotAuthenticationError) {
164
+ this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
165
+ } else if (e instanceof SwitchbotOperationError) {
166
+ this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
167
+ } else if (e instanceof CharacteristicMissingError) {
168
+ this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
169
+ }
153
170
  for (const r of entry.resolvers) r.reject(e)
154
171
  }
155
172
  }, this.writeDebounceMs)
@@ -166,8 +183,17 @@ export class SwitchBotClient implements ISwitchBotClient {
166
183
  const result = await this.client.setDeviceState(id, body)
167
184
  this.logger?.debug?.(`[${id}] client.setDeviceState() response:`, result)
168
185
  return result
169
- } catch (e) {
170
- this.logger?.warn?.(`Client setDeviceState failed for ${id}, trying fallback:`, e)
186
+ } catch (e: any) {
187
+ if (e instanceof SwitchbotAuthenticationError) {
188
+ this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
189
+ throw e
190
+ } else if (e instanceof SwitchbotOperationError) {
191
+ this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
192
+ } else if (e instanceof CharacteristicMissingError) {
193
+ this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
194
+ } else {
195
+ this.logger?.warn?.(`Client setDeviceState failed for ${id}, trying fallback:`, e)
196
+ }
171
197
  }
172
198
  }
173
199
 
@@ -178,8 +204,17 @@ export class SwitchBotClient implements ISwitchBotClient {
178
204
  const result = await this.client.sendCommand(id, body)
179
205
  this.logger?.debug?.(`[${id}] client.sendCommand() response:`, result)
180
206
  return result
181
- } catch (e) {
182
- this.logger?.warn?.(`Client sendCommand failed for ${id}, trying OpenAPI:`, e)
207
+ } catch (e: any) {
208
+ if (e instanceof SwitchbotAuthenticationError) {
209
+ this.logger?.error?.(`Authentication error for sendCommand(${id}):`, e.message)
210
+ throw e
211
+ } else if (e instanceof SwitchbotOperationError) {
212
+ this.logger?.warn?.(`Operation error for sendCommand(${id}):`, e.message, e.code)
213
+ } else if (e instanceof CharacteristicMissingError) {
214
+ this.logger?.warn?.(`Characteristic missing for sendCommand(${id}):`, e.characteristic)
215
+ } else {
216
+ this.logger?.warn?.(`Client sendCommand failed for ${id}, trying OpenAPI:`, e)
217
+ }
183
218
  }
184
219
  }
185
220
 
@@ -203,7 +238,7 @@ export class SwitchBotClient implements ISwitchBotClient {
203
238
  return result
204
239
  }
205
240
 
206
- throw new Error('No SwitchBot client available for setDeviceState')
241
+ throw new SwitchbotOperationError('No SwitchBot client available for setDeviceState', 'no_client')
207
242
  }
208
243
 
209
244
  async destroy(): Promise<void> {
@@ -47,6 +47,27 @@ describe('Matter integration harness', () => {
47
47
  expect(wcType).not.toBe(4)
48
48
  })
49
49
 
50
+ it('registers RGBIC Neon Wire Rope Light as lightstrip with conformant ColorControl defaults', async () => {
51
+ const matterApi = makeFakeMatterApi()
52
+ const api = makeFakeApiWithMatter(matterApi)
53
+
54
+ const platform = new SwitchBotMatterPlatform(console as any, {
55
+ devices: [{ id: 'neon-1', type: 'RGBIC Neon Wire Rope Light', name: 'Family Room Neon Wire Rope Light' }],
56
+ enableMatter: true,
57
+ preferMatter: true,
58
+ } as any, api as any)
59
+
60
+ await (platform as any).registerMatterAccessories()
61
+
62
+ const regs = matterApi.getRegistered()
63
+ expect(regs.length).toBeGreaterThanOrEqual(1)
64
+ const neon = regs.find((r: any) => r?.context?.deviceId === 'neon-1')
65
+ expect(neon).toBeDefined()
66
+ expect(neon?.context?.type).toBe('lightstrip')
67
+ expect(neon?.clusters?.colorControl).toBeDefined()
68
+ expect(neon?.clusters?.colorControl?.colorMode).toBe(0)
69
+ })
70
+
50
71
  it('registers Matter accessories when Matter API available', async () => {
51
72
  // Instantiate the real Matter platform directly to test its registration path
52
73
  const matterApi = makeFakeMatterApi()