@matter-server/dashboard 0.3.2 → 0.3.4

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 (124) hide show
  1. package/README.md +76 -0
  2. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
  3. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
  4. package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
  5. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
  6. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
  7. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
  8. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
  9. package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
  10. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  11. package/dist/esm/pages/cluster-commands/index.js +1 -0
  12. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  13. package/dist/esm/pages/components/footer.d.ts.map +1 -1
  14. package/dist/esm/pages/components/footer.js +4 -7
  15. package/dist/esm/pages/components/footer.js.map +1 -1
  16. package/dist/esm/pages/components/header.d.ts +5 -0
  17. package/dist/esm/pages/components/header.d.ts.map +1 -1
  18. package/dist/esm/pages/components/header.js +75 -0
  19. package/dist/esm/pages/components/header.js.map +1 -1
  20. package/dist/esm/pages/components/node-details.js +2 -2
  21. package/dist/esm/pages/components/node-details.js.map +1 -1
  22. package/dist/esm/pages/components/server-details.d.ts.map +1 -1
  23. package/dist/esm/pages/components/server-details.js +0 -1
  24. package/dist/esm/pages/components/server-details.js.map +1 -1
  25. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  26. package/dist/esm/pages/matter-cluster-view.js +9 -4
  27. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  28. package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
  29. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  30. package/dist/esm/pages/matter-dashboard-app.js +84 -4
  31. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  32. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  33. package/dist/esm/pages/matter-endpoint-view.js +8 -2
  34. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  35. package/dist/esm/pages/matter-network-view.d.ts +52 -0
  36. package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
  37. package/dist/esm/pages/matter-network-view.js +309 -0
  38. package/dist/esm/pages/matter-network-view.js.map +6 -0
  39. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  40. package/dist/esm/pages/matter-node-view.js +86 -3
  41. package/dist/esm/pages/matter-node-view.js.map +1 -1
  42. package/dist/esm/pages/matter-server-view.d.ts +4 -0
  43. package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
  44. package/dist/esm/pages/matter-server-view.js +16 -1
  45. package/dist/esm/pages/matter-server-view.js.map +1 -1
  46. package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
  47. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
  48. package/dist/esm/pages/network/base-network-graph.js +411 -0
  49. package/dist/esm/pages/network/base-network-graph.js.map +6 -0
  50. package/dist/esm/pages/network/device-icons.d.ts +52 -0
  51. package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
  52. package/dist/esm/pages/network/device-icons.js +197 -0
  53. package/dist/esm/pages/network/device-icons.js.map +6 -0
  54. package/dist/esm/pages/network/device-panel.d.ts +31 -0
  55. package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
  56. package/dist/esm/pages/network/device-panel.js +183 -0
  57. package/dist/esm/pages/network/device-panel.js.map +6 -0
  58. package/dist/esm/pages/network/network-details.d.ts +77 -0
  59. package/dist/esm/pages/network/network-details.d.ts.map +1 -0
  60. package/dist/esm/pages/network/network-details.js +904 -0
  61. package/dist/esm/pages/network/network-details.js.map +6 -0
  62. package/dist/esm/pages/network/network-types.d.ts +159 -0
  63. package/dist/esm/pages/network/network-types.d.ts.map +1 -0
  64. package/dist/esm/pages/network/network-types.js +19 -0
  65. package/dist/esm/pages/network/network-types.js.map +6 -0
  66. package/dist/esm/pages/network/network-utils.d.ts +196 -0
  67. package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
  68. package/dist/esm/pages/network/network-utils.js +540 -0
  69. package/dist/esm/pages/network/network-utils.js.map +6 -0
  70. package/dist/esm/pages/network/thread-graph.d.ts +27 -0
  71. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
  72. package/dist/esm/pages/network/thread-graph.js +137 -0
  73. package/dist/esm/pages/network/thread-graph.js.map +6 -0
  74. package/dist/esm/pages/network/update-connections-dialog.d.ts +55 -0
  75. package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -0
  76. package/dist/esm/pages/network/update-connections-dialog.js +284 -0
  77. package/dist/esm/pages/network/update-connections-dialog.js.map +6 -0
  78. package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
  79. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
  80. package/dist/esm/pages/network/wifi-graph.js +169 -0
  81. package/dist/esm/pages/network/wifi-graph.js.map +6 -0
  82. package/dist/esm/util/format_hex.d.ts +18 -0
  83. package/dist/esm/util/format_hex.d.ts.map +1 -1
  84. package/dist/esm/util/format_hex.js +21 -1
  85. package/dist/esm/util/format_hex.js.map +1 -1
  86. package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-CcMuttYO.js} +5 -5
  87. package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-CqTRDMAr.js} +2 -5
  88. package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-DgwtTVwK.js} +2 -5
  89. package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-XaN2SEnE.js} +2 -5
  90. package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-COpDD8i7.js} +2 -2
  91. package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-mDYWi2sw.js} +1 -1
  92. package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Bc32kZVw.js} +2 -3
  93. package/dist/web/js/main.js +1 -1
  94. package/dist/web/js/matter-dashboard-app-CrBHT4fT.js +31606 -0
  95. package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-C8fqOJiB.js} +2 -4
  96. package/dist/web/js/prevent_default-D-ohDGsN.js +8 -0
  97. package/package.json +6 -5
  98. package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
  99. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
  100. package/src/pages/cluster-commands/index.ts +1 -0
  101. package/src/pages/components/footer.ts +4 -7
  102. package/src/pages/components/header.ts +81 -0
  103. package/src/pages/components/node-details.ts +3 -3
  104. package/src/pages/components/server-details.ts +0 -1
  105. package/src/pages/matter-cluster-view.ts +11 -4
  106. package/src/pages/matter-dashboard-app.ts +105 -5
  107. package/src/pages/matter-endpoint-view.ts +10 -3
  108. package/src/pages/matter-network-view.ts +325 -0
  109. package/src/pages/matter-node-view.ts +93 -4
  110. package/src/pages/matter-server-view.ts +17 -1
  111. package/src/pages/network/base-network-graph.ts +477 -0
  112. package/src/pages/network/device-icons.ts +283 -0
  113. package/src/pages/network/device-panel.ts +180 -0
  114. package/src/pages/network/network-details.ts +1015 -0
  115. package/src/pages/network/network-types.ts +167 -0
  116. package/src/pages/network/network-utils.ts +861 -0
  117. package/src/pages/network/thread-graph.ts +170 -0
  118. package/src/pages/network/update-connections-dialog.ts +327 -0
  119. package/src/pages/network/wifi-graph.ts +193 -0
  120. package/src/util/format_hex.ts +39 -0
  121. package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
  122. package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
  123. package/dist/web/js/prevent_default-BPgSQsuY.js +0 -814
  124. package/dist/web/js/validator-C735j770.js +0 -1122
