@matter-server/dashboard 0.6.2 → 0.6.3

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 (52) hide show
  1. package/dist/esm/pages/matter-network-view.d.ts +15 -0
  2. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  3. package/dist/esm/pages/matter-network-view.js +171 -1
  4. package/dist/esm/pages/matter-network-view.js.map +1 -1
  5. package/dist/esm/pages/network/base-network-graph.d.ts +4 -0
  6. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
  7. package/dist/esm/pages/network/base-network-graph.js +9 -0
  8. package/dist/esm/pages/network/base-network-graph.js.map +1 -1
  9. package/dist/esm/pages/network/border-router-store.d.ts +20 -0
  10. package/dist/esm/pages/network/border-router-store.d.ts.map +1 -0
  11. package/dist/esm/pages/network/border-router-store.js +29 -0
  12. package/dist/esm/pages/network/border-router-store.js.map +6 -0
  13. package/dist/esm/pages/network/network-details.d.ts +40 -12
  14. package/dist/esm/pages/network/network-details.d.ts.map +1 -1
  15. package/dist/esm/pages/network/network-details.js +440 -112
  16. package/dist/esm/pages/network/network-details.js.map +1 -1
  17. package/dist/esm/pages/network/network-types.d.ts +76 -0
  18. package/dist/esm/pages/network/network-types.d.ts.map +1 -1
  19. package/dist/esm/pages/network/network-types.js.map +1 -1
  20. package/dist/esm/pages/network/network-utils.d.ts +89 -22
  21. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  22. package/dist/esm/pages/network/network-utils.js +233 -95
  23. package/dist/esm/pages/network/network-utils.js.map +1 -1
  24. package/dist/esm/pages/network/thread-graph.d.ts +68 -9
  25. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  26. package/dist/esm/pages/network/thread-graph.js +388 -50
  27. package/dist/esm/pages/network/thread-graph.js.map +2 -2
  28. package/dist/esm/util/device-icons.d.ts +6 -0
  29. package/dist/esm/util/device-icons.d.ts.map +1 -1
  30. package/dist/esm/util/device-icons.js +6 -0
  31. package/dist/esm/util/device-icons.js.map +1 -1
  32. package/dist/web/js/{attribute-write-dialog-g4B6BoRt.js → attribute-write-dialog-DlMTUiLK.js} +1 -1
  33. package/dist/web/js/{command-invoke-dialog-D6G704VK.js → command-invoke-dialog-DO3IyFcm.js} +1 -1
  34. package/dist/web/js/{commission-node-dialog-Bg3oo5ub.js → commission-node-dialog-CMSvCm0i.js} +4 -4
  35. package/dist/web/js/{commission-node-existing-DO3g1aQJ.js → commission-node-existing-D08jghFu.js} +2 -2
  36. package/dist/web/js/{commission-node-thread-DM432aH1.js → commission-node-thread-D5waY758.js} +2 -2
  37. package/dist/web/js/{commission-node-wifi-Bx40FXij.js → commission-node-wifi-ClBlCFTZ.js} +2 -2
  38. package/dist/web/js/{dialog-box-DjyfULWB.js → dialog-box-D9vS2SmP.js} +1 -1
  39. package/dist/web/js/{fire_event-BstgNPuh.js → fire_event-BPhROjTC.js} +1 -1
  40. package/dist/web/js/main.js +1 -1
  41. package/dist/web/js/{matter-dashboard-app-Cj88TtbZ.js → matter-dashboard-app-C9zTE5uH.js} +1359 -302
  42. package/dist/web/js/{node-binding-dialog-9yy2LE3_.js → node-binding-dialog-B5p-gbim.js} +1 -1
  43. package/dist/web/js/{settings-dialog-Cs2xMsXb.js → settings-dialog-BMFhom0W.js} +1 -1
  44. package/package.json +4 -4
  45. package/src/pages/matter-network-view.ts +185 -1
  46. package/src/pages/network/base-network-graph.ts +10 -0
  47. package/src/pages/network/border-router-store.ts +38 -0
  48. package/src/pages/network/network-details.ts +535 -140
  49. package/src/pages/network/network-types.ts +76 -0
  50. package/src/pages/network/network-utils.ts +390 -171
  51. package/src/pages/network/thread-graph.ts +532 -73
  52. package/src/util/device-icons.ts +13 -0
@@ -24,13 +24,11 @@ import "../../components/ha-svg-icon";
24
24
  import { formatNodeAddressFromAny, getEffectiveFabricIndex } from "../../util/format_hex.js";
25
25
  import { getCssVar, reducedMotionStyles } from "../../util/shared-styles.js";
