@matter-server/ws-controller 0.6.2-alpha.0-20260425-025dd18 → 0.6.2

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.
@@ -9,7 +9,6 @@ import {
9
9
  AsyncObservable,
10
10
  camelize,
11
11
  ClientNode,
12
- ClientNodeInteraction,
13
12
  CommissioningClient,
14
13
  FabricId,
15
14
  FabricIndex,
@@ -84,12 +83,6 @@ import {
84
83
  MatterNodeData,
85
84
  OpenCommissioningWindowRequest,
86
85
  OpenCommissioningWindowResponse,
87
- ReadEventRequest,
88
- ReadEventResponse,
89
- SubscribeAttributeRequest,
90
- SubscribeAttributeResponse,
91
- SubscribeEventRequest,
92
- SubscribeEventResponse,
93
86
  WriteAttributeRequest,
94
87
  } from "../types/CommandHandler.js";
95
88
  import {
@@ -370,7 +363,10 @@ export class ControllerCommandHandler {
370
363
  basicInfoChangedInBatch = false;
371
364
  this.events.nodeStructureChanged.emit(nodeId);
372
365
  });
373
- node.events.decommissioned.on(() => this.events.nodeDecommissioned.emit(nodeId));
366
+ node.events.decommissioned.on(() => {
367
+ this.#cleanupNodeAfterRemoval(nodeId);
368
+ this.events.nodeDecommissioned.emit(nodeId);
369
+ });
374
370
  node.events.nodeEndpointAdded.on(endpointId => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
375
371
  node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
376
372
 
@@ -465,7 +461,7 @@ export class ControllerCommandHandler {
465
461
  }),
466
462
  includeKnownVersions: true, // do not send DataVersionFilters, so we do a new clean read
467
463
  };
468
- for await (const _chunk of (node.node.interaction as ClientNodeInteraction).read(read));
464
+ for await (const _chunk of node.node.interaction.read(read));
469
465
  }
470
466
 
