@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
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { consume } from "@lit/context";
8
8
  import "@material/web/divider/divider";
9
- import { isTestNodeId, type MatterClient, type MatterNode } from "@matter-server/ws-client";
9
+ import { isTestNodeId, type BorderRouterEntry, type MatterClient, type MatterNode } from "@matter-server/ws-client";
10
10
  import { mdiClose, mdiRefresh, mdiSignalCellular1, mdiSignalCellular2, mdiSignalCellular3 } from "@mdi/js";
11
11
  import { LitElement, TemplateResult, css, html, nothing } from "lit";
12
12
  import { customElement, property, state } from "lit/decorators.js";
@@ -14,16 +14,14 @@ import { clientContext } from "../../client/client-context.js";
14
14
  import "../../components/ha-svg-icon";
15
15
  import { formatNodeAddressFromAny, getEffectiveFabricIndex } from "../../util/format_hex.js";
16
16
  import { getCssVar, reducedMotionStyles } from "../../util/shared-styles.js";
17
- import type { ThreadNeighbor } from "./network-types.js";
17
+ import type { ThreadEdgePair, ThreadExternalDevice } from "./network-types.js";
18
18
  import type { NodeConnection } from "./network-utils.js";
19
19
  import {
20
- buildExtAddrMap,
21
- buildRloc16Map,
20
+ decodeMeshcopStateBitmap,
22
21
  getDeviceName,
23
22
  getNetworkType,
24
- getNodeConnections,
23
+ getNodeConnectionsFromPairs,
25
24
  getRoutableDestinationsCount,
26
- getSignalColor,
27
25
  getSignalColorFromRssi,
28
26
  getThreadChannel,
29
27
  getThreadExtendedAddressHex,
@@ -32,7 +30,6 @@ import {
32
30
  getWiFiDiagnostics,
33
31
  getWiFiSecurityTypeName,
34
32
  getWiFiVersionName,
35
- parseNeighborTable,
36
33
  } from "./network-utils.js";
37
34
  import "./update-connections-dialog.js";
38
35
 
@@ -47,18 +44,33 @@ export class NetworkDetails extends LitElement {
47
44
  @property()
48
45
  public selectedNodeId: number | string | null = null;
49
46
 
47
+ @property({ type: Boolean })
48
+ public hideOfflineNodes = false;
49
+
50
+ @property({ type: Boolean })
51
+ public hideWeakSignalEdges = false;
52
+
53
+ @property({ type: Boolean })
54
+ public hideMediumSignalEdges = false;
55
+
56
+ @property({ type: Boolean })
57
+ public hideStrongSignalEdges = false;
58
+
50
59
  @property({ type: Object })
51
60
  public nodes: Record<string, MatterNode> = {};
52
61
 
53
62
  @property({ type: Object })
54
- public unknownDevices: Map<
55
- string,
56
- { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
57
- > = new Map();
63
+ public unknownDevices: ReadonlyMap<string, ThreadExternalDevice> = new Map();
64
+
65
+ @property({ attribute: false })
66
+ public borderRouters: ReadonlyMap<string, BorderRouterEntry> = new Map();
58
67
 
59
68
  @property({ type: Object })
60
69
  public wifiAccessPoints: Map<string, { bssid: string; connectedNodes: string[] }> = new Map();
61
70
 
71
+ @property({ type: Object })
72
+ public threadEdgePairs: Map<string, ThreadEdgePair> = new Map();
73
+
62
74
  @consume({ context: clientContext })
63
75
  private client!: MatterClient;
64
76
 
@@ -92,18 +104,6 @@ export class NetworkDetails extends LitElement {
92
104
  }
93
105
  }
94
106
 
95
- private _formatExtAddress(extAddr: bigint | string | undefined): string {
96
- if (extAddr === undefined || extAddr === "") return "Unknown";
97
- if (typeof extAddr === "bigint") {
98
- return extAddr.toString(16).toUpperCase().padStart(16, "0");
99
- }
100
- return extAddr;
101
- }
102
-
103
- private _getSignalIcon(neighbor: ThreadNeighbor): string {
104
- return this._getSignalIconFromColor(getSignalColor(neighbor));
105
- }
106
-
107
107
  private _getSignalIconFromColor(color: string): string {
108
108
  const strongColor = getCssVar("--signal-color-strong", "#4caf50");
109
109
  const mediumColor = getCssVar("--signal-color-medium", "#ff9800");
@@ -185,13 +185,14 @@ export class NetworkDetails extends LitElement {
185
185
  const threadRole = getThreadRole(node);
186
186
  const channel = getThreadChannel(node);
187
187
  const extAddressHex = getThreadExtendedAddressHex(node);
188
- const extAddrMap = buildExtAddrMap(this.nodes);
189
- const rloc16Map = buildRloc16Map(this.nodes);
190
-
191
- // Get all connections (bidirectional) - this matches what the graph shows
192
- // Use string to avoid BigInt precision loss
188
+ // Get connections from edge pairs with the same filter pipeline as the graph
193
189
  const nodeId = String(node.node_id);
194
- const connections = getNodeConnections(nodeId, this.nodes, extAddrMap, rloc16Map);
190
+ const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes, {
191
+ hideOfflineNodes: this.hideOfflineNodes,
192
+ hideWeakSignalEdges: this.hideWeakSignalEdges,
193
+ hideMediumSignalEdges: this.hideMediumSignalEdges,
194
+ hideStrongSignalEdges: this.hideStrongSignalEdges,
195
+ });
195
196
 
196
197
  return html`
197
198
  <div class="section">
@@ -294,9 +295,17 @@ export class NetworkDetails extends LitElement {
294
295
  >, Cost: ${conn.pathCost}</span
295
296
  >`
296
297
  : nothing}
297
- ${!conn.isOutgoing
298
- ? html` <span class="direction-hint">(reverse)</span> `
299
- : nothing}
298
+ ${conn.isReverseOnly
299
+ ? html`
300
+ <span
301
+ class="direction-hint reverse-only"
302
+ 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)."
303
+ >← one-way</span
304
+ >
305
+ `
306
+ : !conn.isOutgoing
307
+ ? html` <span class="direction-hint">(reverse)</span> `
308
+ : nothing}
300
309
  </div>
301
310
  </div>
302
311
  </div>
@@ -362,25 +371,12 @@ export class NetworkDetails extends LitElement {
362
371
  `;
363
372
  }
364
373
 
365
- /**
366
- * Find the neighbor entry for an unknown device from a node's neighbor table.
367
- */
368
- private _findNeighborEntry(node: MatterNode, unknownExtAddrHex: string): ThreadNeighbor | null {
369
- const neighbors = parseNeighborTable(node);
370
- for (const neighbor of neighbors) {
371
- const neighborHex = this._formatExtAddress(neighbor.extAddress);
372
- if (neighborHex === unknownExtAddrHex) {
373
- return neighbor;
374
- }
375
- }
376
- return null;
377
- }
378
-
379
374
  private _renderUnknownDeviceInfo(deviceId: string): TemplateResult | typeof nothing {
380
- const unknown = this.unknownDevices.get(deviceId);
381
- if (!unknown) {
375
+ const device = this.unknownDevices.get(deviceId);
376
+ if (!device || device.kind !== "unknown") {
382
377
  return html` <p>Unknown device data not available</p> `;
383
378
  }
379
+ const unknown = device;
384
380
 
385
381
  return html`
386
382
  <div class="section">
@@ -393,6 +389,22 @@ export class NetworkDetails extends LitElement {
393
389
  <span class="label">Extended Address:</span>
394
390
  <span class="value mono">${unknown.extAddressHex}</span>
395
391
  </div>
392
+ ${unknown.networkName !== undefined
393
+ ? html`
394
+ <div class="info-row">
395
+ <span class="label">Thread Network:</span>
396
+ <span class="value">${unknown.networkName}</span>
397
+ </div>
398
+ `
399
+ : nothing}
400
+ ${unknown.extendedPanIdHex !== undefined
401
+ ? html`
402
+ <div class="info-row">
403
+ <span class="label">Extended PAN ID:</span>
404
+ <span class="value mono">${unknown.extendedPanIdHex}</span>
405
+ </div>
406
+ `
407
+ : nothing}
396
408
  ${unknown.bestRssi !== null
397
409
  ? html`
398
410
  <div class="info-row">
@@ -403,84 +415,349 @@ export class NetworkDetails extends LitElement {
403
415
  : nothing}
404
416
  </div>
405
417
 
406
- ${unknown.seenBy.length > 0
418
+ ${this._renderExternalDeviceNeighbors(deviceId)}
419
+
420
+ <md-divider></md-divider>
421
+ <div class="section">
422
+ <p class="hint-text">
423
+ This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
424
+ Thread Border Router whose Thread radio MAC differs from its MeshCoP border-agent ID (common with
425
+ Apple and Aqara), or a device from another Matter ecosystem.
426
+ </p>
427
+ </div>
428
+ `;
429
+ }
430
+
431
+ /**
432
+ * Neighbor list shared by external-device panels (unknown + BR). Uses the
433
+ * same edge pairs as the graph so panel and graph agree on which links
434
+ * survive filtering. Sorted by best RSSI/LQI signal, descending.
435
+ */
436
+ private _renderExternalDeviceNeighbors(deviceId: string): TemplateResult | typeof nothing {
437
+ const connections = getNodeConnectionsFromPairs(deviceId, this.threadEdgePairs, this.nodes, {
438
+ hideOfflineNodes: this.hideOfflineNodes,
439
+ hideWeakSignalEdges: this.hideWeakSignalEdges,
440
+ hideMediumSignalEdges: this.hideMediumSignalEdges,
441
+ hideStrongSignalEdges: this.hideStrongSignalEdges,
442
+ });
443
+
444
+ if (connections.length === 0) return nothing;
445
+
446
+ return html`
447
+ <md-divider></md-divider>
448
+ <div class="section">
449
+ <h4>Neighbors (${connections.length})</h4>
450
+ <div class="neighbors-list">
451
+ ${connections
452
+ .toSorted((a, b) => {
453
+ const score = (conn: NodeConnection): number => {
454
+ if (conn.rssi !== null && conn.rssi !== undefined) return conn.rssi;
455
+ if (conn.lqi !== null && conn.lqi !== undefined) return conn.lqi;
456
+ return -Infinity;
457
+ };
458
+ return score(b) - score(a);
459
+ })
460
+ .map((conn: NodeConnection) => {
461
+ if (!conn.connectedNode) return nothing;
462
+
463
+ return html`
464
+ <div
465
+ class="neighbor-item clickable"
466
+ role="button"
467
+ tabindex="0"
468
+ @click=${() => this._handleSelectNode(conn.connectedNodeId)}
469
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, conn.connectedNodeId)}
470
+ >
471
+ <ha-svg-icon
472
+ .path=${this._getSignalIconFromColor(conn.signalColor)}
473
+ style="--icon-primary-color: ${conn.signalColor}"
474
+ ></ha-svg-icon>
475
+ <div class="neighbor-info">
476
+ <div class="neighbor-name">
477
+ Node ${conn.connectedNodeId}
478
+ <span class="node-id-hex"
479
+ >${this._formatNodeIdHex(conn.connectedNodeId)}</span
480
+ >: ${getDeviceName(conn.connectedNode)}
481
+ </div>
482
+ <div class="neighbor-signal">
483
+ ${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm, ` : nothing}
484
+ ${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}
485
+ </div>
486
+ </div>
487
+ </div>
488
+ `;
489
+ })}
490
+ </div>
491
+ </div>
492
+ `;
493
+ }
494
+
495
+ /**
496
+ * Identity rows for a Border Router (network name, vendor, model, Thread version, ext address).
497
+ * Caller controls the surrounding <div class="section"> + heading.
498
+ */
499
+ private _renderBorderRouterIdentityRows(br: BorderRouterEntry, includeExtAddr: boolean): TemplateResult {
500
+ return html`
501
+ ${br.networkName
407
502
  ? html`
408
- <md-divider></md-divider>
409
- <div class="section">
410
- <h4>Neighbors (${unknown.seenBy.length})</h4>
411
- <div class="neighbors-list">
412
- ${unknown.seenBy
413
- .toSorted((a, b) => {
414
- const score = (nodeId: string): number => {
415
- const n = this.nodes[nodeId.toString()];
416
- if (!n) return -Infinity;
417
- const entry = this._findNeighborEntry(n, unknown.extAddressHex);
418
- if (!entry) return -Infinity;
419
- const rssi = entry.avgRssi ?? entry.lastRssi;
420
- if (rssi !== null && rssi !== undefined) return rssi;
421
- if (entry.lqi !== null && entry.lqi !== undefined) return entry.lqi;
422
- return -Infinity;
423
- };
424
- return score(b) - score(a);
425
- })
426
- .map(nodeId => {
427
- const node = this.nodes[nodeId.toString()];
428
- if (!node) return nothing;
503
+ <div class="info-row">
504
+ <span class="label">Network name:</span>
505
+ <span class="value">${br.networkName}</span>
506
+ </div>
507
+ `
508
+ : nothing}
509
+ ${br.vendorName
510
+ ? html`
511
+ <div class="info-row">
512
+ <span class="label">Vendor:</span>
513
+ <span class="value">${br.vendorName}</span>
514
+ </div>
515
+ `
516
+ : nothing}
517
+ ${br.modelName
518
+ ? html`
519
+ <div class="info-row">
520
+ <span class="label">Model:</span>
521
+ <span class="value">${br.modelName}</span>
522
+ </div>
523
+ `
524
+ : nothing}
525
+ ${br.threadVersion
526
+ ? html`
527
+ <div class="info-row">
528
+ <span class="label">Thread version:</span>
529
+ <span class="value">${br.threadVersion}</span>
530
+ </div>
531
+ `
532
+ : nothing}
533
+ ${includeExtAddr
534
+ ? html`
535
+ <div class="info-row">
536
+ <span class="label">Extended Address:</span>
537
+ <span class="value mono">${br.extAddressHex}</span>
538
+ </div>
539
+ `
540
+ : nothing}
541
+ `;
542
+ }
429
543
 
430
- // Find the neighbor entry to get RSSI/LQI
431
- const neighborEntry = this._findNeighborEntry(node, unknown.extAddressHex);
432
- const signalColor = neighborEntry
433
- ? getSignalColor(neighborEntry)
434
- : getCssVar("--graph-node-fallback", "#999");
435
- const rssi = neighborEntry?.avgRssi ?? neighborEntry?.lastRssi ?? null;
436
- const lqi = neighborEntry?.lqi;
544
+ /**
545
+ * Render the MeshCoP state bitmap as decoded fields (BBR role, connection mode, Thread
546
+ * interface status, availability, ePSKc) plus the raw hex underneath. Reserved values are
547
+ * rendered as numeric so a future spec extension stays visible.
548
+ */
549
+ private _renderStateBitmap(hex: string | undefined): TemplateResult | typeof nothing {
550
+ if (hex === undefined) return nothing;
551
+ const decoded = decodeMeshcopStateBitmap(hex);
552
+ if (decoded === undefined) {
553
+ return html`
554
+ <div class="info-row">
555
+ <span class="label">State bitmap:</span>
556
+ <span class="value mono">${hex}</span>
557
+ </div>
558
+ `;
559
+ }
437
560
 
438
- return html`
439
- <div
440
- class="neighbor-item clickable"
441
- role="button"
442
- tabindex="0"
443
- @click=${() => this._handleSelectNode(nodeId)}
444
- @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
445
- >
446
- ${neighborEntry
447
- ? html`
448
- <ha-svg-icon
449
- .path=${this._getSignalIcon(neighborEntry)}
450
- style="--icon-primary-color: ${signalColor}"
451
- ></ha-svg-icon>
452
- `
453
- : nothing}
454
- <div class="neighbor-info">
455
- <div class="neighbor-name">
456
- Node ${nodeId}
457
- <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
458
- ${getDeviceName(node)}
459
- </div>
460
- ${neighborEntry
461
- ? html`
462
- <div class="neighbor-signal">
463
- ${rssi !== null ? html`RSSI: ${rssi} dBm, ` : nothing}
464
- ${lqi !== undefined ? html`LQI: ${lqi}` : nothing}
465
- </div>
466
- `
467
- : nothing}
468
- </div>
469
- </div>
470
- `;
471
- })}
472
- </div>
561
+ const stateParts = new Array<string>();
562
+ stateParts.push(decoded.bbr ? `BBR (${decoded.bbrFunction ?? "?"})` : "not BBR");
563
+ if (decoded.threadRole !== undefined) {
564
+ stateParts.push(`Thread ${decoded.threadRole}`);
565
+ }
566
+ if (decoded.threadInterfaceStatus !== undefined) {
567
+ stateParts.push(decoded.threadInterfaceStatus);
568
+ }
569
+
570
+ return html`
571
+ <div class="info-row">
572
+ <span class="label">State:</span>
573
+ <span class="value">${stateParts.join(", ")}</span>
574
+ </div>
575
+ <div class="info-row">
576
+ <span class="label">Connection:</span>
577
+ <span class="value">${decoded.connectionMode ?? `reserved (${decoded.connectionModeValue})`}</span>
578
+ </div>
579
+ <div class="info-row">
580
+ <span class="label">Availability:</span>
581
+ <span class="value">${decoded.availability ?? `reserved (${decoded.availabilityValue})`}</span>
582
+ </div>
583
+ <div class="info-row">
584
+ <span class="label">ePSKc:</span>
585
+ <span class="value">${decoded.epskcSupported ? "supported" : "not supported"}</span>
586
+ </div>
587
+ ${decoded.multiAilStateValue !== 0
588
+ ? html`
589
+ <div class="info-row">
590
+ <span class="label">Multi-AIL:</span>
591
+ <span class="value">
592
+ ${decoded.multiAilState ?? `reserved (${decoded.multiAilStateValue})`}
593
+ </span>
473
594
  </div>
474
595
  `
475
596
  : nothing}
597
+ <div class="info-row">
598
+ <span class="label">State bitmap (raw):</span>
599
+ <span class="value mono">${hex}</span>
600
+ </div>
601
+ `;
602
+ }
476
603
 
477
- <md-divider></md-divider>
604
+ /**
605
+ * Network-info rows for a Border Router (extended PAN ID, partition, timestamps, state, domain, agent ID).
606
+ * Returns nothing if no fields are populated, so the caller can skip the surrounding section.
607
+ */
608
+ private _renderBorderRouterNetworkRows(br: BorderRouterEntry): TemplateResult | typeof nothing {
609
+ const hasAny =
610
+ br.extendedPanIdHex !== undefined ||
611
+ br.partitionIdHex !== undefined ||
612
+ br.activeTimestampHex !== undefined ||
613
+ br.stateBitmapHex !== undefined ||
614
+ br.domainName !== undefined ||
615
+ br.borderAgentIdHex !== undefined;
616
+ if (!hasAny) return nothing;
617
+
618
+ return html`
619
+ ${br.extendedPanIdHex
620
+ ? html`
621
+ <div class="info-row">
622
+ <span class="label">Extended PAN ID:</span>
623
+ <span class="value mono">${br.extendedPanIdHex}</span>
624
+ </div>
625
+ `
626
+ : nothing}
627
+ ${br.partitionIdHex
628
+ ? html`
629
+ <div class="info-row">
630
+ <span class="label">Partition ID:</span>
631
+ <span class="value mono">${br.partitionIdHex}</span>
632
+ </div>
633
+ `
634
+ : nothing}
635
+ ${br.activeTimestampHex
636
+ ? html`
637
+ <div class="info-row">
638
+ <span class="label">Active timestamp:</span>
639
+ <span class="value mono">${br.activeTimestampHex}</span>
640
+ </div>
641
+ `
642
+ : nothing}
643
+ ${this._renderStateBitmap(br.stateBitmapHex)}
644
+ ${br.domainName
645
+ ? html`
646
+ <div class="info-row">
647
+ <span class="label">Domain:</span>
648
+ <span class="value">${br.domainName}</span>
649
+ </div>
650
+ `
651
+ : nothing}
652
+ ${br.borderAgentIdHex
653
+ ? html`
654
+ <div class="info-row">
655
+ <span class="label">Border agent ID:</span>
656
+ <span class="value mono">${br.borderAgentIdHex}</span>
657
+ </div>
658
+ `
659
+ : nothing}
660
+ `;
661
+ }
662
+
663
+ /**
664
+ * Address rows for a Border Router (hostname, IPs, ports, sources).
665
+ */
666
+ private _renderBorderRouterAddressRows(br: BorderRouterEntry): TemplateResult | typeof nothing {
667
+ const hasAny =
668
+ br.hostname !== undefined ||
669
+ br.addresses.length > 0 ||
670
+ br.meshcopPort !== undefined ||
671
+ br.trelPort !== undefined ||
672
+ br.sources.length > 0;
673
+ if (!hasAny) return nothing;
674
+
675
+ return html`
676
+ ${br.hostname
677
+ ? html`
678
+ <div class="info-row">
679
+ <span class="label">Hostname:</span>
680
+ <span class="value mono">${br.hostname}</span>
681
+ </div>
682
+ `
683
+ : nothing}
684
+ ${br.addresses.map(
685
+ addr => html`
686
+ <div class="info-row">
687
+ <span class="label">Address:</span>
688
+ <span class="value mono">${addr}</span>
689
+ </div>
690
+ `,
691
+ )}
692
+ ${br.meshcopPort !== undefined
693
+ ? html`
694
+ <div class="info-row">
695
+ <span class="label">meshcop port:</span>
696
+ <span class="value">${br.meshcopPort}</span>
697
+ </div>
698
+ `
699
+ : nothing}
700
+ ${br.trelPort !== undefined
701
+ ? html`
702
+ <div class="info-row">
703
+ <span class="label">trel port:</span>
704
+ <span class="value">${br.trelPort}</span>
705
+ </div>
706
+ `
707
+ : nothing}
708
+ ${br.sources.length > 0
709
+ ? html`
710
+ <div class="info-row">
711
+ <span class="label">Sources:</span>
712
+ <span class="value">${br.sources.join(", ")}</span>
713
+ </div>
714
+ `
715
+ : nothing}
716
+ `;
717
+ }
718
+
719
+ private _renderBorderRouterInfo(deviceId: string): TemplateResult | typeof nothing {
720
+ const device = this.unknownDevices.get(deviceId);
721
+ if (!device || device.kind !== "br") {
722
+ return html` <p>Border router data not available</p> `;
723
+ }
724
+ const br = device;
725
+ const networkRows = this._renderBorderRouterNetworkRows(br);
726
+ const addressRows = this._renderBorderRouterAddressRows(br);
727
+
728
+ return html`
478
729
  <div class="section">
479
- <p class="hint-text">
480
- This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
481
- Thread Border Router or a device from another Matter ecosystem.
482
- </p>
730
+ <h4>Border Router</h4>
731
+ ${this._renderBorderRouterIdentityRows(br, true)}
732
+ ${br.bestRssi !== null
733
+ ? html`
734
+ <div class="info-row">
735
+ <span class="label">Best RSSI:</span>
736
+ <span class="value">${br.bestRssi} dBm</span>
737
+ </div>
738
+ `
739
+ : nothing}
483
740
  </div>
741
+
742
+ ${networkRows !== nothing
743
+ ? html`
744
+ <md-divider></md-divider>
745
+ <div class="section">
746
+ <h4>Thread Network</h4>
747
+ ${networkRows}
748
+ </div>
749
+ `
750
+ : nothing}
751
+ ${addressRows !== nothing
752
+ ? html`
753
+ <md-divider></md-divider>
754
+ <div class="section">
755
+ <h4>Addresses</h4>
756
+ ${addressRows}
757
+ </div>
758
+ `
759
+ : nothing}
760
+ ${this._renderExternalDeviceNeighbors(deviceId)}
484
761
  `;
485
762
  }
486
763
 
@@ -494,9 +771,11 @@ export class NetworkDetails extends LitElement {
494
771
  const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
495
772
  if (isAccessPoint) return false;
496
773
 
497
- // Unknown devices: only if they have online seenBy nodes
498
- const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
499
- if (isUnknown) {
774
+ // External devices (unknown or BR) gate on having online seenBy nodes
775
+ const isExternal =
776
+ typeof this.selectedNodeId === "string" &&
777
+ (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_"));
778
+ if (isExternal) {
500
779
  return this._getOnlineSeenByNodes().length > 0;
501
780
  }
502
781
 
@@ -514,7 +793,10 @@ export class NetworkDetails extends LitElement {
514
793
  * Get the type of the currently selected node for dialog variant.
515
794
  */
516
795
  private _getSelectedNodeType(): "online" | "offline" | "unknown" {
517
- if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
796
+ if (
797
+ typeof this.selectedNodeId === "string" &&
798
+ (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_"))
799
+ ) {
518
800
  return "unknown";
519
801
  }
520
802
 
@@ -534,17 +816,12 @@ export class NetworkDetails extends LitElement {
534
816
 
535
817
  const networkType = getNetworkType(node);
536
818
  if (networkType === "thread") {
537
- const extAddrMap = buildExtAddrMap(this.nodes);
538
- const rloc16Map = buildRloc16Map(this.nodes);
539
- const connections = getNodeConnections(nodeId, this.nodes, extAddrMap, rloc16Map);
819
+ // Use edge pairs without filters to get ALL connections (for update dialog)
820
+ const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes);
540
821
  return connections
541
822
  .filter(conn => {
542
- // Only include commissioned nodes (not unknown devices)
543
- if (typeof conn.connectedNodeId === "string" && conn.connectedNodeId.startsWith("unknown_")) {
544
- return false;
545
- }
546
- const connectedNode = this.nodes[String(conn.connectedNodeId)];
547
- return connectedNode?.available === true;
823
+ if (conn.isUnknown) return false;
824
+ return conn.connectedNode?.available === true;
548
825
  })
549
826
  .map(conn => String(conn.connectedNodeId));
550
827
  }
@@ -557,14 +834,17 @@ export class NetworkDetails extends LitElement {
557
834
  * Get online nodes that see an unknown device.
558
835
  */
559
836
  private _getOnlineSeenByNodes(): string[] {
560
- if (typeof this.selectedNodeId !== "string" || !this.selectedNodeId.startsWith("unknown_")) {
837
+ if (
838
+ typeof this.selectedNodeId !== "string" ||
839
+ (!this.selectedNodeId.startsWith("unknown_") && !this.selectedNodeId.startsWith("br_"))
840
+ ) {
561
841
  return [];
562
842
  }
563
843
 
564
- const unknown = this.unknownDevices.get(this.selectedNodeId);
565
- if (!unknown) return [];
844
+ const device = this.unknownDevices.get(this.selectedNodeId);
845
+ if (!device) return [];
566
846
 
567
- return unknown.seenBy.filter(nodeId => {
847
+ return device.seenBy.filter(nodeId => {
568
848
  const node = this.nodes[nodeId.toString()];
569
849
  return node?.available === true;
570
850
  });
@@ -574,11 +854,19 @@ export class NetworkDetails extends LitElement {
574
854
  * Get the name of the selected node for display in dialog.
575
855
  */
576
856
  private _getSelectedNodeName(): string {
577
- if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
578
- const unknown = this.unknownDevices.get(this.selectedNodeId);
579
- if (!unknown) return "External Device";
580
- const typeLabel = unknown.isRouter ? "External Router" : "External Device";
581
- return `${typeLabel} (${unknown.extAddressHex.slice(-8)})`;
857
+ if (typeof this.selectedNodeId === "string") {
858
+ if (this.selectedNodeId.startsWith("br_")) {
859
+ const device = this.unknownDevices.get(this.selectedNodeId);
860
+ if (!device || device.kind !== "br") return "Border Router";
861
+ const label = device.networkName ?? device.vendorName ?? "Border Router";
862
+ return `${label} (${device.extAddressHex.slice(-8)})`;
863
+ }
864
+ if (this.selectedNodeId.startsWith("unknown_")) {
865
+ const device = this.unknownDevices.get(this.selectedNodeId);
866
+ if (!device || device.kind !== "unknown") return "External Device";
867
+ const typeLabel = device.isRouter ? "External Router" : "External Device";
868
+ return `${typeLabel} (${device.extAddressHex.slice(-8)})`;
869
+ }
582
870
  }
583
871
 
584
872
  const node = this.nodes[this.selectedNodeId!.toString()];
@@ -591,6 +879,12 @@ export class NetworkDetails extends LitElement {
591
879
 
592
880
  private _handleDialogClose(): void {
593
881
  this._showUpdateDialog = false;
882
+ this.dispatchEvent(
883
+ new CustomEvent("connections-updated", {
884
+ bubbles: true,
885
+ composed: true,
886
+ }),
887
+ );
594
888
  }
595
889
 
596
890
  private _renderWiFiAccessPointInfo(apId: string): TemplateResult | typeof nothing {
@@ -665,6 +959,40 @@ export class NetworkDetails extends LitElement {
665
959
  `;
666
960
  }
