@matter-server/dashboard 0.3.3 → 0.3.5-alpha.0-20260203-66f5d74

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 (84) hide show
  1. package/README.md +76 -0
  2. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  3. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +44 -17
  4. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  5. package/dist/esm/entrypoint/main.js +1 -1
  6. package/dist/esm/entrypoint/main.js.map +1 -1
  7. package/dist/esm/pages/components/node-details.js +1 -1
  8. package/dist/esm/pages/components/node-details.js.map +1 -1
  9. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  10. package/dist/esm/pages/matter-cluster-view.js +9 -4
  11. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  12. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  13. package/dist/esm/pages/matter-endpoint-view.js +8 -2
  14. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  15. package/dist/esm/pages/matter-network-view.d.ts +5 -0
  16. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  17. package/dist/esm/pages/matter-network-view.js +96 -13
  18. package/dist/esm/pages/matter-network-view.js.map +1 -1
  19. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  20. package/dist/esm/pages/matter-node-view.js +17 -3
  21. package/dist/esm/pages/matter-node-view.js.map +1 -1
  22. package/dist/esm/pages/network/base-network-graph.d.ts +34 -0
  23. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
  24. package/dist/esm/pages/network/base-network-graph.js +116 -25
  25. package/dist/esm/pages/network/base-network-graph.js.map +1 -1
  26. package/dist/esm/pages/network/device-icons.js +1 -1
  27. package/dist/esm/pages/network/network-details.d.ts +31 -1
  28. package/dist/esm/pages/network/network-details.d.ts.map +1 -1
  29. package/dist/esm/pages/network/network-details.js +233 -15
  30. package/dist/esm/pages/network/network-details.js.map +1 -1
  31. package/dist/esm/pages/network/network-types.d.ts +6 -0
  32. package/dist/esm/pages/network/network-types.d.ts.map +1 -1
  33. package/dist/esm/pages/network/network-utils.d.ts +26 -0
  34. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  35. package/dist/esm/pages/network/network-utils.js +71 -3
  36. package/dist/esm/pages/network/network-utils.js.map +1 -1
  37. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  38. package/dist/esm/pages/network/thread-graph.js +5 -2
  39. package/dist/esm/pages/network/thread-graph.js.map +1 -1
  40. package/dist/esm/pages/network/update-connections-dialog.d.ts +55 -0
  41. package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -0
  42. package/dist/esm/pages/network/update-connections-dialog.js +284 -0
  43. package/dist/esm/pages/network/update-connections-dialog.js.map +6 -0
  44. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -1
  45. package/dist/esm/pages/network/wifi-graph.js +5 -3
  46. package/dist/esm/pages/network/wifi-graph.js.map +1 -1
  47. package/dist/esm/util/format_hex.d.ts +18 -0
  48. package/dist/esm/util/format_hex.d.ts.map +1 -1
  49. package/dist/esm/util/format_hex.js +21 -1
  50. package/dist/esm/util/format_hex.js.map +1 -1
  51. package/dist/esm/util/matter-status.d.ts +49 -0
  52. package/dist/esm/util/matter-status.d.ts.map +1 -0
  53. package/dist/esm/util/matter-status.js +110 -0
  54. package/dist/esm/util/matter-status.js.map +6 -0
  55. package/dist/web/js/{commission-node-dialog-B1_khzZb.js → commission-node-dialog-DBugVQOl.js} +5 -5
  56. package/dist/web/js/{commission-node-existing-RpdajrwF.js → commission-node-existing-ts2MN2bQ.js} +2 -2
  57. package/dist/web/js/{commission-node-thread-5f2itkTG.js → commission-node-thread-CixfNuM3.js} +2 -2
  58. package/dist/web/js/{commission-node-wifi-DZ_pWqsa.js → commission-node-wifi-CWC30EYn.js} +2 -2
  59. package/dist/web/js/{dialog-box-DEUxM4B1.js → dialog-box-SeMuFiWx.js} +2 -2
  60. package/dist/web/js/{fire_event-BczBMT8E.js → fire_event-B63oGTcK.js} +1 -1
  61. package/dist/web/js/{log-level-dialog-Cr3PfX1X.js → log-level-dialog-J0gFiLLM.js} +2 -2
  62. package/dist/web/js/main.js +2 -2
  63. package/dist/web/js/{matter-dashboard-app-BuCe_Jxf.js → matter-dashboard-app-D7YWrgXj.js} +2041 -246
  64. package/dist/web/js/{node-binding-dialog-DMiHNDLA.js → node-binding-dialog-DSBwIJcQ.js} +147 -19
  65. package/dist/web/js/prevent_default-D-ohDGsN.js +8 -0
  66. package/package.json +4 -4
  67. package/src/components/dialogs/binding/node-binding-dialog.ts +45 -21
  68. package/src/entrypoint/main.ts +2 -0
  69. package/src/pages/components/node-details.ts +1 -1
  70. package/src/pages/matter-cluster-view.ts +11 -4
  71. package/src/pages/matter-endpoint-view.ts +10 -3
  72. package/src/pages/matter-network-view.ts +102 -13
  73. package/src/pages/matter-node-view.ts +19 -4
  74. package/src/pages/network/base-network-graph.ts +147 -33
  75. package/src/pages/network/device-icons.ts +1 -1
  76. package/src/pages/network/network-details.ts +281 -16
  77. package/src/pages/network/network-types.ts +6 -0
  78. package/src/pages/network/network-utils.ts +109 -0
  79. package/src/pages/network/thread-graph.ts +7 -1
  80. package/src/pages/network/update-connections-dialog.ts +327 -0
  81. package/src/pages/network/wifi-graph.ts +4 -3
  82. package/src/util/format_hex.ts +39 -0
  83. package/src/util/matter-status.ts +165 -0
  84. package/dist/web/js/prevent_default-D4FX_PIh.js +0 -774
