@matter-server/ws-controller 0.2.0-alpha.0-00000000-000000000

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.
Files changed (95) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +11 -0
  3. package/dist/esm/controller/AttributeDataCache.d.ts +49 -0
  4. package/dist/esm/controller/AttributeDataCache.d.ts.map +1 -0
  5. package/dist/esm/controller/AttributeDataCache.js +154 -0
  6. package/dist/esm/controller/AttributeDataCache.js.map +6 -0
  7. package/dist/esm/controller/ControllerCommandHandler.d.ts +118 -0
  8. package/dist/esm/controller/ControllerCommandHandler.d.ts.map +1 -0
  9. package/dist/esm/controller/ControllerCommandHandler.js +1015 -0
  10. package/dist/esm/controller/ControllerCommandHandler.js.map +6 -0
  11. package/dist/esm/controller/LegacyDataInjector.d.ts +95 -0
  12. package/dist/esm/controller/LegacyDataInjector.d.ts.map +1 -0
  13. package/dist/esm/controller/LegacyDataInjector.js +196 -0
  14. package/dist/esm/controller/LegacyDataInjector.js.map +6 -0
  15. package/dist/esm/controller/MatterController.d.ts +59 -0
  16. package/dist/esm/controller/MatterController.d.ts.map +1 -0
  17. package/dist/esm/controller/MatterController.js +212 -0
  18. package/dist/esm/controller/MatterController.js.map +6 -0
  19. package/dist/esm/controller/Nodes.d.ts +62 -0
  20. package/dist/esm/controller/Nodes.d.ts.map +1 -0
  21. package/dist/esm/controller/Nodes.js +85 -0
  22. package/dist/esm/controller/Nodes.js.map +6 -0
  23. package/dist/esm/controller/TestNodeCommandHandler.d.ts +84 -0
  24. package/dist/esm/controller/TestNodeCommandHandler.d.ts.map +1 -0
  25. package/dist/esm/controller/TestNodeCommandHandler.js +225 -0
  26. package/dist/esm/controller/TestNodeCommandHandler.js.map +6 -0
  27. package/dist/esm/data/VendorIDs.d.ts +7 -0
  28. package/dist/esm/data/VendorIDs.d.ts.map +1 -0
  29. package/dist/esm/data/VendorIDs.js +1237 -0
  30. package/dist/esm/data/VendorIDs.js.map +6 -0
  31. package/dist/esm/example/send-command.d.ts +7 -0
  32. package/dist/esm/example/send-command.d.ts.map +1 -0
  33. package/dist/esm/example/send-command.js +60 -0
  34. package/dist/esm/example/send-command.js.map +6 -0
  35. package/dist/esm/index.d.ts +21 -0
  36. package/dist/esm/index.d.ts.map +1 -0
  37. package/dist/esm/index.js +26 -0
  38. package/dist/esm/index.js.map +6 -0
  39. package/dist/esm/model/ModelMapper.d.ts +34 -0
  40. package/dist/esm/model/ModelMapper.d.ts.map +1 -0
  41. package/dist/esm/model/ModelMapper.js +62 -0
  42. package/dist/esm/model/ModelMapper.js.map +6 -0
  43. package/dist/esm/package.json +3 -0
  44. package/dist/esm/server/ConfigStorage.d.ts +29 -0
  45. package/dist/esm/server/ConfigStorage.d.ts.map +1 -0
  46. package/dist/esm/server/ConfigStorage.js +84 -0
  47. package/dist/esm/server/ConfigStorage.js.map +6 -0
  48. package/dist/esm/server/Converters.d.ts +53 -0
  49. package/dist/esm/server/Converters.d.ts.map +1 -0
  50. package/dist/esm/server/Converters.js +343 -0
  51. package/dist/esm/server/Converters.js.map +6 -0
  52. package/dist/esm/server/WebSocketControllerHandler.d.ts +21 -0
  53. package/dist/esm/server/WebSocketControllerHandler.d.ts.map +1 -0
  54. package/dist/esm/server/WebSocketControllerHandler.js +767 -0
  55. package/dist/esm/server/WebSocketControllerHandler.js.map +6 -0
  56. package/dist/esm/types/CommandHandler.d.ts +258 -0
  57. package/dist/esm/types/CommandHandler.d.ts.map +1 -0
  58. package/dist/esm/types/CommandHandler.js +6 -0
  59. package/dist/esm/types/CommandHandler.js.map +6 -0
  60. package/dist/esm/types/WebServer.d.ts +12 -0
  61. package/dist/esm/types/WebServer.d.ts.map +1 -0
  62. package/dist/esm/types/WebServer.js +6 -0
  63. package/dist/esm/types/WebServer.js.map +6 -0
  64. package/dist/esm/types/WebSocketMessageTypes.d.ts +478 -0
  65. package/dist/esm/types/WebSocketMessageTypes.d.ts.map +1 -0
  66. package/dist/esm/types/WebSocketMessageTypes.js +77 -0
  67. package/dist/esm/types/WebSocketMessageTypes.js.map +6 -0
  68. package/dist/esm/util/matterVersion.d.ts +12 -0
  69. package/dist/esm/util/matterVersion.d.ts.map +1 -0
  70. package/dist/esm/util/matterVersion.js +32 -0
  71. package/dist/esm/util/matterVersion.js.map +6 -0
  72. package/dist/esm/util/network.d.ts +14 -0
  73. package/dist/esm/util/network.d.ts.map +1 -0
  74. package/dist/esm/util/network.js +63 -0
  75. package/dist/esm/util/network.js.map +6 -0
  76. package/package.json +45 -0
  77. package/src/controller/AttributeDataCache.ts +194 -0
  78. package/src/controller/ControllerCommandHandler.ts +1256 -0
  79. package/src/controller/LegacyDataInjector.ts +314 -0
  80. package/src/controller/MatterController.ts +265 -0
  81. package/src/controller/Nodes.ts +115 -0
  82. package/src/controller/TestNodeCommandHandler.ts +305 -0
  83. package/src/data/VendorIDs.ts +1234 -0
  84. package/src/example/send-command.ts +82 -0
  85. package/src/index.ts +33 -0
  86. package/src/model/ModelMapper.ts +87 -0
  87. package/src/server/ConfigStorage.ts +112 -0
  88. package/src/server/Converters.ts +483 -0
  89. package/src/server/WebSocketControllerHandler.ts +917 -0
  90. package/src/tsconfig.json +7 -0
  91. package/src/types/CommandHandler.ts +270 -0
  92. package/src/types/WebServer.ts +14 -0
  93. package/src/types/WebSocketMessageTypes.ts +525 -0
  94. package/src/util/matterVersion.ts +45 -0
  95. package/src/util/network.ts +85 -0