667
961
 
962
+ /**
963
+ * Annotation shown on a commissioned Thread node that is also a discovered Border Router.
964
+ * Mirrors the BR Identity/Network/Addresses sections, sans the redundant ext-address row.
965
+ */
966
+ private _renderCommissionedNodeBorderRouterAnnotation(node: MatterNode): TemplateResult | typeof nothing {
967
+ const xaHex = getThreadExtendedAddressHex(node);
968
+ if (!xaHex) return nothing;
969
+ const br = this.borderRouters.get(xaHex);
970
+ if (!br) return nothing;
971
+
972
+ const networkRows = this._renderBorderRouterNetworkRows(br);
973
+ const addressRows = this._renderBorderRouterAddressRows(br);
974
+
975
+ return html`
976
+ <md-divider></md-divider>
977
+ <div class="section">
978
+ <h4>Also a Border Router</h4>
979
+ ${this._renderBorderRouterIdentityRows(br, false)}
980
+ ${networkRows !== nothing
981
+ ? html`
982
+ <div class="subsection-label">Thread Network</div>
983
+ ${networkRows}
984
+ `
985
+ : nothing}
986
+ ${addressRows !== nothing
987
+ ? html`
988
+ <div class="subsection-label">Addresses</div>
989
+ ${addressRows}
990
+ `
991
+ : nothing}
992
+ </div>
993
+ `;
994
+ }
995
+
668
996
  override render() {
669
997
  if (this.selectedNodeId === null) {
670
998
  return html`
@@ -719,6 +1047,54 @@ export class NetworkDetails extends LitElement {
719
1047
  `;
720
1048
  }
721
1049
 
1050
+ // Check if this is a discovered Border Router (mDNS-enriched external device)
1051
+ const borderRouterId =
1052
+ typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("br_")
1053
+ ? this.selectedNodeId
1054
+ : null;
1055
+
1056
+ if (borderRouterId !== null) {
1057
+ const onlineSeenByNodes = this._getOnlineSeenByNodes();
1058
+ return html`
1059
+ <div class="details-panel">
1060
+ <div class="header">
1061
+ <h3>Border Router</h3>
1062
+ <div class="header-actions">
1063
+ ${onlineSeenByNodes.length > 0
1064
+ ? html`
1065
+ <button
1066
+ class="action-button"
1067
+ @click=${this._handleUpdateConnections}
1068
+ aria-label="Update connection data"
1069
+ title="Update connection data"
1070
+ >
1071
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
1072
+ </button>
1073
+ `
1074
+ : nothing}
1075
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
1076
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
1077
+ </button>
1078
+ </div>
1079
+ </div>
1080
+ <div class="content">${this._renderBorderRouterInfo(borderRouterId)}</div>
1081
+ </div>
1082
+ ${this._showUpdateDialog
1083
+ ? html`
1084
+ <update-connections-dialog
1085
+ .client=${this.client}
1086
+ .nodes=${this.nodes}
1087
+ selectedNodeType="unknown"
1088
+ .selectedNodeName=${this._getSelectedNodeName()}
1089
+ .selectedNodeId=${this.selectedNodeId}
1090
+ .onlineNeighborIds=${onlineSeenByNodes}
1091
+ @dialog-closed=${this._handleDialogClose}
1092
+ ></update-connections-dialog>
1093
+ `
1094
+ : nothing}
1095
+ `;
1096
+ }
1097
+
722
1098
  // Check if this is a WiFi access point
723
1099
  const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
724
1100
 
@@ -774,7 +1150,9 @@ export class NetworkDetails extends LitElement {
774
1150
  </button>
775
1151
  </div>
776
1152
  </div>
777
- <div class="content">${this._renderNodeInfo(node)}</div>
1153
+ <div class="content">
1154
+ ${this._renderNodeInfo(node)}${this._renderCommissionedNodeBorderRouterAnnotation(node)}
1155
+ </div>
778
1156
  <div class="footer">
779
1157
  <a href="#node/${this.selectedNodeId}" class="view-link">View node details</a>
780
1158
  </div>
@@ -909,6 +1287,15 @@ export class NetworkDetails extends LitElement {
909
1287
  letter-spacing: 0.5px;
910
1288
  }
911
1289
 
1290
+ .subsection-label {
1291
+ margin: 12px 0 4px 0;
1292
+ font-size: 0.75rem;
1293
+ font-weight: 500;
1294
+ color: var(--md-sys-color-on-surface-variant, #666);
1295
+ text-transform: uppercase;
1296
+ letter-spacing: 0.4px;
1297
+ }
1298
+
912
1299
  .info-row {
913
1300
  display: flex;
914
1301
  justify-content: space-between;
@@ -992,6 +1379,14 @@ export class NetworkDetails extends LitElement {
992
1379
  opacity: 0.8;
993
1380
  }
994
1381
 
1382
+ .direction-hint.reverse-only {
1383
+ font-style: normal;
1384
+ font-weight: 500;
1385
+ opacity: 1;
1386
+ color: var(--md-sys-color-error, #b3261e);
1387
+ cursor: help;
1388
+ }
1389
+
995
1390
  .route-info {
996
1391
  color: var(--md-sys-color-tertiary, #7d5260);
997
1392
  font-size: 0.85em;