@@ -4,12 +4,15 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import { consume } from "@lit/context";
7
8
  import "@material/web/divider/divider";
8
- import type { MatterNode } from "@matter-server/ws-client";
9
- import { mdiClose, mdiSignal, mdiSignalCellular1, mdiSignalCellular2 } from "@mdi/js";
9
+ import { isTestNodeId, type MatterClient, type MatterNode } from "@matter-server/ws-client";
10
+ import { mdiClose, mdiRefresh, mdiSignal, mdiSignalCellular1, mdiSignalCellular2 } from "@mdi/js";
10
11
  import { LitElement, TemplateResult, css, html, nothing } from "lit";
11
- import { customElement, property } from "lit/decorators.js";
12
+ import { customElement, property, state } from "lit/decorators.js";
13
+ import { clientContext } from "../../client/client-context.js";
12
14
  import "../../components/ha-svg-icon";
15
+ import { formatNodeAddressFromAny, getEffectiveFabricIndex } from "../../util/format_hex.js";
13
16
  import type { ThreadNeighbor } from "./network-types.js";
14
17
  import type { NodeConnection } from "./network-utils.js";
15
18
  import {
@@ -17,6 +20,7 @@ import {
17
20
  getDeviceName,
18
21
  getNetworkType,
19
22
  getNodeConnections,
23
+ getRoutableDestinationsCount,
20
24
  getSignalColor,
21
25
  getSignalColorFromRssi,
22
26
  getThreadChannel,
@@ -28,6 +32,7 @@ import {
28
32
  getWiFiVersionName,
29
33
  parseNeighborTable,
30
34
  } from "./network-utils.js";
35
+ import "./update-connections-dialog.js";
31
36
 
32
37
  declare global {
33
38
  interface HTMLElementTagNameMap {
@@ -52,6 +57,12 @@ export class NetworkDetails extends LitElement {
52
57
  @property({ type: Object })
53
58
  public wifiAccessPoints: Map<string, { bssid: string; connectedNodes: string[] }> = new Map();
54
59
 
60
+ @consume({ context: clientContext })
61
+ private client!: MatterClient;
62
+
63
+ @state()
64
+ private _showUpdateDialog: boolean = false;
65
+
55
66
  private _handleClose(): void {
56
67
  this.dispatchEvent(
57
68
  new CustomEvent("close", {
@@ -100,6 +111,19 @@ export class NetworkDetails extends LitElement {
100
111
  return mdiSignalCellular1; // Weak
101
112
  }
102
113
 
114
+ /**
115
+ * Format a node ID as hex for Matter log format display.
116
+ * Returns format like "@1:7b" for node ID 123.
117
+ */
118
+ private _formatNodeIdHex(nodeId: number | bigint | string): string {
119
+ // For unknown devices (not in nodes), we can't determine if it's a test node,
120
+ // so we use the fabric index if available
121
+ const node = this.nodes[String(nodeId)];
122
+ const isTestNode = node ? isTestNodeId(node.node_id) : false;
123
+ const fabricIndex = getEffectiveFabricIndex(this.client?.serverInfo?.fabric_index, isTestNode);
124
+ return formatNodeAddressFromAny(fabricIndex, nodeId);
125
+ }
126
+
103
127
  private _renderWiFiInfo(node: MatterNode): TemplateResult | typeof nothing {
104
128
  const wifiDiag = getWiFiDiagnostics(node);
105
129
 
@@ -190,6 +214,21 @@ export class NetworkDetails extends LitElement {
190
214
  </div>
191
215
  `
192
216
  : nothing}
217
+ <div class="info-row">
218
+ <span class="label">Direct neighbors:</span>
219
+ <span class="value">${connections.length}</span>
220
+ </div>
221
+ ${(() => {
222
+ const routableCount = getRoutableDestinationsCount(node);
223
+ return routableCount > 0
224
+ ? html`
225
+ <div class="info-row">
226
+ <span class="label">Routable destinations:</span>
227
+ <span class="value">${routableCount}</span>
228
+ </div>
229
+ `
230
+ : nothing;
231
+ })()}
193
232
  </div>
194
233
 
195
234
  ${connections.length > 0
@@ -214,13 +253,26 @@ export class NetworkDetails extends LitElement {
214
253
  <div class="neighbor-info">
215
254
  <div class="neighbor-name">
216
255
  ${conn.connectedNode
217
- ? html`Node ${conn.connectedNodeId}:
218
- ${getDeviceName(conn.connectedNode)}`
256
+ ? html`Node ${conn.connectedNodeId}
257
+ <span class="node-id-hex"
258
+ >${this._formatNodeIdHex(conn.connectedNodeId)}</span
259
+ >: ${getDeviceName(conn.connectedNode)}`
219
260
  : html`External: <span class="mono">${conn.extAddressHex}</span>`}
220
261
  </div>
221
262
  <div class="neighbor-signal">
222
- ${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm, ` : nothing}
223
- ${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}
263
+ ${conn.rssi !== null
264
+ ? html`RSSI: ${conn.rssi} dBm`
265
+ : nothing}${conn.rssi !== null && conn.lqi !== null
266
+ ? ", "
267
+ : nothing}${conn.lqi !== null
268
+ ? html`LQI: ${conn.lqi}`
269
+ : nothing}${conn.bidirectionalLqi !== undefined
270
+ ? html`<span class="route-info"
271
+ >, Bidir: ${conn.bidirectionalLqi}</span
272
+ >`
273
+ : nothing}${conn.pathCost !== undefined
274
+ ? html`<span class="route-info">, Cost: ${conn.pathCost}</span>`
275
+ : nothing}
224
276
  ${!conn.isOutgoing
225
277
  ? html`<span class="direction-hint">(reverse)</span>`
226
278
  : nothing}
@@ -363,7 +415,11 @@ export class NetworkDetails extends LitElement {
363
415
  `
364
416
  : nothing}
365
417
  <div class="neighbor-info">
366
- <div class="neighbor-name">Node ${nodeId}: ${getDeviceName(node)}</div>
418
+ <div class="neighbor-name">
419
+ Node ${nodeId}
420
+ <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
421
+ ${getDeviceName(node)}
422
+ </div>
367
423
  ${neighborEntry
368
424
  ? html`
369
425
  <div class="neighbor-signal">
@@ -391,6 +447,112 @@ export class NetworkDetails extends LitElement {
391
447
  `;
392
448
  }
393
449
 
450
+ /**
451
+ * Determine if update connections button should be shown.
452
+ */
453
+ private _canUpdateConnections(): boolean {
454
+ if (this.selectedNodeId === null) return false;
455
+
456
+ // WiFi APs: no update possible (not a Matter device)
457
+ const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
458
+ if (isAccessPoint) return false;
459
+
460
+ // Unknown devices: only if they have online seenBy nodes
461
+ const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
462
+ if (isUnknown) {
463
+ return this._getOnlineSeenByNodes().length > 0;
464
+ }
465
+
466
+ // Regular nodes: check network type (no update for ethernet)
467
+ const node = this.nodes[this.selectedNodeId.toString()];
468
+ if (!node) return false;
469
+
470
+ const networkType = getNetworkType(node);
471
+ if (networkType === "ethernet" || networkType === "unknown") return false;
472
+
473
+ return true;
474
+ }
475
+
476
+ /**
477
+ * Get the type of the currently selected node for dialog variant.
478
+ */
479
+ private _getSelectedNodeType(): "online" | "offline" | "unknown" {
480
+ if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
481
+ return "unknown";
482
+ }
483
+
484
+ const node = this.nodes[this.selectedNodeId!.toString()];
485
+ if (!node || node.available === false) {
486
+ return "offline";
487
+ }
488
+ return "online";
489
+ }
490
+
491
+ /**
492
+ * Get online neighbors for a Thread node.
493
+ */
494
+ private _getOnlineNeighbors(nodeId: string): string[] {
495
+ const node = this.nodes[nodeId];
496
+ if (!node) return [];
497
+
498
+ const networkType = getNetworkType(node);
499
+ if (networkType === "thread") {
500
+ const extAddrMap = buildExtAddrMap(this.nodes);
501
+ const connections = getNodeConnections(nodeId, this.nodes, extAddrMap);
502
+ return connections
503
+ .filter(conn => {
504
+ // Only include commissioned nodes (not unknown devices)
505
+ if (typeof conn.connectedNodeId === "string" && conn.connectedNodeId.startsWith("unknown_")) {
506
+ return false;
507
+ }
508
+ const connectedNode = this.nodes[String(conn.connectedNodeId)];
509
+ return connectedNode?.available === true;
510
+ })
511
+ .map(conn => String(conn.connectedNodeId));
512
+ }
513
+
514
+ // WiFi nodes don't have peer connections (just AP)
515
+ return [];
516
+ }
517
+
518
+ /**
519
+ * Get online nodes that see an unknown device.
520
+ */
521
+ private _getOnlineSeenByNodes(): string[] {
522
+ if (typeof this.selectedNodeId !== "string" || !this.selectedNodeId.startsWith("unknown_")) {
523
+ return [];
524
+ }
525
+
526
+ const unknown = this.unknownDevices.get(this.selectedNodeId);
527
+ if (!unknown) return [];
528
+
529
+ return unknown.seenBy.filter(nodeId => {
530
+ const node = this.nodes[nodeId.toString()];
531
+ return node?.available === true;
532
+ });
533
+ }
534
+
535
+ /**
536
+ * Get the name of the selected node for display in dialog.
537
+ */
538
+ private _getSelectedNodeName(): string {
539
+ if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
540
+ const unknown = this.unknownDevices.get(this.selectedNodeId);
541
+ return unknown ? `Unknown (${unknown.extAddressHex.slice(-8)})` : "Unknown Device";
542
+ }
543
+
544
+ const node = this.nodes[this.selectedNodeId!.toString()];
545
+ return node ? getDeviceName(node) : "Unknown";
546
+ }
547
+
548
+ private _handleUpdateConnections(): void {
549
+ this._showUpdateDialog = true;
550
+ }
551
+
552
+ private _handleDialogClose(): void {
553
+ this._showUpdateDialog = false;
554
+ }
555
+
394
556
  private _renderWiFiAccessPointInfo(apId: string): TemplateResult | typeof nothing {
395
557
  const ap = this.wifiAccessPoints.get(apId);
396
558
  if (!ap) {
@@ -429,7 +591,11 @@ export class NetworkDetails extends LitElement {
429
591
  @click=${() => this._handleSelectNode(nodeId)}
430
592
  @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
431
593
  >
432
- <div class="node-name">Node ${nodeId}: ${getDeviceName(node)}</div>
594
+ <div class="node-name">
595
+ Node ${nodeId}
596
+ <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
597
+ ${getDeviceName(node)}
598
+ </div>
433
599
  ${wifiDiag.rssi !== null
434
600
  ? html`<div class="node-signal" style="color: ${signalColor}">
435
601
  ${wifiDiag.rssi} dBm
@@ -464,16 +630,44 @@ export class NetworkDetails extends LitElement {
464
630
  const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
465
631
 
466
632
  if (isUnknown) {
633
+ const onlineSeenByNodes = this._getOnlineSeenByNodes();
467
634
  return html`
468
635
  <div class="details-panel">
469
636
  <div class="header">
470
637
  <h3>External Device</h3>
471
- <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
472
- <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
473
- </button>
638
+ <div class="header-actions">
639
+ ${onlineSeenByNodes.length > 0
640
+ ? html`
641
+ <button
642
+ class="action-button"
643
+ @click=${this._handleUpdateConnections}
644
+ aria-label="Update connection data"
645
+ title="Update connection data"
646
+ >
647
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
648
+ </button>
649
+ `
650
+ : nothing}
651
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
652
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
653
+ </button>
654
+ </div>
474
655
  </div>
475
656
  <div class="content">${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}</div>
476
657
  </div>
658
+ ${this._showUpdateDialog
659
+ ? html`
660
+ <update-connections-dialog
661
+ .client=${this.client}
662
+ .nodes=${this.nodes}
663
+ selectedNodeType="unknown"
664
+ .selectedNodeName=${this._getSelectedNodeName()}
665
+ .selectedNodeId=${this.selectedNodeId}
666
+ .onlineNeighborIds=${onlineSeenByNodes}
667
+ @dialog-closed=${this._handleDialogClose}
668
+ ></update-connections-dialog>
669
+ `
670
+ : nothing}
477
671
  `;
478
672
  }
479
673
 
@@ -503,19 +697,53 @@ export class NetworkDetails extends LitElement {
503
697
  `;
504
698
  }
505
699
 
700
+ const canUpdate = this._canUpdateConnections();
701
+ const nodeType = this._getSelectedNodeType();
702
+ const onlineNeighbors = this._getOnlineNeighbors(String(this.selectedNodeId));
703
+
506
704
  return html`
507
705
  <div class="details-panel">
508
706
  <div class="header">
509
- <h3>Node ${this.selectedNodeId}</h3>
510
- <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
511
- <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
512
- </button>
707
+ <h3>
708
+ Node ${this.selectedNodeId}
709
+ <span class="node-id-hex">${this._formatNodeIdHex(this.selectedNodeId)}</span>
710
+ </h3>
711
+ <div class="header-actions">
712
+ ${canUpdate
713
+ ? html`
714
+ <button
715
+ class="action-button"
716
+ @click=${this._handleUpdateConnections}
717
+ aria-label="Update connection data"
718
+ title="Update connection data"
719
+ >
720
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
721
+ </button>
722
+ `
723
+ : nothing}
724
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
725
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
726
+ </button>
727
+ </div>
513
728
  </div>
514
729
  <div class="content">${this._renderNodeInfo(node)}</div>
515
730
  <div class="footer">
516
731
  <a href="#node/${this.selectedNodeId}" class="view-link">View node details</a>
517
732
  </div>
518
733
  </div>
734
+ ${this._showUpdateDialog
735
+ ? html`
736
+ <update-connections-dialog
737
+ .client=${this.client}
738
+ .nodes=${this.nodes}
739
+ .selectedNodeType=${nodeType}
740
+ .selectedNodeName=${this._getSelectedNodeName()}
741
+ .selectedNodeId=${this.selectedNodeId}
742
+ .onlineNeighborIds=${onlineNeighbors}
743
+ @dialog-closed=${this._handleDialogClose}
744
+ ></update-connections-dialog>
745
+ `
746
+ : nothing}
519
747
  `;
520
748
  }
521
749
 
@@ -561,6 +789,38 @@ export class NetworkDetails extends LitElement {
561
789
  color: var(--md-sys-color-on-surface, #333);
562
790
  }
563
791
 
792
+ .node-id-hex {
793
+ font-size: 0.75em;
794
+ font-weight: 400;
795
+ color: var(--md-sys-color-on-surface-variant, #666);
796
+ font-family: monospace;
797
+ }
798
+
799
+ .header-actions {
800
+ display: flex;
801
+ align-items: center;
802
+ gap: 4px;
803
+ }
804
+
805
+ .action-button {
806
+ background: none;
807
+ border: none;
808
+ padding: 4px;
809
+ cursor: pointer;
810
+ border-radius: 50%;
811
+ display: flex;
812
+ align-items: center;
813
+ justify-content: center;
814
+ }
815
+
816
+ .action-button:hover {
817
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
818
+ }
819
+
820
+ .action-button ha-svg-icon {
821
+ --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
822
+ }
823
+
564
824
  .close-button {
565
825
  background: none;
566
826
  border: none;
@@ -682,6 +942,11 @@ export class NetworkDetails extends LitElement {
682
942
  opacity: 0.8;
683
943
  }
684
944
 
945
+ .route-info {
946
+ color: var(--md-sys-color-tertiary, #7d5260);
947
+ font-size: 0.85em;
948
+ }
949
+
685
950
  .footer {
686
951
  padding: 12px 16px;
687
952
  border-top: 1px solid var(--md-sys-color-outline-variant, #ccc);
@@ -105,6 +105,12 @@ export interface ThreadConnection {
105
105
  signalColor: string;
106
106
  lqi: number;
107
107
  rssi: number | null;
108
+ /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
109
+ pathCost?: number;
110
+ /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
111
+ bidirectionalLqi?: number;
112
+ /** Whether this connection was supplemented by route table data (vs neighbor table only) */
113
+ fromRouteTable?: boolean;
108
114
  }
109
115
 
110
116
  /**
@@ -297,6 +297,38 @@ export function parseRouteTable(node: MatterNode): ThreadRoute[] {
297
297
  });
298
298
  }
299
299
 
300
+ /**
301
+ * Find a route table entry for a specific destination by extended address.
302
+ * Returns the route entry if found, undefined otherwise.
303
+ */
304
+ export function findRouteByExtAddress(node: MatterNode, targetExtAddr: bigint): ThreadRoute | undefined {
305
+ const routes = parseRouteTable(node);
306
+ return routes.find(route => route.extAddress === targetExtAddr && route.linkEstablished);
307
+ }
308
+
309
+ /**
310
+ * Count the number of routable destinations for a node (from route table).
311
+ * Only counts entries where allocated=true and linkEstablished=true.
312
+ * This is typically only meaningful for router nodes.
313
+ */
314
+ export function getRoutableDestinationsCount(node: MatterNode): number {
315
+ const routes = parseRouteTable(node);
316
+ return routes.filter(route => route.allocated && route.linkEstablished).length;
317
+ }
318
+
319
+ /**
320
+ * Calculate combined bidirectional LQI from route table entry.
321
+ * Returns average of lqiIn and lqiOut if both are non-zero.
322
+ */
323
+ export function getRouteBidirectionalLqi(route: ThreadRoute): number | undefined {
324
+ if (route.lqiIn > 0 && route.lqiOut > 0) {
325
+ return Math.round((route.lqiIn + route.lqiOut) / 2);
326
+ }
327
+ if (route.lqiIn > 0) return route.lqiIn;
328
+ if (route.lqiOut > 0) return route.lqiOut;
329
+ return undefined;
330
+ }
331
+
300
332
  /**
301
333
  * Gets the RLOC16 (short address) for a Thread node.
302
334
  * Uses attribute 0/53/64 (Rloc16, 0x0040).
@@ -437,6 +469,21 @@ export function getSignalColor(neighbor: ThreadNeighbor): string {
437
469
  return SIGNAL_COLOR_WEAK;
438
470
  }
439
471
 
472
+ /**
473
+ * Get signal color based on LQI value alone.
474
+ * Used for route table entries where only LQI is available.
475
+ * @param lqi Link Quality Indicator (0-255, higher is better)
476
+ */
477
+ export function getSignalColorFromLqi(lqi: number): string {
478
+ if (lqi > LQI_STRONG_THRESHOLD) {
479
+ return SIGNAL_COLOR_STRONG;
480
+ }
481
+ if (lqi > LQI_MEDIUM_THRESHOLD) {
482
+ return SIGNAL_COLOR_MEDIUM;
483
+ }
484
+ return SIGNAL_COLOR_WEAK;
485
+ }
486
+
440
487
  /**
441
488
  * Gets a human-readable display name for a node.
442
489
  * Format: nodeLabel || productName (serialNumber)
@@ -634,12 +681,58 @@ export function buildThreadConnections(
634
681
  }
635
682
  seenConnections.add(connectionKey);
636
683
 
684
+ // Look up route table entry for supplementary data (pathCost, bidirectional LQI)
685
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
686
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
687
+
688
+ // Always use neighbor table RSSI/LQI for signal color (most accurate link quality)
637
689
  connections.push({
638
690
  fromNodeId,
639
691
  toNodeId,
640
692
  signalColor: getSignalColor(neighbor),
641
693
  lqi: neighbor.lqi,
642
694
  rssi: neighbor.avgRssi ?? neighbor.lastRssi,
695
+ pathCost: routeEntry?.pathCost,
696
+ bidirectionalLqi,
697
+ });
698
+ }
699
+
700
+ // Check route table for connections not in neighbor table (supplementary data)
701
+ // This helps when neighbor table is stale or incomplete
702
+ const routes = parseRouteTable(node);
703
+ for (const route of routes) {
704
+ if (!route.linkEstablished || !route.allocated) continue;
705
+
706
+ let toNodeId: string | undefined = extAddrMap.get(route.extAddress);
707
+ if (toNodeId === undefined) {
708
+ toNodeId = unknownExtAddrMap.get(route.extAddress);
709
+ }
710
+ if (toNodeId === undefined || toNodeId === fromNodeId) continue;
711
+
712
+ const connectionKey = `${fromNodeId}-${toNodeId}`;
713
+ const reverseKey = `${toNodeId}-${fromNodeId}`;
714
+
715
+ // Only add if we don't already have this connection from neighbor table
716
+ if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
717
+ continue;
718
+ }
719
+ seenConnections.add(connectionKey);
720
+
721
+ const bidirectionalLqi = getRouteBidirectionalLqi(route);
722
+ const signalColor =
723
+ bidirectionalLqi !== undefined
724
+ ? getSignalColorFromLqi(bidirectionalLqi)
725
+ : "var(--md-sys-color-outline, grey)"; // Unknown signal
726
+
727
+ connections.push({
728
+ fromNodeId,
729
+ toNodeId,
730
+ signalColor,
731
+ lqi: bidirectionalLqi ?? 0,
732
+ rssi: null,
733
+ pathCost: route.pathCost,
734
+ bidirectionalLqi,
735
+ fromRouteTable: true,
643
736
  });
644
737
  }
645
738
  }
@@ -666,6 +759,10 @@ export interface NodeConnection {
666
759
  isOutgoing: boolean;
667
760
  /** Whether this is an unknown/external device */
668
761
  isUnknown: boolean;
762
+ /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
763
+ pathCost?: number;
764
+ /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
765
+ bidirectionalLqi?: number;
669
766
  }
670
767
 
671
768
  /**
@@ -705,6 +802,10 @@ export function getNodeConnections(
705
802
 
706
803
  seenConnectedIds.add(displayId);
707
804
 
805
+ // Look up route table entry for enhanced data
806
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
807
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
808
+
708
809
  connections.push({
709
810
  connectedNodeId: displayId,
710
811
  connectedNode,
@@ -714,6 +815,8 @@ export function getNodeConnections(
714
815
  rssi: neighbor.avgRssi ?? neighbor.lastRssi,
715
816
  isOutgoing: true,
716
817
  isUnknown,
818
+ pathCost: routeEntry?.pathCost,
819
+ bidirectionalLqi,
717
820
  });
718
821
  }
719
822
 
@@ -734,6 +837,10 @@ export function getNodeConnections(
734
837
  const otherExtAddr = getThreadExtendedAddress(otherNode);
735
838
  const extAddrHex = otherExtAddr ? otherExtAddr.toString(16).toUpperCase().padStart(16, "0") : "Unknown";
736
839
 
840
+ // Look up route table entry from the other node's perspective
841
+ const routeEntry = findRouteByExtAddress(otherNode, thisExtAddr);
842
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
843
+
737
844
  connections.push({
738
845
  connectedNodeId: otherNodeId,
739
846
  connectedNode: otherNode,
@@ -743,6 +850,8 @@ export function getNodeConnections(
743
850
  rssi: reverseEntry.avgRssi ?? reverseEntry.lastRssi,
744
851
  isOutgoing: false,
745
852
  isUnknown: false,
853
+ pathCost: routeEntry?.pathCost,
854
+ bidirectionalLqi,
746
855
  });
747
856
  }
748
857
  }
@@ -115,6 +115,12 @@ export class ThreadGraph extends BaseNetworkGraph {
115
115
  // Create edge data for vis.js
116
116
  const graphEdges: NetworkGraphEdge[] = connections.map((conn, index) => {
117
117
  const isToUnknown = typeof conn.toNodeId === "string" && conn.toNodeId.startsWith("unknown_");
118
+
119
+ // Check if either endpoint is offline - connection data may be stale
120
+ const fromNode = this.nodes[String(conn.fromNodeId)];
121
+ const toNode = this.nodes[String(conn.toNodeId)];
122
+ const hasOfflineEndpoint = fromNode?.available === false || toNode?.available === false;
123
+
118
124
  return {
119
125
  id: `edge_${index}`,
120
126
  from: conn.fromNodeId,
@@ -125,7 +131,7 @@ export class ThreadGraph extends BaseNetworkGraph {
125
131
  },
126
132
  width: 2,
127
133
  title: conn.rssi !== null ? `RSSI: ${conn.rssi} dBm, LQI: ${conn.lqi}` : `LQI: ${conn.lqi}`,
128
- dashes: isToUnknown, // Dashed lines to unknown devices
134
+ dashes: isToUnknown || hasOfflineEndpoint, // Dashed lines to unknown or offline devices
129
135
  };
130
136
  });
131
137