smart-home-engine 1.53.4 → 2.0.1
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/web/assets/{index-Cp--IWao.css → index-Bu5o4KXc.css} +1 -1
- package/dist/web/assets/index-CqYTIaIG.js +306 -0
- package/dist/web/assets/{tsMode-Ci4l56iF.js → tsMode-20i8zomj.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/src/config.js +21 -1
- package/src/index.js +107 -9
- package/src/lib/crash-record.js +103 -0
- package/src/lib/secrets.js +28 -1
- package/src/matter/bindings.js +291 -0
- package/src/matter/bridge-templates.js +261 -0
- package/src/matter/controller.js +1299 -12
- package/src/matter/dcl.js +252 -0
- package/src/matter/device-health.js +337 -0
- package/src/matter/groups.js +192 -0
- package/src/matter/mqtt-bridge.js +513 -0
- package/src/matter/thread-datasets.js +285 -0
- package/src/matter/thread-health.js +278 -0
- package/src/matter/thread-mesh.js +535 -0
- package/src/matter/thread.js +267 -0
- package/src/web/ai-tools.js +369 -0
- package/src/web/matter-api.js +560 -0
- package/src/web/prompts/tools.md +2 -1
- package/src/web/server.js +4 -1
- package/dist/web/assets/index-DPRtP55L.js +0 -298
package/src/web/matter-api.js
CHANGED
|
@@ -7,8 +7,49 @@
|
|
|
7
7
|
* GET /she/matter/devices → list paired nodes
|
|
8
8
|
* POST /she/matter/commission → commission a device
|
|
9
9
|
* GET /she/matter/devices/:nodeId → node detail (endpoints + clusters)
|
|
10
|
+
* GET /she/matter/devices/:nodeId/dcl → vendor and product details from the CSA ledger
|
|
11
|
+
* GET /she/matter/thread/networks → Thread networks on the LAN with their border routers
|
|
12
|
+
* POST /she/matter/thread/refresh → probe every border router's REST API again
|
|
13
|
+
* POST /she/matter/thread/routers/:ext/probe → probe one border router
|
|
14
|
+
* GET /she/matter/health → every node's battery, last seen, offline history, alerts
|
|
15
|
+
* POST /she/matter/health/check → evaluate the alerts now
|
|
16
|
+
* GET /she/matter/devices/:nodeId/health → one node
|
|
17
|
+
* GET /she/matter/thread/datasets → the known Thread datasets, redacted
|
|
18
|
+
* POST /she/matter/thread/datasets → import { tlv, source?, note? }
|
|
19
|
+
* PUT /she/matter/thread/datasets/:ext → { meshcop } MeshCoP diagnostics on/off
|
|
20
|
+
* DELETE /she/matter/thread/datasets/:ext → forget a dataset
|
|
21
|
+
* POST /she/matter/thread/datasets/:ext/reveal → the full TLV (behind a confirmation in the UI)
|
|
22
|
+
* POST /she/matter/thread/routers/:ext/import-dataset → read the active dataset from a border router's REST API
|
|
23
|
+
* GET /she/matter/thread/health → Thread health alerts (active, history, MQTT topic)
|
|
24
|
+
* POST /she/matter/thread/health/check → evaluate now
|
|
25
|
+
* DELETE /she/matter/thread/health/history → forget the history
|
|
26
|
+
* GET /she/matter/thread/mesh → the Thread mesh graph (nodes, links, bindings)
|
|
27
|
+
* POST /she/matter/thread/mesh/refresh[/:nodeId] → re-read the neighbour and route tables
|
|
28
|
+
* GET /she/matter/bridge → the MQTT bridge's settings, devices, status
|
|
29
|
+
* PUT /she/matter/bridge → { enabled?, format?, templates? }
|
|
30
|
+
* PUT /she/matter/bridge/devices/:nodeId → { name?, publish? } (the MQTT name, on/off)
|
|
31
|
+
* GET /she/matter/groups → she's groups, members, derived state, key sets
|
|
32
|
+
* POST /she/matter/groups → create { name, keySet? }
|
|
33
|
+
* PATCH /she/matter/groups/:id → rename
|
|
34
|
+
* DELETE /she/matter/groups/:id → delete (members deprovisioned)
|
|
35
|
+
* POST /she/matter/groups/:id/members → add { nodeId, endpointId, clusters? }
|
|
36
|
+
* DELETE /she/matter/groups/:id/members/:nodeId/:ep → remove a member
|
|
37
|
+
* POST /she/matter/groups/:id/command → one multicast command { clusterName, command, args? }
|
|
38
|
+
* GET /she/matter/updates → firmware state of every node, updates the ledger offers
|
|
39
|
+
* POST /she/matter/updates/check → ask the ledger now (every online node)
|
|
40
|
+
* POST /she/matter/devices/:nodeId/update/check → ask the ledger for one node
|
|
41
|
+
* POST /she/matter/devices/:nodeId/update → start the update (the user's click)
|
|
42
|
+
* DELETE /she/matter/devices/:nodeId/update → cancel it
|
|
43
|
+
* GET /she/matter/devices/:nodeId/fabrics → the device's fabrics and pairing-window state
|
|
44
|
+
* POST /she/matter/devices/:nodeId/commissioning-window → open a pairing window (QR + manual code)
|
|
45
|
+
* DELETE /she/matter/devices/:nodeId/commissioning-window → close it
|
|
46
|
+
* DELETE /she/matter/devices/:nodeId/fabrics/:fabricIndex → remove another controller's fabric
|
|
10
47
|
* DELETE /she/matter/devices/:nodeId → unpair a device
|
|
11
48
|
* POST /she/matter/devices/:nodeId/command → invoke a cluster command
|
|
49
|
+
* GET /she/matter/bindings → every binding (own fabric resolved, other fabrics read-only)
|
|
50
|
+
* GET /she/matter/bindings/candidates → sources, targets, endpoints that cannot bind
|
|
51
|
+
* POST /she/matter/bindings → create { source, target, clusters } (ACL grant + binding)
|
|
52
|
+
* DELETE /she/matter/bindings → remove { source, target, clusters }
|
|
12
53
|
*
|
|
13
54
|
* All NodeIds are represented as decimal strings in JSON.
|
|
14
55
|
*/
|
|
@@ -94,6 +135,456 @@ router.get('/devices/:nodeId', (req, res) => {
|
|
|
94
135
|
}
|
|
95
136
|
});
|
|
96
137
|
|
|
138
|
+
// GET /she/matter/devices/:nodeId/dcl — vendor and product details from the CSA ledger (M25)
|
|
139
|
+
router.get('/devices/:nodeId/dcl', async (req, res) => {
|
|
140
|
+
if (!isReady()) return notReady(res);
|
|
141
|
+
try {
|
|
142
|
+
res.json(await getController().getDeviceInfo(req.params.nodeId));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
|
|
145
|
+
res.status(500).json({ error: err.message });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── Thread (M15) ──────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function getThread() {
|
|
152
|
+
return require('../matter/thread');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// GET /she/matter/thread/networks — the Thread networks on the LAN with their border routers
|
|
156
|
+
router.get('/thread/networks', (req, res) => {
|
|
157
|
+
if (!isReady()) return notReady(res);
|
|
158
|
+
try {
|
|
159
|
+
res.json(getThread().listNetworks());
|
|
160
|
+
} catch (err) {
|
|
161
|
+
res.status(500).json({ error: err.message });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// POST /she/matter/thread/refresh — probe every border router's REST API again
|
|
166
|
+
router.post('/thread/refresh', async (req, res) => {
|
|
167
|
+
if (!isReady()) return notReady(res);
|
|
168
|
+
try {
|
|
169
|
+
res.json(await getThread().refresh());
|
|
170
|
+
} catch (err) {
|
|
171
|
+
res.status(500).json({ error: err.message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// POST /she/matter/thread/routers/:ext/probe — probe one border router
|
|
176
|
+
router.post('/thread/routers/:ext/probe', async (req, res) => {
|
|
177
|
+
if (!isReady()) return notReady(res);
|
|
178
|
+
try {
|
|
179
|
+
res.json(await getThread().probeRouter(req.params.ext, { force: true }));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (/not found/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
182
|
+
res.status(500).json({ error: err.message });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Device health (M21) ───────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
// GET /she/matter/health — every node: battery, last seen, offline history, alerts
|
|
189
|
+
router.get('/health', (req, res) => {
|
|
190
|
+
if (!isReady()) return notReady(res);
|
|
191
|
+
try {
|
|
192
|
+
res.json(require('../matter/device-health').listHealth());
|
|
193
|
+
} catch (err) {
|
|
194
|
+
res.status(500).json({ error: err.message });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// POST /she/matter/health/check — evaluate the alerts now
|
|
199
|
+
router.post('/health/check', (req, res) => {
|
|
200
|
+
if (!isReady()) return notReady(res);
|
|
201
|
+
try {
|
|
202
|
+
const dh = require('../matter/device-health');
|
|
203
|
+
dh.check();
|
|
204
|
+
res.json(dh.listHealth());
|
|
205
|
+
} catch (err) {
|
|
206
|
+
res.status(500).json({ error: err.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// GET /she/matter/devices/:nodeId/health
|
|
211
|
+
router.get('/devices/:nodeId/health', (req, res) => {
|
|
212
|
+
if (!isReady()) return notReady(res);
|
|
213
|
+
try {
|
|
214
|
+
const h = require('../matter/device-health').nodeHealth(req.params.nodeId);
|
|
215
|
+
if (!h) return res.status(404).json({ error: `no health record for node ${req.params.nodeId}` });
|
|
216
|
+
res.json(h);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res.status(500).json({ error: err.message });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Thread datasets (M17) ─────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function getDatasets() {
|
|
225
|
+
return require('../matter/thread-datasets');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// GET /she/matter/thread/datasets — the known datasets, redacted (never the network key or PSKc)
|
|
229
|
+
router.get('/thread/datasets', (req, res) => {
|
|
230
|
+
if (!isReady()) return notReady(res);
|
|
231
|
+
try {
|
|
232
|
+
res.json(getDatasets().list());
|
|
233
|
+
} catch (err) {
|
|
234
|
+
res.status(500).json({ error: err.message });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// POST /she/matter/thread/datasets — { tlv, source?, note? } import a dataset from its TLV hex
|
|
239
|
+
router.post('/thread/datasets', (req, res) => {
|
|
240
|
+
if (!isReady()) return notReady(res);
|
|
241
|
+
const { tlv, source, note } = req.body || {};
|
|
242
|
+
if (typeof tlv !== 'string' || !tlv.trim()) return res.status(400).json({ error: 'body must contain the dataset TLV as hex' });
|
|
243
|
+
try {
|
|
244
|
+
res.status(201).json(getDatasets().add({ tlv, source, note }));
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (/not available/.test(err.message)) return res.status(503).json({ error: err.message });
|
|
247
|
+
res.status(400).json({ error: err.message });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// PUT /she/matter/thread/datasets/:ext — { meshcop } switch the MeshCoP diagnostics for the network
|
|
252
|
+
router.put('/thread/datasets/:ext', (req, res) => {
|
|
253
|
+
if (!isReady()) return notReady(res);
|
|
254
|
+
const { meshcop } = req.body || {};
|
|
255
|
+
if (typeof meshcop !== 'boolean') return res.status(400).json({ error: 'body must contain meshcop (boolean)' });
|
|
256
|
+
try {
|
|
257
|
+
res.json(getDatasets().setMeshcop(req.params.ext, meshcop));
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (/no dataset/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
260
|
+
res.status(500).json({ error: err.message });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// DELETE /she/matter/thread/datasets/:ext
|
|
265
|
+
router.delete('/thread/datasets/:ext', (req, res) => {
|
|
266
|
+
if (!isReady()) return notReady(res);
|
|
267
|
+
try {
|
|
268
|
+
res.json(getDatasets().remove(req.params.ext));
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (/no dataset/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
271
|
+
res.status(500).json({ error: err.message });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// POST /she/matter/thread/datasets/:ext/reveal — the full TLV (the UI asks for a confirmation first)
|
|
276
|
+
router.post('/thread/datasets/:ext/reveal', (req, res) => {
|
|
277
|
+
if (!isReady()) return notReady(res);
|
|
278
|
+
try {
|
|
279
|
+
res.json(getDatasets().reveal(req.params.ext));
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (/no dataset/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
282
|
+
res.status(500).json({ error: err.message });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// POST /she/matter/thread/routers/:ext/import-dataset — read the active dataset from a border router's REST API
|
|
287
|
+
router.post('/thread/routers/:ext/import-dataset', async (req, res) => {
|
|
288
|
+
if (!isReady()) return notReady(res);
|
|
289
|
+
try {
|
|
290
|
+
res.status(201).json(await getThread().importDataset(req.params.ext));
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (/not found/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
293
|
+
if (/no REST API|no active dataset/.test(err.message)) return res.status(409).json({ error: err.message });
|
|
294
|
+
res.status(500).json({ error: err.message });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// GET /she/matter/thread/health — the Thread health alerts (M26): active ones, the history, the MQTT topic
|
|
299
|
+
router.get('/thread/health', (req, res) => {
|
|
300
|
+
if (!isReady()) return notReady(res);
|
|
301
|
+
try {
|
|
302
|
+
res.json(require('../matter/thread-health').status());
|
|
303
|
+
} catch (err) {
|
|
304
|
+
res.status(500).json({ error: err.message });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// POST /she/matter/thread/health/check — evaluate now
|
|
309
|
+
router.post('/thread/health/check', async (req, res) => {
|
|
310
|
+
if (!isReady()) return notReady(res);
|
|
311
|
+
try {
|
|
312
|
+
res.json(await require('../matter/thread-health').check());
|
|
313
|
+
} catch (err) {
|
|
314
|
+
res.status(500).json({ error: err.message });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// DELETE /she/matter/thread/health/history
|
|
319
|
+
router.delete('/thread/health/history', (req, res) => {
|
|
320
|
+
if (!isReady()) return notReady(res);
|
|
321
|
+
try {
|
|
322
|
+
res.json(require('../matter/thread-health').clearHistory());
|
|
323
|
+
} catch (err) {
|
|
324
|
+
res.status(500).json({ error: err.message });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// GET /she/matter/thread/mesh — the mesh graph (M16): Thread nodes, border routers, neighbour links, bindings
|
|
329
|
+
router.get('/thread/mesh', async (req, res) => {
|
|
330
|
+
if (!isReady()) return notReady(res);
|
|
331
|
+
try {
|
|
332
|
+
res.json(await require('../matter/thread-mesh').getMesh());
|
|
333
|
+
} catch (err) {
|
|
334
|
+
res.status(500).json({ error: err.message });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// POST /she/matter/thread/mesh/refresh[/:nodeId] — re-read the neighbour and route tables
|
|
339
|
+
router.post(['/thread/mesh/refresh', '/thread/mesh/refresh/:nodeId'], async (req, res) => {
|
|
340
|
+
if (!isReady()) return notReady(res);
|
|
341
|
+
try {
|
|
342
|
+
res.json(await require('../matter/thread-mesh').refresh(req.params.nodeId));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
res.status(500).json({ error: err.message });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── The MQTT bridge (M19) ─────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
function getBridge() {
|
|
351
|
+
return require('../matter/mqtt-bridge');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// GET /she/matter/bridge — settings, defaults, the devices with their MQTT names, status
|
|
355
|
+
router.get('/bridge', (req, res) => {
|
|
356
|
+
if (!isReady()) return notReady(res);
|
|
357
|
+
try {
|
|
358
|
+
res.json(getBridge().getSettings());
|
|
359
|
+
} catch (err) {
|
|
360
|
+
res.status(500).json({ error: err.message });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// PUT /she/matter/bridge — { enabled?, format?, templates? }
|
|
365
|
+
router.put('/bridge', (req, res) => {
|
|
366
|
+
if (!isReady()) return notReady(res);
|
|
367
|
+
const { enabled, format, templates } = req.body || {};
|
|
368
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
369
|
+
try {
|
|
370
|
+
res.json(getBridge().updateSettings({ enabled, format, templates }));
|
|
371
|
+
} catch (err) {
|
|
372
|
+
res.status(400).json({ error: err.message });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// PUT /she/matter/bridge/devices/:nodeId — { name?, publish? }
|
|
377
|
+
router.put('/bridge/devices/:nodeId', (req, res) => {
|
|
378
|
+
if (!isReady()) return notReady(res);
|
|
379
|
+
const { name, publish } = req.body || {};
|
|
380
|
+
if (name !== undefined && typeof name !== 'string') return res.status(400).json({ error: 'name must be a string' });
|
|
381
|
+
if (publish !== undefined && typeof publish !== 'boolean') return res.status(400).json({ error: 'publish must be a boolean' });
|
|
382
|
+
try {
|
|
383
|
+
res.json(getBridge().setDevice(req.params.nodeId, { name, publish }));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (/not found/.test(err.message)) return res.status(404).json({ error: err.message });
|
|
386
|
+
res.status(400).json({ error: err.message });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ── Groups (M23) ──────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
function groupError(res, err) {
|
|
393
|
+
const msg = err.message || String(err);
|
|
394
|
+
if (/not found/.test(msg)) return res.status(404).json({ error: msg });
|
|
395
|
+
if (/not available|offline|exists|must be|holds at most|no cluster|not a member|missing from the store|refused/.test(msg)) return res.status(409).json({ error: msg });
|
|
396
|
+
return res.status(500).json({ error: msg });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// GET /she/matter/groups — she's groups, members, derived state, key sets (never the keys)
|
|
400
|
+
router.get('/groups', (req, res) => {
|
|
401
|
+
if (!isReady()) return notReady(res);
|
|
402
|
+
try {
|
|
403
|
+
res.json(getController().listGroups());
|
|
404
|
+
} catch (err) {
|
|
405
|
+
res.status(500).json({ error: err.message });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// POST /she/matter/groups — { name, keySet?: 'shared' | 'own' }
|
|
410
|
+
router.post('/groups', async (req, res) => {
|
|
411
|
+
if (!isReady()) return notReady(res);
|
|
412
|
+
const { name, keySet } = req.body || {};
|
|
413
|
+
if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'body must contain a name' });
|
|
414
|
+
if (keySet !== undefined && keySet !== 'shared' && keySet !== 'own') return res.status(400).json({ error: "keySet must be 'shared' or 'own'" });
|
|
415
|
+
try {
|
|
416
|
+
res.status(201).json(await getController().createGroup({ name, keySet }));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
groupError(res, err);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// PATCH /she/matter/groups/:id — { name }
|
|
423
|
+
router.patch('/groups/:id', (req, res) => {
|
|
424
|
+
if (!isReady()) return notReady(res);
|
|
425
|
+
const name = req.body?.name;
|
|
426
|
+
if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'body must contain a name' });
|
|
427
|
+
try {
|
|
428
|
+
res.json(getController().renameGroup(req.params.id, name));
|
|
429
|
+
} catch (err) {
|
|
430
|
+
groupError(res, err);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// DELETE /she/matter/groups/:id
|
|
435
|
+
router.delete('/groups/:id', async (req, res) => {
|
|
436
|
+
if (!isReady()) return notReady(res);
|
|
437
|
+
try {
|
|
438
|
+
res.json(await getController().deleteGroup(req.params.id));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
groupError(res, err);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// POST /she/matter/groups/:id/members — { nodeId, endpointId, clusters? }
|
|
445
|
+
router.post('/groups/:id/members', async (req, res) => {
|
|
446
|
+
if (!isReady()) return notReady(res);
|
|
447
|
+
const { nodeId, endpointId, clusters } = req.body || {};
|
|
448
|
+
if (nodeId === undefined || endpointId === undefined) return res.status(400).json({ error: 'body must contain nodeId and endpointId' });
|
|
449
|
+
try {
|
|
450
|
+
res.status(201).json(await getController().addGroupMember(req.params.id, { nodeId: String(nodeId), endpointId: Number(endpointId), clusters }));
|
|
451
|
+
} catch (err) {
|
|
452
|
+
groupError(res, err);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// DELETE /she/matter/groups/:id/members/:nodeId/:endpointId
|
|
457
|
+
router.delete('/groups/:id/members/:nodeId/:endpointId', async (req, res) => {
|
|
458
|
+
if (!isReady()) return notReady(res);
|
|
459
|
+
try {
|
|
460
|
+
res.json(await getController().removeGroupMember(req.params.id, req.params.nodeId, Number(req.params.endpointId)));
|
|
461
|
+
} catch (err) {
|
|
462
|
+
groupError(res, err);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// POST /she/matter/groups/:id/command — { clusterName, command, args? }
|
|
467
|
+
router.post('/groups/:id/command', async (req, res) => {
|
|
468
|
+
if (!isReady()) return notReady(res);
|
|
469
|
+
const { clusterName, command, args } = req.body || {};
|
|
470
|
+
if (!clusterName || !command) return res.status(400).json({ error: 'body must contain clusterName and command' });
|
|
471
|
+
try {
|
|
472
|
+
await getController().sendGroupCommand(req.params.id, clusterName, command, args ?? {});
|
|
473
|
+
res.json({ ok: true });
|
|
474
|
+
} catch (err) {
|
|
475
|
+
groupError(res, err);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ── Firmware updates (M22) ────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
function updateError(res, err) {
|
|
482
|
+
const msg = err.message || String(err);
|
|
483
|
+
if (/not found/.test(msg)) return res.status(404).json({ error: msg });
|
|
484
|
+
if (/offline|not available|no update is known|not newer|is busy|already|no OTA requestor|no vendor/.test(msg)) return res.status(409).json({ error: msg });
|
|
485
|
+
return res.status(500).json({ error: msg });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// GET /she/matter/updates — every node's firmware state and what the ledger offers
|
|
489
|
+
router.get('/updates', async (req, res) => {
|
|
490
|
+
if (!isReady()) return notReady(res);
|
|
491
|
+
try {
|
|
492
|
+
res.json(await getController().listUpdates());
|
|
493
|
+
} catch (err) {
|
|
494
|
+
res.status(500).json({ error: err.message });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// POST /she/matter/updates/check — ask the ledger for every online node
|
|
499
|
+
router.post('/updates/check', async (req, res) => {
|
|
500
|
+
if (!isReady()) return notReady(res);
|
|
501
|
+
try {
|
|
502
|
+
res.json(await getController().checkUpdates());
|
|
503
|
+
} catch (err) {
|
|
504
|
+
updateError(res, err);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// POST /she/matter/devices/:nodeId/update/check — ask the ledger for one node
|
|
509
|
+
router.post('/devices/:nodeId/update/check', async (req, res) => {
|
|
510
|
+
if (!isReady()) return notReady(res);
|
|
511
|
+
try {
|
|
512
|
+
res.json(await getController().checkUpdates(req.params.nodeId));
|
|
513
|
+
} catch (err) {
|
|
514
|
+
updateError(res, err);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// POST /she/matter/devices/:nodeId/update — { softwareVersion? } start the update
|
|
519
|
+
router.post('/devices/:nodeId/update', async (req, res) => {
|
|
520
|
+
if (!isReady()) return notReady(res);
|
|
521
|
+
try {
|
|
522
|
+
res.status(202).json(await getController().startUpdate(req.params.nodeId, { softwareVersion: req.body?.softwareVersion }));
|
|
523
|
+
} catch (err) {
|
|
524
|
+
updateError(res, err);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// DELETE /she/matter/devices/:nodeId/update — cancel
|
|
529
|
+
router.delete('/devices/:nodeId/update', async (req, res) => {
|
|
530
|
+
if (!isReady()) return notReady(res);
|
|
531
|
+
try {
|
|
532
|
+
res.json(await getController().cancelUpdate(req.params.nodeId));
|
|
533
|
+
} catch (err) {
|
|
534
|
+
updateError(res, err);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── Multi-admin (M20) ─────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
function fabricError(res, err) {
|
|
541
|
+
const msg = err.message || String(err);
|
|
542
|
+
if (/not found/.test(msg)) return res.status(404).json({ error: msg });
|
|
543
|
+
if (/offline|own fabric|positive integer/.test(msg)) return res.status(409).json({ error: msg });
|
|
544
|
+
return res.status(500).json({ error: msg });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// GET /she/matter/devices/:nodeId/fabrics — the fabrics the device belongs to, and its pairing-window state
|
|
548
|
+
router.get('/devices/:nodeId/fabrics', async (req, res) => {
|
|
549
|
+
if (!isReady()) return notReady(res);
|
|
550
|
+
try {
|
|
551
|
+
res.json(await getController().listFabrics(req.params.nodeId));
|
|
552
|
+
} catch (err) {
|
|
553
|
+
fabricError(res, err);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// POST /she/matter/devices/:nodeId/commissioning-window — { timeoutSeconds? } → the codes to show
|
|
558
|
+
router.post('/devices/:nodeId/commissioning-window', async (req, res) => {
|
|
559
|
+
if (!isReady()) return notReady(res);
|
|
560
|
+
try {
|
|
561
|
+
res.status(201).json(await getController().openCommissioningWindow(req.params.nodeId, { timeoutSeconds: req.body?.timeoutSeconds }));
|
|
562
|
+
} catch (err) {
|
|
563
|
+
fabricError(res, err);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// DELETE /she/matter/devices/:nodeId/commissioning-window — close it early
|
|
568
|
+
router.delete('/devices/:nodeId/commissioning-window', async (req, res) => {
|
|
569
|
+
if (!isReady()) return notReady(res);
|
|
570
|
+
try {
|
|
571
|
+
await getController().closeCommissioningWindow(req.params.nodeId);
|
|
572
|
+
res.json({ ok: true });
|
|
573
|
+
} catch (err) {
|
|
574
|
+
fabricError(res, err);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// DELETE /she/matter/devices/:nodeId/fabrics/:fabricIndex — remove another controller's fabric
|
|
579
|
+
router.delete('/devices/:nodeId/fabrics/:fabricIndex', async (req, res) => {
|
|
580
|
+
if (!isReady()) return notReady(res);
|
|
581
|
+
try {
|
|
582
|
+
res.json(await getController().removeFabric(req.params.nodeId, Number(req.params.fabricIndex)));
|
|
583
|
+
} catch (err) {
|
|
584
|
+
fabricError(res, err);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
97
588
|
// DELETE /she/matter/devices/:nodeId — unpair
|
|
98
589
|
router.delete('/devices/:nodeId', async (req, res) => {
|
|
99
590
|
if (!isReady()) return notReady(res);
|
|
@@ -122,4 +613,73 @@ router.post('/devices/:nodeId/command', async (req, res) => {
|
|
|
122
613
|
}
|
|
123
614
|
});
|
|
124
615
|
|
|
616
|
+
// ── Bindings (M13) ────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
function bindingBody(body) {
|
|
619
|
+
const { source, target, clusters } = body || {};
|
|
620
|
+
const ok = (x) => x && typeof x === 'object' && x.nodeId !== undefined && x.endpointId !== undefined;
|
|
621
|
+
const isGroup = target && typeof target === 'object' && target.group !== undefined && target.group !== null;
|
|
622
|
+
if (!ok(source) || !(ok(target) || isGroup) || !Array.isArray(clusters) || clusters.length === 0) {
|
|
623
|
+
return { error: 'body must contain source {nodeId, endpointId}, target {nodeId, endpointId} or {group}, and a non-empty clusters list' };
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
value: {
|
|
627
|
+
source: { nodeId: String(source.nodeId), endpointId: Number(source.endpointId) },
|
|
628
|
+
target: isGroup ? { group: Number(target.group) } : { nodeId: String(target.nodeId), endpointId: Number(target.endpointId) },
|
|
629
|
+
clusters,
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function bindingError(res, err) {
|
|
635
|
+
const msg = err.message || String(err);
|
|
636
|
+
if (/not found/.test(msg)) return res.status(404).json({ error: msg });
|
|
637
|
+
if (/offline|same fabric|same endpoint|at least one cluster|Unknown cluster|allows only/.test(msg)) return res.status(409).json({ error: msg });
|
|
638
|
+
return res.status(500).json({ error: msg });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// GET /she/matter/bindings
|
|
642
|
+
router.get('/bindings', async (req, res) => {
|
|
643
|
+
if (!isReady()) return notReady(res);
|
|
644
|
+
try {
|
|
645
|
+
res.json(await getController().listBindings());
|
|
646
|
+
} catch (err) {
|
|
647
|
+
res.status(500).json({ error: err.message });
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// GET /she/matter/bindings/candidates
|
|
652
|
+
router.get('/bindings/candidates', (req, res) => {
|
|
653
|
+
if (!isReady()) return notReady(res);
|
|
654
|
+
try {
|
|
655
|
+
res.json(getController().bindingCandidates());
|
|
656
|
+
} catch (err) {
|
|
657
|
+
res.status(500).json({ error: err.message });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// POST /she/matter/bindings — { source, target, clusters }
|
|
662
|
+
router.post('/bindings', async (req, res) => {
|
|
663
|
+
if (!isReady()) return notReady(res);
|
|
664
|
+
const parsed = bindingBody(req.body);
|
|
665
|
+
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
|
666
|
+
try {
|
|
667
|
+
res.status(201).json(await getController().addBinding(parsed.value));
|
|
668
|
+
} catch (err) {
|
|
669
|
+
bindingError(res, err);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// DELETE /she/matter/bindings — { source, target, clusters }
|
|
674
|
+
router.delete('/bindings', async (req, res) => {
|
|
675
|
+
if (!isReady()) return notReady(res);
|
|
676
|
+
const parsed = bindingBody(req.body);
|
|
677
|
+
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
|
678
|
+
try {
|
|
679
|
+
res.json(await getController().removeBinding(parsed.value));
|
|
680
|
+
} catch (err) {
|
|
681
|
+
bindingError(res, err);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
125
685
|
module.exports = { router };
|
package/src/web/prompts/tools.md
CHANGED
|
@@ -7,7 +7,8 @@ Use the tools; they read the running daemon. Where to start:
|
|
|
7
7
|
- which script does what: read_script (the source with its subscriptions, publishes and the scripts wired to it), list_scripts, who_publishes
|
|
8
8
|
- what a script did or logged: get_script_logs with a time window; what is still pending: list_timers
|
|
9
9
|
- "nothing happens": get_health first, then list_services for the adapters
|
|
10
|
-
- Matter: list_matter_devices (with state), get_matter_attribute;
|
|
10
|
+
- Matter: list_matter_devices (with state), get_matter_device (one device in full), get_matter_attribute; the bindings (a switch driving a device without she): list_matter_bindings; the groups: list_matter_groups; which apps a device belongs to: get_matter_fabrics; a Matter device acts only through she.matter.send in a script, or — with the Matter ↔ MQTT bridge on — through publish_mqtt on `matter/set/<device>/<endpoint>/<cluster>/<attribute>`
|
|
11
|
+
- Thread ("why is the sensor offline", "which devices route over the basement", "where is the weak link"): get_thread_mesh (roles, neighbours with link quality), get_thread_networks (border routers), get_matter_health (batteries, offline periods, Thread alerts, firmware updates); sheDB: list_shedb_docs, get_shedb_doc
|
|
11
12
|
How to call them:
|
|
12
13
|
- ask for every independent tool in the same turn — the calls of a turn run concurrently, and each turn costs a round trip
|
|
13
14
|
- use MQTT filters (`radar-x/status/#`, `zigbee2mqtt/+_workshop/#`) where the topic tree is known; a substring search is for an unknown name
|
package/src/web/server.js
CHANGED
|
@@ -150,7 +150,10 @@ app.get('/she/health', (req, res) => {
|
|
|
150
150
|
mqtt,
|
|
151
151
|
scripts: h?.scripts ?? 0,
|
|
152
152
|
};
|
|
153
|
-
if (h?.safeMode)
|
|
153
|
+
if (h?.safeMode) {
|
|
154
|
+
body.safeMode = true;
|
|
155
|
+
if (h.safeModeReason) body.safeModeReason = h.safeModeReason;
|
|
156
|
+
}
|
|
154
157
|
if (checkAuth(req)) body.version = pkg.version;
|
|
155
158
|
res.set('Cache-Control', 'no-store');
|
|
156
159
|
res.status(ok ? 200 : 503).json(body);
|