@@ -0,0 +1,861 @@
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
+ * Find a route table entry for a specific destination by extended address.
302
+ * Returns the route entry if found, undefined otherwise.
303
+ */
304
+ export function findRouteByExtAddress(node: MatterNode, targetExtAddr: bigint): ThreadRoute | undefined {
305
+ const routes = parseRouteTable(node);
306
+ return routes.find(route => route.extAddress === targetExtAddr && route.linkEstablished);
307
+ }
308
+
309
+ /**
310
+ * Count the number of routable destinations for a node (from route table).
311
+ * Only counts entries where allocated=true and linkEstablished=true.
312
+ * This is typically only meaningful for router nodes.
313
+ */
314
+ export function getRoutableDestinationsCount(node: MatterNode): number {
315
+ const routes = parseRouteTable(node);
316
+ return routes.filter(route => route.allocated && route.linkEstablished).length;
317
+ }
318
+
319
+ /**
320
+ * Calculate combined bidirectional LQI from route table entry.
321
+ * Returns average of lqiIn and lqiOut if both are non-zero.
322
+ */
323
+ export function getRouteBidirectionalLqi(route: ThreadRoute): number | undefined {
324
+ if (route.lqiIn > 0 && route.lqiOut > 0) {
325
+ return Math.round((route.lqiIn + route.lqiOut) / 2);
326
+ }
327
+ if (route.lqiIn > 0) return route.lqiIn;
328
+ if (route.lqiOut > 0) return route.lqiOut;
329
+ return undefined;
330
+ }
331
+
332
+ /**
333
+ * Gets the RLOC16 (short address) for a Thread node.
334
+ * Uses attribute 0/53/64 (Rloc16, 0x0040).
335
+ */
336
+ export function getThreadRloc16(node: MatterNode): number | undefined {
337
+ const value = node.attributes["0/53/64"];
338
+ if (typeof value === "number") {
339
+ return value;
340
+ }
341
+ return undefined;
342
+ }
343
+
344
+ /**
345
+ * Builds a map of extended addresses (BigInt) to node IDs for Thread devices.
346
+ * Uses General Diagnostics NetworkInterfaces (0/51/0) for the hardware address.
347
+ * Node IDs are stored as strings to avoid BigInt precision loss.
348
+ */
349
+ export function buildExtAddrMap(nodes: Record<string, MatterNode>): Map<bigint, string> {
350
+ const extAddrMap = new Map<bigint, string>();
351
+
352
+ for (const node of Object.values(nodes)) {
353
+ const nodeId = String(node.node_id);
354
+ const extAddr = getThreadExtendedAddress(node);
355
+
356
+ if (extAddr !== undefined) {
357
+ extAddrMap.set(extAddr, nodeId);
358
+ }
359
+ }
360
+
361
+ return extAddrMap;
362
+ }
363
+
364
+ /**
365
+ * Builds a map of RLOC16 (short addresses) to node IDs for Thread devices.
366
+ * Used as fallback when ExtAddress is not available.
367
+ * Node IDs are stored as strings to avoid BigInt precision loss.
368
+ */
369
+ export function buildRloc16Map(nodes: Record<string, MatterNode>): Map<number, string> {
370
+ const rloc16Map = new Map<number, string>();
371
+
372
+ for (const node of Object.values(nodes)) {
373
+ const nodeId = String(node.node_id);
374
+ const rloc16 = getThreadRloc16(node);
375
+
376
+ if (rloc16 !== undefined) {
377
+ rloc16Map.set(rloc16, nodeId);
378
+ }
379
+ }
380
+
381
+ return rloc16Map;
382
+ }
383
+
384
+ /**
385
+ * Finds unknown Thread devices - addresses seen in neighbor tables
386
+ * that don't match any known commissioned device.
387
+ * These are typically Thread Border Routers or devices from other ecosystems.
388
+ */
389
+ export function findUnknownDevices(
390
+ nodes: Record<string, MatterNode>,
391
+ extAddrMap: Map<bigint, string>,
392
+ ): UnknownThreadDevice[] {
393
+ const unknownMap = new Map<string, UnknownThreadDevice>();
394
+
395
+ for (const node of Object.values(nodes)) {
396
+ const nodeId = String(node.node_id);
397
+ const neighbors = parseNeighborTable(node);
398
+
399
+ for (const neighbor of neighbors) {
400
+ // Check if this neighbor is in our known devices
401
+ if (extAddrMap.has(neighbor.extAddress)) {
402
+ continue;
403
+ }
404
+
405
+ const extAddressHex = neighbor.extAddress.toString(16).padStart(16, "0").toUpperCase();
406
+ const id = `unknown_${extAddressHex}`;
407
+
408
+ if (!unknownMap.has(id)) {
409
+ unknownMap.set(id, {
410
+ id,
411
+ extAddressHex,
412
+ extAddress: neighbor.extAddress,
413
+ seenBy: [],
414
+ isRouter: false,
415
+ bestRssi: null,
416
+ });
417
+ }
418
+
419
+ const unknown = unknownMap.get(id)!;
420
+
421
+ // Add this node to seenBy if not already there
422
+ if (!unknown.seenBy.includes(nodeId)) {
423
+ unknown.seenBy.push(nodeId);
424
+ }
425
+
426
+ // Update router status (field 10 = rxOnWhenIdle, indicates router-like behavior)
427
+ if (neighbor.rxOnWhenIdle) {
428
+ unknown.isRouter = true;
429
+ }
430
+
431
+ // Track best signal
432
+ const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
433
+ if (rssi !== null && (unknown.bestRssi === null || rssi > unknown.bestRssi)) {
434
+ unknown.bestRssi = rssi;
435
+ }
436
+ }
437
+ }
438
+
439
+ return Array.from(unknownMap.values());
440
+ }
441
+
442
+ /**
443
+ * Gets the signal color based on RSSI or LQI values.
444
+ * Green: Strong signal
445
+ * Orange: Medium signal
446
+ * Red: Weak signal
447
+ */
448
+ export function getSignalColor(neighbor: ThreadNeighbor): string {
449
+ // Prefer RSSI if available
450
+ const rssi = neighbor.avgRssi ?? neighbor.lastRssi;
451
+
452
+ if (rssi !== null) {
453
+ if (rssi > SIGNAL_STRONG_THRESHOLD) {
454
+ return SIGNAL_COLOR_STRONG;
455
+ }
456
+ if (rssi > SIGNAL_MEDIUM_THRESHOLD) {
457
+ return SIGNAL_COLOR_MEDIUM;
458
+ }
459
+ return SIGNAL_COLOR_WEAK;
460
+ }
461
+
462
+ // Fallback to LQI (0-255, higher is better)
463
+ if (neighbor.lqi > LQI_STRONG_THRESHOLD) {
464
+ return SIGNAL_COLOR_STRONG;
465
+ }
466
+ if (neighbor.lqi > LQI_MEDIUM_THRESHOLD) {
467
+ return SIGNAL_COLOR_MEDIUM;
468
+ }
469
+ return SIGNAL_COLOR_WEAK;
470
+ }
471
+
472
+ /**
473
+ * Get signal color based on LQI value alone.
474
+ * Used for route table entries where only LQI is available.
475
+ * @param lqi Link Quality Indicator (0-255, higher is better)
476
+ */
477
+ export function getSignalColorFromLqi(lqi: number): string {
478
+ if (lqi > LQI_STRONG_THRESHOLD) {
479
+ return SIGNAL_COLOR_STRONG;
480
+ }
481
+ if (lqi > LQI_MEDIUM_THRESHOLD) {
482
+ return SIGNAL_COLOR_MEDIUM;
483
+ }
484
+ return SIGNAL_COLOR_WEAK;
485
+ }
486
+
487
+ /**
488
+ * Gets a human-readable display name for a node.
489
+ * Format: nodeLabel || productName (serialNumber)
490
+ */
491
+ export function getDeviceName(node: MatterNode): string {
492
+ if (node.nodeLabel) {
493
+ return node.nodeLabel;
494
+ }
495
+
496
+ const productName = node.productName || "Unknown Device";
497
+ const serialNumber = node.serialNumber;
498
+
499
+ if (serialNumber) {
500
+ return `${productName} (${serialNumber})`;
501
+ }
502
+
503
+ return productName;
504
+ }
505
+
506
+ /**
507
+ * Gets the human-readable name for a Thread routing role.
508
+ */
509
+ export function getThreadRoleName(role: number | undefined): string {
510
+ switch (role) {
511
+ case 0:
512
+ return "Unspecified";
513
+ case 1:
514
+ return "Unassigned";
515
+ case 2:
516
+ return "Sleepy End Device";
517
+ case 3:
518
+ return "End Device";
519
+ case 4:
520
+ return "REED";
521
+ case 5:
522
+ return "Router";
523
+ case 6:
524
+ return "Leader";
525
+ default:
526
+ return "Unknown";
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Parses WiFi diagnostics from a node's attributes.
532
+ * Cluster 0x36/54 - WiFi Network Diagnostics.
533
+ */
534
+ export function getWiFiDiagnostics(node: MatterNode): WiFiDiagnostics {
535
+ // BSSID is attribute 0/54/0, stored as base64
536
+ const bssidRaw = node.attributes["0/54/0"] as string | undefined;
537
+ let bssid: string | null = null;
538
+ if (bssidRaw) {
539
+ try {
540
+ const binary = atob(bssidRaw);
541
+ bssid = Array.from(binary)
542
+ .map(c => c.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase())
543
+ .join(":");
544
+ } catch {
545
+ bssid = null;
546
+ }
547
+ }
548
+
549
+ // RSSI is attribute 0/54/4
550
+ const rssi = node.attributes["0/54/4"] as number | null | undefined;
551
+
552
+ // Channel is attribute 0/54/3
553
+ const channel = node.attributes["0/54/3"] as number | null | undefined;
554
+
555
+ // Security type is attribute 0/54/1
556
+ const securityType = node.attributes["0/54/1"] as number | null | undefined;
557
+
558
+ // WiFi version is attribute 0/54/2
559
+ const wifiVersion = node.attributes["0/54/2"] as number | null | undefined;
560
+
561
+ return {
562
+ bssid: bssid,
563
+ rssi: rssi ?? null,
564
+ channel: channel ?? null,
565
+ securityType: securityType ?? null,
566
+ wifiVersion: wifiVersion ?? null,
567
+ };
568
+ }
569
+
570
+ /**
571
+ * Gets the signal color for a given RSSI value.
572
+ */
573
+ export function getSignalColorFromRssi(rssi: number | null): string {
574
+ if (rssi === null) {
575
+ return SIGNAL_COLOR_MEDIUM; // Default to medium if unknown
576
+ }
577
+ if (rssi > SIGNAL_STRONG_THRESHOLD) {
578
+ return SIGNAL_COLOR_STRONG;
579
+ }
580
+ if (rssi > SIGNAL_MEDIUM_THRESHOLD) {
581
+ return SIGNAL_COLOR_MEDIUM;
582
+ }
583
+ return SIGNAL_COLOR_WEAK;
584
+ }
585
+
586
+ /**
587
+ * Gets WiFi security type name.
588
+ */
589
+ export function getWiFiSecurityTypeName(securityType: number | null): string {
590
+ switch (securityType) {
591
+ case 0:
592
+ return "Unspecified";
593
+ case 1:
594
+ return "None";
595
+ case 2:
596
+ return "WEP";
597
+ case 3:
598
+ return "WPA Personal";
599
+ case 4:
600
+ return "WPA2 Personal";
601
+ case 5:
602
+ return "WPA3 Personal";
603
+ default:
604
+ return "Unknown";
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Gets WiFi version name.
610
+ */
611
+ export function getWiFiVersionName(version: number | null): string {
612
+ switch (version) {
613
+ case 0:
614
+ return "802.11a";
615
+ case 1:
616
+ return "802.11b";
617
+ case 2:
618
+ return "802.11g";
619
+ case 3:
620
+ return "802.11n";
621
+ case 4:
622
+ return "802.11ac";
623
+ case 5:
624
+ return "802.11ax";
625
+ case 6:
626
+ return "802.11ah";
627
+ default:
628
+ return "Unknown";
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Builds Thread mesh connections from neighbor tables.
634
+ * Returns connections with signal information.
635
+ * Includes connections to unknown devices (prefixed with 'unknown_').
636
+ */
637
+ export function buildThreadConnections(
638
+ nodes: Record<string, MatterNode>,
639
+ extAddrMap: Map<bigint, string>,
640
+ unknownDevices: UnknownThreadDevice[],
641
+ ): ThreadConnection[] {
642
+ const connections: ThreadConnection[] = [];
643
+ const seenConnections = new Set<string>();
644
+
645
+ // Build map of unknown device extAddress -> id
646
+ const unknownExtAddrMap = new Map<bigint, string>();
647
+ for (const unknown of unknownDevices) {
648
+ unknownExtAddrMap.set(unknown.extAddress, unknown.id);
649
+ }
650
+
651
+ for (const node of Object.values(nodes)) {
652
+ const fromNodeId = String(node.node_id);
653
+ const neighbors = parseNeighborTable(node);
654
+
655
+ for (const neighbor of neighbors) {
656
+ // Try to find in known devices first
657
+ let toNodeId: string | undefined = extAddrMap.get(neighbor.extAddress);
658
+
659
+ // If not found, check unknown devices
660
+ if (toNodeId === undefined) {
661
+ toNodeId = unknownExtAddrMap.get(neighbor.extAddress);
662
+ }
663
+
664
+ if (toNodeId === undefined) {
665
+ // Should not happen if unknownDevices was built correctly
666
+ continue;
667
+ }
668
+
669
+ // Skip self-connections
670
+ if (fromNodeId === toNodeId) {
671
+ continue;
672
+ }
673
+
674
+ // Create a unique key for this connection
675
+ const connectionKey = `${fromNodeId}-${toNodeId}`;
676
+ const reverseKey = `${toNodeId}-${fromNodeId}`;
677
+
678
+ if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
679
+ // Already have this connection
680
+ continue;
681
+ }
682
+ seenConnections.add(connectionKey);
683
+
684
+ // Look up route table entry for supplementary data (pathCost, bidirectional LQI)
685
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
686
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
687
+
688
+ // Always use neighbor table RSSI/LQI for signal color (most accurate link quality)
689
+ connections.push({
690
+ fromNodeId,
691
+ toNodeId,
692
+ signalColor: getSignalColor(neighbor),
693
+ lqi: neighbor.lqi,
694
+ rssi: neighbor.avgRssi ?? neighbor.lastRssi,
695
+ pathCost: routeEntry?.pathCost,
696
+ bidirectionalLqi,
697
+ });
698
+ }
699
+
700
+ // Check route table for connections not in neighbor table (supplementary data)
701
+ // This helps when neighbor table is stale or incomplete
702
+ const routes = parseRouteTable(node);
703
+ for (const route of routes) {
704
+ if (!route.linkEstablished || !route.allocated) continue;
705
+
706
+ let toNodeId: string | undefined = extAddrMap.get(route.extAddress);
707
+ if (toNodeId === undefined) {
708
+ toNodeId = unknownExtAddrMap.get(route.extAddress);
709
+ }
710
+ if (toNodeId === undefined || toNodeId === fromNodeId) continue;
711
+
712
+ const connectionKey = `${fromNodeId}-${toNodeId}`;
713
+ const reverseKey = `${toNodeId}-${fromNodeId}`;
714
+
715
+ // Only add if we don't already have this connection from neighbor table
716
+ if (seenConnections.has(connectionKey) || seenConnections.has(reverseKey)) {
717
+ continue;
718
+ }
719
+ seenConnections.add(connectionKey);
720
+
721
+ const bidirectionalLqi = getRouteBidirectionalLqi(route);
722
+ const signalColor =
723
+ bidirectionalLqi !== undefined
724
+ ? getSignalColorFromLqi(bidirectionalLqi)
725
+ : "var(--md-sys-color-outline, grey)"; // Unknown signal
726
+
727
+ connections.push({
728
+ fromNodeId,
729
+ toNodeId,
730
+ signalColor,
731
+ lqi: bidirectionalLqi ?? 0,
732
+ rssi: null,
733
+ pathCost: route.pathCost,
734
+ bidirectionalLqi,
735
+ fromRouteTable: true,
736
+ });
737
+ }
738
+ }
739
+
740
+ return connections;
741
+ }
742
+
743
+ /**
744
+ * Represents a connection from the perspective of a specific node.
745
+ * Includes both neighbors this node reports AND nodes that report this node as their neighbor.
746
+ */
747
+ export interface NodeConnection {
748
+ /** The connected node ID (number for known nodes, string for unknown devices) */
749
+ connectedNodeId: number | string;
750
+ /** The connected MatterNode if it's a known device */
751
+ connectedNode?: MatterNode;
752
+ /** Extended address hex string for display */
753
+ extAddressHex: string;
754
+ /** Signal strength info (if available) */
755
+ signalColor: string;
756
+ lqi: number | null;
757
+ rssi: number | null;
758
+ /** Whether this connection is from THIS node's neighbor table (true) or from the OTHER node's table (false) */
759
+ isOutgoing: boolean;
760
+ /** Whether this is an unknown/external device */
761
+ isUnknown: boolean;
762
+ /** Path cost from route table (1 = direct, higher = multi-hop). Only available for routers. */
763
+ pathCost?: number;
764
+ /** Bidirectional LQI from route table (average of lqiIn and lqiOut) */
765
+ bidirectionalLqi?: number;
766
+ }
767
+
768
+ /**
769
+ * Get all connections for a specific node (bidirectional).
770
+ * This includes:
771
+ * 1. Neighbors this node reports in its neighbor table (outgoing)
772
+ * 2. Nodes that report this node as their neighbor (incoming)
773
+ *
774
+ * Returns a deduplicated list - if both directions exist, only the outgoing one is included
775
+ * (since that has signal data from THIS node's perspective).
776
+ *
777
+ * @param nodeId - Node ID as string to avoid BigInt precision loss
778
+ */
779
+ export function getNodeConnections(
780
+ nodeId: string,
781
+ nodes: Record<string, MatterNode>,
782
+ extAddrMap: Map<bigint, string>,
783
+ ): NodeConnection[] {
784
+ const connections: NodeConnection[] = [];
785
+ const seenConnectedIds = new Set<string>();
786
+
787
+ const node = nodes[nodeId];
788
+ if (!node) return connections;
789
+
790
+ // Get this node's extended address for reverse lookups (from General Diagnostics, not Thread Diagnostics)
791
+ const thisExtAddr = getThreadExtendedAddress(node);
792
+
793
+ // 1. Add neighbors this node reports (outgoing connections)
794
+ const neighbors = parseNeighborTable(node);
795
+ for (const neighbor of neighbors) {
796
+ const connectedNodeId = extAddrMap.get(neighbor.extAddress);
797
+ const connectedNode = connectedNodeId ? nodes[connectedNodeId] : undefined;
798
+ const isUnknown = connectedNodeId === undefined;
799
+ const displayId = isUnknown
800
+ ? `unknown_${neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0")}`
801
+ : connectedNodeId;
802
+
803
+ seenConnectedIds.add(displayId);
804
+
805
+ // Look up route table entry for enhanced data
806
+ const routeEntry = findRouteByExtAddress(node, neighbor.extAddress);
807
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
808
+
809
+ connections.push({
810
+ connectedNodeId: displayId,
811
+ connectedNode,
812
+ extAddressHex: neighbor.extAddress.toString(16).toUpperCase().padStart(16, "0"),
813
+ signalColor: getSignalColor(neighbor),
814
+ lqi: neighbor.lqi,
815
+ rssi: neighbor.avgRssi ?? neighbor.lastRssi,
816
+ isOutgoing: true,
817
+ isUnknown,
818
+ pathCost: routeEntry?.pathCost,
819
+ bidirectionalLqi,
820
+ });
821
+ }
822
+
823
+ // 2. Find nodes that report THIS node as their neighbor (incoming connections)
824
+ if (thisExtAddr !== undefined) {
825
+ for (const otherNode of Object.values(nodes)) {
826
+ const otherNodeId = String(otherNode.node_id);
827
+ if (otherNodeId === nodeId) continue; // Skip self
828
+
829
+ // Check if already connected via outgoing
830
+ if (seenConnectedIds.has(otherNodeId)) continue;
831
+
832
+ // Check if other node reports this node as neighbor
833
+ const otherNeighbors = parseNeighborTable(otherNode);
834
+ const reverseEntry = otherNeighbors.find(n => n.extAddress === thisExtAddr);
835
+
836
+ if (reverseEntry) {
837
+ const otherExtAddr = getThreadExtendedAddress(otherNode);
838
+ const extAddrHex = otherExtAddr ? otherExtAddr.toString(16).toUpperCase().padStart(16, "0") : "Unknown";
839
+
840
+ // Look up route table entry from the other node's perspective
841
+ const routeEntry = findRouteByExtAddress(otherNode, thisExtAddr);
842
+ const bidirectionalLqi = routeEntry ? getRouteBidirectionalLqi(routeEntry) : undefined;
843
+
844
+ connections.push({
845
+ connectedNodeId: otherNodeId,
846
+ connectedNode: otherNode,
847
+ extAddressHex: extAddrHex,
848
+ signalColor: getSignalColor(reverseEntry),
849
+ lqi: reverseEntry.lqi,
850
+ rssi: reverseEntry.avgRssi ?? reverseEntry.lastRssi,
851
+ isOutgoing: false,
852
+ isUnknown: false,
853
+ pathCost: routeEntry?.pathCost,
854
+ bidirectionalLqi,
855
+ });
856
+ }
857
+ }
858
+ }
859
+
860
+ return connections;
861
+ }