26
26
  import {
27
- buildExtAddrMap,
28
- buildRloc16Map,
27
+ decodeMeshcopStateBitmap,
29
28
  getDeviceName,
30
29
  getNetworkType,
31
- getNodeConnections,
30
+ getNodeConnectionsFromPairs,
32
31
  getRoutableDestinationsCount,
33
- getSignalColor,
34
32
  getSignalColorFromRssi,
35
33
  getThreadChannel,
36
34
  getThreadExtendedAddressHex,
@@ -38,17 +36,22 @@ import {
38
36
  getThreadRoleName,
39
37
  getWiFiDiagnostics,
40
38
  getWiFiSecurityTypeName,
41
- getWiFiVersionName,
42
- parseNeighborTable
39
+ getWiFiVersionName
43
40
  } from "./network-utils.js";
44
41
  import "./update-connections-dialog.js";
45
42
  let NetworkDetails = class extends LitElement {
46
43
  constructor() {
47
44
  super(...arguments);
48
45
  this.selectedNodeId = null;
46
+ this.hideOfflineNodes = false;
47
+ this.hideWeakSignalEdges = false;
48
+ this.hideMediumSignalEdges = false;
49
+ this.hideStrongSignalEdges = false;
49
50
  this.nodes = {};
50
51
  this.unknownDevices = /* @__PURE__ */ new Map();
52
+ this.borderRouters = /* @__PURE__ */ new Map();
51
53
  this.wifiAccessPoints = /* @__PURE__ */ new Map();
54
+ this.threadEdgePairs = /* @__PURE__ */ new Map();
52
55
  this._showUpdateDialog = false;
53
56
  }
54
57
  _handleClose() {
@@ -75,16 +78,6 @@ let NetworkDetails = class extends LitElement {
75
78
  this._handleSelectNode(nodeId);
76
79
  }
77
80
  }
78
- _formatExtAddress(extAddr) {
79
- if (extAddr === void 0 || extAddr === "") return "Unknown";
80
- if (typeof extAddr === "bigint") {
81
- return extAddr.toString(16).toUpperCase().padStart(16, "0");
82
- }
83
- return extAddr;
84
- }
85
- _getSignalIcon(neighbor) {
86
- return this._getSignalIconFromColor(getSignalColor(neighbor));
87
- }
88
81
  _getSignalIconFromColor(color) {
89
82
  const strongColor = getCssVar("--signal-color-strong", "#4caf50");
90
83
  const mediumColor = getCssVar("--signal-color-medium", "#ff9800");
@@ -148,10 +141,13 @@ let NetworkDetails = class extends LitElement {
148
141
  const threadRole = getThreadRole(node);
149
142
  const channel = getThreadChannel(node);
150
143
  const extAddressHex = getThreadExtendedAddressHex(node);
151
- const extAddrMap = buildExtAddrMap(this.nodes);
152
- const rloc16Map = buildRloc16Map(this.nodes);
153
144
  const nodeId = String(node.node_id);
154
- const connections = getNodeConnections(nodeId, this.nodes, extAddrMap, rloc16Map);
145
+ const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes, {
146
+ hideOfflineNodes: this.hideOfflineNodes,
147
+ hideWeakSignalEdges: this.hideWeakSignalEdges,
148
+ hideMediumSignalEdges: this.hideMediumSignalEdges,
149
+ hideStrongSignalEdges: this.hideStrongSignalEdges
150
+ });
155
151
  return html`
156
152
  <div class="section">
157
153
  <h4>Thread Network</h4>
@@ -231,7 +227,13 @@ let NetworkDetails = class extends LitElement {
231
227
  >` : nothing}${conn.pathCost !== void 0 ? html`<span class="route-info"
232
228
  >, Cost: ${conn.pathCost}</span
233
229
  >` : nothing}
234
- ${!conn.isOutgoing ? html` <span class="direction-hint">(reverse)</span> ` : nothing}
230
+ ${conn.isReverseOnly ? html`
231
+ <span
232
+ class="direction-hint reverse-only"
233
+ title="Peer reports this node but this node has no matching neighbor-table entry. Possible one-way visibility (range, TX power, or stale neighbor table)."
234
+ >← one-way</span
235
+ >
236
+ ` : !conn.isOutgoing ? html` <span class="direction-hint">(reverse)</span> ` : nothing}
235
237
  </div>
236
238
  </div>
237
239
  </div>
@@ -287,24 +289,12 @@ let NetworkDetails = class extends LitElement {
287
289
  ` : nothing}
288
290
  `;
289
291
  }
290
- /**
291
- * Find the neighbor entry for an unknown device from a node's neighbor table.
292
- */
293
- _findNeighborEntry(node, unknownExtAddrHex) {
294
- const neighbors = parseNeighborTable(node);
295
- for (const neighbor of neighbors) {
296
- const neighborHex = this._formatExtAddress(neighbor.extAddress);
297
- if (neighborHex === unknownExtAddrHex) {
298
- return neighbor;
299
- }
300
- }
301
- return null;
302
- }
303
292
  _renderUnknownDeviceInfo(deviceId) {
304
- const unknown = this.unknownDevices.get(deviceId);
305
- if (!unknown) {
293
+ const device = this.unknownDevices.get(deviceId);
294
+ if (!device || device.kind !== "unknown") {
306
295
  return html` <p>Unknown device data not available</p> `;
307
296
  }
297
+ const unknown = device;
308
298
  return html`
309
299
  <div class="section">
310
300
  <h4>Unknown Device</h4>
@@ -316,6 +306,18 @@ let NetworkDetails = class extends LitElement {
316
306
  <span class="label">Extended Address:</span>
317
307
  <span class="value mono">${unknown.extAddressHex}</span>
318
308
  </div>
309
+ ${unknown.networkName !== void 0 ? html`
310
+ <div class="info-row">
311
+ <span class="label">Thread Network:</span>
312
+ <span class="value">${unknown.networkName}</span>
313
+ </div>
314
+ ` : nothing}
315
+ ${unknown.extendedPanIdHex !== void 0 ? html`
316
+ <div class="info-row">
317
+ <span class="label">Extended PAN ID:</span>
318
+ <span class="value mono">${unknown.extendedPanIdHex}</span>
319
+ </div>
320
+ ` : nothing}
319
321
  ${unknown.bestRssi !== null ? html`
320
322
  <div class="info-row">
321
323
  <span class="label">Best RSSI:</span>
@@ -324,71 +326,286 @@ let NetworkDetails = class extends LitElement {
324
326
  ` : nothing}
325
327
  </div>
326
328
 
327
- ${unknown.seenBy.length > 0 ? html`
328
- <md-divider></md-divider>
329
- <div class="section">
330
- <h4>Neighbors (${unknown.seenBy.length})</h4>
331
- <div class="neighbors-list">
332
- ${unknown.seenBy.toSorted((a, b) => {
333
- const score = (nodeId) => {
334
- const n = this.nodes[nodeId.toString()];
335
- if (!n) return -Infinity;
336
- const entry = this._findNeighborEntry(n, unknown.extAddressHex);
337
- if (!entry) return -Infinity;
338
- const rssi = entry.avgRssi ?? entry.lastRssi;
339
- if (rssi !== null && rssi !== void 0) return rssi;
340
- if (entry.lqi !== null && entry.lqi !== void 0) return entry.lqi;
329
+ ${this._renderExternalDeviceNeighbors(deviceId)}
330
+
331
+ <md-divider></md-divider>
332
+ <div class="section">
333
+ <p class="hint-text">
334
+ This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
335
+ Thread Border Router whose Thread radio MAC differs from its MeshCoP border-agent ID (common with
336
+ Apple and Aqara), or a device from another Matter ecosystem.
337
+ </p>
338
+ </div>
339
+ `;
340
+ }
341
+ /**
342
+ * Neighbor list shared by external-device panels (unknown + BR). Uses the
343
+ * same edge pairs as the graph so panel and graph agree on which links
344
+ * survive filtering. Sorted by best RSSI/LQI signal, descending.
345
+ */
346
+ _renderExternalDeviceNeighbors(deviceId) {
347
+ const connections = getNodeConnectionsFromPairs(deviceId, this.threadEdgePairs, this.nodes, {
348
+ hideOfflineNodes: this.hideOfflineNodes,
349
+ hideWeakSignalEdges: this.hideWeakSignalEdges,
350
+ hideMediumSignalEdges: this.hideMediumSignalEdges,
351
+ hideStrongSignalEdges: this.hideStrongSignalEdges
352
+ });
353
+ if (connections.length === 0) return nothing;
354
+ return html`
355
+ <md-divider></md-divider>
356
+ <div class="section">
357
+ <h4>Neighbors (${connections.length})</h4>
358
+ <div class="neighbors-list">
359
+ ${connections.toSorted((a, b) => {
360
+ const score = (conn) => {
361
+ if (conn.rssi !== null && conn.rssi !== void 0) return conn.rssi;
362
+ if (conn.lqi !== null && conn.lqi !== void 0) return conn.lqi;
341
363
  return -Infinity;
342
364
  };
343
365
  return score(b) - score(a);
344
- }).map((nodeId) => {
345
- const node = this.nodes[nodeId.toString()];
346
- if (!node) return nothing;
347
- const neighborEntry = this._findNeighborEntry(node, unknown.extAddressHex);
348
- const signalColor = neighborEntry ? getSignalColor(neighborEntry) : getCssVar("--graph-node-fallback", "#999");
349
- const rssi = neighborEntry?.avgRssi ?? neighborEntry?.lastRssi ?? null;
350
- const lqi = neighborEntry?.lqi;
366
+ }).map((conn) => {
367
+ if (!conn.connectedNode) return nothing;
351
368
  return html`
352
- <div
353
- class="neighbor-item clickable"
354
- role="button"
355
- tabindex="0"
356
- @click=${() => this._handleSelectNode(nodeId)}
357
- @keydown=${(e) => this._handleKeyDown(e, nodeId)}
358
- >
359
- ${neighborEntry ? html`
360
- <ha-svg-icon
361
- .path=${this._getSignalIcon(neighborEntry)}
362
- style="--icon-primary-color: ${signalColor}"
363
- ></ha-svg-icon>
364
- ` : nothing}
365
- <div class="neighbor-info">
366
- <div class="neighbor-name">
367
- Node ${nodeId}
368
- <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
369
- ${getDeviceName(node)}
370
- </div>
371
- ${neighborEntry ? html`
372
- <div class="neighbor-signal">
373
- ${rssi !== null ? html`RSSI: ${rssi} dBm, ` : nothing}
374
- ${lqi !== void 0 ? html`LQI: ${lqi}` : nothing}
375
- </div>
376
- ` : nothing}
377
- </div>
378
- </div>
379
- `;
369
+ <div
370
+ class="neighbor-item clickable"
371
+ role="button"
372
+ tabindex="0"
373
+ @click=${() => this._handleSelectNode(conn.connectedNodeId)}
374
+ @keydown=${(e) => this._handleKeyDown(e, conn.connectedNodeId)}
375
+ >
376
+ <ha-svg-icon
377
+ .path=${this._getSignalIconFromColor(conn.signalColor)}
378
+ style="--icon-primary-color: ${conn.signalColor}"
379
+ ></ha-svg-icon>
380
+ <div class="neighbor-info">
381
+ <div class="neighbor-name">
382
+ Node ${conn.connectedNodeId}
383
+ <span class="node-id-hex"
384
+ >${this._formatNodeIdHex(conn.connectedNodeId)}</span
385
+ >: ${getDeviceName(conn.connectedNode)}
386
+ </div>
387
+ <div class="neighbor-signal">
388
+ ${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm, ` : nothing}
389
+ ${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}
390
+ </div>
391
+ </div>
392
+ </div>
393
+ `;
380
394
  })}
381
- </div>
395
+ </div>
396
+ </div>
397
+ `;
398
+ }
399
+ /**
400
+ * Identity rows for a Border Router (network name, vendor, model, Thread version, ext address).
401
+ * Caller controls the surrounding <div class="section"> + heading.
402
+ */
403
+ _renderBorderRouterIdentityRows(br, includeExtAddr) {
404
+ return html`
405
+ ${br.networkName ? html`
406
+ <div class="info-row">
407
+ <span class="label">Network name:</span>
408
+ <span class="value">${br.networkName}</span>
382
409
  </div>
383
410
  ` : nothing}
384
-
385
- <md-divider></md-divider>
411
+ ${br.vendorName ? html`
412
+ <div class="info-row">
413
+ <span class="label">Vendor:</span>
414
+ <span class="value">${br.vendorName}</span>
415
+ </div>
416
+ ` : nothing}
417
+ ${br.modelName ? html`
418
+ <div class="info-row">
419
+ <span class="label">Model:</span>
420
+ <span class="value">${br.modelName}</span>
421
+ </div>
422
+ ` : nothing}
423
+ ${br.threadVersion ? html`
424
+ <div class="info-row">
425
+ <span class="label">Thread version:</span>
426
+ <span class="value">${br.threadVersion}</span>
427
+ </div>
428
+ ` : nothing}
429
+ ${includeExtAddr ? html`
430
+ <div class="info-row">
431
+ <span class="label">Extended Address:</span>
432
+ <span class="value mono">${br.extAddressHex}</span>
433
+ </div>
434
+ ` : nothing}
435
+ `;
436
+ }
437
+ /**
438
+ * Render the MeshCoP state bitmap as decoded fields (BBR role, connection mode, Thread
439
+ * interface status, availability, ePSKc) plus the raw hex underneath. Reserved values are
440
+ * rendered as numeric so a future spec extension stays visible.
441
+ */
442
+ _renderStateBitmap(hex) {
443
+ if (hex === void 0) return nothing;
444
+ const decoded = decodeMeshcopStateBitmap(hex);
445
+ if (decoded === void 0) {
446
+ return html`
447
+ <div class="info-row">
448
+ <span class="label">State bitmap:</span>
449
+ <span class="value mono">${hex}</span>
450
+ </div>
451
+ `;
452
+ }
453
+ const stateParts = new Array();
454
+ stateParts.push(decoded.bbr ? `BBR (${decoded.bbrFunction ?? "?"})` : "not BBR");
455
+ if (decoded.threadRole !== void 0) {
456
+ stateParts.push(`Thread ${decoded.threadRole}`);
457
+ }
458
+ if (decoded.threadInterfaceStatus !== void 0) {
459
+ stateParts.push(decoded.threadInterfaceStatus);
460
+ }
461
+ return html`
462
+ <div class="info-row">
463
+ <span class="label">State:</span>
464
+ <span class="value">${stateParts.join(", ")}</span>
465
+ </div>
466
+ <div class="info-row">
467
+ <span class="label">Connection:</span>
468
+ <span class="value">${decoded.connectionMode ?? `reserved (${decoded.connectionModeValue})`}</span>
469
+ </div>
470
+ <div class="info-row">
471
+ <span class="label">Availability:</span>
472
+ <span class="value">${decoded.availability ?? `reserved (${decoded.availabilityValue})`}</span>
473
+ </div>
474
+ <div class="info-row">
475
+ <span class="label">ePSKc:</span>
476
+ <span class="value">${decoded.epskcSupported ? "supported" : "not supported"}</span>
477
+ </div>
478
+ ${decoded.multiAilStateValue !== 0 ? html`
479
+ <div class="info-row">
480
+ <span class="label">Multi-AIL:</span>
481
+ <span class="value">
482
+ ${decoded.multiAilState ?? `reserved (${decoded.multiAilStateValue})`}
483
+ </span>
484
+ </div>
485
+ ` : nothing}
486
+ <div class="info-row">
487
+ <span class="label">State bitmap (raw):</span>
488
+ <span class="value mono">${hex}</span>
489
+ </div>
490
+ `;
491
+ }
492
+ /**
493
+ * Network-info rows for a Border Router (extended PAN ID, partition, timestamps, state, domain, agent ID).
494
+ * Returns nothing if no fields are populated, so the caller can skip the surrounding section.
495
+ */
496
+ _renderBorderRouterNetworkRows(br) {
497
+ const hasAny = br.extendedPanIdHex !== void 0 || br.partitionIdHex !== void 0 || br.activeTimestampHex !== void 0 || br.stateBitmapHex !== void 0 || br.domainName !== void 0 || br.borderAgentIdHex !== void 0;
498
+ if (!hasAny) return nothing;
499
+ return html`
500
+ ${br.extendedPanIdHex ? html`
501
+ <div class="info-row">
502
+ <span class="label">Extended PAN ID:</span>
503
+ <span class="value mono">${br.extendedPanIdHex}</span>
504
+ </div>
505
+ ` : nothing}
506
+ ${br.partitionIdHex ? html`
507
+ <div class="info-row">
508
+ <span class="label">Partition ID:</span>
509
+ <span class="value mono">${br.partitionIdHex}</span>
510
+ </div>
511
+ ` : nothing}
512
+ ${br.activeTimestampHex ? html`
513
+ <div class="info-row">
514
+ <span class="label">Active timestamp:</span>
515
+ <span class="value mono">${br.activeTimestampHex}</span>
516
+ </div>
517
+ ` : nothing}
518
+ ${this._renderStateBitmap(br.stateBitmapHex)}
519
+ ${br.domainName ? html`
520
+ <div class="info-row">
521
+ <span class="label">Domain:</span>
522
+ <span class="value">${br.domainName}</span>
523
+ </div>
524
+ ` : nothing}
525
+ ${br.borderAgentIdHex ? html`
526
+ <div class="info-row">
527
+ <span class="label">Border agent ID:</span>
528
+ <span class="value mono">${br.borderAgentIdHex}</span>
529
+ </div>
530
+ ` : nothing}
531
+ `;
532
+ }
533
+ /**
534
+ * Address rows for a Border Router (hostname, IPs, ports, sources).
535
+ */
536
+ _renderBorderRouterAddressRows(br) {
537
+ const hasAny = br.hostname !== void 0 || br.addresses.length > 0 || br.meshcopPort !== void 0 || br.trelPort !== void 0 || br.sources.length > 0;
538
+ if (!hasAny) return nothing;
539
+ return html`
540
+ ${br.hostname ? html`
541
+ <div class="info-row">
542
+ <span class="label">Hostname:</span>
543
+ <span class="value mono">${br.hostname}</span>
544
+ </div>
545
+ ` : nothing}
546
+ ${br.addresses.map(
547
+ (addr) => html`
548
+ <div class="info-row">
549
+ <span class="label">Address:</span>
550
+ <span class="value mono">${addr}</span>
551
+ </div>
552
+ `
553
+ )}
554
+ ${br.meshcopPort !== void 0 ? html`
555
+ <div class="info-row">
556
+ <span class="label">meshcop port:</span>
557
+ <span class="value">${br.meshcopPort}</span>
558
+ </div>
559
+ ` : nothing}
560
+ ${br.trelPort !== void 0 ? html`
561
+ <div class="info-row">
562
+ <span class="label">trel port:</span>
563
+ <span class="value">${br.trelPort}</span>
564
+ </div>
565
+ ` : nothing}
566
+ ${br.sources.length > 0 ? html`
567
+ <div class="info-row">
568
+ <span class="label">Sources:</span>
569
+ <span class="value">${br.sources.join(", ")}</span>
570
+ </div>
571
+ ` : nothing}
572
+ `;
573
+ }
574
+ _renderBorderRouterInfo(deviceId) {
575
+ const device = this.unknownDevices.get(deviceId);
576
+ if (!device || device.kind !== "br") {
577
+ return html` <p>Border router data not available</p> `;
578
+ }
579
+ const br = device;
580
+ const networkRows = this._renderBorderRouterNetworkRows(br);
581
+ const addressRows = this._renderBorderRouterAddressRows(br);
582
+ return html`
386
583
  <div class="section">
387
- <p class="hint-text">
388
- This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
389
- Thread Border Router or a device from another Matter ecosystem.
390
- </p>
584
+ <h4>Border Router</h4>
585
+ ${this._renderBorderRouterIdentityRows(br, true)}
586
+ ${br.bestRssi !== null ? html`
587
+ <div class="info-row">
588
+ <span class="label">Best RSSI:</span>
589
+ <span class="value">${br.bestRssi} dBm</span>
590
+ </div>
591
+ ` : nothing}
391
592
  </div>
593
+
594
+ ${networkRows !== nothing ? html`
595
+ <md-divider></md-divider>
596
+ <div class="section">
597
+ <h4>Thread Network</h4>
598
+ ${networkRows}
599
+ </div>
600
+ ` : nothing}
601
+ ${addressRows !== nothing ? html`
602
+ <md-divider></md-divider>
603
+ <div class="section">
604
+ <h4>Addresses</h4>
605
+ ${addressRows}
606
+ </div>
607
+ ` : nothing}
608
+ ${this._renderExternalDeviceNeighbors(deviceId)}
392
609
  `;
393
610
  }
394
611
  /**
@@ -398,8 +615,8 @@ let NetworkDetails = class extends LitElement {
398
615
  if (this.selectedNodeId === null) return false;
399
616
  const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
400
617
  if (isAccessPoint) return false;
401
- const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
402
- if (isUnknown) {
618
+ const isExternal = typeof this.selectedNodeId === "string" && (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_"));
619
+ if (isExternal) {
403
620
  return this._getOnlineSeenByNodes().length > 0;
404
621
  }
405
622
  const node = this.nodes[this.selectedNodeId.toString()];
@@ -412,7 +629,7 @@ let NetworkDetails = class extends LitElement {
412
629
  * Get the type of the currently selected node for dialog variant.
413
630
  */
414
631
  _getSelectedNodeType() {
415
- if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
632
+ if (typeof this.selectedNodeId === "string" && (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_"))) {
416
633
  return "unknown";
417
634
  }
418
635
  const node = this.nodes[this.selectedNodeId.toString()];
@@ -429,15 +646,10 @@ let NetworkDetails = class extends LitElement {
429
646
  if (!node) return [];
430
647
  const networkType = getNetworkType(node);
431
648
  if (networkType === "thread") {
432
- const extAddrMap = buildExtAddrMap(this.nodes);
433
- const rloc16Map = buildRloc16Map(this.nodes);
434
- const connections = getNodeConnections(nodeId, this.nodes, extAddrMap, rloc16Map);
649
+ const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes);
435
650
  return connections.filter((conn) => {
436
- if (typeof conn.connectedNodeId === "string" && conn.connectedNodeId.startsWith("unknown_")) {
437
- return false;
438
- }
439
- const connectedNode = this.nodes[String(conn.connectedNodeId)];
440
- return connectedNode?.available === true;
651
+ if (conn.isUnknown) return false;
652
+ return conn.connectedNode?.available === true;
441
653
  }).map((conn) => String(conn.connectedNodeId));
442
654
  }
443
655
  return [];
@@ -446,12 +658,12 @@ let NetworkDetails = class extends LitElement {
446
658
  * Get online nodes that see an unknown device.
447
659
  */
448
660
  _getOnlineSeenByNodes() {
449
- if (typeof this.selectedNodeId !== "string" || !this.selectedNodeId.startsWith("unknown_")) {
661
+ if (typeof this.selectedNodeId !== "string" || !this.selectedNodeId.startsWith("unknown_") && !this.selectedNodeId.startsWith("br_")) {
450
662
  return [];
451
663
  }
452
- const unknown = this.unknownDevices.get(this.selectedNodeId);
453
- if (!unknown) return [];
454
- return unknown.seenBy.filter((nodeId) => {
664
+ const device = this.unknownDevices.get(this.selectedNodeId);
665
+ if (!device) return [];
666
+ return device.seenBy.filter((nodeId) => {
455
667
  const node = this.nodes[nodeId.toString()];
456
668
  return node?.available === true;
457
669
  });
@@ -460,11 +672,19 @@ let NetworkDetails = class extends LitElement {
460
672
  * Get the name of the selected node for display in dialog.
461
673
  */
462
674
  _getSelectedNodeName() {
463
- if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
464
- const unknown = this.unknownDevices.get(this.selectedNodeId);
465
- if (!unknown) return "External Device";
466
- const typeLabel = unknown.isRouter ? "External Router" : "External Device";
467
- return `${typeLabel} (${unknown.extAddressHex.slice(-8)})`;
675
+ if (typeof this.selectedNodeId === "string") {
676
+ if (this.selectedNodeId.startsWith("br_")) {
677
+ const device = this.unknownDevices.get(this.selectedNodeId);
678
+ if (!device || device.kind !== "br") return "Border Router";
679
+ const label = device.networkName ?? device.vendorName ?? "Border Router";
680
+ return `${label} (${device.extAddressHex.slice(-8)})`;
681
+ }
682
+ if (this.selectedNodeId.startsWith("unknown_")) {
683
+ const device = this.unknownDevices.get(this.selectedNodeId);
684
+ if (!device || device.kind !== "unknown") return "External Device";
685
+ const typeLabel = device.isRouter ? "External Router" : "External Device";
686
+ return `${typeLabel} (${device.extAddressHex.slice(-8)})`;
687
+ }
468
688
  }
469
689
  const node = this.nodes[this.selectedNodeId.toString()];
470
690
  return node ? getDeviceName(node) : "Unknown";
@@ -474,6 +694,12 @@ let NetworkDetails = class extends LitElement {
474
694
  }
475
695
  _handleDialogClose() {
476
696
  this._showUpdateDialog = false;
697
+ this.dispatchEvent(
698
+ new CustomEvent("connections-updated", {
699
+ bubbles: true,
700
+ composed: true
701
+ })
702
+ );
477
703
  }
478
704
  _renderWiFiAccessPointInfo(apId) {
479
705
  const ap = this.wifiAccessPoints.get(apId);
@@ -538,6 +764,33 @@ let NetworkDetails = class extends LitElement {
538
764
  </div>
539
765
  `;
540
766
  }
767
+ /**
768
+ * Annotation shown on a commissioned Thread node that is also a discovered Border Router.
769
+ * Mirrors the BR Identity/Network/Addresses sections, sans the redundant ext-address row.
770
+ */
771
+ _renderCommissionedNodeBorderRouterAnnotation(node) {
772
+ const xaHex = getThreadExtendedAddressHex(node);
773
+ if (!xaHex) return nothing;
774
+ const br = this.borderRouters.get(xaHex);
775
+ if (!br) return nothing;
776
+ const networkRows = this._renderBorderRouterNetworkRows(br);
777
+ const addressRows = this._renderBorderRouterAddressRows(br);
778
+ return html`
779
+ <md-divider></md-divider>
780
+ <div class="section">
781
+ <h4>Also a Border Router</h4>
782
+ ${this._renderBorderRouterIdentityRows(br, false)}
783
+ ${networkRows !== nothing ? html`
784
+ <div class="subsection-label">Thread Network</div>
785
+ ${networkRows}
786
+ ` : nothing}
787
+ ${addressRows !== nothing ? html`
788
+ <div class="subsection-label">Addresses</div>
789
+ ${addressRows}
790
+ ` : nothing}
791
+ </div>
792
+ `;
793
+ }
541
794
  render() {
542
795
  if (this.selectedNodeId === null) {
543
796
  return html`
@@ -584,6 +837,44 @@ let NetworkDetails = class extends LitElement {
584
837
  ` : nothing}
585
838
  `;
586
839
  }
840
+ const borderRouterId = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("br_") ? this.selectedNodeId : null;
841
+ if (borderRouterId !== null) {
842
+ const onlineSeenByNodes = this._getOnlineSeenByNodes();
843
+ return html`
844
+ <div class="details-panel">
845
+ <div class="header">
846
+ <h3>Border Router</h3>
847
+ <div class="header-actions">
848
+ ${onlineSeenByNodes.length > 0 ? html`
849
+ <button
850
+ class="action-button"
851
+ @click=${this._handleUpdateConnections}
852
+ aria-label="Update connection data"
853
+ title="Update connection data"
854
+ >
855
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
856
+ </button>
857
+ ` : nothing}
858
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
859
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
860
+ </button>
861
+ </div>
862
+ </div>
863
+ <div class="content">${this._renderBorderRouterInfo(borderRouterId)}</div>
864
+ </div>
865
+ ${this._showUpdateDialog ? html`
866
+ <update-connections-dialog
867
+ .client=${this.client}
868
+ .nodes=${this.nodes}
869
+ selectedNodeType="unknown"
870
+ .selectedNodeName=${this._getSelectedNodeName()}
871
+ .selectedNodeId=${this.selectedNodeId}
872
+ .onlineNeighborIds=${onlineSeenByNodes}
873
+ @dialog-closed=${this._handleDialogClose}
874
+ ></update-connections-dialog>
875
+ ` : nothing}
876
+ `;
877
+ }
587
878
  const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
588
879
  if (isAccessPoint) {
589
880
  return html`
@@ -632,7 +923,9 @@ let NetworkDetails = class extends LitElement {
632
923
  </button>
633
924
  </div>
634
925
  </div>
635
- <div class="content">${this._renderNodeInfo(node)}</div>
926
+ <div class="content">
927
+ ${this._renderNodeInfo(node)}${this._renderCommissionedNodeBorderRouterAnnotation(node)}
928
+ </div>
636
929
  <div class="footer">
637
930
  <a href="#node/${this.selectedNodeId}" class="view-link">View node details</a>
638
931
  </div>
@@ -765,6 +1058,15 @@ NetworkDetails.styles = [
765
1058
  letter-spacing: 0.5px;
766
1059
  }
767
1060
 
1061
+ .subsection-label {
1062
+ margin: 12px 0 4px 0;
1063
+ font-size: 0.75rem;
1064
+ font-weight: 500;
1065
+ color: var(--md-sys-color-on-surface-variant, #666);
1066
+ text-transform: uppercase;
1067
+ letter-spacing: 0.4px;
1068
+ }
1069
+
768
1070
  .info-row {
769
1071
  display: flex;
770
1072
  justify-content: space-between;
@@ -848,6 +1150,14 @@ NetworkDetails.styles = [
848
1150
  opacity: 0.8;
849
1151
  }
850
1152
 
1153
+ .direction-hint.reverse-only {
1154
+ font-style: normal;
1155
+ font-weight: 500;
1156
+ opacity: 1;
1157
+ color: var(--md-sys-color-error, #b3261e);
1158
+ cursor: help;
1159
+ }
1160
+
851
1161
  .route-info {
852
1162
  color: var(--md-sys-color-tertiary, #7d5260);
853
1163
  font-size: 0.85em;
@@ -922,15 +1232,33 @@ NetworkDetails.styles = [
922
1232
  __decorateClass([
923
1233
  property()
924
1234
  ], NetworkDetails.prototype, "selectedNodeId", 2);
1235
+ __decorateClass([
1236
+ property({ type: Boolean })
1237
+ ], NetworkDetails.prototype, "hideOfflineNodes", 2);
1238
+ __decorateClass([
1239
+ property({ type: Boolean })
1240
+ ], NetworkDetails.prototype, "hideWeakSignalEdges", 2);
1241
+ __decorateClass([
1242
+ property({ type: Boolean })
1243
+ ], NetworkDetails.prototype, "hideMediumSignalEdges", 2);
1244
+ __decorateClass([
1245
+ property({ type: Boolean })
1246
+ ], NetworkDetails.prototype, "hideStrongSignalEdges", 2);
925
1247
  __decorateClass([
926
1248
  property({ type: Object })
927
1249
  ], NetworkDetails.prototype, "nodes", 2);
928
1250
  __decorateClass([
929
1251
  property({ type: Object })
930
1252
  ], NetworkDetails.prototype, "unknownDevices", 2);
1253
+ __decorateClass([
1254
+ property({ attribute: false })
1255
+ ], NetworkDetails.prototype, "borderRouters", 2);
931
1256
  __decorateClass([
932
1257
  property({ type: Object })
933
1258
  ], NetworkDetails.prototype, "wifiAccessPoints", 2);
1259
+ __decorateClass([
1260
+ property({ type: Object })
1261
+ ], NetworkDetails.prototype, "threadEdgePairs", 2);
934
1262
  __decorateClass([
935
1263
  consume({ context: clientContext })
936
1264
  ], NetworkDetails.prototype, "client", 2);