@switchbot/homebridge-switchbot 5.0.0-beta.111 → 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 (85) 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 +209 -16
  26. package/dist/homebridge-ui/public/index.html +87 -56
  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 +1604 -143
  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 +1244 -124
  42. package/dist/homebridge-ui/public/js/discovery.js.map +1 -1
  43. package/dist/homebridge-ui/public/js/discovery.ts +1344 -142
  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 +389 -36
  51. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/render.ts +421 -38
  53. package/dist/platform.d.ts +1 -0
  54. package/dist/platform.d.ts.map +1 -1
  55. package/dist/platform.js +3 -4
  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 +9 -8
  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 +209 -16
  71. package/src/homebridge-ui/public/index.html +87 -56
  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 +1344 -142
  76. package/src/homebridge-ui/public/js/modals.ts +50 -1
  77. package/src/homebridge-ui/public/js/render.ts +421 -38
  78. package/src/platform.ts +3 -4
  79. package/src/switchbotClient.ts +44 -9
  80. package/test/e2e/run-e2e.spec.ts +12 -10
  81. package/test/helpers/matter-harness.ts +2 -2
  82. package/test/matter-integration.spec.ts +21 -0
  83. package/todo/COMPLETED.md +313 -0
  84. package/todo/TODO.md +80 -66
  85. package/switchbot-homebridge-switchbot-5.0.0.tgz +0 -0
@@ -176,11 +176,238 @@ 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')
410
+ ul.className = 'device-grid'
184
411
  ul.style.maxHeight = '400px'
185
412
  ul.style.overflowY = 'auto'
186
413
  ul.style.marginTop = '12px'
