@matter-server/dashboard 0.3.2 → 0.3.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 (104) hide show
  1. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
  2. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
  3. package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
  4. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
  5. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
  6. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
  7. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
  8. package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
  9. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  10. package/dist/esm/pages/cluster-commands/index.js +1 -0
  11. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  12. package/dist/esm/pages/components/footer.d.ts.map +1 -1
  13. package/dist/esm/pages/components/footer.js +4 -7
  14. package/dist/esm/pages/components/footer.js.map +1 -1
  15. package/dist/esm/pages/components/header.d.ts +5 -0
  16. package/dist/esm/pages/components/header.d.ts.map +1 -1
  17. package/dist/esm/pages/components/header.js +75 -0
  18. package/dist/esm/pages/components/header.js.map +1 -1
  19. package/dist/esm/pages/components/node-details.js +1 -1
  20. package/dist/esm/pages/components/node-details.js.map +1 -1
  21. package/dist/esm/pages/components/server-details.d.ts.map +1 -1
  22. package/dist/esm/pages/components/server-details.js +0 -1
  23. package/dist/esm/pages/components/server-details.js.map +1 -1
  24. package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
  25. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  26. package/dist/esm/pages/matter-dashboard-app.js +84 -4
  27. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  28. package/dist/esm/pages/matter-network-view.d.ts +52 -0
  29. package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
  30. package/dist/esm/pages/matter-network-view.js +309 -0
  31. package/dist/esm/pages/matter-network-view.js.map +6 -0
  32. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  33. package/dist/esm/pages/matter-node-view.js +70 -1
  34. package/dist/esm/pages/matter-node-view.js.map +1 -1
  35. package/dist/esm/pages/matter-server-view.d.ts +4 -0
  36. package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
  37. package/dist/esm/pages/matter-server-view.js +16 -1
  38. package/dist/esm/pages/matter-server-view.js.map +1 -1
  39. package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
  40. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
  41. package/dist/esm/pages/network/base-network-graph.js +403 -0
  42. package/dist/esm/pages/network/base-network-graph.js.map +6 -0
  43. package/dist/esm/pages/network/device-icons.d.ts +52 -0
  44. package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
  45. package/dist/esm/pages/network/device-icons.js +197 -0
  46. package/dist/esm/pages/network/device-icons.js.map +6 -0
  47. package/dist/esm/pages/network/device-panel.d.ts +31 -0
  48. package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
  49. package/dist/esm/pages/network/device-panel.js +183 -0
  50. package/dist/esm/pages/network/device-panel.js.map +6 -0
  51. package/dist/esm/pages/network/network-details.d.ts +47 -0
  52. package/dist/esm/pages/network/network-details.d.ts.map +1 -0
  53. package/dist/esm/pages/network/network-details.js +686 -0
  54. package/dist/esm/pages/network/network-details.js.map +6 -0
  55. package/dist/esm/pages/network/network-types.d.ts +153 -0
  56. package/dist/esm/pages/network/network-types.d.ts.map +1 -0
  57. package/dist/esm/pages/network/network-types.js +19 -0
  58. package/dist/esm/pages/network/network-types.js.map +6 -0
  59. package/dist/esm/pages/network/network-utils.d.ts +170 -0
  60. package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
  61. package/dist/esm/pages/network/network-utils.js +472 -0
  62. package/dist/esm/pages/network/network-utils.js.map +6 -0
  63. package/dist/esm/pages/network/thread-graph.d.ts +27 -0
  64. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
  65. package/dist/esm/pages/network/thread-graph.js +134 -0
  66. package/dist/esm/pages/network/thread-graph.js.map +6 -0
  67. package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
  68. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
  69. package/dist/esm/pages/network/wifi-graph.js +167 -0
  70. package/dist/esm/pages/network/wifi-graph.js.map +6 -0
  71. package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-B1_khzZb.js} +5 -5
  72. package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-RpdajrwF.js} +2 -5
  73. package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-5f2itkTG.js} +2 -5
  74. package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-DZ_pWqsa.js} +2 -5
  75. package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-DEUxM4B1.js} +2 -2
  76. package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-BczBMT8E.js} +1 -1
  77. package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Cr3PfX1X.js} +2 -3
  78. package/dist/web/js/main.js +1 -1
  79. package/dist/web/js/matter-dashboard-app-BuCe_Jxf.js +29990 -0
  80. package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-DMiHNDLA.js} +2 -4
  81. package/dist/web/js/{prevent_default-BPgSQsuY.js → prevent_default-D4FX_PIh.js} +2 -42
  82. package/package.json +5 -4
  83. package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
  84. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
  85. package/src/pages/cluster-commands/index.ts +1 -0
  86. package/src/pages/components/footer.ts +4 -7
  87. package/src/pages/components/header.ts +81 -0
  88. package/src/pages/components/node-details.ts +2 -2
  89. package/src/pages/components/server-details.ts +0 -1
  90. package/src/pages/matter-dashboard-app.ts +105 -5
  91. package/src/pages/matter-network-view.ts +325 -0
  92. package/src/pages/matter-node-view.ts +75 -1
  93. package/src/pages/matter-server-view.ts +17 -1
  94. package/src/pages/network/base-network-graph.ts +463 -0
  95. package/src/pages/network/device-icons.ts +283 -0
  96. package/src/pages/network/device-panel.ts +180 -0
  97. package/src/pages/network/network-details.ts +750 -0
  98. package/src/pages/network/network-types.ts +161 -0
  99. package/src/pages/network/network-utils.ts +752 -0
  100. package/src/pages/network/thread-graph.ts +164 -0
  101. package/src/pages/network/wifi-graph.ts +192 -0
  102. package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
  103. package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
  104. package/dist/web/js/validator-C735j770.js +0 -1122
