@matter-server/ws-client 1.1.8-alpha.0-20260707-c552b9d → 1.2.1

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.
@@ -0,0 +1,932 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { BorderRouterEntry, ThreadDiagnosticsBatch, ThreadDiagnosticsNode } from "../models/model.js";
8
+ import type {
9
+ CategorizedDevices,
10
+ DiagnosticMeshNode,
11
+ NetworkType,
12
+ SignalLevel,
13
+ ThreadConnection,
14
+ ThreadEdgePair,
15
+ ThreadExternalDevice,
16
+ ThreadNeighbor,
17
+ ThreadRoute,
18
+ TopologySourceNode,
19
+ WiFiDiagnostics,
20
+ } from "./topology-types.js";
21
+
22
+ // NetworkCommissioning cluster feature map bits (cluster 0x31/49)
23
+ const WIFI_FEATURE = 1 << 0; // Bit 0: WiFi Network Interface
24
+ const THREAD_FEATURE = 1 << 1; // Bit 1: Thread Network Interface
25
+ const ETHERNET_FEATURE = 1 << 2; // Bit 2: Ethernet Network Interface
26
+
27
+ // Thread LQI thresholds. Spec types LQI as uint8 (0-255), but OpenThread — the
28
+ // dominant Thread stack — only ever reports 0-3. We classify on the 0-3 scale:
29
+ // 3 = strong, 2 = medium, 1 = weak, 0 = no link (stale/dead neighbor entry).
30
+ const LQI_STRONG_THRESHOLD = 2;
31
+ const LQI_MEDIUM_THRESHOLD = 1;
32
+
33
+ /**
34
+ * Converts a base64-encoded extended address to BigInt.
35
+ * Extended addresses are 8 bytes (64 bits) stored as big-endian.
36
+ * Some Matter implementations include a TLV prefix byte that we need to skip.
37
+ */
38
+ function base64ToBigInt(base64: string): bigint {
39
+ try {
40
+ const binary = atob(base64);
41
+ let result = 0n;
42
+
43
+ // If we have 9 bytes, skip the first byte (likely a TLV type prefix)
44
+ // EUI-64 should be exactly 8 bytes
45
+ const start = binary.length > 8 ? binary.length - 8 : 0;
46
+
47
+ for (let i = start; i < binary.length; i++) {
48
+ result = (result << 8n) | BigInt(binary.charCodeAt(i));
49
+ }
50
+ return result;
51
+ } catch {
52
+ return 0n;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Normalizes an extended address to BigInt for comparison.
58
+ * Handles: BigInt, base64 strings, numbers.
59
+ */
60
+ function normalizeExtAddress(value: unknown): bigint {
61
+ if (typeof value === "bigint") {
62
+ return value;
63
+ }
64
+ if (typeof value === "string") {
65
+ return base64ToBigInt(value);
66
+ }
67
+ if (typeof value === "number") {
68
+ return BigInt(value);
69
+ }
70
+ return 0n;
71
+ }
72
+
73
+ /**
74
+ * Detects the network type from the NetworkCommissioning cluster feature map.
75
+ * Uses attribute 0/49/65532 (FeatureMap).
76
+ */
77
+ export function getNetworkType(node: TopologySourceNode): NetworkType {
78
+ const featureMap = node.attributes["0/49/65532"] as number | undefined;
79
+
80
+ if (featureMap === undefined) {
81
+ return "unknown";
82
+ }
83
+
84
+ // Check in priority order: Thread > WiFi > Ethernet
85
+ if (featureMap & THREAD_FEATURE) {
86
+ return "thread";
87
+ }
88
+ if (featureMap & WIFI_FEATURE) {
89
+ return "wifi";
90
+ }
91
+ if (featureMap & ETHERNET_FEATURE) {
92
+ return "ethernet";
93
+ }
94
+
95
+ return "unknown";
96
+ }
97
+
98
+ /**
99
+ * Thread protocol version supported by the device's Thread interface.
100
+ * Uses NetworkCommissioning cluster (0x31/49) ThreadVersion attribute (0x0A/10).
101
+ */
102
+ export function getThreadVersion(node: TopologySourceNode): number | undefined {
103
+ const v = node.attributes["0/49/10"];
104
+ return typeof v === "number" ? v : undefined;
105
+ }
106
+
107
+ /**
108
+ * Categorizes nodes by their network type.
109
+ * Node IDs are stored as strings to avoid BigInt precision loss.
110
+ */
111
+ export function categorizeDevices(nodes: Record<string, TopologySourceNode>): CategorizedDevices {
112
+ const result: CategorizedDevices = {
113
+ thread: [],
114
+ wifi: [],
115
+ ethernet: [],
116
+ unknown: [],
117
+ };
118
+
119
+ for (const node of Object.values(nodes)) {
120
+ const nodeId = String(node.node_id);
121
+ const networkType = getNetworkType(node);
122
+ result[networkType].push(nodeId);
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * Gets the Thread routing role for a node.
130
+ * Uses attribute 0/53/1 (RoutingRole, nullable per Matter spec).
131
+ */
132
+ export function getThreadRole(node: TopologySourceNode): number | undefined {
133
+ const v = node.attributes["0/53/1"];
134
+ return typeof v === "number" ? v : undefined;
135
+ }
136
+
137
+ /**
138
+ * Gets the Thread channel for a node.
139
+ * Uses attribute 0/53/0 (Channel, nullable per Matter spec).
140
+ */
141
+ export function getThreadChannel(node: TopologySourceNode): number | undefined {
142
+ const v = node.attributes["0/53/0"];
143
+ return typeof v === "number" ? v : undefined;
144
+ }
145
+
146
+ /**
147
+ * Gets the Thread extended PAN ID for a node.
148
+ * Uses attribute 0/53/4 (ExtendedPanId, nullable per Matter spec).
149
+ *
150
+ * The WebSocket JSON reviver only revives integers above Number.MAX_SAFE_INTEGER
151
+ * as bigint; smaller uint64 values arrive as plain number, so accept both.
152
+ */
153
+ export function getThreadExtendedPanId(node: TopologySourceNode): bigint | undefined {
154
+ const v = node.attributes["0/53/4"];
155
+ if (typeof v === "bigint") return v;
156
+ if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);
157
+ return undefined;
158
+ }
159
+
160
+ /**
161
+ * Gets the Thread extended address (EUI-64) for a node.
162
+ *
163
+ * Uses General Diagnostics cluster (0x0033/51) NetworkInterfaces attribute (0/51/0).
164
+ * The NetworkInterface struct has:
165
+ * - Field 4: HardwareAddress (base64 encoded EUI-64)
166
+ * - Field 7: Type (4 = Thread)
167
+ *
168
+ * Returns the full EUI-64 as BigInt. Decoded from the base64 hardware address,
169
+ * so it is exact and used whole for matching — the JSON number-precision caveat
170
+ * that applies to revived integers does not apply here.
171
+ */
172
+ export function getThreadExtendedAddress(node: TopologySourceNode): bigint | undefined {
173
+ // Get NetworkInterfaces from General Diagnostics cluster (0/51/0)
174
+ const networkInterfaces = node.attributes["0/51/0"] as Array<Record<string, unknown>> | undefined;
175
+
176
+ if (!Array.isArray(networkInterfaces) || networkInterfaces.length === 0) {
177
+ return undefined;
178
+ }
179
+
180
+ // Find Thread interface (type 7 field = 4) or use first with hardware address
181
+ const threadIface = networkInterfaces.find(i => i["7"] === 4) || networkInterfaces[0];
182
+
183
+ if (!threadIface) {
184
+ return undefined;
185
+ }
186
+
187
+ // HardwareAddress is field 4, base64 encoded
188
+ const hwAddrB64 = threadIface["4"];
189
+
190
+ if (typeof hwAddrB64 !== "string" || !hwAddrB64) {
191
+ return undefined;
192
+ }
193
+
194
+ // Decode base64 to get EUI-64
195
+ const extAddr = base64ToBigInt(hwAddrB64);
196
+ return extAddr !== 0n ? extAddr : undefined;
197
+ }
198
+
199
+ /**
200
+ * Gets the Thread extended address as a hex string for display.
201
+ * Uses General Diagnostics NetworkInterfaces (0/51/0).
202
+ */
203
+ export function getThreadExtendedAddressHex(node: TopologySourceNode): string | undefined {
204
+ const extAddr = getThreadExtendedAddress(node);
205
+ if (extAddr !== undefined) {
206
+ return extAddr.toString(16).padStart(16, "0").toUpperCase();
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ /**
212
+ * Counts entries in the Thread neighbor table without normalizing each entry.
213
+ * Use this in hot paths where only the cardinality matters; the full parse
214
+ * does a base64 decode per entry that adds up across re-renders.
215
+ */
216
+ export function getNeighborTableLength(node: TopologySourceNode): number {
217
+ const neighborTable = node.attributes["0/53/7"];
218
+ return Array.isArray(neighborTable) ? neighborTable.length : 0;
219
+ }
220
+
221
+ /**
222
+ * Parses the Thread neighbor table from a node's attributes.
223
+ * Attribute 0/53/7 (NeighborTable) is an array of neighbor objects.
224
+ * The data uses numeric keys matching the Matter spec field IDs.
225
+ */
226
+ export function parseNeighborTable(node: TopologySourceNode): ThreadNeighbor[] {
227
+ const neighborTable = node.attributes["0/53/7"];
228
+
229
+ if (!Array.isArray(neighborTable)) {
230
+ return [];
231
+ }
232
+
233
+ return neighborTable.map((entry: Record<string, unknown>) => {
234
+ // Field 0: extAddress - can be BigInt or base64 string
235
+ const rawExtAddr = entry["0"] ?? entry.extAddress;
236
+ const extAddress = normalizeExtAddress(rawExtAddr);
237
+
238
+ return {
239
+ extAddress,
240
+ // Field 1: age
241
+ age: (entry["1"] ?? entry.age ?? 0) as number,
242
+ // Field 2: rloc16
243
+ rloc16: (entry["2"] ?? entry.rloc16 ?? 0) as number,
244
+ // Field 3: linkFrameCounter
245
+ linkFrameCounter: (entry["3"] ?? entry.linkFrameCounter ?? 0) as number,
246
+ // Field 4: mleFrameCounter
247
+ mleFrameCounter: (entry["4"] ?? entry.mleFrameCounter ?? 0) as number,
248
+ // Field 5: lqi
249
+ lqi: (entry["5"] ?? entry.lqi ?? 0) as number,
250
+ // Field 6: averageRssi (nullable)
251
+ avgRssi: (entry["6"] ?? entry.averageRssi ?? null) as number | null,
252
+ // Field 7: lastRssi (nullable)
253
+ lastRssi: (entry["7"] ?? entry.lastRssi ?? null) as number | null,
254
+ // Field 8: frameErrorRate
255
+ frameErrorRate: (entry["8"] ?? entry.frameErrorRate ?? 0) as number,
256
+ // Field 9: messageErrorRate
257
+ messageErrorRate: (entry["9"] ?? entry.messageErrorRate ?? 0) as number,
258
+ // Field 10: rxOnWhenIdle
259
+ rxOnWhenIdle: (entry["10"] ?? entry.rxOnWhenIdle ?? false) as boolean,
260
+ // Field 11: fullThreadDevice
261
+ fullThreadDevice: (entry["11"] ?? entry.fullThreadDevice ?? false) as boolean,
262
+ // Field 12: fullNetworkData
263
+ fullNetworkData: (entry["12"] ?? entry.fullNetworkData ?? false) as boolean,
264
+ // Field 13: isChild
265
+ isChild: (entry["13"] ?? entry.isChild ?? false) as boolean,
266
+ };
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Parses the Thread route table from a node's attributes.
272
+ * Attribute 0/53/8 (RouteTable) is an array of route objects.
273
+ * The data uses numeric keys matching the Matter spec field IDs.
274
+ */
275
+ export function parseRouteTable(node: TopologySourceNode): ThreadRoute[] {
276
+ const routeTable = node.attributes["0/53/8"];
277
+
278
+ if (!Array.isArray(routeTable)) {
279
+ return [];
280
+ }
281
+
282
+ return routeTable.map((entry: Record<string, unknown>) => {
283
+ // Field 0: extAddress - can be BigInt or base64 string
284
+ const rawExtAddr = entry["0"] ?? entry.extAddress;
285
+ const extAddress = normalizeExtAddress(rawExtAddr);
286
+
287
+ return {
288
+ extAddress,
289
+ // Field 1: rloc16
290
+ rloc16: (entry["1"] ?? entry.rloc16 ?? 0) as number,
291
+ // Field 2: routerId
292
+ routerId: (entry["2"] ?? entry.routerId ?? 0) as number,
293
+ // Field 3: nextHop
294
+ nextHop: (entry["3"] ?? entry.nextHop ?? 0) as number,
295
+ // Field 4: pathCost
296
+ pathCost: (entry["4"] ?? entry.pathCost ?? 0) as number,
297
+ // Field 5: lqiIn
298
+ lqiIn: (entry["5"] ?? entry.lqiIn ?? 0) as number,
299
+ // Field 6: lqiOut
300
+ lqiOut: (entry["6"] ?? entry.lqiOut ?? 0) as number,
301
+ // Field 7: age
302
+ age: (entry["7"] ?? entry.age ?? 0) as number,
303
+ // Field 8: allocated
304
+ allocated: (entry["8"] ?? entry.allocated ?? false) as boolean,
305
+ // Field 9: linkEstablished
306
+ linkEstablished: (entry["9"] ?? entry.linkEstablished ?? false) as boolean,
307
+ };
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Find a route table entry for a specific destination by extended address.
313
+ * Returns the route entry if found, undefined otherwise.
314
+ */
315
+ export function findRouteByExtAddress(node: TopologySourceNode, targetExtAddr: bigint): ThreadRoute | undefined {
316
+ const routes = parseRouteTable(node);
317
+ return routes.find(route => route.extAddress === targetExtAddr && route.linkEstablished);
318
+ }
319
+
320
+ /**
321
+ * Count the number of routable destinations for a node (from route table).
322
+ * Only counts entries where allocated=true and linkEstablished=true.
323
+ * This is typically only meaningful for router nodes.
324
+ */
325
+ export function getRoutableDestinationsCount(node: TopologySourceNode): number {
326
+ const routes = parseRouteTable(node);
327
+ return routes.filter(route => route.allocated && route.linkEstablished).length;
328
+ }
329
+
330
+ /**
331
+ * Calculate combined bidirectional LQI from route table entry.
332
+ * Returns average of lqiIn and lqiOut if both are non-zero.
333
+ */
334
+ export function getRouteBidirectionalLqi(route: Pick<ThreadRoute, "lqiIn" | "lqiOut">): number | undefined {
335
+ if (route.lqiIn > 0 && route.lqiOut > 0) {
336
+ return Math.round((route.lqiIn + route.lqiOut) / 2);
337
+ }
338
+ if (route.lqiIn > 0) return route.lqiIn;
339
+ if (route.lqiOut > 0) return route.lqiOut;
340
+ return undefined;
341
+ }
342
+
343
+ /**
344
+ * Gets the RLOC16 (short address) for a Thread node.
345
+ * Uses attribute 0/53/64 (Rloc16, 0x0040).
346
+ */
347
+ export function getThreadRloc16(node: TopologySourceNode): number | undefined {
348
+ const value = node.attributes["0/53/64"];
349
+ if (typeof value === "number") {
350
+ return value;
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ /**
356
+ * Builds a map of extended addresses (BigInt) to node IDs for Thread devices.
357
+ * Uses General Diagnostics NetworkInterfaces (0/51/0) for the hardware address.
358
+ * Node IDs are stored as strings to avoid BigInt precision loss.
359
+ */
360
+ export function buildExtAddrMap(nodes: Record<string, TopologySourceNode>): Map<bigint, string> {
361
+ const extAddrMap = new Map<bigint, string>();
362
+
363
+ for (const node of Object.values(nodes)) {
364
+ const nodeId = String(node.node_id);
365
+ const extAddr = getThreadExtendedAddress(node);
366
+
367
+ if (extAddr !== undefined) {
368
+ extAddrMap.set(extAddr, nodeId);
369
+ }
370
+ }
371
+
372
+ return extAddrMap;
373
+ }
374
+
375
+ /**
376
+ * Builds a map of RLOC16 (short addresses) to node IDs for Thread devices.
377
+ * Used as fallback when ExtAddress is not available.
378
+ * Node IDs are stored as strings to avoid BigInt precision loss.
379
+ */
380
+ export function buildRloc16Map(nodes: Record<string, TopologySourceNode>): Map<number, string> {
381
+ const rloc16Map = new Map<number, string>();
382
+
383
+ for (const node of Object.values(nodes)) {
384
+ const nodeId = String(node.node_id);
385
+ const rloc16 = getThreadRloc16(node);
386
+
387
+ if (rloc16 !== undefined) {
388
+ rloc16Map.set(rloc16, nodeId);
389
+ }
390
+ }
391
+
392
+ return rloc16Map;
393
+ }
394
+
395
+ /**
396
+ * Stable graph id for a diagnostic mesh node: prefer the globally-unique extMac,
397
+ * else an rloc16 namespaced by extPanId (rloc16 is only unique within a network).
398
+ */
399
+ export function diagnosticNodeId(
400
+ node: Pick<ThreadDiagnosticsNode, "extMacAddress" | "rloc16">,
401
+ extPanIdHex: string,
402
+ ): string {
403
+ if (node.extMacAddress !== undefined) return `thread_${node.extMacAddress.toUpperCase()}`;
404
+ return `meshrloc_${extPanIdHex.toUpperCase()}_${node.rloc16 ?? "x"}`;
405
+ }
406
+
407
+ /** Resolver key joining a network's extPanId with an rloc16 (rloc16 alone is not unique across networks). */
408
+ function diagRlocKey(extPanIdHex: string, rloc16: number): string {
409
+ return `${extPanIdHex.toUpperCase()}:${rloc16}`;
410
+ }
411
+
412
+ /**
413
+ * `extPanId:rloc16` -> Matter node id, scoped per Thread network. rloc16 is only
414
+ * unique within a network, so a global rloc16 map would mis-attach diagnostic
415
+ * edges from one network onto a Matter device on another. Keyed via {@link diagRlocKey}.
416
+ */
417
+ export function buildMatterRloc16ByXp(nodes: Record<string, TopologySourceNode>): Map<string, string> {
418
+ const map = new Map<string, string>();
419
+ for (const node of Object.values(nodes)) {
420
+ const rloc16 = getThreadRloc16(node);
421
+ const xp = getThreadExtendedPanId(node);
422
+ if (rloc16 === undefined || xp === undefined) continue;
423
+ map.set(diagRlocKey(xp.toString(16).padStart(16, "0"), rloc16), String(node.node_id));
424
+ }
425
+ return map;
426
+ }
427
+
428
+ /**
429
+ * Resolve a diagnostic node to the graph node id it is actually rendered under:
430
+ * a commissioned Matter device (by extMac), a known Border Router (`br_<xa>`), a
431
+ * neighbor-inferred unknown, or — when it matches none — its own diagnostic id.
432
+ * Mirrors the precedence in {@link findDiagnosticMeshNodes} so edges target the
433
+ * same node those materialize, rather than a phantom `thread_`/`meshrloc_` id.
434
+ */
435
+ function diagnosticGraphNodeId(
436
+ node: ThreadDiagnosticsNode,
437
+ extPanIdHex: string,
438
+ matterExtAddrMap: Map<bigint, string>,
439
+ borderRouters: ReadonlyMap<string, BorderRouterEntry>,
440
+ unknownIdByExt: Map<string, string>,
441
+ ): string {
442
+ const up = node.extMacAddress?.toUpperCase();
443
+ if (up !== undefined) {
444
+ const matterId = matterExtAddrMap.get(BigInt(`0x${up}`));
445
+ if (matterId !== undefined) return matterId;
446
+ if (borderRouters.has(up)) return `br_${up}`;
447
+ const unknownId = unknownIdByExt.get(up);
448
+ if (unknownId !== undefined) return unknownId;
449
+ }
450
+ return diagnosticNodeId(node, extPanIdHex);
451
+ }
452
+
453
+ /**
454
+ * `extPanId:rloc16` -> graph node id across diagnostic batches, layered UNDER the
455
+ * Matter rloc16 map. Resolves route64/childTable references within the referencing
456
+ * node's own network to whatever id that node is drawn as (Matter / `br_` /
457
+ * `unknown_` / diagnostic). Keyed via {@link diagRlocKey}.
458
+ */
459
+ export function buildDiagnosticRloc16Map(
460
+ batches: ReadonlyMap<string, ThreadDiagnosticsBatch>,
461
+ matterRloc16ByXp: Map<string, string>,
462
+ matterExtAddrMap: Map<bigint, string>,
463
+ borderRouters: ReadonlyMap<string, BorderRouterEntry>,
464
+ unknownDevices: ThreadExternalDevice[],
465
+ ): Map<string, string> {
466
+ const unknownIdByExt = new Map<string, string>();
467
+ for (const d of unknownDevices) unknownIdByExt.set(d.extAddressHex.toUpperCase(), d.id);
468
+
469
+ const map = new Map<string, string>();
470
+ for (const batch of batches.values()) {
471
+ for (const node of batch.nodes) {
472
+ if (node.rloc16 === undefined) continue;
473
+ // Matter device on THIS network (by rloc16) wins.
474
+ if (matterRloc16ByXp.has(diagRlocKey(batch.extPanIdHex, node.rloc16))) continue;
475
+ map.set(
476
+ diagRlocKey(batch.extPanIdHex, node.rloc16),
477
+ diagnosticGraphNodeId(node, batch.extPanIdHex, matterExtAddrMap, borderRouters, unknownIdByExt),
478
+ );
479
+ }
480
+ }
481
+ return map;
482
+ }
483
+
484
+ /**
485
+ * Build a per-network rloc16 resolver: a Matter device on the same Thread network
486
+ * wins, else the diagnostic node id within the same extPanId. Returns undefined
487
+ * when unresolved. Both lookups are keyed by `extPanId:rloc16` so identical rloc16
488
+ * values across networks never cross-attach.
489
+ */
490
+ export function makeDiagnosticRloc16Resolver(
491
+ matterRloc16ByXp: Map<string, string>,
492
+ diagRloc16Map: Map<string, string>,
493
+ ): (rloc16: number, extPanIdHex: string) => string | undefined {
494
+ return (rloc16, extPanIdHex) =>
495
+ matterRloc16ByXp.get(diagRlocKey(extPanIdHex, rloc16)) ?? diagRloc16Map.get(diagRlocKey(extPanIdHex, rloc16));
496
+ }
497
+
498
+ interface ExternalAggregate {
499
+ extAddressHex: string;
500
+ extAddress: bigint;
501
+ seenBy: string[];
502
+ isRouter: boolean;
503
+ bestRssi: number | null;
504
+ /** xp of the first observing matter node; all neighbors of a Thread node share its xp. */
505
+ extendedPanIdHex?: string;
506
+ }
507
+
508
+ /**
509
+ * Finds external Thread devices - addresses seen in neighbor tables that don't match
510
+ * any commissioned device. Classifies each against the optional Border Router registry:
511
+ * matched ones are emitted as kind:"br" with full mDNS enrichment; the rest stay as
512
+ * kind:"unknown". Uses RLOC16 as fallback when extended address matching fails.
513
+ */
514
+ export function findUnknownDevices(
515
+ nodes: Record<string, TopologySourceNode>,
516
+ extAddrMap: Map<bigint, string>,
517
+ rloc16Map: Map<number, string>,
518
+ borderRouters?: ReadonlyMap<string, BorderRouterEntry>,
519
+ ): ThreadExternalDevice[] {
520
+ const aggregates = new Map<string, ExternalAggregate>();
521
+
522
+ for (const node of Object.values(nodes)) {
523
+ const nodeId = String(node.node_id);
524
+ const neighbors = parseNeighborTable(node);
525
+ const observerXp = getThreadExtendedPanId(node);
526
+ const observerXpHex =
527
+ observerXp !== undefined ? observerXp.toString(16).padStart(16, "0").toUpperCase() : undefined;
528
+
529
+ for (const neighbor of neighbors) {
530
+ if (extAddrMap.has(neighbor.extAddress)) {
531
+ continue;
532
+ }
533
+ if (neighbor.rloc16 !== 0 && rloc16Map.has(neighbor.rloc16)) {
534
+ continue;
535
+ }
536
+
537
+ const extAddressHex = neighbor.extAddress.toString(16).padStart(16, "0").toUpperCase();
538
+
539
+ let agg = aggregates.get(extAddressHex);
540
+ if (agg === undefined) {
541
+ agg = {
542
+ extAddressHex,
543
+ extAddress: neighbor.extAddress,
544
+ seenBy: [],
545
+ isRouter: false,
546
+ bestRssi: null,
547
+ extendedPanIdHex: observerXpHex,
548
+ };
549
+ aggregates.set(extAddressHex, agg);
550
+ } else if (agg.extendedPanIdHex === undefined && observerXpHex !== undefined) {
551
+ agg.extendedPanIdHex = observerXpHex;
552
+ }
553
+
554
+ if (!agg.seenBy.includes(nodeId)) {
555
+ agg.seenBy.push(nodeId);
556
+ }
557
+ if (neighbor.rxOnWhenIdle) {
558
+ agg.isRouter = true;
559
+ }
560
+ const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
561
+ if (rssi !== null && (agg.bestRssi === null || rssi > agg.bestRssi)) {
562
+ agg.bestRssi = rssi;
563
+ }
564
+ }
565
+ }
566
+
567
+ // Pre-compute xp → networkName from the BR registry so we can label unknowns by network.
568
+ const networkNameByXp = new Map<string, string>();
569
+ if (borderRouters !== undefined) {
570
+ for (const br of borderRouters.values()) {
571
+ if (br.extendedPanIdHex !== undefined && br.networkName !== undefined) {
572
+ networkNameByXp.set(br.extendedPanIdHex, br.networkName);
573
+ }
574
+ }
575
+ }
576
+
577
+ const out = new Array<ThreadExternalDevice>();
578
+ for (const agg of aggregates.values()) {
579
+ const br = borderRouters?.get(agg.extAddressHex);
580
+ if (br !== undefined) {
581
+ out.push({
582
+ kind: "br",
583
+ ...br,
584
+ id: `br_${agg.extAddressHex}`,
585
+ extAddressHex: agg.extAddressHex,
586
+ extAddress: agg.extAddress,
587
+ seenBy: agg.seenBy,
588
+ isRouter: agg.isRouter,
589
+ bestRssi: agg.bestRssi,
590
+ });
591
+ } else {
592
+ const networkName =
593
+ agg.extendedPanIdHex !== undefined ? networkNameByXp.get(agg.extendedPanIdHex) : undefined;
594
+ out.push({
595
+ kind: "unknown",
596
+ id: `unknown_${agg.extAddressHex}`,
597
+ extAddressHex: agg.extAddressHex,
598
+ extAddress: agg.extAddress,
599
+ seenBy: agg.seenBy,
600
+ isRouter: agg.isRouter,
601
+ bestRssi: agg.bestRssi,
602
+ extendedPanIdHex: agg.extendedPanIdHex,
603
+ networkName,
604
+ });
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+
610
+ /** Determine signal level from a Thread neighbor's LQI. */
611
+ export function getSignalLevel(neighbor: ThreadNeighbor): SignalLevel {
612
+ return getSignalLevelFromLqi(neighbor.lqi);
613
+ }
614
+
615
+ /**
616
+ * Map an LQI value (0-3 in practice on OpenThread) to a signal level.
617
+ * 0 = "none" (no recent valid frames — stale/dead link).
618
+ */
619
+ export function getSignalLevelFromLqi(lqi: number): SignalLevel {
620
+ if (lqi <= 0) return "none";
621
+ if (lqi > LQI_STRONG_THRESHOLD) return "strong";
622
+ if (lqi > LQI_MEDIUM_THRESHOLD) return "medium";
623
+ return "weak";
624
+ }
625
+
626
+ /**
627
+ * Materialize mesh nodes that exist only in diagnostics: router records and
628
+ * childTable children whose rloc16/extMac matches no Matter device, BR, or
629
+ * already-found unknown. Matter/BR/unknown matches are enriched in place, not
630
+ * duplicated.
631
+ */
632
+ export function findDiagnosticMeshNodes(
633
+ batches: ReadonlyMap<string, ThreadDiagnosticsBatch>,
634
+ matterRloc16ByXp: Map<string, string>,
635
+ matterExtAddrMap: Map<bigint, string>,
636
+ borderRouters: ReadonlyMap<string, BorderRouterEntry>,
637
+ unknownDevices: ThreadExternalDevice[],
638
+ ): DiagnosticMeshNode[] {
639
+ const unknownExt = new Set<string>(unknownDevices.map(d => d.extAddressHex.toUpperCase()));
640
+ const out = new Map<string, DiagnosticMeshNode>();
641
+
642
+ const matchesExisting = (extPanIdHex: string, rloc16: number | undefined, extHex?: string): boolean => {
643
+ if (rloc16 !== undefined && matterRloc16ByXp.has(diagRlocKey(extPanIdHex, rloc16))) return true;
644
+ if (extHex !== undefined) {
645
+ const up = extHex.toUpperCase();
646
+ if (matterExtAddrMap.has(BigInt(`0x${up}`))) return true;
647
+ if (borderRouters.has(up)) return true;
648
+ if (unknownExt.has(up)) return true;
649
+ }
650
+ return false;
651
+ };
652
+
653
+ for (const batch of batches.values()) {
654
+ for (const node of batch.nodes) {
655
+ // Only materialize a node for the responding router itself (a route64
656
+ // participant). Its childTable children are leaf end devices — surfaced
657
+ // as a count on the router, not as individual floating nodes — unless a
658
+ // child is itself a commissioned Matter device, which already has a node.
659
+ if (node.rloc16 === undefined) continue;
660
+ if (matchesExisting(batch.extPanIdHex, node.rloc16, node.extMacAddress)) continue;
661
+ const id = diagnosticNodeId(node, batch.extPanIdHex);
662
+ out.set(id, {
663
+ kind: "diagnostic",
664
+ id,
665
+ rloc16: node.rloc16,
666
+ extAddressHex: node.extMacAddress?.toUpperCase(),
667
+ isRouter: (node.rloc16 & 0x3ff) === 0,
668
+ vendorName: node.vendorName,
669
+ childCount: node.childTable?.length ?? 0,
670
+ networkName: batch.networkName,
671
+ });
672
+ }
673
+ }
674
+ return [...out.values()];
675
+ }
676
+
677
+ /**
678
+ * Creates a canonical pair key from two node IDs.
679
+ * The key is always ordered so that the same pair produces the same key regardless of direction.
680
+ */
681
+ export function makePairKey(a: string, b: string): string {
682
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
683
+ }
684
+
685
+ /**
686
+ * Computes a numeric signal score for edge comparison.
687
+ * Lower score = weaker signal (worst case).
688
+ */
689
+ export function getEdgeSignalScore(conn: ThreadConnection): number {
690
+ const levelScore =
691
+ conn.signalLevel === "strong"
692
+ ? 3000
693
+ : conn.signalLevel === "medium"
694
+ ? 2000
695
+ : conn.signalLevel === "weak"
696
+ ? 1000
697
+ : 0;
698
+ const detail = conn.rssi !== null ? conn.rssi + 200 : conn.lqi;
699
+ return levelScore + detail;
700
+ }
701
+
702
+ /**
703
+ * Builds edge pairs for all Thread connections.
704
+ * Each pair represents two connected nodes with up to 2 directional edges
705
+ * (one from each node's neighbor/route table). No dedup is performed —
706
+ * callers are responsible for selecting which edge to display per pair.
707
+ */
708
+ export function buildThreadEdgePairs(
709
+ nodes: Record<string, TopologySourceNode>,
710
+ extAddrMap: Map<bigint, string>,
711
+ rloc16Map: Map<number, string>,
712
+ unknownDevices: ThreadExternalDevice[],
713
+ ): Map<string, ThreadEdgePair> {
714
+ const pairs = new Map<string, ThreadEdgePair>();
715
+
716
+ const unknownExtAddrMap = new Map<bigint, string>();
717
+ for (const unknown of unknownDevices) {
718
+ unknownExtAddrMap.set(unknown.extAddress, unknown.id);
719
+ }
720
+
721
+ for (const node of Object.values(nodes)) {
722
+ const fromNodeId = String(node.node_id);
723
+ const neighbors = parseNeighborTable(node);
724
+ const routes = parseRouteTable(node);
725
+ const routeByExtAddress = new Map<bigint, ThreadRoute>();
726
+ for (const route of routes) {
727
+ if (route.linkEstablished && !routeByExtAddress.has(route.extAddress)) {
728
+ routeByExtAddress.set(route.extAddress, route);
729
+ }
730
+ }
731
+
732
+ for (const neighbor of neighbors) {
733
+ let toNodeId: string | undefined = extAddrMap.get(neighbor.extAddress);
734
+ if (toNodeId === undefined && neighbor.rloc16 !== 0) {
735
+ toNodeId = rloc16Map.get(neighbor.rloc16);
736
+ }
737
+ if (toNodeId === undefined) {
738
+ toNodeId = unknownExtAddrMap.get(neighbor.extAddress);
739
+ }
740
+ if (toNodeId === undefined || fromNodeId === toNodeId) continue;
741
+
742
+ const pairKey = makePairKey(fromNodeId, toNodeId);
743
+ if (!pairs.has(pairKey)) {
744
+ const [nodeA, nodeB] = fromNodeId < toNodeId ? [fromNodeId, toNodeId] : [toNodeId, fromNodeId];
745
+ pairs.set(pairKey, { pairKey, nodeA, nodeB });
746
+ }
747
+
748
+ const pair = pairs.get(pairKey)!;
749
+ const isFromA = fromNodeId === pair.nodeA;
750
+
751
+ // Neighbor table entry takes precedence — skip if already present for this direction
752
+ if (isFromA && pair.edgeAB) continue;
753
+ if (!isFromA && pair.edgeBA) continue;
754
+
755
+ const routeEntry = routeByExtAddress.get(neighbor.extAddress);
756
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
757
+
758
+ const edge: ThreadConnection = {
759
+ fromNodeId,
760
+ toNodeId,
761
+ signalLevel: getSignalLevel(neighbor),
762
+ lqi: neighbor.lqi,
763
+ rssi: neighbor.avgRssi ?? neighbor.lastRssi,
764
+ pathCost: routeEntry?.pathCost,
765
+ bidirectionalLqi,
766
+ };
767
+
768
+ if (isFromA) {
769
+ pair.edgeAB = edge;
770
+ } else {
771
+ pair.edgeBA = edge;
772
+ }
773
+ }
774
+
775
+ // Supplementary: route table entries not already covered by neighbor table
776
+ for (const route of routes) {
777
+ if (!route.linkEstablished || !route.allocated) continue;
778
+
779
+ let toNodeId: string | undefined = extAddrMap.get(route.extAddress);
780
+ if (toNodeId === undefined && route.rloc16 !== 0) {
781
+ toNodeId = rloc16Map.get(route.rloc16);
782
+ }
783
+ if (toNodeId === undefined) {
784
+ toNodeId = unknownExtAddrMap.get(route.extAddress);
785
+ }
786
+ if (toNodeId === undefined || toNodeId === fromNodeId) continue;
787
+
788
+ const pairKey = makePairKey(fromNodeId, toNodeId);
789
+ if (!pairs.has(pairKey)) {
790
+ const [nodeA, nodeB] = fromNodeId < toNodeId ? [fromNodeId, toNodeId] : [toNodeId, fromNodeId];
791
+ pairs.set(pairKey, { pairKey, nodeA, nodeB });
792
+ }
793
+
794
+ const pair = pairs.get(pairKey)!;
795
+ const isFromA = fromNodeId === pair.nodeA;
796
+
797
+ // Only add from route table if no neighbor table edge for this direction
798
+ if (isFromA && pair.edgeAB) continue;
799
+ if (!isFromA && pair.edgeBA) continue;
800
+
801
+ const bidirectionalLqi = getRouteBidirectionalLqi(route);
802
+ // No bidirectional LQI = both lqiIn and lqiOut are 0 → treat as no-link.
803
+ const signalLevel: SignalLevel =
804
+ bidirectionalLqi !== undefined ? getSignalLevelFromLqi(bidirectionalLqi) : "none";
805
+
806
+ const edge: ThreadConnection = {
807
+ fromNodeId,
808
+ toNodeId,
809
+ signalLevel,
810
+ lqi: bidirectionalLqi ?? 0,
811
+ rssi: null,
812
+ pathCost: route.pathCost,
813
+ bidirectionalLqi,
814
+ fromRouteTable: true,
815
+ };
816
+
817
+ if (isFromA) {
818
+ pair.edgeAB = edge;
819
+ } else {
820
+ pair.edgeBA = edge;
821
+ }
822
+ }
823
+ }
824
+
825
+ return pairs;
826
+ }
827
+
828
+ /**
829
+ * Merge route64 (router<->router) and childTable (router->child) edges from
830
+ * diagnostics into an existing edge-pair map. Resolves references to graph node
831
+ * ids via `resolveRloc16`; unresolved references are dropped (no phantom nodes).
832
+ * Existing edges (Matter-sourced) take precedence per direction.
833
+ */
834
+ export function mergeDiagnosticEdges(
835
+ pairs: Map<string, ThreadEdgePair>,
836
+ batches: ReadonlyMap<string, ThreadDiagnosticsBatch>,
837
+ resolveRloc16: (rloc16: number, extPanIdHex: string) => string | undefined,
838
+ ): void {
839
+ const addEdge = (fromNodeId: string, toNodeId: string, lqi: number, pathCost?: number): void => {
840
+ if (fromNodeId === toNodeId) return;
841
+ const pairKey = makePairKey(fromNodeId, toNodeId);
842
+ let pair = pairs.get(pairKey);
843
+ if (pair === undefined) {
844
+ const [nodeA, nodeB] = fromNodeId < toNodeId ? [fromNodeId, toNodeId] : [toNodeId, fromNodeId];
845
+ pair = { pairKey, nodeA, nodeB };
846
+ pairs.set(pairKey, pair);
847
+ }
848
+ const isFromA = fromNodeId === pair.nodeA;
849
+ if (isFromA && pair.edgeAB) return; // existing (Matter) edge wins
850
+ if (!isFromA && pair.edgeBA) return;
851
+ const signalLevel = getSignalLevelFromLqi(lqi);
852
+ const edge: ThreadConnection = {
853
+ fromNodeId,
854
+ toNodeId,
855
+ signalLevel,
856
+ lqi,
857
+ rssi: null,
858
+ pathCost,
859
+ fromRouteTable: true,
860
+ };
861
+ if (isFromA) pair.edgeAB = edge;
862
+ else pair.edgeBA = edge;
863
+ };
864
+
865
+ for (const batch of batches.values()) {
866
+ for (const node of batch.nodes) {
867
+ if (node.rloc16 === undefined) continue;
868
+ const xp = batch.extPanIdHex;
869
+ const fromId = resolveRloc16(node.rloc16, xp) ?? diagnosticNodeId(node, xp);
870
+ const routerId = (node.rloc16 >> 10) & 0x3f;
871
+ if (node.route64 !== undefined) {
872
+ for (const e of node.route64.entries) {
873
+ if (e.routerId === routerId) continue;
874
+ const toId = resolveRloc16((e.routerId << 10) & 0xffff, xp);
875
+ if (toId === undefined) continue;
876
+ addEdge(fromId, toId, e.linkQualityIn, e.routeCost);
877
+ }
878
+ }
879
+ if (node.childTable !== undefined) {
880
+ for (const child of node.childTable) {
881
+ const childRloc16 = ((routerId << 10) | child.childId) & 0xffff;
882
+ // Only link children that are themselves a real graph node (a
883
+ // commissioned Matter device). Non-Matter leaves are a count on
884
+ // the parent, not floating nodes — so don't invent an edge.
885
+ const toId = resolveRloc16(childRloc16, xp);
886
+ if (toId === undefined) continue;
887
+ addEdge(fromId, toId, child.incomingLinkQuality);
888
+ }
889
+ }
890
+ }
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Parses WiFi diagnostics from a node's attributes.
896
+ * Cluster 0x36/54 - WiFi Network Diagnostics.
897
+ */
898
+ export function getWiFiDiagnostics(node: TopologySourceNode): WiFiDiagnostics {
899
+ // BSSID is attribute 0/54/0, stored as base64
900
+ const bssidRaw = node.attributes["0/54/0"] as string | undefined;
901
+ let bssid: string | null = null;
902
+ if (bssidRaw) {
903
+ try {
904
+ const binary = atob(bssidRaw);
905
+ bssid = Array.from(binary)
906
+ .map(c => c.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase())
907
+ .join(":");
908
+ } catch {
909
+ bssid = null;
910
+ }
911
+ }
912
+
913
+ // RSSI is attribute 0/54/4
914
+ const rssi = node.attributes["0/54/4"] as number | null | undefined;
915
+
916
+ // Channel is attribute 0/54/3
917
+ const channel = node.attributes["0/54/3"] as number | null | undefined;
918
+
919
+ // Security type is attribute 0/54/1
920
+ const securityType = node.attributes["0/54/1"] as number | null | undefined;
921
+
922
+ // WiFi version is attribute 0/54/2
923
+ const wifiVersion = node.attributes["0/54/2"] as number | null | undefined;
924
+
925
+ return {
926
+ bssid: bssid,
927
+ rssi: rssi ?? null,
928
+ channel: channel ?? null,
929
+ securityType: securityType ?? null,
930
+ wifiVersion: wifiVersion ?? null,
931
+ };
932
+ }