smart-home-engine 1.53.4 → 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,267 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Thread networks and border routers (roadmap M15): what matter.js's `@matter/thread-br-client`
5
+ * sees on the LAN — every `_meshcop._udp` border agent (Apple TV, HomePod, OTBRs, our ESP router)
6
+ * with its network name, extended PAN ID, vendor and model, Thread version, state bitmap and
7
+ * addresses — grouped by network, plus a probe of the classic OpenThread REST API (OTBR on 8081,
8
+ * esp-thread-br on 80) per border router so a manageable one shows its role, RLOC16 and leader data.
9
+ *
10
+ * Read-only: nothing is written to any border router here.
11
+ *
12
+ * Reuse per D-14: the registry, the REST probe and client are the library's (Apache-2.0, used as a
13
+ * dependency, nothing copied).
14
+ */
15
+
16
+ let _log = null;
17
+ let _broadcast = null;
18
+ let _registry = null;
19
+ let _env = null;
20
+ /** extAddressHex → { capability, node, probedAt, error, port } */
21
+ const _probes = new Map();
22
+ const PROBE_TTL_MS = 10 * 60 * 1000;
23
+ const PROBE_PORTS = [8081, 80];
24
+
25
+ // ── The border agent state bitmap (Thread MeshCoP, `_meshcop` TXT "sb", 4 bytes big-endian) ──
26
+
27
+ const CONNECTION_MODES = ['not allowed', 'PSKc', 'PSKd', 'vendor', 'X.509'];
28
+ const IF_STATUS = ['not initialised', 'initialised, not active', 'active'];
29
+ const AVAILABILITY = ['infrequent', 'high'];
30
+ const ROLES = ['disabled or detached', 'child', 'router', 'leader'];
31
+
32
+ /**
33
+ * Decode the state bitmap: connection mode (bits 0–2), Thread interface status (3–4),
34
+ * availability (5–6), BBR active (7), BBR primary (8), Thread role (9–10), ePSKc supported (11).
35
+ * @param {string|undefined} hex
36
+ */
37
+ function decodeStateBitmap(hex) {
38
+ if (!hex || !/^[0-9a-fA-F]{1,8}$/.test(hex)) return null;
39
+ const v = parseInt(hex, 16);
40
+ return {
41
+ connectionMode: CONNECTION_MODES[v & 0x7] ?? `mode ${v & 0x7}`,
42
+ interfaceStatus: IF_STATUS[(v >> 3) & 0x3] ?? `status ${(v >> 3) & 0x3}`,
43
+ availability: AVAILABILITY[(v >> 5) & 0x3] ?? `availability ${(v >> 5) & 0x3}`,
44
+ bbrActive: !!(v & (1 << 7)),
45
+ bbrPrimary: !!(v & (1 << 8)),
46
+ role: ROLES[(v >> 9) & 0x3] ?? `role ${(v >> 9) & 0x3}`,
47
+ epskcSupported: !!(v & (1 << 11)),
48
+ threadActive: ((v >> 3) & 0x3) === 2,
49
+ };
50
+ }
51
+
52
+ function _entryView(e) {
53
+ const probe = _probes.get(e.extAddressHex) ?? null;
54
+ return {
55
+ extAddress: e.extAddressHex,
56
+ extPanId: e.extendedPanIdHex ?? null,
57
+ networkName: e.networkName ?? null,
58
+ vendorName: e.vendorName ?? null,
59
+ modelName: e.modelName ?? null,
60
+ hostname: e.hostname ?? null,
61
+ addresses: e.addresses ?? [],
62
+ meshcopPort: e.meshcopPort ?? null,
63
+ trelPort: e.trelPort ?? null,
64
+ threadVersion: e.threadVersion ?? null,
65
+ swVersion: e.swVersion ?? null,
66
+ borderAgentId: e.borderAgentIdHex ?? null,
67
+ partitionId: e.partitionIdHex ?? null,
68
+ activeTimestamp: e.activeTimestampHex ?? null,
69
+ domainName: e.domainName ?? null,
70
+ state: decodeStateBitmap(e.stateBitmapHex),
71
+ sources: e.sources ?? [],
72
+ lastSeen: e.lastSeen ?? null,
73
+ rest: probe
74
+ ? {
75
+ manageable: !!probe.capability,
76
+ port: probe.port ?? null,
77
+ baseUrl: probe.capability?.baseUrl ?? null,
78
+ diagnosticsApi: probe.capability?.diagnosticsApi ?? null,
79
+ node: probe.node ?? null,
80
+ probedAt: probe.probedAt,
81
+ error: probe.error ?? null,
82
+ }
83
+ : null,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * The networks on the LAN, grouped by extended PAN ID, each with its border routers; border
89
+ * routers that name no PAN ID go into `unknown`.
90
+ * @param {object[]} [entries] the registry's entries (for tests)
91
+ */
92
+ function listNetworks(entries) {
93
+ const list = entries ?? (_registry ? _registry.list() : []);
94
+ const byPan = new Map();
95
+ const unknown = [];
96
+ for (const e of list) {
97
+ const view = _entryView(e);
98
+ if (!view.extPanId) {
99
+ unknown.push(view);
100
+ continue;
101
+ }
102
+ let net = byPan.get(view.extPanId);
103
+ if (!net) {
104
+ net = { extPanId: view.extPanId, networkName: view.networkName, borderRouters: [], partitions: new Set(), datasetKnown: _datasetKnown(view.extPanId) };
105
+ byPan.set(view.extPanId, net);
106
+ }
107
+ if (!net.networkName && view.networkName) net.networkName = view.networkName;
108
+ if (view.partitionId) net.partitions.add(view.partitionId);
109
+ net.borderRouters.push(view);
110
+ }
111
+ const networks = [...byPan.values()].map((n) => ({
112
+ ...n,
113
+ partitions: [...n.partitions],
114
+ routerCount: n.borderRouters.length,
115
+ borderRouters: n.borderRouters.sort((a, b) => (a.vendorName ?? '').localeCompare(b.vendorName ?? '') || a.extAddress.localeCompare(b.extAddress)),
116
+ }));
117
+ networks.sort((a, b) => (a.networkName ?? '').localeCompare(b.networkName ?? ''));
118
+ return { running: !!_registry, networks, unknown };
119
+ }
120
+
121
+ function _datasetKnown(extPanId) {
122
+ try {
123
+ return require('./thread-datasets').has(extPanId);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** Import the active dataset from a border router with the REST API (M17). */
130
+ async function importDataset(extAddressHex) {
131
+ const e = _registry?.get(extAddressHex);
132
+ if (!e) throw new Error(`border router not found: ${extAddressHex}`);
133
+ const probe = _probes.get(extAddressHex);
134
+ if (!probe?.capability) throw new Error('the border router has no REST API (probe it first)');
135
+ const { OtbrRestClient } = require('@matter/thread-br-client');
136
+ const client = new OtbrRestClient({ host: _ipForProbe(e), port: probe.port });
137
+ const ds = await client.getDataset();
138
+ if (!ds?.activeHex) throw new Error('the border router returned no active dataset');
139
+ return require('./thread-datasets').add({ tlv: ds.activeHex, source: 'router', note: `${e.vendorName ?? ''} ${e.modelName ?? ''}`.trim() });
140
+ }
141
+
142
+ function _ipForProbe(e) {
143
+ // prefer IPv4, then a global IPv6, then anything (a link-local needs a scope we do not have)
144
+ const addrs = e.addresses ?? [];
145
+ return addrs.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) ?? addrs.find((a) => a.includes(':') && !/^fe80:/i.test(a)) ?? addrs[0] ?? null;
146
+ }
147
+
148
+ /**
149
+ * Probe a border router's classic REST API (OTBR: 8081, esp-thread-br: 80) and, when found, read
150
+ * its node info. Cached for ten minutes; `force` probes again.
151
+ */
152
+ async function probeRouter(extAddressHex, { force = false } = {}) {
153
+ const e = _registry?.get(extAddressHex);
154
+ if (!e) throw new Error(`border router not found: ${extAddressHex}`);
155
+ const cached = _probes.get(extAddressHex);
156
+ if (cached && !force && Date.now() - cached.probedAt < PROBE_TTL_MS) return _entryView(e);
157
+ const { OtbrRestProbe, OtbrRestClient } = require('@matter/thread-br-client');
158
+ const host = _ipForProbe(e);
159
+ const result = { capability: null, node: null, probedAt: Date.now(), error: null, port: null };
160
+ if (!host) {
161
+ result.error = 'no address';
162
+ } else {
163
+ for (const port of PROBE_PORTS) {
164
+ try {
165
+ const cap = await OtbrRestProbe.probe(host, port, 1500);
166
+ if (!cap) continue;
167
+ result.capability = cap;
168
+ result.port = port;
169
+ try {
170
+ const client = new OtbrRestClient({ host, port });
171
+ const node = await client.getNode();
172
+ result.node = {
173
+ state: node.state ?? null,
174
+ role: node.state ?? null,
175
+ rloc16: node.rloc16 === undefined ? null : Number(node.rloc16),
176
+ rlocAddress: node.rlocAddress ?? null,
177
+ numOfRouter: node.numOfRouter ?? null,
178
+ networkName: node.networkName ?? null,
179
+ extAddress: node.extAddress ? Buffer.from(node.extAddress).toString('hex').toUpperCase() : null,
180
+ extPanId: node.extPanId ? Buffer.from(node.extPanId).toString('hex').toUpperCase() : null,
181
+ leaderData: node.leaderData ?? null,
182
+ };
183
+ } catch (err) {
184
+ result.error = `node info: ${err.message}`;
185
+ }
186
+ break;
187
+ } catch (err) {
188
+ result.error = err.message;
189
+ }
190
+ }
191
+ }
192
+ _probes.set(extAddressHex, result);
193
+ _log?.debug(
194
+ `matter thread: ${e.vendorName ?? ''} ${e.modelName ?? ''} ${extAddressHex} REST ${result.capability ? 'found on ' + result.port : 'not found'}${result.error ? ' (' + result.error + ')' : ''}`,
195
+ );
196
+ _broadcast?.({ type: 'matter:thread' });
197
+ return _entryView(e);
198
+ }
199
+
200
+ let _probeQueue = Promise.resolve();
201
+ function _scheduleProbe(e) {
202
+ _probeQueue = _probeQueue.then(() => probeRouter(e.extAddressHex).catch(() => {}));
203
+ }
204
+
205
+ /**
206
+ * Start the passive mDNS discovery on the controller's environment.
207
+ * @param {object} env the matter.js Environment (shares the controller's mDNS)
208
+ */
209
+ async function start(env, { log, broadcast } = {}) {
210
+ _log = log ?? _log;
211
+ _broadcast = broadcast ?? _broadcast;
212
+ _env = env;
213
+ if (_registry) return;
214
+ const { BorderRouterRegistry } = require('@matter/thread-br-client');
215
+ const registry = new BorderRouterRegistry(env);
216
+ registry.events.added.on((e) => {
217
+ _log?.info(`matter thread: border router ${e.vendorName ?? '?'} ${e.modelName ?? ''} (${e.networkName ?? 'network ?'}, ${e.extAddressHex})`);
218
+ _broadcast?.({ type: 'matter:thread' });
219
+ _scheduleProbe(e);
220
+ });
221
+ registry.events.updated.on(() => _broadcast?.({ type: 'matter:thread' }));
222
+ registry.events.removed.on((e) => {
223
+ _probes.delete(e.extAddressHex);
224
+ _log?.info(`matter thread: border router ${e.extAddressHex} gone`);
225
+ _broadcast?.({ type: 'matter:thread' });
226
+ });
227
+ await registry.start();
228
+ _registry = registry;
229
+ _log?.info('matter thread: border router discovery started');
230
+ }
231
+
232
+ async function stop() {
233
+ const r = _registry;
234
+ _registry = null;
235
+ _probes.clear();
236
+ if (r) {
237
+ try {
238
+ await r.stop();
239
+ } catch {
240
+ /* stopping */
241
+ }
242
+ }
243
+ }
244
+
245
+ /** Probe every border router again (the refresh button). */
246
+ async function refresh() {
247
+ if (!_registry) throw new Error('Thread discovery is not running');
248
+ for (const e of _registry.list()) await probeRouter(e.extAddressHex, { force: true }).catch(() => {});
249
+ return listNetworks();
250
+ }
251
+
252
+ function isRunning() {
253
+ return !!_registry;
254
+ }
255
+
256
+ /** The library's own entry (for `connectMeshcop`). */
257
+ function _registryEntry(extAddressHex) {
258
+ return _registry?.get(extAddressHex) ?? null;
259
+ }
260
+
261
+ /** Test hook. */
262
+ function _inject({ registry } = {}) {
263
+ _registry = registry ?? null;
264
+ _probes.clear();
265
+ }
266
+
267
+ module.exports = { start, stop, refresh, listNetworks, probeRouter, importDataset, decodeStateBitmap, isRunning, _inject, _probes, _registryEntry, _env: () => _env };
@@ -239,6 +239,85 @@ const TOOL_DEFINITIONS = [
239
239
  },
240
240
  },
241
241
  },
242
+ {
243
+ type: 'function',
244
+ function: {
245
+ name: 'get_matter_device',
246
+ description:
247
+ 'One Matter device in full: every endpoint with its clusters and attribute values (what list_matter_devices leaves out), the device types, the CSA ledger vendor/product. Narrow with cluster.',
248
+ parameters: {
249
+ type: 'object',
250
+ properties: {
251
+ node: { type: 'string', description: 'Node id or device name.' },
252
+ endpoint: { type: 'string', description: 'Only this endpoint (id or name).' },
253
+ cluster: { type: 'string', description: 'Only this cluster (camelCase, e.g. "onOff"); then every attribute of it is listed.' },
254
+ },
255
+ required: ['node'],
256
+ },
257
+ },
258
+ },
259
+ {
260
+ type: 'function',
261
+ function: {
262
+ name: 'list_matter_bindings',
263
+ description:
264
+ 'The Matter bindings (a switch controlling a device directly, without she): per source endpoint its targets and clusters, whether she or another controller made it; and which endpoints can be binding sources or targets.',
265
+ parameters: { type: 'object', properties: { node: { type: 'string', description: 'Only bindings of this source or target node (id or name).' } }, required: [] },
266
+ },
267
+ },
268
+ {
269
+ type: 'function',
270
+ function: {
271
+ name: 'list_matter_groups',
272
+ description: "she's Matter groups (one multicast command for several devices): members with their state and provisioning status, the derived group state, the key set.",
273
+ parameters: { type: 'object', properties: {}, required: [] },
274
+ },
275
+ },
276
+ {
277
+ type: 'function',
278
+ function: {
279
+ name: 'get_matter_fabrics',
280
+ description: 'Which controllers (fabrics — Apple Home, Home Assistant, she, …) a Matter device belongs to, and whether a pairing window is open.',
281
+ parameters: { type: 'object', properties: { node: { type: 'string', description: 'Node id or device name.' } }, required: ['node'] },
282
+ },
283
+ },
284
+ {
285
+ type: 'function',
286
+ function: {
287
+ name: 'get_thread_networks',
288
+ description:
289
+ 'The Thread networks on the LAN with their border routers (Apple TV, HomePod, OpenThread, the ESP router): vendor, model, Thread version, role, state, addresses, last seen; whether she knows the network credentials.',
290
+ parameters: { type: 'object', properties: {}, required: [] },
291
+ },
292
+ },
293
+ {
294
+ type: 'function',
295
+ function: {
296
+ name: 'get_thread_mesh',
297
+ description:
298
+ 'The Thread mesh: every Thread device in she\'s fabric with its routing role, RLOC16 and partition, its neighbours with link quality (LQI 3 good, 2 fair, 1 poor) and RSSI, the border routers, Wi-Fi devices by RSSI, bindings. For "why is X offline", "which devices route over Y", "where is the weak link".',
299
+ parameters: {
300
+ type: 'object',
301
+ properties: {
302
+ node: { type: 'string', description: 'Only this device and its links (id or name).' },
303
+ refresh: {
304
+ type: 'boolean',
305
+ description: 'Re-read the neighbour tables from the devices first (slow — sleepy devices answer late). Default false: the last read.',
306
+ },
307
+ },
308
+ required: [],
309
+ },
310
+ },
311
+ },
312
+ {
313
+ type: 'function',
314
+ function: {
315
+ name: 'get_matter_health',
316
+ description:
317
+ 'The health of the Matter devices and the Thread mesh: battery, last seen, offline periods, active alerts (low battery, offline, leader change, partition, border router offline, degraded link), firmware updates the ledger offers.',
318
+ parameters: { type: 'object', properties: { node: { type: 'string', description: 'Only this device (id or name).' } }, required: [] },
319
+ },
320
+ },
242
321
  {
243
322
  type: 'function',
244
323
  function: {
@@ -557,6 +636,20 @@ async function runTool(name, args, ctx) {
557
636
  return await toolListMatterDevices(args);
558
637
  case 'get_matter_attribute':
559
638
  return await toolGetMatterAttribute(args);
639
+ case 'get_matter_device':
640
+ return await toolGetMatterDevice(args);
641
+ case 'list_matter_bindings':
642
+ return await toolListMatterBindings(args);
643
+ case 'list_matter_groups':
644
+ return toolListMatterGroups(args);
645
+ case 'get_matter_fabrics':
646
+ return await toolGetMatterFabrics(args);
647
+ case 'get_thread_networks':
648
+ return toolGetThreadNetworks(args);
649
+ case 'get_thread_mesh':
650
+ return await toolGetThreadMesh(args);
651
+ case 'get_matter_health':
652
+ return await toolGetMatterHealth(args);
560
653
  case 'get_topic_history':
561
654
  return await toolGetTopicHistory(args, ctx);
562
655
  case 'get_timeline':
@@ -1758,6 +1851,282 @@ async function toolGetMatterAttribute({ node, endpoint, cluster, attribute } = {
1758
1851
  }
1759
1852
  }
1760
1853
 
1854
+ // ── Matter and Thread (I44) — read-only views over the controller's modules ──
1855
+
1856
+ function matterModule(name) {
1857
+ try {
1858
+ return require(`../matter/${name}`);
1859
+ } catch {
1860
+ return null;
1861
+ }
1862
+ }
1863
+
1864
+ function resolveNodeId(controller, node) {
1865
+ const s = String(node);
1866
+ const paired = controller.listPaired();
1867
+ const hit = paired.find((n) => n.nodeId === s) ?? paired.find((n) => n.name === s) ?? paired.find((n) => (n.name ?? '').toLowerCase() === s.toLowerCase());
1868
+ return hit ? hit.nodeId : null;
1869
+ }
1870
+
1871
+ const fmtJson = (v) => {
1872
+ const j = JSON.stringify(v);
1873
+ return j.length > 160 ? j.slice(0, 157) + '…' : j;
1874
+ };
1875
+
1876
+ async function toolGetMatterDevice({ node, endpoint, cluster } = {}) {
1877
+ const controller = matterController();
1878
+ if (!controller) return 'Matter controller not available.';
1879
+ if (!node) return 'node is required.';
1880
+ const nodeId = resolveNodeId(controller, node);
1881
+ if (!nodeId) return `No Matter device "${node}".`;
1882
+ const dev = controller.listPaired().find((n) => n.nodeId === nodeId);
1883
+ let endpoints;
1884
+ try {
1885
+ endpoints = controller.getEndpoints(nodeId);
1886
+ } catch (e) {
1887
+ return `Cannot read ${node}: ${e.message}`;
1888
+ }
1889
+ const lines = [`### ${dev.name || 'Unnamed'} (nodeId "${nodeId}", ${dev.online ? 'online' : 'offline'})`];
1890
+ const dcl = matterModule('dcl');
1891
+ if (dcl && typeof controller.getDeviceInfo === 'function') {
1892
+ try {
1893
+ const info = await withTimeout(controller.getDeviceInfo(nodeId), 3000);
1894
+ if (info.vendorName || info.product)
1895
+ lines.push(
1896
+ `ledger: ${info.vendorName ?? '?'}${info.product?.productName ? ' · ' + info.product.productName : ''}${info.reported?.softwareVersionString ? ' · firmware ' + info.reported.softwareVersionString : ''}`,
1897
+ );
1898
+ } catch {
1899
+ /* optional */
1900
+ }
1901
+ }
1902
+ const wantEp = endpoint === undefined || endpoint === null || endpoint === '' ? null : String(endpoint);
1903
+ const perCluster = cluster ? 1000 : 12;
1904
+ for (const ep of endpoints) {
1905
+ if (wantEp !== null && String(ep.endpointId) !== wantEp && (ep.name ?? '') !== wantEp) continue;
1906
+ const types = (ep.clusters.find((c) => c.name === 'descriptor')?.attrs?.deviceTypeList ?? []).map((t) => `0x${Number(t?.deviceType ?? t).toString(16)}`);
1907
+ lines.push(`\n- endpoint ${ep.endpointId}${ep.name ? ` "${ep.name}"` : ''}${types.length ? ` (device types ${types.join(', ')})` : ''}`);
1908
+ for (const c of ep.clusters) {
1909
+ if (cluster && c.name.toLowerCase() !== String(cluster).toLowerCase()) continue;
1910
+ const entries = Object.entries(c.attrs ?? {}).filter(
1911
+ ([k]) => !['generatedCommandList', 'acceptedCommandList', 'eventList', 'attributeList', 'clusterRevision', 'featureMap'].includes(k),
1912
+ );
1913
+ const shown = entries.slice(0, perCluster).map(([k, v]) => `${k}=${fmtJson(v)}`);
1914
+ lines.push(` - ${c.name}: ${shown.join(', ')}${entries.length > perCluster ? ` … ${entries.length - perCluster} more (ask with cluster="${c.name}")` : ''}`);
1915
+ }
1916
+ }
1917
+ return lines.join('\n');
1918
+ }
1919
+
1920
+ async function toolListMatterBindings({ node } = {}) {
1921
+ const controller = matterController();
1922
+ if (!controller || typeof controller.listBindings !== 'function') return 'Matter controller not available.';
1923
+ const want = node ? resolveNodeId(controller, node) : null;
1924
+ if (node && !want) return `No Matter device "${node}".`;
1925
+ let bindings;
1926
+ try {
1927
+ bindings = await withTimeout(controller.listBindings(), 8000);
1928
+ } catch (e) {
1929
+ return `Cannot read the bindings: ${e.message}`;
1930
+ }
1931
+ const rows = bindings.filter((b) => !want || b.sourceNodeId === want || b.node === want);
1932
+ const lines = [];
1933
+ if (rows.length === 0) lines.push(want ? `No bindings involve ${node}.` : 'No bindings on any paired device.');
1934
+ else {
1935
+ lines.push(`${rows.length} binding entr${rows.length === 1 ? 'y' : 'ies'}:`);
1936
+ for (const b of rows) {
1937
+ const src = `${b.sourceName ?? 'node ' + b.sourceNodeId}/${b.sourceEndpointName ?? 'ep ' + b.sourceEndpointId}`;
1938
+ const tgt =
1939
+ b.group !== null && b.group !== undefined
1940
+ ? (b.targetName ?? `group ${b.group}`)
1941
+ : b.own
1942
+ ? `${b.targetName ?? 'node ' + b.node}/${b.targetEndpointName ?? 'ep ' + b.endpoint}`
1943
+ : `node ${b.node} ep ${b.endpoint} (that fabric's numbering)`;
1944
+ lines.push(`- ${src} → ${tgt}: ${b.clusterName ?? 'cluster ' + b.cluster} [${b.own ? 'she' : 'another controller, fabric ' + b.fabricIndex}]`);
1945
+ }
1946
+ }
1947
+ if (typeof controller.bindingCandidates === 'function') {
1948
+ try {
1949
+ const c = controller.bindingCandidates();
1950
+ const name = (e) => `${e.nodeName ?? 'node ' + e.nodeId}/${e.name ?? 'ep ' + e.endpointId}`;
1951
+ lines.push(
1952
+ `\nCan be a source (Binding cluster + client cluster): ${c.sources.length ? c.sources.map((e) => `${name(e)} (${e.clusters.map((x) => x.label).join(', ')})`).join('; ') : 'none'}`,
1953
+ );
1954
+ lines.push(`Can be a target: ${c.targets.length ? c.targets.map((e) => `${name(e)} (${e.clusters.map((x) => x.label).join(', ')})`).join('; ') : 'none'}`);
1955
+ if (c.notBindable.length) lines.push(`Cannot bind (button events only — use she.matter.sub): ${c.notBindable.map(name).join('; ')}`);
1956
+ } catch {
1957
+ /* optional */
1958
+ }
1959
+ }
1960
+ return lines.join('\n');
1961
+ }
1962
+
1963
+ function toolListMatterGroups() {
1964
+ const controller = matterController();
1965
+ if (!controller || typeof controller.listGroups !== 'function') return 'Matter controller not available.';
1966
+ const g = controller.listGroups();
1967
+ if (!g.available) return `Groups are not available${g.error ? ': ' + g.error : ''}.`;
1968
+ if (!g.groups.length) return 'No Matter groups defined.';
1969
+ const lines = [`${g.groups.length} group(s):`];
1970
+ for (const x of g.groups) {
1971
+ const st = x.state.on === null ? 'state unknown' : x.state.on === 'all' ? 'all on' : x.state.on === 'some' ? `${x.state.onCount} of ${x.state.memberCount} on` : 'all off';
1972
+ lines.push(
1973
+ `\n### ${x.name} (group id ${x.id}, key set ${x.keySet?.shared ? 'shared' : 'own'}; ${st}${x.state.level !== null ? `, level ${x.state.level}` : ''}) — she.matter.send('group:${x.name}', null, 'onOff', 'on')`,
1974
+ );
1975
+ for (const m of x.members)
1976
+ lines.push(
1977
+ `- ${m.name ?? 'node ' + m.nodeId}/${m.endpointName ?? 'ep ' + m.endpointId}: ${m.clusterNames.join(', ')}; ${m.online ? 'online' : 'offline'}; ${m.status}${m.error ? ' (' + m.error + ')' : ''}${m.state.onOff !== null ? '; ' + (m.state.onOff ? 'on' : 'off') : ''}${m.state.level !== null ? ', level ' + m.state.level : ''}`,
1978
+ );
1979
+ }
1980
+ return lines.join('\n');
1981
+ }
1982
+
1983
+ async function toolGetMatterFabrics({ node } = {}) {
1984
+ const controller = matterController();
1985
+ if (!controller || typeof controller.listFabrics !== 'function') return 'Matter controller not available.';
1986
+ if (!node) return 'node is required.';
1987
+ const nodeId = resolveNodeId(controller, node);
1988
+ if (!nodeId) return `No Matter device "${node}".`;
1989
+ try {
1990
+ const f = await withTimeout(controller.listFabrics(nodeId), 8000);
1991
+ const lines = [
1992
+ `${node}: ${f.fabrics.length} fabric(s)${f.supportedFabrics ? ` of ${f.supportedFabrics} supported` : ''}${f.fresh ? '' : ' (from the cache — the device is offline)'}:`,
1993
+ ];
1994
+ for (const x of f.fabrics)
1995
+ lines.push(`- ${x.name}${x.vendorName ? ` (${x.vendorName})` : ''}${x.own ? ' — she' : ''}: fabric index ${x.fabricIndex}, node id there ${x.nodeId}`);
1996
+ const w = f.window;
1997
+ lines.push(
1998
+ w.status
1999
+ ? `A pairing window is open${w.codes ? ` (she opened it; manual code ${w.codes.manualCode}, closes ${isoShort(w.codes.expiresAt)})` : ''}.`
2000
+ : 'No pairing window open.',
2001
+ );
2002
+ return lines.join('\n');
2003
+ } catch (e) {
2004
+ return `Cannot read the fabrics of ${node}: ${e.message}`;
2005
+ }
2006
+ }
2007
+
2008
+ function toolGetThreadNetworks() {
2009
+ const thread = matterModule('thread');
2010
+ if (!thread) return 'Thread discovery not available.';
2011
+ const n = thread.listNetworks();
2012
+ if (!n.running) return 'Thread discovery is not running (the Matter controller is off).';
2013
+ if (!n.networks.length && !n.unknown.length) return 'No Thread border router has announced itself on the LAN (discovery is passive — give it a minute after start).';
2014
+ const lines = [`${n.networks.length} Thread network(s):`];
2015
+ for (const net of n.networks) {
2016
+ lines.push(
2017
+ `\n### ${net.networkName ?? 'unnamed'} (ext PAN ID ${net.extPanId}; credentials ${net.datasetKnown ? 'known to she' : 'unknown'}${net.partitions.length > 1 ? `; WARNING ${net.partitions.length} partition IDs — split network` : ''})`,
2018
+ );
2019
+ for (const br of net.borderRouters) {
2020
+ const st = br.state ? `${br.state.role}${br.state.bbrPrimary ? ', primary BBR' : ''}, ${br.state.interfaceStatus}` : 'state unknown';
2021
+ lines.push(
2022
+ `- ${br.vendorName ?? '?'} ${br.modelName ?? ''}${br.hostname ? ` (${br.hostname.replace(/\.$/, '')})` : ''}: Thread ${br.threadVersion ?? '?'}; ${st}; ${br.addresses.slice(0, 2).join(', ')}; seen ${br.lastSeen ? ago(Date.now() - br.lastSeen) + ' ago' : '?'}${br.rest?.manageable ? `; REST API on :${br.rest.port}${br.rest.node ? ` (${br.rest.node.role}, ${br.rest.node.numOfRouter ?? '?'} routers)` : ''}` : ''}`,
2023
+ );
2024
+ }
2025
+ }
2026
+ if (n.unknown.length) lines.push(`\nBorder routers without a network: ${n.unknown.map((b) => `${b.vendorName ?? '?'} ${b.modelName ?? ''}`).join('; ')}`);
2027
+ return lines.join('\n');
2028
+ }
2029
+
2030
+ async function toolGetThreadMesh({ node, refresh = false } = {}) {
2031
+ const mesh = matterModule('thread-mesh');
2032
+ const controller = matterController();
2033
+ if (!mesh || !controller) return 'Thread mesh not available.';
2034
+ let g;
2035
+ try {
2036
+ g = refresh ? await withTimeout(mesh.refresh(), 60000) : await withTimeout(mesh.getMesh(), 10000);
2037
+ } catch (e) {
2038
+ return `Cannot read the mesh: ${e.message}`;
2039
+ }
2040
+ const want = node ? resolveNodeId(controller, node) : null;
2041
+ if (node && !want) return `No Matter device "${node}".`;
2042
+ const nameOf = (id) => g.nodes.find((x) => x.id === id)?.name ?? id;
2043
+ const nodes = g.nodes
2044
+ .filter((n) => n.kind !== 'other')
2045
+ .filter((n) => !want || n.nodeId === want || g.links.some((l) => (l.source === `node:${want}` && l.target === n.id) || (l.target === `node:${want}` && l.source === n.id)));
2046
+ if (!nodes.length) return want ? `${node} has no Thread or Wi-Fi diagnostics.` : "No Thread device in she's fabric and no border router seen.";
2047
+ const lines = [
2048
+ `${g.nodes.filter((n) => n.kind === 'thread').length} Thread device(s) in she's fabric, ${g.nodes.filter((n) => n.kind === 'border-router').length} border router(s), ${g.links.filter((l) => l.kind === 'neighbour').length} link(s)${g.lastRefresh ? `; tables read ${ago(Date.now() - g.lastRefresh)} ago` : '; tables from the subscriptions (refresh=true re-reads them)'}${g.meshcop?.nodes ? `; ${g.meshcop.nodes} node(s) seen through a border router (MeshCoP)` : ''}:`,
2049
+ ];
2050
+ for (const n of nodes.slice(0, 40)) {
2051
+ const head =
2052
+ n.kind === 'thread'
2053
+ ? `${n.name}: ${n.role ?? 'role ?'}${n.rloc16 !== null && n.rloc16 !== undefined ? `, RLOC16 0x${n.rloc16.toString(16)}` : ''}${n.partitionId !== null && n.partitionId !== undefined ? `, partition ${n.partitionId}` : ''}${n.online ? '' : ', OFFLINE'}${n.borderRouter ? ' (border router)' : ''}`
2054
+ : n.kind === 'border-router'
2055
+ ? `${n.name} [border router${n.role ? ', ' + n.role : ''}]`
2056
+ : n.kind === 'wifi'
2057
+ ? `${n.name} [Wi-Fi, RSSI ${n.rssi ?? '?'} dBm = ${n.quality}]`
2058
+ : n.kind === 'meshcop'
2059
+ ? `${n.name} [seen through the border router${n.role ? ', ' + n.role : ''}]`
2060
+ : `${n.name} [not paired with she]`;
2061
+ const links = g.links
2062
+ .filter((l) => l.kind === 'neighbour' && (l.source === n.id || l.target === n.id))
2063
+ .slice(0, 8)
2064
+ .map(
2065
+ (l) =>
2066
+ `${nameOf(l.source === n.id ? l.target : l.source)} LQI ${l.lqi}${l.rssi !== null && l.rssi !== undefined ? ` ${l.rssi} dBm` : ''}${l.isChild ? ' child' : ''}${l.bidirectional === false ? ' one-way' : ''}`,
2067
+ );
2068
+ lines.push(`- ${head}${links.length ? ` — links: ${links.join('; ')}` : ''}`);
2069
+ }
2070
+ if (nodes.length > 40) lines.push(`… ${nodes.length - 40} more node(s); ask for one device with node.`);
2071
+ const arrows = g.links.filter((l) => l.kind === 'binding');
2072
+ if (arrows.length) lines.push(`Bindings: ${arrows.map((l) => `${nameOf(l.source)} → ${nameOf(l.target)}`).join('; ')}`);
2073
+ return lines.join('\n');
2074
+ }
2075
+
2076
+ async function toolGetMatterHealth({ node } = {}) {
2077
+ const controller = matterController();
2078
+ if (!controller) return 'Matter controller not available.';
2079
+ const want = node ? resolveNodeId(controller, node) : null;
2080
+ if (node && !want) return `No Matter device "${node}".`;
2081
+ const lines = [];
2082
+ const dh = matterModule('device-health');
2083
+ if (dh) {
2084
+ const h = dh.listHealth();
2085
+ const nodes = h.nodes.filter((n) => !want || n.nodeId === want);
2086
+ lines.push(`Devices (${h.val}${h.alerts.length ? `, ${h.alerts.length} alert(s)` : ''}):`);
2087
+ for (const n of nodes) {
2088
+ const bat = n.battery
2089
+ ? `battery ${n.battery.percent !== null ? n.battery.percent + ' %' : (n.battery.level ?? '?')}${n.battery.state && n.battery.state !== 'unknown' ? ' ' + n.battery.state : ''}`
2090
+ : 'no battery info';
2091
+ lines.push(
2092
+ `- ${n.name ?? 'node ' + n.nodeId}: ${n.online === false ? `OFFLINE since ${n.since ? isoShort(n.since) : '?'}` : n.online ? `online since ${n.since ? isoShort(n.since) : '?'}` : 'state unknown'}; last seen ${n.lastSeen ? ago(Date.now() - n.lastSeen) + ' ago' : 'never'}; ${bat}${n.offlineCount7d ? `; ${n.offlineCount7d} offline period(s) in 7 days` : ''}${n.alerts.length ? `; ALERT: ${n.alerts.map((a) => a.message).join('; ')}` : ''}`,
2093
+ );
2094
+ }
2095
+ }
2096
+ const th = matterModule('thread-health');
2097
+ if (th && !want) {
2098
+ const t = th.status();
2099
+ lines.push(
2100
+ `\nThread (${t.val}): ${t.active.length ? t.active.map((a) => `${a.kind}: ${a.message} (since ${isoShort(a.since)})`).join('; ') : 'no active alert'}${
2101
+ t.history.length
2102
+ ? `; last events: ${t.history
2103
+ .slice(0, 3)
2104
+ .map((e) => `${isoShort(e.at)} ${e.event} ${e.kind}`)
2105
+ .join(', ')}`
2106
+ : ''
2107
+ }`,
2108
+ );
2109
+ }
2110
+ if (typeof controller.listUpdates === 'function') {
2111
+ try {
2112
+ const u = await withTimeout(controller.listUpdates(), 5000);
2113
+ const avail = u.nodes.filter((n) => n.available && (!want || n.nodeId === want));
2114
+ const running = u.nodes.filter((n) => n.pending && (!want || n.nodeId === want));
2115
+ if (avail.length || running.length)
2116
+ lines.push(
2117
+ `\nFirmware: ${avail
2118
+ .map((n) => `${n.name ?? n.nodeId} ${n.softwareVersionString ?? n.softwareVersion} → ${n.available.softwareVersionString} available`)
2119
+ .concat(running.map((n) => `${n.name ?? n.nodeId} updating (${n.updateStateName ?? n.pending.status})`))
2120
+ .join('; ')}`,
2121
+ );
2122
+ else if (u.available) lines.push('\nFirmware: no update offered by the ledger for any device.');
2123
+ } catch {
2124
+ /* optional */
2125
+ }
2126
+ }
2127
+ return lines.join('\n') || 'No health data.';
2128
+ }
2129
+
1761
2130
  module.exports = {
1762
2131
  TOOL_DEFINITIONS,
1763
2132
  TOOL_DEFINITIONS_ANTHROPIC,