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.
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  const { Environment, ServerNode, ControllerBehavior, Logger, LogLevel, LogFormat } = require('@matter/main');
13
+ const dcl = require('./dcl');
13
14
 
14
15
  // Upper bound for a commissioning attempt — matter.js has internal retries that
15
16
  // can keep an HTTP request (and the pairing wizard) hanging for a very long time.
@@ -28,6 +29,14 @@ function _inspect(v) {
28
29
  /** @type {((msg: object) => void) | null} */
29
30
  let _broadcast = null;
30
31
 
32
+ /**
33
+ * The controller's event stream for other parts of the daemon (the MQTT bridge, M19):
34
+ * 'attr' { nodeId, endpointId, clusterName, attrName, value, ts } · 'event' { …, eventName } ·
35
+ * 'status' { nodeId, online } · 'deviceList' (after pairing, unpairing, renaming).
36
+ */
37
+ const events = new (require('events').EventEmitter)();
38
+ events.setMaxListeners(50);
39
+
31
40
  // ── Attribute / event listeners registered by sandbox scripts ───────────────
32
41
  // Map<scriptFile, Map<listenerId, { cancel: () => void }>>
33
42
  const _listeners = new Map();
@@ -107,6 +116,12 @@ function _clusterName(clusterId) {
107
116
  return clusterId;
108
117
  }
109
118
 
119
+ function _emitDeviceList() {
120
+ const devices = listPaired();
121
+ events.emit('deviceList', devices);
122
+ _broadcast?.({ type: 'matter:deviceList', devices });
123
+ }
124
+
110
125
  // ── Lifecycle ─────────────────────────────────────────────────────────────────
111
126
 
112
127
  /**
@@ -117,7 +132,7 @@ function _clusterName(clusterId) {
117
132
  * @param {{ info: Function, warn: Function, error: Function }} log Daemon logger
118
133
  * @param {((msg: object) => void) | null} [broadcastFn] WebSocket broadcast function from log-ws.js
119
134
  */
120
- async function init(storagePath, log, broadcastFn) {
135
+ async function init(storagePath, log, broadcastFn, options = {}) {
121
136
  _broadcast = broadcastFn ?? null;
122
137
  if (_server) throw new Error('Matter controller already started');
123
138
  _log = log;
@@ -209,11 +224,41 @@ async function init(storagePath, log, broadcastFn) {
209
224
 
210
225
  _server = await ServerNode.create({
211
226
  id: 'she-matter-controller',
227
+ // the fabric label devices show for she (new commissionings; existing fabrics keep theirs)
228
+ controller: { adminFabricLabel: 'she' },
212
229
  });
213
230
 
231
+ // The OTA provider endpoint with matter.js's SoftwareUpdateManager (M22): finds updates on the
232
+ // CSA ledger, serves the image over BDX when the user starts an update. Not with DCL access off.
233
+ if (options.dclOffline !== true) _setupOta(storagePath).catch((err) => _log?.warn(`matter: OTA provider not available: ${err.message}`));
234
+
214
235
  await _server.start();
215
236
  _log.info('matter controller started, storage:', storagePath);
216
237
 
238
+ // she's Matter groups (M23): the key sets and the group → key set map into she's own fabric
239
+ try {
240
+ _loadGroups(storagePath);
241
+ await _registerGroupsInFabric();
242
+ } catch (err) {
243
+ _log?.warn(`matter: groups not registered: ${err.message}`);
244
+ }
245
+
246
+ // the Thread dataset store (M17) — before discovery, so the networks' badges are right
247
+ try {
248
+ require('./thread-datasets').init({ storageDir: storagePath, log, broadcast: _broadcast });
249
+ } catch (err) {
250
+ _log?.warn(`matter: Thread dataset store not loaded: ${err.message}`);
251
+ }
252
+ // Thread border router discovery on the controller's mDNS (M15) — never holds the start
253
+ require('./thread')
254
+ .start(_server.env, { log, broadcast: _broadcast })
255
+ .catch((err) => _log?.warn(`matter: Thread discovery not started: ${err.message}`));
256
+ // the mesh's periodic table refresh (M16)
257
+ require('./thread-mesh').start({ log, broadcast: _broadcast });
258
+
259
+ // Device information from the CSA ledger (M25) — never holds the start, never throws
260
+ dcl.start({ env: _server.env, storageDir: storagePath, log, offline: options.dclOffline === true }).catch((err) => _log?.warn(`matter: DCL start failed: ${err.message}`));
261
+
217
262
  // Subscribe lifecycle events for already-paired nodes (persisted from previous session).
218
263
  for (const node of _server.peers) {
219
264
  const addr = node.peerAddress;
@@ -226,6 +271,10 @@ async function init(storagePath, log, broadcastFn) {
226
271
 
227
272
  async function close() {
228
273
  if (!_server) return;
274
+ _ota = null;
275
+ require('./thread-mesh').stop();
276
+ await require('./thread').stop();
277
+ await dcl.close();
229
278
  try {
230
279
  await _server.close();
231
280
  } catch (err) {
@@ -416,7 +465,7 @@ async function _commissionInner(options) {
416
465
  // attribute watchers now or the node stays without live updates until the
417
466
  // next daemon restart.
418
467
  _broadcastNodeAttributes(clientNode, nodeId);
419
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
468
+ _emitDeviceList();
420
469
  return nodeId;
421
470
  }
422
471
 
@@ -434,7 +483,7 @@ async function unpair(nodeId) {
434
483
  _log?.warn(`matter: decommission of ${nodeId} failed (${err.message}), force-deleting`);
435
484
  await node.delete();
436
485
  }
437
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
486
+ _emitDeviceList();
438
487
  }
439
488
 
440
489
  /**
@@ -503,17 +552,51 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
503
552
  * @returns {Promise<void>}
504
553
  */
505
554
  async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
506
- const node = _findClientNode(nodeId);
507
- const { Write, WriteResult } = require('@matter/protocol');
555
+ await _writeAttribute(_findClientNode(nodeId), endpointId, clusterName, attrName, value);
556
+ }
557
+
558
+ function _clusterDef(clusterName, attrName) {
508
559
  const clusters = require('@matter/main/clusters');
509
560
  const clusterDef = clusters[clusterName.charAt(0).toUpperCase() + clusterName.slice(1)]?.Complete;
510
561
  if (!clusterDef) throw new Error(`Unknown cluster "${clusterName}"`);
511
- if (!clusterDef.attributes?.[attrName]) throw new Error(`Unknown attribute "${clusterName}.${attrName}"`);
562
+ if (attrName !== undefined && !clusterDef.attributes?.[attrName]) throw new Error(`Unknown attribute "${clusterName}.${attrName}"`);
563
+ return clusterDef;
564
+ }
565
+
566
+ /** The interaction-level write (see setAttribute) for a resolved node. */
567
+ async function _writeAttribute(node, endpointId, clusterName, attrName, value) {
568
+ const { Write, WriteResult } = require('@matter/protocol');
569
+ const clusterDef = _clusterDef(clusterName, attrName);
512
570
  const request = Write(Write.Attribute({ endpoint: Number(endpointId), cluster: clusterDef, attributes: attrName, value }));
513
571
  const result = await node.interaction.write(request);
514
572
  WriteResult.assertSuccess(result);
515
573
  }
516
574
 
575
+ /**
576
+ * A fresh read of one attribute from the device (not the cached state): the bindings code must
577
+ * never write a fabric-scoped list from stale state. `fabricFiltered: false` returns the other
578
+ * fabrics' entries of a fabric-scoped list too (their non-sensitive fields).
579
+ */
580
+ async function _readAttribute(node, endpointId, clusterName, attrName, { fabricFiltered = true } = {}) {
581
+ const { Read } = require('@matter/protocol');
582
+ const clusterDef = _clusterDef(clusterName, attrName);
583
+ const request = Read({ fabricFilter: fabricFiltered }, Read.Attribute({ endpoint: Number(endpointId), cluster: clusterDef, attributes: attrName }));
584
+ let value;
585
+ let found = false;
586
+ for await (const chunk of node.interaction.read(request)) {
587
+ for await (const report of chunk) {
588
+ if (report.kind === 'attr-value') {
589
+ value = report.value;
590
+ found = true;
591
+ } else if (report.kind === 'attr-status') {
592
+ throw new Error(`reading ${clusterName}.${attrName} failed with status ${report.status}`);
593
+ }
594
+ }
595
+ }
596
+ if (!found) throw new Error(`reading ${clusterName}.${attrName}: the device returned no value`);
597
+ return value;
598
+ }
599
+
517
600
  /**
518
601
  * Rename a device by writing basicInformation.nodeLabel on the root endpoint —
519
602
  * the Matter-standard writable user label (max 32 chars), persisted on the
@@ -527,7 +610,7 @@ async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
527
610
  async function rename(nodeId, name) {
528
611
  await setAttribute(nodeId, 0, 'basicInformation', 'nodeLabel', String(name));
529
612
  _log?.info(`matter: renamed node ${nodeId} to "${name}"`);
530
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
613
+ _emitDeviceList();
531
614
  }
532
615
 
533
616
  // ── Commands ──────────────────────────────────────────────────────────────────
@@ -601,6 +684,10 @@ function _mandatoryCommandFields(behavior, commandName) {
601
684
  * @returns {Promise<unknown>}
602
685
  */
603
686
  async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
687
+ // she.matter.send('group:Wohnzimmer', null, 'onOff', 'on') — a group command (M23)
688
+ if (typeof nodeId === 'string' && nodeId.startsWith('group:')) {
689
+ return sendGroupCommand(nodeId.slice(6), clusterName, commandName, args);
690
+ }
604
691
  const node = _findClientNode(nodeId);
605
692
  // Use the target endpoint's own act() instead of navigating via agent.parts,
606
693
  // because Parts in @matter/node is a set-like iterable, not a Map (.get doesn't exist).
@@ -634,6 +721,1092 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
634
721
  });
635
722
  }
636
723
 
724
+ // ── Bindings and ACLs (M13) ───────────────────────────────────────────────────
725
+
726
+ const bindings = require('./bindings');
727
+
728
+ /** The root endpoint's cluster state of a node, or undefined. */
729
+ function _rootState(node, clusterName) {
730
+ try {
731
+ for (const ep of node.endpoints) if ((ep.number ?? 0) === 0) return ep.state?.[clusterName];
732
+ } catch {
733
+ /* offline */
734
+ }
735
+ return undefined;
736
+ }
737
+
738
+ /** she's own node ID in the fabric the peer was commissioned into. */
739
+ function _sheNodeIdFor(node) {
740
+ const { FabricManager } = require('@matter/protocol');
741
+ const fabric = _server.env.get(FabricManager).for(node.peerAddress.fabricIndex);
742
+ return fabric.rootNodeId;
743
+ }
744
+
745
+ /** The index the *device* gives she's fabric (what its fabric-scoped entries carry). */
746
+ function _deviceFabricIndex(node) {
747
+ const v = _rootState(node, 'operationalCredentials')?.currentFabricIndex;
748
+ return v === undefined || v === null ? undefined : Number(v);
749
+ }
750
+
751
+ function _requireOnline(node, what) {
752
+ if (!node.lifecycle?.isOnline) throw new Error(`${what} (${_getDeviceName(node) ?? _nodeIdStr(node.peerAddress.nodeId)}) is offline`);
753
+ }
754
+
755
+ /** Endpoint data the bindings classifier needs: device types, server and client cluster IDs. */
756
+ function _endpointSummary(node) {
757
+ const out = [];
758
+ for (const ep of node.endpoints) {
759
+ const d = ep.state?.descriptor;
760
+ out.push({
761
+ endpointId: ep.number ?? 0,
762
+ name: _getEndpointName(ep),
763
+ deviceTypes: (d?.deviceTypeList ?? []).map((t) => Number(t?.deviceType ?? t)),
764
+ serverList: (d?.serverList ?? []).map(Number),
765
+ clientList: (d?.clientList ?? []).map(Number),
766
+ });
767
+ }
768
+ return out;
769
+ }
770
+
771
+ /**
772
+ * Every binding of every commissioned node: she's own fabric's entries resolved to device names,
773
+ * the other fabrics' entries (D-10) with node IDs in that fabric's numbering. Online nodes are
774
+ * read fresh with fabric filtering off; offline ones show what the cache holds (own fabric).
775
+ */
776
+ async function listBindings() {
777
+ if (!_server) return [];
778
+ const result = [];
779
+ for (const node of _server.peers) {
780
+ const addr = node.peerAddress;
781
+ if (!addr) continue;
782
+ const nodeId = _nodeIdStr(addr.nodeId);
783
+ const deviceFabric = _deviceFabricIndex(node);
784
+ for (const ep of node.endpoints) {
785
+ const endpointId = ep.number ?? 0;
786
+ const cached = ep.state?.binding;
787
+ if (!cached) continue;
788
+ let entries = Array.isArray(cached.binding) ? cached.binding : [];
789
+ let fresh = false;
790
+ if (node.lifecycle?.isOnline) {
791
+ try {
792
+ entries = (await _readAttribute(node, endpointId, 'binding', 'binding', { fabricFiltered: false })) ?? [];
793
+ fresh = true;
794
+ } catch (err) {
795
+ _log?.debug(`matter: reading bindings of ${nodeId}/${endpointId} failed, using cached state: ${err.message}`);
796
+ }
797
+ }
798
+ for (const b of entries) {
799
+ const fi = b.fabricIndex === undefined || b.fabricIndex === null ? undefined : Number(b.fabricIndex);
800
+ const own = !fresh || fi === undefined || deviceFabric === undefined || fi === deviceFabric;
801
+ const entry = {
802
+ sourceNodeId: nodeId,
803
+ sourceName: _getDeviceName(node),
804
+ sourceEndpointId: endpointId,
805
+ sourceEndpointName: _getEndpointName(ep),
806
+ own,
807
+ fabricIndex: fi,
808
+ node: b.node === undefined || b.node === null ? null : String(b.node),
809
+ group: b.group === undefined || b.group === null ? null : Number(b.group),
810
+ endpoint: b.endpoint === undefined || b.endpoint === null ? null : Number(b.endpoint),
811
+ cluster: b.cluster === undefined || b.cluster === null ? null : Number(b.cluster),
812
+ clusterName: b.cluster === undefined || b.cluster === null ? null : bindings.clusterNameOf(Number(b.cluster)),
813
+ targetName: null,
814
+ targetEndpointName: null,
815
+ };
816
+ if (own && entry.group !== null) {
817
+ const g = _groupStore?.groups.find((x) => Number(x.id) === entry.group);
818
+ entry.targetName = g ? `group ${g.name}` : null;
819
+ }
820
+ if (own && entry.node !== null) {
821
+ try {
822
+ const target = _findClientNode(entry.node);
823
+ entry.targetName = _getDeviceName(target);
824
+ if (entry.endpoint !== null) entry.targetEndpointName = _getEndpointName(_resolveEndpoint(target, entry.endpoint));
825
+ } catch {
826
+ /* a node she does not know (any more) */
827
+ }
828
+ }
829
+ result.push(entry);
830
+ }
831
+ }
832
+ }
833
+ return result;
834
+ }
835
+
836
+ /** Binding sources, targets and the endpoints that cannot bind, from the descriptor clusters. */
837
+ function bindingCandidates() {
838
+ if (!_server) return { sources: [], targets: [], notBindable: [] };
839
+ const nodes = [];
840
+ for (const node of _server.peers) {
841
+ const addr = node.peerAddress;
842
+ if (!addr) continue;
843
+ nodes.push({ nodeId: _nodeIdStr(addr.nodeId), name: _getDeviceName(node), online: node.lifecycle?.isOnline ?? false, endpoints: _endpointSummary(node) });
844
+ }
845
+ return bindings.classifyEndpoints(nodes);
846
+ }
847
+
848
+ /**
849
+ * Create a binding: grant the source Operate on the target (ACL, guarded, read back), then add
850
+ * the entries to the source's Binding list (read back); the ACL change is rolled back when the
851
+ * binding write fails.
852
+ *
853
+ * @param {{ source: { nodeId: string, endpointId: number }, target: { nodeId: string, endpointId: number }, clusters: (string|number)[] }} opts
854
+ * @returns {Promise<{ ok: true, clusters: number[], aclChanged: boolean, bindingsAdded: number[] }>}
855
+ */
856
+ async function addBinding({ source, target, clusters }) {
857
+ if (!_server) throw new Error('Matter controller not started');
858
+ if (!Array.isArray(clusters) || clusters.length === 0) throw new Error('clusters must name at least one cluster');
859
+ if (target && target.group !== undefined && target.group !== null) return _addGroupBinding({ source, group: target.group, clusters });
860
+ const clusterIds = clusters.map(bindings.clusterIdOf);
861
+ const sourceNode = _findClientNode(source.nodeId);
862
+ const targetNode = _findClientNode(target.nodeId);
863
+ if (sourceNode === targetNode && Number(source.endpointId) === Number(target.endpointId)) throw new Error('source and target are the same endpoint');
864
+ _requireOnline(sourceNode, 'the source');
865
+ _requireOnline(targetNode, 'the target');
866
+ if (sourceNode.peerAddress.fabricIndex !== targetNode.peerAddress.fabricIndex) throw new Error('source and target are not in the same fabric');
867
+ const sheNodeId = _sheNodeIdFor(targetNode);
868
+ const subject = sourceNode.peerAddress.nodeId;
869
+ const targetEp = Number(target.endpointId);
870
+ const sourceEp = Number(source.endpointId);
871
+ const label = `${_getDeviceName(sourceNode) ?? source.nodeId}/${sourceEp} → ${_getDeviceName(targetNode) ?? target.nodeId}/${targetEp}`;
872
+
873
+ // 1. the target's ACL: read fresh, extend, guard, write, read back
874
+ const aclBefore = (await _readAttribute(targetNode, 0, 'accessControl', 'acl')) ?? [];
875
+ const limits = _rootState(targetNode, 'accessControl');
876
+ const aclAfter = bindings.grantOperate(aclBefore, { subject, endpoint: targetEp, clusters: clusterIds, sheNodeId, limits, fabricIndex: _deviceFabricIndex(targetNode) });
877
+ const aclChanged = JSON.stringify(_jsonSafe(aclAfter)) !== JSON.stringify(_jsonSafe(aclBefore));
878
+ if (aclChanged) {
879
+ bindings.assertSafeAcl(aclAfter, sheNodeId);
880
+ _log?.info(`matter: binding ${label}: granting Operate on the target`);
881
+ await _writeAttribute(targetNode, 0, 'accessControl', 'acl', aclAfter);
882
+ const readBack = (await _readAttribute(targetNode, 0, 'accessControl', 'acl')) ?? [];
883
+ if (!bindings.findAdminEntry(readBack, sheNodeId)) throw new Error("the target's ACL lost she's Administer entry after the write — check the device immediately");
884
+ const granted = bindings.grantedClusters(readBack, { subject, endpoint: targetEp });
885
+ const missing = clusterIds.filter((id) => !granted.includes(id));
886
+ if (missing.length) throw new Error(`the target did not keep the access grant for cluster(s) ${missing.join(', ')}`);
887
+ }
888
+
889
+ // 2. the source's Binding list: read fresh, append, write, read back — roll the ACL back on failure
890
+ try {
891
+ const before = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
892
+ const { bindings: after, added } = bindings.addBindingEntries(before, {
893
+ node: targetNode.peerAddress.nodeId,
894
+ endpoint: targetEp,
895
+ clusters: clusterIds,
896
+ fabricIndex: _deviceFabricIndex(sourceNode),
897
+ });
898
+ if (added.length) {
899
+ _log?.info(`matter: binding ${label}: writing the source's binding list`);
900
+ await _writeAttribute(sourceNode, sourceEp, 'binding', 'binding', after);
901
+ const readBack = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
902
+ const bound = bindings.clustersStillBound(readBack, { node: targetNode.peerAddress.nodeId, endpoint: targetEp });
903
+ const missing = clusterIds.filter((id) => !bound.includes(id));
904
+ if (missing.length) throw new Error(`the source did not keep the binding for cluster(s) ${missing.join(', ')}`);
905
+ }
906
+ _broadcast?.({ type: 'matter:bindings' });
907
+ return { ok: true, clusters: clusterIds, aclChanged, bindingsAdded: added };
908
+ } catch (err) {
909
+ if (aclChanged) {
910
+ _log?.warn(`matter: binding ${label} failed (${err.message}) — rolling the target's ACL back`);
911
+ try {
912
+ bindings.assertSafeAcl(aclBefore, sheNodeId);
913
+ await _writeAttribute(targetNode, 0, 'accessControl', 'acl', aclBefore);
914
+ } catch (rollbackErr) {
915
+ _log?.error(`matter: ACL rollback on ${target.nodeId} failed: ${rollbackErr.message}`);
916
+ }
917
+ }
918
+ throw err;
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Remove a binding: drop the entries from the source's Binding list, then revoke the ACL grant
924
+ * for the clusters no other binding of that source still needs. A target that is offline keeps
925
+ * its grant (reported in the result) — the binding itself is gone.
926
+ */
927
+ async function removeBinding({ source, target, clusters }) {
928
+ if (!_server) throw new Error('Matter controller not started');
929
+ if (!Array.isArray(clusters) || clusters.length === 0) throw new Error('clusters must name at least one cluster');
930
+ if (target && target.group !== undefined && target.group !== null) return _removeGroupBinding({ source, group: target.group, clusters });
931
+ const clusterIds = clusters.map(bindings.clusterIdOf);
932
+ const sourceNode = _findClientNode(source.nodeId);
933
+ _requireOnline(sourceNode, 'the source');
934
+ const sourceEp = Number(source.endpointId);
935
+ const targetEp = Number(target.endpointId);
936
+ let targetNode = null;
937
+ try {
938
+ targetNode = _findClientNode(target.nodeId);
939
+ } catch {
940
+ /* the target may be gone — the binding entry is removed anyway */
941
+ }
942
+ const targetNodeId = targetNode ? targetNode.peerAddress.nodeId : BigInt(target.nodeId);
943
+
944
+ const before = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
945
+ const { bindings: after, removed } = bindings.removeBindingEntries(before, { node: targetNodeId, endpoint: targetEp, clusters: clusterIds });
946
+ if (removed.length) {
947
+ await _writeAttribute(sourceNode, sourceEp, 'binding', 'binding', after);
948
+ const readBack = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
949
+ const still = bindings.clustersStillBound(readBack, { node: targetNodeId, endpoint: targetEp });
950
+ const left = clusterIds.filter((id) => still.includes(id));
951
+ if (left.length) throw new Error(`the source kept the binding for cluster(s) ${left.join(', ')}`);
952
+ }
953
+
954
+ // which of the clusters does another entry of this source still bind on that endpoint?
955
+ const stillBound = bindings.clustersStillBound(after, { node: targetNodeId, endpoint: targetEp });
956
+ const toRevoke = clusterIds.filter((id) => !stillBound.includes(id));
957
+ let aclRevoked = false;
958
+ let warning = null;
959
+ if (toRevoke.length && targetNode) {
960
+ if (!targetNode.lifecycle?.isOnline) {
961
+ warning = 'the target is offline — its access grant for the source stays until it is back and the binding is removed again';
962
+ } else {
963
+ const sheNodeId = _sheNodeIdFor(targetNode);
964
+ const acl = (await _readAttribute(targetNode, 0, 'accessControl', 'acl')) ?? [];
965
+ const aclAfter = bindings.revokeOperate(acl, { subject: sourceNode.peerAddress.nodeId, endpoint: targetEp, clusters: toRevoke, sheNodeId });
966
+ if (JSON.stringify(_jsonSafe(aclAfter)) !== JSON.stringify(_jsonSafe(acl))) {
967
+ bindings.assertSafeAcl(aclAfter, sheNodeId);
968
+ await _writeAttribute(targetNode, 0, 'accessControl', 'acl', aclAfter);
969
+ const readBack = (await _readAttribute(targetNode, 0, 'accessControl', 'acl')) ?? [];
970
+ if (!bindings.findAdminEntry(readBack, sheNodeId)) throw new Error("the target's ACL lost she's Administer entry after the write — check the device immediately");
971
+ aclRevoked = true;
972
+ }
973
+ }
974
+ } else if (toRevoke.length && !targetNode) {
975
+ warning = 'the target is not paired with she — nothing to revoke there';
976
+ }
977
+ _broadcast?.({ type: 'matter:bindings' });
978
+ return { ok: true, clusters: clusterIds, bindingsRemoved: removed, aclRevoked, warning };
979
+ }
980
+
981
+ // ── Groups (M23) ──────────────────────────────────────────────────────────────
982
+
983
+ const groups = require('./groups');
984
+
985
+ /** @type {{ keySets: object, groups: object[] } | null} null = the store cannot be read (no key) */
986
+ let _groupStore = null;
987
+ let _groupStorageDir = null;
988
+ let _groupStoreError = null;
989
+
990
+ function _loadGroups(storagePath) {
991
+ _groupStorageDir = storagePath;
992
+ try {
993
+ _groupStore = groups.readStore(storagePath);
994
+ _groupStoreError = null;
995
+ if (_groupStore.groups.length) _log?.info(`matter: ${_groupStore.groups.length} group(s), ${Object.keys(_groupStore.keySets).length} key set(s) loaded`);
996
+ } catch (err) {
997
+ _groupStore = null;
998
+ _groupStoreError = err.message;
999
+ _log?.error(`matter: the group store cannot be read: ${err.message}`);
1000
+ }
1001
+ }
1002
+
1003
+ function _saveGroups() {
1004
+ groups.writeStore(_groupStorageDir, _groupStore);
1005
+ }
1006
+
1007
+ function _requireGroups() {
1008
+ if (!_server) throw new Error('Matter controller not started');
1009
+ if (!_groupStore) throw new Error(`the group store is not available: ${_groupStoreError ?? 'not loaded'}`);
1010
+ return _groupStore;
1011
+ }
1012
+
1013
+ /** she's own fabrics (one, normally). */
1014
+ function _ownFabrics() {
1015
+ const { FabricManager } = require('@matter/protocol');
1016
+ return _server.env.get(FabricManager).fabrics;
1017
+ }
1018
+
1019
+ /** Register every key set and the group → key set map in she's fabric(s), so she can send group messages. */
1020
+ async function _registerGroupsInFabric() {
1021
+ if (!_groupStore) return;
1022
+ const { GroupId } = require('@matter/types');
1023
+ for (const fabric of _ownFabrics()) {
1024
+ for (const ks of Object.values(_groupStore.keySets)) await fabric.groups.setFromGroupKeySet(groups.groupKeySetStruct(ks));
1025
+ fabric.groups.groupKeyIdMap = new Map(_groupStore.groups.map((g) => [GroupId(Number(g.id)), Number(g.keySetId)]));
1026
+ }
1027
+ }
1028
+
1029
+ function _findGroup(ref) {
1030
+ const store = _requireGroups();
1031
+ const str = String(ref);
1032
+ const g = store.groups.find((x) => String(x.id) === str) ?? store.groups.find((x) => x.name === str);
1033
+ if (!g) throw new Error(`Matter group not found: ${ref}`);
1034
+ return g;
1035
+ }
1036
+
1037
+ function _memberClusters(node, endpointId) {
1038
+ const ep = _endpointSummary(node).find((e) => e.endpointId === Number(endpointId));
1039
+ if (!ep) throw new Error(`Endpoint not found: ${endpointId}`);
1040
+ const ids = Object.values(bindings.BINDABLE_CLUSTERS)
1041
+ .map((d) => d.id)
1042
+ .filter((id) => ep.serverList.includes(id));
1043
+ if (!ids.length) throw new Error('the endpoint has no cluster a group could control (On/Off, Level Control, …)');
1044
+ return ids;
1045
+ }
1046
+
1047
+ /** The key set and the group's map entry on a device — written only when missing; limits checked first. */
1048
+ async function _ensureKeySetOnDevice(node, ks, groupId, { groupIsNew = true } = {}) {
1049
+ const nodeId = _nodeIdStr(node.peerAddress.nodeId);
1050
+ const idsResp = await sendCommand(nodeId, 0, 'groupKeyManagement', 'keySetReadAllIndices');
1051
+ const ids = (idsResp?.groupKeySetIDs ?? idsResp?.groupKeySetIds ?? []).map(Number);
1052
+ const map = (await _readAttribute(node, 0, 'groupKeyManagement', 'groupKeyMap')) ?? [];
1053
+ let groupsOnDevice = 0;
1054
+ try {
1055
+ const table = (await _readAttribute(node, 0, 'groupKeyManagement', 'groupTable')) ?? [];
1056
+ groupsOnDevice = new Set(table.map((t) => Number(t.groupId))).size;
1057
+ } catch {
1058
+ groupsOnDevice = new Set(map.map((e) => Number(e.groupId))).size;
1059
+ }
1060
+ const keySetIsNew = !ids.includes(Number(ks.id));
1061
+ groups.assertWithinLimits({
1062
+ keySetsOnDevice: ids.length,
1063
+ keySetIsNew,
1064
+ groupsOnDevice,
1065
+ groupIsNew: groupIsNew && !map.some((e) => Number(e.groupId) === Number(groupId)),
1066
+ limits: _rootState(node, 'groupKeyManagement'),
1067
+ });
1068
+ if (keySetIsNew) {
1069
+ _log?.info(`matter: writing key set ${ks.id} to node ${nodeId}`);
1070
+ await sendCommand(nodeId, 0, 'groupKeyManagement', 'keySetWrite', { groupKeySet: groups.groupKeySetStruct(ks) });
1071
+ }
1072
+ const after = groups.groupKeyMapAdd(map, { groupId, keySetId: ks.id, fabricIndex: _deviceFabricIndex(node) });
1073
+ if (JSON.stringify(_jsonSafe(after)) !== JSON.stringify(_jsonSafe(map))) {
1074
+ await _writeAttribute(node, 0, 'groupKeyManagement', 'groupKeyMap', after);
1075
+ const back = (await _readAttribute(node, 0, 'groupKeyManagement', 'groupKeyMap')) ?? [];
1076
+ if (!back.some((e) => Number(e.groupId) === Number(groupId) && Number(e.groupKeySetId) === Number(ks.id)))
1077
+ throw new Error('the device did not keep the group key map entry');
1078
+ }
1079
+ }
1080
+
1081
+ /** Drop the group's map entry and, when nothing else maps it, the key set — best effort. */
1082
+ async function _dropKeySetOnDevice(node, ks, groupId) {
1083
+ const nodeId = _nodeIdStr(node.peerAddress.nodeId);
1084
+ const map = (await _readAttribute(node, 0, 'groupKeyManagement', 'groupKeyMap')) ?? [];
1085
+ const after = groups.groupKeyMapRemove(map, groupId);
1086
+ if (after.length !== map.length) await _writeAttribute(node, 0, 'groupKeyManagement', 'groupKeyMap', after);
1087
+ if (!groups.keySetStillMapped(after, ks.id)) {
1088
+ try {
1089
+ await sendCommand(nodeId, 0, 'groupKeyManagement', 'keySetRemove', { groupKeySetId: Number(ks.id) });
1090
+ } catch (err) {
1091
+ _log?.debug(`matter: key set ${ks.id} not removed from node ${nodeId}: ${err.message}`);
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ async function _provisionMember(group, ks, node, endpointId, clusters) {
1097
+ _requireOnline(node, 'the member');
1098
+ const nodeId = _nodeIdStr(node.peerAddress.nodeId);
1099
+ await _ensureKeySetOnDevice(node, ks, group.id);
1100
+ const resp = await sendCommand(nodeId, endpointId, 'groups', 'addGroup', { groupId: Number(group.id), groupName: group.name });
1101
+ if (resp?.status !== undefined && Number(resp.status) !== 0) throw new Error(`AddGroup refused with status ${resp.status}`);
1102
+ const sheNodeId = _sheNodeIdFor(node);
1103
+ const acl = (await _readAttribute(node, 0, 'accessControl', 'acl')) ?? [];
1104
+ const aclAfter = bindings.grantOperate(acl, {
1105
+ subject: Number(group.id),
1106
+ authMode: bindings.AUTH_MODE.group,
1107
+ endpoint: Number(endpointId),
1108
+ clusters,
1109
+ sheNodeId,
1110
+ limits: _rootState(node, 'accessControl'),
1111
+ fabricIndex: _deviceFabricIndex(node),
1112
+ });
1113
+ if (JSON.stringify(_jsonSafe(aclAfter)) !== JSON.stringify(_jsonSafe(acl))) {
1114
+ bindings.assertSafeAcl(aclAfter, sheNodeId);
1115
+ await _writeAttribute(node, 0, 'accessControl', 'acl', aclAfter);
1116
+ const back = (await _readAttribute(node, 0, 'accessControl', 'acl')) ?? [];
1117
+ if (!bindings.findAdminEntry(back, sheNodeId)) throw new Error("the device's ACL lost she's Administer entry after the write — check the device immediately");
1118
+ const granted = bindings.grantedClusters(back, { subject: Number(group.id), endpoint: Number(endpointId), authMode: bindings.AUTH_MODE.group });
1119
+ const missing = clusters.filter((id) => !granted.includes(id));
1120
+ if (missing.length) throw new Error(`the device did not keep the group's access grant for cluster(s) ${missing.join(', ')}`);
1121
+ }
1122
+ }
1123
+
1124
+ /** Reverse of _provisionMember, each step best effort; the other groups of she on the device keep their key set. */
1125
+ async function _deprovisionMember(group, ks, node, endpointId, clusters) {
1126
+ const nodeId = _nodeIdStr(node.peerAddress.nodeId);
1127
+ const problems = [];
1128
+ try {
1129
+ await sendCommand(nodeId, endpointId, 'groups', 'removeGroup', { groupId: Number(group.id) });
1130
+ } catch (err) {
1131
+ problems.push(`RemoveGroup: ${err.message}`);
1132
+ }
1133
+ try {
1134
+ const sheNodeId = _sheNodeIdFor(node);
1135
+ const acl = (await _readAttribute(node, 0, 'accessControl', 'acl')) ?? [];
1136
+ const aclAfter = bindings.revokeOperate(acl, { subject: Number(group.id), authMode: bindings.AUTH_MODE.group, endpoint: Number(endpointId), clusters, sheNodeId });
1137
+ if (JSON.stringify(_jsonSafe(aclAfter)) !== JSON.stringify(_jsonSafe(acl))) {
1138
+ bindings.assertSafeAcl(aclAfter, sheNodeId);
1139
+ await _writeAttribute(node, 0, 'accessControl', 'acl', aclAfter);
1140
+ }
1141
+ } catch (err) {
1142
+ problems.push(`ACL: ${err.message}`);
1143
+ }
1144
+ // the map entry stays while another endpoint of this node is in the group
1145
+ const stillMember = group.members.some((m) => m.nodeId === nodeId && Number(m.endpointId) !== Number(endpointId));
1146
+ if (!stillMember) {
1147
+ try {
1148
+ await _dropKeySetOnDevice(node, ks, group.id);
1149
+ } catch (err) {
1150
+ problems.push(`key set: ${err.message}`);
1151
+ }
1152
+ }
1153
+ return problems;
1154
+ }
1155
+
1156
+ function _memberView(m) {
1157
+ let node = null;
1158
+ try {
1159
+ node = _findClientNode(m.nodeId);
1160
+ } catch {
1161
+ /* unpaired since */
1162
+ }
1163
+ let endpointName = null;
1164
+ let state = { onOff: null, level: null };
1165
+ if (node) {
1166
+ try {
1167
+ const ep = _resolveEndpoint(node, m.endpointId);
1168
+ endpointName = _getEndpointName(ep);
1169
+ const onOff = ep.state?.onOff?.onOff;
1170
+ const level = ep.state?.levelControl?.currentLevel;
1171
+ state = { onOff: typeof onOff === 'boolean' ? onOff : null, level: level === undefined || level === null ? null : Number(level) };
1172
+ } catch {
1173
+ /* endpoint gone */
1174
+ }
1175
+ }
1176
+ return {
1177
+ nodeId: m.nodeId,
1178
+ endpointId: Number(m.endpointId),
1179
+ name: node ? _getDeviceName(node) : null,
1180
+ endpointName,
1181
+ online: node?.lifecycle?.isOnline ?? false,
1182
+ paired: !!node,
1183
+ clusters: (m.clusters ?? []).map(Number),
1184
+ clusterNames: (m.clusters ?? []).map((id) => bindings.clusterNameOf(Number(id)) ?? String(id)),
1185
+ status: m.status ?? 'ok',
1186
+ error: m.error ?? null,
1187
+ state,
1188
+ };
1189
+ }
1190
+
1191
+ function _groupView(g) {
1192
+ const members = (g.members ?? []).map(_memberView);
1193
+ const ks = _groupStore.keySets[String(g.keySetId)];
1194
+ return {
1195
+ id: Number(g.id),
1196
+ name: g.name,
1197
+ keySetId: Number(g.keySetId),
1198
+ keySet: ks ? groups.publicKeySet(ks) : null,
1199
+ members,
1200
+ state: groups.derivedState(members.map((m) => m.state)),
1201
+ created: g.created ?? null,
1202
+ };
1203
+ }
1204
+
1205
+ /** she's groups with their members, the derived state and the key sets (never the keys). */
1206
+ function listGroups() {
1207
+ if (!_server) return { available: false, error: 'Matter controller not started', groups: [], keySets: [] };
1208
+ if (!_groupStore) return { available: false, error: _groupStoreError, groups: [], keySets: [] };
1209
+ return {
1210
+ available: true,
1211
+ error: null,
1212
+ groups: _groupStore.groups.map(_groupView),
1213
+ keySets: Object.values(_groupStore.keySets).map(groups.publicKeySet),
1214
+ sharedKeySetId: groups.SHARED_KEY_SET_ID,
1215
+ };
1216
+ }
1217
+
1218
+ function _sharedKeySet() {
1219
+ const store = _requireGroups();
1220
+ const id = String(groups.SHARED_KEY_SET_ID);
1221
+ if (!store.keySets[id]) store.keySets[id] = groups.newKeySet(groups.SHARED_KEY_SET_ID, { name: 'shared', shared: true });
1222
+ return store.keySets[id];
1223
+ }
1224
+
1225
+ /**
1226
+ * Create a group: a free application group ID, the shared key set (default) or one of its own.
1227
+ * @param {{ name: string, keySet?: 'shared'|'own' }} opts
1228
+ */
1229
+ async function createGroup({ name, keySet = 'shared' } = {}) {
1230
+ const store = _requireGroups();
1231
+ if (!groups.validName(name)) throw new Error(`the group name must be 1 to ${groups.GROUP_NAME_MAX} bytes`);
1232
+ name = String(name).trim();
1233
+ if (store.groups.some((g) => g.name === name)) throw new Error(`a group named "${name}" exists`);
1234
+ const id = groups.nextGroupId(store.groups);
1235
+ let ks;
1236
+ if (keySet === 'own') {
1237
+ ks = groups.newKeySet(groups.nextKeySetId(store.keySets), { name, shared: false });
1238
+ store.keySets[String(ks.id)] = ks;
1239
+ } else {
1240
+ ks = _sharedKeySet();
1241
+ }
1242
+ const g = { id, name, keySetId: ks.id, members: [], created: Date.now() };
1243
+ store.groups.push(g);
1244
+ _saveGroups();
1245
+ await _registerGroupsInFabric();
1246
+ _log?.info(`matter: group "${name}" (${id}) created with key set ${ks.id}${ks.shared ? ' (shared)' : ''}`);
1247
+ _broadcast?.({ type: 'matter:groups' });
1248
+ return _groupView(g);
1249
+ }
1250
+
1251
+ /** Rename a group in she (the name the devices hold is cosmetic and stays). */
1252
+ function renameGroup(ref, name) {
1253
+ const g = _findGroup(ref);
1254
+ if (!groups.validName(name)) throw new Error(`the group name must be 1 to ${groups.GROUP_NAME_MAX} bytes`);
1255
+ g.name = String(name).trim();
1256
+ _saveGroups();
1257
+ _broadcast?.({ type: 'matter:groups' });
1258
+ return _groupView(g);
1259
+ }
1260
+
1261
+ /** Delete a group: every member is deprovisioned (best effort), an own key set that is no longer used goes too. */
1262
+ async function deleteGroup(ref) {
1263
+ const store = _requireGroups();
1264
+ const g = _findGroup(ref);
1265
+ const ks = store.keySets[String(g.keySetId)];
1266
+ const problems = [];
1267
+ for (const m of [...g.members]) {
1268
+ try {
1269
+ const node = _findClientNode(m.nodeId);
1270
+ if (!node.lifecycle?.isOnline) {
1271
+ problems.push(`${_getDeviceName(node) ?? m.nodeId}/${m.endpointId}: offline — the device keeps the group until it is removed again`);
1272
+ continue;
1273
+ }
1274
+ g.members = g.members.filter((x) => x !== m);
1275
+ problems.push(...(await _deprovisionMember(g, ks, node, m.endpointId, m.clusters ?? [])).map((p) => `${_getDeviceName(node) ?? m.nodeId}/${m.endpointId}: ${p}`));
1276
+ } catch (err) {
1277
+ problems.push(`${m.nodeId}/${m.endpointId}: ${err.message}`);
1278
+ }
1279
+ }
1280
+ store.groups = store.groups.filter((x) => x !== g);
1281
+ if (ks && !ks.shared && !store.groups.some((x) => Number(x.keySetId) === Number(ks.id))) delete store.keySets[String(ks.id)];
1282
+ _saveGroups();
1283
+ await _registerGroupsInFabric();
1284
+ _log?.info(`matter: group "${g.name}" (${g.id}) deleted`);
1285
+ _broadcast?.({ type: 'matter:groups' });
1286
+ return { ok: true, problems };
1287
+ }
1288
+
1289
+ /**
1290
+ * Add an endpoint to a group: key set and map entry on the device, AddGroup on the endpoint, the
1291
+ * group's ACL entry (guarded, read back). A failure is recorded on the member for the UI.
1292
+ * @param {string|number} ref group id or name
1293
+ * @param {{ nodeId: string, endpointId: number, clusters?: (string|number)[] }} member
1294
+ */
1295
+ async function addGroupMember(ref, { nodeId, endpointId, clusters }) {
1296
+ const store = _requireGroups();
1297
+ const g = _findGroup(ref);
1298
+ const node = _findClientNode(nodeId);
1299
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1300
+ const ep = Number(endpointId);
1301
+ if (!Number.isInteger(ep) || ep < 1) throw new Error('endpointId must be an endpoint number ≥ 1');
1302
+ const clusterIds = clusters && clusters.length ? clusters.map(bindings.clusterIdOf) : _memberClusters(node, ep);
1303
+ const ks = store.keySets[String(g.keySetId)];
1304
+ if (!ks) throw new Error(`the group's key set ${g.keySetId} is missing from the store`);
1305
+ let m = g.members.find((x) => x.nodeId === id && Number(x.endpointId) === ep);
1306
+ if (!m) {
1307
+ m = { nodeId: id, endpointId: ep, clusters: clusterIds, status: 'pending', error: null, added: Date.now() };
1308
+ g.members.push(m);
1309
+ } else {
1310
+ m.clusters = clusterIds;
1311
+ m.status = 'pending';
1312
+ m.error = null;
1313
+ }
1314
+ _saveGroups();
1315
+ try {
1316
+ await _provisionMember(g, ks, node, ep, clusterIds);
1317
+ m.status = 'ok';
1318
+ m.error = null;
1319
+ _log?.info(`matter: ${_getDeviceName(node) ?? id}/${ep} joined group "${g.name}"`);
1320
+ } catch (err) {
1321
+ m.status = 'error';
1322
+ m.error = err.message;
1323
+ _log?.warn(`matter: ${_getDeviceName(node) ?? id}/${ep} could not join group "${g.name}": ${err.message}`);
1324
+ _saveGroups();
1325
+ _broadcast?.({ type: 'matter:groups' });
1326
+ throw err;
1327
+ }
1328
+ _saveGroups();
1329
+ _broadcast?.({ type: 'matter:groups' });
1330
+ return _groupView(g);
1331
+ }
1332
+
1333
+ /** Remove an endpoint from a group; the device is cleaned when online (problems are reported, not thrown). */
1334
+ async function removeGroupMember(ref, nodeId, endpointId) {
1335
+ const store = _requireGroups();
1336
+ const g = _findGroup(ref);
1337
+ const ep = Number(endpointId);
1338
+ const m = g.members.find((x) => String(x.nodeId) === String(nodeId) && Number(x.endpointId) === ep);
1339
+ if (!m) throw new Error(`${nodeId}/${endpointId} is not a member of group "${g.name}"`);
1340
+ g.members = g.members.filter((x) => x !== m);
1341
+ _saveGroups();
1342
+ let problems = [];
1343
+ try {
1344
+ const node = _findClientNode(m.nodeId);
1345
+ if (node.lifecycle?.isOnline) problems = await _deprovisionMember(g, store.keySets[String(g.keySetId)], node, ep, m.clusters ?? []);
1346
+ else problems.push('the device is offline — it keeps the group until it is removed again');
1347
+ } catch (err) {
1348
+ problems.push(err.message);
1349
+ }
1350
+ _broadcast?.({ type: 'matter:groups' });
1351
+ return { ok: true, problems, group: _groupView(g) };
1352
+ }
1353
+
1354
+ /**
1355
+ * Send a command to a group — one multicast, no answer by design (best effort; a lost multicast
1356
+ * on Thread is not retried).
1357
+ * @param {string|number} ref group id or name
1358
+ */
1359
+ async function sendGroupCommand(ref, clusterName, commandName, args) {
1360
+ const g = _findGroup(ref);
1361
+ const { Invoke } = require('@matter/protocol');
1362
+ const { GroupId, NodeId } = require('@matter/types');
1363
+ const clusterDef = _clusterDef(clusterName);
1364
+ if (!clusterDef.commands?.[commandName]) throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
1365
+ const [fabric] = _ownFabrics();
1366
+ if (!fabric) throw new Error('she has no fabric yet');
1367
+ const address = { fabricIndex: fabric.fabricIndex, nodeId: NodeId.fromGroupId(GroupId(Number(g.id))) };
1368
+ const group = _server.peers.get(address) ?? (await _server.peers.forAddress(address));
1369
+ const merged = args !== undefined && args !== null && typeof args === 'object' ? { ...args } : {};
1370
+ for (const field of _mandatoryCommandFields({ cluster: clusterDef }, commandName)) {
1371
+ if (merged[field] !== undefined) continue;
1372
+ if (field === 'optionsMask' || field === 'optionsOverride') merged[field] = {};
1373
+ else if (field === 'transitionTime') merged[field] = 0;
1374
+ }
1375
+ const invoke = Invoke({ commands: [{ cluster: clusterDef, command: commandName, fields: merged }] });
1376
+ for await (const chunk of group.interaction.invoke(invoke)) {
1377
+ for (const _entry of chunk) {
1378
+ /* a group invoke has no response */
1379
+ }
1380
+ }
1381
+ _log?.debug(`matter: group "${g.name}" ← ${clusterName}.${commandName}`);
1382
+ return null;
1383
+ }
1384
+
1385
+ /** A group binding: the source gets the group's key set and map entry, then a `{ group, cluster }` entry (M23). */
1386
+ async function _addGroupBinding({ source, group, clusters }) {
1387
+ const store = _requireGroups();
1388
+ const g = _findGroup(group);
1389
+ const clusterIds = clusters.map(bindings.clusterIdOf);
1390
+ const sourceNode = _findClientNode(source.nodeId);
1391
+ _requireOnline(sourceNode, 'the source');
1392
+ const sourceEp = Number(source.endpointId);
1393
+ const ks = store.keySets[String(g.keySetId)];
1394
+ if (!ks) throw new Error(`the group's key set ${g.keySetId} is missing from the store`);
1395
+ await _ensureKeySetOnDevice(sourceNode, ks, g.id);
1396
+ const before = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
1397
+ const { bindings: after, added } = bindings.addBindingEntries(before, { group: g.id, clusters: clusterIds, fabricIndex: _deviceFabricIndex(sourceNode) });
1398
+ if (added.length) {
1399
+ await _writeAttribute(sourceNode, sourceEp, 'binding', 'binding', after);
1400
+ const back = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
1401
+ const bound = bindings.clustersStillBound(back, { group: g.id });
1402
+ const missing = clusterIds.filter((id) => !bound.includes(id));
1403
+ if (missing.length) throw new Error(`the source did not keep the group binding for cluster(s) ${missing.join(', ')}`);
1404
+ }
1405
+ _log?.info(`matter: group binding ${_getDeviceName(sourceNode) ?? source.nodeId}/${sourceEp} → "${g.name}"`);
1406
+ _broadcast?.({ type: 'matter:bindings' });
1407
+ return { ok: true, clusters: clusterIds, aclChanged: false, bindingsAdded: added, group: Number(g.id) };
1408
+ }
1409
+
1410
+ async function _removeGroupBinding({ source, group, clusters }) {
1411
+ const store = _requireGroups();
1412
+ const clusterIds = clusters.map(bindings.clusterIdOf);
1413
+ const sourceNode = _findClientNode(source.nodeId);
1414
+ _requireOnline(sourceNode, 'the source');
1415
+ const sourceEp = Number(source.endpointId);
1416
+ const groupId = Number(group);
1417
+ const before = (await _readAttribute(sourceNode, sourceEp, 'binding', 'binding')) ?? [];
1418
+ const { bindings: after, removed } = bindings.removeBindingEntries(before, { group: groupId, clusters: clusterIds });
1419
+ if (removed.length) await _writeAttribute(sourceNode, sourceEp, 'binding', 'binding', after);
1420
+ let warning = null;
1421
+ const g = store.groups.find((x) => Number(x.id) === groupId);
1422
+ const id = _nodeIdStr(sourceNode.peerAddress.nodeId);
1423
+ const isMember = !!g?.members.some((m) => m.nodeId === id);
1424
+ if (!bindings.groupStillBound(after, groupId) && !isMember && g) {
1425
+ try {
1426
+ await _dropKeySetOnDevice(sourceNode, store.keySets[String(g.keySetId)], groupId);
1427
+ } catch (err) {
1428
+ warning = `the source keeps the group's key set: ${err.message}`;
1429
+ }
1430
+ }
1431
+ _broadcast?.({ type: 'matter:bindings' });
1432
+ return { ok: true, clusters: clusterIds, bindingsRemoved: removed, aclRevoked: false, warning };
1433
+ }
1434
+
1435
+ // ── Firmware updates (M22) ────────────────────────────────────────────────────
1436
+
1437
+ /** @type {object|null} the OTA provider endpoint (matter.js Endpoint) */
1438
+ let _ota = null;
1439
+ /** nodeId → { info, checkedAt } — the updates the ledger offered, persisted across restarts */
1440
+ let _updates = new Map();
1441
+ let _updatesFile = null;
1442
+ /** nodeId → 'done' | 'failed' | null — the last outcome, for the UI */
1443
+ const _updateOutcome = new Map();
1444
+
1445
+ /** OTA requestor UpdateState (cluster 0x002A) names. */
1446
+ const UPDATE_STATES = ['unknown', 'idle', 'querying', 'delayed on query', 'downloading', 'applying', 'delayed on apply', 'rolling back', 'delayed on user consent'];
1447
+
1448
+ function _loadUpdates() {
1449
+ _updates = new Map();
1450
+ if (!_updatesFile) return;
1451
+ try {
1452
+ const raw = JSON.parse(require('fs').readFileSync(_updatesFile, 'utf8'));
1453
+ for (const [k, v] of Object.entries(raw)) if (v && typeof v === 'object') _updates.set(k, v);
1454
+ } catch (err) {
1455
+ if (err.code !== 'ENOENT') _log?.warn(`matter: cannot read ${_updatesFile}: ${err.message}`);
1456
+ }
1457
+ }
1458
+
1459
+ function _saveUpdates() {
1460
+ if (!_updatesFile) return;
1461
+ try {
1462
+ const fs = require('fs');
1463
+ fs.writeFileSync(_updatesFile + '.tmp', JSON.stringify(Object.fromEntries(_updates), null, 1));
1464
+ fs.renameSync(_updatesFile + '.tmp', _updatesFile);
1465
+ } catch (err) {
1466
+ _log?.warn(`matter: cannot write ${_updatesFile}: ${err.message}`);
1467
+ }
1468
+ }
1469
+
1470
+ function _updateInfo(info) {
1471
+ return {
1472
+ softwareVersion: Number(info.softwareVersion),
1473
+ softwareVersionString: info.softwareVersionString ?? String(info.softwareVersion),
1474
+ releaseNotesUrl: info.releaseNotesUrl ?? null,
1475
+ vendorId: Number(info.vendorId),
1476
+ productId: Number(info.productId),
1477
+ source: info.source === undefined ? null : String(info.source),
1478
+ };
1479
+ }
1480
+
1481
+ async function _setupOta(storagePath) {
1482
+ const { Endpoint } = require('@matter/main');
1483
+ const { OtaProviderEndpoint } = require('@matter/main/endpoints');
1484
+ const { SoftwareUpdateManager } = require('@matter/node');
1485
+ _updatesFile = require('path').join(storagePath, 'ota-updates.json');
1486
+ _loadUpdates();
1487
+ const ota = new Endpoint(OtaProviderEndpoint.with(SoftwareUpdateManager), {
1488
+ id: 'ota-provider',
1489
+ // never announce she as every device's default provider: updates start on a click only
1490
+ softwareupdates: { announceAsDefaultProvider: false },
1491
+ });
1492
+ await _server.add(ota);
1493
+ _ota = ota;
1494
+ const ev = ota.events.softwareupdates;
1495
+ ev.updateAvailable.on((peer, info) => {
1496
+ const nodeId = _nodeIdStr(peer.nodeId);
1497
+ _updates.set(nodeId, { info: _updateInfo(info), checkedAt: Date.now() });
1498
+ _saveUpdates();
1499
+ _log?.info(`matter: update available for node ${nodeId}: ${info.softwareVersionString ?? info.softwareVersion}`);
1500
+ _broadcast?.({ type: 'matter:update', nodeId, available: _updateInfo(info) });
1501
+ });
1502
+ ev.updateDone.on((peer) => {
1503
+ const nodeId = _nodeIdStr(peer.nodeId);
1504
+ _updates.delete(nodeId);
1505
+ _saveUpdates();
1506
+ _updateOutcome.set(nodeId, 'done');
1507
+ _log?.info(`matter: update of node ${nodeId} finished`);
1508
+ _broadcast?.({ type: 'matter:update', nodeId, outcome: 'done' });
1509
+ });
1510
+ ev.updateFailed.on((peer) => {
1511
+ const nodeId = _nodeIdStr(peer.nodeId);
1512
+ _updateOutcome.set(nodeId, 'failed');
1513
+ _log?.warn(`matter: update of node ${nodeId} failed or was cancelled`);
1514
+ _broadcast?.({ type: 'matter:update', nodeId, outcome: 'failed' });
1515
+ });
1516
+ _log?.info('matter: OTA provider ready (updates from the CSA ledger, started by the user only)');
1517
+ }
1518
+
1519
+ /** Run a function with the SoftwareUpdateManager agent. */
1520
+ function _withUpdates(label, fn) {
1521
+ if (!_ota) throw new Error('firmware updates are not available (DCL access is off or the OTA provider failed to start)');
1522
+ return _ota.act(label, (agent) => fn(agent.softwareupdates));
1523
+ }
1524
+
1525
+ /** The update view of one node: versions, what the ledger offered, the requestor's state. */
1526
+ function _nodeUpdateView(node, pending) {
1527
+ const nodeId = _nodeIdStr(node.peerAddress.nodeId);
1528
+ const bi = _rootState(node, 'basicInformation') ?? {};
1529
+ const req = _rootState(node, 'otaSoftwareUpdateRequestor') ?? {};
1530
+ const state = req.updateState === undefined || req.updateState === null ? null : Number(req.updateState);
1531
+ const known = _updates.get(nodeId);
1532
+ const current = bi.softwareVersion === undefined ? null : Number(bi.softwareVersion);
1533
+ const available = known?.info && (current === null || known.info.softwareVersion > current) ? known.info : null;
1534
+ const q = pending?.find((p) => _nodeIdStr(p.peerAddress.nodeId) === nodeId) ?? null;
1535
+ return {
1536
+ nodeId,
1537
+ name: _getDeviceName(node),
1538
+ online: node.lifecycle?.isOnline ?? false,
1539
+ vendorId: bi.vendorId === undefined ? null : Number(bi.vendorId),
1540
+ productId: bi.productId === undefined ? null : Number(bi.productId),
1541
+ softwareVersion: current,
1542
+ softwareVersionString: bi.softwareVersionString ?? null,
1543
+ hasRequestor: !!_rootState(node, 'otaSoftwareUpdateRequestor'),
1544
+ available,
1545
+ checkedAt: known?.checkedAt ?? null,
1546
+ updateState: state,
1547
+ updateStateName: state === null ? null : (UPDATE_STATES[state] ?? String(state)),
1548
+ updateStateProgress: req.updateStateProgress === undefined || req.updateStateProgress === null ? null : Number(req.updateStateProgress),
1549
+ pending: q
1550
+ ? {
1551
+ status: q.status,
1552
+ targetSoftwareVersion: Number(q.targetSoftwareVersion),
1553
+ bdxTransferredBytes: q.bdxTransferredBytes ?? null,
1554
+ bdxDataLength: q.bdxDataLength ?? null,
1555
+ }
1556
+ : null,
1557
+ outcome: _updateOutcome.get(nodeId) ?? null,
1558
+ };
1559
+ }
1560
+
1561
+ /** All nodes with their firmware state (M22). */
1562
+ async function listUpdates() {
1563
+ if (!_server) return { available: false, nodes: [] };
1564
+ let pending = [];
1565
+ if (_ota) {
1566
+ try {
1567
+ pending = await _withUpdates('she.matter.updates', (m) => m.queuedUpdates);
1568
+ } catch (err) {
1569
+ _log?.debug(`matter: reading the update queue failed: ${err.message}`);
1570
+ }
1571
+ }
1572
+ const nodes = [];
1573
+ for (const node of _server.peers) if (node.peerAddress) nodes.push(_nodeUpdateView(node, pending));
1574
+ return { available: !!_ota, nodes };
1575
+ }
1576
+
1577
+ /**
1578
+ * Ask the ledger now — for one node, or for every online node. Returns the update view.
1579
+ * @param {string} [nodeId]
1580
+ */
1581
+ async function checkUpdates(nodeId) {
1582
+ if (!_server) throw new Error('Matter controller not started');
1583
+ const peerToCheck = nodeId === undefined ? undefined : _findClientNode(nodeId);
1584
+ if (peerToCheck) _requireOnline(peerToCheck, 'the device');
1585
+ const found = await _withUpdates('she.matter.checkUpdates', (m) => m.queryUpdates(peerToCheck ? { peerToCheck } : {}));
1586
+ const seen = new Set();
1587
+ for (const { peerAddress, info } of found ?? []) {
1588
+ const id = _nodeIdStr(peerAddress.nodeId);
1589
+ seen.add(id);
1590
+ _updates.set(id, { info: _updateInfo(info), checkedAt: Date.now() });
1591
+ }
1592
+ // a node checked without a result has no update (any more)
1593
+ const checked = peerToCheck
1594
+ ? [_nodeIdStr(peerToCheck.peerAddress.nodeId)]
1595
+ : [..._server.peers].filter((n) => n.peerAddress && n.lifecycle?.isOnline).map((n) => _nodeIdStr(n.peerAddress.nodeId));
1596
+ for (const id of checked) {
1597
+ if (!seen.has(id)) _updates.set(id, { info: null, checkedAt: Date.now() });
1598
+ }
1599
+ for (const [id, v] of _updates) if (v.info === null) _updates.set(id, { info: null, checkedAt: v.checkedAt });
1600
+ _saveUpdates();
1601
+ _broadcast?.({ type: 'matter:update' });
1602
+ return listUpdates();
1603
+ }
1604
+
1605
+ /**
1606
+ * Start the update of a node to the version the ledger offered (or the given one). Never
1607
+ * automatic: this is the user's click. One update per device at a time; the device must be idle.
1608
+ * @param {string} nodeId
1609
+ * @param {{ softwareVersion?: number }} [opts]
1610
+ */
1611
+ async function startUpdate(nodeId, { softwareVersion } = {}) {
1612
+ const node = _findClientNode(nodeId);
1613
+ _requireOnline(node, 'the device');
1614
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1615
+ const pending = await _withUpdates('she.matter.startUpdate', (m) => m.queuedUpdates);
1616
+ const view = _nodeUpdateView(node, pending);
1617
+ if (!view.hasRequestor) throw new Error('the device has no OTA requestor cluster — it cannot be updated over Matter');
1618
+ const target = softwareVersion !== undefined ? Number(softwareVersion) : view.available?.softwareVersion;
1619
+ if (!Number.isFinite(target)) throw new Error('no update is known for this device — check for updates first');
1620
+ if (view.softwareVersion !== null && target <= view.softwareVersion) throw new Error(`version ${target} is not newer than the installed ${view.softwareVersion}`);
1621
+ if (view.updateState !== null && view.updateState !== 1 && view.updateState !== 0) throw new Error(`the device is busy (${view.updateStateName}) — wait until it is idle`);
1622
+ if (view.pending) throw new Error(`an update to ${view.pending.targetSoftwareVersion} is already ${view.pending.status}`);
1623
+ const vendorId = view.vendorId;
1624
+ const productId = view.productId;
1625
+ if (vendorId === null || productId === null) throw new Error('the device reports no vendor/product ID');
1626
+ _updateOutcome.delete(id);
1627
+ await _withUpdates('she.matter.startUpdate', (m) => m.forceUpdate(node.peerAddress, vendorId, productId, target));
1628
+ _log?.info(`matter: update of node ${id} to ${target} started`);
1629
+ _broadcast?.({ type: 'matter:update', nodeId: id, started: target });
1630
+ return { ok: true, nodeId: id, targetSoftwareVersion: target };
1631
+ }
1632
+
1633
+ /** Cancel a queued or running update (possible while querying or downloading). */
1634
+ async function cancelUpdate(nodeId) {
1635
+ const node = _findClientNode(nodeId);
1636
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1637
+ await _withUpdates('she.matter.cancelUpdate', (m) => m.removeConsent(node.peerAddress));
1638
+ _log?.info(`matter: update of node ${id} cancelled`);
1639
+ _broadcast?.({ type: 'matter:update', nodeId: id, outcome: 'cancelled' });
1640
+ return { ok: true, nodeId: id };
1641
+ }
1642
+
1643
+ // ── Multi-admin: fabrics, pairing windows (M20) ───────────────────────────────
1644
+
1645
+ /** Vendor IDs of the common controllers, for when the ledger has no name (or is off). */
1646
+ const KNOWN_FABRIC_VENDORS = { 0x1349: 'Apple', 0x6006: 'Google', 0x131d: 'Amazon', 0x10e1: 'SmartThings', 0x1393: 'Home Assistant', 0xfff1: 'matter.js (test vendor)' };
1647
+
1648
+ // nodeId → the codes of the pairing window she opened on that device
1649
+ const _windows = new Map();
1650
+
1651
+ /** Run an invoke request on a node and return the response data; throws on a failure status. */
1652
+ async function _invoke(node, invoke, what) {
1653
+ const { Status, StatusResponseError } = require('@matter/types');
1654
+ let data;
1655
+ for await (const chunk of node.interaction.invoke(invoke)) {
1656
+ for (const entry of chunk) {
1657
+ if (entry.kind === 'cmd-status' && entry.status !== Status.Success) {
1658
+ throw StatusResponseError.create(entry.status, `${what} failed`, entry.clusterStatus);
1659
+ }
1660
+ if (entry.kind === 'cmd-response') data = entry.data;
1661
+ }
1662
+ }
1663
+ return data;
1664
+ }
1665
+
1666
+ /**
1667
+ * The fabrics a device belongs to (Operational Credentials `fabrics`, read with fabric filtering
1668
+ * off), each named after its label or its vendor; she's own fabric marked.
1669
+ * @param {string} nodeId
1670
+ */
1671
+ async function listFabrics(nodeId) {
1672
+ const node = _findClientNode(nodeId);
1673
+ const oc = _rootState(node, 'operationalCredentials') ?? {};
1674
+ let fabrics = Array.isArray(oc.fabrics) ? oc.fabrics : [];
1675
+ let fresh = false;
1676
+ if (node.lifecycle?.isOnline) {
1677
+ try {
1678
+ fabrics = (await _readAttribute(node, 0, 'operationalCredentials', 'fabrics', { fabricFiltered: false })) ?? [];
1679
+ fresh = true;
1680
+ } catch (err) {
1681
+ _log?.debug(`matter: reading fabrics of ${nodeId} failed, using cached state: ${err.message}`);
1682
+ }
1683
+ }
1684
+ const current = _deviceFabricIndex(node);
1685
+ const ac = _rootState(node, 'administratorCommissioning') ?? {};
1686
+ const list = fabrics.map((f) => {
1687
+ const vendorId = f.vendorId === undefined || f.vendorId === null ? null : Number(f.vendorId);
1688
+ const fabricIndex = f.fabricIndex === undefined || f.fabricIndex === null ? null : Number(f.fabricIndex);
1689
+ const vendorName = vendorId === null ? null : (dcl.vendorName(vendorId) ?? KNOWN_FABRIC_VENDORS[vendorId] ?? null);
1690
+ const label = typeof f.label === 'string' ? f.label : '';
1691
+ const own = fabricIndex !== null && current !== undefined && fabricIndex === current;
1692
+ return {
1693
+ fabricIndex,
1694
+ fabricId: f.fabricId === undefined || f.fabricId === null ? null : String(f.fabricId),
1695
+ nodeId: f.nodeId === undefined || f.nodeId === null ? null : String(f.nodeId),
1696
+ vendorId,
1697
+ vendorName,
1698
+ label,
1699
+ own,
1700
+ name: own ? label || 'she' : label || vendorName || (vendorId === null ? 'unknown' : `vendor 0x${vendorId.toString(16).padStart(4, '0')}`),
1701
+ };
1702
+ });
1703
+ const win = _windows.get(_nodeIdStr(node.peerAddress.nodeId));
1704
+ return {
1705
+ nodeId: _nodeIdStr(node.peerAddress.nodeId),
1706
+ fresh,
1707
+ fabrics: list,
1708
+ supportedFabrics: oc.supportedFabrics === undefined ? null : Number(oc.supportedFabrics),
1709
+ commissionedFabrics: oc.commissionedFabrics === undefined ? null : Number(oc.commissionedFabrics),
1710
+ window: {
1711
+ status: ac.windowStatus === undefined || ac.windowStatus === null ? null : Number(ac.windowStatus), // 0 closed, 1 enhanced, 2 basic
1712
+ adminFabricIndex: ac.adminFabricIndex === undefined || ac.adminFabricIndex === null ? null : Number(ac.adminFabricIndex),
1713
+ adminVendorId: ac.adminVendorId === undefined || ac.adminVendorId === null ? null : Number(ac.adminVendorId),
1714
+ codes: win && win.expiresAt > Date.now() ? win : null,
1715
+ },
1716
+ };
1717
+ }
1718
+
1719
+ /**
1720
+ * Open an enhanced commissioning window on a device so another app (Apple Home, Home Assistant,
1721
+ * …) can add it: a random passcode and discriminator, the PAKE verifier computed here, the timed
1722
+ * OpenCommissioningWindow command, and the QR payload and manual code to show.
1723
+ * @param {string} nodeId
1724
+ * @param {{ timeoutSeconds?: number }} [opts] the window length, 180 … 900 s (Matter's limits)
1725
+ */
1726
+ async function openCommissioningWindow(nodeId, { timeoutSeconds = 900 } = {}) {
1727
+ const node = _findClientNode(nodeId);
1728
+ _requireOnline(node, 'the device');
1729
+ const timeout = Math.min(900, Math.max(180, Number(timeoutSeconds) || 900));
1730
+ const { PaseClient, Invoke } = require('@matter/protocol');
1731
+ const { Crypto } = require('@matter/general');
1732
+ const { QrPairingCodeCodec, ManualPairingCodeCodec, DiscoveryCapabilitiesSchema } = require('@matter/types');
1733
+ const clusters = require('@matter/main/clusters');
1734
+ const crypto = _server.env.get(Crypto);
1735
+ const passcode = PaseClient.generateRandomPasscode(crypto);
1736
+ const discriminator = PaseClient.generateRandomDiscriminator(crypto);
1737
+ const salt = require('crypto').randomBytes(32);
1738
+ const iterations = 1000;
1739
+ const pakePasscodeVerifier = await PaseClient.generatePakePasscodeVerifier(crypto, passcode, { iterations, salt });
1740
+ const invoke = Invoke({
1741
+ timed: true,
1742
+ commands: [
1743
+ Invoke.ConcreteCommandRequest({
1744
+ endpoint: 0,
1745
+ cluster: clusters.AdministratorCommissioning.Complete,
1746
+ command: 'openCommissioningWindow',
1747
+ fields: { commissioningTimeout: timeout, pakePasscodeVerifier, discriminator, iterations, salt },
1748
+ }),
1749
+ ],
1750
+ });
1751
+ await _invoke(node, invoke, 'OpenCommissioningWindow');
1752
+ const bi = _rootState(node, 'basicInformation') ?? {};
1753
+ const vendorId = Number(bi.vendorId ?? 0);
1754
+ const productId = Number(bi.productId ?? 0);
1755
+ const qrCode = QrPairingCodeCodec.encode([
1756
+ { version: 0, vendorId, productId, flowType: 0, discoveryCapabilities: DiscoveryCapabilitiesSchema.encode({ onIpNetwork: true }), discriminator, passcode },
1757
+ ]);
1758
+ const manualCode = ManualPairingCodeCodec.encode({ discriminator, passcode });
1759
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1760
+ const win = { passcode, discriminator, qrCode, manualCode, openedAt: Date.now(), expiresAt: Date.now() + timeout * 1000, timeoutSeconds: timeout };
1761
+ _windows.set(id, win);
1762
+ _log?.info(`matter: commissioning window opened on node ${id} for ${timeout} s (discriminator ${discriminator})`);
1763
+ _broadcast?.({ type: 'matter:fabrics', nodeId: id });
1764
+ return win;
1765
+ }
1766
+
1767
+ /** Close a pairing window early (RevokeCommissioning, timed). */
1768
+ async function closeCommissioningWindow(nodeId) {
1769
+ const node = _findClientNode(nodeId);
1770
+ _requireOnline(node, 'the device');
1771
+ const { Invoke } = require('@matter/protocol');
1772
+ const clusters = require('@matter/main/clusters');
1773
+ const invoke = Invoke({
1774
+ timed: true,
1775
+ commands: [Invoke.ConcreteCommandRequest({ endpoint: 0, cluster: clusters.AdministratorCommissioning.Complete, command: 'revokeCommissioning', fields: {} })],
1776
+ });
1777
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1778
+ try {
1779
+ await _invoke(node, invoke, 'RevokeCommissioning');
1780
+ } catch (err) {
1781
+ // WindowNotOpen (cluster status 4) when it already closed — not a failure for the user
1782
+ if (!/WindowNotOpen|clusterStatus.*4\b|status 4\b/i.test(err.message) && err.clusterStatus !== 4) throw err;
1783
+ }
1784
+ _windows.delete(id);
1785
+ _log?.info(`matter: commissioning window on node ${id} closed`);
1786
+ _broadcast?.({ type: 'matter:fabrics', nodeId: id });
1787
+ }
1788
+
1789
+ /**
1790
+ * Remove another controller's fabric from a device (Operational Credentials RemoveFabric). she's
1791
+ * own fabric cannot be removed this way — that is "unpair".
1792
+ * @param {string} nodeId
1793
+ * @param {number} fabricIndex the device's index of that fabric (from listFabrics)
1794
+ */
1795
+ async function removeFabric(nodeId, fabricIndex) {
1796
+ const node = _findClientNode(nodeId);
1797
+ _requireOnline(node, 'the device');
1798
+ const fi = Number(fabricIndex);
1799
+ if (!Number.isInteger(fi) || fi < 1) throw new Error('fabricIndex must be a positive integer');
1800
+ if (fi === _deviceFabricIndex(node)) throw new Error("that is she's own fabric — unpair the device instead");
1801
+ const id = _nodeIdStr(node.peerAddress.nodeId);
1802
+ const result = await sendCommand(id, 0, 'operationalCredentials', 'removeFabric', { fabricIndex: fi });
1803
+ const status = result?.statusCode === undefined ? 0 : Number(result.statusCode);
1804
+ if (status !== 0) throw new Error(`the device refused to remove fabric ${fi}: status ${status}${result?.debugText ? ' (' + result.debugText + ')' : ''}`);
1805
+ _log?.info(`matter: removed fabric ${fi} from node ${id}`);
1806
+ _broadcast?.({ type: 'matter:fabrics', nodeId: id });
1807
+ return { ok: true, fabricIndex: fi };
1808
+ }
1809
+
637
1810
  // ── Node lifecycle events (online / offline) ────────────────────────────────
638
1811
 
639
1812
  /**
@@ -726,6 +1899,37 @@ function _attrValue(clusterName, attrName, value) {
726
1899
  return _jsonSafe(value);
727
1900
  }
728
1901
 
1902
+ /**
1903
+ * The names (camelCase) of a cluster's events — from the client behavior's model (what matter.js
1904
+ * generated for the discovered cluster), else from the standard cluster definition. Events live in
1905
+ * the same per-cluster table as the attribute change events (`endpoint.events[cluster]`), keyed by
1906
+ * their bare name; attribute and event names of a cluster never collide (M18).
1907
+ * @param {object} endpoint
1908
+ * @param {string} clusterName
1909
+ * @returns {string[]}
1910
+ */
1911
+ function _clusterEventNames(endpoint, clusterName) {
1912
+ const names = new Set();
1913
+ try {
1914
+ const schema = endpoint.behaviors?.supported?.[clusterName]?.schema;
1915
+ if (schema?.children) {
1916
+ for (const c of schema.children) if (c.tag === 'event' && c.name) names.add(_camelize(c.name));
1917
+ }
1918
+ } catch {
1919
+ /* best-effort */
1920
+ }
1921
+ if (names.size === 0) {
1922
+ try {
1923
+ const clusters = require('@matter/main/clusters');
1924
+ const def = clusters[clusterName.charAt(0).toUpperCase() + clusterName.slice(1)]?.Complete;
1925
+ for (const k of Object.keys(def?.events ?? {})) names.add(k);
1926
+ } catch {
1927
+ /* best-effort */
1928
+ }
1929
+ }
1930
+ return [...names];
1931
+ }
1932
+
729
1933
  function _broadcastNodeAttributes(node, nodeId) {
730
1934
  if (!_broadcast) return;
731
1935
  if (_attrBroadcastNodes.has(nodeId)) return;
@@ -746,13 +1950,15 @@ function _broadcastNodeAttributes(node, nodeId) {
746
1950
  const changeEvent = clusterEvents[`${attrName}$Changed`];
747
1951
  if (!changeEvent || typeof changeEvent.on !== 'function') continue;
748
1952
  changeEvent.on((value) => {
1953
+ const safe = _attrValue(clusterName, attrName, value);
1954
+ events.emit('attr', { nodeId, endpointId, clusterName, attrName, value: safe, ts: Date.now() });
749
1955
  _broadcast?.({
750
1956
  type: 'matter:attr',
751
1957
  nodeId,
752
1958
  endpointId,
753
1959
  clusterName,
754
1960
  attrName,
755
- value: _attrValue(clusterName, attrName, value),
1961
+ value: safe,
756
1962
  ts: Date.now(),
757
1963
  });
758
1964
  // Device/endpoint labels feed the device list — rebroadcast it
@@ -760,11 +1966,30 @@ function _broadcastNodeAttributes(node, nodeId) {
760
1966
  // the local mirror only learns the new nodeLabel when the
761
1967
  // device's subscription report arrives, i.e. exactly now.
762
1968
  if (attrName === 'nodeLabel' || attrName === 'productName') {
763
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
1969
+ _emitDeviceList();
764
1970
  }
765
1971
  });
766
1972
  count++;
767
1973
  }
1974
+ // Cluster events (Switch.initialPress, multiPressComplete, …) go to the feed as
1975
+ // well: the UI shows them typed matter:event (M18).
1976
+ for (const eventName of _clusterEventNames(endpoint, clusterName)) {
1977
+ const ev = clusterEvents[eventName];
1978
+ if (!ev || typeof ev.on !== 'function') continue;
1979
+ ev.on((payload) => {
1980
+ events.emit('event', { nodeId, endpointId, clusterName, eventName, value: _jsonSafe(payload), ts: Date.now() });
1981
+ _broadcast?.({
1982
+ type: 'matter:event',
1983
+ nodeId,
1984
+ endpointId,
1985
+ clusterName,
1986
+ eventName,
1987
+ value: _jsonSafe(payload),
1988
+ ts: Date.now(),
1989
+ });
1990
+ });
1991
+ count++;
1992
+ }
768
1993
  }
769
1994
  }
770
1995
  if (count === 0) {
@@ -790,10 +2015,12 @@ function _subscribeNodeLifecycle(node, nodeId) {
790
2015
  const lc = node.lifecycle;
791
2016
  if (!lc) return;
792
2017
  lc.online?.on?.(() => {
2018
+ events.emit('status', { nodeId, online: true });
793
2019
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: true });
794
2020
  _broadcastNodeAttributes(node, nodeId);
795
2021
  });
796
2022
  lc.offline?.on?.(() => {
2023
+ events.emit('status', { nodeId, online: false });
797
2024
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: false });
798
2025
  });
799
2026
  }
@@ -817,13 +2044,24 @@ function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrNam
817
2044
  const endpoint = _resolveEndpoint(node, endpointId);
818
2045
  const events = endpoint.events?.[_clusterName(clusterName)];
819
2046
  if (!events) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
820
- const changeEvent = events[`${attrName}$Changed`];
821
- if (!changeEvent) throw new Error(`Attribute "${attrName}" on cluster "${clusterName}" has no change event`);
2047
+ // An attribute's change event, or — when `attrName` is not an attribute — the cluster event of
2048
+ // that name (Switch.multiPressComplete …), so button presses reach scripts through the same
2049
+ // call (M18). An event callback gets the payload and `{ event: true, cluster, name, ts }`.
2050
+ let changeEvent = events[`${attrName}$Changed`];
2051
+ let isEvent = false;
2052
+ if (!changeEvent) {
2053
+ changeEvent = events[attrName];
2054
+ isEvent = true;
2055
+ }
2056
+ if (!changeEvent || typeof changeEvent.on !== 'function') {
2057
+ throw new Error(`"${attrName}" is neither an attribute with a change event nor an event of cluster "${clusterName}"`);
2058
+ }
822
2059
 
823
2060
  const listenerId = _nextListenerId++;
824
2061
  const observer = (value, oldValue) => {
825
2062
  try {
826
- callback(value, oldValue);
2063
+ if (isEvent) callback(value, { event: true, cluster: clusterName, name: attrName, ts: Date.now() });
2064
+ else callback(value, oldValue);
827
2065
  } catch (err) {
828
2066
  _log?.error(`matter subscriber error in ${scriptFile}:`, err.message);
829
2067
  }
@@ -889,6 +2127,31 @@ function getDeviceSubtitle(nodeId) {
889
2127
  }
890
2128
  }
891
2129
 
2130
+ /**
2131
+ * Vendor and product details for a node from the CSA ledger (M25): what the ledger says about
2132
+ * the vendor/product IDs the device reports.
2133
+ * @param {string} nodeId
2134
+ */
2135
+ async function getDeviceInfo(nodeId) {
2136
+ const node = _findClientNode(nodeId);
2137
+ const bi = _rootState(node, 'basicInformation') ?? {};
2138
+ const info = await dcl.deviceInfo(bi);
2139
+ return {
2140
+ ...info,
2141
+ reported: {
2142
+ vendorName: bi.vendorName ?? null,
2143
+ productName: bi.productName ?? null,
2144
+ productLabel: bi.productLabel ?? null,
2145
+ productUrl: bi.productUrl ?? null,
2146
+ partNumber: bi.partNumber ?? null,
2147
+ softwareVersion: bi.softwareVersion === undefined ? null : Number(bi.softwareVersion),
2148
+ softwareVersionString: bi.softwareVersionString ?? null,
2149
+ hardwareVersionString: bi.hardwareVersionString ?? null,
2150
+ serialNumber: bi.serialNumber ?? null,
2151
+ },
2152
+ };
2153
+ }
2154
+
892
2155
  function isStarted() {
893
2156
  return _server !== null;
894
2157
  }
@@ -896,6 +2159,10 @@ function isStarted() {
896
2159
  module.exports = {
897
2160
  // for the unit tests: the value conversion, without a running controller
898
2161
  _attrValue,
2162
+ // for the Thread mesh (M16): the raw peers and the fresh attribute read
2163
+ _peers: () => (_server ? [..._server.peers] : []),
2164
+ _readAttribute,
2165
+ events,
899
2166
  init,
900
2167
  close,
901
2168
  isStarted,
@@ -904,10 +2171,30 @@ module.exports = {
904
2171
  unpair,
905
2172
  getEndpoints,
906
2173
  getDeviceSubtitle,
2174
+ getDeviceInfo,
907
2175
  getAttribute,
908
2176
  setAttribute,
909
2177
  rename,
910
2178
  sendCommand,
2179
+ listGroups,
2180
+ createGroup,
2181
+ renameGroup,
2182
+ deleteGroup,
2183
+ addGroupMember,
2184
+ removeGroupMember,
2185
+ sendGroupCommand,
2186
+ listUpdates,
2187
+ checkUpdates,
2188
+ startUpdate,
2189
+ cancelUpdate,
2190
+ listFabrics,
2191
+ openCommissioningWindow,
2192
+ closeCommissioningWindow,
2193
+ removeFabric,
2194
+ listBindings,
2195
+ bindingCandidates,
2196
+ addBinding,
2197
+ removeBinding,
911
2198
  subscribeAttribute,
912
2199
  unsubscribe,
913
2200
  cleanup,