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.
@@ -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 };
@@ -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; sheDB: list_shedb_docs, get_shedb_doc
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) body.safeMode = true;
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);