471
467
  /**
@@ -517,42 +513,44 @@ export class ControllerCommandHandler {
517
513
  fabricFiltered = false,
518
514
  ): Promise<AttributesData> {
519
515
  const result: AttributesData = {};
520
- const client = this.#nodes.interactionClientFor(nodeId);
516
+ const node = this.#nodes.get(nodeId);
521
517
  const batchSize = 9;
522
-
523
- // Parse all paths (wildcards become undefined for that component)
524
518
  const parsedPaths = attributePaths.map(path => splitAttributePath(path));
525
519
 
526
- // Process in batches of up to 9
527
520
  for (let i = 0; i < parsedPaths.length; i += batchSize) {
528
521
  const batch = parsedPaths.slice(i, i + batchSize);
529
- const attributes = batch.map(({ endpointId, clusterId, attributeId }) => ({
530
- endpointId: endpointId !== undefined ? EndpointNumber(endpointId) : undefined,
531
- clusterId: clusterId !== undefined ? ClusterId(clusterId) : undefined,
532
- attributeId: attributeId !== undefined ? AttributeId(attributeId) : undefined,
533
- }));
534
-
535
- const { attributeData, attributeStatus } = await client.getMultipleAttributesAndStatus({
536
- attributes,
537
- isFabricFiltered: fabricFiltered,
538
- });
539
-
540
- for (const { path: attrPath, value } of attributeData) {
541
- const { pathStr, value: wsValue } = this.#convertAttributeToWebSocket(
542
- {
543
- endpointId: EndpointNumber(attrPath.endpointId),
544
- clusterId: ClusterId(attrPath.clusterId),
545
- attributeId: attrPath.attributeId,
546
- },
547
- value,
548
- );
549
- result[pathStr] = wsValue;
550
- }
522
+ const readRequest = {
523
+ ...Read({
524
+ fabricFilter: fabricFiltered,
525
+ attributes: batch.map(({ endpointId, clusterId, attributeId }) => ({
526
+ endpointId: endpointId !== undefined ? EndpointNumber(endpointId) : undefined,
527
+ clusterId: clusterId !== undefined ? ClusterId(clusterId) : undefined,
528
+ attributeId: attributeId !== undefined ? AttributeId(attributeId) : undefined,
529
+ })),
530
+ }),
531
+ includeKnownVersions: true,
532
+ };
551
533
 
552
- if (attributeStatus && attributeStatus.length > 0) {
553
- for (const { path: attrPath, status } of attributeStatus) {
554
- const pathStr = buildAttributePath(attrPath.endpointId, attrPath.clusterId, attrPath.attributeId);
555
- logger.warn(`Failed to read attribute ${pathStr}: status=${status}`);
534
+ for await (const chunk of node.node.interaction.read(readRequest)) {
535
+ for (const entry of chunk) {
536
+ if (entry.kind === "attr-value") {
537
+ const { pathStr, value: wsValue } = this.#convertAttributeToWebSocket(
538
+ {
539
+ endpointId: EndpointNumber(entry.path.endpointId),
540
+ clusterId: ClusterId(entry.path.clusterId),
541
+ attributeId: entry.path.attributeId,
542
+ },
543
+ entry.value,
544
+ );
545
+ result[pathStr] = wsValue;
546
+ } else if (entry.kind === "attr-status") {
547
+ const pathStr = buildAttributePath(
548
+ entry.path.endpointId,
549
+ entry.path.clusterId,
550
+ entry.path.attributeId,
551
+ );
552
+ logger.warn(`Failed to read attribute ${pathStr}: status=${entry.status}`);
553
+ }
556
554
  }
557
555
  }
558
556
  }
@@ -582,6 +580,33 @@ export class ControllerCommandHandler {
582
580
  };
583
581
  }
584
582
 
583
+ /**
584
+ * Write a single attribute on a remote node. Uses `setStateOf(string, ...)` (not `set({...})`)
585
+ * because peer cluster behaviors are dynamically registered and aren't on the agent's cached property getters.
586
+ */
587
+ async #writeAttribute(
588
+ nodeId: NodeId,
589
+ endpointId: EndpointNumber,
590
+ clusterId: ClusterId,
591
+ attributeName: string,
592
+ value: unknown,
593
+ ): Promise<{ status: number; clusterStatus?: number }> {
594
+ const node = this.#nodes.get(nodeId);
595
+ const clusterEntry = ClusterMap[clusterId];
596
+ if (!clusterEntry) {
597
+ throw ServerError.invalidArguments(`Cluster Id "${clusterId}" unknown`);
598
+ }
599
+ const clusterProperty = clusterEntry.model.propertyName;
600
+
601
+ try {
602
+ await node.node.endpoints.for(endpointId).setStateOf(clusterProperty, { [attributeName]: value });
603
+ return { status: 0 };
604
+ } catch (error) {
605
+ StatusResponseError.accept(error);
606
+ return { status: error.code, clusterStatus: error.clusterCode };
607
+ }
608
+ }
609
+
585
610
  /**
586
611
  * Set the fabric label. Pass null or empty string to reset to "Home".
587
612
  * Note: matter.js requires non-empty labels (1-32 chars), so null/empty resets to default.
@@ -594,165 +619,27 @@ export class ControllerCommandHandler {
594
619
  return this.#controller.disconnectNode(nodeId, true);
595
620
  }
596
621
 
597
- async handleReadEvent(data: ReadEventRequest): Promise<ReadEventResponse> {
598
- const { nodeId, endpointId, clusterId, eventId, eventMin } = data;
599
- const client = this.#nodes.interactionClientFor(nodeId);
600
- const { eventData, eventStatus } = await client.getMultipleEventsAndStatus({
601
- events: [
602
- {
603
- endpointId,
604
- clusterId,
605
- eventId,
606
- },
607
- ],
608
- eventFilters: eventMin ? [{ eventMin }] : undefined,
609
- });
610
-
611
- return {
612
- values: eventData.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
613
- events.map(({ eventNumber, data }) => ({
614
- eventId,
615
- clusterId,
616
- endpointId,
617
- eventNumber,
618
- value: data,
619
- })),
620
- ),
621
- status: eventStatus?.map(({ path: { endpointId, clusterId, eventId }, status, clusterStatus }) => ({
622
- clusterId,
623
- endpointId,
624
- eventId,
625
- status,
626
- clusterStatus,
627
- })),
628
- };
629
- }
630
-
631
- async handleSubscribeAttribute(data: SubscribeAttributeRequest): Promise<SubscribeAttributeResponse> {
632
- const { nodeId, endpointId, clusterId, attributeId, minInterval, maxInterval, changeListener } = data;
633
- const client = this.#nodes.interactionClientFor(nodeId);
634
- const updated = Observable<[void]>();
635
- let ignoreData = true; // We ignore data coming in during initial seeding
636
- const { attributeReports = [] } = await client.subscribeMultipleAttributesAndEvents({
637
- attributes: [
638
- {
639
- endpointId,
640
- clusterId,
641
- attributeId,
642
- },
643
- ],
644
- minIntervalFloorSeconds: minInterval,
645
- maxIntervalCeilingSeconds: maxInterval,
646
- attributeListener: data => {
647
- if (ignoreData) return;
648
- changeListener({
649
- attributeId: data.path.attributeId,
650
- clusterId: data.path.clusterId,
651
- endpointId: data.path.endpointId,
652
- dataVersion: data.version,
653
- value: data.value,
654
- });
655
- },
656
- updateReceived: () => {
657
- updated.emit();
658
- },
659
- keepSubscriptions: false,
660
- });
661
- ignoreData = false;
662
-
663
- return {
664
- values: attributeReports.map(
665
- ({ path: { endpointId, clusterId, attributeId }, value, version: dataVersion }) => ({
666
- attributeId,
667
- clusterId,
668
- endpointId,
669
- dataVersion,
670
- value,
671
- }),
672
- ),
673
- updated,
674
- };
675
- }
676
-
677
- async handleSubscribeEvent(data: SubscribeEventRequest): Promise<SubscribeEventResponse> {
678
- const { nodeId, endpointId, clusterId, eventId, minInterval, maxInterval, changeListener } = data;
679
- const client = this.#nodes.interactionClientFor(nodeId);
680
- const updated = Observable<[void]>();
681
- let ignoreData = true; // We ignore data coming in during initial seeding
682
- const { eventReports = [] } = await client.subscribeMultipleAttributesAndEvents({
683
- events: [
684
- {
685
- endpointId,
686
- clusterId,
687
- eventId,
688
- },
689
- ],
690
- minIntervalFloorSeconds: minInterval,
691
- maxIntervalCeilingSeconds: maxInterval,
692
- eventListener: data => {
693
- if (ignoreData) return;
694
- data.events.forEach(event =>
695
- changeListener({
696
- eventId: data.path.eventId,
697
- clusterId: data.path.clusterId,
698
- endpointId: data.path.endpointId,
699
- eventNumber: event.eventNumber,
700
- value: event.data,
701
- }),
702
- );
703
- },
704
- updateReceived: () => {
705
- updated.emit();
706
- },
707
- keepSubscriptions: false,
708
- });
709
- ignoreData = false;
710
-
711
- return {
712
- values: eventReports.flatMap(({ path: { endpointId, clusterId, eventId }, events }) =>
713
- events.map(({ eventNumber, data }) => ({
714
- eventId,
715
- clusterId,
716
- endpointId,
717
- eventNumber,
718
- value: data,
719
- })),
720
- ),
721
- updated,
722
- };
723
- }
724
-
725
622
  async handleWriteAttribute(data: WriteAttributeRequest): Promise<AttributeResponseStatus> {
726
623
  const { nodeId, endpointId, clusterId, attributeId } = data;
727
624
  let { value } = data;
728
625
 
729
- const client = this.#nodes.clusterClientByIdFor(nodeId, endpointId, clusterId);
730
-
731
626
  const clusterEntry = ClusterMap[clusterId];
732
- const model = clusterEntry?.attributes[attributeId];
733
- if (model && clusterEntry) {
734
- value = convertWebSocketTagBasedToMatter(value, model, clusterEntry.model);
627
+ const attributeModel = clusterEntry?.attributes[attributeId];
628
+ if (!clusterEntry || !attributeModel) {
629
+ throw ServerError.invalidArguments(`Attribute ${attributeId} on cluster ${clusterId} unknown`);
735
630
  }
736
631
 
632
+ value = convertWebSocketTagBasedToMatter(value, attributeModel, clusterEntry.model);
633
+
737
634
  logger.info("Writing attribute", attributeId, "with value", value);
738
- try {
739
- await client.attributes[attributeId].set(value);
740
- return {
741
- attributeId,
742
- clusterId,
743
- endpointId,
744
- status: 0,
745
- };
746
- } catch (error) {
747
- StatusResponseError.accept(error);
748
- return {
749
- attributeId,
750
- clusterId,
751
- endpointId,
752
- status: error.code,
753
- clusterStatus: error.clusterCode,
754
- };
755
- }
635
+ const { status, clusterStatus } = await this.#writeAttribute(
636
+ nodeId,
637
+ endpointId,
638
+ clusterId,
639
+ attributeModel.propertyName,
640
+ value,
641
+ );
642
+ return { attributeId, clusterId, endpointId, status, clusterStatus };
756
643
  }
757
644
 
758
645
  async #invokeCommand<const C extends Specifier.ClusterLike>(
@@ -1140,12 +1027,21 @@ export class ControllerCommandHandler {
1140
1027
  throw ServerError.nodeNotExists(nodeId);
1141
1028
  }
1142
1029
  await this.#controller.removeNode(nodeId, !!node?.isConnected);
1030
+ this.#cleanupNodeAfterRemoval(nodeId);
1031
+ }
1032
+
1033
+ /**
1034
+ * Drop all references to a removed node so subsequent reads don't reach a
1035
+ * destroyed PairedNode. Idempotent — both the `decommissioned` listener
1036
+ * (external fabric leave) and `decommissionNode` invoke it, and the
1037
+ * listener may have run first.
1038
+ */
1039
+ #cleanupNodeAfterRemoval(nodeId: NodeId) {
1143
1040
  this.#reconnectTimers.get(nodeId)?.stop();
