@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
@@ -4,15 +4,17 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
- import type { MatterNode } from "@matter-server/ws-client";
7
+ import type { BorderRouterEntry, MatterNode } from "@matter-server/ws-client";
8
8
  import { getCssVar } from "../../util/shared-styles.js";
9
9
  import type {
10
10
  CategorizedDevices,
11
11
  NetworkType,
12
+ SignalLevel,
12
13
  ThreadConnection,
14
+ ThreadEdgePair,
15
+ ThreadExternalDevice,
13
16
  ThreadNeighbor,
14
17
  ThreadRoute,
15
- UnknownThreadDevice,
16
18
  } from "./network-types.js";
17
19
 
18
20
  // NetworkCommissioning cluster feature map bits (cluster 0x31/49)
@@ -388,25 +390,38 @@ export function buildRloc16Map(nodes: Record<string, MatterNode>): Map<number, s
388
390
  return rloc16Map;
389
391
  }
390
392
 
393
+ interface ExternalAggregate {
394
+ extAddressHex: string;
395
+ extAddress: bigint;
396
+ seenBy: string[];
397
+ isRouter: boolean;
398
+ bestRssi: number | null;
399
+ /** xp of the first observing matter node; all neighbors of a Thread node share its xp. */
400
+ extendedPanIdHex?: string;
401
+ }
402
+
391
403
  /**
392
- * Finds unknown Thread devices - addresses seen in neighbor tables
393
- * that don't match any known commissioned device.
394
- * These are typically Thread Border Routers or devices from other ecosystems.
395
- * Uses RLOC16 as fallback when extended address matching fails.
404
+ * Finds external Thread devices - addresses seen in neighbor tables that don't match
405
+ * any commissioned device. Classifies each against the optional Border Router registry:
406
+ * matched ones are emitted as kind:"br" with full mDNS enrichment; the rest stay as
407
+ * kind:"unknown". Uses RLOC16 as fallback when extended address matching fails.
396
408
  */
