@matter-server/ws-controller 0.3.6 → 0.3.7

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.
@@ -167,9 +167,73 @@ export function convertCommandDataToMatter(
167
167
  return convertWebSocketGenericToMatter(value, model, clusterModel);
168
168
  }
169
169
 
170
+ /**
171
+ * Model conversion kinds, classified once per ValueModel and cached for fast dispatch.
172
+ */
173
+ const enum ConvKind {
174
+ Passthrough,
175
+ EpochS,
176
+ EpochUS,
177
+ Bytes,
178
+ Bitmap,
179
+ Struct,
180
+ List,
181
+ }
182
+
183
+ /** Cached model-to-kind classification. Avoids repeated metabase property traversal. */
184
+ const modelKindCache = new WeakMap<ValueModel, ConvKind>();
185
+
186
+ /** Precomputed struct member info: avoids camelize() on every conversion. */
187
+ type StructMemberEntry = { readonly name: string; readonly id: number; readonly model: ValueModel };
188
+ const structMemberCache = new WeakMap<ValueModel, StructMemberEntry[]>();
189
+
190
+ function classifyModel(model: ValueModel): ConvKind {
191
+ let kind = modelKindCache.get(model);
192
+ if (kind !== undefined) return kind;
193
+
194
+ if (model.type === "list") {
195
+ kind = ConvKind.List;
196
+ } else if (model.metabase?.name === "struct") {
197
+ kind = ConvKind.Struct;
198
+ } else if (model.metabase?.metatype === "bitmap") {
199
+ kind = ConvKind.Bitmap;
200
+ } else if (model.metabase?.metatype === "bytes") {
201
+ kind = ConvKind.Bytes;
202
+ } else if (model.metabase?.metatype === "integer") {
203
+ kind =
204
+ model.type === "epoch-s"
205
+ ? ConvKind.EpochS
206
+ : model.type === "epoch-us"
207
+ ? ConvKind.EpochUS
208
+ : ConvKind.Passthrough;
209
+ } else {
210
+ kind = ConvKind.Passthrough;
211
+ }
212
+
213
+ modelKindCache.set(model, kind);
214
+ return kind;
215
+ }
216
+
217
+ function getStructMembers(model: ValueModel): StructMemberEntry[] {
218
+ let members = structMemberCache.get(model);
219
+ if (members !== undefined) return members;
220
+
221
+ members = [];
222
+ for (const member of model.members) {
223
+ if (member.name !== undefined && member.id !== undefined) {
224
+ members.push({ name: camelize(member.name), id: member.id, model: member });
225
+ }
226
+ }
227
+ structMemberCache.set(model, members);
228
+ return members;
229
+ }
230
+
170
231
  /**
171
232
  * Uses the matter.js Model to convert the response data for read, subscribe and invoke into a tag-based response
172
233
  * including conversion of data types.
234
+ *
235
+ * Model classification and struct member info are cached in WeakMaps so that repeated calls
236
+ * for the same model (e.g. across 160 nodes with identical clusters) skip the metabase traversal.
173
237
  */
