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.
- package/dist/web/assets/{index-BsO7OQRT.css → index-Bvl2uECO.css} +1 -1
- package/dist/web/assets/index-DLP7FxoS.js +305 -0
- package/dist/web/assets/{tsMode-CWVhEJ2t.js → tsMode-BJVHqGQB.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/src/config.js +21 -1
- package/src/index.js +107 -9
- package/src/lib/crash-record.js +103 -0
- package/src/lib/secrets.js +28 -1
- package/src/matter/bindings.js +291 -0
- package/src/matter/bridge-templates.js +261 -0
- package/src/matter/controller.js +1391 -14
- package/src/matter/dcl.js +252 -0
- package/src/matter/device-health.js +337 -0
- package/src/matter/groups.js +192 -0
- package/src/matter/mqtt-bridge.js +513 -0
- package/src/matter/thread-datasets.js +285 -0
- package/src/matter/thread-health.js +278 -0
- package/src/matter/thread-mesh.js +496 -0
- package/src/matter/thread.js +267 -0
- package/src/web/ai-tools.js +369 -0
- package/src/web/matter-api.js +560 -0
- package/src/web/prompts/tools.md +2 -1
- package/src/web/server.js +4 -1
- package/dist/web/assets/index-D2gZU7ce.js +0 -298
package/src/matter/controller.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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,
|
|
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,
|