@@ -190,6 +417,8 @@ export async function renderDiscoveredDevices(
190
417
  const { addDeviceToConfig } = await import('./discovery.js')
191
418
  const { loadConfiguredDevices } = await import('./devices.js')
192
419
  const configuredIds = options.configuredIds ?? new Set<string>()
420
+ const selectedIds = options.selectedIds ?? new Set<string>()
421
+ const onToggleSelect = options.onToggleSelect
193
422
 
194
423
  for (const d of devices) {
195
424
  const deviceId = normalizeId(d.id)
@@ -198,35 +427,106 @@ export async function renderDiscoveredDevices(
198
427
  const li = document.createElement('li')
199
428
  li.className = 'device-item'
200
429
  li.style.display = 'flex'
201
- li.style.alignItems = 'center'
202
- li.style.justifyContent = 'space-between'
203
- li.style.padding = '10px 12px'
204
- li.style.marginBottom = '6px'
205
- li.style.borderRadius = '6px'
430
+ li.style.flexDirection = 'column'
431
+ li.style.alignItems = 'stretch'
432
+ li.style.justifyContent = 'flex-start'
433
+ li.style.padding = '5px 8px'
434
+ li.style.marginBottom = '0'
435
+ li.style.borderRadius = '5px'
206
436
  li.style.transition = 'all 0.2s ease'
207
437
 
208
438
  const info = document.createElement('div')
209
- info.style.flex = '1'
439
+ info.style.flex = '1 1 auto'
440
+ info.style.width = '100%'
441
+ info.style.minWidth = '0'
210
442
 
211
443
  const nameContainer = document.createElement('div')
212
444
  nameContainer.style.display = 'flex'
213
445
  nameContainer.style.alignItems = 'center'
214
- nameContainer.style.marginBottom = '4px'
446
+ nameContainer.style.marginBottom = '0'
215
447
  nameContainer.style.flexWrap = 'wrap'
216
- nameContainer.style.gap = '6px'
448
+ nameContainer.style.gap = '4px'
217
449
 
218
450
  const name = document.createElement('div')
219
451
  name.style.fontWeight = '500'
220
- name.style.fontSize = '14px'
452
+ name.style.fontSize = '13px'
221
453
  name.textContent = d.name || d.id
222
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
+
223
473
  nameContainer.appendChild(name)
224
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
+
225
525
  const duplicateBadge = document.createElement('span')
226
526
  duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'
227
527
  duplicateBadge.style.cssText = alreadyAdded
228
- ? 'background: #16a34a; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;'
229
- : 'background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;'
528
+ ? 'background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
529
+ : 'background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
230
530
  nameContainer.appendChild(duplicateBadge)
231
531
 
232
532
  // Add connection type badge
@@ -246,11 +546,26 @@ export async function renderDiscoveredDevices(
246
546
  nameContainer.appendChild(renderSignalQualityBadge(d.rssi))
247
547
  }
248
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
+
249
561
  const details = document.createElement('div')
250
- details.style.fontSize = '11px'
562
+ details.style.fontSize = '10px'
251
563
  details.style.opacity = '0.7'
252
- details.style.marginTop = '4px'
564
+ details.style.marginTop = '0'
253
565
  details.style.fontFamily = 'monospace'
566
+ details.style.whiteSpace = 'normal'
567
+ details.style.overflowWrap = 'anywhere'
568
+ details.style.wordBreak = 'break-word'
254
569
 
255
570
  let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`
256
571
  if (d.hubDeviceId) { detailsText += ` | Hub: ${d.hubDeviceId}` }
@@ -259,12 +574,14 @@ export async function renderDiscoveredDevices(
259
574
 
260
575
  info.appendChild(nameContainer)
261
576
  info.appendChild(details)
577
+ info.appendChild(expandedDetails)
262
578
 
263
579
  const addBtn = document.createElement('button')
264
580
  addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'
265
- addBtn.style.marginLeft = '8px'
266
- addBtn.style.padding = '6px 14px'
267
- addBtn.style.fontSize = '12px'
581
+ addBtn.style.marginLeft = '0'
582
+ addBtn.style.marginTop = '2px'
583
+ addBtn.style.padding = '4px 9px'
584
+ addBtn.style.fontSize = '11px'
268
585
  addBtn.style.whiteSpace = 'nowrap'
269
586
  addBtn.style.flexShrink = '0'
270
587
  addBtn.disabled = alreadyAdded
@@ -284,26 +601,46 @@ export async function renderDiscoveredDevices(
284
601
  const viewBtn = document.createElement('button')
285
602
  viewBtn.textContent = 'View in Config'
286
603
  viewBtn.className = 'secondary'
287
- viewBtn.style.marginLeft = '8px'
288
- viewBtn.style.padding = '6px 12px'
289
- viewBtn.style.fontSize = '12px'
604
+ viewBtn.style.marginLeft = '0'
605
+ viewBtn.style.padding = '4px 9px'
606
+ viewBtn.style.fontSize = '11px'
290
607
  viewBtn.onclick = async () => {
291
608
  await loadConfiguredDevices()
292
609
  scrollToConfiguredDevice(d.id)
293
610
  }
294
611
  li.appendChild(info)
295
612
  const actions = document.createElement('div')
613
+ actions.className = 'device-actions'
296
614
  actions.style.display = 'flex'
297
615
  actions.style.alignItems = 'center'
616
+ actions.style.flexWrap = 'wrap'
617
+ actions.style.justifyContent = 'flex-start'
618
+ actions.style.marginLeft = '0'
619
+ actions.style.width = '100%'
620
+ actions.style.marginTop = '2px'
621
+ actions.style.gap = '5px'
298
622
  actions.appendChild(viewBtn)
299
623
  actions.appendChild(addBtn)
624
+ actions.appendChild(createConnectionTestControls(d))
300
625
  li.appendChild(actions)
301
626
  ul.appendChild(li)
302
627
  continue
303
628
  }
304
629
 
630
+ const actions = document.createElement('div')
631
+ actions.className = 'device-actions'
632
+ actions.style.display = 'flex'
633
+ actions.style.flexWrap = 'wrap'
634
+ actions.style.justifyContent = 'flex-start'
635
+ actions.style.marginLeft = '0'
636
+ actions.style.width = '100%'
637
+ actions.style.marginTop = '2px'
638
+ actions.style.gap = '5px'
639
+ actions.appendChild(addBtn)
640
+ actions.appendChild(createConnectionTestControls(d))
641
+
305
642
  li.appendChild(info)
306
- li.appendChild(addBtn)
643
+ li.appendChild(actions)
307
644
  ul.appendChild(li)
308
645
  }
309
646
 
@@ -474,6 +811,7 @@ export function renderDeviceList(list: any[]): void {
474
811
  }
475
812
 
476
813
  status.textContent = `Found ${list.length} device(s)`
814
+ ul.classList.add('device-grid')
477
815
  ul.innerHTML = ''
478
816
 
479
817
  // Show remove all button when devices exist
@@ -485,27 +823,64 @@ export function renderDeviceList(list: any[]): void {
485
823
  const li = document.createElement('li')
486
824
  li.className = 'device-item'
487
825
  li.setAttribute('data-device-id', normalizeId(d.id))
826
+ li.style.display = 'flex'
827
+ li.style.flexDirection = 'column'
828
+ li.style.alignItems = 'stretch'
829
+ li.style.marginBottom = '0'
488
830
 
489
831
  const info = document.createElement('div')
490
- info.style.flex = '1'
832
+ info.style.flex = '1 1 auto'
833
+ info.style.width = '100%'
834
+ info.style.minWidth = '0'
491
835
 
492
836
  const nameContainer = document.createElement('div')
493
837
  nameContainer.style.display = 'flex'
494
- nameContainer.style.alignItems = 'center'
495
- nameContainer.style.flexWrap = 'wrap'
496
- nameContainer.style.gap = '6px'
838
+ nameContainer.style.flexDirection = 'column'
839
+ nameContainer.style.alignItems = 'flex-start'
840
+ nameContainer.style.gap = '0'
497
841
 
498
842
  const name = document.createElement('div')
499
843
  name.style.fontWeight = '500'
844
+ name.style.fontSize = '13px'
500
845
  name.textContent = d.name || d.id
501
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
+
502
866
  const code = document.createElement('code')
503
867
  code.textContent = d.id
504
- code.style.fontSize = '12px'
868
+ code.style.fontSize = '10px'
505
869
  code.style.opacity = '0.75'
506
- code.style.marginLeft = '8px'
507
-
508
- nameContainer.appendChild(name)
870
+ code.style.marginLeft = '0'
871
+ code.style.whiteSpace = 'normal'
872
+ code.style.overflowWrap = 'anywhere'
873
+ code.style.wordBreak = 'break-word'
874
+ code.style.maxWidth = '100%'
875
+
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)
509
884
  nameContainer.appendChild(code)
510
885
 
511
886
  // Add signal strength visualization if RSSI is available
@@ -516,8 +891,8 @@ export function renderDeviceList(list: any[]): void {
516
891
 
517
892
  const meta = document.createElement('div')
518
893
  meta.style.opacity = '0.75'
519
- meta.style.marginTop = '4px'
520
- meta.style.fontSize = '12px'
894
+ meta.style.marginTop = '0'
895
+ meta.style.fontSize = '11px'
521
896
 
522
897
  const typeText = d.type ? `type: ${d.type}` : ''
523
898
  const connText = d.connectionPreference ? `conn: ${d.connectionPreference}` : ''
@@ -526,15 +901,22 @@ export function renderDeviceList(list: any[]): void {
526
901
 
527
902
  info.appendChild(nameContainer)
528
903
  info.appendChild(meta)
904
+ info.appendChild(expandedDetails)
529
905
 
530
906
  const buttons = document.createElement('div')
907
+ buttons.className = 'device-actions'
531
908
  buttons.style.display = 'flex'
532
- buttons.style.gap = '8px'
909
+ buttons.style.flexWrap = 'wrap'
910
+ buttons.style.justifyContent = 'flex-start'
911
+ buttons.style.marginLeft = '0'
912
+ buttons.style.width = '100%'
913
+ buttons.style.marginTop = '2px'
914
+ buttons.style.gap = '5px'
533
915
 
534
916
  const editBtn = document.createElement('button')
535
917
  editBtn.textContent = '✏️ Edit'
536
- editBtn.style.padding = '6px 12px'
537
- editBtn.style.fontSize = '12px'
918
+ editBtn.style.padding = '4px 9px'
919
+ editBtn.style.fontSize = '11px'
538
920
  editBtn.onclick = async () => {
539
921
  const { editDevice } = await import('./modals.js')
540
922
  await editDevice(d)
@@ -542,8 +924,8 @@ export function renderDeviceList(list: any[]): void {
542
924
 
543
925
  const copyBtn = document.createElement('button')
544
926
  copyBtn.textContent = 'Copy ID'
545
- copyBtn.style.padding = '6px 12px'
546
- copyBtn.style.fontSize = '12px'
927
+ copyBtn.style.padding = '4px 9px'
928
+ copyBtn.style.fontSize = '11px'
547
929
  copyBtn.addEventListener('click', async () => {
548
930
  try {
549
931
  await navigator.clipboard.writeText(d.id)
@@ -560,8 +942,8 @@ export function renderDeviceList(list: any[]): void {
560
942
 
561
943
  const deleteBtn = document.createElement('button')
562
944
  deleteBtn.textContent = '🗑️ Delete'
563
- deleteBtn.style.padding = '6px 12px'
564
- deleteBtn.style.fontSize = '12px'
945
+ deleteBtn.style.padding = '4px 9px'
946
+ deleteBtn.style.fontSize = '11px'
565
947
  deleteBtn.style.background = '#ef4444'
566
948
  deleteBtn.onclick = async () => {
567
949
  const { deleteDeviceFromConfig } = await import('./devices-delete.js')
@@ -570,6 +952,7 @@ export function renderDeviceList(list: any[]): void {
570
952
 
571
953
  buttons.appendChild(editBtn)
572
954
  buttons.appendChild(copyBtn)
955
+ buttons.appendChild(createConnectionTestControls(d))
573
956
  buttons.appendChild(deleteBtn)
574
957
 
575
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: {
@@ -133,9 +133,6 @@ const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
133
133
  maxLevel: 254,
134
134
  },
135
135
  colorControl: {
136
- currentHue: 0,
137
- currentSaturation: 0,
138
- colorTemperatureMireds: 400,
139
136
  colorMode: 0,
140
137
  },
141
138
  }, // Lightbulb with color → OnOff + LevelControl + ColorControl
@@ -1099,6 +1096,8 @@ export class SwitchBotHAPPlatform {
1099
1096
  id: raw.deviceId ?? raw.id,
1100
1097
  name: raw.configDeviceName ?? raw.name,
1101
1098
  type: raw.configDeviceType ?? raw.type ?? raw.deviceType ?? 'unknown',
1099
+ encryptionKey: raw.encryptionKey,
1100
+ keyId: raw.keyId,
1102
1101
  _raw: raw,
1103
1102
  }
1104
1103