smart-home-engine 1.53.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -81,9 +90,15 @@ function _resolveEndpoint(node, endpointIdOrName) {
81
90
  throw new Error(`Endpoint not found: ${endpointIdOrName}`);
82
91
  }
83
92
 
84
- /** Recursively convert BigInt values to numbers/strings so the result is JSON-serialisable. */
93
+ /**
94
+ * Recursively convert values so the result is JSON-serialisable: BigInt to a number (or a string
95
+ * beyond 2^53), byte arrays to `{ $bytes: "<hex>" }` — JSON.stringify would turn a Uint8Array into
96
+ * an object of numbered keys, which the UI cannot tell from a struct.
97
+ */
85
98
  function _jsonSafe(v) {
86
99
  if (typeof v === 'bigint') return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString();
100
+ if (ArrayBuffer.isView(v)) return { $bytes: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString('hex') };
101
+ if (v instanceof ArrayBuffer) return { $bytes: Buffer.from(v).toString('hex') };
87
102
  if (Array.isArray(v)) return v.map(_jsonSafe);
88
103
  if (v !== null && typeof v === 'object') {
89
104
  const out = {};
@@ -101,6 +116,12 @@ function _clusterName(clusterId) {
101
116
  return clusterId;
102
117
  }
103
118
 
119
+ function _emitDeviceList() {
120
+ const devices = listPaired();
121
+ events.emit('deviceList', devices);
122
+ _broadcast?.({ type: 'matter:deviceList', devices });
123
+ }
124
+
104
125
  // ── Lifecycle ─────────────────────────────────────────────────────────────────
105
126
 
106
127
  /**
@@ -111,7 +132,7 @@ function _clusterName(clusterId) {
111
132
  * @param {{ info: Function, warn: Function, error: Function }} log Daemon logger
112
133
  * @param {((msg: object) => void) | null} [broadcastFn] WebSocket broadcast function from log-ws.js
113
134
  */
114
- async function init(storagePath, log, broadcastFn) {
135
+ async function init(storagePath, log, broadcastFn, options = {}) {
115
136
  _broadcast = broadcastFn ?? null;
116
137
  if (_server) throw new Error('Matter controller already started');
117
138
  _log = log;
@@ -203,11 +224,41 @@ async function init(storagePath, log, broadcastFn) {
203
224
 
204
225
  _server = await ServerNode.create({
205
226
  id: 'she-matter-controller',
227
+ // the fabric label devices show for she (new commissionings; existing fabrics keep theirs)
228
+ controller: { adminFabricLabel: 'she' },
206
229
  });
207
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
+
208
235
  await _server.start();
209
236
  _log.info('matter controller started, storage:', storagePath);
210
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
+
211
262
  // Subscribe lifecycle events for already-paired nodes (persisted from previous session).
212
263
  for (const node of _server.peers) {
213
264
  const addr = node.peerAddress;
@@ -220,6 +271,10 @@ async function init(storagePath, log, broadcastFn) {
220
271
 
221
272
  async function close() {
222
273
  if (!_server) return;
274
+ _ota = null;
275
+ require('./thread-mesh').stop();
276
+ await require('./thread').stop();
277
+ await dcl.close();
223
278
  try {
224
279
  await _server.close();
225
280
  } catch (err) {
@@ -410,7 +465,7 @@ async function _commissionInner(options) {
410
465
  // attribute watchers now or the node stays without live updates until the
411
466
  // next daemon restart.
412
467
  _broadcastNodeAttributes(clientNode, nodeId);
413
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
468
+ _emitDeviceList();
414
469
  return nodeId;
415
470
  }
416
471
 
@@ -428,7 +483,7 @@ async function unpair(nodeId) {
428
483
  _log?.warn(`matter: decommission of ${nodeId} failed (${err.message}), force-deleting`);
429
484
  await node.delete();
430
485
  }
431
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
486
+ _emitDeviceList();
432
487
  }
433
488
 
434
489
  /**
@@ -448,7 +503,7 @@ function getEndpoints(nodeId) {
448
503
  const attrs = {};
449
504
  if (clusterState && typeof clusterState === 'object') {
450
505
  for (const [k, v] of Object.entries(clusterState)) {
451
- attrs[k] = _jsonSafe(v);
506
+ attrs[k] = _attrValue(clusterName, k, v);
452
507
  }
453
508
  }
454
509
  clusters.push({ name: clusterName, attrs });
@@ -497,17 +552,51 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
497
552
  * @returns {Promise<void>}
498
553
  */
499
554
  async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
500
- const node = _findClientNode(nodeId);
501
- const { Write, WriteResult } = require('@matter/protocol');
555
+ await _writeAttribute(_findClientNode(nodeId), endpointId, clusterName, attrName, value);
556
+ }
557
+
558
+ function _clusterDef(clusterName, attrName) {
502
559
  const clusters = require('@matter/main/clusters');
503
560
  const clusterDef = clusters[clusterName.charAt(0).toUpperCase() + clusterName.slice(1)]?.Complete;
504
561
  if (!clusterDef) throw new Error(`Unknown cluster "${clusterName}"`);
505
- 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);
506
570
  const request = Write(Write.Attribute({ endpoint: Number(endpointId), cluster: clusterDef, attributes: attrName, value }));
507
571
  const result = await node.interaction.write(request);
508
572
  WriteResult.assertSuccess(result);
509
573
  }
510
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
+
511
600
  /**
512
601
  * Rename a device by writing basicInformation.nodeLabel on the root endpoint —
513
602
  * the Matter-standard writable user label (max 32 chars), persisted on the
@@ -521,7 +610,7 @@ async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
521
610
  async function rename(nodeId, name) {
522
611
  await setAttribute(nodeId, 0, 'basicInformation', 'nodeLabel', String(name));
523
612
  _log?.info(`matter: renamed node ${nodeId} to "${name}"`);
524
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
613
+ _emitDeviceList();
525
614
  }
526
615
 
527
616
  // ── Commands ──────────────────────────────────────────────────────────────────
@@ -595,6 +684,10 @@ function _mandatoryCommandFields(behavior, commandName) {
595
684
  * @returns {Promise<unknown>}
596
685
  */
597
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
+ }
598
691
  const node = _findClientNode(nodeId);
599
692
  // Use the target endpoint's own act() instead of navigating via agent.parts,
600
693
  // because Parts in @matter/node is a set-like iterable, not a Map (.get doesn't exist).
@@ -628,6 +721,1092 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
628
721
  });
629
722
  }
630
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
+
631
1810
  // ── Node lifecycle events (online / offline) ────────────────────────────────
632
1811
 
633
1812
  /**
@@ -638,6 +1817,119 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
638
1817
  * @param {object} node ClientNode
639
1818
  * @param {string} nodeId decimal string
640
1819
  */
1820
+ /** Matter's attribute OIDs (1.3.6.1.4.1.37244.1.x, Matter core spec 6.5.6.1) by name. */
1821
+ const MATTER_DN_OIDS = {
1822
+ '1.3.6.1.4.1.37244.1.1': 'matter-node-id',
1823
+ '1.3.6.1.4.1.37244.1.2': 'matter-firmware-signing-id',
1824
+ '1.3.6.1.4.1.37244.1.3': 'matter-icac-id',
1825
+ '1.3.6.1.4.1.37244.1.4': 'matter-rcac-id',
1826
+ '1.3.6.1.4.1.37244.1.5': 'matter-fabric-id',
1827
+ '1.3.6.1.4.1.37244.1.6': 'matter-noc-cat',
1828
+ };
1829
+
1830
+ /** node's X509Certificate distinguished name ("oid=value\n…") with Matter's OIDs named */
1831
+ function _matterDn(dn) {
1832
+ return String(dn || '')
1833
+ .split('\n')
1834
+ .filter(Boolean)
1835
+ .map((part) => {
1836
+ const eq = part.indexOf('=');
1837
+ const key = part.slice(0, eq);
1838
+ return `${MATTER_DN_OIDS[key] || key}=${part.slice(eq + 1)}`;
1839
+ })
1840
+ .join(', ');
1841
+ }
1842
+
1843
+ /**
1844
+ * A Matter operational certificate (TLV, as the OperationalCredentials cluster holds it) as
1845
+ * `{ $cert: { kind, subject, validFrom, validTo, fingerprint, pem } }`: converted to X.509 DER by
1846
+ * matter.js, read back by node's X509Certificate. Anything that does not convert stays `$bytes`.
1847
+ */
1848
+ function _certValue(kind, bytes) {
1849
+ if (!ArrayBuffer.isView(bytes)) return _jsonSafe(bytes);
1850
+ try {
1851
+ const { Noc, Icac, Rcac } = require('@matter/protocol');
1852
+ const cert = { NOC: Noc, ICAC: Icac, RCAC: Rcac }[kind].fromTlv(bytes);
1853
+ const der = Buffer.from(cert.asSignedDer());
1854
+ const x509 = new (require('crypto').X509Certificate)(der);
1855
+ const lines = der
1856
+ .toString('base64')
1857
+ .match(/.{1,64}/g)
1858
+ .join('\n');
1859
+ return {
1860
+ $cert: {
1861
+ kind,
1862
+ // Matter's own fields (nodeId, fabricId, rcacId, icacId, caseAuthenticatedTags …)
1863
+ subject: _jsonSafe(cert.cert.subject),
1864
+ issuer: _jsonSafe(cert.cert.issuer),
1865
+ // the same as X.509 distinguished names, with Matter's OIDs named
1866
+ subjectDn: _matterDn(x509.subject),
1867
+ issuerDn: _matterDn(x509.issuer),
1868
+ serialNumber: x509.serialNumber,
1869
+ validFrom: new Date(x509.validFrom).toISOString(),
1870
+ validTo: new Date(x509.validTo).toISOString(),
1871
+ ca: x509.ca,
1872
+ // key usage, extended key usage, basic constraints, key identifiers — all the
1873
+ // certificate carries; Matter operational certificates have no CRL distribution point
1874
+ extensions: _jsonSafe(cert.cert.extensions),
1875
+ fingerprint: x509.fingerprint256,
1876
+ pem: `-----BEGIN CERTIFICATE-----\n${lines}\n-----END CERTIFICATE-----`,
1877
+ },
1878
+ };
1879
+ } catch {
1880
+ return _jsonSafe(bytes);
1881
+ }
1882
+ }
1883
+
1884
+ /** An attribute value for the UI and the REST API: certificates as PEM, the rest via _jsonSafe. */
1885
+ function _attrValue(clusterName, attrName, value) {
1886
+ if (clusterName === 'operationalCredentials') {
1887
+ if (attrName === 'nocs' && Array.isArray(value)) {
1888
+ return value.map((entry) => {
1889
+ const out = _jsonSafe(entry);
1890
+ if (entry?.noc) out.noc = _certValue('NOC', entry.noc);
1891
+ if (entry?.icac) out.icac = _certValue('ICAC', entry.icac);
1892
+ return out;
1893
+ });
1894
+ }
1895
+ if (attrName === 'trustedRootCertificates' && Array.isArray(value)) {
1896
+ return value.map((root) => _certValue('RCAC', root));
1897
+ }
1898
+ }
1899
+ return _jsonSafe(value);
1900
+ }
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
+
641
1933
  function _broadcastNodeAttributes(node, nodeId) {
642
1934
  if (!_broadcast) return;
643
1935
  if (_attrBroadcastNodes.has(nodeId)) return;
@@ -658,13 +1950,15 @@ function _broadcastNodeAttributes(node, nodeId) {
658
1950
  const changeEvent = clusterEvents[`${attrName}$Changed`];
659
1951
  if (!changeEvent || typeof changeEvent.on !== 'function') continue;
660
1952
  changeEvent.on((value) => {
1953
+ const safe = _attrValue(clusterName, attrName, value);
1954
+ events.emit('attr', { nodeId, endpointId, clusterName, attrName, value: safe, ts: Date.now() });
661
1955
  _broadcast?.({
662
1956
  type: 'matter:attr',
663
1957
  nodeId,
664
1958
  endpointId,
665
1959
  clusterName,
666
1960
  attrName,
667
- value: _jsonSafe(value),
1961
+ value: safe,
668
1962
  ts: Date.now(),
669
1963
  });
670
1964
  // Device/endpoint labels feed the device list — rebroadcast it
@@ -672,11 +1966,30 @@ function _broadcastNodeAttributes(node, nodeId) {
672
1966
  // the local mirror only learns the new nodeLabel when the
673
1967
  // device's subscription report arrives, i.e. exactly now.
674
1968
  if (attrName === 'nodeLabel' || attrName === 'productName') {
675
- _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
1969
+ _emitDeviceList();
676
1970
  }
677
1971
  });
678
1972
  count++;
679
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
+ }
680
1993
  }
681
1994
  }
682
1995
  if (count === 0) {
@@ -702,10 +2015,12 @@ function _subscribeNodeLifecycle(node, nodeId) {
702
2015
  const lc = node.lifecycle;
703
2016
  if (!lc) return;
704
2017
  lc.online?.on?.(() => {
2018
+ events.emit('status', { nodeId, online: true });
705
2019
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: true });
706
2020
  _broadcastNodeAttributes(node, nodeId);
707
2021
  });
708
2022
  lc.offline?.on?.(() => {
2023
+ events.emit('status', { nodeId, online: false });
709
2024
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: false });
710
2025
  });