174
238
  export function convertMatterToWebSocketTagBased(
175
239
  value: unknown,
@@ -180,34 +244,50 @@ export function convertMatterToWebSocketTagBased(
180
244
  return null;
181
245
  }
182
246
  if (model === undefined) {
183
- // Do some simple conversions when we have unknown attributes
247
+ // Unknown attributes: simple type conversions only
184
248
  if (Bytes.isBytes(value)) {
185
- return `${Bytes.toBase64(value)}`;
249
+ return Bytes.toBase64(value);
186
250
  }
187
251
  if (isObject(value) || !["string", "number", "bigint", "boolean", "undefined"].includes(typeof value)) {
188
- return null; // We cannot convert this
252
+ return null;
189
253
  }
190
-
191
254
  return value;
192
255
  }
193
- if (Array.isArray(value) && model.type === "list") {
194
- return value.map(v => convertMatterToWebSocketTagBased(v, model.members[0], clusterModel));
195
- }
196
- if (isObject(value) && model.metabase?.name === "struct") {
197
- const valueKeys = Object.keys(value);
198
- const result: { [key: string]: any } = {};
199
- for (const member of model.members) {
200
- const name = camelize(member.name);
201
- if (member.name !== undefined && member.id !== undefined && valueKeys.includes(name)) {
202
- result[member.id] = convertMatterToWebSocketTagBased(value[name], member, clusterModel);
256
+
257
+ switch (classifyModel(model)) {
258
+ case ConvKind.Passthrough:
259
+ return value;
260
+
261
+ case ConvKind.EpochS:
262
+ return typeof value === "number" ? value - MATTER_EPOCH_OFFSET_S : value;
263
+
264
+ case ConvKind.EpochUS:
265
+ return typeof value === "number" || typeof value === "bigint"
266
+ ? BigInt(value) - MATTER_EPOCH_OFFSET_US
267
+ : value;
268
+
269
+ case ConvKind.Bytes:
270
+ return value instanceof Uint8Array ? Bytes.toBase64(value) : value;
271
+
272
+ case ConvKind.List:
273
+ return Array.isArray(value)
274
+ ? value.map(v => convertMatterToWebSocketTagBased(v, model.members[0], clusterModel))
275
+ : value;
276
+
277
+ case ConvKind.Struct: {
278
+ if (!isObject(value)) return value;
279
+ const result: { [key: string]: any } = {};
280
+ for (const { name, id, model: memberModel } of getStructMembers(model)) {
281
+ if (Object.hasOwn(value, name)) {
282
+ result[id] = convertMatterToWebSocketTagBased(value[name], memberModel, clusterModel);
283
+ }
203
284
  }
285
+ return result;
204
286
  }
205
- return result;
206
- }
207
- if (isObject(value) && model.metabase?.metatype === "bitmap") {
208
- if (clusterModel !== undefined) {
209
- let numberValue = 0;
210
287
 
288
+ case ConvKind.Bitmap: {
289
+ if (!isObject(value) || clusterModel === undefined) return value;
290
+ let numberValue = 0;
211
291
  for (const member of clusterModel.scope.membersOf(model)) {
212
292
  const memberValue =
213
293
  member.name !== undefined && value[camelize(member.name)]
@@ -231,26 +311,9 @@ export function convertMatterToWebSocketTagBased(
231
311
  numberValue |= typeof memberValue === "boolean" ? 1 : memberValue << minBit;
232
312
  }
233
313
  }
234
-
235
314
  return numberValue;
236
315
  }
237
316
  }
238
-
239
- if (value instanceof Uint8Array && model.metabase?.metatype === "bytes") {
240
- value = `${Bytes.toBase64(value)}`;
241
- }
242
-
243
- if (model.metabase?.metatype === "integer") {
244
- // Convert Epoch timestamps to Unix timestamps we use internally
245
- if (model.type === "epoch-s" && typeof value === "number") {
246
- value -= MATTER_EPOCH_OFFSET_S;
247
- } else if (model.type === "epoch-us" && (typeof value === "number" || typeof value === "bigint")) {
248
- value = BigInt(value) - MATTER_EPOCH_OFFSET_US;
249
- }
250
- return value;
251
- }
252
-
253
- return value;
254
317
  }
255
318
 
256
319
  /**
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { camelize, ClusterId, FabricIndex, Logger, LogLevel, Millis, NodeId, ObserverGroup } from "@matter/main";
8
8
  import { ControllerCommissioningFlowOptions } from "@matter/main/protocol";
9
- import { EndpointNumber, getClusterById, QrPairingCodeCodec } from "@matter/main/types";
9
+ import { EndpointNumber, QrPairingCodeCodec } from "@matter/main/types";
10
10
  import { NodeStates } from "@project-chip/matter.js/device";
11
11
  import { WebSocketServer } from "ws";
12
12
  import { ControllerCommandHandler } from "../controller/ControllerCommandHandler.js";
@@ -138,19 +138,19 @@ export class WebSocketControllerHandler implements WebServerHandler {
138
138
 
139
139
  switch (eventName) {
140
140
  case "node_added":
141
- case "node_updated":
142
- this.#collectNodeDetails(nodeId).then(
143
- nodeDetails => {
144
- logger.debug(`[${connId}] Sending ${eventName} event for Node ${formatNodeId(nodeId)}`);
145
- ws.send(toBigIntAwareJson({ event: eventName, data: nodeDetails }));
146
- },
147
- err =>
148
- logger.error(
149
- `[${connId}] Failed to collect node details for Node ${formatNodeId(nodeId)}`,
150
- err,
151
- ),
152
- );
141
+ case "node_updated": {
142
+ try {
143
+ const nodeDetails = this.#collectNodeDetails(nodeId);
144
+ logger.debug(`[${connId}] Sending ${eventName} event for Node ${formatNodeId(nodeId)}`);
145
+ ws.send(toBigIntAwareJson({ event: eventName, data: nodeDetails }));
146
+ } catch (err) {
147
+ logger.error(
148
+ `[${connId}] Failed to collect node details for Node ${formatNodeId(nodeId)}`,
149
+ err,
150
+ );
151
+ }
153
152
  break;
153
+ }
154
154
  case "node_removed":
155
155
  logger.debug(`[${connId}] Sending node_removed event for Node ${formatNodeId(nodeId)}`);
156
156
  ws.send(toBigIntAwareJson({ event: eventName, data: nodeId }));
@@ -163,8 +163,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
163
163
  if (this.#closed) return;
164
164
  const { endpointId, clusterId, attributeId } = data.path;
165
165
  const pathStr = `${endpointId}/${clusterId}/${attributeId}`;
166
- const cluster = getClusterById(clusterId);
167
- const clusterData = ClusterMap[cluster.name.toLowerCase()];
166
+ const clusterData = ClusterMap[clusterId];
168
167
  const value = convertMatterToWebSocketTagBased(
169
168
  data.value,
170
169
  clusterData?.attributes[attributeId],
@@ -321,7 +320,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
321
320
  );
322
321
  });
323
322
 
324
- await this.#commandHandler.connect();
323
+ // Initialize all nodes (populates attribute caches) and start connecting them.
324
+ // Guarded internally so it runs exactly once even with multiple listen addresses.
325
+ await this.#commandHandler.initializeNodes();
325
326
  }
326
327
 
327
328
  unregister(): Promise<void> {
@@ -372,7 +373,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
372
373
  let enableListeners: boolean | undefined = undefined;
373
374
  switch (command) {
374
375
  case "start_listening":
375
- result = await this.#handleStartListening(args);
376
+ result = this.#handleStartListening(args);
376
377
  enableListeners = true;
377
378
  break;
378
379
  case "set_default_fabric_label":
@@ -388,7 +389,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
388
389
  result = await this.#handleGetNode(args);
389
390
  break;
390
391
  case "get_nodes":
391
- result = await this.#handleGetNodes(args);
392
+ result = this.#handleGetNodes(args);
392
393
  break;
393
394
  case "get_node_ip_addresses":
394
395
  result = await this.#handleGetNodeIpAddresses(args);
@@ -414,7 +415,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
414
415
  case "diagnostics":
415
416
  result = {
416
417
  info: await this.#getServerInfo(),
417
- nodes: await this.#handleGetNodes(args),
418
+ nodes: this.#handleGetNodes(args),
418
419
  events: this.getEventHistory(),
419
420
  };
420
421
  break;
@@ -543,8 +544,11 @@ export class WebSocketControllerHandler implements WebServerHandler {
543
544
  this.#broadcastEvent("server_info_updated", serverInfo);
544
545
  }
545
546
 
546
- async #handleStartListening(_args: ArgsOf<"start_listening">): Promise<ResponseOf<"start_listening">> {
547
- return await this.#handleGetNodes({});
547
+ #handleStartListening(_args: ArgsOf<"start_listening">): ResponseOf<"start_listening"> {
548
+ logger.info("WebSocket server start_listening");
549
+ const data = this.#handleGetNodes({});
550
+ logger.info("WebSocket server start_listening. Returned", data.length, "nodes");
551
+ return data;
548
552
  }
549
553
 
550
554
  async #handleSetDefaultFabricLabel(
@@ -592,7 +596,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
592
596
  threadCredentials,
593
597
  });
594
598
 
595
- return await this.#collectNodeDetails(nodeId);
599
+ return this.#collectNodeDetails(nodeId);
596
600
  }
597
601
 
598
602
  async #handleCommissionOnNetwork(
@@ -640,15 +644,15 @@ export class WebSocketControllerHandler implements WebServerHandler {
640
644
 
641
645
  const { nodeId } = await this.#commandHandler.commissionNode(commissionRequest);
642
646
 
643
- return await this.#collectNodeDetails(nodeId);
647
+ return this.#collectNodeDetails(nodeId);
644
648
  }
645
649
 
646
- async #handleGetNodes(args: ArgsOf<"get_nodes">): Promise<ResponseOf<"get_nodes">> {
650
+ #handleGetNodes(args: ArgsOf<"get_nodes">): ResponseOf<"get_nodes"> {
647
651
  const { only_available = false } = args ?? {};
648
652
  const nodeDetails = new Array<MatterNode>();
649
653
  // Include real nodes
650
654
  for (const node of this.#commandHandler.getNodeIds()) {
651
- const details = await this.#collectNodeDetails(node);
655
+ const details = this.#collectNodeDetails(node);
652
656
  if (!only_available || details.available) {
653
657
  nodeDetails.push(details);
654
658
  }
@@ -673,9 +677,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
673
677
 
674
678
  // Pass the last interview date for real nodes
675
679
  if (handler === this.#commandHandler) {
676
- return await this.#commandHandler.getNodeDetails(nodeId, this.#lastInterviewDates.get(nodeId));
680
+ return this.#commandHandler.getNodeDetails(nodeId, this.#lastInterviewDates.get(nodeId));
677
681
  }
678
- return await handler.getNodeDetails(nodeId);
682
+ return handler.getNodeDetails(nodeId);
679
683
  }
680
684
 
681
685
  async #handleGetNodeIpAddresses(
@@ -899,24 +903,25 @@ export class WebSocketControllerHandler implements WebServerHandler {
899
903
  addresses,
900
904
  mrpSessionActiveInterval,
901
905
  mrpSessionIdleInterval,
902
- }) => ({
903
- instance_name: instanceName,
904
- host_name: hostName, // TODO
905
- port,
906
- long_discriminator: longDiscriminator,
907
- vendor_id: vendorId,
908
- product_id: productId,
909
- commissioning_mode: commissioningMode,
910
- device_type: deviceType,
911
- device_name: deviceName,
912
- pairing_instruction: pairingInstruction,
913
- pairing_hint: pairingHint,
914
- mrp_retry_interval_idle: mrpSessionIdleInterval,
915
- mrp_retry_interval_active: mrpSessionActiveInterval,
916
- supports_tcp: supportsTcpServer,
917
- addresses,
918
- rotating_id: rotatingId,
919
- }),
906
+ }) =>
907
+ ({
908
+ instance_name: instanceName,
909
+ host_name: hostName, // TODO
910
+ port,
911
+ long_discriminator: longDiscriminator,
912
+ vendor_id: vendorId,
913
+ product_id: productId,
914
+ commissioning_mode: commissioningMode,
915
+ device_type: deviceType,
916
+ device_name: deviceName,
917
+ pairing_instruction: pairingInstruction,
918
+ pairing_hint: pairingHint,
919
+ mrp_retry_interval_idle: mrpSessionIdleInterval,
920
+ mrp_retry_interval_active: mrpSessionActiveInterval,
921
+ supports_tcp: supportsTcpServer,
922
+ addresses,
923
+ rotating_id: rotatingId,
924
+ }),
920
925
  );
921
926
  }
922
927
 
@@ -968,9 +973,9 @@ export class WebSocketControllerHandler implements WebServerHandler {
968
973
  return await this.#commandHandler.updateNode(NodeId(node_id), targetVersion);
969
974
  }
970
975
 
971
- async #collectNodeDetails(nodeId: NodeId): Promise<MatterNode> {
976
+ #collectNodeDetails(nodeId: NodeId): MatterNode {
972
977
  const lastInterviewDate = this.#lastInterviewDates.get(nodeId);
973
- return await this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate);
978
+ return this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate);
974
979
  }
975
980
 
976
981
  #convertCommandDataToWebSocketTagBased(
@@ -980,8 +985,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
980
985
  clusterData?: ClusterMapEntry,
981
986
  ) {
982
987
  if (!clusterData) {
983
- const cluster = getClusterById(clusterId);
984
- clusterData = clusterData ?? ClusterMap[cluster.name.toLowerCase()];
988
+ clusterData = ClusterMap[clusterId];
985
989
  }
986
990
 
987
991
  if (clusterData === undefined || clusterData.commands[commandName.toLowerCase()] === undefined) {