@@ -0,0 +1,483 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { AttributeId, Bytes, camelize, ClusterId, isObject, Logger } from "@matter/main";
8
+ import { ClusterModel, FieldValue, ValueModel } from "@matter/main/model";
9
+ import { EndpointNumber, MATTER_EPOCH_OFFSET_S, MATTER_EPOCH_OFFSET_US } from "@matter/main/types";
10
+
11
+ const logger = new Logger("ChipToolWebSocketHandler");
12
+
13
+ /** Convert stringified numbers in hex and normal style to either number or bigint. */
14
+ export function parseNumber(number: string): number | bigint {
15
+ const parsed = number.startsWith("0x") ? BigInt(number) : parseInt(number);
16
+ if (typeof parsed === "number" && isNaN(parsed)) {
17
+ throw new Error(`Failed to parse number: ${number}`);
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function convertWebSocketGenericToMatter(value: unknown, model: ValueModel, clusterModel: ClusterModel) {
23
+ // Handle bitmaps - convert number to object with boolean flags
24
+ if (typeof value === "number" && model.metabase?.metatype === "bitmap") {
25
+ const bitmapValue: { [key: string]: boolean | number } = {};
26
+
27
+ for (const member of clusterModel.scope.membersOf(model)) {
28
+ const memberName =
29
+ member.name !== undefined && model.name !== "FeatureMap"
30
+ ? camelize(member.name)
31
+ : member.title !== undefined
32
+ ? camelize(member.title)
33
+ : undefined;
34
+
35
+ if (memberName === undefined) {
36
+ continue;
37
+ }
38
+
39
+ const constraintValue = FieldValue.numericValue(member.constraint.value);
40
+ if (constraintValue !== undefined) {
41
+ // Single bit - extract as boolean
42
+ bitmapValue[memberName] = (value & (1 << constraintValue)) !== 0;
43
+ } else {
44
+ const minBit = FieldValue.numericValue(member.constraint.min) ?? 0;
45
+ const maxBit = FieldValue.numericValue(member.constraint.max);
46
+ if (maxBit !== undefined) {
47
+ // Multi-bit field - extract value
48
+ const mask = ((1 << (maxBit - minBit + 1)) - 1) << minBit;
49
+ bitmapValue[memberName] = (value & mask) >> minBit;
50
+ } else {
51
+ // Single bit at minBit position
52
+ bitmapValue[memberName] = (value & (1 << minBit)) !== 0;
53
+ }
54
+ }
55
+ }
56
+
57
+ return bitmapValue;
58
+ }
59
+
60
+ // Handle bytes - convert base64 string to Uint8Array
61
+ if (typeof value === "string" && model.metabase?.metatype === "bytes") {
62
+ return Bytes.fromBase64(value);
63
+ }
64
+
65
+ // Handle epoch timestamps - convert from Unix timestamps to Matter epoch
66
+ if (model.metabase?.metatype === "integer") {
67
+ if (model.type === "epoch-s" && typeof value === "number") {
68
+ return value + MATTER_EPOCH_OFFSET_S;
69
+ } else if (model.type === "epoch-us" && (typeof value === "number" || typeof value === "bigint")) {
70
+ return BigInt(value) + MATTER_EPOCH_OFFSET_US;
71
+ }
72
+ }
73
+
74
+ // Return primitives as-is
75
+ return value;
76
+ }
77
+
78
+ /**
79
+ * Converts tag-based WebSocket data (with numeric keys) back to Matter.js data format (with camelCased names).
80
+ * This is the reverse of convertMatterToWebSocketTagBased.
81
+ */
82
+ export function convertWebSocketTagBasedToMatter(
83
+ value: unknown,
84
+ model: ValueModel | undefined,
85
+ clusterModel: ClusterModel,
86
+ ): unknown {
87
+ if (model === undefined || value === null) {
88
+ return value; // Return null/undefined values as-is
89
+ }
90
+
91
+ // Handle lists
92
+ if (Array.isArray(value) && model.type === "list") {
93
+ return value.map(v => convertWebSocketTagBasedToMatter(v, model.members[0], clusterModel));
94
+ }
95
+
96
+ // Handle structs - convert numeric keys to camelCased member names
97
+ if (isObject(value) && model.metabase?.name === "struct") {
98
+ const valueKeys = Object.keys(value);
99
+ const result: { [key: string]: unknown } = {};
100
+
101
+ // Build a map of member ID to member for efficient lookup
102
+ const memberById: { [id: number]: ValueModel } = {};
103
+ for (const member of model.members) {
104
+ if (member.id !== undefined) {
105
+ memberById[member.id] = member;
106
+ }
107
+ }
108
+
109
+ for (const key of valueKeys) {
110
+ const memberId = parseInt(key);
111
+ if (!isNaN(memberId) && memberById[memberId]) {
112
+ const member = memberById[memberId];
113
+ result[camelize(member.name)] = convertWebSocketTagBasedToMatter(value[key], member, clusterModel);
114
+ } else {
115
+ // Keep unknown keys as-is (fallback for unknown attributes)
116
+ result[key] = value[key];
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ return convertWebSocketGenericToMatter(value, model, clusterModel);
123
+ }
124
+
125
+ /**
126
+ * Converts camelized name-based WebSocket data to Matter.js data format. Mainly to ensure binary and epoch data
127
+ */
128
+ export function convertCommandDataToMatter(
129
+ value: unknown,
130
+ model: ValueModel | undefined,
131
+ clusterModel: ClusterModel,
132
+ ): unknown {
133
+ if (model === undefined || value === null) {
134
+ return value; // Return null/undefined values as-is
135
+ }
136
+
137
+ // Handle lists
138
+ if (Array.isArray(value) && model.type === "list") {
139
+ return value.map(v => convertCommandDataToMatter(v, model.members[0], clusterModel));
140
+ }
141
+
142
+ // Handle structs - convert numeric keys to camelCased member names
143
+ if (isObject(value) && model.metabase?.name === "struct") {
144
+ const valueKeys = Object.keys(value);
145
+ const result: { [key: string]: unknown } = {};
146
+
147
+ // Build a map of member ID to member for efficient lookup
148
+ const memberByName: { [name: string]: ValueModel } = {};
149
+ for (const member of model.members) {
150
+ if (member.name !== undefined) {
151
+ memberByName[camelize(member.name)] = member;
152
+ }
153
+ }
154
+
155
+ for (const key of valueKeys) {
156
+ if (memberByName[key]) {
157
+ const member = memberByName[key];
158
+ result[key] = convertCommandDataToMatter(value[key], member, clusterModel);
159
+ } else {
160
+ // Keep unknown keys as-is (fallback for unknown attributes)
161
+ result[key] = value[key];
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+
167
+ return convertWebSocketGenericToMatter(value, model, clusterModel);
168
+ }
169
+
170
+ /**
171
+ * Uses the matter.js Model to convert the response data for read, subscribe and invoke into a tag-based response
172
+ * including conversion of data types.
173
+ */
174
+ export function convertMatterToWebSocketTagBased(
175
+ value: unknown,
176
+ model: ValueModel | undefined,
177
+ clusterModel: ClusterModel | undefined,
178
+ ): unknown {
179
+ if (value === null) {
180
+ return null;
181
+ }
182
+ if (model === undefined) {
183
+ // Do some simple conversions when we have unknown attributes
184
+ if (Bytes.isBytes(value)) {
185
+ return `${Bytes.toBase64(value)}`;
186
+ }
187
+ if (isObject(value) || !["string", "number", "bigint", "boolean", "undefined"].includes(typeof value)) {
188
+ return null; // We cannot convert this
189
+ }
190
+
191
+ return value;
192
+ }
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);
203
+ }
204
+ }
205
+ return result;
206
+ }
207
+ if (isObject(value) && model.metabase?.metatype === "bitmap") {
208
+ if (clusterModel !== undefined) {
209
+ let numberValue = 0;
210
+
211
+ for (const member of clusterModel.scope.membersOf(model)) {
212
+ const memberValue =
213
+ member.name !== undefined && value[camelize(member.name)]
214
+ ? value[camelize(member.name)]
215
+ : member.title !== undefined && value[camelize(member.title)]
216
+ ? value[camelize(member.title)]
217
+ : undefined;
218
+
219
+ if (!memberValue) {
220
+ continue;
221
+ }
222
+ if (typeof memberValue !== "boolean" && typeof memberValue !== "number") {
223
+ throw new Error("Invalid bitmap value", memberValue);
224
+ }
225
+
226
+ const constraintValue = FieldValue.numericValue(member.constraint.value);
227
+ if (constraintValue !== undefined) {
228
+ numberValue |= 1 << constraintValue;
229
+ } else {
230
+ const minBit = FieldValue.numericValue(member.constraint.min) ?? 0;
231
+ numberValue |= typeof memberValue === "boolean" ? 1 : memberValue << minBit;
232
+ }
233
+ }
234
+
235
+ return numberValue;
236
+ }
237
+ }
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
+ }
255
+
256
+ /**
257
+ * Serialize to JSON with BigInt support.
258
+ * - BigInt values within safe integer range are converted to numbers
259
+ * - Large BigInt values are output as raw decimal numbers (not quoted strings)
260
+ */
261
+ export function toBigIntAwareJson(object: object, spaces?: number): string {
262
+ const replacements = new Array<{ from: string; to: string }>();
263
+ let result = JSON.stringify(
264
+ object,
265
+ (_key, value) => {
266
+ if (typeof value === "bigint") {
267
+ if (value > Number.MAX_SAFE_INTEGER) {
268
+ // Store replacement: quoted hex string -> raw decimal number
269
+ replacements.push({ from: `"0x${value.toString(16)}"`, to: value.toString() });
270
+ return `0x${value.toString(16)}`;
271
+ } else {
272
+ return Number(value);
273
+ }
274
+ }
275
+ return value;
276
+ },
277
+ spaces,
278
+ );
279
+ // Large numbers need to be raw (not quoted) in the output, so replace hex placeholders with decimal
280
+ // This handles both object values and array elements
281
+ if (replacements.length > 0) {
282
+ replacements.forEach(({ from, to }) => {
283
+ result = result.replaceAll(from, to);
284
+ });
285
+ }
286
+
287
+ return result;
288
+ }
289
+
290
+ /** Marker prefix for large numbers that need BigInt conversion */
291
+ const BIGINT_MARKER = "__BIGINT__";
292
+
293
+ /**
294
+ * Parse JSON with BigInt support for large numbers that exceed JavaScript precision.
295
+ * Numbers with 15+ digits that exceed MAX_SAFE_INTEGER are converted to BigInt.
296
+ */
297
+ export function parseBigIntAwareJson(json: string): unknown {
298
+ // Pre-process: Replace large numbers (15+ digits) with marked string placeholders
299
+ // This must happen before JSON.parse to preserve precision
300
+ // Match numbers after colon (object values) or after [ or , (array elements)
301
+ const processed = json.replace(/([:,[])\s*(\d{15,})(?=[,}\]\s])/g, (match, prefix, number) => {
302
+ const num = BigInt(number);
303
+ if (num > Number.MAX_SAFE_INTEGER) {
304
+ return `${prefix}"${BIGINT_MARKER}${number}"`;
305
+ }
306
+ return match;
307
+ });
308
+
309
+ // Parse with reviver to convert marked strings back to BigInt
310
+ return JSON.parse(processed, (_key, value) => {
311
+ if (typeof value === "string" && value.startsWith(BIGINT_MARKER)) {
312
+ return BigInt(value.slice(BIGINT_MARKER.length));
313
+ }
314
+ return value;
315
+ });
316
+ }
317
+
318
+ /** Chip JSON-like data strings can contain long numbers that are not supported by JSON.parse */
319
+ function parseChipJSON(json: string) {
320
+ json = json.replace(/: (\d{15,})[,}]/g, (match, number) => {
321
+ const num = BigInt(number);
322
+ if (num > Number.MAX_SAFE_INTEGER) {
323
+ return match.replace(number, `"0x${num.toString(16)}"`);
324
+ }
325
+ return match;
326
+ });
327
+
328
+ return JSON.parse(json);
329
+ }
330
+
331
+ /** Use the matter.js model to convert the incoming data for write and invoke commands into the expected format. */
332
+ export function convertWebsocketDataToMatter(value: any, model: ValueModel): any {
333
+ if (value === undefined) {
334
+ return undefined;
335
+ }
336
+ if (value === "null" || value === null) {
337
+ return null;
338
+ }
339
+
340
+ if (model.type === "list") {
341
+ if (typeof value === "string") {
342
+ value = parseChipJSON(value);
343
+ }
344
+ if (Array.isArray(value)) {
345
+ return value.map(v => convertWebsocketDataToMatter(v, model.members[0]));
346
+ }
347
+ }
348
+
349
+ if (model.metabase?.name === "struct") {
350
+ if (typeof value === "string") {
351
+ value = parseChipJSON(value);
352
+ }
353
+ if (typeof value === "object") {
354
+ const members = model.members.reduce(
355
+ (acc, member) => {
356
+ if (member.name !== undefined) {
357
+ acc[member.name.toLowerCase()] = member;
358
+ }
359
+ return acc;
360
+ },
361
+ {} as { [key: string]: ValueModel },
362
+ );
363
+ const valueKeys = Object.keys(value);
364
+ const result: { [key: string]: unknown } = {};
365
+ valueKeys.forEach(key => {
366
+ const member = members[camelize(key).toLowerCase()];
367
+ if (member !== undefined) {
368
+ result[camelize(member.name)] = convertWebsocketDataToMatter(value[key], member);
369
+ }
370
+ });
371
+ return result;
372
+ }
373
+ }
374
+
375
+ if (
376
+ (typeof value === "number" || typeof value === "bigint") &&
377
+ (model.metabase?.metatype === "integer" || model.metabase?.metatype === "enum")
378
+ ) {
379
+ // Convert Epoch timestamps to Unix timestamps we use internally
380
+ if (model.type === "epoch-s" && typeof value === "number") {
381
+ value += MATTER_EPOCH_OFFSET_S;
382
+ } else if (model.type === "epoch-us") {
383
+ value = BigInt(value) + MATTER_EPOCH_OFFSET_US;
384
+ }
385
+ return value;
386
+ }
387
+
388
+ if (typeof value === "string") {
389
+ if (model.metabase?.metatype === "bytes" && value.startsWith("hex:")) {
390
+ return Bytes.fromHex(value.slice(4));
391
+ }
392
+
393
+ if (model.metabase?.metatype === "bitmap") {
394
+ const numberValue = parseInt(value);
395
+ if (isNaN(numberValue)) {
396
+ throw new Error("Invalid bitmap value");
397
+ }
398
+ const bitmapValue: { [key: string]: boolean } = {};
399
+ model.members.forEach(member => {
400
+ if (
401
+ member.constraint !== undefined &&
402
+ member.name !== undefined &&
403
+ numberValue & (1 << parseInt(member.constraint as unknown as string))
404
+ ) {
405
+ bitmapValue[camelize(member.name)] = true;
406
+ }
407
+ });
408
+ return bitmapValue;
409
+ }
410
+
411
+ if (
412
+ ((model.metabase?.metatype === "integer" || model.metabase?.metatype === "enum") &&
413
+ value.startsWith("0x") &&
414
+ value.match(/^0x[\da-fA-F]+$/)) ||
415
+ value.match(/^-?[1-9]\d*$/) ||
416
+ value === "0"
417
+ ) {
418
+ let numberValue = parseNumber(value);
419
+ if (model.type === "epoch-s" && typeof numberValue === "number") {
420
+ numberValue += MATTER_EPOCH_OFFSET_S;
421
+ } else if (model.type === "epoch-us") {
422
+ numberValue = BigInt(value) + MATTER_EPOCH_OFFSET_US;
423
+ }
424
+ return numberValue;
425
+ }
426
+
427
+ if (model.metabase?.metatype === "boolean") {
428
+ return value === "true" || value === "1" || value === "True";
429
+ }
430
+
431
+ if (model.metabase?.metatype === "string") {
432
+ return value;
433
+ }
434
+ }
435
+
436
+ logger.warn("UNHANDLED value ...", value, model.type, model.metatype, model.metabase?.metatype);
437
+
438
+ return value;
439
+ }
440
+
441
+ export function getDateAsString(date: Date) {
442
+ const year = date.getFullYear();
443
+ const month = String(date.getMonth() + 1).padStart(2, "0");
444
+ const day = String(date.getDate()).padStart(2, "0");
445
+ const hours = String(date.getHours()).padStart(2, "0");
446
+ const minutes = String(date.getMinutes()).padStart(2, "0");
447
+ const seconds = String(date.getSeconds()).padStart(2, "0");
448
+ const milliseconds = String(date.getMilliseconds()).padStart(3, "0");
449
+ const microseconds = "000"; // JavaScript Date object does not support microseconds
450
+
451
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}${microseconds}`;
452
+ }
453
+
454
+ export function buildAttributePath(endpointId: number, clusterId: number, attributeId: number): string {
455
+ return `${endpointId}/${clusterId}/${attributeId}`;
456
+ }
457
+
458
+ /**
459
+ * Parse an attribute path string into its components.
460
+ * Supports wildcards (*) for endpoint, cluster, and attribute IDs.
461
+ * Non-numeric values are treated as wildcards and returned as undefined.
462
+ *
463
+ * @param path - Attribute path string in format "endpoint/cluster/attribute"
464
+ * @returns Object with endpointId, clusterId, attributeId - each undefined if wildcard
465
+ */
466
+ export function splitAttributePath(path: string): {
467
+ endpointId: EndpointNumber | undefined;
468
+ clusterId: ClusterId | undefined;
469
+ attributeId: AttributeId | undefined;
470
+ } {
471
+ const [endpointStr, clusterStr, attributeStr] = path.split("/");
472
+
473
+ // Non-numeric values (like "*") are treated as wildcards (undefined)
474
+ const endpointNum = /^\d+$/.test(endpointStr) ? parseInt(endpointStr, 10) : undefined;
475
+ const clusterNum = /^\d+$/.test(clusterStr) ? parseInt(clusterStr, 10) : undefined;
476
+ const attributeNum = /^\d+$/.test(attributeStr) ? parseInt(attributeStr, 10) : undefined;
477
+
478
+ return {
479
+ endpointId: endpointNum !== undefined ? EndpointNumber(endpointNum) : undefined,
480
+ clusterId: clusterNum !== undefined ? ClusterId(clusterNum) : undefined,
481
+ attributeId: attributeNum !== undefined ? AttributeId(attributeNum) : undefined,
482
+ };
483
+ }