397
409
  export function findUnknownDevices(
398
410
  nodes: Record<string, MatterNode>,
399
411
  extAddrMap: Map<bigint, string>,
400
412
  rloc16Map: Map<number, string>,
401
- ): UnknownThreadDevice[] {
402
- const unknownMap = new Map<string, UnknownThreadDevice>();
413
+ borderRouters?: ReadonlyMap<string, BorderRouterEntry>,
414
+ ): ThreadExternalDevice[] {
415
+ const aggregates = new Map<string, ExternalAggregate>();
403
416
 
404
417
  for (const node of Object.values(nodes)) {
405
418
  const nodeId = String(node.node_id);
406
419
  const neighbors = parseNeighborTable(node);
420
+ const observerXp = getThreadExtendedPanId(node);
421
+ const observerXpHex =
422
+ observerXp !== undefined ? observerXp.toString(16).padStart(16, "0").toUpperCase() : undefined;
407
423
 
408
424
  for (const neighbor of neighbors) {
409
- // Check if this neighbor is in our known devices (by extended address or RLOC16 fallback)
410
425
  if (extAddrMap.has(neighbor.extAddress)) {
411
426
  continue;
412
427
  }
@@ -415,43 +430,180 @@ export function findUnknownDevices(
415
430
  }
416
431
 
417
432
  const extAddressHex = neighbor.extAddress.toString(16).padStart(16, "0").toUpperCase();
418
- const id = `unknown_${extAddressHex}`;
419
433
 
420
- if (!unknownMap.has(id)) {
421
- unknownMap.set(id, {
422
- id,
434
+ let agg = aggregates.get(extAddressHex);
435
+ if (agg === undefined) {
436
+ agg = {
423
437
  extAddressHex,
424
438
  extAddress: neighbor.extAddress,
425
439
  seenBy: [],
426
440
  isRouter: false,
427
441
  bestRssi: null,
428
- });
442
+ extendedPanIdHex: observerXpHex,
443
+ };
444
+ aggregates.set(extAddressHex, agg);
445
+ } else if (agg.extendedPanIdHex === undefined && observerXpHex !== undefined) {
446
+ agg.extendedPanIdHex = observerXpHex;
429
447
  }
430
448
 
431
- const unknown = unknownMap.get(id)!;
432
-
433
- // Add this node to seenBy if not already there
434
- if (!unknown.seenBy.includes(nodeId)) {
435
- unknown.seenBy.push(nodeId);
449
+ if (!agg.seenBy.includes(nodeId)) {
450
+ agg.seenBy.push(nodeId);
436
451
  }
437
-
438
- // Update router status (field 10 = rxOnWhenIdle, indicates router-like behavior)
439
452
  if (neighbor.rxOnWhenIdle) {
440
- unknown.isRouter = true;
453
+ agg.isRouter = true;
441
454
  }
442
-
443
- // Track best signal
444
455
  const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
445
- if (rssi !== null && (unknown.bestRssi === null || rssi > unknown.bestRssi)) {
446
- unknown.bestRssi = rssi;
456
+ if (rssi !== null && (agg.bestRssi === null || rssi > agg.bestRssi)) {
457
+ agg.bestRssi = rssi;
447
458
  }
448
459
  }
449
460
  }
450
461
 
451
- return Array.from(unknownMap.values());
462
+ // Pre-compute xp → networkName from the BR registry so we can label unknowns by network.
463
+ const networkNameByXp = new Map<string, string>();
464
+ if (borderRouters !== undefined) {
465
+ for (const br of borderRouters.values()) {
466
+ if (br.extendedPanIdHex !== undefined && br.networkName !== undefined) {
467
+ networkNameByXp.set(br.extendedPanIdHex, br.networkName);
468
+ }
469
+ }
470
+ }
471
+
472
+ const out = new Array<ThreadExternalDevice>();
473
+ for (const agg of aggregates.values()) {
474
+ const br = borderRouters?.get(agg.extAddressHex);
475
+ if (br !== undefined) {
476
+ out.push({
477
+ kind: "br",
478
+ ...br,
479
+ id: `br_${agg.extAddressHex}`,
480
+ extAddressHex: agg.extAddressHex,
481
+ extAddress: agg.extAddress,
482
+ seenBy: agg.seenBy,
483
+ isRouter: agg.isRouter,
484
+ bestRssi: agg.bestRssi,
485
+ });
486
+ } else {
487
+ const networkName =
488
+ agg.extendedPanIdHex !== undefined ? networkNameByXp.get(agg.extendedPanIdHex) : undefined;
489
+ out.push({
490
+ kind: "unknown",
491
+ id: `unknown_${agg.extAddressHex}`,
492
+ extAddressHex: agg.extAddressHex,
493
+ extAddress: agg.extAddress,
494
+ seenBy: agg.seenBy,
495
+ isRouter: agg.isRouter,
496
+ bestRssi: agg.bestRssi,
497
+ extendedPanIdHex: agg.extendedPanIdHex,
498
+ networkName,
499
+ });
500
+ }
501
+ }
502
+ return out;
503
+ }
504
+
505
+ /**
506
+ * Decoded form of the MeshCoP `_meshcop` TXT `sb` (state bitmap) field. Layout per
507
+ * OpenThread's `border_agent_txt_data.hpp` (`openthread/openthread`, the de-facto reference
508
+ * implementation for Thread Border Router service publication):
509
+ *
510
+ * bits 0-2 Connection Mode (0=Disabled, 1=PSKc/DTLS, 2=PSKd/DTLS, 3=Vendor, 4=X.509)
511
+ * bits 3-4 Thread Interface State (0=NotInit, 1=Init/inactive, 2=Init/active)
512
+ * bits 5-6 Availability (0=Infrequent, 1=High)
513
+ * bit 7 BBR Active (0/1)
514
+ * bit 8 BBR Is Primary (0=secondary, 1=primary; only meaningful when BBR Active)
515
+ * bits 9-10 Thread Role (0=Detached, 1=Child, 2=Router, 3=Leader)
516
+ * bit 11 ePSKc Supported (0/1)
517
+ * bits 12-13 Multi-AIL State (0=Disabled, 1=Not detected, 2=Detected)
518
+ * bits 14-31 Reserved
519
+ */
520
+ export interface DecodedStateBitmap {
521
+ connectionMode?: string;
522
+ connectionModeValue: number;
523
+ threadInterfaceStatus?: string;
524
+ threadInterfaceStatusValue: number;
525
+ availability?: string;
526
+ availabilityValue: number;
527
+ bbr: boolean;
528
+ /** "primary" / "secondary" — only meaningful when {@link bbr} is true. */
529
+ bbrFunction?: string;
530
+ threadRole?: string;
531
+ threadRoleValue: number;
532
+ epskcSupported: boolean;
533
+ multiAilState?: string;
534
+ multiAilStateValue: number;
535
+ /** Hex of any bits beyond bit 13 (reserved/future). Undefined when zero. */
536
+ reservedHex?: string;
452
537
  }
453
538
 
454
- export type SignalLevel = "strong" | "medium" | "weak";
539
+ const CONNECTION_MODE_LABELS: Record<number, string> = {
540
+ 0: "disabled",
541
+ 1: "PSKc / DTLS",
542
+ 2: "PSKd / DTLS",
543
+ 3: "vendor-defined",
544
+ 4: "X.509",
545
+ };
546
+
547
+ const THREAD_INTERFACE_STATUS_LABELS: Record<number, string> = {
548
+ 0: "not initialized",
549
+ 1: "initialized, inactive",
550
+ 2: "initialized, active",
551
+ };
552
+
553
+ const AVAILABILITY_LABELS: Record<number, string> = {
554
+ 0: "infrequent",
555
+ 1: "high",
556
+ };
557
+
558
+ const THREAD_ROLE_LABELS: Record<number, string> = {
559
+ 0: "detached",
560
+ 1: "child",
561
+ 2: "router",
562
+ 3: "leader",
563
+ };
564
+
565
+ const MULTI_AIL_STATE_LABELS: Record<number, string> = {
566
+ 0: "disabled",
567
+ 1: "not detected",
568
+ 2: "detected",
569
+ };
570
+
571
+ /**
572
+ * Decodes a MeshCoP state bitmap hex string (e.g. "000005B1") per the OpenThread reference
573
+ * layout. Returns undefined if the input is not a valid hex value.
574
+ */
575
+ export function decodeMeshcopStateBitmap(hex: string | undefined): DecodedStateBitmap | undefined {
576
+ if (hex === undefined || !/^[0-9a-fA-F]{1,8}$/.test(hex)) return undefined;
577
+ const value = parseInt(hex, 16);
578
+ if (!Number.isFinite(value)) return undefined;
579
+
580
+ const connectionModeValue = value & 0x7;
581
+ const threadInterfaceStatusValue = (value >> 3) & 0x3;
582
+ const availabilityValue = (value >> 5) & 0x3;
583
+ const bbr = ((value >> 7) & 0x1) === 1;
584
+ const bbrIsPrimary = ((value >> 8) & 0x1) === 1;
585
+ const threadRoleValue = (value >> 9) & 0x3;
586
+ const epskcSupported = ((value >> 11) & 0x1) === 1;
587
+ const multiAilStateValue = (value >> 12) & 0x3;
588
+ const reserved = (value >>> 14) >>> 0;
589
+
590
+ return {
591
+ connectionModeValue,
592
+ connectionMode: CONNECTION_MODE_LABELS[connectionModeValue],
593
+ threadInterfaceStatusValue,
594
+ threadInterfaceStatus: THREAD_INTERFACE_STATUS_LABELS[threadInterfaceStatusValue],
595
+ availabilityValue,
596
+ availability: AVAILABILITY_LABELS[availabilityValue],
597
+ bbr,
598
+ bbrFunction: bbr ? (bbrIsPrimary ? "primary" : "secondary") : undefined,
599
+ threadRoleValue,
600
+ threadRole: THREAD_ROLE_LABELS[threadRoleValue],
601
+ epskcSupported,
602
+ multiAilStateValue,
603
+ multiAilState: MULTI_AIL_STATE_LABELS[multiAilStateValue],
604
+ reservedHex: reserved !== 0 ? reserved.toString(16).toUpperCase() : undefined,
605
+ };
606
+ }
455
607
 
456
608
  /** Determine signal level from a Thread neighbor's RSSI/LQI. */
457
609
  export function getSignalLevel(neighbor: ThreadNeighbor): SignalLevel {
@@ -461,8 +613,13 @@ export function getSignalLevel(neighbor: ThreadNeighbor): SignalLevel {
461
613
  if (rssi > SIGNAL_MEDIUM_THRESHOLD) return "medium";
462
614
  return "weak";
463
615
  }
464
- if (neighbor.lqi > LQI_STRONG_THRESHOLD) return "strong";
465
- if (neighbor.lqi > LQI_MEDIUM_THRESHOLD) return "medium";
616
+ return getSignalLevelFromLqi(neighbor.lqi);
617
+ }
618
+
619
+ /** Determine signal level from an LQI value alone (e.g. route table entries without RSSI). */
620
+ export function getSignalLevelFromLqi(lqi: number): SignalLevel {
621
+ if (lqi > LQI_STRONG_THRESHOLD) return "strong";
622
+ if (lqi > LQI_MEDIUM_THRESHOLD) return "medium";
466
623
  return "weak";
467
624
  }
468
625
 
@@ -669,20 +826,73 @@ export function getWiFiVersionName(version: number | null): string {
669
826
  }
670
827
 
671
828
  /**
672
- * Builds Thread mesh connections from neighbor tables.
673
- * Returns connections with signal information.
674
- * Includes connections to unknown devices (prefixed with 'unknown_').
829
+ * Represents a connection from the perspective of a specific node.
830
+ * Includes both neighbors this node reports AND nodes that report this node as their neighbor.
831
+ */
832
+ export interface NodeConnection {
833
+ /** The connected node ID (number for known nodes, string for unknown devices) */
834
+ connectedNodeId: number | string;
835
+ /** The connected MatterNode if it's a known device */
836
+ connectedNode?: MatterNode;
837
+ /** Extended address hex string for display */
838
+ extAddressHex: string;
839
+ /** Signal strength info (if available) */
840
+ signalColor: string;
841
+ /** Undefined when link strength is unknown. */
842
+ signalLevel?: SignalLevel;
843
+ lqi: number | null;
844
+ rssi: number | null;
845
+ /** Whether this connection is from THIS node's neighbor table (true) or from the OTHER node's table (false) */
846
+ isOutgoing: boolean;
847
+ /** True when only the peer reports this edge — this node has no matching neighbor-table entry. Surfaces true asymmetric visibility, distinct from a reverse view caused by filtering. */
848
+ isReverseOnly: boolean;
849
+ /** Whether this is an unknown/external device */
850
+ isUnknown: boolean;
851
+ /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
852
+ pathCost?: number;
853
+ /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
854
+ bidirectionalLqi?: number;
855
+ }
856
+
857
+ /**
858
+ * Creates a canonical pair key from two node IDs.
859
+ * The key is always ordered so that the same pair produces the same key regardless of direction.
675
860
  */
676
- export function buildThreadConnections(
861
+ export function makePairKey(a: string, b: string): string {
862
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
863
+ }
864
+
865
+ /**
866
+ * Computes a numeric signal score for edge comparison.
867
+ * Lower score = weaker signal (worst case).
868
+ */
869
+ export function getEdgeSignalScore(conn: ThreadConnection): number {
870
+ const levelScore =
871
+ conn.signalLevel === "strong"
872
+ ? 3000
873
+ : conn.signalLevel === "medium"
874
+ ? 2000
875
+ : conn.signalLevel === "weak"
876
+ ? 1000
877
+ : 0;
878
+ const detail = conn.rssi !== null ? conn.rssi + 200 : conn.lqi;
879
+ return levelScore + detail;
880
+ }
881
+
882
+ /**
883
+ * Builds edge pairs for all Thread connections.
884
+ * Each pair represents two connected nodes with up to 2 directional edges
885
+ * (one from each node's neighbor/route table). No dedup is performed —
886
+ * callers are responsible for selecting which edge to display per pair.
887
+ */
888
+ export function buildThreadEdgePairs(
677
889
  nodes: Record<string, MatterNode>,
678
890
  extAddrMap: Map<bigint, string>,
679
891
  rloc16Map: Map<number, string>,
680
- unknownDevices: UnknownThreadDevice[],
681
- ): ThreadConnection[] {
682
- const connections: ThreadConnection[] = [];
683
- const seenConnections = new Set<string>();
892
+ unknownDevices: ThreadExternalDevice[],
893
+ ): Map<string, ThreadEdgePair> {
894
+ const pairs = new Map<string, ThreadEdgePair>();
684
895
 
685
- // Build map of unknown device extAddress -> id
686
896
  const unknownExtAddrMap = new Map<bigint, string>();
687
897
  for (const unknown of unknownDevices) {
688
898
  unknownExtAddrMap.set(unknown.extAddress, unknown.id);
@@ -693,55 +903,50 @@ export function buildThreadConnections(
693
903
  const neighbors = parseNeighborTable(node);
694
904
 
695
905
  for (const neighbor of neighbors) {
696
- // Try to find in known devices first (extAddress, then RLOC16 fallback)
697
906
  let toNodeId: string | undefined = extAddrMap.get(neighbor.extAddress);
698
907
  if (toNodeId === undefined && neighbor.rloc16 !== 0) {
699
908
  toNodeId = rloc16Map.get(neighbor.rloc16);
700
909
  }
701
-
702
- // If not found, check unknown devices
703
910
  if (toNodeId === undefined) {
704
911
  toNodeId = unknownExtAddrMap.get(neighbor.extAddress);
705
912
  }
913
+ if (toNodeId === undefined || fromNodeId === toNodeId) continue;
706
914
 
707
- if (toNodeId === undefined) {
708
- // Should not happen if unknownDevices was built correctly
709
- continue;
915
+ const pairKey = makePairKey(fromNodeId, toNodeId);
916
+ if (!pairs.has(pairKey)) {
917
+ const [nodeA, nodeB] = fromNodeId < toNodeId ? [fromNodeId, toNodeId] : [toNodeId, fromNodeId];
918
+ pairs.set(pairKey, { pairKey, nodeA, nodeB });
710
919
  }
711
920
 
712
- // Skip self-connections
713
- if (fromNodeId === toNodeId) {
714
- continue;
715
- }
921
+ const pair = pairs.get(pairKey)!;
922
+ const isFromA = fromNodeId === pair.nodeA;
716
923
 
717
- // Create a unique key for this connection
718
- const connectionKey = `${fromNodeId}-${toNodeId}`;
719
- const reverseKey = `${toNodeId}-${fromNodeId}`;
924
+ // Neighbor table entry takes precedence — skip if already present for this direction
925
+ if (isFromA && pair.edgeAB) continue;
926
+ if (!isFromA && pair.edgeBA) continue;
720
927
 
721
- if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
722
- // Already have this connection
723
- continue;
724
- }
725
- seenConnections.add(connectionKey);
726
-
727
- // Look up route table entry for supplementary data (pathCost, bidirectional LQI)
728
928
  const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
729
929
  const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
730
930
 
731
- // Always use neighbor table RSSI/LQI for signal color (most accurate link quality)
732
- connections.push({
931
+ const edge: ThreadConnection = {
733
932
  fromNodeId,
734
933
  toNodeId,
735
934
  signalColor: getSignalColor(neighbor),
935
+ signalLevel: getSignalLevel(neighbor),
736
936
  lqi: neighbor.lqi,
737
937
  rssi: neighbor.avgRssi ?? neighbor.lastRssi,
738
938
  pathCost: routeEntry?.pathCost,
739
939
  bidirectionalLqi,
740
- });
940
+ };
941
+
942
+ if (isFromA) {
943
+ pair.edgeAB = edge;
944
+ } else {
945
+ pair.edgeBA = edge;
946
+ }
741
947
  }
742
948
 
743
- // Check route table for connections not in neighbor table (supplementary data)
744
- // This helps when neighbor table is stale or incomplete
949
+ // Supplementary: route table entries not already covered by neighbor table
745
950
  const routes = parseRouteTable(node);
746
951
  for (const route of routes) {
747
952
  if (!route.linkEstablished || !route.allocated) continue;
@@ -755,156 +960,170 @@ export function buildThreadConnections(
755
960
  }
756
961
  if (toNodeId === undefined || toNodeId === fromNodeId) continue;
757
962
 
758
- const connectionKey = `${fromNodeId}-${toNodeId}`;
759
- const reverseKey = `${toNodeId}-${fromNodeId}`;
760
-
761
- // Only add if we don't already have this connection from neighbor table
762
- if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
763
- continue;
963
+ const pairKey = makePairKey(fromNodeId, toNodeId);
964
+ if (!pairs.has(pairKey)) {
965
+ const [nodeA, nodeB] = fromNodeId < toNodeId ? [fromNodeId, toNodeId] : [toNodeId, fromNodeId];
966
+ pairs.set(pairKey, { pairKey, nodeA, nodeB });
764
967
  }
765
- seenConnections.add(connectionKey);
968
+
969
+ const pair = pairs.get(pairKey)!;
970
+ const isFromA = fromNodeId === pair.nodeA;
971
+
972
+ // Only add from route table if no neighbor table edge for this direction
973
+ if (isFromA && pair.edgeAB) continue;
974
+ if (!isFromA && pair.edgeBA) continue;
766
975
 
767
976
  const bidirectionalLqi = getRouteBidirectionalLqi(route);
768
977
  const signalColor =
769
978
  bidirectionalLqi !== undefined
770
979
  ? getSignalColorFromLqi(bidirectionalLqi)
771
- : "var(--md-sys-color-outline, grey)"; // Unknown signal
980
+ : "var(--md-sys-color-outline, grey)";
772
981
 
773
- connections.push({
982
+ const edge: ThreadConnection = {
774
983
  fromNodeId,
775
984
  toNodeId,
776
985
  signalColor,
986
+ signalLevel: bidirectionalLqi !== undefined ? getSignalLevelFromLqi(bidirectionalLqi) : undefined,
777
987
  lqi: bidirectionalLqi ?? 0,
778
988
  rssi: null,
779
989
  pathCost: route.pathCost,
780
990
  bidirectionalLqi,
781
991
  fromRouteTable: true,
782
- });
992
+ };
993
+
994
+ if (isFromA) {
995
+ pair.edgeAB = edge;
996
+ } else {
997
+ pair.edgeBA = edge;
998
+ }
783
999
  }
784
1000
  }
785
1001
 
786
- return connections;
1002
+ return pairs;
787
1003
  }
788
1004
 
789
1005
  /**
790
- * Represents a connection from the perspective of a specific node.
791
- * Includes both neighbors this node reports AND nodes that report this node as their neighbor.
1006
+ * Filter options for edge visibility, matching the graph's filter pipeline.
792
1007
  */
793
- export interface NodeConnection {
794
- /** The connected node ID (number for known nodes, string for unknown devices) */
795
- connectedNodeId: number | string;
796
- /** The connected MatterNode if it's a known device */
797
- connectedNode?: MatterNode;
798
- /** Extended address hex string for display */
799
- extAddressHex: string;
800
- /** Signal strength info (if available) */
801
- signalColor: string;
802
- lqi: number | null;
803
- rssi: number | null;
804
- /** Whether this connection is from THIS node's neighbor table (true) or from the OTHER node's table (false) */
805
- isOutgoing: boolean;
806
- /** Whether this is an unknown/external device */
807
- isUnknown: boolean;
808
- /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
809
- pathCost?: number;
810
- /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
811
- bidirectionalLqi?: number;
1008
+ export interface EdgeFilterOptions {
1009
+ hideOfflineNodes?: boolean;
1010
+ hideWeakSignalEdges?: boolean;
1011
+ hideMediumSignalEdges?: boolean;
1012
+ hideStrongSignalEdges?: boolean;
812
1013
  }
813
1014
 
814
1015
  /**
815
- * Get all connections for a specific node (bidirectional).
816
- * This includes:
817
- * 1. Neighbors this node reports in its neighbor table (outgoing)
818
- * 2. Nodes that report this node as their neighbor (incoming)
1016
+ * Derives NodeConnection[] from pre-computed edge pairs for a given node.
1017
+ * Uses the same edge pairs as the graph, ensuring the side panel and the
1018
+ * graph always agree on which connections exist.
1019
+ *
1020
+ * The function mirrors the graph's exact pipeline:
1021
+ * 1. Filter each edge independently (offline cascade + signal level)
1022
+ * 2. Among survivors per pair, prefer the outgoing edge (matches graph
1023
+ * highlight swap); fall back to worst signal (matches graph dedup)
819
1024
  *
820
- * Returns a deduplicated list - if both directions exist, only the outgoing one is included
821
- * (since that has signal data from THIS node's perspective).
1025
+ * When filters are omitted, no filtering is applied and the outgoing
1026
+ * edge is preferred (useful for the "update connections" dialog).
822
1027
  *
823
- * @param nodeId - Node ID as string to avoid BigInt precision loss
1028
+ * One entry per connected peer (no duplicates).
824
1029
  */
825
- export function getNodeConnections(
1030
+ export function getNodeConnectionsFromPairs(
826
1031
  nodeId: string,
1032
+ edgePairs: Map<string, ThreadEdgePair>,
827
1033
  nodes: Record<string, MatterNode>,
828
- extAddrMap: Map<bigint, string>,
829
- rloc16Map?: Map<number, string>,
1034
+ filters?: EdgeFilterOptions,
830
1035
  ): NodeConnection[] {
831
1036
  const connections: NodeConnection[] = [];
832
- const seenConnectedIds = new Set<string>();
833
1037
 
834
- const node = nodes[nodeId];
835
- if (!node) return connections;
1038
+ // Build set of hidden node IDs (offline cascade, same as graph)
1039
+ const hiddenNodeIds = new Set<string>();
1040
+ if (filters?.hideOfflineNodes) {
1041
+ for (const node of Object.values(nodes)) {
1042
+ if (node.available === false) {
1043
+ hiddenNodeIds.add(String(node.node_id));
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ for (const pair of edgePairs.values()) {
1049
+ const isA = pair.nodeA === nodeId;
1050
+ const isB = pair.nodeB === nodeId;
1051
+ if (!isA && !isB) continue;
1052
+
1053
+ const remoteId = isA ? pair.nodeB : pair.nodeA;
1054
+ const outgoing = isA ? pair.edgeAB : pair.edgeBA;
1055
+ const incoming = isA ? pair.edgeBA : pair.edgeAB;
1056
+
1057
+ // Apply filters to each edge independently (mirrors graph pipeline)
1058
+ const survivors: { conn: ThreadConnection; isOutgoing: boolean }[] = [];
1059
+
1060
+ for (const [conn, isOut] of [
1061
+ [outgoing, true],
1062
+ [incoming, false],
1063
+ ] as const) {
1064
+ if (!conn) continue;
1065
+
1066
+ if (filters) {
1067
+ const fromId = String(conn.fromNodeId);
1068
+ const toId = String(conn.toNodeId);
1069
+
1070
+ // Offline cascade: skip if either endpoint is hidden
1071
+ if (hiddenNodeIds.has(fromId) || hiddenNodeIds.has(toId)) continue;
836
1072
 
837
- // Get this node's extended address for reverse lookups (from General Diagnostics, not Thread Diagnostics)
838
- const thisExtAddr = getThreadExtendedAddress(node);
1073
+ // Signal level filters
1074
+ if (filters.hideWeakSignalEdges && conn.signalLevel === "weak") continue;
1075
+ if (filters.hideMediumSignalEdges && conn.signalLevel === "medium") continue;
1076
+ if (filters.hideStrongSignalEdges && conn.signalLevel === "strong") continue;
1077
+ }
839
1078
 
840
- // 1. Add neighbors this node reports (outgoing connections)
841
- const neighbors = parseNeighborTable(node);
842
- for (const neighbor of neighbors) {
843
- let connectedNodeId = extAddrMap.get(neighbor.extAddress);
844
- if (connectedNodeId === undefined && rloc16Map && neighbor.rloc16 !== 0) {
845
- connectedNodeId = rloc16Map.get(neighbor.rloc16);
1079
+ survivors.push({ conn, isOutgoing: isOut });
846
1080
  }
847
- const connectedNode = connectedNodeId ? nodes[connectedNodeId] : undefined;
848
- const isUnknown = connectedNodeId === undefined;
849
- const displayId: string =
850
- connectedNodeId ?? `unknown_${neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0")}`;
851
1081
 
852
- seenConnectedIds.add(displayId);
1082
+ if (survivors.length === 0) continue;
1083
+
1084
+ // Among survivors: prefer outgoing (matches graph highlight swap),
1085
+ // fall back to worst signal (matches graph dedup)
1086
+ let winner: { conn: ThreadConnection; isOutgoing: boolean };
1087
+ const outgoingSurvivor = survivors.find(s => s.isOutgoing);
1088
+ if (outgoingSurvivor) {
1089
+ winner = outgoingSurvivor;
1090
+ } else {
1091
+ survivors.sort((a, b) => getEdgeSignalScore(a.conn) - getEdgeSignalScore(b.conn));
1092
+ winner = survivors[0];
1093
+ }
853
1094
 
854
- // Look up route table entry for enhanced data
855
- const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
856
- const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
1095
+ const remoteNode = nodes[remoteId];
1096
+ const isExternalUnknown = remoteId.startsWith("unknown_");
1097
+ const isExternalBr = remoteId.startsWith("br_");
1098
+ const isUnknown = isExternalUnknown || isExternalBr;
1099
+
1100
+ // Derive extended address hex for display
1101
+ let extAddressHex: string;
1102
+ if (isExternalUnknown) {
1103
+ extAddressHex = remoteId.slice("unknown_".length);
1104
+ } else if (isExternalBr) {
1105
+ extAddressHex = remoteId.slice("br_".length);
1106
+ } else if (remoteNode) {
1107
+ extAddressHex = getThreadExtendedAddressHex(remoteNode) ?? "Unknown";
1108
+ } else {
1109
+ extAddressHex = "Unknown";
1110
+ }
857
1111
 
858
1112
  connections.push({
859
- connectedNodeId: displayId,
860
- connectedNode,
861
- extAddressHex: neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0"),
862
- signalColor: getSignalColor(neighbor),
863
- lqi: neighbor.lqi,
864
- rssi: neighbor.avgRssi ?? neighbor.lastRssi,
865
- isOutgoing: true,
1113
+ connectedNodeId: remoteId,
1114
+ connectedNode: remoteNode,
1115
+ extAddressHex,
1116
+ signalColor: winner.conn.signalColor,
1117
+ signalLevel: winner.conn.signalLevel,
1118
+ lqi: winner.conn.lqi,
1119
+ rssi: winner.conn.rssi,
1120
+ isOutgoing: winner.isOutgoing,
1121
+ isReverseOnly: !outgoing,
866
1122
  isUnknown,
867
- pathCost: routeEntry?.pathCost,
868
- bidirectionalLqi,
1123
+ pathCost: winner.conn.pathCost,
1124
+ bidirectionalLqi: winner.conn.bidirectionalLqi,
869
1125
  });
870
1126
  }
871
1127
 
872
- // 2. Find nodes that report THIS node as their neighbor (incoming connections)
873
- if (thisExtAddr !== undefined) {
874
- for (const otherNode of Object.values(nodes)) {
875
- const otherNodeId = String(otherNode.node_id);
876
- if (otherNodeId === nodeId) continue; // Skip self
877
-
878
- // Check if already connected via outgoing
879
- if (seenConnectedIds.has(otherNodeId)) continue;
880
-
881
- // Check if other node reports this node as neighbor
882
- const otherNeighbors = parseNeighborTable(otherNode);
883
- const reverseEntry = otherNeighbors.find(n => n.extAddress === thisExtAddr);
884
-
885
- if (reverseEntry) {
886
- const otherExtAddr = getThreadExtendedAddress(otherNode);
887
- const extAddrHex = otherExtAddr ? otherExtAddr.toString(16).toUpperCase().padStart(16, "0") : "Unknown";
888
-
889
- // Look up route table entry from the other node's perspective
890
- const routeEntry = findRouteByExtAddress(otherNode, thisExtAddr);
891
- const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
892
-
893
- connections.push({
894
- connectedNodeId: otherNodeId,
895
- connectedNode: otherNode,
896
- extAddressHex: extAddrHex,
897
- signalColor: getSignalColor(reverseEntry),
898
- lqi: reverseEntry.lqi,
899
- rssi: reverseEntry.avgRssi ?? reverseEntry.lastRssi,
900
- isOutgoing: false,
901
- isUnknown: false,
902
- pathCost: routeEntry?.pathCost,
903
- bidirectionalLqi,
904
- });
905
- }
906
- }
907
- }
908
-
909
1128
  return connections;
910
1129
  }