@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
@@ -151,8 +151,215 @@ function scrollToConfiguredDevice(deviceId) {
151
151
  target.style.background = originalBackground;
152
152
  }, 1800);
153
153
  }
154
+ function createConnectionTestControls(device) {
155
+ const controls = document.createElement('div');
156
+ controls.style.display = 'inline-flex';
157
+ controls.style.alignItems = 'center';
158
+ controls.style.gap = '6px';
159
+ const button = document.createElement('button');
160
+ button.textContent = 'Test Connection';
161
+ button.className = 'secondary';
162
+ button.style.padding = '4px 9px';
163
+ button.style.fontSize = '11px';
164
+ const status = document.createElement('span');
165
+ status.style.fontSize = '10px';
166
+ status.style.opacity = '0.85';
167
+ status.style.whiteSpace = 'normal';
168
+ status.style.overflowWrap = 'anywhere';
169
+ button.onclick = async () => {
170
+ const startedAt = Date.now();
171
+ button.disabled = true;
172
+ button.textContent = 'Testing...';
173
+ status.textContent = 'Checking...';
174
+ status.style.color = '#6b7280';
175
+ try {
176
+ const { testDeviceConnection } = await import('./api.js');
177
+ const result = await testDeviceConnection({
178
+ deviceId: String(device?.id || device?.deviceId || ''),
179
+ connectionType: device?.connectionType,
180
+ address: device?.address,
181
+ });
182
+ const measuredLatency = Number(result?.latencyMs) > 0
183
+ ? Number(result.latencyMs)
184
+ : Date.now() - startedAt;
185
+ if (result?.success) {
186
+ const method = result?.method || 'Auto';
187
+ status.textContent = `✓ ${method} · ${measuredLatency}ms`;
188
+ status.style.color = '#16a34a';
189
+ }
190
+ else {
191
+ const detail = result?.message ? ` · ${result.message}` : '';
192
+ status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`;
193
+ status.style.color = '#dc2626';
194
+ }
195
+ }
196
+ catch (e) {
197
+ status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`;
198
+ status.style.color = '#dc2626';
199
+ }
200
+ finally {
201
+ button.disabled = false;
202
+ button.textContent = 'Test Connection';
203
+ }
204
+ };
205
+ controls.appendChild(button);
206
+ controls.appendChild(status);
207
+ return controls;
208
+ }
209
+ function formatLastSeen(value) {
210
+ if (!value) {
211
+ return 'N/A';
212
+ }
213
+ try {
214
+ const date = new Date(value);
215
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
216
+ }
217
+ catch (_e) {
218
+ return String(value);
219
+ }
220
+ }
221
+ export function renderDeviceDetailsPanel(device) {
222
+ const details = document.createElement('div');
223
+ details.style.cssText = `
224
+ border-top: 1px solid #ddd;
225
+ padding: 8px;
226
+ background: #f9fafb;
227
+ border-radius: 4px;
228
+ font-size: 12px;
229
+ margin-top: 4px;
230
+ `;
231
+ // --- Battery history trending ---
232
+ // Persist battery readings in localStorage per device
233
+ const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`;
234
+ let batteryHistory = [];
235
+ try {
236
+ const raw = localStorage.getItem(batteryHistoryKey);
237
+ if (raw)
238
+ batteryHistory = JSON.parse(raw);
239
+ }
240
+ catch { }
241
+ const now = Date.now();
242
+ if (typeof device?.battery === 'number') {
243
+ // Only add if different from last or >1h since last
244
+ const last = batteryHistory[batteryHistory.length - 1];
245
+ if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) {
246
+ batteryHistory.push({ value: device.battery, ts: now });
247
+ // Keep only last 30 entries (about a month if daily)
248
+ if (batteryHistory.length > 30)
249
+ batteryHistory = batteryHistory.slice(-30);
250
+ try {
251
+ localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory));
252
+ }
253
+ catch { }
254
+ }
255
+ }
256
+ const rows = [
257
+ { label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) },
258
+ { label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address },
259
+ { label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') },
260
+ { label: 'Model', value: String(device?.model || 'N/A') },
261
+ { label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') },
262
+ { label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' },
263
+ { label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') },
264
+ { label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' },
265
+ { label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) },
266
+ ];
267
+ for (const row of rows) {
268
+ const line = document.createElement('div');
269
+ line.style.display = 'flex';
270
+ line.style.alignItems = 'center';
271
+ line.style.justifyContent = 'space-between';
272
+ line.style.gap = '8px';
273
+ line.style.padding = '2px 0';
274
+ const label = document.createElement('span');
275
+ label.style.fontWeight = '600';
276
+ label.style.minWidth = '110px';
277
+ label.textContent = `${row.label}:`;
278
+ const valueWrap = document.createElement('span');
279
+ valueWrap.style.display = 'inline-flex';
280
+ valueWrap.style.alignItems = 'center';
281
+ valueWrap.style.gap = '6px';
282
+ valueWrap.style.flex = '1';
283
+ valueWrap.style.justifyContent = 'flex-end';
284
+ valueWrap.style.minWidth = '0';
285
+ const value = document.createElement('span');
286
+ value.style.fontFamily = 'monospace';
287
+ value.style.fontSize = '11px';
288
+ value.style.opacity = '0.9';
289
+ value.style.whiteSpace = 'normal';
290
+ value.style.overflowWrap = 'anywhere';
291
+ value.style.wordBreak = 'break-word';
292
+ value.style.textAlign = 'right';
293
+ value.textContent = row.value;
294
+ valueWrap.appendChild(value);
295
+ if (row.copyable && row.value && row.value !== 'N/A') {
296
+ const copyBtn = document.createElement('button');
297
+ copyBtn.textContent = '📋';
298
+ copyBtn.title = `Copy ${row.label}`;
299
+ copyBtn.style.padding = '2px 6px';
300
+ copyBtn.style.fontSize = '10px';
301
+ copyBtn.style.lineHeight = '1';
302
+ copyBtn.style.background = '#e5e7eb';
303
+ copyBtn.style.color = '#111827';
304
+ copyBtn.onclick = async () => {
305
+ try {
306
+ await navigator.clipboard.writeText(row.value);
307
+ copyBtn.textContent = '✓';
308
+ setTimeout(() => {
309
+ copyBtn.textContent = '📋';
310
+ }, 1200);
311
+ }
312
+ catch (_e) {
313
+ copyBtn.textContent = '!';
314
+ setTimeout(() => {
315
+ copyBtn.textContent = '📋';
316
+ }, 1200);
317
+ }
318
+ };
319
+ valueWrap.appendChild(copyBtn);
320
+ }
321
+ line.appendChild(label);
322
+ line.appendChild(valueWrap);
323
+ details.appendChild(line);
324
+ }
325
+ // --- Expose advanced/extra features dynamically ---
326
+ const featureKeys = [
327
+ 'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature',
328
+ 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand',
329
+ 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state',
330
+ ];
331
+ const shown = new Set(rows.map(r => r.label.toLowerCase().replace(/\s/g, '')));
332
+ for (const key of featureKeys) {
333
+ if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) {
334
+ const line = document.createElement('div');
335
+ line.style.display = 'flex';
336
+ line.style.alignItems = 'center';
337
+ line.style.justifyContent = 'space-between';
338
+ line.style.gap = '8px';
339
+ line.style.padding = '2px 0';
340
+ const label = document.createElement('span');
341
+ label.style.fontWeight = '600';
342
+ label.style.minWidth = '110px';
343
+ label.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) + ':';
344
+ const value = document.createElement('span');
345
+ value.style.fontFamily = 'monospace';
346
+ value.style.fontSize = '11px';
347
+ value.style.opacity = '0.9';
348
+ value.style.whiteSpace = 'normal';
349
+ value.style.overflowWrap = 'anywhere';
350
+ value.style.wordBreak = 'break-word';
351
+ value.style.textAlign = 'right';
352
+ value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key]);
353
+ line.appendChild(label);
354
+ line.appendChild(value);
355
+ details.appendChild(line);
356
+ }
357
+ }
358
+ return details;
359
+ }
154
360
  export async function renderDiscoveredDevices(devices, options = {}) {
155
361
  const ul = document.createElement('ul');
362
+ ul.className = 'device-grid';
156
363
  ul.style.maxHeight = '400px';
157
364
  ul.style.overflowY = 'auto';
158
365
  ul.style.marginTop = '12px';
@@ -161,36 +368,104 @@ export async function renderDiscoveredDevices(devices, options = {}) {
161
368
  const { addDeviceToConfig } = await import('./discovery.js');
162
369
  const { loadConfiguredDevices } = await import('./devices.js');
163
370
  const configuredIds = options.configuredIds ?? new Set();
371
+ const selectedIds = options.selectedIds ?? new Set();
372
+ const onToggleSelect = options.onToggleSelect;
164
373
  for (const d of devices) {
165
374
  const deviceId = normalizeId(d.id);
166
375
  const alreadyAdded = configuredIds.has(deviceId);
167
376
  const li = document.createElement('li');
168
377
  li.className = 'device-item';
169
378
  li.style.display = 'flex';
170
- li.style.alignItems = 'center';
171
- li.style.justifyContent = 'space-between';
172
- li.style.padding = '10px 12px';
173
- li.style.marginBottom = '6px';
174
- li.style.borderRadius = '6px';
379
+ li.style.flexDirection = 'column';
380
+ li.style.alignItems = 'stretch';
381
+ li.style.justifyContent = 'flex-start';
382
+ li.style.padding = '5px 8px';
383
+ li.style.marginBottom = '0';
384
+ li.style.borderRadius = '5px';
175
385
  li.style.transition = 'all 0.2s ease';
176
386
  const info = document.createElement('div');
177
- info.style.flex = '1';
387
+ info.style.flex = '1 1 auto';
388
+ info.style.width = '100%';
389
+ info.style.minWidth = '0';
178
390
  const nameContainer = document.createElement('div');
179
391
  nameContainer.style.display = 'flex';
180
392
  nameContainer.style.alignItems = 'center';
181
- nameContainer.style.marginBottom = '4px';
393
+ nameContainer.style.marginBottom = '0';
182
394
  nameContainer.style.flexWrap = 'wrap';
183
- nameContainer.style.gap = '6px';
395
+ nameContainer.style.gap = '4px';
184
396
  const name = document.createElement('div');
185
397
  name.style.fontWeight = '500';
186
- name.style.fontSize = '14px';
398
+ name.style.fontSize = '13px';
187
399
  name.textContent = d.name || d.id;
400
+ const selectCheckbox = document.createElement('input');
401
+ selectCheckbox.type = 'checkbox';
402
+ selectCheckbox.style.width = 'auto';
403
+ selectCheckbox.style.margin = '0 2px 0 0';
404
+ selectCheckbox.checked = selectedIds.has(deviceId);
405
+ if (alreadyAdded) {
406
+ selectCheckbox.disabled = true;
407
+ selectCheckbox.title = 'Already configured';
408
+ }
409
+ selectCheckbox.onchange = () => {
410
+ onToggleSelect?.(d, selectCheckbox.checked);
411
+ // Notify listeners (e.g., batch buttons) of selection change
412
+ window.dispatchEvent(new CustomEvent('discovery-selection-changed'));
413
+ };
414
+ nameContainer.appendChild(selectCheckbox);
188
415
  nameContainer.appendChild(name);
416
+ // Show firmware update available indicator if present
417
+ if (d.firmwareUpdateAvailable) {
418
+ const fwBadge = document.createElement('span');
419
+ fwBadge.textContent = 'Update Available';
420
+ fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
421
+ fwBadge.title = 'A firmware update is available for this device.';
422
+ nameContainer.appendChild(fwBadge);
423
+ }
424
+ // Show offline/unreachable indicator if device is offline
425
+ let offline = false;
426
+ let lastSeen = d.lastSeen || d.lastseen || d.updatedAt;
427
+ if (typeof d.offline === 'boolean') {
428
+ offline = d.offline;
429
+ }
430
+ else if (lastSeen) {
431
+ try {
432
+ const last = new Date(lastSeen).getTime();
433
+ if (!isNaN(last) && Date.now() - last > 1000 * 60 * 60) { // 1 hour
434
+ offline = true;
435
+ }
436
+ }
437
+ catch { }
438
+ }
439
+ if (offline) {
440
+ const offlineBadge = document.createElement('span');
441
+ offlineBadge.textContent = 'Offline';
442
+ offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
443
+ offlineBadge.title = 'Device is offline or unreachable.';
444
+ nameContainer.appendChild(offlineBadge);
445
+ }
446
+ const expandedDetails = document.createElement('div');
447
+ expandedDetails.style.display = 'none';
448
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d));
449
+ const expandBtn = document.createElement('button');
450
+ expandBtn.textContent = '▾';
451
+ expandBtn.title = 'Show details';
452
+ expandBtn.style.padding = '2px 6px';
453
+ expandBtn.style.fontSize = '11px';
454
+ expandBtn.style.marginLeft = '4px';
455
+ expandBtn.style.background = '#e5e7eb';
456
+ expandBtn.style.color = '#111827';
457
+ expandBtn.style.transition = 'transform 0.2s ease';
458
+ expandBtn.onclick = () => {
459
+ const isHidden = expandedDetails.style.display === 'none';
460
+ expandedDetails.style.display = isHidden ? 'block' : 'none';
461
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
462
+ };
463
+ nameContainer.appendChild(expandBtn);
189
464
  const duplicateBadge = document.createElement('span');
190
465
  duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device';
191
466
  duplicateBadge.style.cssText = alreadyAdded
192
- ? 'background: #16a34a; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;'
193
- : 'background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600;';
467
+ ? 'background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'
468
+ : 'background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;';
194
469
  nameContainer.appendChild(duplicateBadge);
195
470
  // Add connection type badge
196
471
  if (d.connectionType) {
@@ -208,11 +483,25 @@ export async function renderDiscoveredDevices(devices, options = {}) {
208
483
  nameContainer.appendChild(renderSignalBars(d.rssi));
209
484
  nameContainer.appendChild(renderSignalQualityBadge(d.rssi));
210
485
  }
486
+ // Add battery warning indicator if battery < 20%
487
+ if (typeof d.battery === 'number' && d.battery < 20) {
488
+ const batteryWarn = document.createElement('span');
489
+ batteryWarn.textContent = `⚠️ ${d.battery}%`;
490
+ batteryWarn.style.cssText =
491
+ d.battery < 10
492
+ ? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'
493
+ : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;';
494
+ batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low';
495
+ nameContainer.appendChild(batteryWarn);
496
+ }
211
497
  const details = document.createElement('div');
212
- details.style.fontSize = '11px';
498
+ details.style.fontSize = '10px';
213
499
  details.style.opacity = '0.7';
214
- details.style.marginTop = '4px';
500
+ details.style.marginTop = '0';
215
501
  details.style.fontFamily = 'monospace';
502
+ details.style.whiteSpace = 'normal';
503
+ details.style.overflowWrap = 'anywhere';
504
+ details.style.wordBreak = 'break-word';
216
505
  let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`;
217
506
  if (d.hubDeviceId) {
218
507
  detailsText += ` | Hub: ${d.hubDeviceId}`;
@@ -223,11 +512,13 @@ export async function renderDiscoveredDevices(devices, options = {}) {
223
512
  details.textContent = detailsText;
224
513
  info.appendChild(nameContainer);
225
514
  info.appendChild(details);
515
+ info.appendChild(expandedDetails);
226
516
  const addBtn = document.createElement('button');
227
517
  addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config';
228
- addBtn.style.marginLeft = '8px';
229
- addBtn.style.padding = '6px 14px';
230
- addBtn.style.fontSize = '12px';
518
+ addBtn.style.marginLeft = '0';
519
+ addBtn.style.marginTop = '2px';
520
+ addBtn.style.padding = '4px 9px';
521
+ addBtn.style.fontSize = '11px';
231
522
  addBtn.style.whiteSpace = 'nowrap';
232
523
  addBtn.style.flexShrink = '0';
233
524
  addBtn.disabled = alreadyAdded;
@@ -246,25 +537,44 @@ export async function renderDiscoveredDevices(devices, options = {}) {
246
537
  const viewBtn = document.createElement('button');
247
538
  viewBtn.textContent = 'View in Config';
248
539
  viewBtn.className = 'secondary';
249
- viewBtn.style.marginLeft = '8px';
250
- viewBtn.style.padding = '6px 12px';
251
- viewBtn.style.fontSize = '12px';
540
+ viewBtn.style.marginLeft = '0';
541
+ viewBtn.style.padding = '4px 9px';
542
+ viewBtn.style.fontSize = '11px';
252
543
  viewBtn.onclick = async () => {
253
544
  await loadConfiguredDevices();
254
545
  scrollToConfiguredDevice(d.id);
255
546
  };
256
547
  li.appendChild(info);
257
548
  const actions = document.createElement('div');
549
+ actions.className = 'device-actions';
258
550
  actions.style.display = 'flex';
259
551
  actions.style.alignItems = 'center';
552
+ actions.style.flexWrap = 'wrap';
553
+ actions.style.justifyContent = 'flex-start';
554
+ actions.style.marginLeft = '0';
555
+ actions.style.width = '100%';
556
+ actions.style.marginTop = '2px';
557
+ actions.style.gap = '5px';
260
558
  actions.appendChild(viewBtn);
261
559
  actions.appendChild(addBtn);
560
+ actions.appendChild(createConnectionTestControls(d));
262
561
  li.appendChild(actions);
263
562
  ul.appendChild(li);
264
563
  continue;
265
564
  }
565
+ const actions = document.createElement('div');
566
+ actions.className = 'device-actions';
567
+ actions.style.display = 'flex';
568
+ actions.style.flexWrap = 'wrap';
569
+ actions.style.justifyContent = 'flex-start';
570
+ actions.style.marginLeft = '0';
571
+ actions.style.width = '100%';
572
+ actions.style.marginTop = '2px';
573
+ actions.style.gap = '5px';
574
+ actions.appendChild(addBtn);
575
+ actions.appendChild(createConnectionTestControls(d));
266
576
  li.appendChild(info);
267
- li.appendChild(addBtn);
577
+ li.appendChild(actions);
268
578
  ul.appendChild(li);
269
579
  }
270
580
  return ul;
@@ -409,6 +719,7 @@ export function renderDeviceList(list) {
409
719
  return;
410
720
  }
411
721
  status.textContent = `Found ${list.length} device(s)`;
722
+ ul.classList.add('device-grid');
412
723
  ul.innerHTML = '';
413
724
  // Show remove all button when devices exist
414
725
  if (removeAllContainer) {
@@ -418,22 +729,56 @@ export function renderDeviceList(list) {
418
729
  const li = document.createElement('li');
419
730
  li.className = 'device-item';
420
731
  li.setAttribute('data-device-id', normalizeId(d.id));
732
+ li.style.display = 'flex';
733
+ li.style.flexDirection = 'column';
734
+ li.style.alignItems = 'stretch';
735
+ li.style.marginBottom = '0';
421
736
  const info = document.createElement('div');
422
- info.style.flex = '1';
737
+ info.style.flex = '1 1 auto';
738
+ info.style.width = '100%';
739
+ info.style.minWidth = '0';
423
740
  const nameContainer = document.createElement('div');
424
741
  nameContainer.style.display = 'flex';
425
- nameContainer.style.alignItems = 'center';
426
- nameContainer.style.flexWrap = 'wrap';
427
- nameContainer.style.gap = '6px';
742
+ nameContainer.style.flexDirection = 'column';
743
+ nameContainer.style.alignItems = 'flex-start';
744
+ nameContainer.style.gap = '0';
428
745
  const name = document.createElement('div');
429
746
  name.style.fontWeight = '500';
747
+ name.style.fontSize = '13px';
430
748
  name.textContent = d.name || d.id;
749
+ const expandedDetails = document.createElement('div');
750
+ expandedDetails.style.display = 'none';
751
+ expandedDetails.appendChild(renderDeviceDetailsPanel(d));
752
+ const expandBtn = document.createElement('button');
753
+ expandBtn.textContent = '▾';
754
+ expandBtn.title = 'Show details';
755
+ expandBtn.style.padding = '2px 6px';
756
+ expandBtn.style.fontSize = '11px';
757
+ expandBtn.style.marginLeft = '4px';
758
+ expandBtn.style.background = '#e5e7eb';
759
+ expandBtn.style.color = '#111827';
760
+ expandBtn.style.transition = 'transform 0.2s ease';
761
+ expandBtn.onclick = () => {
762
+ const isHidden = expandedDetails.style.display === 'none';
763
+ expandedDetails.style.display = isHidden ? 'block' : 'none';
764
+ expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
765
+ };
431
766
  const code = document.createElement('code');
432
767
  code.textContent = d.id;
433
- code.style.fontSize = '12px';
768
+ code.style.fontSize = '10px';
434
769
  code.style.opacity = '0.75';
435
- code.style.marginLeft = '8px';
436
- nameContainer.appendChild(name);
770
+ code.style.marginLeft = '0';
771
+ code.style.whiteSpace = 'normal';
772
+ code.style.overflowWrap = 'anywhere';
773
+ code.style.wordBreak = 'break-word';
774
+ code.style.maxWidth = '100%';
775
+ const headerRow = document.createElement('div');
776
+ headerRow.style.display = 'inline-flex';
777
+ headerRow.style.alignItems = 'center';
778
+ headerRow.style.gap = '4px';
779
+ headerRow.appendChild(name);
780
+ headerRow.appendChild(expandBtn);
781
+ nameContainer.appendChild(headerRow);
437
782
  nameContainer.appendChild(code);
438
783
  // Add signal strength visualization if RSSI is available
439
784
  if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) {
@@ -442,29 +787,36 @@ export function renderDeviceList(list) {
442
787
  }
443
788
  const meta = document.createElement('div');
444
789
  meta.style.opacity = '0.75';
445
- meta.style.marginTop = '4px';
446
- meta.style.fontSize = '12px';
790
+ meta.style.marginTop = '0';
791
+ meta.style.fontSize = '11px';
447
792
  const typeText = d.type ? `type: ${d.type}` : '';
448
793
  const connText = d.connectionPreference ? `conn: ${d.connectionPreference}` : '';
449
794
  const roomText = d.room ? `room: ${d.room}` : '';
450
795
  meta.textContent = [typeText, connText, roomText].filter(Boolean).join(' | ');
451
796
  info.appendChild(nameContainer);
452
797
  info.appendChild(meta);
798
+ info.appendChild(expandedDetails);
453
799
  const buttons = document.createElement('div');
800
+ buttons.className = 'device-actions';
454
801
  buttons.style.display = 'flex';
455
- buttons.style.gap = '8px';
802
+ buttons.style.flexWrap = 'wrap';
803
+ buttons.style.justifyContent = 'flex-start';
804
+ buttons.style.marginLeft = '0';
805
+ buttons.style.width = '100%';
806
+ buttons.style.marginTop = '2px';
807
+ buttons.style.gap = '5px';
456
808
  const editBtn = document.createElement('button');
457
809
  editBtn.textContent = '✏️ Edit';
458
- editBtn.style.padding = '6px 12px';
459
- editBtn.style.fontSize = '12px';
810
+ editBtn.style.padding = '4px 9px';
811
+ editBtn.style.fontSize = '11px';
460
812
  editBtn.onclick = async () => {
461
813
  const { editDevice } = await import('./modals.js');
462
814
  await editDevice(d);
463
815
  };
464
816
  const copyBtn = document.createElement('button');
465
817
  copyBtn.textContent = 'Copy ID';
466
- copyBtn.style.padding = '6px 12px';
467
- copyBtn.style.fontSize = '12px';
818
+ copyBtn.style.padding = '4px 9px';
819
+ copyBtn.style.fontSize = '11px';
468
820
  copyBtn.addEventListener('click', async () => {
469
821
  try {
470
822
  await navigator.clipboard.writeText(d.id);
@@ -481,8 +833,8 @@ export function renderDeviceList(list) {
481
833
  });
482
834
  const deleteBtn = document.createElement('button');
483
835
  deleteBtn.textContent = '🗑️ Delete';
484
- deleteBtn.style.padding = '6px 12px';
485
- deleteBtn.style.fontSize = '12px';
836
+ deleteBtn.style.padding = '4px 9px';
837
+ deleteBtn.style.fontSize = '11px';
486
838
  deleteBtn.style.background = '#ef4444';
487
839
  deleteBtn.onclick = async () => {
488
840
  const { deleteDeviceFromConfig } = await import('./devices-delete.js');
@@ -490,6 +842,7 @@ export function renderDeviceList(list) {
490
842
  };
491
843
  buttons.appendChild(editBtn);
492
844
  buttons.appendChild(copyBtn);
845
+ buttons.appendChild(createConnectionTestControls(d));
493
846
  buttons.appendChild(deleteBtn);
494
847
  li.appendChild(info);
495
848
  li.appendChild(buttons);