@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.
- package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
- package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
- package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/index.js +1 -0
- package/dist/esm/pages/cluster-commands/index.js.map +1 -1
- package/dist/esm/pages/components/footer.d.ts.map +1 -1
- package/dist/esm/pages/components/footer.js +4 -7
- package/dist/esm/pages/components/footer.js.map +1 -1
- package/dist/esm/pages/components/header.d.ts +5 -0
- package/dist/esm/pages/components/header.d.ts.map +1 -1
- package/dist/esm/pages/components/header.js +75 -0
- package/dist/esm/pages/components/header.js.map +1 -1
- package/dist/esm/pages/components/node-details.js +1 -1
- package/dist/esm/pages/components/node-details.js.map +1 -1
- package/dist/esm/pages/components/server-details.d.ts.map +1 -1
- package/dist/esm/pages/components/server-details.js +0 -1
- package/dist/esm/pages/components/server-details.js.map +1 -1
- package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
- package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
- package/dist/esm/pages/matter-dashboard-app.js +84 -4
- package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
- package/dist/esm/pages/matter-network-view.d.ts +52 -0
- package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
- package/dist/esm/pages/matter-network-view.js +309 -0
- package/dist/esm/pages/matter-network-view.js.map +6 -0
- package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-node-view.js +70 -1
- package/dist/esm/pages/matter-node-view.js.map +1 -1
- package/dist/esm/pages/matter-server-view.d.ts +4 -0
- package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-server-view.js +16 -1
- package/dist/esm/pages/matter-server-view.js.map +1 -1
- package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
- package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/base-network-graph.js +403 -0
- package/dist/esm/pages/network/base-network-graph.js.map +6 -0
- package/dist/esm/pages/network/device-icons.d.ts +52 -0
- package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
- package/dist/esm/pages/network/device-icons.js +197 -0
- package/dist/esm/pages/network/device-icons.js.map +6 -0
- package/dist/esm/pages/network/device-panel.d.ts +31 -0
- package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
- package/dist/esm/pages/network/device-panel.js +183 -0
- package/dist/esm/pages/network/device-panel.js.map +6 -0
- package/dist/esm/pages/network/network-details.d.ts +47 -0
- package/dist/esm/pages/network/network-details.d.ts.map +1 -0
- package/dist/esm/pages/network/network-details.js +686 -0
- package/dist/esm/pages/network/network-details.js.map +6 -0
- package/dist/esm/pages/network/network-types.d.ts +153 -0
- package/dist/esm/pages/network/network-types.d.ts.map +1 -0
- package/dist/esm/pages/network/network-types.js +19 -0
- package/dist/esm/pages/network/network-types.js.map +6 -0
- package/dist/esm/pages/network/network-utils.d.ts +170 -0
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
- package/dist/esm/pages/network/network-utils.js +472 -0
- package/dist/esm/pages/network/network-utils.js.map +6 -0
- package/dist/esm/pages/network/thread-graph.d.ts +27 -0
- package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/thread-graph.js +134 -0
- package/dist/esm/pages/network/thread-graph.js.map +6 -0
- package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
- package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/wifi-graph.js +167 -0
- package/dist/esm/pages/network/wifi-graph.js.map +6 -0
- package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-B1_khzZb.js} +5 -5
- package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-RpdajrwF.js} +2 -5
- package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-5f2itkTG.js} +2 -5
- package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-DZ_pWqsa.js} +2 -5
- package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-DEUxM4B1.js} +2 -2
- package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-BczBMT8E.js} +1 -1
- package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Cr3PfX1X.js} +2 -3
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/matter-dashboard-app-BuCe_Jxf.js +29990 -0
- package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-DMiHNDLA.js} +2 -4
- package/dist/web/js/{prevent_default-BPgSQsuY.js → prevent_default-D4FX_PIh.js} +2 -42
- package/package.json +5 -4
- package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
- package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
- package/src/pages/cluster-commands/index.ts +1 -0
- package/src/pages/components/footer.ts +4 -7
- package/src/pages/components/header.ts +81 -0
- package/src/pages/components/node-details.ts +2 -2
- package/src/pages/components/server-details.ts +0 -1
- package/src/pages/matter-dashboard-app.ts +105 -5
- package/src/pages/matter-network-view.ts +325 -0
- package/src/pages/matter-node-view.ts +75 -1
- package/src/pages/matter-server-view.ts +17 -1
- package/src/pages/network/base-network-graph.ts +463 -0
- package/src/pages/network/device-icons.ts +283 -0
- package/src/pages/network/device-panel.ts +180 -0
- package/src/pages/network/network-details.ts +750 -0
- package/src/pages/network/network-types.ts +161 -0
- package/src/pages/network/network-utils.ts +752 -0
- package/src/pages/network/thread-graph.ts +164 -0
- package/src/pages/network/wifi-graph.ts +192 -0
- package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
- package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
- 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
|
+
}
|