@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.
- package/dist/esm/pages/matter-network-view.d.ts +15 -0
- package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-network-view.js +171 -1
- package/dist/esm/pages/matter-network-view.js.map +1 -1
- package/dist/esm/pages/network/base-network-graph.d.ts +4 -0
- package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -1
- package/dist/esm/pages/network/base-network-graph.js +9 -0
- package/dist/esm/pages/network/base-network-graph.js.map +1 -1
- package/dist/esm/pages/network/border-router-store.d.ts +20 -0
- package/dist/esm/pages/network/border-router-store.d.ts.map +1 -0
- package/dist/esm/pages/network/border-router-store.js +29 -0
- package/dist/esm/pages/network/border-router-store.js.map +6 -0
- package/dist/esm/pages/network/network-details.d.ts +40 -12
- package/dist/esm/pages/network/network-details.d.ts.map +1 -1
- package/dist/esm/pages/network/network-details.js +440 -112
- package/dist/esm/pages/network/network-details.js.map +1 -1
- package/dist/esm/pages/network/network-types.d.ts +76 -0
- package/dist/esm/pages/network/network-types.d.ts.map +1 -1
- package/dist/esm/pages/network/network-types.js.map +1 -1
- package/dist/esm/pages/network/network-utils.d.ts +89 -22
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
- package/dist/esm/pages/network/network-utils.js +233 -95
- package/dist/esm/pages/network/network-utils.js.map +1 -1
- package/dist/esm/pages/network/thread-graph.d.ts +68 -9
- package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
- package/dist/esm/pages/network/thread-graph.js +388 -50
- package/dist/esm/pages/network/thread-graph.js.map +2 -2
- package/dist/esm/util/device-icons.d.ts +6 -0
- package/dist/esm/util/device-icons.d.ts.map +1 -1
- package/dist/esm/util/device-icons.js +6 -0
- package/dist/esm/util/device-icons.js.map +1 -1
- package/dist/web/js/{attribute-write-dialog-g4B6BoRt.js → attribute-write-dialog-DlMTUiLK.js} +1 -1
- package/dist/web/js/{command-invoke-dialog-D6G704VK.js → command-invoke-dialog-DO3IyFcm.js} +1 -1
- package/dist/web/js/{commission-node-dialog-Bg3oo5ub.js → commission-node-dialog-CMSvCm0i.js} +4 -4
- package/dist/web/js/{commission-node-existing-DO3g1aQJ.js → commission-node-existing-D08jghFu.js} +2 -2
- package/dist/web/js/{commission-node-thread-DM432aH1.js → commission-node-thread-D5waY758.js} +2 -2
- package/dist/web/js/{commission-node-wifi-Bx40FXij.js → commission-node-wifi-ClBlCFTZ.js} +2 -2
- package/dist/web/js/{dialog-box-DjyfULWB.js → dialog-box-D9vS2SmP.js} +1 -1
- package/dist/web/js/{fire_event-BstgNPuh.js → fire_event-BPhROjTC.js} +1 -1
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/{matter-dashboard-app-Cj88TtbZ.js → matter-dashboard-app-C9zTE5uH.js} +1359 -302
- package/dist/web/js/{node-binding-dialog-9yy2LE3_.js → node-binding-dialog-B5p-gbim.js} +1 -1
- package/dist/web/js/{settings-dialog-Cs2xMsXb.js → settings-dialog-BMFhom0W.js} +1 -1
- package/package.json +4 -4
- package/src/pages/matter-network-view.ts +185 -1
- package/src/pages/network/base-network-graph.ts +10 -0
- package/src/pages/network/border-router-store.ts +38 -0
- package/src/pages/network/network-details.ts +535 -140
- package/src/pages/network/network-types.ts +76 -0
- package/src/pages/network/network-utils.ts +390 -171
- package/src/pages/network/thread-graph.ts +532 -73
- 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
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
+
agg.isRouter = true;
|
|
441
454
|
}
|
|
442
|
-
|
|
443
|
-
// Track best signal
|
|
444
455
|
const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
|
|
445
|
-
if (rssi !== null && (
|
|
446
|
-
|
|
456
|
+
if (rssi !== null && (agg.bestRssi === null || rssi > agg.bestRssi)) {
|
|
457
|
+
agg.bestRssi = rssi;
|
|
447
458
|
}
|
|
448
459
|
}
|
|
449
460
|
}
|
|
450
461
|
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
*
|
|
673
|
-
*
|
|
674
|
-
|
|
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
|
|
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:
|
|
681
|
-
):
|
|
682
|
-
const
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
continue;
|
|
715
|
-
}
|
|
921
|
+
const pair = pairs.get(pairKey)!;
|
|
922
|
+
const isFromA = fromNodeId === pair.nodeA;
|
|
716
923
|
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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)";
|
|
980
|
+
: "var(--md-sys-color-outline, grey)";
|
|
772
981
|
|
|
773
|
-
|
|
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
|
|
1002
|
+
return pairs;
|
|
787
1003
|
}
|
|
788
1004
|
|
|
789
1005
|
/**
|
|
790
|
-
*
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
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
|
-
*
|
|
821
|
-
*
|
|
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
|
-
*
|
|
1028
|
+
* One entry per connected peer (no duplicates).
|
|
824
1029
|
*/
|
|
825
|
-
export function
|
|
1030
|
+
export function getNodeConnectionsFromPairs(
|
|
826
1031
|
nodeId: string,
|
|
1032
|
+
edgePairs: Map<string, ThreadEdgePair>,
|
|
827
1033
|
nodes: Record<string, MatterNode>,
|
|
828
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
const
|
|
856
|
-
const
|
|
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:
|
|
860
|
-
connectedNode,
|
|
861
|
-
extAddressHex
|
|
862
|
-
signalColor:
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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:
|
|
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
|
}
|