@@ -0,0 +1,752 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterNode } from "@matter-server/ws-client";
8
+ import type {
9
+ CategorizedDevices,
10
+ NetworkType,
11
+ ThreadConnection,
12
+ ThreadNeighbor,
13
+ ThreadRoute,
14
+ UnknownThreadDevice,
15
+ } from "./network-types.js";
16
+
17
+ // NetworkCommissioning cluster feature map bits (cluster 0x31/49)
18
+ const WIFI_FEATURE = 1 << 0; // Bit 0: WiFi Network Interface
19
+ const THREAD_FEATURE = 1 << 1; // Bit 1: Thread Network Interface
20
+ const ETHERNET_FEATURE = 1 << 2; // Bit 2: Ethernet Network Interface
21
+
22
+ // Signal strength thresholds (dBm)
23
+ const SIGNAL_STRONG_THRESHOLD = -70;
24
+ const SIGNAL_MEDIUM_THRESHOLD = -85;
25
+
26
+ // LQI thresholds (0-255)
27
+ const LQI_STRONG_THRESHOLD = 200;
28
+ const LQI_MEDIUM_THRESHOLD = 100;
29
+
30
+ // Signal colors
31
+ const SIGNAL_COLOR_STRONG = "#4caf50"; // Green
32
+ const SIGNAL_COLOR_MEDIUM = "#ff9800"; // Orange
33
+ const SIGNAL_COLOR_WEAK = "#f44336"; // Red
34
+
35
+ /**
36
+ * WiFi Diagnostics info from cluster 0x36/54.
37
+ */
38
+ export interface WiFiDiagnostics {
39
+ /** BSSID as hex string */
40
+ bssid: string | null;
41
+ /** RSSI in dBm (-120 to 0) */
42
+ rssi: number | null;
43
+ /** WiFi channel */
44
+ channel: number | null;
45
+ /** Security type */
46
+ securityType: number | null;
47
+ /** WiFi version */
48
+ wifiVersion: number | null;
49
+ }
50
+
51
+ /**
52
+ * Converts a base64-encoded extended address to BigInt.
53
+ * Extended addresses are 8 bytes (64 bits) stored as big-endian.
54
+ * Some Matter implementations include a TLV prefix byte that we need to skip.
55
+ */
56
+ function base64ToBigInt(base64: string): bigint {
57
+ try {
58
+ const binary = atob(base64);
59
+ let result = 0n;
60
+
61
+ // If we have 9 bytes, skip the first byte (likely a TLV type prefix)
62
+ // EUI-64 should be exactly 8 bytes
63
+ const start = binary.length > 8 ? binary.length - 8 : 0;
64
+
65
+ for (let i = start; i < binary.length; i++) {
66
+ result = (result << 8n) | BigInt(binary.charCodeAt(i));
67
+ }
68
+ return result;
69
+ } catch {
70
+ return 0n;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Normalizes an extended address to BigInt for comparison.
76
+ * Handles: BigInt, base64 strings, numbers.
77
+ */
78
+ function normalizeExtAddress(value: unknown): bigint {
79
+ if (typeof value === "bigint") {
80
+ return value;
81
+ }
82
+ if (typeof value === "string") {
83
+ return base64ToBigInt(value);
84
+ }
85
+ if (typeof value === "number") {
86
+ return BigInt(value);
87
+ }
88
+ return 0n;
89
+ }
90
+
91
+ /**
92
+ * Detects the network type from the NetworkCommissioning cluster feature map.
93
+ * Uses attribute 0/49/65532 (FeatureMap).
94
+ */
95
+ export function getNetworkType(node: MatterNode): NetworkType {
96
+ const featureMap = node.attributes["0/49/65532"] as number | undefined;
97
+
98
+ if (featureMap === undefined) {
99
+ return "unknown";
100
+ }
101
+
102
+ // Check in priority order: Thread > WiFi > Ethernet
103
+ if (featureMap & THREAD_FEATURE) {
104
+ return "thread";
105
+ }
106
+ if (featureMap & WIFI_FEATURE) {
107
+ return "wifi";
108
+ }
109
+ if (featureMap & ETHERNET_FEATURE) {
110
+ return "ethernet";
111
+ }
112
+
113
+ return "unknown";
114
+ }
115
+
116
+ /**
117
+ * Categorizes nodes by their network type.
118
+ * Node IDs are stored as strings to avoid BigInt precision loss.
119
+ */
120
+ export function categorizeDevices(nodes: Record<string, MatterNode>): CategorizedDevices {
121
+ const result: CategorizedDevices = {
122
+ thread: [],
123
+ wifi: [],
124
+ ethernet: [],
125
+ unknown: [],
126
+ };
127
+
128
+ for (const node of Object.values(nodes)) {
129
+ const nodeId = String(node.node_id);
130
+ const networkType = getNetworkType(node);
131
+ result[networkType].push(nodeId);
132
+ }
133
+
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Gets the Thread routing role for a node.
139
+ * Uses attribute 0/53/1 (RoutingRole).
140
+ */
141
+ export function getThreadRole(node: MatterNode): number | undefined {
142
+ return node.attributes["0/53/1"] as number | undefined;
143
+ }
144
+
145
+ /**
146
+ * Gets the Thread channel for a node.
147
+ * Uses attribute 0/53/0 (Channel).
148
+ */
149
+ export function getThreadChannel(node: MatterNode): number | undefined {
150
+ return node.attributes["0/53/0"] as number | undefined;
151
+ }
152
+
153
+ /**
154
+ * Gets the Thread extended PAN ID for a node.
155
+ * Uses attribute 0/53/4 (ExtendedPanId).
156
+ */
157
+ export function getThreadExtendedPanId(node: MatterNode): bigint | undefined {
158
+ return node.attributes["0/53/4"] as bigint | undefined;
159
+ }
160
+
161
+ /**
162
+ * Gets the Thread extended address (EUI-64) for a node.
163
+ *
164
+ * Uses General Diagnostics cluster (0x0033/51) NetworkInterfaces attribute (0/51/0).
165
+ * The NetworkInterface struct has:
166
+ * - Field 4: HardwareAddress (base64 encoded EUI-64)
167
+ * - Field 7: Type (4 = Thread)
168
+ *
169
+ * Returns as BigInt. Only upper 48 bits should be used for matching due to JSON precision loss.
170
+ */
171
+ export function getThreadExtendedAddress(node: MatterNode): bigint | undefined {
172
+ // Get NetworkInterfaces from General Diagnostics cluster (0/51/0)
173
+ const networkInterfaces = node.attributes["0/51/0"] as Array<Record<string, unknown>> | undefined;
174
+
175
+ if (!Array.isArray(networkInterfaces) || networkInterfaces.length === 0) {
176
+ return undefined;
177
+ }
178
+
179
+ // Find Thread interface (type 7 field = 4) or use first with hardware address
180
+ const threadIface = networkInterfaces.find(i => i["7"] === 4) || networkInterfaces[0];
181
+
182
+ if (!threadIface) {
183
+ return undefined;
184
+ }
185
+
186
+ // HardwareAddress is field 4, base64 encoded
187
+ const hwAddrB64 = threadIface["4"];
188
+
189
+ if (typeof hwAddrB64 !== "string" || !hwAddrB64) {
190
+ return undefined;
191
+ }
192
+
193
+ // Decode base64 to get EUI-64
194
+ const extAddr = base64ToBigInt(hwAddrB64);
195
+ return extAddr !== 0n ? extAddr : undefined;
196
+ }
197
+
198
+ /**
199
+ * Gets the Thread extended address as a hex string for display.
200
+ * Uses General Diagnostics NetworkInterfaces (0/51/0).
201
+ */
202
+ export function getThreadExtendedAddressHex(node: MatterNode): string | undefined {
203
+ const extAddr = getThreadExtendedAddress(node);
204
+ if (extAddr !== undefined) {
205
+ return extAddr.toString(16).padStart(16, "0").toUpperCase();
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ /**
211
+ * Parses the Thread neighbor table from a node's attributes.
212
+ * Attribute 0/53/7 (NeighborTable) is an array of neighbor objects.
213
+ * The data uses numeric keys matching the Matter spec field IDs.
214
+ */
215
+ export function parseNeighborTable(node: MatterNode): ThreadNeighbor[] {
216
+ const neighborTable = node.attributes["0/53/7"];
217
+
218
+ if (!Array.isArray(neighborTable)) {
219
+ return [];
220
+ }
221
+
222
+ return neighborTable.map((entry: Record<string, unknown>) => {
223
+ // Field 0: extAddress - can be BigInt or base64 string
224
+ const rawExtAddr = entry["0"] ?? entry.extAddress;
225
+ const extAddress = normalizeExtAddress(rawExtAddr);
226
+
227
+ return {
228
+ extAddress,
229
+ // Field 1: age
230
+ age: (entry["1"] ?? entry.age ?? 0) as number,
231
+ // Field 2: rloc16
232
+ rloc16: (entry["2"] ?? entry.rloc16 ?? 0) as number,
233
+ // Field 3: linkFrameCounter
234
+ linkFrameCounter: (entry["3"] ?? entry.linkFrameCounter ?? 0) as number,
235
+ // Field 4: mleFrameCounter
236
+ mleFrameCounter: (entry["4"] ?? entry.mleFrameCounter ?? 0) as number,
237
+ // Field 5: lqi
238
+ lqi: (entry["5"] ?? entry.lqi ?? 0) as number,
239
+ // Field 6: averageRssi (nullable)
240
+ avgRssi: (entry["6"] ?? entry.averageRssi ?? null) as number | null,
241
+ // Field 7: lastRssi (nullable)
242
+ lastRssi: (entry["7"] ?? entry.lastRssi ?? null) as number | null,
243
+ // Field 8: frameErrorRate
244
+ frameErrorRate: (entry["8"] ?? entry.frameErrorRate ?? 0) as number,
245
+ // Field 9: messageErrorRate
246
+ messageErrorRate: (entry["9"] ?? entry.messageErrorRate ?? 0) as number,
247
+ // Field 10: rxOnWhenIdle
248
+ rxOnWhenIdle: (entry["10"] ?? entry.rxOnWhenIdle ?? false) as boolean,
249
+ // Field 11: fullThreadDevice
250
+ fullThreadDevice: (entry["11"] ?? entry.fullThreadDevice ?? false) as boolean,
251
+ // Field 12: fullNetworkData
252
+ fullNetworkData: (entry["12"] ?? entry.fullNetworkData ?? false) as boolean,
253
+ // Field 13: isChild
254
+ isChild: (entry["13"] ?? entry.isChild ?? false) as boolean,
255
+ };
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Parses the Thread route table from a node's attributes.
261
+ * Attribute 0/53/8 (RouteTable) is an array of route objects.
262
+ * The data uses numeric keys matching the Matter spec field IDs.
263
+ */
264
+ export function parseRouteTable(node: MatterNode): ThreadRoute[] {
265
+ const routeTable = node.attributes["0/53/8"];
266
+
267
+ if (!Array.isArray(routeTable)) {
268
+ return [];
269
+ }
270
+
271
+ return routeTable.map((entry: Record<string, unknown>) => {
272
+ // Field 0: extAddress - can be BigInt or base64 string
273
+ const rawExtAddr = entry["0"] ?? entry.extAddress;
274
+ const extAddress = normalizeExtAddress(rawExtAddr);
275
+
276
+ return {
277
+ extAddress,
278
+ // Field 1: rloc16
279
+ rloc16: (entry["1"] ?? entry.rloc16 ?? 0) as number,
280
+ // Field 2: routerId
281
+ routerId: (entry["2"] ?? entry.routerId ?? 0) as number,
282
+ // Field 3: nextHop
283
+ nextHop: (entry["3"] ?? entry.nextHop ?? 0) as number,
284
+ // Field 4: pathCost
285
+ pathCost: (entry["4"] ?? entry.pathCost ?? 0) as number,
286
+ // Field 5: lqiIn
287
+ lqiIn: (entry["5"] ?? entry.lqiIn ?? 0) as number,
288
+ // Field 6: lqiOut
289
+ lqiOut: (entry["6"] ?? entry.lqiOut ?? 0) as number,
290
+ // Field 7: age
291
+ age: (entry["7"] ?? entry.age ?? 0) as number,
292
+ // Field 8: allocated
293
+ allocated: (entry["8"] ?? entry.allocated ?? false) as boolean,
294
+ // Field 9: linkEstablished
295
+ linkEstablished: (entry["9"] ?? entry.linkEstablished ?? false) as boolean,
296
+ };
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Gets the RLOC16 (short address) for a Thread node.
302
+ * Uses attribute 0/53/64 (Rloc16, 0x0040).
303
+ */
304
+ export function getThreadRloc16(node: MatterNode): number | undefined {
305
+ const value = node.attributes["0/53/64"];
306
+ if (typeof value === "number") {
307
+ return value;
308
+ }
309
+ return undefined;
310
+ }
311
+
312
+ /**
313
+ * Builds a map of extended addresses (BigInt) to node IDs for Thread devices.
314
+ * Uses General Diagnostics NetworkInterfaces (0/51/0) for the hardware address.
315
+ * Node IDs are stored as strings to avoid BigInt precision loss.
316
+ */
317
+ export function buildExtAddrMap(nodes: Record<string, MatterNode>): Map<bigint, string> {
318
+ const extAddrMap = new Map<bigint, string>();
319
+
320
+ for (const node of Object.values(nodes)) {
321
+ const nodeId = String(node.node_id);
322
+ const extAddr = getThreadExtendedAddress(node);
323
+
324
+ if (extAddr !== undefined) {
325
+ extAddrMap.set(extAddr, nodeId);
326
+ }
327
+ }
328
+
329
+ return extAddrMap;
330
+ }
331
+
332
+ /**
333
+ * Builds a map of RLOC16 (short addresses) to node IDs for Thread devices.
334
+ * Used as fallback when ExtAddress is not available.
335
+ * Node IDs are stored as strings to avoid BigInt precision loss.
336
+ */
337
+ export function buildRloc16Map(nodes: Record<string, MatterNode>): Map<number, string> {
338
+ const rloc16Map = new Map<number, string>();
339
+
340
+ for (const node of Object.values(nodes)) {
341
+ const nodeId = String(node.node_id);
342
+ const rloc16 = getThreadRloc16(node);
343
+
344
+ if (rloc16 !== undefined) {
345
+ rloc16Map.set(rloc16, nodeId);
346
+ }
347
+ }
348
+
349
+ return rloc16Map;
350
+ }
351
+
352
+ /**
353
+ * Finds unknown Thread devices - addresses seen in neighbor tables
354
+ * that don't match any known commissioned device.
355
+ * These are typically Thread Border Routers or devices from other ecosystems.
356
+ */
357
+ export function findUnknownDevices(
358
+ nodes: Record<string, MatterNode>,
359
+ extAddrMap: Map<bigint, string>,
360
+ ): UnknownThreadDevice[] {
361
+ const unknownMap = new Map<string, UnknownThreadDevice>();
362
+
363
+ for (const node of Object.values(nodes)) {
364
+ const nodeId = String(node.node_id);
365
+ const neighbors = parseNeighborTable(node);
366
+
367
+ for (const neighbor of neighbors) {
368
+ // Check if this neighbor is in our known devices
369
+ if (extAddrMap.has(neighbor.extAddress)) {
370
+ continue;
371
+ }
372
+
373
+ const extAddressHex = neighbor.extAddress.toString(16).padStart(16, "0").toUpperCase();
374
+ const id = `unknown_${extAddressHex}`;
375
+
376
+ if (!unknownMap.has(id)) {
377
+ unknownMap.set(id, {
378
+ id,
379
+ extAddressHex,
380
+ extAddress: neighbor.extAddress,
381
+ seenBy: [],
382
+ isRouter: false,
383
+ bestRssi: null,
384
+ });
385
+ }
386
+
387
+ const unknown = unknownMap.get(id)!;
388
+
389
+ // Add this node to seenBy if not already there
390
+ if (!unknown.seenBy.includes(nodeId)) {
391
+ unknown.seenBy.push(nodeId);
392
+ }
393
+
394
+ // Update router status (field 10 = rxOnWhenIdle, indicates router-like behavior)
395
+ if (neighbor.rxOnWhenIdle) {
396
+ unknown.isRouter = true;
397
+ }
398
+
399
+ // Track best signal
400
+ const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
401
+ if (rssi !== null && (unknown.bestRssi === null || rssi > unknown.bestRssi)) {
402
+ unknown.bestRssi = rssi;
403
+ }
404
+ }
405
+ }
406
+
407
+ return Array.from(unknownMap.values());
408
+ }
409
+
410
+ /**
411
+ * Gets the signal color based on RSSI or LQI values.
412
+ * Green: Strong signal
413
+ * Orange: Medium signal
414
+ * Red: Weak signal
415
+ */
416
+ export function getSignalColor(neighbor: ThreadNeighbor): string {
417
+ // Prefer RSSI if available
418
+ const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
419
+
420
+ if (rssi !== null) {
421
+ if (rssi > SIGNAL_STRONG_THRESHOLD) {
422
+ return SIGNAL_COLOR_STRONG;
423
+ }
424
+ if (rssi > SIGNAL_MEDIUM_THRESHOLD) {
425
+ return SIGNAL_COLOR_MEDIUM;
426
+ }
427
+ return SIGNAL_COLOR_WEAK;
428
+ }
429
+
430
+ // Fallback to LQI (0-255, higher is better)
431
+ if (neighbor.lqi > LQI_STRONG_THRESHOLD) {
432
+ return SIGNAL_COLOR_STRONG;
433
+ }
434
+ if (neighbor.lqi > LQI_MEDIUM_THRESHOLD) {
435
+ return SIGNAL_COLOR_MEDIUM;
436
+ }
437
+ return SIGNAL_COLOR_WEAK;
438
+ }
439
+
440
+ /**
441
+ * Gets a human-readable display name for a node.
442
+ * Format: nodeLabel || productName (serialNumber)
443
+ */
444
+ export function getDeviceName(node: MatterNode): string {
445
+ if (node.nodeLabel) {
446
+ return node.nodeLabel;
447
+ }
448
+
449
+ const productName = node.productName || "Unknown Device";
450
+ const serialNumber = node.serialNumber;
451
+
452
+ if (serialNumber) {
453
+ return `${productName} (${serialNumber})`;
454
+ }
455
+
456
+ return productName;
457
+ }
458
+
459
+ /**
460
+ * Gets the human-readable name for a Thread routing role.
461
+ */
462
+ export function getThreadRoleName(role: number | undefined): string {
463
+ switch (role) {
464
+ case 0:
465
+ return "Unspecified";
466
+ case 1:
467
+ return "Unassigned";
468
+ case 2:
469
+ return "Sleepy End Device";
470
+ case 3:
471
+ return "End Device";
472
+ case 4:
473
+ return "REED";
474
+ case 5:
475
+ return "Router";
476
+ case 6:
477
+ return "Leader";
478
+ default:
479
+ return "Unknown";
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Parses WiFi diagnostics from a node's attributes.
485
+ * Cluster 0x36/54 - WiFi Network Diagnostics.
486
+ */
487
+ export function getWiFiDiagnostics(node: MatterNode): WiFiDiagnostics {
488
+ // BSSID is attribute 0/54/0, stored as base64
489
+ const bssidRaw = node.attributes["0/54/0"] as string | undefined;
490
+ let bssid: string | null = null;
491
+ if (bssidRaw) {
492
+ try {
493
+ const binary = atob(bssidRaw);
494
+ bssid = Array.from(binary)
495
+ .map(c => c.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase())
496
+ .join(":");
497
+ } catch {
498
+ bssid = null;
499
+ }
500
+ }
501
+
502
+ // RSSI is attribute 0/54/4
503
+ const rssi = node.attributes["0/54/4"] as number | null | undefined;
504
+
505
+ // Channel is attribute 0/54/3
506
+ const channel = node.attributes["0/54/3"] as number | null | undefined;
507
+
508
+ // Security type is attribute 0/54/1
509
+ const securityType = node.attributes["0/54/1"] as number | null | undefined;
510
+
511
+ // WiFi version is attribute 0/54/2
512
+ const wifiVersion = node.attributes["0/54/2"] as number | null | undefined;
513
+
514
+ return {
515
+ bssid: bssid,
516
+ rssi: rssi ?? null,
517
+ channel: channel ?? null,
518
+ securityType: securityType ?? null,
519
+ wifiVersion: wifiVersion ?? null,
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Gets the signal color for a given RSSI value.
525
+ */
526
+ export function getSignalColorFromRssi(rssi: number | null): string {
527
+ if (rssi === null) {
528
+ return SIGNAL_COLOR_MEDIUM; // Default to medium if unknown
529
+ }
530
+ if (rssi > SIGNAL_STRONG_THRESHOLD) {
531
+ return SIGNAL_COLOR_STRONG;
532
+ }
533
+ if (rssi > SIGNAL_MEDIUM_THRESHOLD) {
534
+ return SIGNAL_COLOR_MEDIUM;
535
+ }
536
+ return SIGNAL_COLOR_WEAK;
537
+ }
538
+
539
+ /**
540
+ * Gets WiFi security type name.
541
+ */
542
+ export function getWiFiSecurityTypeName(securityType: number | null): string {
543
+ switch (securityType) {
544
+ case 0:
545
+ return "Unspecified";
546
+ case 1:
547
+ return "None";
548
+ case 2:
549
+ return "WEP";
550
+ case 3:
551
+ return "WPA Personal";
552
+ case 4:
553
+ return "WPA2 Personal";
554
+ case 5:
555
+ return "WPA3 Personal";
556
+ default:
557
+ return "Unknown";
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Gets WiFi version name.
563
+ */
564
+ export function getWiFiVersionName(version: number | null): string {
565
+ switch (version) {
566
+ case 0:
567
+ return "802.11a";
568
+ case 1:
569
+ return "802.11b";
570
+ case 2:
571
+ return "802.11g";
572
+ case 3:
573
+ return "802.11n";
574
+ case 4:
575
+ return "802.11ac";
576
+ case 5:
577
+ return "802.11ax";
578
+ case 6:
579
+ return "802.11ah";
580
+ default:
581
+ return "Unknown";
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Builds Thread mesh connections from neighbor tables.
587
+ * Returns connections with signal information.
588
+ * Includes connections to unknown devices (prefixed with 'unknown_').
589
+ */
590
+ export function buildThreadConnections(
591
+ nodes: Record<string, MatterNode>,
592
+ extAddrMap: Map<bigint, string>,
593
+ unknownDevices: UnknownThreadDevice[],
594
+ ): ThreadConnection[] {
595
+ const connections: ThreadConnection[] = [];
596
+ const seenConnections = new Set<string>();
597
+
598
+ // Build map of unknown device extAddress -> id
599
+ const unknownExtAddrMap = new Map<bigint, string>();
600
+ for (const unknown of unknownDevices) {
601
+ unknownExtAddrMap.set(unknown.extAddress, unknown.id);
602
+ }
603
+
604
+ for (const node of Object.values(nodes)) {
605
+ const fromNodeId = String(node.node_id);
606
+ const neighbors = parseNeighborTable(node);
607
+
608
+ for (const neighbor of neighbors) {
609
+ // Try to find in known devices first
610
+ let toNodeId: string | undefined = extAddrMap.get(neighbor.extAddress);
611
+
612
+ // If not found, check unknown devices
613
+ if (toNodeId === undefined) {
614
+ toNodeId = unknownExtAddrMap.get(neighbor.extAddress);
615
+ }
616
+
617
+ if (toNodeId === undefined) {
618
+ // Should not happen if unknownDevices was built correctly
619
+ continue;
620
+ }
621
+
622
+ // Skip self-connections
623
+ if (fromNodeId === toNodeId) {
624
+ continue;
625
+ }
626
+
627
+ // Create a unique key for this connection
628
+ const connectionKey = `${fromNodeId}-${toNodeId}`;
629
+ const reverseKey = `${toNodeId}-${fromNodeId}`;
630
+
631
+ if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
632
+ // Already have this connection
633
+ continue;
634
+ }
635
+ seenConnections.add(connectionKey);
636
+
637
+ connections.push({
638
+ fromNodeId,
639
+ toNodeId,
640
+ signalColor: getSignalColor(neighbor),
641
+ lqi: neighbor.lqi,
642
+ rssi: neighbor.avgRssi ?? neighbor.lastRssi,
643
+ });
644
+ }
645
+ }
646
+
647
+ return connections;
648
+ }
649
+
650
+ /**
651
+ * Represents a connection from the perspective of a specific node.
652
+ * Includes both neighbors this node reports AND nodes that report this node as their neighbor.
653
+ */
654
+ export interface NodeConnection {
655
+ /** The connected node ID (number for known nodes, string for unknown devices) */
656
+ connectedNodeId: number | string;
657
+ /** The connected MatterNode if it's a known device */
658
+ connectedNode?: MatterNode;
659
+ /** Extended address hex string for display */
660
+ extAddressHex: string;
661
+ /** Signal strength info (if available) */
662
+ signalColor: string;
663
+ lqi: number | null;
664
+ rssi: number | null;
665
+ /** Whether this connection is from THIS node's neighbor table (true) or from the OTHER node's table (false) */
666
+ isOutgoing: boolean;
667
+ /** Whether this is an unknown/external device */
668
+ isUnknown: boolean;
669
+ }
670
+
671
+ /**
672
+ * Get all connections for a specific node (bidirectional).
673
+ * This includes:
674
+ * 1. Neighbors this node reports in its neighbor table (outgoing)
675
+ * 2. Nodes that report this node as their neighbor (incoming)
676
+ *
677
+ * Returns a deduplicated list - if both directions exist, only the outgoing one is included
678
+ * (since that has signal data from THIS node's perspective).
679
+ *
680
+ * @param nodeId - Node ID as string to avoid BigInt precision loss
681
+ */
682
+ export function getNodeConnections(
683
+ nodeId: string,
684
+ nodes: Record<string, MatterNode>,
685
+ extAddrMap: Map<bigint, string>,
686
+ ): NodeConnection[] {
687
+ const connections: NodeConnection[] = [];
688
+ const seenConnectedIds = new Set<string>();
689
+
690
+ const node = nodes[nodeId];
691
+ if (!node) return connections;
692
+
693
+ // Get this node's extended address for reverse lookups (from General Diagnostics, not Thread Diagnostics)
694
+ const thisExtAddr = getThreadExtendedAddress(node);
695
+
696
+ // 1. Add neighbors this node reports (outgoing connections)
697
+ const neighbors = parseNeighborTable(node);
698
+ for (const neighbor of neighbors) {
699
+ const connectedNodeId = extAddrMap.get(neighbor.extAddress);
700
+ const connectedNode = connectedNodeId ? nodes[connectedNodeId] : undefined;
701
+ const isUnknown = connectedNodeId === undefined;
702
+ const displayId = isUnknown
703
+ ? `unknown_${neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0")}`
704
+ : connectedNodeId;
705
+
706
+ seenConnectedIds.add(displayId);
707
+
708
+ connections.push({
709
+ connectedNodeId: displayId,
710
+ connectedNode,
711
+ extAddressHex: neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0"),
712
+ signalColor: getSignalColor(neighbor),
713
+ lqi: neighbor.lqi,
714
+ rssi: neighbor.avgRssi ?? neighbor.lastRssi,
715
+ isOutgoing: true,
716
+ isUnknown,
717
+ });
718
+ }
719
+
720
+ // 2. Find nodes that report THIS node as their neighbor (incoming connections)
721
+ if (thisExtAddr !== undefined) {
722
+ for (const otherNode of Object.values(nodes)) {
723
+ const otherNodeId = String(otherNode.node_id);
724
+ if (otherNodeId === nodeId) continue; // Skip self
725
+
726
+ // Check if already connected via outgoing
727
+ if (seenConnectedIds.has(otherNodeId)) continue;
728
+
729
+ // Check if other node reports this node as neighbor
730
+ const otherNeighbors = parseNeighborTable(otherNode);
731
+ const reverseEntry = otherNeighbors.find(n => n.extAddress === thisExtAddr);
732
+
733
+ if (reverseEntry) {
734
+ const otherExtAddr = getThreadExtendedAddress(otherNode);
735
+ const extAddrHex = otherExtAddr ? otherExtAddr.toString(16).toUpperCase().padStart(16, "0") : "Unknown";
736
+
737
+ connections.push({
738
+ connectedNodeId: otherNodeId,
739
+ connectedNode: otherNode,
740
+ extAddressHex: extAddrHex,
741
+ signalColor: getSignalColor(reverseEntry),
742
+ lqi: reverseEntry.lqi,
743
+ rssi: reverseEntry.avgRssi ?? reverseEntry.lastRssi,
744
+ isOutgoing: false,
745
+ isUnknown: false,
746
+ });
747
+ }
748
+ }
749
+ }
750
+
751
+ return connections;
752
+ }