711
2026
  }
@@ -729,13 +2044,24 @@ function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrNam
729
2044
  const endpoint = _resolveEndpoint(node, endpointId);
730
2045
  const events = endpoint.events?.[_clusterName(clusterName)];
731
2046
  if (!events) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
732
- const changeEvent = events[`${attrName}$Changed`];
733
- 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
+ }
734
2059
 
735
2060
  const listenerId = _nextListenerId++;
736
2061
  const observer = (value, oldValue) => {
737
2062
  try {
738
- callback(value, oldValue);
2063
+ if (isEvent) callback(value, { event: true, cluster: clusterName, name: attrName, ts: Date.now() });
2064
+ else callback(value, oldValue);
739
2065
  } catch (err) {
740
2066
  _log?.error(`matter subscriber error in ${scriptFile}:`, err.message);
741
2067
  }
@@ -801,11 +2127,42 @@ function getDeviceSubtitle(nodeId) {
801
2127
  }
802
2128
  }
803
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
+
804
2155
  function isStarted() {
805
2156
  return _server !== null;
806
2157
  }
807
2158
 
808
2159
  module.exports = {
2160
+ // for the unit tests: the value conversion, without a running controller
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,
809
2166
  init,
810
2167
  close,
811
2168
  isStarted,
@@ -814,10 +2171,30 @@ module.exports = {
814
2171
  unpair,
815
2172
  getEndpoints,
816
2173
  getDeviceSubtitle,
2174
+ getDeviceInfo,
817
2175
  getAttribute,
818
2176
  setAttribute,
819
2177
  rename,
820
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,
821
2198
  subscribeAttribute,
822
2199
  unsubscribe,
823
2200
  cleanup,