smart-home-engine 1.53.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,496 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The Thread mesh (roadmap M16): every Thread device in she's fabric with its routing role,
5
+ * RLOC16, extended address, neighbour table and route table from the Thread Network Diagnostics
6
+ * cluster (0x0035), the Wi-Fi devices with their RSSI and access point from Wi-Fi Network
7
+ * Diagnostics, the border routers from the mDNS registry (M15) joined by extended address, the
8
+ * bindings (M13) as arrows — as a graph of nodes and links the Thread tab draws.
9
+ *
10
+ * Most devices report their neighbour and route tables only when a subscription is set up, so
11
+ * the tables are re-read on demand (the refresh button) and periodically; the rest comes from
12
+ * the cached node state. The ideas (roles, LQI colours, external nodes) follow the Open Home
13
+ * Foundation's matterjs-server dashboard (Apache-2.0, D-14); no code was copied.
14
+ */
15
+
16
+ const ROLES = { 0: 'unspecified', 1: 'unassigned', 2: 'sleepy end device', 3: 'end device', 4: 'REED', 5: 'router', 6: 'leader' };
17
+ const INTERFACE_THREAD = 4;
18
+ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
19
+
20
+ let _log = null;
21
+ let _broadcast = null;
22
+ let _controller = null;
23
+ let _thread = null;
24
+ let _timer = null;
25
+ /** nodeId → { neighborTable, routeTable, readAt } — the fresh tables */
26
+ const _tables = new Map();
27
+ let _lastRefresh = null;
28
+
29
+ function _ctrl() {
30
+ return _controller ?? require('./controller');
31
+ }
32
+ function _registry() {
33
+ return _thread ?? require('./thread');
34
+ }
35
+
36
+ function hex64(v) {
37
+ if (v === undefined || v === null) return null;
38
+ if (typeof v === 'string') return v.toUpperCase().replace(/^0X/, '').padStart(16, '0');
39
+ if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) return Buffer.from(v).toString('hex').toUpperCase().padStart(16, '0');
40
+ if (v && typeof v === 'object' && typeof v.$bytes === 'string') return v.$bytes.toUpperCase().padStart(16, '0');
41
+ try {
42
+ return BigInt(v).toString(16).toUpperCase().padStart(16, '0');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** LQI 3 → good, 2 → fair, 1 → poor, 0 → unknown (the OHF dashboard's colours). */
49
+ function lqiQuality(lqi) {
50
+ if (lqi >= 3) return 'good';
51
+ if (lqi === 2) return 'fair';
52
+ if (lqi === 1) return 'poor';
53
+ return 'unknown';
54
+ }
55
+
56
+ /** RSSI > −70 good, −70 … −85 fair, ≤ −85 poor. */
57
+ function rssiQuality(rssi) {
58
+ if (rssi === null || rssi === undefined) return 'unknown';
59
+ if (rssi > -70) return 'good';
60
+ if (rssi > -85) return 'fair';
61
+ return 'poor';
62
+ }
63
+
64
+ /**
65
+ * Build the graph from per-node data (pure, for the tests).
66
+ * @param {object[]} nodes [{ nodeId, name, online, thread?: { role, rloc16, extAddress, channel, partitionId, leaderRouterId, networkName, neighborTable, routeTable, readAt }, wifi?: { rssi, bssid } }]
67
+ * @param {object[]} borderRouters the Thread tab's border router views (M15)
68
+ * @param {object[]} bindings M13's binding list
69
+ */
70
+ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
71
+ const out = { nodes: [], links: [], networks: [], meshcop: { nodes: 0, networks: [] } };
72
+ const byExt = new Map();
73
+ const byRloc = new Map();
74
+ const byNodeId = new Map();
75
+ for (const n of nodes) {
76
+ if (n.thread) {
77
+ const g = {
78
+ id: `node:${n.nodeId}`,
79
+ kind: 'thread',
80
+ nodeId: n.nodeId,
81
+ name: n.name ?? `Node ${n.nodeId}`,
82
+ online: !!n.online,
83
+ role: ROLES[n.thread.role] ?? (n.thread.role === null || n.thread.role === undefined ? 'unknown' : String(n.thread.role)),
84
+ roleCode: n.thread.role ?? null,
85
+ rloc16: n.thread.rloc16 ?? null,
86
+ extAddress: n.thread.extAddress ?? null,
87
+ channel: n.thread.channel ?? null,
88
+ partitionId: n.thread.partitionId ?? null,
89
+ networkName: n.thread.networkName ?? null,
90
+ neighbours: (n.thread.neighborTable ?? []).length,
91
+ routes: (n.thread.routeTable ?? []).length,
92
+ readAt: n.thread.readAt ?? null,
93
+ };
94
+ out.nodes.push(g);
95
+ byNodeId.set(n.nodeId, g);
96
+ if (g.extAddress) byExt.set(g.extAddress, g);
97
+ if (g.rloc16 !== null) byRloc.set(g.rloc16, g);
98
+ } else if (n.wifi) {
99
+ const g = {
100
+ id: `node:${n.nodeId}`,
101
+ kind: 'wifi',
102
+ nodeId: n.nodeId,
103
+ name: n.name ?? `Node ${n.nodeId}`,
104
+ online: !!n.online,
105
+ rssi: n.wifi.rssi ?? null,
106
+ bssid: n.wifi.bssid ?? null,
107
+ quality: rssiQuality(n.wifi.rssi),
108
+ };
109
+ out.nodes.push(g);
110
+ byNodeId.set(n.nodeId, g);
111
+ } else {
112
+ const g = { id: `node:${n.nodeId}`, kind: 'other', nodeId: n.nodeId, name: n.name ?? `Node ${n.nodeId}`, online: !!n.online };
113
+ out.nodes.push(g);
114
+ byNodeId.set(n.nodeId, g);
115
+ }
116
+ }
117
+ // border routers: a graph node each, joined to a fabric node when the ext address matches
118
+ for (const br of borderRouters) {
119
+ const ext = br.extAddress ? br.extAddress.toUpperCase() : null;
120
+ const existing = ext ? byExt.get(ext) : null;
121
+ if (existing) {
122
+ existing.borderRouter = { vendorName: br.vendorName, modelName: br.modelName, networkName: br.networkName };
123
+ existing.kind = 'thread';
124
+ continue;
125
+ }
126
+ const g = {
127
+ id: `br:${br.extAddress}`,
128
+ kind: 'border-router',
129
+ name: `${br.vendorName ?? ''} ${br.modelName ?? ''}`.trim() || br.extAddress,
130
+ extAddress: ext,
131
+ networkName: br.networkName ?? null,
132
+ extPanId: br.extPanId ?? null,
133
+ role: br.state?.role ?? null,
134
+ online: true,
135
+ rloc16: br.rest?.node?.rloc16 ?? null,
136
+ };
137
+ out.nodes.push(g);
138
+ if (ext) byExt.set(ext, g);
139
+ if (g.rloc16 !== null) byRloc.set(g.rloc16, g);
140
+ }
141
+ // links from the neighbour tables; a neighbour nobody in the fabric knows becomes an external node
142
+ const linkKey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
143
+ const links = new Map();
144
+ for (const n of nodes) {
145
+ if (!n.thread) continue;
146
+ const from = byNodeId.get(n.nodeId);
147
+ for (const nb of n.thread.neighborTable ?? []) {
148
+ const ext = hex64(nb.extAddress);
149
+ let to = (ext && byExt.get(ext)) || (nb.rloc16 !== undefined && byRloc.get(Number(nb.rloc16))) || null;
150
+ if (!to) {
151
+ const id = `ext:${ext ?? nb.rloc16}`;
152
+ to = out.nodes.find((x) => x.id === id);
153
+ if (!to) {
154
+ to = {
155
+ id,
156
+ kind: 'external',
157
+ name: ext ? `external ${ext.slice(-4)}` : `external 0x${Number(nb.rloc16).toString(16)}`,
158
+ extAddress: ext,
159
+ rloc16: nb.rloc16 ?? null,
160
+ online: true,
161
+ explanation:
162
+ "A neighbour of a device in she's fabric that is not paired with she: a device of another controller, or a border router that announces a different address.",
163
+ };
164
+ out.nodes.push(to);
165
+ if (ext) byExt.set(ext, to);
166
+ }
167
+ }
168
+ const key = linkKey(from.id, to.id);
169
+ const lqi = Number(nb.lqi ?? 0);
170
+ const prev = links.get(key);
171
+ const entry = prev ?? { kind: 'neighbour', source: from.id, target: to.id, lqi: 0, lqiBack: null, rssi: null, isChild: false, bidirectional: false };
172
+ if (prev) {
173
+ entry.bidirectional = true;
174
+ entry.lqiBack = lqi;
175
+ if (lqi > entry.lqi) entry.lqi = lqi;
176
+ } else {
177
+ entry.lqi = lqi;
178
+ }
179
+ if (nb.averageRssi !== undefined && nb.averageRssi !== null) entry.rssi = Number(nb.averageRssi);
180
+ if (nb.isChild) entry.isChild = true;
181
+ entry.quality = lqiQuality(entry.lqi);
182
+ links.set(key, entry);
183
+ }
184
+ }
185
+ // what a border router told us through MeshCoP (M17's dataset): nodes the fabric does not know, and
186
+ // their links — the mesh from the router's side, non-Matter nodes included
187
+ for (const m of meshcop) {
188
+ const ext = m.extAddress ? m.extAddress.toUpperCase() : null;
189
+ let g = (ext && byExt.get(ext)) || (m.rloc16 !== null && m.rloc16 !== undefined && byRloc.get(Number(m.rloc16))) || null;
190
+ if (g) {
191
+ if (g.kind === 'external') {
192
+ g.kind = 'meshcop';
193
+ g.name = m.vendorName || m.vendorModel ? `${m.vendorName ?? ''} ${m.vendorModel ?? ''}`.trim() : g.name;
194
+ g.explanation = 'Seen through the border router (MeshCoP): a node of this network that is not paired with she.';
195
+ }
196
+ if (g.role === undefined || g.role === null || g.kind === 'border-router') g.role = m.role ?? g.role ?? null;
197
+ if (g.rloc16 === null || g.rloc16 === undefined) g.rloc16 = m.rloc16 ?? null;
198
+ g.meshcop = { vendorName: m.vendorName ?? null, vendorModel: m.vendorModel ?? null, version: m.version ?? null, children: (m.children ?? []).length };
199
+ } else {
200
+ g = {
201
+ id: `mc:${ext ?? m.rloc16}`,
202
+ kind: 'meshcop',
203
+ name:
204
+ m.vendorName || m.vendorModel
205
+ ? `${m.vendorName ?? ''} ${m.vendorModel ?? ''}`.trim()
206
+ : ext
207
+ ? `node ${ext.slice(-4)}`
208
+ : `node 0x${Number(m.rloc16).toString(16)}`,
209
+ extAddress: ext,
210
+ rloc16: m.rloc16 ?? null,
211
+ role: m.role ?? null,
212
+ online: true,
213
+ networkName: m.networkName ?? null,
214
+ explanation: 'Seen through the border router (MeshCoP): a node of this network that is not paired with she.',
215
+ meshcop: { vendorName: m.vendorName ?? null, vendorModel: m.vendorModel ?? null, version: m.version ?? null, children: (m.children ?? []).length },
216
+ };
217
+ out.nodes.push(g);
218
+ if (ext) byExt.set(ext, g);
219
+ if (g.rloc16 !== null) byRloc.set(g.rloc16, g);
220
+ }
221
+ out.meshcop.nodes++;
222
+ }
223
+ for (const m of meshcop) {
224
+ const ext = m.extAddress ? m.extAddress.toUpperCase() : null;
225
+ const from = (ext && byExt.get(ext)) || (m.rloc16 !== null && m.rloc16 !== undefined && byRloc.get(Number(m.rloc16))) || null;
226
+ if (!from) continue;
227
+ for (const nb of m.neighbours ?? []) {
228
+ const nbExt = nb.extAddress ? nb.extAddress.toUpperCase() : null;
229
+ const to = (nbExt && byExt.get(nbExt)) || (nb.rloc16 !== null && nb.rloc16 !== undefined && byRloc.get(Number(nb.rloc16))) || null;
230
+ if (!to || to === from) continue;
231
+ const key = linkKey(from.id, to.id);
232
+ if (links.has(key)) continue;
233
+ const lqi = nb.lqi ?? (nb.linkMargin !== undefined ? (nb.linkMargin > 20 ? 3 : nb.linkMargin > 10 ? 2 : 1) : 0);
234
+ links.set(key, {
235
+ kind: 'neighbour',
236
+ source: from.id,
237
+ target: to.id,
238
+ lqi,
239
+ lqiBack: null,
240
+ rssi: nb.averageRssi ?? null,
241
+ isChild: !!nb.isChild,
242
+ bidirectional: false,
243
+ quality: lqiQuality(lqi),
244
+ via: 'meshcop',
245
+ });
246
+ }
247
+ }
248
+ out.links.push(...links.values());
249
+ // bindings as arrows
250
+ for (const b of bindings) {
251
+ if (!b.own || b.node === null || b.node === undefined) continue;
252
+ const from = byNodeId.get(b.sourceNodeId);
253
+ const to = byNodeId.get(String(b.node));
254
+ if (!from || !to) continue;
255
+ const key = `binding|${from.id}|${to.id}`;
256
+ if (out.links.some((l) => l.key === key)) continue;
257
+ out.links.push({
258
+ key,
259
+ kind: 'binding',
260
+ source: from.id,
261
+ target: to.id,
262
+ clusters: [b.clusterName ?? b.cluster],
263
+ label: `${b.sourceEndpointName ?? 'ep ' + b.sourceEndpointId} → ${b.targetEndpointName ?? 'ep ' + b.endpoint}`,
264
+ });
265
+ }
266
+ // networks seen in the fabric
267
+ const nets = new Map();
268
+ for (const g of out.nodes) {
269
+ if (g.kind !== 'thread' || !g.networkName) continue;
270
+ const n = nets.get(g.networkName) ?? { networkName: g.networkName, partitions: new Set(), channel: g.channel ?? null, nodes: 0 };
271
+ if (g.partitionId !== null && g.partitionId !== undefined) n.partitions.add(String(g.partitionId));
272
+ n.nodes++;
273
+ nets.set(g.networkName, n);
274
+ }
275
+ out.networks = [...nets.values()].map((n) => ({ ...n, partitions: [...n.partitions] }));
276
+ return out;
277
+ }
278
+
279
+ function _nodeData(node, nodeId) {
280
+ const dev = { nodeId, name: null, online: node.lifecycle?.isOnline ?? false };
281
+ let root = null;
282
+ for (const ep of node.endpoints) if ((ep.number ?? 0) === 0) root = ep;
283
+ if (!root?.state) return dev;
284
+ const bi = root.state.basicInformation;
285
+ dev.name = bi?.nodeLabel || bi?.productName || null;
286
+ const tnd = root.state.threadNetworkDiagnostics;
287
+ const gd = root.state.generalDiagnostics;
288
+ const ifs = Array.isArray(gd?.networkInterfaces) ? gd.networkInterfaces : [];
289
+ const threadIf = ifs.find((i) => Number(i.type) === INTERFACE_THREAD);
290
+ if (tnd) {
291
+ const fresh = _tables.get(nodeId);
292
+ dev.thread = {
293
+ role: tnd.routingRole === undefined || tnd.routingRole === null ? null : Number(tnd.routingRole),
294
+ rloc16: tnd.rloc16 === undefined || tnd.rloc16 === null ? null : Number(tnd.rloc16),
295
+ extAddress: hex64(tnd.extAddress ?? threadIf?.hardwareAddress),
296
+ channel: tnd.channel ?? null,
297
+ partitionId: tnd.partitionId === undefined || tnd.partitionId === null ? null : Number(tnd.partitionId),
298
+ leaderRouterId: tnd.leaderRouterId ?? null,
299
+ networkName: tnd.networkName ?? null,
300
+ neighborTable: fresh?.neighborTable ?? (Array.isArray(tnd.neighborTable) ? tnd.neighborTable : []),
301
+ routeTable: fresh?.routeTable ?? (Array.isArray(tnd.routeTable) ? tnd.routeTable : []),
302
+ readAt: fresh?.readAt ?? null,
303
+ };
304
+ return dev;
305
+ }
306
+ const wnd = root.state.wiFiNetworkDiagnostics;
307
+ if (wnd) {
308
+ dev.wifi = {
309
+ rssi: wnd.rssi === undefined || wnd.rssi === null ? null : Number(wnd.rssi),
310
+ bssid: wnd.bssid ? (wnd.bssid.$bytes ?? Buffer.from(wnd.bssid).toString('hex')) : null,
311
+ };
312
+ }
313
+ return dev;
314
+ }
315
+
316
+ // ── MeshCoP through a border router (M17's dataset, opt-in per network) ────────
317
+
318
+ /** extPanId → { nodes, readAt, error } */
319
+ const _meshcop = new Map();
320
+ const MESHCOP_WINDOW_MS = 12000;
321
+
322
+ function _datasets() {
323
+ return require('./thread-datasets');
324
+ }
325
+
326
+ /**
327
+ * Ask the network's nodes for their diagnostics through a border router, as the network's
328
+ * commissioner (CoAP over DTLS with the PSKc) — only for networks whose dataset is known and whose
329
+ * MeshCoP switch is on. One commissioner per network at a time: the petition fails when another
330
+ * app holds it. Returns the nodes seen.
331
+ */
332
+ async function meshcopQuery(extPanId, { entry } = {}) {
333
+ const creds = _datasets().credentialsFor(extPanId);
334
+ if (!creds) throw new Error('MeshCoP is off for this network, or its dataset has no PSKc');
335
+ const reg = _registry();
336
+ const nets = reg.listNetworks();
337
+ const net = nets.networks.find((n) => n.extPanId.toUpperCase() === String(extPanId).toUpperCase());
338
+ if (!net) throw new Error('no border router of this network is on the LAN');
339
+ const brs = net.borderRouters.filter((b) => b.meshcopPort && b.addresses.some((a) => !/^fe80:/i.test(a)));
340
+ if (!brs.length) throw new Error('no border router of this network announces a MeshCoP port with a routable address');
341
+ const br = entry ?? brs[0];
342
+ const raw = reg._registryEntry ? reg._registryEntry(br.extAddress) : null;
343
+ const { connectMeshcop, DefaultTlvSet } = require('@matter/thread-br-client');
344
+ const env = reg._env ? reg._env() : null;
345
+ const brEntry = raw ?? {
346
+ extAddressHex: br.extAddress,
347
+ addresses: br.addresses,
348
+ meshcopPort: br.meshcopPort,
349
+ networkName: br.networkName,
350
+ extendedPanIdHex: br.extPanId,
351
+ sources: ['meshcop'],
352
+ lastSeen: Date.now(),
353
+ };
354
+ const handle = await connectMeshcop({ creds, br: brEntry, environment: env });
355
+ const seen = [];
356
+ try {
357
+ const q = handle.source.queryMulticast('ff03::2', { tlvTypes: [...DefaultTlvSet], windowMs: MESHCOP_WINDOW_MS });
358
+ q.onNode.on((r) => seen.push(_meshcopNode(r)));
359
+ q.onError.on((err) => _log?.debug(`matter thread: MeshCoP: ${err.message}`));
360
+ await q.done;
361
+ } finally {
362
+ await handle.close().catch(() => {});
363
+ }
364
+ _meshcop.set(String(extPanId).toUpperCase(), { nodes: seen, readAt: Date.now(), error: null, via: br.extAddress });
365
+ _log?.info(`matter thread: MeshCoP through ${br.vendorName ?? br.extAddress}: ${seen.length} node(s) answered`);
366
+ _broadcast?.({ type: 'matter:thread' });
367
+ return seen;
368
+ }
369
+
370
+ function _meshcopNode(r) {
371
+ const rloc16 = r.rloc16 === undefined ? null : Number(r.rloc16);
372
+ const isRouter = rloc16 !== null && (rloc16 & 0x3ff) === 0;
373
+ const leader = r.leaderData && rloc16 !== null && r.leaderData.leaderRouterId === rloc16 >> 10;
374
+ const role = leader ? 'leader' : isRouter ? 'router' : r.mode ? (r.mode.rxOnWhenIdle ? 'end device' : 'sleepy end device') : null;
375
+ const neighbours = [];
376
+ for (const n of r.routerNeighbors ?? [])
377
+ neighbours.push({ extAddress: hex64(n.extAddress), rloc16: n.rloc16, averageRssi: n.averageRssi ?? null, linkMargin: n.linkMargin ?? null });
378
+ for (const c of r.childTable ?? []) {
379
+ if (rloc16 === null) continue;
380
+ neighbours.push({ rloc16: (rloc16 & 0xfc00) | Number(c.childId), lqi: Number(c.incomingLinkQuality ?? 0), isChild: true, timeout: c.timeoutSeconds ?? null });
381
+ }
382
+ return {
383
+ extAddress: hex64(r.extMacAddress),
384
+ rloc16,
385
+ role,
386
+ vendorName: r.vendorName ?? null,
387
+ vendorModel: r.vendorModel ?? null,
388
+ version: r.vendorSwVersion ?? r.threadStackVersion ?? null,
389
+ batteryLevel: r.batteryLevel ?? null,
390
+ children: (r.childTable ?? []).map((c) => Number(c.childId)),
391
+ neighbours,
392
+ };
393
+ }
394
+
395
+ /** The graph from the cached state, the fresh tables, the border routers and the bindings. */
396
+ async function getMesh({ withBindings = true } = {}) {
397
+ const ctrl = _ctrl();
398
+ const nodes = [];
399
+ for (const node of ctrl._peers?.() ?? []) {
400
+ const addr = node.peerAddress;
401
+ if (!addr) continue;
402
+ nodes.push(_nodeData(node, String(addr.nodeId)));
403
+ }
404
+ let bindings = [];
405
+ if (withBindings) {
406
+ try {
407
+ bindings = await ctrl.listBindings();
408
+ } catch {
409
+ /* optional */
410
+ }
411
+ }
412
+ const brs = _registry().listNetworks();
413
+ const routers = [...brs.networks.flatMap((n) => n.borderRouters), ...brs.unknown];
414
+ const meshcopNodes = [];
415
+ const meshcopNets = [];
416
+ for (const [extPanId, m] of _meshcop) {
417
+ meshcopNodes.push(...m.nodes.map((n) => ({ ...n, networkName: brs.networks.find((x) => x.extPanId === extPanId)?.networkName ?? null })));
418
+ meshcopNets.push({ extPanId, nodes: m.nodes.length, readAt: m.readAt, via: m.via, error: m.error });
419
+ }
420
+ const graph = buildGraph(nodes, routers, bindings, meshcopNodes);
421
+ graph.meshcop.networks = meshcopNets;
422
+ graph.meshcop.enabled = brs.networks.filter((n) => n.datasetKnown && _datasets().credentialsFor(n.extPanId)).map((n) => n.extPanId);
423
+ return { ...graph, lastRefresh: _lastRefresh, nodeCount: nodes.length };
424
+ }
425
+
426
+ /**
427
+ * Re-read the neighbour and route tables of one node or every online Thread node (a fresh read;
428
+ * sleepy devices answer slowly — the per-node timeout is the interaction's).
429
+ */
430
+ async function refresh(nodeId) {
431
+ const ctrl = _ctrl();
432
+ const targets = [];
433
+ for (const node of ctrl._peers?.() ?? []) {
434
+ const addr = node.peerAddress;
435
+ if (!addr) continue;
436
+ const id = String(addr.nodeId);
437
+ if (nodeId !== undefined && id !== String(nodeId)) continue;
438
+ if (!node.lifecycle?.isOnline) continue;
439
+ let root = null;
440
+ for (const ep of node.endpoints) if ((ep.number ?? 0) === 0) root = ep;
441
+ if (!root?.state?.threadNetworkDiagnostics) continue;
442
+ targets.push({ node, id });
443
+ }
444
+ let ok = 0;
445
+ for (const { node, id } of targets) {
446
+ try {
447
+ const neighborTable = (await ctrl._readAttribute(node, 0, 'threadNetworkDiagnostics', 'neighborTable')) ?? [];
448
+ const routeTable = (await ctrl._readAttribute(node, 0, 'threadNetworkDiagnostics', 'routeTable')) ?? [];
449
+ _tables.set(id, { neighborTable, routeTable, readAt: Date.now() });
450
+ ok++;
451
+ } catch (err) {
452
+ _log?.debug(`matter thread: tables of node ${id} not read: ${err.message}`);
453
+ }
454
+ }
455
+ _lastRefresh = Date.now();
456
+ _log?.info(`matter thread: neighbour and route tables read from ${ok} of ${targets.length} Thread node(s)`);
457
+ // the networks with MeshCoP on: ask through their border router too
458
+ try {
459
+ for (const n of _registry().listNetworks().networks) {
460
+ if (!n.datasetKnown || !_datasets().credentialsFor(n.extPanId)) continue;
461
+ await meshcopQuery(n.extPanId).catch((err) => {
462
+ _meshcop.set(n.extPanId.toUpperCase(), {
463
+ nodes: _meshcop.get(n.extPanId.toUpperCase())?.nodes ?? [],
464
+ readAt: _meshcop.get(n.extPanId.toUpperCase())?.readAt ?? null,
465
+ error: err.message,
466
+ via: null,
467
+ });
468
+ _log?.warn(`matter thread: MeshCoP query of ${n.networkName ?? n.extPanId} failed: ${err.message}`);
469
+ });
470
+ }
471
+ } catch {
472
+ /* the registry is optional */
473
+ }
474
+ _broadcast?.({ type: 'matter:thread' });
475
+ return getMesh();
476
+ }
477
+
478
+ function start({ log, broadcast, controller, thread } = {}) {
479
+ _log = log ?? _log;
480
+ _broadcast = broadcast ?? _broadcast;
481
+ _controller = controller ?? null;
482
+ _thread = thread ?? null;
483
+ if (_timer) clearInterval(_timer);
484
+ _timer = setInterval(() => refresh().catch(() => {}), REFRESH_INTERVAL_MS);
485
+ if (typeof _timer.unref === 'function') _timer.unref();
486
+ }
487
+
488
+ function stop() {
489
+ if (_timer) clearInterval(_timer);
490
+ _timer = null;
491
+ _tables.clear();
492
+ _meshcop.clear();
493
+ _lastRefresh = null;
494
+ }
495
+
496
+ module.exports = { buildGraph, getMesh, refresh, meshcopQuery, start, stop, hex64, lqiQuality, rssiQuality, ROLES, _tables, _meshcop, _meshcopNode };