1144
1041
  this.#reconnectTimers.delete(nodeId);
1145
- // Remove node from storage (also clears attribute cache)
1146
1042
  this.#nodes.delete(nodeId);
1147
- // Unregister from custom cluster polling
1148
1043
  this.#customClusterPoller.unregisterNode(nodeId);
1044
+ this.#availableUpdates.delete(nodeId);
1149
1045
  }
1150
1046
 
1151
1047
  async openCommissioningWindow(data: OpenCommissioningWindowRequest): Promise<OpenCommissioningWindowResponse> {
@@ -1172,7 +1068,7 @@ export class ControllerCommandHandler {
1172
1068
  includeKnownVersions: true, // we want to read from device
1173
1069
  };
1174
1070
 
1175
- for await (const chunk of (node.node.interaction as ClientNodeInteraction).read(read)) {
1071
+ for await (const chunk of node.node.interaction.read(read)) {
1176
1072
  for (const attr of chunk) {
1177
1073
  if (attr.kind === "attr-value" && Array.isArray(attr.value)) {
1178
1074
  // We only expect one array response
@@ -1197,12 +1093,8 @@ export class ControllerCommandHandler {
1197
1093
  /**
1198
1094
  * Set Access Control List entries on a node.
1199
1095
  * Writes to the ACL attribute on the AccessControl cluster (endpoint 0).
1200
- * TODO Migrate to new Node API
1201
1096
  */
1202
1097
  async setAclEntry(nodeId: NodeId, entries: AccessControlEntry[]): Promise<AttributeWriteResult[] | null> {
1203
- const client = this.#nodes.clusterClientFor(nodeId, EndpointNumber(0), AccessControl.Cluster);
1204
-
1205
- // Convert from WebSocket format (snake_case) to Matter.js format (camelCase)
1206
1098
  const aclEntries: AccessControl.AccessControlEntry[] = entries.map(entry => ({
1207
1099
  privilege: entry.privilege as AccessControl.AccessControlEntryPrivilege,
1208
1100
  authMode: entry.auth_mode as AccessControl.AccessControlEntryAuthMode,
@@ -1218,46 +1110,30 @@ export class ControllerCommandHandler {
1218
1110
 
1219
1111
  logger.info("Setting ACL entries", aclEntries);
1220
1112
 
1221
- try {
1222
- await client.setAclAttribute(aclEntries);
1223
- return [
1224
- {
1225
- path: {
1226
- endpoint_id: 0,
1227
- cluster_id: AccessControl.Cluster.id,
1228
- attribute_id: 0, // ACL attribute ID
1229
- },
1230
- status: 0,
1231
- },
1232
- ];
1233
- } catch (error) {
1234
- StatusResponseError.accept(error);
1235
- return [
1236
- {
1237
- path: {
1238
- endpoint_id: 0,
1239
- cluster_id: AccessControl.Cluster.id,
1240
- attribute_id: 0,
1241
- },
1242
- status: error.code,
1243
- },
1244
- ];
1245
- }
1113
+ const { status } = await this.#writeAttribute(
1114
+ nodeId,
1115
+ EndpointNumber(0),
1116
+ AccessControl.Cluster.id,
1117
+ "acl",
1118
+ aclEntries,
1119
+ );
1120
+ return [
1121
+ {
1122
+ path: { endpoint_id: 0, cluster_id: AccessControl.Cluster.id, attribute_id: 0 },
1123
+ status,
1124
+ },
1125
+ ];
1246
1126
  }
1247
1127
 
1248
1128
  /**
1249
1129
  * Set bindings on a specific endpoint of a node.
1250
1130
  * Writes to the Binding attribute on the Binding cluster.
1251
- * TODO Migrate to new Node API
1252
1131
  */
1253
1132
  async setNodeBinding(
1254
1133
  nodeId: NodeId,
1255
1134
  endpointId: EndpointNumber,
1256
1135
  bindings: BindingTarget[],
1257
1136
  ): Promise<AttributeWriteResult[] | null> {
1258
- const client = this.#nodes.clusterClientFor(nodeId, endpointId, Binding.Cluster);
1259
-
1260
- // Convert from WebSocket format to Matter.js format
1261
1137
  const bindingEntries: Binding.Target[] = bindings.map(binding => ({
1262
1138
  node: binding.node !== null ? NodeId(binding.node) : undefined,
1263
1139
  group: binding.group !== null ? GroupId(binding.group) : undefined,
@@ -1268,31 +1144,19 @@ export class ControllerCommandHandler {
1268
1144
 
1269
1145
  logger.info("Setting bindings on endpoint", endpointId, bindingEntries);
1270
1146
 
1271
- try {
1272
- await client.attributes.binding.set(bindingEntries);
1273
- return [
1274
- {
1275
- path: {
1276
- endpoint_id: endpointId,
1277
- cluster_id: Binding.Cluster.id,
1278
- attribute_id: 0, // Binding attribute ID
1279
- },
1280
- status: 0,
1281
- },
1282
- ];
1283
- } catch (error) {
1284
- StatusResponseError.accept(error);
1285
- return [
1286
- {
1287
- path: {
1288
- endpoint_id: endpointId,
1289
- cluster_id: Binding.Cluster.id,
1290
- attribute_id: 0,
1291
- },
1292
- status: error.code,
1293
- },
1294
- ];
1295
- }
1147
+ const { status } = await this.#writeAttribute(
1148
+ nodeId,
1149
+ endpointId,
1150
+ Binding.Cluster.id,
1151
+ "binding",
1152
+ bindingEntries,
1153
+ );
1154
+ return [
1155
+ {
1156
+ path: { endpoint_id: endpointId, cluster_id: Binding.Cluster.id, attribute_id: 0 },
1157
+ status,
1158
+ },
1159
+ ];
1296
1160
  }
1297
1161
 
1298
1162
  /**
@@ -5,20 +5,16 @@
5
5
  */
6
6
 
7
7
  import { NodeId } from "@matter/main";
8
- import { ClusterClientObj } from "@matter/main/protocol";
9
- import { ClusterId, ClusterType, EndpointNumber } from "@matter/main/types";
10
- import { InteractionClient } from "@project-chip/matter.js/cluster";
11
8
  import { NodeStates, PairedNode } from "@project-chip/matter.js/device";
12
9
  import { ServerError } from "../types/WebSocketMessageTypes.js";
13
10
  import { AttributeDataCache } from "./AttributeDataCache.js";
14
11
 
15
12
  /**
16
- * Manages node storage and provides access to nodes and their clients.
13
+ * Manages node storage and tracks per-node availability.
17
14
  *
18
15
  * This class handles:
19
16
  * - Storage of PairedNode instances
20
17
  * - Node retrieval and existence checking
21
- * - Access to interaction clients and cluster clients
22
18
  * - Attribute data caching
23
19
  * - Connection state tracking for availability debouncing
24
20
  */
@@ -156,47 +152,4 @@ export class Nodes {
156
152
  isAvailable(nodeId: NodeId): boolean {
157
153
  return this.#lastAvailability.get(nodeId) ?? false;
158
154
  }
159
-
160
- /**
161
- * Get the interaction client for a node.
162
- */
163
- interactionClientFor(nodeId: NodeId): InteractionClient {
164
- return this.get(nodeId).getInteractionClient();
165
- }
166
-
167
- /**
168
- * Get a cluster client by cluster ID for a specific endpoint on a node.
169
- * @throws Error if endpoint or cluster not found
170
- * TODO: Migrate to new node API
171
- */
172
- clusterClientByIdFor(nodeId: NodeId, endpointId: EndpointNumber, clusterId: ClusterId): ClusterClientObj<any> {
173
- const node = this.get(nodeId);
174
-
175
- const endpoint = endpointId === 0 ? node.getRootEndpoint() : node.getDeviceById(endpointId);
176
-
177
- if (endpoint === undefined) {
178
- throw ServerError.invalidArguments(`Endpoint ${endpointId} on node ${nodeId} not found`);
179
- }
180
-
181
- const client = endpoint.getClusterClientById(clusterId);
182
-
183
- if (client === undefined) {
184
- throw ServerError.invalidArguments(
185
- `Cluster ${clusterId} on endpoint ${endpointId} on node ${nodeId} not found`,
186
- );
187
- }
188
-
189
- return client;
190
- }
191
-
192
- /**
193
- * Get a typed cluster client for a specific endpoint on a node.
194
- */
195
- clusterClientFor<const T extends ClusterType>(
196
- nodeId: NodeId,
197
- endpointId: EndpointNumber,
198
- cluster: T,
199
- ): ClusterClientObj<T["Typing"]> {
200
- return this.clusterClientByIdFor(nodeId, endpointId, cluster.id!) as ClusterClientObj<T["Typing"]>;
201
- }
202
155
  }