@ntcore-ts/client 1.0.0-beta.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.
Files changed (56) hide show
  1. package/README.md +51 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.esm.js +4845 -0
  5. package/dist/index.esm.js.map +1 -0
  6. package/dist/lib/client.d.ts +262 -0
  7. package/dist/lib/client.d.ts.map +1 -0
  8. package/dist/lib/index.d.ts +8 -0
  9. package/dist/lib/index.d.ts.map +1 -0
  10. package/dist/lib/pubsub/base-topic.d.ts +104 -0
  11. package/dist/lib/pubsub/base-topic.d.ts.map +1 -0
  12. package/dist/lib/pubsub/index.d.ts +6 -0
  13. package/dist/lib/pubsub/index.d.ts.map +1 -0
  14. package/dist/lib/pubsub/json-topic.d.ts +50 -0
  15. package/dist/lib/pubsub/json-topic.d.ts.map +1 -0
  16. package/dist/lib/pubsub/prefix-topic.d.ts +45 -0
  17. package/dist/lib/pubsub/prefix-topic.d.ts.map +1 -0
  18. package/dist/lib/pubsub/protobuf-schema-manager.d.ts +96 -0
  19. package/dist/lib/pubsub/protobuf-schema-manager.d.ts.map +1 -0
  20. package/dist/lib/pubsub/protobuf-topic.d.ts +126 -0
  21. package/dist/lib/pubsub/protobuf-topic.d.ts.map +1 -0
  22. package/dist/lib/pubsub/pubsub.d.ts +134 -0
  23. package/dist/lib/pubsub/pubsub.d.ts.map +1 -0
  24. package/dist/lib/pubsub/struct-topic.d.ts +40 -0
  25. package/dist/lib/pubsub/struct-topic.d.ts.map +1 -0
  26. package/dist/lib/pubsub/topic.d.ts +134 -0
  27. package/dist/lib/pubsub/topic.d.ts.map +1 -0
  28. package/dist/lib/socket/messenger.d.ts +104 -0
  29. package/dist/lib/socket/messenger.d.ts.map +1 -0
  30. package/dist/lib/socket/socket.d.ts +193 -0
  31. package/dist/lib/socket/socket.d.ts.map +1 -0
  32. package/dist/lib/struct/built-in-schemas.d.ts +21 -0
  33. package/dist/lib/struct/built-in-schemas.d.ts.map +1 -0
  34. package/dist/lib/struct/geometry.d.ts +129 -0
  35. package/dist/lib/struct/geometry.d.ts.map +1 -0
  36. package/dist/lib/struct/index.d.ts +5 -0
  37. package/dist/lib/struct/index.d.ts.map +1 -0
  38. package/dist/lib/struct/struct-codec.d.ts +14 -0
  39. package/dist/lib/struct/struct-codec.d.ts.map +1 -0
  40. package/dist/lib/struct/struct-descriptor.d.ts +60 -0
  41. package/dist/lib/struct/struct-descriptor.d.ts.map +1 -0
  42. package/dist/lib/struct/struct-parser.d.ts +28 -0
  43. package/dist/lib/struct/struct-parser.d.ts.map +1 -0
  44. package/dist/lib/struct/struct-schema-manager.d.ts +41 -0
  45. package/dist/lib/struct/struct-schema-manager.d.ts.map +1 -0
  46. package/dist/lib/types/index.d.ts +2 -0
  47. package/dist/lib/types/index.d.ts.map +1 -0
  48. package/dist/lib/types/schemas.d.ts +269 -0
  49. package/dist/lib/types/schemas.d.ts.map +1 -0
  50. package/dist/lib/types/types.d.ts +66 -0
  51. package/dist/lib/types/types.d.ts.map +1 -0
  52. package/dist/lib/util/logger.d.ts +60 -0
  53. package/dist/lib/util/logger.d.ts.map +1 -0
  54. package/dist/lib/util/util.d.ts +68 -0
  55. package/dist/lib/util/util.d.ts.map +1 -0
  56. package/package.json +64 -0
@@ -0,0 +1,4845 @@
1
+ import { Logger } from 'tslog';
2
+ import { z } from 'zod';
3
+ import { decodeMulti, encode } from '@msgpack/msgpack';
4
+ import WebSocket from 'isomorphic-ws';
5
+ import * as protobufNs from 'protobufjs';
6
+ import 'protobufjs/ext/descriptor/index.js';
7
+ import descriptorJson from 'protobufjs/google/protobuf/descriptor.json' assert { type: 'json' };
8
+
9
+ /**
10
+ * Log levels available for configuration.
11
+ * These map to tslog's log levels:
12
+ * - TRACE = 1 (tslog's trace)
13
+ * - DEBUG = 2 (tslog's debug)
14
+ * - INFO = 3 (tslog's info)
15
+ * - WARN = 4 (tslog's warn)
16
+ * - ERROR = 5 (tslog's error)
17
+ * - FATAL = 6 (tslog's fatal)
18
+ * - SILENT = 99 (disable all logging, not a tslog level)
19
+ */
20
+ var LogLevel;
21
+ (function (LogLevel) {
22
+ /** Very detailed debugging information (maps to tslog's trace level 1) */
23
+ LogLevel[LogLevel["TRACE"] = 1] = "TRACE";
24
+ /** Detailed debugging information (maps to tslog's debug level 2) */
25
+ LogLevel[LogLevel["DEBUG"] = 2] = "DEBUG";
26
+ /** General informational messages (maps to tslog's info level 3) */
27
+ LogLevel[LogLevel["INFO"] = 3] = "INFO";
28
+ /** Warning messages (maps to tslog's warn level 4) */
29
+ LogLevel[LogLevel["WARN"] = 4] = "WARN";
30
+ /** Error messages (maps to tslog's error level 5) */
31
+ LogLevel[LogLevel["ERROR"] = 5] = "ERROR";
32
+ /** Fatal error messages (maps to tslog's fatal level 6) */
33
+ LogLevel[LogLevel["FATAL"] = 6] = "FATAL";
34
+ /** Disable all logging (uses 99, not a tslog level) */
35
+ LogLevel[LogLevel["SILENT"] = 99] = "SILENT";
36
+ })(LogLevel || (LogLevel = {}));
37
+ /**
38
+ * Map of module names to their logger instances.
39
+ */
40
+ const moduleLoggers = new Map();
41
+ /**
42
+ * Creates a base logger with the specified min level.
43
+ * @param minLevel - The minimum log level to display.
44
+ * @returns A base logger instance.
45
+ */
46
+ function createBaseLogger(minLevel) {
47
+ return new Logger({
48
+ minLevel,
49
+ type: 'pretty',
50
+ // v5: hideLogPositionForProduction → stack.capture; prettyLogTemplate → pretty.template
51
+ stack: { capture: 'off' },
52
+ pretty: {
53
+ template: '{{yyyy}}.{{mm}}.{{dd}} {{hh}}:{{MM}}:{{ss}}:{{ms}}\t[{{logLevelName}}]\t{{name}}\t',
54
+ },
55
+ });
56
+ }
57
+ // Use a mutable variable so we can recreate the base logger when settings change
58
+ let baseLogger = createBaseLogger(LogLevel.INFO);
59
+ /**
60
+ * Gets or creates a logger instance for a specific module.
61
+ * @param module - The module name.
62
+ * @returns A logger instance configured for the module.
63
+ */
64
+ function getLogger(module = 'default') {
65
+ let logger = moduleLoggers.get(module);
66
+ if (!logger) {
67
+ logger = baseLogger.getSubLogger({
68
+ name: module.toUpperCase(),
69
+ });
70
+ moduleLoggers.set(module, logger);
71
+ }
72
+ return logger;
73
+ }
74
+ /**
75
+ * Sets the log level for all loggers.
76
+ * @param level - The log level to set.
77
+ */
78
+ function setLogLevel(level) {
79
+ // LogLevel enum values already match tslog's numeric levels (except SILENT)
80
+ const numericLevel = level === LogLevel.SILENT ? 99 : level;
81
+ // Recreate base logger with new level
82
+ baseLogger = createBaseLogger(numericLevel);
83
+ // Recreate all module loggers to inherit new settings
84
+ moduleLoggers.forEach((_logger, module) => {
85
+ const newLogger = baseLogger.getSubLogger({
86
+ name: module.toUpperCase(),
87
+ });
88
+ moduleLoggers.set(module, newLogger);
89
+ });
90
+ }
91
+ /**
92
+ * Sets the log level for a specific module.
93
+ * @param module - The module name.
94
+ * @param level - The log level to set.
95
+ */
96
+ function setModuleLogLevel(module, level) {
97
+ // LogLevel enum values already match tslog's numeric levels (except SILENT)
98
+ const numericLevel = level === LogLevel.SILENT ? 99 : level;
99
+ // Create a new sub-logger with the specific level
100
+ const newLogger = baseLogger.getSubLogger({
101
+ name: module.toUpperCase(),
102
+ minLevel: numericLevel,
103
+ });
104
+ moduleLoggers.set(module, newLogger);
105
+ }
106
+ /**
107
+ * Gets the current log level for a specific module.
108
+ * @param module - The module name.
109
+ * @returns The current log level.
110
+ */
111
+ function getModuleLogLevel(module = 'default') {
112
+ const logger = getLogger(module);
113
+ const minLevel = logger.settings.minLevel;
114
+ // Map tslog's numeric levels to our LogLevel enum
115
+ // tslog: 0=silly, 1=trace, 2=debug, 3=info, 4=warn, 5=error, 6=fatal
116
+ // Our enum: TRACE=1, DEBUG=2, INFO=3, WARN=4, ERROR=5, FATAL=6, SILENT=99
117
+ if (minLevel >= 99)
118
+ return LogLevel.SILENT;
119
+ if (minLevel >= 6)
120
+ return LogLevel.FATAL;
121
+ if (minLevel >= 5)
122
+ return LogLevel.ERROR;
123
+ if (minLevel >= 4)
124
+ return LogLevel.WARN;
125
+ if (minLevel >= 3)
126
+ return LogLevel.INFO;
127
+ if (minLevel >= 2)
128
+ return LogLevel.DEBUG;
129
+ if (minLevel >= 1)
130
+ return LogLevel.TRACE;
131
+ // minLevel 0 is tslog's "silly" level, map to TRACE
132
+ return LogLevel.TRACE;
133
+ }
134
+ /**
135
+ * Creates a proxy logger that always returns the current logger instance from the map.
136
+ * This ensures exported loggers update when setLogLevel() or setModuleLogLevel() is called.
137
+ * @param module - The module name.
138
+ * @returns A logger instance.
139
+ */
140
+ function createLoggerProxy(module) {
141
+ return new Proxy({}, {
142
+ get(_target, prop) {
143
+ const logger = getLogger(module);
144
+ const value = logger[prop];
145
+ // Bind methods to the actual logger instance to preserve 'this' context
146
+ if (typeof value === 'function') {
147
+ return value.bind(logger);
148
+ }
149
+ return value;
150
+ },
151
+ });
152
+ }
153
+ // Export convenience loggers for each module
154
+ // These proxies ensure they always reference the current logger instance
155
+ const socketLogger = createLoggerProxy('socket');
156
+ const messengerLogger = createLoggerProxy('messenger');
157
+ const pubsubLogger = createLoggerProxy('pubsub');
158
+ const defaultLogger = createLoggerProxy('default');
159
+
160
+ class NetworkTablesBaseTopic {
161
+ isRegular() {
162
+ return this.type === 'regular';
163
+ }
164
+ isPrefix() {
165
+ return this.type === 'prefix';
166
+ }
167
+ /**
168
+ * Gets the ID of the topic.
169
+ * @returns The ID of the topic.
170
+ */
171
+ get id() {
172
+ return this._id;
173
+ }
174
+ /**
175
+ * Gets the name of the topic.
176
+ * @returns The name of the topic.
177
+ */
178
+ get name() {
179
+ return this._name;
180
+ }
181
+ /**
182
+ * Gets the server time of the last value change.
183
+ * @returns The server time of the last value change.
184
+ */
185
+ get lastChangedTime() {
186
+ return this._lastChangedTime;
187
+ }
188
+ /**
189
+ * Whether the topic has been announced.
190
+ * @returns Whether the topic has been announced.
191
+ */
192
+ get announced() {
193
+ return this._announceParams != null;
194
+ }
195
+ /**
196
+ * Gets the subscribers to the topic.
197
+ * @returns The subscribers to the topic.
198
+ */
199
+ get subscribers() {
200
+ return this._subscribers;
201
+ }
202
+ /**
203
+ * Creates a new topic. This should only be done after the
204
+ * base NTCore client has been initialized.
205
+ * @param client - The client that owns the topic.
206
+ * @param name - The name of the topic.
207
+ */
208
+ constructor(client, name) {
209
+ this.client = client;
210
+ this._name = name;
211
+ this._announceParams = null;
212
+ this._subscribers = new Map();
213
+ }
214
+ // ------------- //
215
+ // ANNOUNCEMENTS //
216
+ // ------------- //
217
+ /**
218
+ * Marks the topic as announced. This should only be called by the PubSubClient.
219
+ * @param params - The parameters of the announcement.
220
+ */
221
+ announce(params) {
222
+ pubsubLogger.debug('Topic announced in base topic', { topicName: this.name, topicId: params.id });
223
+ this._announceParams = params;
224
+ this._id = params.id;
225
+ }
226
+ /** Marks the topic as unannounced. This should only be called by the PubSubClient. */
227
+ unannounce() {
228
+ pubsubLogger.debug('Topic unannounced in base topic', { topicName: this.name, topicId: this._id });
229
+ this._announceParams = null;
230
+ this._id = undefined;
231
+ }
232
+ /**
233
+ * Removes a subscriber
234
+ * @param subuid - The UID of the subscriber.
235
+ * @param removeCallback - Whether to remove the callback. Leave this as true unless you know what you're doing.
236
+ */
237
+ unsubscribe(subuid, removeCallback = true) {
238
+ pubsubLogger.debug('Unsubscribed from topic', {
239
+ topicName: this.name,
240
+ subuid,
241
+ removeCallback,
242
+ remainingSubscribers: this.subscribers.size - (this.subscribers.has(subuid) ? 1 : 0),
243
+ });
244
+ this.client.messenger.unsubscribe(subuid);
245
+ if (removeCallback)
246
+ this.subscribers.delete(subuid);
247
+ }
248
+ /**
249
+ * Removes all local subscribers.
250
+ */
251
+ unsubscribeAll() {
252
+ const subscriberCount = this.subscribers.size;
253
+ pubsubLogger.debug('Unsubscribing all', { topicName: this.name, subscriberCount });
254
+ this.subscribers.forEach((_, subuid) => this.unsubscribe(subuid));
255
+ }
256
+ /**
257
+ * Strips client-only subscribe options (e.g. `immediateNotify`) before sending to the NT server.
258
+ * @param options - The subscriber options, possibly including client-only fields.
259
+ * @returns Protocol subscribe options.
260
+ */
261
+ toProtocolSubscribeOptions(options = {}) {
262
+ const rest = { ...options };
263
+ delete rest.immediateNotify;
264
+ return rest;
265
+ }
266
+ // ---------- //
267
+ // PUBLISHING //
268
+ // ---------- //
269
+ /**
270
+ * Sets the properties of the topic.
271
+ * @param properties - Topic properties to update (e.g. `{ persistent: true, retained: true }`).
272
+ * @returns The server's response.
273
+ */
274
+ async setProperties(properties = {}) {
275
+ const setPropertiesParams = {
276
+ name: this.name,
277
+ update: properties,
278
+ };
279
+ // Send the set properties request
280
+ return await this.client.messenger.setProperties(setPropertiesParams);
281
+ }
282
+ }
283
+
284
+ class NetworkTablesPrefixTopic extends NetworkTablesBaseTopic {
285
+ /**
286
+ * Creates a new topic. This should only be done after the
287
+ * base NTCore client has been initialized.
288
+ * @param client - The client that owns the topic.
289
+ * @param name - The name of the topic.
290
+ */
291
+ constructor(client, name) {
292
+ super(client, name);
293
+ this.type = 'prefix';
294
+ this._lastSubtopicValues = new Map();
295
+ const existingTopic = this.client.getPrefixTopicFromName(name);
296
+ if (existingTopic) {
297
+ pubsubLogger.debug('Existing prefix topic reused', { prefix: name });
298
+ return existingTopic;
299
+ }
300
+ pubsubLogger.debug('Prefix topic created', { prefix: name });
301
+ this.client.registerTopic(this);
302
+ }
303
+ /** */
304
+ /* SUBSCRIBING */
305
+ /** */
306
+ /**
307
+ * Creates a new subscriber.
308
+ * @param callback - The callback to call when the topic value changes.
309
+ * @param options - The options for the subscriber. `immediateNotify` is client-side only
310
+ * and replays cached matching subtopic values (not sent to the NT server).
311
+ * @returns The UID of the subscriber.
312
+ */
313
+ subscribe(callback, options = {}) {
314
+ const subuid = this.subscribeWithId(callback, options, undefined, true);
315
+ if (options.immediateNotify) {
316
+ this._lastSubtopicValues.forEach(({ params, value }) => {
317
+ if (params.name === this.name)
318
+ return;
319
+ try {
320
+ callback(value, params);
321
+ }
322
+ catch (error) {
323
+ pubsubLogger.warn('Prefix immediateNotify callback threw', {
324
+ prefix: this.name,
325
+ subtopicName: params.name,
326
+ error,
327
+ });
328
+ }
329
+ });
330
+ }
331
+ return subuid;
332
+ }
333
+ /**
334
+ * Creates a new subscriber, optionally reusing a subscription UID.
335
+ * @internal Used by resubscribeAll. Does not honor `immediateNotify`.
336
+ * @param callback - The callback to call when the topic value changes.
337
+ * @param options - The options for the subscriber.
338
+ * @param id - The UID of the subscriber.
339
+ * @param save - Whether to save the subscriber.
340
+ * @returns The UID of the subscriber.
341
+ */
342
+ subscribeWithId(callback, options = {}, id, save = true) {
343
+ const subuid = id || this.client.messenger.getNextSubUID();
344
+ const serverOptions = this.toProtocolSubscribeOptions(options);
345
+ const subscribeParams = {
346
+ topics: [this.name],
347
+ subuid,
348
+ options: {
349
+ ...serverOptions,
350
+ prefix: true,
351
+ },
352
+ };
353
+ this.client.messenger.subscribe(subscribeParams);
354
+ if (save)
355
+ this.subscribers.set(subuid, { callback, options: serverOptions });
356
+ pubsubLogger.debug('Subscriber added to prefix topic', {
357
+ prefix: this.name,
358
+ subuid,
359
+ options: serverOptions,
360
+ totalSubscribers: this.subscribers.size,
361
+ });
362
+ return subuid;
363
+ }
364
+ resubscribeAll(client) {
365
+ const subscriberCount = this.subscribers.size;
366
+ pubsubLogger.debug('Resubscribe all prefix topic', { prefix: this.name, subscriberCount });
367
+ this.client = client;
368
+ this.subscribers.forEach((info, subuid) => {
369
+ this.subscribeWithId(info.callback, info.options, subuid, false);
370
+ });
371
+ }
372
+ /**
373
+ * Updates the value of a subtopic. Notifies all subscribers of the change.
374
+ * @param params - The params of the subtopic
375
+ * @param value - The value of the subtopic
376
+ * @param serverTime - The time the value was updated
377
+ */
378
+ updateValue(params, value, serverTime) {
379
+ pubsubLogger.debug('Value update applied to prefix topic', {
380
+ prefix: this.name,
381
+ subtopicName: params.name,
382
+ subtopicId: params.id,
383
+ serverTime,
384
+ });
385
+ if (value == null) {
386
+ this._lastSubtopicValues.delete(params.name);
387
+ }
388
+ else {
389
+ this._lastSubtopicValues.set(params.name, { params, value });
390
+ }
391
+ this._lastChangedTime = serverTime;
392
+ this.notifySubscribers(params, value);
393
+ }
394
+ notifySubscribers(params, value) {
395
+ const subscriberCount = this.subscribers.size;
396
+ pubsubLogger.debug('Subscribers notified for prefix topic', {
397
+ prefix: this.name,
398
+ subtopicName: params.name,
399
+ count: subscriberCount,
400
+ });
401
+ this.subscribers.forEach((info, subuid) => {
402
+ pubsubLogger.trace('Callback invoked for prefix topic', {
403
+ prefix: this.name,
404
+ subuid,
405
+ subtopicName: params.name,
406
+ value,
407
+ params,
408
+ });
409
+ try {
410
+ info.callback(value, params);
411
+ }
412
+ catch (error) {
413
+ pubsubLogger.warn('Prefix subscriber callback threw', {
414
+ prefix: this.name,
415
+ subuid,
416
+ subtopicName: params.name,
417
+ error,
418
+ });
419
+ }
420
+ });
421
+ }
422
+ }
423
+
424
+ class NetworkTablesTopic extends NetworkTablesBaseTopic {
425
+ /**
426
+ * Gets the type info for the topic.
427
+ * @returns The type info for the topic.
428
+ */
429
+ get typeInfo() {
430
+ return this._typeInfo;
431
+ }
432
+ /**
433
+ * Gets whether the client is the publisher of the topic.
434
+ * @returns Whether the client is the publisher of the topic.
435
+ */
436
+ get publisher() {
437
+ return this._publisher;
438
+ }
439
+ /**
440
+ * Gets the UID of the publisher.
441
+ * @returns The UID of the publisher, or undefined if the client is not the publisher.
442
+ */
443
+ get pubuid() {
444
+ return this._pubuid;
445
+ }
446
+ /**
447
+ * Creates a new topic. This should only be done after the
448
+ * base NTCore client has been initialized.
449
+ * @param client - The client that owns the topic.
450
+ * @param name - The name of the topic.
451
+ * @param typeInfo - The type info for the topic.
452
+ * @param defaultValue - The default value for the topic.
453
+ */
454
+ constructor(client, name, typeInfo, defaultValue) {
455
+ super(client, name);
456
+ this.type = 'regular';
457
+ this._typeInfo = typeInfo;
458
+ this._wireValue = (defaultValue ?? null);
459
+ this._publisher = false;
460
+ const existingTopic = this.client.getTopicFromName(name);
461
+ if (existingTopic) {
462
+ if (existingTopic.typeInfo[0] === typeInfo[0] && existingTopic.typeInfo[1] === typeInfo[1]) {
463
+ pubsubLogger.debug('Existing topic reused', { topicName: name, type: typeInfo[1] });
464
+ // Valid cast: we verified typeInfo matches; map stores base topic type.
465
+ return existingTopic;
466
+ }
467
+ else {
468
+ pubsubLogger.debug('Type mismatch detected', {
469
+ topicName: name,
470
+ existingType: existingTopic.typeInfo[1],
471
+ requestedType: typeInfo[1],
472
+ });
473
+ throw new Error(`Topic ${name} already exists, but with a different type.`);
474
+ }
475
+ }
476
+ pubsubLogger.debug('Topic created', { topicName: name, type: typeInfo[1] });
477
+ this.client.registerTopic(this);
478
+ }
479
+ /**
480
+ * Sets the value of the topic.
481
+ * The client must be the publisher of the topic to set the value.
482
+ * @param value - The value to set.
483
+ */
484
+ setValue(value) {
485
+ if (!this.publisher) {
486
+ pubsubLogger.debug('Publisher check failed before setValue', { topicName: this.name });
487
+ throw new Error('Cannot set value on topic without being the publisher');
488
+ }
489
+ const oldValue = this.getValue();
490
+ pubsubLogger.debug('Value set', { topicName: this.name, oldValue, newValue: value, type: this._typeInfo[1] });
491
+ this.setWireValue(value);
492
+ this.afterSetWireValue();
493
+ }
494
+ /** Internal: set wire-format value and notify subscribers. Subclasses may call this when encoding decoded values. */
495
+ setWireValue(value) {
496
+ this._wireValue = value;
497
+ this.notifySubscribers();
498
+ }
499
+ /** Called after setWireValue from subclasses that encode before setting. Runs prefix notify and updateServer. */
500
+ afterSetWireValue() {
501
+ if (this._wireValue == null)
502
+ return;
503
+ // Notify prefix subscribers (e.g. "all topics" table) so locally published values appear there.
504
+ this.client.notifyPrefixTopicsForLocalUpdate(this.getAnnounceParamsForNotify(), (this.getValue() ?? this._wireValue));
505
+ // If we're disconnected, just keep our local retained value.
506
+ // We will always resend the latest retained value after reconnect.
507
+ if (!this.client.messenger.socket.isConnected()) {
508
+ pubsubLogger.debug('Skipped server update (awaiting reconnect)', { topicName: this.name });
509
+ return;
510
+ }
511
+ this.client.updateServer(this, this._wireValue);
512
+ }
513
+ /** Params for prefix notify; use real announce params when we have them, else minimal fallback. */
514
+ getAnnounceParamsForNotify() {
515
+ return this._announceParams ?? { name: this.name, id: -1, type: this._typeInfo[1], properties: {} };
516
+ }
517
+ /**
518
+ * Resends the latest retained value after reconnect.
519
+ *
520
+ * This is intended to be called after reconnect, once publish frames have been re-sent.
521
+ * This ensures that if the server/robot restarted, it receives the current state.
522
+ */
523
+ resendLatestValue() {
524
+ if (!this._publisher ||
525
+ this._pubuid == null ||
526
+ !this.client.messenger.socket.isConnected() ||
527
+ this._wireValue == null) {
528
+ return;
529
+ }
530
+ pubsubLogger.debug('Resending latest retained value after reconnect', {
531
+ topicName: this.name,
532
+ pubuid: this._pubuid,
533
+ });
534
+ this.client.updateServer(this, this._wireValue);
535
+ }
536
+ /**
537
+ * Gets the value of the topic.
538
+ * @returns The value of the topic.
539
+ */
540
+ getValue() {
541
+ return this._wireValue;
542
+ }
543
+ /**
544
+ * Updates the value of the topic.
545
+ * This should only be called by the PubSubClient.
546
+ * @param value - The value to update (wire format).
547
+ * @param lastChangedTime - The server time of the last value change.
548
+ */
549
+ updateValue(value, lastChangedTime) {
550
+ const oldValue = this._wireValue;
551
+ pubsubLogger.debug('Value updated', {
552
+ topicName: this.name,
553
+ oldValue,
554
+ newValue: value,
555
+ lastChangedTime,
556
+ type: this._typeInfo[1],
557
+ });
558
+ this.setWireValue(value);
559
+ this._lastChangedTime = lastChangedTime;
560
+ }
561
+ /** */
562
+ /* ANNOUNCEMENTS */
563
+ /** */
564
+ /**
565
+ * Marks the topic as announced. This should only be called by the PubSubClient.
566
+ * @param params - The parameters of the announcement.
567
+ */
568
+ announce(params) {
569
+ super.announce(params);
570
+ const wasPublisher = this._publisher;
571
+ if (params.pubuid === this._pubuid) {
572
+ this._publisher = true;
573
+ pubsubLogger.debug('Publisher status updated', {
574
+ topicName: this.name,
575
+ pubuid: this._pubuid,
576
+ wasPublisher,
577
+ isPublisher: true,
578
+ });
579
+ }
580
+ pubsubLogger.debug('Topic announced', { topicName: this.name, topicId: params.id, pubuid: params.pubuid });
581
+ }
582
+ /** */
583
+ /* SUBSCRIBING */
584
+ /** */
585
+ /**
586
+ * Creates a new subscriber.
587
+ * @param callback - The callback to call when the topic value changes.
588
+ * @param options - The options for the subscriber. `immediateNotify` is client-side only and is
589
+ * not sent to the NT server. `id` and `save` are not part of this public signature.
590
+ * @returns The UID of the subscriber.
591
+ */
592
+ subscribe(callback, options = {}) {
593
+ const subuid = this.subscribeWithId(callback, options, undefined, true);
594
+ if (options.immediateNotify) {
595
+ const params = this._announceParams ??
596
+ {
597
+ name: this.name,
598
+ id: -1,
599
+ type: this._typeInfo[1],
600
+ properties: this._publishProperties ?? {},
601
+ ...(this._pubuid != null ? { pubuid: this._pubuid } : {}),
602
+ };
603
+ callback(this.getValue(), params);
604
+ }
605
+ return subuid;
606
+ }
607
+ /**
608
+ * Creates a new subscriber, optionally reusing a subscription UID.
609
+ * @internal Used by resubscribeAll. Does not honor `immediateNotify`.
610
+ * @param callback - The callback to call when the topic value changes.
611
+ * @param options - The options for the subscriber.
612
+ * @param id - The UID of the subscriber. You must verify that the ID is not already in use.
613
+ * @param save - Whether to save the subscriber.
614
+ * @returns The UID of the subscriber.
615
+ */
616
+ subscribeWithId(callback, options = {}, id, save = true) {
617
+ const subuid = id || this.client.messenger.getNextSubUID();
618
+ const serverOptions = this.toProtocolSubscribeOptions(options);
619
+ const subscribeParams = {
620
+ topics: [this.name],
621
+ subuid,
622
+ options: serverOptions,
623
+ };
624
+ this.client.messenger.subscribe(subscribeParams);
625
+ if (save)
626
+ this.subscribers.set(subuid, { callback, options: serverOptions });
627
+ pubsubLogger.debug('Subscriber added', {
628
+ topicName: this.name,
629
+ subuid,
630
+ options: serverOptions,
631
+ totalSubscribers: this.subscribers.size,
632
+ });
633
+ return subuid;
634
+ }
635
+ resubscribeAll(client) {
636
+ const subscriberCount = this.subscribers.size;
637
+ pubsubLogger.debug('Resubscribe all', { topicName: this.name, subscriberCount });
638
+ this.client = client;
639
+ this.subscribers.forEach((info, subuid) => {
640
+ this.subscribeWithId(info.callback, info.options, subuid, false);
641
+ });
642
+ }
643
+ /**
644
+ * Notifies all subscribers of the current value.
645
+ * Protected so derived classes (e.g. NetworkTablesProtobufTopic) can override
646
+ * and pass decoded/transformed values to callbacks.
647
+ */
648
+ notifySubscribers() {
649
+ const subscriberCount = this.subscribers.size;
650
+ pubsubLogger.debug('Subscribers notified', { topicName: this.name, count: subscriberCount });
651
+ // If the topic has not been announced yet, synthesize params so callbacks always
652
+ // receive a well-formed object (rather than `null`).
653
+ const params = this._announceParams ??
654
+ {
655
+ name: this.name,
656
+ id: -1,
657
+ type: this._typeInfo[1],
658
+ properties: this._publishProperties ?? {},
659
+ ...(this._pubuid != null ? { pubuid: this._pubuid } : {}),
660
+ };
661
+ this.subscribers.forEach((info, subuid) => {
662
+ pubsubLogger.trace('Callback invoked', {
663
+ topicName: this.name,
664
+ subuid,
665
+ value: this.getValue(),
666
+ params,
667
+ });
668
+ info.callback(this.getValue(), params);
669
+ });
670
+ }
671
+ /** */
672
+ /* PUBLISHING */
673
+ /** */
674
+ /**
675
+ * Publishes the topic.
676
+ * @param properties - The properties to publish the topic with.
677
+ * @param id - The UID of the publisher. You must verify that the ID is not already in use.
678
+ * @returns A promise that resolves when the topic is published.
679
+ */
680
+ async publish(properties = {}, id) {
681
+ // Use unified in-flight protection from PubSubClient
682
+ // Key format: "publish:" prefix to avoid conflicts with schema registrations
683
+ const operationKey = `publish:${this.name}`;
684
+ return this.client.getOrCreateInFlightOperation(operationKey, async () => {
685
+ // Check if already publisher inside the in-flight operation to prevent race conditions
686
+ if (this.publisher) {
687
+ pubsubLogger.debug('Publish skipped', { topicName: this.name, reason: 'already publisher' });
688
+ return;
689
+ }
690
+ this._pubuid = id ?? this.client.messenger.getNextPubUID();
691
+ this._publishProperties = properties;
692
+ pubsubLogger.debug('Topic published', { topicName: this.name, pubuid: this._pubuid, properties });
693
+ const publishParams = {
694
+ type: this.typeInfo[1],
695
+ name: this.name,
696
+ pubuid: this._pubuid,
697
+ properties,
698
+ };
699
+ return await this.client.messenger.publish(publishParams);
700
+ });
701
+ }
702
+ /**
703
+ * Unpublishes the topic.
704
+ */
705
+ unpublish() {
706
+ if (this._pubuid == null) {
707
+ pubsubLogger.debug('Publisher check failed before unpublish', {
708
+ topicName: this.name,
709
+ publisher: this.publisher,
710
+ pubuid: this._pubuid,
711
+ });
712
+ throw new Error('Cannot unpublish topic without being the publisher');
713
+ }
714
+ pubsubLogger.debug('Topic unpublished', { topicName: this.name, pubuid: this._pubuid });
715
+ this.client.messenger.unpublish(this._pubuid);
716
+ this._publisher = false;
717
+ this._pubuid = undefined;
718
+ }
719
+ /**
720
+ * Republishes the topic.
721
+ * @param client - The client to republish with.
722
+ * @returns A promise that resolves when the topic is republished.
723
+ */
724
+ async republish(client) {
725
+ this.client = client;
726
+ if (!this.publisher || this._pubuid == null) {
727
+ throw new Error('Cannot republish topic without being the publisher');
728
+ }
729
+ pubsubLogger.debug('Topic republished', { topicName: this.name, pubuid: this._pubuid });
730
+ this._publisher = false;
731
+ return await this.publish(this._publishProperties, this._pubuid);
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Primitive type names and their byte sizes (WPILib struct schema).
737
+ */
738
+ const PRIMITIVE_SIZES = {
739
+ bool: 1,
740
+ char: 1,
741
+ int8: 1,
742
+ uint8: 1,
743
+ int16: 2,
744
+ uint16: 2,
745
+ int32: 4,
746
+ uint32: 4,
747
+ int64: 8,
748
+ uint64: 8,
749
+ float: 4,
750
+ float32: 4,
751
+ double: 8,
752
+ float64: 8,
753
+ };
754
+ /**
755
+ * Returns the size in bytes of a primitive type.
756
+ */
757
+ function getPrimitiveSize(primitive) {
758
+ const size = PRIMITIVE_SIZES[primitive];
759
+ if (size === undefined) {
760
+ throw new Error(`Unknown primitive type: ${primitive}`);
761
+ }
762
+ return size;
763
+ }
764
+ /**
765
+ * Returns whether the given string is a known primitive type name.
766
+ */
767
+ function isPrimitiveType(typeName) {
768
+ return typeName in PRIMITIVE_SIZES;
769
+ }
770
+
771
+ /**
772
+ * Unpacks a little-endian binary buffer into a plain object using the struct descriptor.
773
+ * @throws Error if buffer is too small for the descriptor size.
774
+ */
775
+ function unpack(buffer, descriptor) {
776
+ if (buffer.length < descriptor.size) {
777
+ throw new Error(`Struct unpack: buffer too small (${buffer.length} bytes, need ${descriptor.size} for ${descriptor.typeName})`);
778
+ }
779
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
780
+ const out = {};
781
+ for (const field of descriptor.fields) {
782
+ out[field.name] = unpackField(view, field);
783
+ }
784
+ return out;
785
+ }
786
+ /**
787
+ * Packs a plain object into a little-endian Uint8Array using the struct descriptor.
788
+ * @throws Error if value is missing required fields or has invalid types.
789
+ */
790
+ function pack(value, descriptor) {
791
+ const buffer = new Uint8Array(descriptor.size);
792
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
793
+ for (const field of descriptor.fields) {
794
+ const v = value[field.name];
795
+ if (v === undefined) {
796
+ throw new Error(`Struct pack: missing required field "${field.name}" for ${descriptor.typeName}`);
797
+ }
798
+ packField(view, field, v);
799
+ }
800
+ return buffer;
801
+ }
802
+ function unpackField(view, field) {
803
+ const offset = field.offset;
804
+ if (field.nestedDescriptor) {
805
+ if (field.arraySize === 1) {
806
+ return unpack(new Uint8Array(view.buffer, view.byteOffset + offset, field.size), field.nestedDescriptor);
807
+ }
808
+ const arr = [];
809
+ const elemSize = field.nestedDescriptor.size;
810
+ for (let i = 0; i < field.arraySize; i++) {
811
+ arr.push(unpack(new Uint8Array(view.buffer, view.byteOffset + offset + i * elemSize, elemSize), field.nestedDescriptor));
812
+ }
813
+ return arr;
814
+ }
815
+ const prim = field.primitive;
816
+ if (!prim)
817
+ throw new Error(`Struct field "${field.name}" has neither nestedDescriptor nor primitive`);
818
+ if (field.arraySize > 1) {
819
+ const elemSize = getPrimitiveSize(prim);
820
+ const arr = [];
821
+ for (let i = 0; i < field.arraySize; i++) {
822
+ arr.push(readPrimitive(view, offset + i * elemSize, prim));
823
+ }
824
+ return arr;
825
+ }
826
+ if (field.bitWidth > 0 && field.bitWidth < field.size * 8) {
827
+ const bitfieldValue = readBitfield(view, offset, field.size, field.bitWidth, field.bitShift);
828
+ return prim === 'bool' ? bitfieldValue !== 0 : bitfieldValue;
829
+ }
830
+ return readPrimitive(view, offset, prim);
831
+ }
832
+ function packField(view, field, value) {
833
+ const offset = field.offset;
834
+ if (field.nestedDescriptor) {
835
+ if (field.arraySize === 1) {
836
+ const buf = pack(value, field.nestedDescriptor);
837
+ new Uint8Array(view.buffer, view.byteOffset + offset, field.size).set(buf);
838
+ return;
839
+ }
840
+ const arr = value;
841
+ if (!Array.isArray(arr) || arr.length !== field.arraySize) {
842
+ throw new Error(`Struct pack: field "${field.name}" expects array of size ${field.arraySize}`);
843
+ }
844
+ const elemSize = field.nestedDescriptor.size;
845
+ for (let i = 0; i < field.arraySize; i++) {
846
+ const slice = new Uint8Array(view.buffer, view.byteOffset + offset + i * elemSize, elemSize);
847
+ slice.set(pack(arr[i], field.nestedDescriptor));
848
+ }
849
+ return;
850
+ }
851
+ const prim = field.primitive;
852
+ if (!prim)
853
+ throw new Error(`Struct field "${field.name}" has neither nestedDescriptor nor primitive`);
854
+ if (field.arraySize > 1) {
855
+ const arr = value;
856
+ if (!Array.isArray(arr) || arr.length !== field.arraySize) {
857
+ throw new Error(`Struct pack: field "${field.name}" expects array of size ${field.arraySize}`);
858
+ }
859
+ const elemSize = getPrimitiveSize(prim);
860
+ for (let i = 0; i < field.arraySize; i++) {
861
+ writePrimitive(view, offset + i * elemSize, prim, arr[i]);
862
+ }
863
+ return;
864
+ }
865
+ if (field.bitWidth > 0 && field.bitWidth < field.size * 8) {
866
+ writeBitfield(view, offset, field.size, field.bitWidth, field.bitShift, value);
867
+ return;
868
+ }
869
+ writePrimitive(view, offset, prim, value);
870
+ }
871
+ function readPrimitive(view, offset, prim) {
872
+ switch (prim) {
873
+ case 'bool':
874
+ return view.getUint8(offset) !== 0;
875
+ case 'char':
876
+ return view.getInt8(offset);
877
+ case 'int8':
878
+ return view.getInt8(offset);
879
+ case 'uint8':
880
+ return view.getUint8(offset);
881
+ case 'int16':
882
+ return view.getInt16(offset, true);
883
+ case 'uint16':
884
+ return view.getUint16(offset, true);
885
+ case 'int32':
886
+ return view.getInt32(offset, true);
887
+ case 'uint32':
888
+ return view.getUint32(offset, true);
889
+ case 'int64':
890
+ // Converted to Number (loses precision beyond ±2^53). BigInt is avoided because it
891
+ // can't mix with Number in arithmetic and breaks JSON.stringify / Zod / React state.
892
+ // No built-in WPILib struct types use int64; revisit if full precision is needed.
893
+ return Number(view.getBigInt64(offset, true));
894
+ case 'uint64':
895
+ return Number(view.getBigUint64(offset, true));
896
+ case 'float':
897
+ case 'float32':
898
+ return view.getFloat32(offset, true);
899
+ case 'double':
900
+ case 'float64':
901
+ return view.getFloat64(offset, true);
902
+ default:
903
+ throw new Error(`Unknown primitive: ${prim}`);
904
+ }
905
+ }
906
+ function writePrimitive(view, offset, prim, value) {
907
+ switch (prim) {
908
+ case 'bool':
909
+ view.setUint8(offset, value ? 1 : 0);
910
+ break;
911
+ case 'char':
912
+ case 'int8':
913
+ view.setInt8(offset, value);
914
+ break;
915
+ case 'uint8':
916
+ view.setUint8(offset, value);
917
+ break;
918
+ case 'int16':
919
+ view.setInt16(offset, value, true);
920
+ break;
921
+ case 'uint16':
922
+ view.setUint16(offset, value, true);
923
+ break;
924
+ case 'int32':
925
+ view.setInt32(offset, value, true);
926
+ break;
927
+ case 'uint32':
928
+ view.setUint32(offset, value, true);
929
+ break;
930
+ case 'int64': {
931
+ const v = typeof value === 'bigint'
932
+ ? value
933
+ : typeof value === 'number' && Number.isSafeInteger(value)
934
+ ? BigInt(value)
935
+ : (() => {
936
+ throw new Error(`Struct pack int64: value ${String(value)} is not a safe integer. ` +
937
+ `Pass a BigInt for values outside ±2^53.`);
938
+ })();
939
+ view.setBigInt64(offset, v, true);
940
+ break;
941
+ }
942
+ case 'uint64': {
943
+ const v = typeof value === 'bigint'
944
+ ? value
945
+ : typeof value === 'number' && Number.isSafeInteger(value)
946
+ ? BigInt(value)
947
+ : (() => {
948
+ throw new Error(`Struct pack uint64: value ${String(value)} is not a safe integer. ` +
949
+ `Pass a BigInt for values outside ±2^53.`);
950
+ })();
951
+ view.setBigUint64(offset, v, true);
952
+ break;
953
+ }
954
+ case 'float':
955
+ case 'float32':
956
+ view.setFloat32(offset, value, true);
957
+ break;
958
+ case 'double':
959
+ case 'float64':
960
+ view.setFloat64(offset, value, true);
961
+ break;
962
+ default:
963
+ throw new Error(`Unknown primitive: ${prim}`);
964
+ }
965
+ }
966
+ function readStorageUnit(view, offset, byteSize) {
967
+ switch (byteSize) {
968
+ case 1:
969
+ return view.getUint8(offset);
970
+ case 2:
971
+ return view.getUint16(offset, true);
972
+ case 4:
973
+ return view.getUint32(offset, true);
974
+ default:
975
+ return Number(view.getBigUint64(offset, true));
976
+ }
977
+ }
978
+ function writeStorageUnit(view, offset, byteSize, value) {
979
+ switch (byteSize) {
980
+ case 1:
981
+ view.setUint8(offset, value);
982
+ break;
983
+ case 2:
984
+ view.setUint16(offset, value, true);
985
+ break;
986
+ case 4:
987
+ view.setUint32(offset, value, true);
988
+ break;
989
+ default:
990
+ view.setBigUint64(offset, BigInt(value), true);
991
+ }
992
+ }
993
+ const MAX_BITFIELD_WIDTH = 31;
994
+ /**
995
+ * Reads a bitfield value from a storage unit. Mirrors WPILib's unsigned read path:
996
+ * `(rawVal >>> bitShift) & bitMask`. Signed sign-extension is not implemented since
997
+ * WPILib only allows unsigned/bool bitfields in practice.
998
+ * JS bitwise ops are 32-bit; bitfields wider than 31 bits or 8-byte storage are unsupported.
999
+ *
1000
+ * @see DynamicStruct.java — getFieldImpl()
1001
+ */
1002
+ function readBitfield(view, offset, byteSize, bitWidth, bitShift) {
1003
+ if (byteSize === 8 || bitWidth > MAX_BITFIELD_WIDTH) {
1004
+ throw new Error(`Struct codec: bitfield read unsupported for byteSize=${byteSize} bitWidth=${bitWidth} (max ${MAX_BITFIELD_WIDTH} bits)`);
1005
+ }
1006
+ const raw = readStorageUnit(view, offset, byteSize);
1007
+ // Use unsigned mask so bitWidth=31 works: (1 << 31) would overflow to negative in JS.
1008
+ const mask = (0xffffffff >>> (32 - bitWidth)) >>> 0;
1009
+ return (raw >>> bitShift) & mask;
1010
+ }
1011
+ /**
1012
+ * Writes a bitfield value into a storage unit via read-modify-write. Clears the target
1013
+ * bits with `~(mask << shift)`, then sets with `(value & mask) << shift`.
1014
+ * JS bitwise ops are 32-bit; bitfields wider than 31 bits or 8-byte storage are unsupported.
1015
+ *
1016
+ * @see DynamicStruct.java — setFieldImpl()
1017
+ */
1018
+ function writeBitfield(view, offset, byteSize, bitWidth, bitShift, value) {
1019
+ if (byteSize === 8 || bitWidth > MAX_BITFIELD_WIDTH) {
1020
+ throw new Error(`Struct codec: bitfield write unsupported for byteSize=${byteSize} bitWidth=${bitWidth} (max ${MAX_BITFIELD_WIDTH} bits)`);
1021
+ }
1022
+ // Use unsigned mask so bitWidth=31 works: (1 << 31) would overflow to negative in JS.
1023
+ const mask = (0xffffffff >>> (32 - bitWidth)) >>> 0;
1024
+ const v = (value & mask) >>> 0;
1025
+ const raw = readStorageUnit(view, offset, byteSize);
1026
+ const updated = ((raw & ~(mask << bitShift)) | (v << bitShift)) >>> 0;
1027
+ writeStorageUnit(view, offset, byteSize, updated);
1028
+ }
1029
+
1030
+ /** Single punctuation or keyword token. */
1031
+ const PUNCT = /^[[\]{}:;,=]/;
1032
+ /** Identifier: letter or _ then alphanumeric or _. */
1033
+ const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*/;
1034
+ /** Integer: optional minus then digits. */
1035
+ const INT = /^-?\d+/;
1036
+ /** Whitespace. */
1037
+ const WS = /^[\s\r\n\t]+/;
1038
+ class Lexer {
1039
+ constructor(input) {
1040
+ this.pos = 0;
1041
+ this.input = input;
1042
+ }
1043
+ eof() {
1044
+ this.skipWhitespace();
1045
+ return this.pos >= this.input.length;
1046
+ }
1047
+ skipWhitespace() {
1048
+ while (this.pos < this.input.length) {
1049
+ const m = this.input.slice(this.pos).match(WS);
1050
+ if (m) {
1051
+ this.pos += m[0].length;
1052
+ }
1053
+ else {
1054
+ break;
1055
+ }
1056
+ }
1057
+ }
1058
+ /** Returns next token as string, or null if eof. */
1059
+ next() {
1060
+ this.skipWhitespace();
1061
+ if (this.pos >= this.input.length)
1062
+ return null;
1063
+ const rest = this.input.slice(this.pos);
1064
+ const p = rest.match(PUNCT);
1065
+ if (p) {
1066
+ this.pos += p[0].length;
1067
+ return p[0];
1068
+ }
1069
+ const i = rest.match(INT);
1070
+ if (i) {
1071
+ this.pos += i[0].length;
1072
+ return i[0];
1073
+ }
1074
+ const id = rest.match(IDENT);
1075
+ if (id) {
1076
+ this.pos += id[0].length;
1077
+ return id[0];
1078
+ }
1079
+ throw new Error(`Struct schema lexer: unexpected character at position ${this.pos}`);
1080
+ }
1081
+ /** Peek next token without consuming. */
1082
+ peek() {
1083
+ const saved = this.pos;
1084
+ const t = this.next();
1085
+ this.pos = saved;
1086
+ return t;
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Parses a WPILib struct schema string into a list of field declarations.
1091
+ * @param schema - Schema string (e.g. "double x;double y" or "Translation2d translation;Rotation2d rotation").
1092
+ * @returns Array of parsed fields.
1093
+ * @throws Error on malformed schema.
1094
+ */
1095
+ function parseSchema(schema) {
1096
+ const lex = new Lexer(schema);
1097
+ const fields = [];
1098
+ while (!lex.eof()) {
1099
+ let enumValues;
1100
+ const first = lex.next();
1101
+ if (!first)
1102
+ break;
1103
+ if (first === 'enum') {
1104
+ const open = lex.next();
1105
+ if (open !== '{')
1106
+ throw new Error('Struct schema: expected "{" after enum');
1107
+ enumValues = parseEnumEntries(lex);
1108
+ const close = lex.next();
1109
+ if (close !== '}')
1110
+ throw new Error('Struct schema: expected "}" after enum entries');
1111
+ }
1112
+ else if (first === '{') {
1113
+ enumValues = parseEnumEntries(lex);
1114
+ const close = lex.next();
1115
+ if (close !== '}')
1116
+ throw new Error('Struct schema: expected "}" after enum entries');
1117
+ }
1118
+ const typeName = first === 'enum' || first === '{' ? lex.next() : first;
1119
+ if (!typeName || PUNCT.test(typeName) || INT.test(typeName)) {
1120
+ throw new Error('Struct schema: expected type after declaration start');
1121
+ }
1122
+ const name = lex.next();
1123
+ if (!name || PUNCT.test(name) || INT.test(name)) {
1124
+ throw new Error('Struct schema: expected identifier after type');
1125
+ }
1126
+ let arraySize = 1;
1127
+ let bitWidth = 0;
1128
+ const afterName = lex.peek();
1129
+ if (afterName === '[') {
1130
+ lex.next();
1131
+ const sizeTok = lex.next();
1132
+ if (!sizeTok || !INT.test(sizeTok))
1133
+ throw new Error('Struct schema: expected integer in array size');
1134
+ const n = parseInt(sizeTok, 10);
1135
+ if (n <= 0)
1136
+ throw new Error('Struct schema: array size must be positive');
1137
+ arraySize = n;
1138
+ const close = lex.next();
1139
+ if (close !== ']')
1140
+ throw new Error('Struct schema: expected "]" after array size');
1141
+ }
1142
+ else if (afterName === ':') {
1143
+ lex.next();
1144
+ const widthTok = lex.next();
1145
+ if (!widthTok || !INT.test(widthTok))
1146
+ throw new Error('Struct schema: expected integer in bitfield width');
1147
+ bitWidth = parseInt(widthTok, 10);
1148
+ if (bitWidth <= 0)
1149
+ throw new Error('Struct schema: bitfield width must be positive');
1150
+ }
1151
+ fields.push({ typeName, name, arraySize, bitWidth, enumValues });
1152
+ while (lex.peek() === ';') {
1153
+ lex.next();
1154
+ }
1155
+ }
1156
+ return fields;
1157
+ }
1158
+ function parseEnumEntries(lex) {
1159
+ const enumValues = {};
1160
+ for (;;) {
1161
+ if (lex.peek() === '}')
1162
+ break;
1163
+ const id = lex.next();
1164
+ if (!id)
1165
+ break;
1166
+ if (PUNCT.test(id) || INT.test(id))
1167
+ throw new Error('Struct schema: expected identifier in enum entry');
1168
+ const eq = lex.next();
1169
+ if (eq !== '=')
1170
+ throw new Error('Struct schema: expected "=" in enum entry');
1171
+ const num = lex.next();
1172
+ if (!num || !INT.test(num))
1173
+ throw new Error('Struct schema: expected integer in enum value');
1174
+ enumValues[id] = parseInt(num, 10);
1175
+ const next = lex.peek();
1176
+ if (next === ',') {
1177
+ lex.next();
1178
+ }
1179
+ else if (next === '}') {
1180
+ break;
1181
+ }
1182
+ else if (next !== null) {
1183
+ throw new Error(`Struct schema: unexpected token "${next}" in enum`);
1184
+ }
1185
+ }
1186
+ return enumValues;
1187
+ }
1188
+ /**
1189
+ * Builds a StructDescriptor from parsed fields and a resolver for nested struct sizes.
1190
+ *
1191
+ * Implements the WPILib `StructDescriptor.calculateOffsets()` algorithm for bitfield
1192
+ * coalescing. Fields are densely packed with no alignment padding. Consecutive bitfields
1193
+ * of the same underlying type share a single storage unit (e.g. two `uint8:4` fields
1194
+ * occupy 1 byte, not 2). When the type changes or bits overflow the storage unit, a new
1195
+ * unit starts.
1196
+ *
1197
+ * @see https://github.com/wpilibsuite/allwpilib — StructDescriptor.java, calculateOffsets()
1198
+ */
1199
+ function buildStructDescriptor(typeName, parsedFields, getNestedDescriptor) {
1200
+ function resolveElemSize(f) {
1201
+ if (isPrimitiveType(f.typeName)) {
1202
+ const elemSize = getPrimitiveSize(f.typeName);
1203
+ if (f.bitWidth > 0 && f.bitWidth > elemSize * 8) {
1204
+ throw new Error(`Struct schema: bitfield width ${f.bitWidth} exceeds type ${f.typeName} size (${elemSize} bytes)`);
1205
+ }
1206
+ return { elemSize, nested: null };
1207
+ }
1208
+ if (f.typeName === typeName) {
1209
+ throw new Error(`Struct schema: circular struct reference "${f.typeName}" in "${typeName}"`);
1210
+ }
1211
+ const nested = getNestedDescriptor(f.typeName);
1212
+ if (!nested) {
1213
+ throw new Error(`Struct schema: unknown or circular struct type "${f.typeName}" in "${typeName}"`);
1214
+ }
1215
+ return { elemSize: nested.size, nested };
1216
+ }
1217
+ const fields = [];
1218
+ // Mirrors WPILib's calculateOffsets state: byte offset, bit position within the
1219
+ // current bitfield storage unit, and the byte-size of that storage unit (0 = none active).
1220
+ let offset = 0;
1221
+ let shift = 0;
1222
+ let prevBitfieldSize = 0;
1223
+ for (const f of parsedFields) {
1224
+ const { elemSize, nested } = resolveElemSize(f);
1225
+ const isBitfield = f.bitWidth > 0 && f.bitWidth < elemSize * 8;
1226
+ if (!isBitfield) {
1227
+ // Non-bitfield: close any active bitfield storage unit, then lay out normally.
1228
+ shift = 0;
1229
+ offset += prevBitfieldSize;
1230
+ prevBitfieldSize = 0;
1231
+ if (nested) {
1232
+ fields.push({
1233
+ name: f.name,
1234
+ offset,
1235
+ size: elemSize,
1236
+ arraySize: f.arraySize,
1237
+ bitWidth: 0,
1238
+ bitShift: 0,
1239
+ nestedDescriptor: nested,
1240
+ });
1241
+ }
1242
+ else {
1243
+ fields.push({
1244
+ name: f.name,
1245
+ offset,
1246
+ size: elemSize,
1247
+ arraySize: f.arraySize,
1248
+ bitWidth: 0,
1249
+ bitShift: 0,
1250
+ enumValues: f.enumValues,
1251
+ primitive: f.typeName,
1252
+ });
1253
+ }
1254
+ offset += elemSize * f.arraySize;
1255
+ }
1256
+ else {
1257
+ // Bitfield coalescing: consecutive bitfields of the same underlying type pack into
1258
+ // a shared storage unit at increasing bitShift offsets (LSB-first). Three cases:
1259
+ const bitWidth = f.bitWidth;
1260
+ const isBool = f.typeName === 'bool';
1261
+ if (isBool && prevBitfieldSize !== 0 && shift + 1 <= prevBitfieldSize * 8) ;
1262
+ else if (elemSize !== prevBitfieldSize || shift + bitWidth > elemSize * 8) {
1263
+ // Case 2: Type changed or bits won't fit — start a new storage unit.
1264
+ shift = 0;
1265
+ offset += prevBitfieldSize;
1266
+ }
1267
+ // Case 3 (implicit): Same type, fits — continues packing into current unit.
1268
+ prevBitfieldSize =
1269
+ isBool && prevBitfieldSize !== 0 && shift + 1 <= prevBitfieldSize * 8 ? prevBitfieldSize : elemSize;
1270
+ fields.push({
1271
+ name: f.name,
1272
+ offset,
1273
+ size: prevBitfieldSize,
1274
+ arraySize: 1,
1275
+ bitWidth,
1276
+ bitShift: shift,
1277
+ enumValues: f.enumValues,
1278
+ primitive: f.typeName,
1279
+ });
1280
+ shift += bitWidth;
1281
+ }
1282
+ }
1283
+ // Account for any trailing bitfield storage unit.
1284
+ const totalSize = offset + prevBitfieldSize;
1285
+ return { typeName, size: totalSize, fields };
1286
+ }
1287
+
1288
+ const SCHEMAS = {
1289
+ Translation2d: 'double x;double y',
1290
+ Rotation2d: 'double value',
1291
+ Pose2d: 'Translation2d translation;Rotation2d rotation',
1292
+ Transform2d: 'Translation2d translation;Rotation2d rotation',
1293
+ Twist2d: 'double dx;double dy;double dtheta',
1294
+ Translation3d: 'double x;double y;double z',
1295
+ Quaternion: 'double w;double x;double y;double z',
1296
+ Rotation3d: 'Quaternion q',
1297
+ Pose3d: 'Translation3d translation;Rotation3d rotation',
1298
+ Transform3d: 'Translation3d translation;Rotation3d rotation',
1299
+ Twist3d: 'double dx;double dy;double dz;double rx;double ry;double rz',
1300
+ };
1301
+ const SCHEMA_STRINGS = SCHEMAS;
1302
+ const builtInDescriptors = new Map();
1303
+ function getBuiltIn(name) {
1304
+ return builtInDescriptors.get(name) ?? null;
1305
+ }
1306
+ function initBuiltIns() {
1307
+ const order = [
1308
+ 'Translation2d',
1309
+ 'Rotation2d',
1310
+ 'Translation3d',
1311
+ 'Quaternion',
1312
+ 'Pose2d',
1313
+ 'Transform2d',
1314
+ 'Twist2d',
1315
+ 'Rotation3d',
1316
+ 'Pose3d',
1317
+ 'Transform3d',
1318
+ 'Twist3d',
1319
+ ];
1320
+ for (const typeName of order) {
1321
+ if (builtInDescriptors.has(typeName))
1322
+ continue;
1323
+ const schema = SCHEMA_STRINGS[typeName];
1324
+ if (!schema)
1325
+ continue;
1326
+ const fields = parseSchema(schema);
1327
+ const descriptor = buildStructDescriptor(typeName, fields, getBuiltIn);
1328
+ builtInDescriptors.set(typeName, descriptor);
1329
+ }
1330
+ }
1331
+ function getBuiltInDescriptor(typeName) {
1332
+ if (builtInDescriptors.size === 0)
1333
+ initBuiltIns();
1334
+ const cached = builtInDescriptors.get(typeName);
1335
+ if (cached)
1336
+ return cached;
1337
+ const schema = SCHEMA_STRINGS[typeName];
1338
+ if (!schema)
1339
+ return null;
1340
+ const fields = parseSchema(schema);
1341
+ const descriptor = buildStructDescriptor(typeName, fields, getBuiltIn);
1342
+ builtInDescriptors.set(typeName, descriptor);
1343
+ return descriptor;
1344
+ }
1345
+ /** All built-in struct type names (WPILib geometry). */
1346
+ const BUILT_IN_STRUCT_TYPE_NAMES = Object.keys(SCHEMAS);
1347
+ /** Returns the schema string for a built-in type, or undefined. */
1348
+ function getBuiltInSchemaString(typeName) {
1349
+ return SCHEMA_STRINGS[typeName];
1350
+ }
1351
+
1352
+ function structTypeInfo(typeName, isArray) {
1353
+ return [5, isArray ? `struct:${typeName}[]` : `struct:${typeName}`];
1354
+ }
1355
+ class NetworkTablesStructTopic extends NetworkTablesTopic {
1356
+ constructor(client, name, options) {
1357
+ const typeName = options?.typeName ?? '';
1358
+ const isArray = typeName.endsWith('[]');
1359
+ const baseTypeName = isArray ? typeName.slice(0, -2) : typeName;
1360
+ super(client, name, structTypeInfo(baseTypeName || 'Unknown', isArray), undefined);
1361
+ this.decodedValue = null;
1362
+ this._descriptor = null;
1363
+ /** When schema option build fails (e.g. nested not yet available), store and retry in ensureDescriptor. */
1364
+ this._pendingSchema = null;
1365
+ this._typeName = baseTypeName || typeName;
1366
+ this._isArray = isArray;
1367
+ this._validator = options?.validator;
1368
+ if (options?.defaultValue !== undefined) {
1369
+ this.decodedValue = options.defaultValue;
1370
+ }
1371
+ if (options?.schema) {
1372
+ try {
1373
+ const fields = parseSchema(options.schema);
1374
+ const getNested = (n) => client.structSchemaManager.fetchDescriptor(n);
1375
+ this._descriptor = buildStructDescriptor(this._typeName, fields, getNested);
1376
+ }
1377
+ catch (err) {
1378
+ pubsubLogger.debug('Deferred struct descriptor build from schema option', { topicName: name, error: err });
1379
+ this._pendingSchema = options.schema;
1380
+ }
1381
+ }
1382
+ if (!this._descriptor && this._typeName) {
1383
+ this._descriptor = getBuiltInDescriptor(this._typeName) ?? null;
1384
+ }
1385
+ }
1386
+ /**
1387
+ * Applies options to this topic. Used when returning a cached topic from getStructTopic.
1388
+ * A later `validator` replaces any previous validator because the topic is a singleton.
1389
+ * @param options - The options to apply.
1390
+ */
1391
+ applyOptions(options) {
1392
+ if (!options)
1393
+ return;
1394
+ if (options.schema !== undefined) {
1395
+ const schema = options.schema;
1396
+ this._pendingSchema = schema || null;
1397
+ if (schema) {
1398
+ try {
1399
+ const fields = parseSchema(schema);
1400
+ const getNested = (n) => this.client.structSchemaManager.fetchDescriptor(n);
1401
+ this._descriptor = buildStructDescriptor(this._typeName, fields, getNested);
1402
+ this._pendingSchema = null;
1403
+ }
1404
+ catch (err) {
1405
+ pubsubLogger.debug('Deferred struct descriptor build from schema option (applyOptions)', {
1406
+ topicName: this.name,
1407
+ error: err,
1408
+ });
1409
+ this._descriptor = null;
1410
+ }
1411
+ }
1412
+ else {
1413
+ this._descriptor = null;
1414
+ }
1415
+ }
1416
+ if (!this._descriptor && this._typeName) {
1417
+ this._descriptor = getBuiltInDescriptor(this._typeName) ?? null;
1418
+ }
1419
+ if (options.validator !== undefined)
1420
+ this._validator = options.validator;
1421
+ if (options.defaultValue !== undefined && this.decodedValue == null)
1422
+ this.decodedValue = options.defaultValue;
1423
+ }
1424
+ ensureDescriptor() {
1425
+ if (this._descriptor)
1426
+ return this._descriptor;
1427
+ const schemaToTry = this._pendingSchema;
1428
+ if (schemaToTry) {
1429
+ try {
1430
+ const fields = parseSchema(schemaToTry);
1431
+ const getNested = (n) => this.client.structSchemaManager.fetchDescriptor(n);
1432
+ this._descriptor = buildStructDescriptor(this._typeName, fields, getNested);
1433
+ this._pendingSchema = null;
1434
+ return this._descriptor;
1435
+ }
1436
+ catch (err) {
1437
+ pubsubLogger.debug('Retry struct descriptor from pending schema failed', {
1438
+ topicName: this.name,
1439
+ error: err,
1440
+ });
1441
+ }
1442
+ }
1443
+ const d = this.client.structSchemaManager.fetchDescriptor(this._typeName);
1444
+ if (d) {
1445
+ this._descriptor = d;
1446
+ return d;
1447
+ }
1448
+ throw new Error(`Struct descriptor not found for type "${this._typeName}"`);
1449
+ }
1450
+ maybeValidate(value) {
1451
+ return (this._validator ? this._validator.parse(value) : value);
1452
+ }
1453
+ getValue() {
1454
+ return this.decodedValue;
1455
+ }
1456
+ setValue(value) {
1457
+ if (!this.publisher) {
1458
+ pubsubLogger.debug('Publisher check failed before setValue', { topicName: this.name });
1459
+ throw new Error('Cannot set value on topic without being the publisher');
1460
+ }
1461
+ const validated = this.maybeValidate(value);
1462
+ const encoded = this.encodeValue(validated);
1463
+ this.decodedValue = validated;
1464
+ this.setWireValue(encoded);
1465
+ this.afterSetWireValue();
1466
+ }
1467
+ decodeValue(value) {
1468
+ try {
1469
+ const desc = this.ensureDescriptor();
1470
+ if (this._isArray) {
1471
+ const elemSize = desc.size;
1472
+ if (value.length % elemSize !== 0)
1473
+ return null;
1474
+ const n = value.length / elemSize;
1475
+ const arr = [];
1476
+ for (let i = 0; i < n; i++) {
1477
+ arr.push(unpack(new Uint8Array(value.buffer, value.byteOffset + i * elemSize, elemSize), desc));
1478
+ }
1479
+ return this.maybeValidate(arr);
1480
+ }
1481
+ const out = unpack(value, desc);
1482
+ return this.maybeValidate(out);
1483
+ }
1484
+ catch (err) {
1485
+ pubsubLogger.debug('Failed to decode struct value', { topicName: this.name, error: err });
1486
+ return null;
1487
+ }
1488
+ }
1489
+ encodeValue(value) {
1490
+ const desc = this.ensureDescriptor();
1491
+ if (this._isArray) {
1492
+ if (!Array.isArray(value)) {
1493
+ throw new Error(`Expected an array for array struct topic ${this.name}, got ${typeof value}${value === null ? ' (null)' : value === undefined ? ' (undefined)' : ''}`);
1494
+ }
1495
+ const arr = value;
1496
+ const elemSize = desc.size;
1497
+ const buf = new Uint8Array(arr.length * elemSize);
1498
+ for (let i = 0; i < arr.length; i++) {
1499
+ buf.set(pack(arr[i], desc), i * elemSize);
1500
+ }
1501
+ return buf;
1502
+ }
1503
+ return pack(value, desc);
1504
+ }
1505
+ updateValue(value, lastChangedTime) {
1506
+ this.decodedValue = this.decodeValue(value);
1507
+ super.updateValue(value, lastChangedTime);
1508
+ }
1509
+ announce(params) {
1510
+ super.announce(params);
1511
+ const typeString = params.type;
1512
+ if (!this.name.startsWith('/.schema/') && typeString.startsWith('struct:')) {
1513
+ const suffix = typeString.slice(7);
1514
+ this._isArray = suffix.endsWith('[]');
1515
+ this._typeName = this._isArray ? suffix.slice(0, -2) : suffix;
1516
+ this._descriptor = this.client.structSchemaManager.fetchDescriptor(this._typeName) ?? this._descriptor;
1517
+ }
1518
+ }
1519
+ async publish(properties = {}, id) {
1520
+ const operationKey = `publish:${this.name}`;
1521
+ return this.client.getOrCreateInFlightOperation(operationKey, async () => {
1522
+ if (this.publisher) {
1523
+ pubsubLogger.debug('Publish skipped', { topicName: this.name, reason: 'already publisher' });
1524
+ return;
1525
+ }
1526
+ const desc = this.ensureDescriptor();
1527
+ await this.client.structSchemaManager.addSchema(desc);
1528
+ const typeString = this._isArray ? `struct:${this._typeName}[]` : `struct:${this._typeName}`;
1529
+ const pubuid = id ?? this.client.messenger.getNextPubUID();
1530
+ this._pubuid = pubuid;
1531
+ this._publishProperties = properties;
1532
+ pubsubLogger.debug('Topic published', { topicName: this.name, pubuid, properties, type: typeString });
1533
+ const publishParams = { type: typeString, name: this.name, pubuid, properties };
1534
+ return await this.client.messenger.publish(publishParams);
1535
+ });
1536
+ }
1537
+ }
1538
+
1539
+ /** Schema for an integer. */
1540
+ const finiteNumSchema = z.number();
1541
+ const integerSchema = finiteNumSchema.int();
1542
+ /** Schema for type numbers in the NT protocol. */
1543
+ const typeNumSchema = z.union([
1544
+ z.literal(0),
1545
+ z.literal(1),
1546
+ z.literal(2),
1547
+ z.literal(3),
1548
+ z.literal(4),
1549
+ z.literal(5),
1550
+ z.literal(16),
1551
+ z.literal(17),
1552
+ z.literal(18),
1553
+ z.literal(19),
1554
+ z.literal(20),
1555
+ ]);
1556
+ /** Schema for type strings in the NT protocol (JSON text frames always use strings per NT 4.1 spec). */
1557
+ const typeStringSchema = z.union([
1558
+ z.literal('boolean'),
1559
+ z.literal('double'),
1560
+ z.literal('int'),
1561
+ z.literal('float'),
1562
+ z.literal('string'),
1563
+ z.literal('json'),
1564
+ z.literal('raw'),
1565
+ z.literal('rpc'),
1566
+ z.literal('msgpack'),
1567
+ z.literal('structschema'),
1568
+ z.literal('protobuf'),
1569
+ z.literal('boolean[]'),
1570
+ z.literal('double[]'),
1571
+ z.literal('int[]'),
1572
+ z.literal('float[]'),
1573
+ z.literal('string[]'),
1574
+ z.string().startsWith('proto:'),
1575
+ z.string().startsWith('struct:'),
1576
+ z.string().startsWith('photonstruct:'),
1577
+ ]);
1578
+ /** Schema for topic properties in the NT protocol. */
1579
+ const topicPropertiesSchema = z
1580
+ .object({
1581
+ persistent: z.boolean(),
1582
+ retained: z.boolean(),
1583
+ cached: z.boolean(),
1584
+ })
1585
+ .partial();
1586
+ /** Schema for a topic in the NT protocol. */
1587
+ const topicSchema = z.object({
1588
+ name: z.string(),
1589
+ id: integerSchema,
1590
+ type: typeStringSchema,
1591
+ properties: topicPropertiesSchema,
1592
+ });
1593
+ /** Schema for subscription options in the NT protocol. */
1594
+ const subscriptionOptionsSchema = z
1595
+ .object({
1596
+ periodic: finiteNumSchema.nonnegative(),
1597
+ all: z.boolean(),
1598
+ topicsonly: z.boolean(),
1599
+ prefix: z.boolean(),
1600
+ })
1601
+ .partial();
1602
+ /** Schema for the publish message in the NT protocol. */
1603
+ const publishMessageParamsSchema = z.object({
1604
+ name: z.string(),
1605
+ pubuid: integerSchema,
1606
+ type: typeStringSchema,
1607
+ properties: topicPropertiesSchema,
1608
+ });
1609
+ /** Schema for the unpublish message params in the NT protocol. */
1610
+ const unpublishMessageParamsSchema = z.object({
1611
+ pubuid: integerSchema,
1612
+ });
1613
+ /** Schema for the set properties message params in the NT protocol. */
1614
+ const setPropertiesMessageParamsSchema = z.object({
1615
+ name: z.string(),
1616
+ update: topicPropertiesSchema,
1617
+ });
1618
+ /** Schema for the subscribe message params in the NT protocol. */
1619
+ const subscribeMessageParamsSchema = z.object({
1620
+ topics: z.array(z.string()),
1621
+ subuid: integerSchema,
1622
+ options: subscriptionOptionsSchema,
1623
+ });
1624
+ /** Schema for the unsubscribe message params in the NT protocol. */
1625
+ const unsubscribeMessageParamsSchema = z.object({
1626
+ subuid: integerSchema,
1627
+ });
1628
+ /** Schema for the announce message params in the NT protocol. */
1629
+ const announceMessageParamsSchema = topicSchema.extend({
1630
+ pubuid: integerSchema.optional(),
1631
+ });
1632
+ /** Schema for the unannounce message params in the NT protocol. */
1633
+ const unannounceMessageParamsSchema = z.object({
1634
+ name: z.string(),
1635
+ id: integerSchema,
1636
+ });
1637
+ /** Schema for the properties message params in the NT protocol. */
1638
+ const propertiesMessageParamsSchema = z.object({
1639
+ name: z.string(),
1640
+ ack: z.boolean().optional(),
1641
+ update: topicPropertiesSchema,
1642
+ });
1643
+ /** Schema for a publish message in the NT protocol. */
1644
+ const publishMessageSchema = z.object({
1645
+ method: z.literal('publish'),
1646
+ params: publishMessageParamsSchema,
1647
+ });
1648
+ /** Schema for an unpublish message in the NT protocol. */
1649
+ const unpublishMessageSchema = z.object({
1650
+ method: z.literal('unpublish'),
1651
+ params: unpublishMessageParamsSchema,
1652
+ });
1653
+ /** Schema for a setproperties message in the NT protocol. */
1654
+ const setPropertiesMessageSchema = z.object({
1655
+ method: z.literal('setproperties'),
1656
+ params: setPropertiesMessageParamsSchema,
1657
+ });
1658
+ /** Schema for a subscribe message in the NT protocol. */
1659
+ const subscribeMessageSchema = z.object({
1660
+ method: z.literal('subscribe'),
1661
+ params: subscribeMessageParamsSchema,
1662
+ });
1663
+ /** Schema for an unsubscribe message in the NT protocol. */
1664
+ const unsubscribeMessageSchema = z.object({
1665
+ method: z.literal('unsubscribe'),
1666
+ params: unsubscribeMessageParamsSchema,
1667
+ });
1668
+ /** Schema for an announce message in the NT protocol. */
1669
+ const announceMessageSchema = z.object({
1670
+ method: z.literal('announce'),
1671
+ params: announceMessageParamsSchema,
1672
+ });
1673
+ /** Schema for an unannounce message in the NT protocol. */
1674
+ const unannounceMessageSchema = z.object({
1675
+ method: z.literal('unannounce'),
1676
+ params: unannounceMessageParamsSchema,
1677
+ });
1678
+ /** Schema for a properties message in the NT protocol. */
1679
+ const propertiesMessageSchema = z.object({
1680
+ method: z.literal('properties'),
1681
+ params: propertiesMessageParamsSchema,
1682
+ });
1683
+ /** Schema for a text frame message in the NT protocol. */
1684
+ const messageSchema = z.array(z.discriminatedUnion('method', [
1685
+ publishMessageSchema,
1686
+ unpublishMessageSchema,
1687
+ setPropertiesMessageSchema,
1688
+ subscribeMessageSchema,
1689
+ unsubscribeMessageSchema,
1690
+ announceMessageSchema,
1691
+ unannounceMessageSchema,
1692
+ propertiesMessageSchema,
1693
+ ]));
1694
+ /** Schema for a value in the msgpack format. */
1695
+ const msgPackValueSchema = z.union([
1696
+ z.boolean(),
1697
+ integerSchema,
1698
+ finiteNumSchema,
1699
+ z.string(),
1700
+ z.custom((v) => v instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(v))),
1701
+ z.array(z.boolean()),
1702
+ z.array(z.string()),
1703
+ z.array(integerSchema),
1704
+ z.array(finiteNumSchema),
1705
+ ]);
1706
+ /** Schema for a binary message in the msgpack format. */
1707
+ const msgPackSchema = z.tuple([
1708
+ z.union([integerSchema.nonnegative(), z.literal(-1)]),
1709
+ integerSchema.nonnegative(),
1710
+ typeNumSchema,
1711
+ msgPackValueSchema,
1712
+ ]);
1713
+
1714
+ class NetworkTablesTypeInfos {
1715
+ /**
1716
+ * Validates and parses a value based on the expected NetworkTables type information.
1717
+ * @param expectedTypeInfo - The expected type information from `NetworkTablesTypeInfo`.
1718
+ * @param value - The value to validate and parse.
1719
+ * @returns The parsed value, matching the expected type.
1720
+ * @throws Will throw an error if the value does not match the expected type or if parsing fails.
1721
+ */
1722
+ static validateData(expectedTypeInfo, value) {
1723
+ switch (expectedTypeInfo) {
1724
+ // 0
1725
+ case NetworkTablesTypeInfos.kBoolean:
1726
+ return z.boolean().parse(value);
1727
+ // 1
1728
+ case NetworkTablesTypeInfos.kDouble:
1729
+ return finiteNumSchema.parse(value);
1730
+ // 2
1731
+ case NetworkTablesTypeInfos.kInteger:
1732
+ return integerSchema.parse(value);
1733
+ // 3
1734
+ case NetworkTablesTypeInfos.kFloat:
1735
+ return finiteNumSchema.parse(value);
1736
+ // 4
1737
+ case NetworkTablesTypeInfos.kString:
1738
+ return z.string().parse(value);
1739
+ case NetworkTablesTypeInfos.kJson: {
1740
+ const parsedString = z.string().parse(value);
1741
+ const parsedJson = JSON.parse(parsedString);
1742
+ if (typeof parsedJson === 'object' && parsedJson !== null) {
1743
+ return parsedString;
1744
+ }
1745
+ else {
1746
+ throw new Error(`Bad JSON value: ${value}`);
1747
+ }
1748
+ }
1749
+ // 5
1750
+ case NetworkTablesTypeInfos.kUint8Array:
1751
+ if (value instanceof Uint8Array) {
1752
+ return value;
1753
+ }
1754
+ else {
1755
+ throw new Error(`Invalid Uint8Array value: ${value}`);
1756
+ }
1757
+ case NetworkTablesTypeInfos.kRPC:
1758
+ if (value instanceof Uint8Array) {
1759
+ return value;
1760
+ }
1761
+ else {
1762
+ throw new Error(`Invalid RPC value: ${value}`);
1763
+ }
1764
+ case NetworkTablesTypeInfos.kMsgpack:
1765
+ if (value instanceof Uint8Array) {
1766
+ return value;
1767
+ }
1768
+ else {
1769
+ throw new Error(`Invalid Msgpack value: ${value}`);
1770
+ }
1771
+ case NetworkTablesTypeInfos.kProtobuf:
1772
+ if (value instanceof Uint8Array) {
1773
+ return value;
1774
+ }
1775
+ else {
1776
+ throw new Error(`Invalid Protobuf value: ${value}`);
1777
+ }
1778
+ case NetworkTablesTypeInfos.kStructSchema:
1779
+ if (value instanceof Uint8Array) {
1780
+ return value;
1781
+ }
1782
+ else {
1783
+ throw new Error(`Invalid struct schema value: ${value}`);
1784
+ }
1785
+ // 16
1786
+ case NetworkTablesTypeInfos.kBooleanArray:
1787
+ return z.array(z.boolean()).parse(value);
1788
+ // 17
1789
+ case NetworkTablesTypeInfos.kDoubleArray:
1790
+ return z.array(finiteNumSchema).parse(value);
1791
+ // 18
1792
+ case NetworkTablesTypeInfos.kIntegerArray:
1793
+ return z.array(integerSchema).parse(value);
1794
+ // 19
1795
+ case NetworkTablesTypeInfos.kFloatArray:
1796
+ return z.array(finiteNumSchema).parse(value);
1797
+ // 20
1798
+ case NetworkTablesTypeInfos.kStringArray:
1799
+ return z.array(z.string()).parse(value);
1800
+ default: {
1801
+ // Type num 5: struct topics store full type in typeInfo (e.g. struct:Pose2d) for topic
1802
+ // reuse/type identity, so we see struct: here. Protobuf topics use kProtobuf [5, 'protobuf']
1803
+ // only; proto:MessageName is used in announce/decoding, not as topic typeInfo.
1804
+ const typeString = expectedTypeInfo[1];
1805
+ if (typeString.startsWith('struct:')) {
1806
+ if (value instanceof Uint8Array)
1807
+ return value;
1808
+ throw new Error(`Invalid struct value: expected Uint8Array`);
1809
+ }
1810
+ throw new Error(`Invalid type info: ${expectedTypeInfo}`);
1811
+ }
1812
+ }
1813
+ }
1814
+ }
1815
+ NetworkTablesTypeInfos.kBoolean = [0, 'boolean'];
1816
+ NetworkTablesTypeInfos.kDouble = [1, 'double'];
1817
+ NetworkTablesTypeInfos.kInteger = [2, 'int'];
1818
+ NetworkTablesTypeInfos.kFloat = [3, 'float'];
1819
+ NetworkTablesTypeInfos.kString = [4, 'string'];
1820
+ NetworkTablesTypeInfos.kJson = [4, 'json'];
1821
+ NetworkTablesTypeInfos.kUint8Array = [5, 'raw'];
1822
+ NetworkTablesTypeInfos.kRPC = [5, 'rpc'];
1823
+ NetworkTablesTypeInfos.kMsgpack = [5, 'msgpack'];
1824
+ NetworkTablesTypeInfos.kProtobuf = [5, 'protobuf'];
1825
+ /** Type info for struct schema topics (/.schema/struct:TypeName). Wire type 5 (raw bytes), type string 'structschema'. */
1826
+ NetworkTablesTypeInfos.kStructSchema = [5, 'structschema'];
1827
+ NetworkTablesTypeInfos.kBooleanArray = [16, 'boolean[]'];
1828
+ NetworkTablesTypeInfos.kDoubleArray = [17, 'double[]'];
1829
+ NetworkTablesTypeInfos.kIntegerArray = [18, 'int[]'];
1830
+ NetworkTablesTypeInfos.kFloatArray = [19, 'float[]'];
1831
+ NetworkTablesTypeInfos.kStringArray = [20, 'string[]'];
1832
+
1833
+ class NetworkTablesProtobufTopic extends NetworkTablesTopic {
1834
+ /**
1835
+ * Creates a new topic. This should only be done after the
1836
+ * base NTCore client has been initialized.
1837
+ * @param client - The client that owns the topic.
1838
+ * @param name - The name of the topic.
1839
+ * @param options - The options for the topic.
1840
+ * @param options.defaultValue
1841
+ * @param options.validator
1842
+ * @param options.protoFilePath - Node.js only. Use protoSource or messageType in the browser.
1843
+ * @param options.protoSource - In-memory .proto text. Safe in the browser.
1844
+ * @param options.messageType - Prebuilt protobufjs Type. Safe in the browser.
1845
+ */
1846
+ constructor(client, name, options) {
1847
+ // Note: We can't encode the default value here because we don't have the message type yet.
1848
+ // The default value will be encoded when setValue is called or when the schema is available.
1849
+ super(client, name, NetworkTablesTypeInfos.kProtobuf, undefined);
1850
+ // Protobuf support
1851
+ this.decodedValue = null;
1852
+ this._protobufMessageType = null;
1853
+ this._schemaRegistered = false;
1854
+ /** Stored error when constructor's fire-and-forget loadProtoSchema fails; re-thrown by ensureMessageType. */
1855
+ this._schemaLoadError = null;
1856
+ this._validator = options?.validator;
1857
+ this._protoFilePath = options?.protoFilePath;
1858
+ this._protoSource = options?.protoSource;
1859
+ this._providedMessageType = options?.messageType;
1860
+ // Store the default value to encode later when we have the schema
1861
+ if (options?.defaultValue !== undefined) {
1862
+ this.decodedValue = options.defaultValue;
1863
+ }
1864
+ if (options?.messageType) {
1865
+ this.applyMessageType(options.messageType);
1866
+ }
1867
+ else if (options?.protoSource) {
1868
+ try {
1869
+ const { messageName, root } = this.client.protobufSchemaManager.parseSource(options.protoSource);
1870
+ this._protobufMessageName = messageName;
1871
+ this._messageTypeString = `proto:${messageName}`;
1872
+ this._protobufMessageType = root.lookupType(messageName);
1873
+ }
1874
+ catch (error) {
1875
+ this._schemaLoadError =
1876
+ error instanceof Error ? error : new Error(`Failed to parse proto source: ${String(error)}`);
1877
+ console.error(`Failed to parse proto source for ${name}:`, error);
1878
+ }
1879
+ this.loadProtoSource(options.protoSource).catch((error) => {
1880
+ if (this._protobufMessageType != null) {
1881
+ console.error(`Failed to register proto source for ${name}:`, error);
1882
+ return;
1883
+ }
1884
+ this._schemaLoadError =
1885
+ error instanceof Error ? error : new Error(`Failed to parse proto source: ${String(error)}`);
1886
+ console.error(`Failed to parse proto source for ${name}:`, error);
1887
+ });
1888
+ }
1889
+ else if (options?.protoFilePath) {
1890
+ this.loadProtoSchema(options.protoFilePath).catch((error) => {
1891
+ this._schemaLoadError =
1892
+ error instanceof Error ? error : new Error(`Failed to load proto schema: ${String(error)}`);
1893
+ console.error(`Failed to load proto schema from ${options.protoFilePath}:`, error);
1894
+ });
1895
+ }
1896
+ }
1897
+ /**
1898
+ * Applies options to an existing protobuf topic.
1899
+ * Used when returning a cached topic from getProtobufTopic so the returned
1900
+ * instance has the requested validator, protoFilePath, and defaultValue.
1901
+ * A later `validator` replaces any previous validator because the topic is a singleton.
1902
+ * @param options - The options to apply.
1903
+ */
1904
+ applyOptions(options) {
1905
+ if (options?.validator !== undefined) {
1906
+ this._validator = options.validator;
1907
+ }
1908
+ if (options?.messageType !== undefined) {
1909
+ this.assertMessageTypeUnchanged(this.normalizeMessageName(options.messageType.fullName));
1910
+ this._providedMessageType = options.messageType;
1911
+ this._schemaLoadError = null;
1912
+ this.applyMessageType(options.messageType);
1913
+ if (!this._publisher)
1914
+ this._schemaRegistered = false;
1915
+ }
1916
+ else if (options?.protoSource !== undefined) {
1917
+ let parsed;
1918
+ try {
1919
+ parsed = this.client.protobufSchemaManager.parseSource(options.protoSource);
1920
+ }
1921
+ catch (error) {
1922
+ if (this._protobufMessageType == null) {
1923
+ this._schemaLoadError =
1924
+ error instanceof Error ? error : new Error(`Failed to parse proto source: ${String(error)}`);
1925
+ }
1926
+ console.error(`Failed to parse proto source for ${this.name}:`, error);
1927
+ return;
1928
+ }
1929
+ this.assertMessageTypeUnchanged(parsed.messageName);
1930
+ this._protoSource = options.protoSource;
1931
+ this._schemaLoadError = null;
1932
+ this._protobufMessageName = parsed.messageName;
1933
+ this._messageTypeString = `proto:${parsed.messageName}`;
1934
+ this._protobufMessageType = parsed.root.lookupType(parsed.messageName);
1935
+ if (!this._publisher)
1936
+ this._schemaRegistered = false;
1937
+ }
1938
+ else if (options?.protoFilePath !== undefined) {
1939
+ if (!this._publisher)
1940
+ this._schemaRegistered = false;
1941
+ this.loadProtoSchema(options.protoFilePath)
1942
+ .then(() => {
1943
+ this._protoFilePath = options.protoFilePath;
1944
+ this._schemaLoadError = null;
1945
+ })
1946
+ .catch((error) => {
1947
+ if (this._protobufMessageType == null) {
1948
+ this._schemaLoadError =
1949
+ error instanceof Error ? error : new Error(`Failed to load proto schema: ${String(error)}`);
1950
+ }
1951
+ console.error(`Failed to load proto schema from ${options.protoFilePath}:`, error);
1952
+ });
1953
+ }
1954
+ if (options?.defaultValue !== undefined && this.decodedValue == null) {
1955
+ this.decodedValue = options.defaultValue;
1956
+ }
1957
+ }
1958
+ normalizeMessageName(name) {
1959
+ return name.startsWith('.') ? name.slice(1) : name;
1960
+ }
1961
+ assertMessageTypeUnchanged(messageName) {
1962
+ if (this._protobufMessageName && this._protobufMessageName !== messageName) {
1963
+ throw new Error(`Cannot change protobuf message type on published topic "${this.name}" from "${this._protobufMessageName}" to "${messageName}".`);
1964
+ }
1965
+ }
1966
+ /**
1967
+ * Gets the value of the topic.
1968
+ * @returns The value of the topic.
1969
+ */
1970
+ getValue() {
1971
+ return this.decodedValue;
1972
+ }
1973
+ setValue(value) {
1974
+ const validatedValue = this._validator ? this._validator.parse(value) : value;
1975
+ // Encode first so schema load errors (e.g. bad protoFilePath) are thrown before publisher check
1976
+ const encoded = this.encodeValue(validatedValue);
1977
+ if (!this.publisher) {
1978
+ pubsubLogger.debug('Publisher check failed before setValue', { topicName: this.name });
1979
+ throw new Error('Cannot set value on topic without being the publisher');
1980
+ }
1981
+ this.decodedValue = validatedValue;
1982
+ this.setWireValue(encoded);
1983
+ this.afterSetWireValue();
1984
+ }
1985
+ /**
1986
+ * Ensures the protobuf message type is available, fetching it if necessary.
1987
+ * @returns The protobuf message type.
1988
+ * @throws {Error} If schema loading failed in the constructor (protoFilePath), re-throws that error.
1989
+ * @throws {Error} If the protobuf message type cannot be found in the schema cache.
1990
+ */
1991
+ ensureMessageType() {
1992
+ if (this._schemaLoadError) {
1993
+ throw this._schemaLoadError;
1994
+ }
1995
+ if (!this._protobufMessageType) {
1996
+ // Try to fetch schema if not already fetched
1997
+ if (this._protobufMessageName) {
1998
+ this.fetchProtobufSchema(this._protobufMessageName);
1999
+ }
2000
+ if (!this._protobufMessageType) {
2001
+ throw new Error('Protobuf message type not found');
2002
+ }
2003
+ }
2004
+ return this._protobufMessageType;
2005
+ }
2006
+ /**
2007
+ * Decodes a protobuf-encoded value to a typed object.
2008
+ * @param value - The protobuf-encoded Uint8Array value.
2009
+ * @returns The decoded protobuf object.
2010
+ * @throws {Error} If the protobuf message type is not found in the schema cache.
2011
+ * This can occur if decoding is attempted before the schema has been
2012
+ * fetched from NetworkTables (e.g., value update arrives before
2013
+ * announcement is processed or schema topic is received).
2014
+ */
2015
+ decodeValue(value) {
2016
+ const messageType = this.ensureMessageType();
2017
+ const decoded = messageType.decode(value);
2018
+ const obj = messageType.toObject(decoded, { defaults: true });
2019
+ // If a validator is provided, validate the decoded object
2020
+ if (this._validator) {
2021
+ return this._validator.parse(obj);
2022
+ }
2023
+ return obj;
2024
+ }
2025
+ /**
2026
+ * Encodes a typed object to a protobuf-encoded Uint8Array.
2027
+ * @param value - The typed object to encode.
2028
+ * @returns The protobuf-encoded Uint8Array.
2029
+ * @throws {Error} If the protobuf message type is not found in the schema cache.
2030
+ * This can occur if encoding is attempted before the schema has been
2031
+ * loaded (e.g., setValue called before protoFilePath schema is loaded
2032
+ * or before the topic announcement provides the message type).
2033
+ */
2034
+ encodeValue(value) {
2035
+ const messageType = this.ensureMessageType();
2036
+ return messageType.encode(value).finish();
2037
+ }
2038
+ /**
2039
+ * Updates the value of the topic.
2040
+ * This should only be called by the PubSubClient.
2041
+ * @param value - The value to update.
2042
+ * @param lastChangedTime - The server time of the last value change.
2043
+ */
2044
+ updateValue(value, lastChangedTime) {
2045
+ this.decodedValue = this.decodeValue(value);
2046
+ super.updateValue(value, lastChangedTime);
2047
+ }
2048
+ // ------------- //
2049
+ // ANNOUNCEMENTS //
2050
+ // ------------- //
2051
+ /**
2052
+ * Marks the topic as announced. This should only be called by the PubSubClient.
2053
+ * @param params - The parameters of the announcement.
2054
+ */
2055
+ announce(params) {
2056
+ super.announce(params);
2057
+ const typeString = params.type;
2058
+ // Check if this is a protobuf topic
2059
+ // Skip schema topics (they contain FileDescriptorProto and shouldn't be decoded)
2060
+ if (!this.name.startsWith('/.schema/')) {
2061
+ // Extract message name from type string
2062
+ let messageName;
2063
+ if (typeString.startsWith('proto:')) {
2064
+ // Type is like "proto:frc.Pose2d" - extract "frc.Pose2d"
2065
+ messageName = typeString.substring(6); // Remove "proto:" prefix
2066
+ }
2067
+ else if (typeString === 'protobuf') {
2068
+ // Type is just "protobuf" - try to infer from topic name
2069
+ // This is a fallback; ideally the type should be "proto:MessageName"
2070
+ messageName = this.name.split('/').pop(); // Use last segment of topic name
2071
+ }
2072
+ if (messageName) {
2073
+ this._protobufMessageName = messageName;
2074
+ // Fetch schema asynchronously
2075
+ this.fetchProtobufSchema(messageName);
2076
+ }
2077
+ }
2078
+ }
2079
+ /**
2080
+ * Fetches the protobuf schema for this topic.
2081
+ * @param messageName - The protobuf message name.
2082
+ */
2083
+ fetchProtobufSchema(messageName) {
2084
+ const messageType = this.client.protobufSchemaManager.fetchMessageType(messageName);
2085
+ if (messageType) {
2086
+ this._protobufMessageType = messageType;
2087
+ }
2088
+ }
2089
+ /**
2090
+ * Loads a proto schema from a file path (Node.js only).
2091
+ * @param protoFilePath - Path to the .proto file.
2092
+ */
2093
+ async loadProtoSchema(protoFilePath) {
2094
+ try {
2095
+ const { messageName, root } = await this.client.protobufSchemaManager.registerSchema(protoFilePath);
2096
+ this.assertMessageTypeUnchanged(messageName);
2097
+ this._protobufMessageName = messageName;
2098
+ this._messageTypeString = `proto:${messageName}`;
2099
+ const messageType = root.lookupType(messageName);
2100
+ if (messageType) {
2101
+ this._protobufMessageType = messageType;
2102
+ }
2103
+ }
2104
+ catch (error) {
2105
+ throw new Error('Failed to load proto schema', { cause: error });
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Loads a proto schema from an in-memory source string.
2110
+ * @param protoSource - Contents of a .proto file.
2111
+ */
2112
+ async loadProtoSource(protoSource) {
2113
+ try {
2114
+ const { messageName, root } = this.client.protobufSchemaManager.parseSource(protoSource);
2115
+ this._protobufMessageName = messageName;
2116
+ this._messageTypeString = `proto:${messageName}`;
2117
+ this._protobufMessageType = root.lookupType(messageName);
2118
+ await this.client.protobufSchemaManager.registerSchemaFromSource(protoSource);
2119
+ }
2120
+ catch (error) {
2121
+ throw new Error('Failed to parse proto source', { cause: error });
2122
+ }
2123
+ }
2124
+ /**
2125
+ * Applies a prebuilt protobufjs Type for encode/decode.
2126
+ * @param messageType - The protobufjs Type.
2127
+ */
2128
+ applyMessageType(messageType) {
2129
+ const normalize = (name) => (name.startsWith('.') ? name.slice(1) : name);
2130
+ const messageName = normalize(messageType.fullName);
2131
+ this._protobufMessageName = messageName;
2132
+ this._messageTypeString = `proto:${messageName}`;
2133
+ this._protobufMessageType = messageType;
2134
+ }
2135
+ // ----------- //
2136
+ // SUBSCRIBING //
2137
+ // ----------- //
2138
+ // ---------- //
2139
+ // PUBLISHING //
2140
+ // ---------- //
2141
+ /**
2142
+ * Publishes the topic.
2143
+ * If a proto file path was provided, registers the schema first.
2144
+ * @param properties - The properties to publish the topic with.
2145
+ * @param id - The UID of the publisher. You must verify that the ID is not already in use.
2146
+ * @returns A promise that resolves when the topic is published.
2147
+ */
2148
+ async publish(properties = {}, id) {
2149
+ // Use unified in-flight protection from PubSubClient
2150
+ // Key format: "publish:" prefix to avoid conflicts with schema registrations
2151
+ const operationKey = `publish:${this.name}`;
2152
+ return this.client.getOrCreateInFlightOperation(operationKey, async () => {
2153
+ // Check if already publisher inside the in-flight operation to prevent race conditions
2154
+ if (this.publisher) {
2155
+ pubsubLogger.debug('Publish skipped', { topicName: this.name, reason: 'already publisher' });
2156
+ return;
2157
+ }
2158
+ // If a schema source is provided and schema not yet registered, register it first
2159
+ if (!this._schemaRegistered) {
2160
+ try {
2161
+ if (this._providedMessageType) {
2162
+ await this.client.protobufSchemaManager.registerSchemaFromType(this._providedMessageType);
2163
+ this._schemaRegistered = true;
2164
+ }
2165
+ else if (this._protoSource) {
2166
+ await this.loadProtoSource(this._protoSource);
2167
+ this._schemaRegistered = true;
2168
+ }
2169
+ else if (this._protoFilePath) {
2170
+ await this.loadProtoSchema(this._protoFilePath);
2171
+ this._schemaRegistered = true;
2172
+ }
2173
+ }
2174
+ catch (error) {
2175
+ throw new Error('Failed to register protobuf schema before publishing', { cause: error });
2176
+ }
2177
+ }
2178
+ // Use the message type string if available, otherwise fall back to generic 'protobuf'
2179
+ const typeString = this._messageTypeString || this.typeInfo[1];
2180
+ // Get or generate pubuid
2181
+ const pubuid = id ?? this.client.messenger.getNextPubUID();
2182
+ this._pubuid = pubuid;
2183
+ this._publishProperties = properties;
2184
+ pubsubLogger.debug('Topic published', { topicName: this.name, pubuid, properties, type: typeString });
2185
+ // Publish with the correct type string using messenger directly
2186
+ const publishParams = {
2187
+ type: typeString,
2188
+ name: this.name,
2189
+ pubuid,
2190
+ properties,
2191
+ };
2192
+ return await this.client.messenger.publish(publishParams);
2193
+ });
2194
+ }
2195
+ }
2196
+
2197
+ /**
2198
+ * JSON topic: public value is a parsed object; wire format is a JSON string with type `'json'`.
2199
+ */
2200
+ class NetworkTablesJsonTopic extends NetworkTablesTopic {
2201
+ /**
2202
+ * Creates a new JSON topic.
2203
+ * @param client - The client that owns the topic.
2204
+ * @param name - The name of the topic.
2205
+ * @param defaultValue - The default public value of the topic.
2206
+ * @param options - Optional runtime validator for parsed JSON.
2207
+ */
2208
+ constructor(client, name, defaultValue, options) {
2209
+ super(client, name, NetworkTablesTypeInfos.kJson, undefined);
2210
+ if (this.decodedValue === undefined) {
2211
+ this.decodedValue = null;
2212
+ }
2213
+ this.applyOptions({ defaultValue, validator: options?.validator });
2214
+ }
2215
+ /**
2216
+ * Applies options to this topic. Used when returning a cached topic from getJsonTopic.
2217
+ * A later defaultValue is ignored once a value exists (matches createTopic).
2218
+ * A later `validator` replaces any previous validator because the topic is a singleton.
2219
+ * @param options - The options to apply.
2220
+ */
2221
+ applyOptions(options) {
2222
+ if (options?.validator !== undefined) {
2223
+ this._validator = options.validator;
2224
+ }
2225
+ if (options?.defaultValue !== undefined && this.decodedValue == null) {
2226
+ this.decodedValue = options.defaultValue;
2227
+ }
2228
+ }
2229
+ maybeValidate(value) {
2230
+ return (this._validator ? this._validator.parse(value) : value);
2231
+ }
2232
+ /**
2233
+ * Gets the parsed JSON value of the topic.
2234
+ * @returns The parsed JSON object, or null if no value has been set.
2235
+ */
2236
+ getValue() {
2237
+ return this.decodedValue;
2238
+ }
2239
+ /**
2240
+ * Sets the JSON value of the topic. The value is serialized with JSON.stringify for the wire.
2241
+ * @param value - The object to publish.
2242
+ */
2243
+ setValue(value) {
2244
+ if (!this.publisher) {
2245
+ pubsubLogger.debug('Publisher check failed before setValue', { topicName: this.name });
2246
+ throw new Error('Cannot set value on topic without being the publisher');
2247
+ }
2248
+ const validated = this.maybeValidate(value);
2249
+ const encoded = JSON.stringify(validated);
2250
+ this.decodedValue = validated;
2251
+ this.setWireValue(encoded);
2252
+ this.afterSetWireValue();
2253
+ }
2254
+ /**
2255
+ * Updates the value of the topic from the wire JSON string.
2256
+ * This should only be called by the PubSubClient.
2257
+ * @param value - The wire-format JSON string.
2258
+ * @param lastChangedTime - The server time of the last value change.
2259
+ */
2260
+ updateValue(value, lastChangedTime) {
2261
+ const parsed = JSON.parse(value);
2262
+ if (typeof parsed !== 'object' || parsed === null) {
2263
+ throw new Error(`Bad JSON value: ${value}`);
2264
+ }
2265
+ this.decodedValue = this.maybeValidate(parsed);
2266
+ super.updateValue(value, lastChangedTime);
2267
+ }
2268
+ }
2269
+
2270
+ /** SystemCore mDNS name (WPILib `SetServerFixed`). Use as a URI, not as a team mapping. */
2271
+ const SYSTEMCORE_MDNS_HOST = 'robot.local';
2272
+ const ROBORIO_MDNS = /^roborio-(\d+)-frc\.local$/i;
2273
+ const TEAM_IP = /^10\.(\d{1,3})\.(\d{1,3})\.2$/;
2274
+ /** WPILib maximum team number for `10.TE.AM.2` addressing. */
2275
+ const MAX_TEAM_NUMBER = 25599;
2276
+ /**
2277
+ * Field/radio IPv4 for a team (`10.TE.AM.2`), matching WPILib `SetServerTeam`.
2278
+ * Team 973 → `10.9.73.2`.
2279
+ */
2280
+ function getTeamIpAddress(team) {
2281
+ const n = Math.trunc(team);
2282
+ if (!Number.isFinite(n) || n < 1 || n > MAX_TEAM_NUMBER) {
2283
+ throw new Error(`Team number must be an integer from 1 to ${MAX_TEAM_NUMBER}`);
2284
+ }
2285
+ return `10.${Math.floor(n / 100)}.${n % 100}.2`;
2286
+ }
2287
+ /**
2288
+ * Hostname used for a team-number connection.
2289
+ * RoboRIO (default): `roborio-{team}-frc.local`.
2290
+ * SystemCore: `10.TE.AM.2` (WPILib `SetServerTeam`). Use {@link SYSTEMCORE_MDNS_HOST} as a URI for `robot.local`.
2291
+ */
2292
+ function getRobotAddress(team, platform = 'roborio') {
2293
+ if (platform === 'systemcore') {
2294
+ return getTeamIpAddress(team);
2295
+ }
2296
+ return `roborio-${team}-frc.local`;
2297
+ }
2298
+ /** Detects a RoboRIO mDNS name or SystemCore team IP. Other hosts return `{ platform: null, team: null }`. */
2299
+ function parseRobotAddress(host) {
2300
+ const robo = host.match(ROBORIO_MDNS);
2301
+ if (robo) {
2302
+ return { platform: 'roborio', team: Number(robo[1]) };
2303
+ }
2304
+ const ip = host.match(TEAM_IP);
2305
+ if (ip) {
2306
+ const te = Number(ip[1]);
2307
+ const am = Number(ip[2]);
2308
+ if (te <= 255 && am <= 99) {
2309
+ const team = te * 100 + am;
2310
+ if (team >= 1 && team <= MAX_TEAM_NUMBER && getTeamIpAddress(team) === host) {
2311
+ return { platform: 'systemcore', team };
2312
+ }
2313
+ }
2314
+ }
2315
+ return { platform: null, team: null };
2316
+ }
2317
+ /**
2318
+ * Class for holding utility functions.
2319
+ */
2320
+ class Util {
2321
+ /**
2322
+ * Get the DOM time in microseconds.
2323
+ * @returns The current microseconds of the DOM.
2324
+ */
2325
+ static getMicros() {
2326
+ return performance.now() * 1000;
2327
+ }
2328
+ /**
2329
+ * Given a number, determine if it is a double
2330
+ * @param x - A number.
2331
+ * @returns Whether it is a double.
2332
+ */
2333
+ static isDouble(x) {
2334
+ if (typeof x === 'number' && Number.isFinite(x) && !Number.isInteger(x)) {
2335
+ return true;
2336
+ }
2337
+ return false;
2338
+ }
2339
+ /**
2340
+ * Create a binary message from a topic.
2341
+ * @param pubuid - The topic's publisher UID.
2342
+ * @param timestamp - The timestamp of the message, matching the server.
2343
+ * @param data - The data.
2344
+ * @param typeInfo - The type info.
2345
+ * @returns The binary message.
2346
+ */
2347
+ static createBinaryMessage(pubuid, timestamp, data, typeInfo) {
2348
+ return [pubuid, timestamp, typeInfo[0], data];
2349
+ }
2350
+ /**
2351
+ * Create a server URL for connecting to the robot.
2352
+ * @param uri - The URI of the robot.
2353
+ * @param port - The port of NT server on the robot.
2354
+ * @returns The server URL with a unique client ID.
2355
+ */
2356
+ static createServerUrl(uri, port) {
2357
+ return `ws://${uri}:${port}/nt/ntcore-ts-${Math.floor(Math.random() * 1000)}`;
2358
+ }
2359
+ /**
2360
+ * Get the hostname of a robot for a team number.
2361
+ * @param team - The team number.
2362
+ * @param platform - RoboRIO mDNS (default) or SystemCore team IP.
2363
+ * @returns The hostname or IPv4 address.
2364
+ */
2365
+ static getRobotAddress(team, platform = 'roborio') {
2366
+ return getRobotAddress(team, platform);
2367
+ }
2368
+ }
2369
+
2370
+ /** Socket for NetworkTables 4.1 */
2371
+ class NetworkTablesSocket {
2372
+ get websocket() {
2373
+ return this._websocket;
2374
+ }
2375
+ /**
2376
+ * Creates a new NetworkTables socket.
2377
+ * @param serverUrl - The URL of the server to connect to.
2378
+ * @param onSocketOpen - Called when the socket is opened.
2379
+ * @param onSocketClose - Called when the socket is closed.
2380
+ * @param onTopicUpdate - Called when a topic is updated.
2381
+ * @param onAnnounce - Called when a topic is announced.
2382
+ * @param onUnannounce - Called when a topic is unannounced.
2383
+ * @param onProperties - Called when a topic's properties are updated.
2384
+ * @param autoConnect - Whether to automatically connect to the server.
2385
+ */
2386
+ constructor(serverUrl, onSocketOpen, onSocketClose, onTopicUpdate, onAnnounce, onUnannounce, onProperties, autoConnect) {
2387
+ this.connectionListeners = new Set();
2388
+ this.offset = 0;
2389
+ this.bestRtt = -1;
2390
+ /** True after the first valid RTT reply; required before control frames and strong value publishes. */
2391
+ this.clockSynced = false;
2392
+ this.connectionAttemptCount = 0;
2393
+ this.lastConnectionLogTime = 0;
2394
+ this.tearingDown = false;
2395
+ this.connectionWaiters = new Map();
2396
+ this.autoConnect = true;
2397
+ this.messageQueue = [];
2398
+ // Connect to the server using the provided URL
2399
+ this._websocket = new WebSocket(serverUrl, [NetworkTablesSocket.PROTOCOL_V4_1, NetworkTablesSocket.PROTOCOL_V4_0]);
2400
+ socketLogger.debug('Connection attempt started', {
2401
+ serverUrl,
2402
+ protocols: [NetworkTablesSocket.PROTOCOL_V4_1, NetworkTablesSocket.PROTOCOL_V4_0],
2403
+ autoConnect,
2404
+ });
2405
+ this.serverUrl = serverUrl;
2406
+ this.onSocketOpen = onSocketOpen;
2407
+ this.onSocketClose = onSocketClose;
2408
+ this.onTopicUpdate = onTopicUpdate;
2409
+ this.onAnnounce = onAnnounce;
2410
+ this.onUnannounce = onUnannounce;
2411
+ this.onProperties = onProperties;
2412
+ this.autoConnect = autoConnect;
2413
+ this.init();
2414
+ }
2415
+ /**
2416
+ * Gets the instance of the NetworkTables socket.
2417
+ * @param serverUrl - The URL of the server to connect to.
2418
+ * @param onSocketOpen - Called when the socket is opened.
2419
+ * @param onSocketClose - Called when the socket is closed.
2420
+ * @param onTopicUpdate - Called when a topic is updated.
2421
+ * @param onAnnounce - Called when a topic is announced.
2422
+ * @param onUnannounce - Called when a topic is unannounced.
2423
+ * @param onProperties - Called when a topic's properties are updated.
2424
+ * @param autoConnect - Whether to automatically connect to the server.
2425
+ * @returns The instance of the NetworkTables socket.
2426
+ */
2427
+ static getInstance(serverUrl, onSocketOpen, onSocketClose, onTopicUpdate, onAnnounce, onUnannounce, onProperties, autoConnect = true) {
2428
+ let instance = this.instances.get(serverUrl);
2429
+ if (!instance) {
2430
+ instance = new this(serverUrl, onSocketOpen, onSocketClose, onTopicUpdate, onAnnounce, onUnannounce, onProperties, autoConnect);
2431
+ this.instances.set(serverUrl, instance);
2432
+ }
2433
+ return instance;
2434
+ }
2435
+ /**
2436
+ * Initialization. This is done outside of the constructor to allow for
2437
+ * the socket to refresh itself.
2438
+ */
2439
+ init() {
2440
+ this.clearHeartbeatInterval();
2441
+ this.clearIdleTimer();
2442
+ if (this._websocket) {
2443
+ const ws = this._websocket;
2444
+ // Open handler
2445
+ this._websocket.onopen = () => {
2446
+ if (this._websocket !== ws)
2447
+ return;
2448
+ // Reset connection attempt counter on successful connection
2449
+ if (this.connectionAttemptCount > 0) {
2450
+ socketLogger.info('Connection restored', {
2451
+ attempts: this.connectionAttemptCount,
2452
+ protocol: this._websocket.protocol === 'v4.1.networktables.first.wpi.edu' ? 'NT 4.1' : 'NT 4.0',
2453
+ });
2454
+ this.connectionAttemptCount = 0;
2455
+ }
2456
+ else {
2457
+ if (this._websocket.protocol === 'v4.1.networktables.first.wpi.edu') {
2458
+ socketLogger.info('Connected on NT 4.1');
2459
+ }
2460
+ else {
2461
+ socketLogger.info('Connected on NT 4.0');
2462
+ }
2463
+ }
2464
+ // Spec: timestamp sync immediately after connect, before other control messages.
2465
+ this.heartbeat();
2466
+ this.resetIdleTimer();
2467
+ // Periodic RTT pings for keepalive. Spec says 4.1 uses WebSocket PING, but Node/JS clients
2468
+ // cannot send PING, so we use binary RTT for both 4.0 and 4.1.
2469
+ this.heartbeatInterval = setInterval(() => {
2470
+ if (this.isSocketOpen()) {
2471
+ this.heartbeat();
2472
+ }
2473
+ }, NetworkTablesSocket.RTT_PERIOD_MS);
2474
+ };
2475
+ // Close handler
2476
+ this._websocket.onclose = (e) => {
2477
+ if (this._websocket !== ws)
2478
+ return;
2479
+ // Notify client and cancel heartbeat
2480
+ this.resetClockSync();
2481
+ this.clearHeartbeatInterval();
2482
+ this.clearIdleTimer();
2483
+ this.updateConnectionListeners();
2484
+ this.onSocketClose();
2485
+ // Increment connection attempt counter
2486
+ this.connectionAttemptCount++;
2487
+ const now = Date.now();
2488
+ const timeSinceLastLog = now - this.lastConnectionLogTime;
2489
+ const shouldLogFullDetails = this.connectionAttemptCount === 1 || timeSinceLastLog >= NetworkTablesSocket.CONNECTION_LOG_INTERVAL;
2490
+ if (shouldLogFullDetails) {
2491
+ // Log full details on first attempt or every 10 seconds
2492
+ socketLogger.warn('Connection lost', {
2493
+ code: e.code,
2494
+ reason: e.reason || 'No reason provided',
2495
+ queuedMessages: this.messageQueue.length,
2496
+ attempt: this.connectionAttemptCount,
2497
+ });
2498
+ this.lastConnectionLogTime = now;
2499
+ }
2500
+ else {
2501
+ // Log concise message for repeated attempts
2502
+ socketLogger.debug('Reconnection attempt', {
2503
+ attempt: this.connectionAttemptCount,
2504
+ nextFullLogIn: Math.ceil((NetworkTablesSocket.CONNECTION_LOG_INTERVAL - timeSinceLastLog) / 1000) + 's',
2505
+ });
2506
+ }
2507
+ if (this.autoConnect && !this.tearingDown) {
2508
+ this.clearReconnectTimer();
2509
+ this.reconnectTimer = setTimeout(() => {
2510
+ this.reconnectTimer = undefined;
2511
+ if (!this.autoConnect || this.tearingDown)
2512
+ return;
2513
+ this._websocket = new WebSocket(this.serverUrl, [
2514
+ NetworkTablesSocket.PROTOCOL_V4_1,
2515
+ NetworkTablesSocket.PROTOCOL_V4_0,
2516
+ ]);
2517
+ this.init();
2518
+ }, NetworkTablesSocket.RECONNECT_TIMEOUT);
2519
+ }
2520
+ };
2521
+ this._websocket.binaryType = 'arraybuffer';
2522
+ // Set up event listeners for messages and errors
2523
+ this._websocket.onmessage = (event) => {
2524
+ if (this._websocket !== ws)
2525
+ return;
2526
+ this.onMessage(event);
2527
+ };
2528
+ this._websocket.onerror = (event) => {
2529
+ if (this._websocket !== ws)
2530
+ return;
2531
+ this.onError(event);
2532
+ };
2533
+ }
2534
+ }
2535
+ /**
2536
+ * Reset the socket and reconnect to the server.
2537
+ * @param serverUrl - The URL of the server to connect to.
2538
+ */
2539
+ reinstantiate(serverUrl) {
2540
+ socketLogger.info('Socket reinstantiation', { oldUrl: this.serverUrl, newUrl: serverUrl });
2541
+ this.clearReconnectTimer();
2542
+ this.clearHeartbeatInterval();
2543
+ this.clearIdleTimer();
2544
+ this.resetClockSync();
2545
+ const previous = this._websocket;
2546
+ previous.onopen = null;
2547
+ previous.onclose = null;
2548
+ previous.onmessage = null;
2549
+ previous.onerror = null;
2550
+ previous.close();
2551
+ this.serverUrl = serverUrl;
2552
+ this.updateConnectionListeners();
2553
+ this._websocket = new WebSocket(this.serverUrl, [
2554
+ NetworkTablesSocket.PROTOCOL_V4_1,
2555
+ NetworkTablesSocket.PROTOCOL_V4_0,
2556
+ ]);
2557
+ this.init();
2558
+ }
2559
+ /**
2560
+ * Returns whether the socket is open and the client clock is synchronized with the server.
2561
+ * @returns Whether the robot connection is ready for control and value frames.
2562
+ */
2563
+ isConnected() {
2564
+ return this.isSocketOpen() && this.clockSynced;
2565
+ }
2566
+ /**
2567
+ * Returns whether the socket is connecting (including waiting for the first RTT).
2568
+ * @returns Whether the robot connection is not yet ready.
2569
+ */
2570
+ isConnecting() {
2571
+ return this._websocket.readyState === WebSocket.CONNECTING || (this.isSocketOpen() && !this.clockSynced);
2572
+ }
2573
+ /**
2574
+ * Wait for the socket to connect.
2575
+ * @returns A promise that resolves when the socket is connected.
2576
+ */
2577
+ waitForConnection() {
2578
+ return new Promise((resolve, reject) => {
2579
+ if (this.tearingDown) {
2580
+ reject(new Error('Socket closed'));
2581
+ return;
2582
+ }
2583
+ if (this.isConnected()) {
2584
+ resolve();
2585
+ return;
2586
+ }
2587
+ const listener = () => {
2588
+ if (this.isConnected()) {
2589
+ this.connectionWaiters.delete(reject);
2590
+ this.removeConnectionListener(listener);
2591
+ resolve();
2592
+ }
2593
+ };
2594
+ this.connectionWaiters.set(reject, listener);
2595
+ this.addConnectionListener(listener);
2596
+ });
2597
+ }
2598
+ /**
2599
+ * Create a connection listener.
2600
+ * @param callback - Called when the connection state changes.
2601
+ * @param immediateNotify - When true, the callback is also invoked with the current connection state
2602
+ * on a microtask after this method returns, so the returned disposer can be used safely from that
2603
+ * first invocation (unlike a synchronous notify before return).
2604
+ * @returns A function that removes the listener.
2605
+ */
2606
+ addConnectionListener(callback, immediateNotify) {
2607
+ this.connectionListeners.add(callback);
2608
+ socketLogger.debug('Connection listener added', {
2609
+ totalListeners: this.connectionListeners.size,
2610
+ immediateNotify,
2611
+ currentState: this.isConnected(),
2612
+ });
2613
+ if (immediateNotify) {
2614
+ queueMicrotask(() => {
2615
+ if (!this.connectionListeners.has(callback))
2616
+ return;
2617
+ callback(this.isConnected());
2618
+ });
2619
+ }
2620
+ return () => this.removeConnectionListener(callback);
2621
+ }
2622
+ /**
2623
+ * Remove a connection listener.
2624
+ * @param callback - The callback to remove.
2625
+ */
2626
+ removeConnectionListener(callback) {
2627
+ this.connectionListeners.delete(callback);
2628
+ socketLogger.debug('Connection listener removed', { totalListeners: this.connectionListeners.size });
2629
+ }
2630
+ /**
2631
+ * Get the best round-trip time to the server in milliseconds.
2632
+ * @returns RTT in ms, or -1 if not connected or not yet measured.
2633
+ */
2634
+ getBestRttMs() {
2635
+ if (!this.isConnected() || this.bestRtt < 0)
2636
+ return -1;
2637
+ return Math.round(this.bestRtt / 1000);
2638
+ }
2639
+ /**
2640
+ * Updates all connection listeners with the current connection state.
2641
+ */
2642
+ updateConnectionListeners() {
2643
+ this.connectionListeners.forEach((callback) => callback(this.isConnected()));
2644
+ }
2645
+ /**
2646
+ * Stops auto-reconnecting to the server.
2647
+ */
2648
+ stopAutoConnect() {
2649
+ socketLogger.info('Auto-connect disabled');
2650
+ this.autoConnect = false;
2651
+ this.clearReconnectTimer();
2652
+ }
2653
+ /**
2654
+ * Starts auto-reconnecting to the server. If the socket is closed, opens a new connection.
2655
+ */
2656
+ startAutoConnect() {
2657
+ socketLogger.info('Auto-connect enabled');
2658
+ this.autoConnect = true;
2659
+ if (this.tearingDown)
2660
+ return;
2661
+ const readyState = this._websocket.readyState;
2662
+ if (readyState === WebSocket.CLOSED || readyState === WebSocket.CLOSING) {
2663
+ this._websocket = new WebSocket(this.serverUrl, [
2664
+ NetworkTablesSocket.PROTOCOL_V4_1,
2665
+ NetworkTablesSocket.PROTOCOL_V4_0,
2666
+ ]);
2667
+ this.init();
2668
+ }
2669
+ }
2670
+ clearReconnectTimer() {
2671
+ if (this.reconnectTimer != null) {
2672
+ clearTimeout(this.reconnectTimer);
2673
+ this.reconnectTimer = undefined;
2674
+ }
2675
+ }
2676
+ rejectConnectionWaiters() {
2677
+ const error = new Error('Socket closed');
2678
+ for (const [reject, listener] of this.connectionWaiters) {
2679
+ this.removeConnectionListener(listener);
2680
+ reject(error);
2681
+ }
2682
+ this.connectionWaiters.clear();
2683
+ }
2684
+ /**
2685
+ * Handle a message from the websocket.
2686
+ * @param event - The message event.
2687
+ */
2688
+ onMessage(event) {
2689
+ this.resetIdleTimer();
2690
+ if (event.data instanceof ArrayBuffer || event.data instanceof Uint8Array) {
2691
+ socketLogger.debug('Binary frame received', { size: event.data.byteLength });
2692
+ this.handleBinaryFrame(event.data);
2693
+ }
2694
+ else {
2695
+ socketLogger.debug('Text frame received', {
2696
+ size: typeof event.data === 'string' ? event.data.length : 'unknown',
2697
+ });
2698
+ this.handleTextFrame(event.data);
2699
+ }
2700
+ }
2701
+ /**
2702
+ * Handle an error from the websocket.
2703
+ * @param event - The error event.
2704
+ */
2705
+ onError(event) {
2706
+ // WebSocket errors typically precede close events, so we check if we should log
2707
+ // The close handler will log the main connection failure message
2708
+ // We only log error details if we haven't logged recently to avoid duplicate messages
2709
+ const now = Date.now();
2710
+ const timeSinceLastLog = now - this.lastConnectionLogTime;
2711
+ // Only log if it's been a while since the last connection log (close handler handles most logging)
2712
+ if (timeSinceLastLog >= NetworkTablesSocket.CONNECTION_LOG_INTERVAL) {
2713
+ const errorMessage = typeof ErrorEvent !== 'undefined' && event instanceof ErrorEvent ? event.message : 'Connection error';
2714
+ socketLogger.debug('WebSocket error occurred', {
2715
+ error: errorMessage,
2716
+ note: 'Connection close details will follow',
2717
+ });
2718
+ }
2719
+ // Otherwise, the close handler will log the full details, so we suppress this
2720
+ }
2721
+ /**
2722
+ * Handle a binary frame from the websocket.
2723
+ * Uses sync decodeMulti (not async streams): we receive one complete frame per
2724
+ * WebSocket message, so decodeMultiStream would add async overhead without benefit.
2725
+ * @param frame - The frame.
2726
+ */
2727
+ handleBinaryFrame(frame) {
2728
+ let messageCount = 0;
2729
+ let heartbeatCount = 0;
2730
+ let topicUpdateCount = 0;
2731
+ let skippedCount = 0;
2732
+ for (const decoded of decodeMulti(frame)) {
2733
+ try {
2734
+ const message = msgPackSchema.parse(decoded);
2735
+ socketLogger.trace('Message schema validated', { topicId: message[0], typeNum: message[2] });
2736
+ const messageData = {
2737
+ topicId: message[0],
2738
+ serverTime: message[1],
2739
+ typeNum: message[2],
2740
+ value: message[3],
2741
+ };
2742
+ messageCount++;
2743
+ if (messageData.topicId === -1) {
2744
+ heartbeatCount++;
2745
+ this.handleRTT(messageData.serverTime, messageData.value);
2746
+ }
2747
+ else {
2748
+ topicUpdateCount++;
2749
+ this.onTopicUpdate(messageData);
2750
+ }
2751
+ }
2752
+ catch (e) {
2753
+ skippedCount++;
2754
+ socketLogger.warn('Skipping malformed or invalid message in binary frame', {
2755
+ error: String(e),
2756
+ skippedInFrame: skippedCount,
2757
+ });
2758
+ }
2759
+ }
2760
+ socketLogger.debug('Binary frame processed', {
2761
+ messageCount,
2762
+ heartbeatCount,
2763
+ topicUpdateCount,
2764
+ skippedCount,
2765
+ });
2766
+ }
2767
+ /**
2768
+ * Handle a text frame from the websocket.
2769
+ * @param frame - The frame.
2770
+ */
2771
+ handleTextFrame(frame) {
2772
+ const messageData = JSON.parse(frame);
2773
+ const messages = messageSchema.parse(messageData);
2774
+ for (const message of messages) {
2775
+ switch (message.method) {
2776
+ case 'announce':
2777
+ this.onAnnounce(message.params);
2778
+ break;
2779
+ case 'unannounce':
2780
+ this.onUnannounce(message.params);
2781
+ break;
2782
+ case 'properties':
2783
+ this.onProperties(message.params);
2784
+ break;
2785
+ default:
2786
+ socketLogger.warn('Client does not handle message method', { method: message.method });
2787
+ }
2788
+ }
2789
+ socketLogger.debug('Text frame processed', { messageCount: messages.length });
2790
+ }
2791
+ /**
2792
+ * Send a text frame to the server.
2793
+ * @param message - The message to send.
2794
+ */
2795
+ sendTextFrame(message) {
2796
+ // Send the message to the server
2797
+ if (this.isConnected()) {
2798
+ socketLogger.debug('Text frame sent', { method: message.method });
2799
+ this._websocket.send(JSON.stringify([message]));
2800
+ }
2801
+ else {
2802
+ socketLogger.debug('Message queued', {
2803
+ method: message.method,
2804
+ reason: 'not connected',
2805
+ queueSize: this.messageQueue.length,
2806
+ });
2807
+ this.messageQueue.push(JSON.stringify([message]));
2808
+ }
2809
+ }
2810
+ /**
2811
+ * Function to send queued messages whenever the WebSocket connection is opened
2812
+ */
2813
+ sendQueuedMessages() {
2814
+ if (this.isConnected()) {
2815
+ const queuedCount = this.messageQueue.length;
2816
+ if (queuedCount > 0) {
2817
+ socketLogger.debug('Sending queued messages', { count: queuedCount });
2818
+ }
2819
+ while (this.messageQueue.length > 0) {
2820
+ const message = this.messageQueue.shift();
2821
+ if (message) {
2822
+ this._websocket.send(message);
2823
+ }
2824
+ }
2825
+ }
2826
+ }
2827
+ /**
2828
+ * Send a message to a topic.
2829
+ * @param pubuid - The topic's publisher UID.
2830
+ * @param value - The value to send.
2831
+ * @param typeInfo - The type info for the value.
2832
+ * @returns The time the message was sent, or -1 if not connected.
2833
+ */
2834
+ sendValueToTopic(pubuid, value, typeInfo) {
2835
+ if (!this.isSocketOpen()) {
2836
+ socketLogger.debug('sendValueToTopic skipped (not connected)', { pubuid, typeNum: typeInfo[0] });
2837
+ return -1;
2838
+ }
2839
+ // Spec: do not send strong (non-RTT) values until the client clock is synchronized.
2840
+ if (pubuid !== -1 && !this.clockSynced) {
2841
+ socketLogger.debug('sendValueToTopic skipped (awaiting clock sync)', { pubuid, typeNum: typeInfo[0] });
2842
+ return -1;
2843
+ }
2844
+ const time = pubuid === -1 ? 0 : Math.ceil(this.getServerTime());
2845
+ const message = Util.createBinaryMessage(pubuid, time, value, typeInfo);
2846
+ const cleanMsg = msgPackSchema.parse(message);
2847
+ const framePubuid = cleanMsg[0];
2848
+ const typeNum = cleanMsg[2];
2849
+ // Send the message to the server (binary frames are never queued).
2850
+ socketLogger.debug('Binary frame sent', { pubuid: framePubuid, typeNum });
2851
+ this._websocket.send(encode(cleanMsg));
2852
+ return time;
2853
+ }
2854
+ /**
2855
+ * Send a timestamp ping to the server (topic id -1, timestamp 0, value = client microseconds).
2856
+ */
2857
+ heartbeat() {
2858
+ if (!this.isSocketOpen()) {
2859
+ return;
2860
+ }
2861
+ const clientTime = Math.round(Util.getMicros());
2862
+ socketLogger.debug('Heartbeat sent', { time: clientTime });
2863
+ const message = Util.createBinaryMessage(-1, 0, clientTime, NetworkTablesTypeInfos.kInteger);
2864
+ const cleanMsg = msgPackSchema.parse(message);
2865
+ this._websocket.send(encode(cleanMsg));
2866
+ }
2867
+ /**
2868
+ * Handle a round trip time message from the server.
2869
+ *
2870
+ * RTT is `now - echoed client time` (the MessagePack value). Clock offset is updated only when
2871
+ * this RTT is the best so far (Cristian's algorithm / NT spec).
2872
+ * @param serverTime - Server timestamp from the reply.
2873
+ * @param clientEcho - Client time echoed back from the ping.
2874
+ */
2875
+ handleRTT(serverTime, clientEcho) {
2876
+ if (typeof clientEcho !== 'number' || !Number.isFinite(clientEcho)) {
2877
+ socketLogger.warn('Ignoring RTT reply with non-numeric client echo', { clientEcho });
2878
+ return;
2879
+ }
2880
+ const now = Util.getMicros();
2881
+ const rtt = now - clientEcho;
2882
+ if (rtt < 0) {
2883
+ socketLogger.debug('Ignoring RTT with negative delta', { rtt, clientEcho, now });
2884
+ return;
2885
+ }
2886
+ const wasUpdated = this.bestRtt < 0 || rtt < this.bestRtt;
2887
+ if (wasUpdated) {
2888
+ this.bestRtt = rtt;
2889
+ this.offset = now - serverTime;
2890
+ }
2891
+ socketLogger.debug('RTT calculated', {
2892
+ rtt,
2893
+ bestRtt: this.bestRtt,
2894
+ offset: this.offset,
2895
+ serverTime,
2896
+ clientEcho,
2897
+ updated: wasUpdated,
2898
+ });
2899
+ this.completeHandshakeIfNeeded();
2900
+ }
2901
+ /**
2902
+ * Get the current server time.
2903
+ * @returns The current server time.
2904
+ */
2905
+ getServerTime() {
2906
+ const clientTime = Util.getMicros();
2907
+ const serverTime = clientTime - this.offset + this.bestRtt / 2;
2908
+ socketLogger.trace('Server time calculated', {
2909
+ clientTime,
2910
+ offset: this.offset,
2911
+ bestRtt: this.bestRtt,
2912
+ serverTime,
2913
+ });
2914
+ return serverTime;
2915
+ }
2916
+ isSocketOpen() {
2917
+ return this._websocket.readyState === WebSocket.OPEN;
2918
+ }
2919
+ resetClockSync() {
2920
+ this.clockSynced = false;
2921
+ this.bestRtt = -1;
2922
+ this.offset = 0;
2923
+ }
2924
+ completeHandshakeIfNeeded() {
2925
+ if (this.clockSynced || !this.isSocketOpen()) {
2926
+ return;
2927
+ }
2928
+ this.clockSynced = true;
2929
+ socketLogger.info('Robot Connected!');
2930
+ // Re-subscribe/re-publish before notifying listeners so value sends cannot race ahead of publish frames.
2931
+ this.onSocketOpen();
2932
+ this.sendQueuedMessages();
2933
+ this.updateConnectionListeners();
2934
+ }
2935
+ resetIdleTimer() {
2936
+ this.clearIdleTimer();
2937
+ if (this.tearingDown || !this.isSocketOpen()) {
2938
+ return;
2939
+ }
2940
+ this.idleTimer = setTimeout(() => {
2941
+ this.idleTimer = undefined;
2942
+ if (this.tearingDown || !this.isSocketOpen()) {
2943
+ return;
2944
+ }
2945
+ socketLogger.warn('No data for idle timeout, closing', { timeoutMs: NetworkTablesSocket.IDLE_TIMEOUT_MS });
2946
+ this._websocket.close();
2947
+ }, NetworkTablesSocket.IDLE_TIMEOUT_MS);
2948
+ }
2949
+ clearIdleTimer() {
2950
+ if (this.idleTimer != null) {
2951
+ clearTimeout(this.idleTimer);
2952
+ this.idleTimer = undefined;
2953
+ }
2954
+ }
2955
+ clearHeartbeatInterval() {
2956
+ if (this.heartbeatInterval != null) {
2957
+ clearInterval(this.heartbeatInterval);
2958
+ this.heartbeatInterval = undefined;
2959
+ }
2960
+ }
2961
+ /**
2962
+ * Close the websocket connection.
2963
+ */
2964
+ close() {
2965
+ this.tearingDown = true;
2966
+ this.stopAutoConnect();
2967
+ this.rejectConnectionWaiters();
2968
+ this.clearHeartbeatInterval();
2969
+ this.clearIdleTimer();
2970
+ this.resetClockSync();
2971
+ this._websocket.close();
2972
+ }
2973
+ /**
2974
+ * Removes this socket from the singleton map so a later getInstance can create a new one.
2975
+ */
2976
+ releaseInstance() {
2977
+ for (const [key, instance] of NetworkTablesSocket.instances) {
2978
+ if (instance === this) {
2979
+ NetworkTablesSocket.instances.delete(key);
2980
+ }
2981
+ }
2982
+ }
2983
+ }
2984
+ NetworkTablesSocket.instances = new Map();
2985
+ NetworkTablesSocket.PROTOCOL_V4_0 = 'networktables.first.wpi.edu';
2986
+ NetworkTablesSocket.PROTOCOL_V4_1 = 'v4.1.networktables.first.wpi.edu';
2987
+ NetworkTablesSocket.RECONNECT_TIMEOUT = 1000;
2988
+ /** Timestamp keepalive period when WebSocket PING cannot be sent (NT 4.0 and JS 4.1). */
2989
+ NetworkTablesSocket.RTT_PERIOD_MS = 1000;
2990
+ /** Disconnect if no server frames arrive. Spec: ~3s when using timestamp messages for aliveness. */
2991
+ NetworkTablesSocket.IDLE_TIMEOUT_MS = 3000;
2992
+ NetworkTablesSocket.CONNECTION_LOG_INTERVAL = 10000; // Log full details every 10 seconds
2993
+
2994
+ /** NetworkTables client. */
2995
+ class Messenger {
2996
+ /**
2997
+ * Gets the NetworkTablesSocket used by the Messenger.
2998
+ * @returns The NetworkTablesSocket used by the Messenger.
2999
+ */
3000
+ get socket() {
3001
+ return this._socket;
3002
+ }
3003
+ /**
3004
+ * Creates a new NetworkTables client.
3005
+ * @param serverUrl - The URL of the server to connect to.
3006
+ * @param onTopicUpdate - Called when a topic is updated.
3007
+ * @param onAnnounce - Called when a topic is announced.
3008
+ * @param onUnannounce - Called when a topic is unannounced.
3009
+ * @param onTopicProperties - Called when a topic's properties are updated.
3010
+ */
3011
+ constructor(serverUrl, onTopicUpdate, onAnnounce, onUnannounce, onTopicProperties) {
3012
+ this.publications = new Map();
3013
+ this.subscriptions = new Map();
3014
+ this._currentPubUID = 0;
3015
+ this._currentSubUID = 0;
3016
+ /**
3017
+ * Called when the socket opens.
3018
+ */
3019
+ this.onSocketOpen = () => {
3020
+ const subscriptionCount = this.subscriptions.size;
3021
+ const publicationCount = this.publications.size;
3022
+ messengerLogger.debug('Subscribing all topics', { count: subscriptionCount });
3023
+ // Send all subscriptions
3024
+ this.subscriptions.forEach((params) => {
3025
+ this.subscribe(params, true);
3026
+ });
3027
+ messengerLogger.debug('Publishing all topics', { count: publicationCount });
3028
+ // Re-send publish frames best-effort on reconnect.
3029
+ // Do not await announcements here (fire-and-forget) to avoid timers/rejections
3030
+ // outliving the reconnect event.
3031
+ this.publications.forEach((params) => {
3032
+ this.sendPublishFrames(params);
3033
+ });
3034
+ };
3035
+ /**
3036
+ * Called when the socket closes.
3037
+ */
3038
+ this.onSocketClose = () => { };
3039
+ this._onAnnounce = onAnnounce;
3040
+ this._socket = NetworkTablesSocket.getInstance(serverUrl, this.onSocketOpen, this.onSocketClose, onTopicUpdate, onAnnounce, onUnannounce, onTopicProperties);
3041
+ }
3042
+ /**
3043
+ * Gets the instance of the NetworkTables client.
3044
+ * @param serverUrl - The URL of the server to connect to. This is not needed after the first call.
3045
+ * @param onTopicUpdate - Called when a topic is updated.
3046
+ * @param onAnnounce - Called when a topic is announced.
3047
+ * @param onUnannounce - Called when a topic is unannounced.
3048
+ * @param onTopicProperties - Called when a topic's properties are updated.
3049
+ * @returns The instance of the NetworkTables client.
3050
+ */
3051
+ static getInstance(serverUrl, onTopicUpdate, onAnnounce, onUnannounce, onTopicProperties) {
3052
+ let instance = this._instances.get(serverUrl);
3053
+ if (!instance) {
3054
+ instance = new this(serverUrl, onTopicUpdate, onAnnounce, onUnannounce, onTopicProperties);
3055
+ this._instances.set(serverUrl, instance);
3056
+ }
3057
+ return instance;
3058
+ }
3059
+ /**
3060
+ * Reinstantiates the messenger by resetting the socket with a new URL.
3061
+ * @param serverUrl - The URL of the server to connect to.
3062
+ */
3063
+ reinstantiate(serverUrl) {
3064
+ messengerLogger.info('Reinstantiating messenger', {
3065
+ newUrl: serverUrl,
3066
+ subscriptionCount: this.subscriptions.size,
3067
+ publicationCount: this.publications.size,
3068
+ });
3069
+ this._socket.stopAutoConnect();
3070
+ this._socket.reinstantiate(serverUrl);
3071
+ this._socket.startAutoConnect();
3072
+ }
3073
+ parseAndFilterMessage(event, method) {
3074
+ if (typeof event.data === 'string') {
3075
+ messengerLogger.trace('Raw message received', { data: event.data });
3076
+ const messageData = JSON.parse(event.data);
3077
+ messengerLogger.trace('Message parsed from JSON', {
3078
+ messageCount: Array.isArray(messageData) ? messageData.length : 1,
3079
+ });
3080
+ const messages = messageSchema.parse(messageData);
3081
+ messengerLogger.trace('Message schema validated', { messageCount: messages.length });
3082
+ const filtered = messages.filter((msg) => msg.method === method);
3083
+ messengerLogger.trace('Message filtered by method', {
3084
+ method,
3085
+ inputCount: messages.length,
3086
+ outputCount: filtered.length,
3087
+ });
3088
+ return filtered;
3089
+ }
3090
+ return [];
3091
+ }
3092
+ /**
3093
+ * Publishes a topic to the server.
3094
+ * @param params - The publication parameters.
3095
+ * @param force - Whether to force the publication.
3096
+ * @returns The announcement parameters.
3097
+ */
3098
+ async publish(params, force) {
3099
+ return new Promise((resolve, reject) => {
3100
+ // Check if the topic is already published
3101
+ if (this.publications.has(params.pubuid) && !force) {
3102
+ messengerLogger.debug('Publish rejected', {
3103
+ topicName: params.name,
3104
+ pubuid: params.pubuid,
3105
+ reason: 'already published',
3106
+ });
3107
+ reject(new Error('Topic is already published'));
3108
+ return;
3109
+ }
3110
+ messengerLogger.debug('Publish request initiated', {
3111
+ topicName: params.name,
3112
+ pubuid: params.pubuid,
3113
+ timeout: 3000,
3114
+ force,
3115
+ });
3116
+ let timeoutId = null;
3117
+ let resolved = false;
3118
+ // Cleanup function to remove listener and clear timeouts
3119
+ const cleanup = () => {
3120
+ messengerLogger.trace('Cleanup: removing event listener and clearing timeouts', { topicName: params.name });
3121
+ this.socket.websocket.removeEventListener('message', wsHandler);
3122
+ if (timeoutId) {
3123
+ clearTimeout(timeoutId);
3124
+ messengerLogger.trace('Main timeout cleared', { topicName: params.name });
3125
+ }
3126
+ };
3127
+ // Listen for the announcement
3128
+ const resolver = (msg) => {
3129
+ if (resolved) {
3130
+ return;
3131
+ }
3132
+ messengerLogger.trace('Promise resolver called', { topicName: params.name, pubuid: params.pubuid });
3133
+ resolved = true;
3134
+ cleanup();
3135
+ // Add the topic to the list of published topics
3136
+ this.publications.set(params.pubuid, params);
3137
+ messengerLogger.debug('Topic published', { topicName: params.name, pubuid: params.pubuid });
3138
+ resolve(msg);
3139
+ };
3140
+ // Rejector function to cleanup before rejecting
3141
+ const rejector = (error) => {
3142
+ if (resolved) {
3143
+ return;
3144
+ }
3145
+ messengerLogger.trace('Promise rejector called', { topicName: params.name, error: error.message });
3146
+ resolved = true;
3147
+ cleanup();
3148
+ reject(error);
3149
+ };
3150
+ const wsHandler = (event) => {
3151
+ const messages = this.parseAndFilterMessage(event, 'announce');
3152
+ messengerLogger.debug('Messages filtered for announce', { count: messages.length });
3153
+ for (const message of messages) {
3154
+ // Match by name and pubuid
3155
+ // We published with a pubuid, so we require the announcement to have the same pubuid
3156
+ // This ensures we get the publish-triggered announcement, not a subscription-triggered one
3157
+ // (per spec, server must respond to publish with announce containing pubuid)
3158
+ const nameMatches = message.params.name === params.name;
3159
+ const pubuidMatches = message.params.pubuid === params.pubuid;
3160
+ if (nameMatches && pubuidMatches) {
3161
+ messengerLogger.debug('Announcement received', {
3162
+ topicName: message.params.name,
3163
+ pubuid: message.params.pubuid,
3164
+ matched: true,
3165
+ });
3166
+ // Notify topic before resolving so callers can assume "publish() resolved" => "topic announced"
3167
+ this._onAnnounce(message.params);
3168
+ resolver(message);
3169
+ break;
3170
+ }
3171
+ // Fallback: topic announced by name but pubuid differs (e.g. robot published first).
3172
+ // Server may have announced from another client before our publish was processed.
3173
+ // Treat as success: merge our pubuid so the topic marks us as publisher, then resolve.
3174
+ if (nameMatches && !resolved) {
3175
+ messengerLogger.debug('Announcement received (name match, pubuid override)', {
3176
+ topicName: message.params.name,
3177
+ serverPubuid: message.params.pubuid,
3178
+ ourPubuid: params.pubuid,
3179
+ });
3180
+ const mergedParams = { ...message.params, pubuid: params.pubuid };
3181
+ this._onAnnounce(mergedParams);
3182
+ resolver({ ...message, params: mergedParams });
3183
+ break;
3184
+ }
3185
+ }
3186
+ };
3187
+ messengerLogger.trace('Event listener attached', { topicName: params.name });
3188
+ this.socket.websocket.addEventListener('message', wsHandler);
3189
+ this.sendPublishFrames(params);
3190
+ // Reject the promise if the topic is not announced within 3 seconds
3191
+ this.socket
3192
+ .waitForConnection()
3193
+ .then(() => {
3194
+ if (resolved) {
3195
+ return;
3196
+ }
3197
+ timeoutId = setTimeout(() => {
3198
+ rejector(new Error(`Topic ${params.name} was not announced within 3 seconds (3000ms)`));
3199
+ }, 3000);
3200
+ })
3201
+ .catch((error) => {
3202
+ rejector(error);
3203
+ });
3204
+ });
3205
+ }
3206
+ sendPublishFrames(params) {
3207
+ const message = {
3208
+ method: 'publish',
3209
+ params,
3210
+ };
3211
+ this._socket.sendTextFrame(message);
3212
+ }
3213
+ /**
3214
+ * Unpublishes a topic from the server.
3215
+ * @param pubuid - The publication ID to unpublish.
3216
+ */
3217
+ unpublish(pubuid) {
3218
+ // Check if the topic is not published
3219
+ const params = this.publications.get(pubuid);
3220
+ if (!this.publications.delete(pubuid))
3221
+ return;
3222
+ messengerLogger.debug('Topic unpublished', { pubuid, topicName: params?.name });
3223
+ // Send the message to the server
3224
+ const message = {
3225
+ method: 'unpublish',
3226
+ params: {
3227
+ pubuid,
3228
+ },
3229
+ };
3230
+ this._socket.sendTextFrame(message);
3231
+ }
3232
+ /**
3233
+ * Subscribes to a topic.
3234
+ * @param params - The subscription parameters.
3235
+ * @param force - Whether to force the subscription.
3236
+ */
3237
+ subscribe(params, force) {
3238
+ if (this.subscriptions.has(params.subuid) && !force) {
3239
+ messengerLogger.debug('Subscription skipped', { subuid: params.subuid, reason: 'already exists' });
3240
+ return;
3241
+ }
3242
+ if (force) {
3243
+ messengerLogger.debug('Force subscribe', { subuid: params.subuid, topicNames: params.topics });
3244
+ }
3245
+ this.subscriptions.set(params.subuid, params);
3246
+ messengerLogger.debug('Subscription created', {
3247
+ subuid: params.subuid,
3248
+ topicNames: params.topics,
3249
+ prefix: params.options?.prefix || false,
3250
+ });
3251
+ // Create the message to send to the server
3252
+ const message = {
3253
+ method: 'subscribe',
3254
+ params,
3255
+ };
3256
+ // Send the message to the server
3257
+ this._socket.sendTextFrame(message);
3258
+ }
3259
+ /**
3260
+ * Unsubscribes from a topic.
3261
+ * @param subuid - The subscription ID to unsubscribe from.
3262
+ */
3263
+ unsubscribe(subuid) {
3264
+ // Check if the topic is not subscribed
3265
+ const params = this.subscriptions.get(subuid);
3266
+ if (!this.subscriptions.has(subuid))
3267
+ return;
3268
+ // Remove the topic from the list of subscribed topics
3269
+ this.subscriptions.delete(subuid);
3270
+ messengerLogger.debug('Unsubscribed from topic', { subuid, topicNames: params?.topics });
3271
+ // Send the message to the server
3272
+ const message = {
3273
+ method: 'unsubscribe',
3274
+ params: {
3275
+ subuid,
3276
+ },
3277
+ };
3278
+ this._socket.sendTextFrame(message);
3279
+ }
3280
+ /**
3281
+ * Sets the properties of a topic.
3282
+ * @param params - The parameters to set
3283
+ * @returns The new properties of the topic.
3284
+ */
3285
+ async setProperties(params) {
3286
+ messengerLogger.debug('Properties update initiated', { topicName: params.name, update: params.update });
3287
+ // Create the message to send to the server
3288
+ const message = {
3289
+ method: 'setproperties',
3290
+ params,
3291
+ };
3292
+ return new Promise((resolve, reject) => {
3293
+ let timeoutId = null;
3294
+ // Cleanup function to remove listener and clear timeout
3295
+ const cleanup = () => {
3296
+ messengerLogger.trace('Cleanup: removing event listener and clearing timeout', { topicName: params.name });
3297
+ this._socket.websocket.removeEventListener('message', wsHandler);
3298
+ if (timeoutId) {
3299
+ clearTimeout(timeoutId);
3300
+ messengerLogger.trace('Timeout cleared', { topicName: params.name });
3301
+ }
3302
+ messengerLogger.debug('Cleanup performed', { topicName: params.name });
3303
+ };
3304
+ const resolver = (message) => {
3305
+ messengerLogger.trace('Promise resolver called', { topicName: params.name });
3306
+ cleanup();
3307
+ messengerLogger.debug('Properties ack received', { topicName: params.name, ack: message.params.ack });
3308
+ resolve(message);
3309
+ };
3310
+ // Rejector function to cleanup before rejecting
3311
+ const rejector = (error) => {
3312
+ messengerLogger.trace('Promise rejector called', { topicName: params.name, error: error.message });
3313
+ cleanup();
3314
+ reject(error);
3315
+ };
3316
+ const wsHandler = (event) => {
3317
+ const messages = this.parseAndFilterMessage(event, 'properties');
3318
+ messengerLogger.debug('Messages filtered for properties', { count: messages.length });
3319
+ for (const message of messages) {
3320
+ if (message.params.name === params.name && message.params.ack) {
3321
+ messengerLogger.debug('Properties message matched', { topicName: params.name, ack: message.params.ack });
3322
+ resolver(message);
3323
+ break;
3324
+ }
3325
+ }
3326
+ };
3327
+ this._socket.websocket.addEventListener('message', wsHandler);
3328
+ // Send the message to the server
3329
+ this._socket.sendTextFrame(message);
3330
+ // Reject the promise if an ACK is not received within 3 seconds
3331
+ this.socket
3332
+ .waitForConnection()
3333
+ .then(() => {
3334
+ timeoutId = setTimeout(() => {
3335
+ rejector(new Error(`Properties for topic ${params.name} were not acknowledged within 3 seconds`));
3336
+ }, 3000);
3337
+ })
3338
+ .catch((error) => {
3339
+ rejector(error);
3340
+ });
3341
+ });
3342
+ }
3343
+ /**
3344
+ * Send data to a topic.
3345
+ * This should only be called by the PubSubClient.
3346
+ * @param topic - The topic to update.
3347
+ * @param value - The value to update the topic to.
3348
+ * @returns The timestamp of the update, or -1 if the socket is not connected.
3349
+ */
3350
+ sendToTopic(topic, value) {
3351
+ const typeInfo = topic.typeInfo;
3352
+ if (!topic.publisher || topic.pubuid == null) {
3353
+ throw new Error(`Topic ${topic.name} is not a publisher, so it cannot be updated`);
3354
+ }
3355
+ return this._socket.sendValueToTopic(topic.pubuid, value, typeInfo);
3356
+ }
3357
+ /**
3358
+ * Gets the next available publisher UID.
3359
+ * @returns The next available publisher UID.
3360
+ */
3361
+ getNextPubUID() {
3362
+ const pubuid = this._currentPubUID++;
3363
+ messengerLogger.debug('Next PubUID generated', { pubuid });
3364
+ return pubuid;
3365
+ }
3366
+ /**
3367
+ * Gets the next available subscriber UID.
3368
+ * @returns The next available subscriber UID.
3369
+ */
3370
+ getNextSubUID() {
3371
+ const subuid = this._currentSubUID++;
3372
+ messengerLogger.debug('Next SubUID generated', { subuid });
3373
+ return subuid;
3374
+ }
3375
+ /**
3376
+ * Removes this messenger and its socket from their singleton maps.
3377
+ */
3378
+ releaseInstance() {
3379
+ for (const [key, instance] of Messenger._instances) {
3380
+ if (instance === this) {
3381
+ Messenger._instances.delete(key);
3382
+ }
3383
+ }
3384
+ this._socket.releaseInstance();
3385
+ }
3386
+ }
3387
+ Messenger._instances = new Map();
3388
+
3389
+ // When Node ESM loads CJS (protobufjs), the namespace may be { default: root }; support both shapes.
3390
+ const protobuf = protobufNs.default ?? protobufNs;
3391
+ /** Extract basename from a path string without Node's path module (works in browser and Node). */
3392
+ function basename(filePath) {
3393
+ return filePath.replace(/^.*[/\\]/, '');
3394
+ }
3395
+ /** True when running under Node.js (filesystem APIs such as protobuf.load are available). */
3396
+ function isNodeRuntime() {
3397
+ return typeof process !== 'undefined' && typeof process.versions?.node === 'string';
3398
+ }
3399
+ const MAX_PROTO_SOURCE_CHARS = 1000000;
3400
+ const MAX_PROTO_SCHEMA_BYTES = 1000000;
3401
+ /**
3402
+ * Manages protobuf schema fetching and caching from NetworkTables.
3403
+ * Schemas are automatically cached from `/.schema/proto:*` topics using a prefix subscription.
3404
+ */
3405
+ class ProtobufSchemaManager {
3406
+ constructor(client) {
3407
+ this.schemaCache = new Map();
3408
+ this.registeredSchemas = new Set();
3409
+ this.fileDescriptorProtoType = protobuf.Root.fromJSON(descriptorJson).lookupType('google.protobuf.FileDescriptorProto');
3410
+ this.client = client;
3411
+ // Create a prefix topic for all schema topics
3412
+ this.schemaPrefixTopic = new NetworkTablesPrefixTopic(client, '/.schema/proto:');
3413
+ // Subscribe to all schema topics and automatically decode and cache them
3414
+ this.schemaPrefixTopic.subscribe((value, params) => {
3415
+ this.handleSchemaUpdate(value, params);
3416
+ });
3417
+ }
3418
+ /**
3419
+ * Handles schema updates from the prefix topic subscription.
3420
+ * Automatically decodes and caches all schema files that arrive.
3421
+ * @param value - The value of the schema update (null when a topic is unannounced).
3422
+ * @param params - The parameters of the announce message.
3423
+ */
3424
+ handleSchemaUpdate(value, params) {
3425
+ if (value == null)
3426
+ return;
3427
+ if (!ArrayBuffer.isView(value))
3428
+ return;
3429
+ if (value.byteLength > MAX_PROTO_SCHEMA_BYTES) {
3430
+ pubsubLogger.debug('Rejected oversized protobuf schema', { name: params.name, byteLength: value.byteLength });
3431
+ return;
3432
+ }
3433
+ const protoFileName = params.name.substring('/.schema/proto:'.length);
3434
+ if (!protoFileName)
3435
+ return;
3436
+ try {
3437
+ const decoded = this.fileDescriptorProtoType.decode(value);
3438
+ const fd = this.fileDescriptorProtoType.toObject(decoded, {
3439
+ enums: Number,
3440
+ longs: String,
3441
+ bytes: String,
3442
+ });
3443
+ const descriptorSet = { file: [fd] };
3444
+ const schemaRoot = protobuf.Root.fromDescriptor(descriptorSet).resolveAll();
3445
+ this.schemaCache.set(protoFileName, schemaRoot);
3446
+ this.schemaCache.set(params.name, schemaRoot);
3447
+ }
3448
+ catch (error) {
3449
+ pubsubLogger.debug('Failed to decode protobuf schema', { name: params.name, error });
3450
+ }
3451
+ }
3452
+ /**
3453
+ * Fetches a protobuf message type from the cache.
3454
+ * Searches all loaded schemas in the cache to find one containing the requested message type.
3455
+ * Returns null if the schema is not yet in cache (e.g. topic announced before schema topic arrived).
3456
+ * @param messageName - The name of the protobuf message type (e.g., "frc.Pose2d").
3457
+ * @returns The protobufjs Type, or null if not found in cache.
3458
+ */
3459
+ fetchMessageType(messageName) {
3460
+ // Search all cached schemas for the requested message type
3461
+ for (const schema of this.schemaCache.values()) {
3462
+ try {
3463
+ return schema.lookupType(messageName);
3464
+ }
3465
+ catch {
3466
+ // lookupType throws if type not found, continue searching
3467
+ continue;
3468
+ }
3469
+ }
3470
+ return null;
3471
+ }
3472
+ /**
3473
+ * Clears the schema cache.
3474
+ */
3475
+ clearCache() {
3476
+ this.schemaCache.clear();
3477
+ }
3478
+ /**
3479
+ * Gets the message name from a protobuf root.
3480
+ * Auto-detects the first message type in the proto file.
3481
+ * @param root - The protobuf root containing the schema.
3482
+ * @returns The message name in format "package.MessageName" or "MessageName" if no package (no leading dot).
3483
+ */
3484
+ getMessageNameFromProto(root) {
3485
+ // Get all nested types (messages) from the root
3486
+ const nested = root.nested;
3487
+ if (!nested) {
3488
+ throw new Error('Proto file has no messages');
3489
+ }
3490
+ // protobufjs fullName includes a leading dot (e.g. ".networktables.TestData");
3491
+ // NetworkTables type strings must be "proto:package.MessageName" with no leading dot.
3492
+ const normalize = (name) => (name.startsWith('.') ? name.slice(1) : name);
3493
+ // Find the first message type
3494
+ for (const nestedObj of Object.values(nested)) {
3495
+ if (nestedObj instanceof protobuf.Type) {
3496
+ return normalize(nestedObj.fullName);
3497
+ }
3498
+ // If it's a namespace (package), recurse into it
3499
+ if (nestedObj instanceof protobuf.Namespace) {
3500
+ const message = this.findFirstMessage(nestedObj);
3501
+ if (message) {
3502
+ return normalize(message.fullName);
3503
+ }
3504
+ }
3505
+ }
3506
+ throw new Error('No message type found in proto file');
3507
+ }
3508
+ /**
3509
+ * Recursively finds the first message type in a namespace.
3510
+ * @param namespace - The namespace to search.
3511
+ * @returns The first message type found, or null.
3512
+ */
3513
+ findFirstMessage(namespace) {
3514
+ for (const nestedObj of Object.values(namespace.nested || {})) {
3515
+ if (nestedObj instanceof protobuf.Type) {
3516
+ return nestedObj;
3517
+ }
3518
+ if (nestedObj instanceof protobuf.Namespace) {
3519
+ const message = this.findFirstMessage(nestedObj);
3520
+ if (message) {
3521
+ return message;
3522
+ }
3523
+ }
3524
+ }
3525
+ return null;
3526
+ }
3527
+ /**
3528
+ * Registers a protobuf schema by loading a proto file and publishing it to NetworkTables.
3529
+ * Node.js only — uses the filesystem. In the browser, use {@link registerSchemaFromSource} or
3530
+ * {@link registerSchemaFromType} instead.
3531
+ * @param protoFilePath - Path to the .proto file.
3532
+ * @param messageName - Optional message name to use. If not provided, auto-detected from proto file.
3533
+ * @returns A promise that resolves to the message name and schema root.
3534
+ * @throws {Error} If called in a browser, or if the proto file cannot be loaded or schema cannot be registered.
3535
+ */
3536
+ async registerSchema(protoFilePath, messageName) {
3537
+ if (!isNodeRuntime()) {
3538
+ throw new Error(`protoFilePath ("${protoFilePath}") requires Node.js filesystem access. Use protoSource or messageType instead for browser environments.`);
3539
+ }
3540
+ const filename = basename(protoFilePath);
3541
+ return this.registerRootFromLoader(filename, messageName, async () => {
3542
+ try {
3543
+ return await protobuf.load(protoFilePath);
3544
+ }
3545
+ catch (error) {
3546
+ throw new Error(`Failed to load proto file "${protoFilePath}"`, { cause: error });
3547
+ }
3548
+ }, protoFilePath);
3549
+ }
3550
+ /**
3551
+ * Parses a `.proto` source string without publishing (no filesystem).
3552
+ * @param protoSource - The contents of a .proto file.
3553
+ * @returns The detected message name and resolved root.
3554
+ */
3555
+ parseSource(protoSource) {
3556
+ if (protoSource.length > MAX_PROTO_SOURCE_CHARS) {
3557
+ throw new Error(`protoSource exceeds ${MAX_PROTO_SOURCE_CHARS} characters`);
3558
+ }
3559
+ try {
3560
+ const parsed = protobuf.parse(protoSource);
3561
+ const root = parsed.root.resolveAll();
3562
+ const messageName = this.getMessageNameFromProto(root);
3563
+ return { messageName, root };
3564
+ }
3565
+ catch (error) {
3566
+ throw new Error('Failed to parse proto source', { cause: error });
3567
+ }
3568
+ }
3569
+ async registerSchemaFromSource(protoSource, filename, messageName) {
3570
+ const parsed = this.parseSource(protoSource);
3571
+ const detectedMessageName = messageName || parsed.messageName;
3572
+ const schemaFilename = filename ?? `${detectedMessageName}.proto`;
3573
+ return this.publishRoot(parsed.root, schemaFilename, detectedMessageName);
3574
+ }
3575
+ /**
3576
+ * Registers a protobuf schema from a prebuilt protobufjs Type (no filesystem). Safe in the browser.
3577
+ * @param messageType - A protobufjs Type used for encode/decode.
3578
+ * @param filename - Optional filename used for the schema topic (defaults to the message name).
3579
+ * @returns A promise that resolves to the message name and schema root.
3580
+ */
3581
+ async registerSchemaFromType(messageType, filename) {
3582
+ const normalize = (name) => (name.startsWith('.') ? name.slice(1) : name);
3583
+ const detectedMessageName = normalize(messageType.fullName);
3584
+ const schemaFilename = filename ?? `${detectedMessageName}.proto`;
3585
+ return this.publishRoot(messageType.root, schemaFilename, detectedMessageName);
3586
+ }
3587
+ /**
3588
+ * Loads a root then publishes it, sharing in-flight protection by schema topic name.
3589
+ */
3590
+ async registerRootFromLoader(filename, messageName, loadRoot, sourceLabel) {
3591
+ const schemaTopicName = `/.schema/proto:${filename}`;
3592
+ if (this.registeredSchemas.has(schemaTopicName)) {
3593
+ const cachedRoot = this.schemaCache.get(schemaTopicName);
3594
+ if (cachedRoot) {
3595
+ const detectedMessageName = messageName || this.getMessageNameFromProto(cachedRoot);
3596
+ return { messageName: detectedMessageName, root: cachedRoot };
3597
+ }
3598
+ }
3599
+ const operationKey = `schema:${schemaTopicName}`;
3600
+ return this.client.getOrCreateInFlightOperation(operationKey, async () => {
3601
+ const root = await loadRoot();
3602
+ return this.publishLoadedRoot(root, filename, messageName, sourceLabel);
3603
+ });
3604
+ }
3605
+ /**
3606
+ * Publishes an already-parsed root, sharing in-flight protection by schema topic name.
3607
+ */
3608
+ async publishRoot(root, filename, messageName) {
3609
+ const schemaTopicName = `/.schema/proto:${filename}`;
3610
+ if (this.registeredSchemas.has(schemaTopicName)) {
3611
+ const cachedRoot = this.schemaCache.get(schemaTopicName);
3612
+ if (cachedRoot) {
3613
+ const detectedMessageName = messageName || this.getMessageNameFromProto(cachedRoot);
3614
+ return { messageName: detectedMessageName, root: cachedRoot };
3615
+ }
3616
+ }
3617
+ const operationKey = `schema:${schemaTopicName}`;
3618
+ return this.client.getOrCreateInFlightOperation(operationKey, async () => {
3619
+ return this.publishLoadedRoot(root, filename, messageName, filename);
3620
+ });
3621
+ }
3622
+ /**
3623
+ * Encodes a FileDescriptorProto from a root, publishes the schema topic, and caches it.
3624
+ * Must be called inside an in-flight operation.
3625
+ */
3626
+ async publishLoadedRoot(root, filename, messageName, sourceLabel) {
3627
+ const schemaTopicName = `/.schema/proto:${filename}`;
3628
+ let encoded;
3629
+ try {
3630
+ const descriptor = root.toDescriptor('proto3');
3631
+ if (!descriptor?.file?.length) {
3632
+ throw new Error('No file descriptor found in proto');
3633
+ }
3634
+ const fileDescriptorProto = descriptor.file[0];
3635
+ encoded = this.fileDescriptorProtoType.encode(fileDescriptorProto).finish();
3636
+ }
3637
+ catch (error) {
3638
+ throw new Error(`Failed to extract/encode FileDescriptorProto from "${sourceLabel}"`, { cause: error });
3639
+ }
3640
+ const detectedMessageName = messageName || this.getMessageNameFromProto(root);
3641
+ let schemaTopic = this.client.getTopicFromName(schemaTopicName);
3642
+ if (!schemaTopic) {
3643
+ schemaTopic = new NetworkTablesTopic(this.client, schemaTopicName, NetworkTablesTypeInfos.kUint8Array);
3644
+ }
3645
+ const pubuid = this.client.messenger.getNextPubUID();
3646
+ schemaTopic['_pubuid'] = pubuid;
3647
+ await this.client.messenger.publish({
3648
+ name: schemaTopicName,
3649
+ pubuid,
3650
+ type: 'proto:FileDescriptorProto',
3651
+ properties: { retained: true },
3652
+ });
3653
+ this.client.updateServer(schemaTopic, encoded);
3654
+ this.schemaCache.set(schemaTopicName, root);
3655
+ this.schemaCache.set(filename, root);
3656
+ this.registeredSchemas.add(schemaTopicName);
3657
+ return { messageName: detectedMessageName, root };
3658
+ }
3659
+ }
3660
+
3661
+ const STRUCT_SCHEMA_PREFIX = '/.schema/struct:';
3662
+ /**
3663
+ * Manages struct schema fetching and caching from NetworkTables.
3664
+ * Subscribes to `/.schema/struct:*` and parses/caches descriptors by type name.
3665
+ */
3666
+ class StructSchemaManager {
3667
+ constructor(client) {
3668
+ this.parsedFieldsCache = new Map();
3669
+ this.descriptorCache = new Map();
3670
+ this.schemaStringCache = new Map();
3671
+ this.pendingTypeNames = new Set();
3672
+ /** Type names for which we have already published a schema topic (avoids redundant republish). */
3673
+ this.publishedSchemaTypes = new Set();
3674
+ this.client = client;
3675
+ // Create a prefix topic for all struct schema topics
3676
+ this.schemaPrefixTopic = new NetworkTablesPrefixTopic(client, STRUCT_SCHEMA_PREFIX);
3677
+ // Subscribe to all schema topics and automatically decode and cache them
3678
+ this.schemaPrefixTopic.subscribe((value, params) => {
3679
+ this.handleSchemaUpdate(value, params);
3680
+ });
3681
+ }
3682
+ /**
3683
+ * Handles schema updates from the prefix topic subscription.
3684
+ * Decodes UTF-8 bytes to string, parses schema, and caches descriptor when dependencies are ready.
3685
+ * @param value - The value of the schema update (null when a topic is unannounced).
3686
+ * @param params - The parameters of the announce message.
3687
+ */
3688
+ handleSchemaUpdate(value, params) {
3689
+ if (value == null)
3690
+ return;
3691
+ if (!ArrayBuffer.isView(value))
3692
+ return;
3693
+ const typeName = params.name.substring(STRUCT_SCHEMA_PREFIX.length);
3694
+ if (!typeName)
3695
+ return;
3696
+ const schemaString = new TextDecoder().decode(value);
3697
+ this.schemaStringCache.set(typeName, schemaString);
3698
+ let parsed;
3699
+ try {
3700
+ parsed = parseSchema(schemaString);
3701
+ }
3702
+ catch (err) {
3703
+ pubsubLogger.debug('Failed to parse struct schema', { typeName, error: err });
3704
+ return;
3705
+ }
3706
+ this.parsedFieldsCache.set(typeName, parsed);
3707
+ this.pendingTypeNames.add(typeName);
3708
+ this.tryBuildPending();
3709
+ }
3710
+ getNested(name) {
3711
+ return this.descriptorCache.get(name) ?? getBuiltInDescriptor(name) ?? null;
3712
+ }
3713
+ tryBuildPending() {
3714
+ while (this.pendingTypeNames.size > 0) {
3715
+ let builtAny = false;
3716
+ for (const typeName of this.pendingTypeNames) {
3717
+ const fields = this.parsedFieldsCache.get(typeName);
3718
+ if (!fields || this.descriptorCache.has(typeName))
3719
+ continue;
3720
+ try {
3721
+ const descriptor = buildStructDescriptor(typeName, fields, (n) => this.getNested(n));
3722
+ this.descriptorCache.set(typeName, descriptor);
3723
+ this.descriptorCache.set(`struct:${typeName}`, descriptor);
3724
+ this.pendingTypeNames.delete(typeName);
3725
+ builtAny = true;
3726
+ }
3727
+ catch (err) {
3728
+ pubsubLogger.debug('Deferred struct descriptor build', { typeName, error: err });
3729
+ }
3730
+ }
3731
+ if (!builtAny)
3732
+ break;
3733
+ }
3734
+ }
3735
+ /**
3736
+ * Returns the struct descriptor for the given type name or type string (e.g. "Pose2d" or "struct:Pose2d").
3737
+ * Returns null if not in cache and not a built-in (built-ins are resolved on first use).
3738
+ */
3739
+ fetchDescriptor(typeNameOrTypeString) {
3740
+ const typeName = typeNameOrTypeString.startsWith('struct:') ? typeNameOrTypeString.slice(7) : typeNameOrTypeString;
3741
+ const cached = this.descriptorCache.get(typeName) ?? this.descriptorCache.get(`struct:${typeName}`);
3742
+ if (cached)
3743
+ return cached;
3744
+ const builtIn = getBuiltInDescriptor(typeName);
3745
+ if (builtIn)
3746
+ return builtIn;
3747
+ return null;
3748
+ }
3749
+ hasSchema(typeString) {
3750
+ return this.fetchDescriptor(typeString) != null;
3751
+ }
3752
+ clearCache() {
3753
+ this.parsedFieldsCache.clear();
3754
+ this.descriptorCache.clear();
3755
+ this.schemaStringCache.clear();
3756
+ this.pendingTypeNames.clear();
3757
+ this.publishedSchemaTypes.clear();
3758
+ }
3759
+ /**
3760
+ * Publishes nested struct schemas to NetworkTables (nested first), then the given descriptor's schema.
3761
+ * Used when publishing a struct topic so subscribers can decode.
3762
+ */
3763
+ async addSchema(descriptor) {
3764
+ const typeName = descriptor.typeName;
3765
+ const schemaTopicName = `${STRUCT_SCHEMA_PREFIX}${typeName}`;
3766
+ // Use unified in-flight protection from PubSubClient
3767
+ // Key format: "schema:" prefix to avoid conflicts with topic publishes
3768
+ const operationKey = `schema:${schemaTopicName}`;
3769
+ await this.client.getOrCreateInFlightOperation(operationKey, async () => {
3770
+ const nested = this.collectNestedTypeNames(descriptor);
3771
+ for (const name of nested) {
3772
+ await this.publishSchemaTopic(name);
3773
+ }
3774
+ await this.publishSchemaTopic(typeName);
3775
+ });
3776
+ }
3777
+ collectNestedTypeNames(desc) {
3778
+ const seen = new Set();
3779
+ const out = [];
3780
+ const visit = (d) => {
3781
+ for (const f of d.fields) {
3782
+ if (f.nestedDescriptor && !seen.has(f.nestedDescriptor.typeName)) {
3783
+ seen.add(f.nestedDescriptor.typeName);
3784
+ visit(f.nestedDescriptor);
3785
+ out.push(f.nestedDescriptor.typeName);
3786
+ }
3787
+ }
3788
+ };
3789
+ visit(desc);
3790
+ return out;
3791
+ }
3792
+ async publishSchemaTopic(typeName) {
3793
+ if (this.publishedSchemaTypes.has(typeName))
3794
+ return;
3795
+ const schemaTopicName = `${STRUCT_SCHEMA_PREFIX}${typeName}`;
3796
+ const schemaString = this.schemaStringCache.get(typeName) ?? getBuiltInSchemaString(typeName);
3797
+ if (!schemaString)
3798
+ return;
3799
+ const encodedSchema = new TextEncoder().encode(schemaString);
3800
+ let topic = this.client.getTopicFromName(schemaTopicName);
3801
+ if (!topic) {
3802
+ topic = new NetworkTablesTopic(this.client, schemaTopicName, NetworkTablesTypeInfos.kStructSchema);
3803
+ }
3804
+ if (topic.typeInfo[1] === 'structschema') {
3805
+ await topic.publish({ retained: true });
3806
+ topic.setValue(encodedSchema);
3807
+ }
3808
+ else {
3809
+ // Existing topic created with older code (e.g. type 'raw'); publish with custom type via messenger
3810
+ const pubuid = this.client.messenger.getNextPubUID();
3811
+ topic['_pubuid'] = pubuid;
3812
+ await this.client.messenger.publish({
3813
+ name: schemaTopicName,
3814
+ pubuid,
3815
+ type: 'structschema',
3816
+ properties: { retained: true },
3817
+ });
3818
+ this.client.updateServer(topic, encodedSchema);
3819
+ }
3820
+ this.publishedSchemaTypes.add(typeName);
3821
+ }
3822
+ }
3823
+
3824
+ /** The client for the PubSub protocol. */
3825
+ class PubSubClient {
3826
+ get messenger() {
3827
+ return this._messenger;
3828
+ }
3829
+ get protobufSchemaManager() {
3830
+ return this._protobufSchemaManager;
3831
+ }
3832
+ get structSchemaManager() {
3833
+ return this._structSchemaManager;
3834
+ }
3835
+ constructor(serverUrl) {
3836
+ // Unified in-flight operations tracking (schema registrations and topic publishes)
3837
+ this.inFlightOperations = new Map();
3838
+ this._isCleaningUp = false;
3839
+ this.beforeUnloadHandler = () => {
3840
+ this.cleanup();
3841
+ };
3842
+ /**
3843
+ * Called by the messenger when a topic is updated.
3844
+ * @param message - The message data.
3845
+ */
3846
+ this.onTopicUpdate = (message) => {
3847
+ pubsubLogger.debug('Value update received', {
3848
+ topicId: message.topicId,
3849
+ typeNum: message.typeNum,
3850
+ serverTime: message.serverTime,
3851
+ });
3852
+ const topic = this.getTopicFromId(message.topicId);
3853
+ if (topic) {
3854
+ pubsubLogger.trace('Raw value received', {
3855
+ topicId: message.topicId,
3856
+ value: message.value,
3857
+ typeNum: message.typeNum,
3858
+ });
3859
+ try {
3860
+ const validatedData = NetworkTablesTypeInfos.validateData(topic.typeInfo, message.value);
3861
+ pubsubLogger.debug('Value validated successfully', { topicName: topic.name, topicId: message.topicId });
3862
+ pubsubLogger.trace('Type validation details', {
3863
+ topicName: topic.name,
3864
+ expectedType: topic.typeInfo[1],
3865
+ receivedTypeNum: message.typeNum,
3866
+ validated: true,
3867
+ });
3868
+ pubsubLogger.debug('Value update applied to topic', { topicName: topic.name, topicId: message.topicId });
3869
+ topic.updateValue(validatedData, message.serverTime);
3870
+ }
3871
+ catch (e) {
3872
+ pubsubLogger.trace('Type validation failed', {
3873
+ topicName: topic.name,
3874
+ expectedType: topic.typeInfo[1],
3875
+ receivedTypeNum: message.typeNum,
3876
+ error: String(e),
3877
+ });
3878
+ pubsubLogger.warn('Skipping invalid value update for topic', {
3879
+ topicName: topic.name,
3880
+ topicId: message.topicId,
3881
+ error: String(e),
3882
+ });
3883
+ // Do not throw: allow remaining messages in the same binary frame to be processed
3884
+ }
3885
+ }
3886
+ const knownTopic = this.getKnownTopicParams(message.topicId);
3887
+ if (knownTopic) {
3888
+ // Use topic's value when we have one so protobuf topics pass decoded value to prefix subscribers
3889
+ const valueForPrefix = topic != null ? (topic.getValue() ?? message.value) : message.value;
3890
+ let matchedPrefixCount = 0;
3891
+ this.prefixTopics.forEach((prefixTopic) => {
3892
+ if (knownTopic.name.startsWith(prefixTopic.name)) {
3893
+ matchedPrefixCount++;
3894
+ pubsubLogger.debug('Prefix topic matched', { topicName: knownTopic.name, prefix: prefixTopic.name });
3895
+ prefixTopic.updateValue(knownTopic, valueForPrefix, message.serverTime);
3896
+ }
3897
+ });
3898
+ if (matchedPrefixCount > 0) {
3899
+ pubsubLogger.debug('Value update applied to prefix topics', {
3900
+ topicName: knownTopic.name,
3901
+ count: matchedPrefixCount,
3902
+ });
3903
+ }
3904
+ }
3905
+ if (!topic && !knownTopic) {
3906
+ // Buffer value update until announcement arrives (handles server sending value before announce).
3907
+ this.pendingValueUpdates.set(message.topicId, message);
3908
+ pubsubLogger.debug('Buffered value update for unknown topic', { topicId: message.topicId });
3909
+ }
3910
+ };
3911
+ /**
3912
+ * Called by the messenger when a topic is announced.
3913
+ * @param params - The announce message parameters.
3914
+ */
3915
+ this.onTopicAnnounce = (params) => {
3916
+ pubsubLogger.trace('Map operation: knownTopicParams.set', {
3917
+ topicId: params.id,
3918
+ topicName: params.name,
3919
+ mapSizeBefore: this.knownTopicParams.size,
3920
+ });
3921
+ this.knownTopicParams.set(params.id, params);
3922
+ pubsubLogger.trace('Map operation: knownTopicParams.set complete', { mapSizeAfter: this.knownTopicParams.size });
3923
+ pubsubLogger.debug('Topic announced', { topicName: params.name, topicId: params.id, type: params.type });
3924
+ // Announce to the topic
3925
+ const topic = this.topics.get(params.name);
3926
+ topic?.announce(params);
3927
+ // Find all prefix topics that match the announced topic
3928
+ let matchedPrefixCount = 0;
3929
+ this.prefixTopics.forEach((prefixTopic) => {
3930
+ if (params.name.startsWith(prefixTopic.name)) {
3931
+ matchedPrefixCount++;
3932
+ pubsubLogger.debug('Prefix topic matched', { topicName: params.name, prefix: prefixTopic.name });
3933
+ prefixTopic.announce(params);
3934
+ }
3935
+ });
3936
+ if (this.prefixTopics.size > 0) {
3937
+ pubsubLogger.debug('Prefix topics checked', {
3938
+ totalPrefixTopics: this.prefixTopics.size,
3939
+ matchedCount: matchedPrefixCount,
3940
+ });
3941
+ }
3942
+ // Flush any value update that arrived before this announcement
3943
+ const pendingMessage = this.pendingValueUpdates.get(params.id);
3944
+ if (pendingMessage) {
3945
+ this.pendingValueUpdates.delete(params.id);
3946
+ this._deliverValueUpdate(params, pendingMessage);
3947
+ }
3948
+ };
3949
+ /**
3950
+ * Called by the messenger when a topic is unannounced.
3951
+ * @param params - The unannounce message parameters.
3952
+ */
3953
+ this.onTopicUnannounce = (params) => {
3954
+ const topic = this.topics.get(params.name);
3955
+ this.pendingValueUpdates.delete(params.id);
3956
+ if (!topic) {
3957
+ pubsubLogger.debug('Topic was unannounced but does not exist', { topicName: params.name });
3958
+ // Still clean up knownTopicParams even if topic doesn't exist locally
3959
+ pubsubLogger.trace('Map operation: knownTopicParams.delete', {
3960
+ topicId: params.id,
3961
+ mapSizeBefore: this.knownTopicParams.size,
3962
+ });
3963
+ this.knownTopicParams.delete(params.id);
3964
+ pubsubLogger.trace('Map operation: knownTopicParams.delete complete', {
3965
+ mapSizeAfter: this.knownTopicParams.size,
3966
+ });
3967
+ return;
3968
+ }
3969
+ pubsubLogger.debug('Topic unannounced', { topicName: params.name, topicId: topic.id });
3970
+ topic.unannounce();
3971
+ // Clean up knownTopicParams when topic is unannounced
3972
+ pubsubLogger.trace('Map operation: knownTopicParams.delete', {
3973
+ topicId: params.id,
3974
+ mapSizeBefore: this.knownTopicParams.size,
3975
+ });
3976
+ this.knownTopicParams.delete(params.id);
3977
+ pubsubLogger.trace('Map operation: knownTopicParams.delete complete', { mapSizeAfter: this.knownTopicParams.size });
3978
+ };
3979
+ /**
3980
+ * Called by the messenger when a topic's properties are updated.
3981
+ * @param params - The properties message parameters.
3982
+ */
3983
+ this.onTopicProperties = (params) => {
3984
+ const topic = this.topics.get(params.name);
3985
+ if (params.ack) {
3986
+ if (!topic) {
3987
+ pubsubLogger.debug('Topic properties updated but does not exist', { topicName: params.name });
3988
+ return;
3989
+ }
3990
+ pubsubLogger.debug('Topic properties updated', { topicName: params.name, ack: params.ack });
3991
+ }
3992
+ };
3993
+ this._messenger = Messenger.getInstance(serverUrl, this.onTopicUpdate, this.onTopicAnnounce, this.onTopicUnannounce, this.onTopicProperties);
3994
+ this.topics = new Map();
3995
+ this.prefixTopics = new Map();
3996
+ this.knownTopicParams = new Map();
3997
+ this.pendingValueUpdates = new Map();
3998
+ this._protobufSchemaManager = new ProtobufSchemaManager(this);
3999
+ this._structSchemaManager = new StructSchemaManager(this);
4000
+ // When the connection drops, local server-side announcement state is no longer reliable.
4001
+ // Clear known ids so outgoing updates can be safely queued until re-announced.
4002
+ this._messenger.socket.addConnectionListener((isConnected) => {
4003
+ if (isConnected) {
4004
+ // Flush any latest-only queued outgoing values after reconnect.
4005
+ // This is scheduled as a microtask so it happens after the socket's onopen handler
4006
+ // has run (which re-sends publish frames). This ensures publish frames precede
4007
+ // any value frames on the wire.
4008
+ queueMicrotask(() => {
4009
+ this.topics.forEach((topic) => {
4010
+ try {
4011
+ topic.resendLatestValue();
4012
+ }
4013
+ catch (error) {
4014
+ pubsubLogger.warn('Failed to flush queued outgoing value after reconnect', {
4015
+ topicName: topic.name,
4016
+ error,
4017
+ });
4018
+ }
4019
+ });
4020
+ });
4021
+ return;
4022
+ }
4023
+ pubsubLogger.debug('Socket disconnected; clearing announcement state', {
4024
+ topicCount: this.topics.size,
4025
+ prefixTopicCount: this.prefixTopics.size,
4026
+ knownTopicParams: this.knownTopicParams.size,
4027
+ });
4028
+ this.knownTopicParams.clear();
4029
+ this.pendingValueUpdates.clear();
4030
+ // Ensure disconnect cleanup never throws.
4031
+ this.topics.forEach((topic) => {
4032
+ try {
4033
+ topic.unannounce();
4034
+ }
4035
+ catch (error) {
4036
+ pubsubLogger.warn('Failed to unannounce topic during disconnect cleanup', { topicName: topic.name, error });
4037
+ }
4038
+ });
4039
+ this.prefixTopics.forEach((prefixTopic) => {
4040
+ try {
4041
+ prefixTopic.unannounce();
4042
+ }
4043
+ catch (error) {
4044
+ pubsubLogger.warn('Failed to unannounce prefix topic during disconnect cleanup', {
4045
+ prefix: prefixTopic.name,
4046
+ error,
4047
+ });
4048
+ }
4049
+ });
4050
+ });
4051
+ // In the DOM, auto-cleanup without overwriting an existing beforeunload handler
4052
+ if (typeof window !== 'undefined') {
4053
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
4054
+ }
4055
+ }
4056
+ /**
4057
+ * Gets the instance of the NetworkTables client.
4058
+ * @param serverUrl - The URL of the server to connect to. This is not used after the first call.
4059
+ * @returns The instance of the NetworkTables client.
4060
+ */
4061
+ static getInstance(serverUrl) {
4062
+ let instance = this._instances.get(serverUrl);
4063
+ if (!instance) {
4064
+ instance = new PubSubClient(serverUrl);
4065
+ this._instances.set(serverUrl, instance);
4066
+ }
4067
+ return instance;
4068
+ }
4069
+ /**
4070
+ * Reinstantiates the client by resubscribing to all previously subscribed topics
4071
+ * and republishing for all previously published topics.
4072
+ * @param url - The URL of the server to connect to.
4073
+ */
4074
+ reinstantiate(url) {
4075
+ pubsubLogger.info('Client reinstantiated', {
4076
+ newUrl: url,
4077
+ topicCount: this.topics.size,
4078
+ prefixTopicCount: this.prefixTopics.size,
4079
+ });
4080
+ this._messenger.reinstantiate(url);
4081
+ // Clear announcement state so ids are reacquired from the new connection.
4082
+ this.knownTopicParams.clear();
4083
+ this.pendingValueUpdates.clear();
4084
+ // Ensure reinstantiation cleanup never throws (tests may register lightweight mocks).
4085
+ this.topics.forEach((topic) => {
4086
+ try {
4087
+ topic.unannounce();
4088
+ }
4089
+ catch (error) {
4090
+ pubsubLogger.warn('Failed to unannounce topic during reinstantiate', { topicName: topic.name, error });
4091
+ }
4092
+ });
4093
+ this.prefixTopics.forEach((prefixTopic) => {
4094
+ try {
4095
+ prefixTopic.unannounce();
4096
+ }
4097
+ catch (error) {
4098
+ pubsubLogger.warn('Failed to unannounce prefix topic during reinstantiate', {
4099
+ prefix: prefixTopic.name,
4100
+ error,
4101
+ });
4102
+ }
4103
+ });
4104
+ // Re-subscribe and re-publish local state for the new connection.
4105
+ this.topics.forEach((topic) => {
4106
+ topic.resubscribeAll(this);
4107
+ if (topic.publisher) {
4108
+ // Fire-and-forget but always handle rejections to avoid unhandled promises.
4109
+ void Promise.resolve(topic.republish(this)).catch((error) => {
4110
+ pubsubLogger.warn('Topic republish failed during reinstantiate', { topicName: topic.name, error });
4111
+ });
4112
+ }
4113
+ });
4114
+ this.prefixTopics.forEach((prefixTopic) => {
4115
+ prefixTopic.resubscribeAll(this);
4116
+ });
4117
+ }
4118
+ /**
4119
+ * Registers a topic with this PubSubClient.
4120
+ * @param topic - The topic to register
4121
+ */
4122
+ registerTopic(topic) {
4123
+ if (topic.isRegular()) {
4124
+ if (this.topics.has(topic.name)) {
4125
+ pubsubLogger.debug('Topic already exists check', { topicName: topic.name, exists: true });
4126
+ throw new Error(`Topic ${topic.name} already exists. Cannot register a topic with the same name.`);
4127
+ }
4128
+ pubsubLogger.debug('Topic already exists check', { topicName: topic.name, exists: false });
4129
+ this.topics.set(topic.name, topic);
4130
+ pubsubLogger.debug('Topic registered', { topicName: topic.name, type: 'regular' });
4131
+ }
4132
+ else if (topic.isPrefix()) {
4133
+ if (this.prefixTopics.has(topic.name)) {
4134
+ pubsubLogger.debug('Prefix topic already exists check', { prefix: topic.name, exists: true });
4135
+ throw new Error(`Prefix topic ${topic.name} already exists. Cannot register a topic with the same name.`);
4136
+ }
4137
+ pubsubLogger.debug('Prefix topic already exists check', { prefix: topic.name, exists: false });
4138
+ this.prefixTopics.set(topic.name, topic);
4139
+ pubsubLogger.debug('Prefix topic registered', { prefix: topic.name });
4140
+ }
4141
+ }
4142
+ /**
4143
+ * Delivers a value update to the regular topic (if any) and all matching prefix topics.
4144
+ */
4145
+ _deliverValueUpdate(knownTopic, message) {
4146
+ const topic = this.topics.get(knownTopic.name);
4147
+ if (topic) {
4148
+ try {
4149
+ const validatedData = NetworkTablesTypeInfos.validateData(topic.typeInfo, message.value);
4150
+ topic.updateValue(validatedData, message.serverTime);
4151
+ pubsubLogger.debug('Buffered value applied to topic', { topicName: knownTopic.name, topicId: message.topicId });
4152
+ }
4153
+ catch (e) {
4154
+ pubsubLogger.trace('Buffered value validation failed for topic', {
4155
+ topicName: knownTopic.name,
4156
+ error: String(e),
4157
+ });
4158
+ }
4159
+ }
4160
+ const valueForPrefix = (topic != null ? (topic.getValue() ?? message.value) : message.value);
4161
+ let matchedPrefixCount = 0;
4162
+ this.prefixTopics.forEach((prefixTopic) => {
4163
+ if (knownTopic.name.startsWith(prefixTopic.name)) {
4164
+ matchedPrefixCount++;
4165
+ prefixTopic.updateValue(knownTopic, valueForPrefix, message.serverTime);
4166
+ }
4167
+ });
4168
+ if (matchedPrefixCount > 0) {
4169
+ pubsubLogger.debug('Buffered value applied to prefix topics', {
4170
+ topicName: knownTopic.name,
4171
+ count: matchedPrefixCount,
4172
+ });
4173
+ }
4174
+ }
4175
+ /**
4176
+ * Updates the value of a topic on the server.
4177
+ * @param topic - The topic to update.
4178
+ * @param value - The new value (wire format) to send.
4179
+ */
4180
+ updateServer(topic, value) {
4181
+ this._messenger.sendToTopic(topic, value);
4182
+ }
4183
+ /**
4184
+ * Notifies all prefix topics that match the given topic name of a local value update.
4185
+ * Called when a regular/protobuf topic's setValue() runs so prefix subscribers (e.g. "all topics" table) see locally published values.
4186
+ */
4187
+ notifyPrefixTopicsForLocalUpdate(params, value) {
4188
+ let matchedPrefixCount = 0;
4189
+ this.prefixTopics.forEach((prefixTopic) => {
4190
+ if (params.name.startsWith(prefixTopic.name)) {
4191
+ matchedPrefixCount++;
4192
+ pubsubLogger.debug('Prefix topic notified (local update)', {
4193
+ topicName: params.name,
4194
+ prefix: prefixTopic.name,
4195
+ });
4196
+ prefixTopic.updateValue(params, value, 0);
4197
+ }
4198
+ });
4199
+ if (matchedPrefixCount > 0) {
4200
+ pubsubLogger.debug('Local value applied to prefix topics', { topicName: params.name, count: matchedPrefixCount });
4201
+ }
4202
+ }
4203
+ /**
4204
+ * Gets the topic with the given ID.
4205
+ * @param topicId - The ID of the topic to get.
4206
+ * @returns The topic with the given ID, or null if no topic with that ID exists.
4207
+ */
4208
+ getTopicFromId(topicId) {
4209
+ for (const topic of this.topics.values()) {
4210
+ if (topic.id === topicId) {
4211
+ pubsubLogger.debug('Topic found by ID', { topicId, topicName: topic.name });
4212
+ return topic;
4213
+ }
4214
+ }
4215
+ pubsubLogger.debug('Topic not found by ID', { topicId });
4216
+ return null;
4217
+ }
4218
+ /**
4219
+ * Gets the topic with the given name.
4220
+ * @param topicName - The name of the topic to get.
4221
+ * @returns The topic with the given name, or null if no topic with that name exists.
4222
+ */
4223
+ getTopicFromName(topicName) {
4224
+ const topic = this.topics.get(topicName);
4225
+ if (topic) {
4226
+ pubsubLogger.debug('Topic found by name', { topicName });
4227
+ return topic;
4228
+ }
4229
+ pubsubLogger.debug('Topic not found by name', { topicName });
4230
+ return null;
4231
+ }
4232
+ /**
4233
+ * Gets the topic with the given name.
4234
+ * @param topicName - The name of the topic to get.
4235
+ * @returns The topic with the given name, or null if no topic with that name exists.
4236
+ */
4237
+ getPrefixTopicFromName(topicName) {
4238
+ return this.prefixTopics.get(topicName) ?? null;
4239
+ }
4240
+ /**
4241
+ * Gets the known announcement parameters for a topic.
4242
+ * @param id - The ID of the topic.
4243
+ * @returns The known announcement parameters for the topic, or undefined if the topic is not known.
4244
+ */
4245
+ getKnownTopicParams(id) {
4246
+ const params = this.knownTopicParams.get(id);
4247
+ if (params) {
4248
+ pubsubLogger.debug('Known topic params retrieved', { topicId: id, topicName: params.name });
4249
+ }
4250
+ else {
4251
+ pubsubLogger.debug('Known topic params not found', { topicId: id });
4252
+ }
4253
+ return params;
4254
+ }
4255
+ /**
4256
+ * Gets or creates an in-flight operation to prevent race conditions.
4257
+ * If an operation with the same key is already in progress, returns the existing promise.
4258
+ * Otherwise, creates a new operation and stores it.
4259
+ * @param key - Unique key for the operation (e.g., "schema:/.schema/proto:filename" or "publish:/topic/name")
4260
+ * @param operation - The async operation to execute
4261
+ * @returns A promise that resolves to the operation result
4262
+ * @throws Error if the client is cleaning up and no existing operation is found
4263
+ */
4264
+ getOrCreateInFlightOperation(key, operation) {
4265
+ const existing = this.inFlightOperations.get(key);
4266
+ if (existing) {
4267
+ return existing;
4268
+ }
4269
+ // Prevent new operations from starting during cleanup
4270
+ if (this._isCleaningUp) {
4271
+ return Promise.reject(new Error('Cannot start new operation: client is cleaning up'));
4272
+ }
4273
+ const promise = (async () => {
4274
+ try {
4275
+ return await operation();
4276
+ }
4277
+ finally {
4278
+ // Always cleanup, regardless of success or failure
4279
+ // This allows retries if the operation failed
4280
+ // Safe to delete even if map was cleared during cleanup (delete is a no-op on non-existent key)
4281
+ this.inFlightOperations.delete(key);
4282
+ }
4283
+ })();
4284
+ // Store the promise IMMEDIATELY so concurrent calls wait for the same operation
4285
+ // This must happen synchronously before any await in the promise
4286
+ this.inFlightOperations.set(key, promise);
4287
+ return promise;
4288
+ }
4289
+ /**
4290
+ * Cleans up the client by unsubscribing from all topics and stopping publishing for all topics.
4291
+ * Prevents new in-flight operations from starting, but allows existing ones to complete naturally.
4292
+ * The socket is closed immediately, which will cause any in-flight operations to fail.
4293
+ */
4294
+ cleanup() {
4295
+ pubsubLogger.debug('Topic cleanup initiated', {
4296
+ topicCount: this.topics.size,
4297
+ prefixTopicCount: this.prefixTopics.size,
4298
+ inFlightOperations: this.inFlightOperations.size,
4299
+ });
4300
+ // Prevent new operations from starting
4301
+ this._isCleaningUp = true;
4302
+ this.topics.forEach((topic) => {
4303
+ topic.unsubscribeAll();
4304
+ if (topic.pubuid != null) {
4305
+ try {
4306
+ topic.unpublish();
4307
+ }
4308
+ catch {
4309
+ /* not publisher / already unpublished */
4310
+ }
4311
+ }
4312
+ });
4313
+ this.prefixTopics.forEach((prefixTopic) => {
4314
+ prefixTopic.unsubscribeAll();
4315
+ });
4316
+ this._messenger.socket.close();
4317
+ if (typeof window !== 'undefined') {
4318
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
4319
+ }
4320
+ // Clear the in-flight operations map
4321
+ // Existing operations will complete/fail naturally when the socket closes
4322
+ // Their finally blocks will try to delete from the map, but that's safe (no-op if already cleared)
4323
+ this.inFlightOperations.clear();
4324
+ }
4325
+ /**
4326
+ * Removes this client, its messenger, and its socket from their singleton maps.
4327
+ * Call after {@link cleanup} so a later getInstance creates a fresh client.
4328
+ */
4329
+ releaseInstance() {
4330
+ for (const [key, instance] of PubSubClient._instances) {
4331
+ if (instance === this) {
4332
+ PubSubClient._instances.delete(key);
4333
+ }
4334
+ }
4335
+ this._messenger.releaseInstance();
4336
+ }
4337
+ }
4338
+ PubSubClient._instances = new Map();
4339
+
4340
+ function isZodType(value) {
4341
+ return typeof value === 'object' && value !== null && typeof value.parse === 'function';
4342
+ }
4343
+ /**
4344
+ * Returns true when the argument is a WPILib-style struct type descriptor (e.g. `Pose2d`).
4345
+ * @param value - The value to check.
4346
+ * @returns Whether the value is a struct type descriptor.
4347
+ */
4348
+ function isStructTypeDescriptor(value) {
4349
+ return (typeof value === 'object' &&
4350
+ value !== null &&
4351
+ typeof value.typeName === 'string' &&
4352
+ isZodType(value.schema));
4353
+ }
4354
+ /** Zod schema for {@link Translation2d}. */
4355
+ const Translation2dSchema = z.object({
4356
+ x: z.number(),
4357
+ y: z.number(),
4358
+ });
4359
+ /** WPILib-style `Translation2d.struct` descriptor. */
4360
+ const Translation2d = {
4361
+ typeName: 'Translation2d',
4362
+ schema: Translation2dSchema,
4363
+ };
4364
+ /** Zod schema for {@link Rotation2d}. */
4365
+ const Rotation2dSchema = z.object({
4366
+ value: z.number(),
4367
+ });
4368
+ /** WPILib-style `Rotation2d.struct` descriptor. */
4369
+ const Rotation2d = {
4370
+ typeName: 'Rotation2d',
4371
+ schema: Rotation2dSchema,
4372
+ };
4373
+ /** Zod schema for {@link Pose2d}. */
4374
+ const Pose2dSchema = z.object({
4375
+ translation: Translation2dSchema,
4376
+ rotation: Rotation2dSchema,
4377
+ });
4378
+ /** WPILib-style `Pose2d.struct` descriptor. */
4379
+ const Pose2d = {
4380
+ typeName: 'Pose2d',
4381
+ schema: Pose2dSchema,
4382
+ };
4383
+ /** Zod schema for {@link Transform2d}. */
4384
+ const Transform2dSchema = z.object({
4385
+ translation: Translation2dSchema,
4386
+ rotation: Rotation2dSchema,
4387
+ });
4388
+ /** WPILib-style `Transform2d.struct` descriptor. */
4389
+ const Transform2d = {
4390
+ typeName: 'Transform2d',
4391
+ schema: Transform2dSchema,
4392
+ };
4393
+ /** Zod schema for {@link Twist2d}. */
4394
+ const Twist2dSchema = z.object({
4395
+ dx: z.number(),
4396
+ dy: z.number(),
4397
+ dtheta: z.number(),
4398
+ });
4399
+ /** WPILib-style `Twist2d.struct` descriptor. */
4400
+ const Twist2d = {
4401
+ typeName: 'Twist2d',
4402
+ schema: Twist2dSchema,
4403
+ };
4404
+ /** Zod schema for {@link Translation3d}. */
4405
+ const Translation3dSchema = z.object({
4406
+ x: z.number(),
4407
+ y: z.number(),
4408
+ z: z.number(),
4409
+ });
4410
+ /** WPILib-style `Translation3d.struct` descriptor. */
4411
+ const Translation3d = {
4412
+ typeName: 'Translation3d',
4413
+ schema: Translation3dSchema,
4414
+ };
4415
+ /** Zod schema for {@link Quaternion}. */
4416
+ const QuaternionSchema = z.object({
4417
+ w: z.number(),
4418
+ x: z.number(),
4419
+ y: z.number(),
4420
+ z: z.number(),
4421
+ });
4422
+ /** WPILib-style `Quaternion.struct` descriptor. */
4423
+ const Quaternion = {
4424
+ typeName: 'Quaternion',
4425
+ schema: QuaternionSchema,
4426
+ };
4427
+ /** Zod schema for {@link Rotation3d}. */
4428
+ const Rotation3dSchema = z.object({
4429
+ q: QuaternionSchema,
4430
+ });
4431
+ /** WPILib-style `Rotation3d.struct` descriptor. */
4432
+ const Rotation3d = {
4433
+ typeName: 'Rotation3d',
4434
+ schema: Rotation3dSchema,
4435
+ };
4436
+ /** Zod schema for {@link Pose3d}. */
4437
+ const Pose3dSchema = z.object({
4438
+ translation: Translation3dSchema,
4439
+ rotation: Rotation3dSchema,
4440
+ });
4441
+ /** WPILib-style `Pose3d.struct` descriptor. */
4442
+ const Pose3d = {
4443
+ typeName: 'Pose3d',
4444
+ schema: Pose3dSchema,
4445
+ };
4446
+ /** Zod schema for {@link Transform3d}. */
4447
+ const Transform3dSchema = z.object({
4448
+ translation: Translation3dSchema,
4449
+ rotation: Rotation3dSchema,
4450
+ });
4451
+ /** WPILib-style `Transform3d.struct` descriptor. */
4452
+ const Transform3d = {
4453
+ typeName: 'Transform3d',
4454
+ schema: Transform3dSchema,
4455
+ };
4456
+ /** Zod schema for {@link Twist3d}. */
4457
+ const Twist3dSchema = z.object({
4458
+ dx: z.number(),
4459
+ dy: z.number(),
4460
+ dz: z.number(),
4461
+ rx: z.number(),
4462
+ ry: z.number(),
4463
+ rz: z.number(),
4464
+ });
4465
+ /** WPILib-style `Twist3d.struct` descriptor. */
4466
+ const Twist3d = {
4467
+ typeName: 'Twist3d',
4468
+ schema: Twist3dSchema,
4469
+ };
4470
+
4471
+ /** NetworkTables class for interacting with NetworkTables over a WebSocket connection. */
4472
+ class NetworkTables {
4473
+ /**
4474
+ * Creates a new NetworkTables instance.
4475
+ * @param props - The properties to use to create the instance.
4476
+ * @throws Error if the team number or URI is not provided.
4477
+ */
4478
+ constructor(props) {
4479
+ this.retainCount = 0;
4480
+ if (props.team) {
4481
+ this.uri = getRobotAddress(props.team, props.platform);
4482
+ defaultLogger.debug('Instance created', {
4483
+ team: props.team,
4484
+ platform: props.platform ?? 'roborio',
4485
+ uri: this.uri,
4486
+ port: props.port,
4487
+ });
4488
+ }
4489
+ else if (props.uri) {
4490
+ this.uri = props.uri;
4491
+ defaultLogger.debug('Instance created', { uri: this.uri, port: props.port });
4492
+ }
4493
+ else {
4494
+ throw new Error('Must provide either a team number or URI.');
4495
+ }
4496
+ this.port = props.port;
4497
+ NetworkTables._instances.set(`${this.uri}:${this.port}`, this);
4498
+ this._client = PubSubClient.getInstance(Util.createServerUrl(this.uri, this.port));
4499
+ }
4500
+ /**
4501
+ * Creates a new NetworkTables instance if it does not exist.
4502
+ * @param team - The team number of the robot.
4503
+ * @param port - The port to connect to the robot on. Defaults to 5810.
4504
+ * @param platform - RoboRIO mDNS (`roborio-{team}-frc.local`, default) or SystemCore team IP (`10.TE.AM.2`).
4505
+ * @returns The NetworkTables instance.
4506
+ * @throws Error if the team number is not provided.
4507
+ */
4508
+ static getInstanceByTeam(team, port = 5810, platform = 'roborio') {
4509
+ const uri = getRobotAddress(team, platform);
4510
+ const key = `${uri}:${port}`;
4511
+ let instance = this._instances.get(key);
4512
+ if (!instance) {
4513
+ instance = new this({ team, port, platform });
4514
+ }
4515
+ else {
4516
+ defaultLogger.debug('Instance retrieved from cache', { team, platform, uri, port });
4517
+ }
4518
+ return instance;
4519
+ }
4520
+ /**
4521
+ * Creates a new NetworkTables instance if it does not exist.
4522
+ * @param uri - The URI of the robot.
4523
+ * @param port - The port to connect to the robot on. Defaults to 5810.
4524
+ * @returns The NetworkTables instance.
4525
+ * @throws Error if the URI is not provided.
4526
+ */
4527
+ static getInstanceByURI(uri, port = 5810) {
4528
+ const key = `${uri}:${port}`;
4529
+ let instance = this._instances.get(key);
4530
+ if (!instance) {
4531
+ instance = new this({ uri, port });
4532
+ }
4533
+ else {
4534
+ defaultLogger.debug('Instance retrieved from cache', { uri, port });
4535
+ }
4536
+ return instance;
4537
+ }
4538
+ /**
4539
+ * Returns the URI of the server.
4540
+ * @returns The robot address.
4541
+ */
4542
+ getURI() {
4543
+ return this.uri;
4544
+ }
4545
+ /**
4546
+ * Changes the URI of the server.
4547
+ * @param uri - The new URI of the server.
4548
+ * @param port - The port to connect to the server on. Defaults to 5810.
4549
+ */
4550
+ changeURI(uri, port = 5810) {
4551
+ const oldUri = this.uri;
4552
+ const oldPort = this.port;
4553
+ defaultLogger.info('URI changed', { oldUri, oldPort, newUri: uri, newPort: port });
4554
+ // Update instance map so getInstanceByURI/getInstanceByTeam return this instance for the new target
4555
+ NetworkTables._instances.delete(`${oldUri}:${oldPort}`);
4556
+ this.uri = uri;
4557
+ this.port = port;
4558
+ NetworkTables._instances.set(`${this.uri}:${this.port}`, this);
4559
+ this._client.reinstantiate(Util.createServerUrl(uri, port));
4560
+ }
4561
+ /**
4562
+ * Changes the server using a team number.
4563
+ * @param team - The team number of the robot.
4564
+ * @param port - The port to connect to the server on. Defaults to 5810.
4565
+ * @param platform - RoboRIO mDNS (default) or SystemCore team IP.
4566
+ */
4567
+ changeTeam(team, port = 5810, platform = 'roborio') {
4568
+ this.changeURI(getRobotAddress(team, platform), port);
4569
+ }
4570
+ /**
4571
+ * Returns the port to connect to the robot on.
4572
+ * @returns The port number.
4573
+ */
4574
+ getPort() {
4575
+ return this.port;
4576
+ }
4577
+ /**
4578
+ * Returns whether the robot is currently connected.
4579
+ * @returns Whether the robot is connected.
4580
+ */
4581
+ isRobotConnected() {
4582
+ return this._client.messenger.socket.isConnected();
4583
+ }
4584
+ /**
4585
+ * Returns whether the robot is currently connecting.
4586
+ * @returns Whether the robot is connecting.
4587
+ */
4588
+ isRobotConnecting() {
4589
+ return this._client.messenger.socket.isConnecting();
4590
+ }
4591
+ /**
4592
+ * Returns the best round-trip time to the robot in milliseconds.
4593
+ * @returns RTT in ms, or -1 if not connected or not yet measured.
4594
+ */
4595
+ getRttMs() {
4596
+ return this._client.messenger.socket.getBestRttMs();
4597
+ }
4598
+ /**
4599
+ * Adds a listener for robot connection status updates.
4600
+ * @param callback - The callback to call when the connection status changes.
4601
+ * @param immediateNotify - When true, also notifies once with the current status on a microtask after
4602
+ * this method returns (so the returned disposer can be called from that first notification).
4603
+ * @returns A function to remove the listener.
4604
+ */
4605
+ addRobotConnectionListener(callback, immediateNotify) {
4606
+ defaultLogger.debug('Connection listener added', { immediateNotify, uri: this.uri, port: this.port });
4607
+ return this._client.messenger.socket.addConnectionListener(callback, immediateNotify);
4608
+ }
4609
+ /**
4610
+ * Stops automatic reconnection to the server. Use when the user dismisses the connection
4611
+ * overlay (e.g. Escape) so the client does not keep trying to reconnect in the background.
4612
+ */
4613
+ stopAutoConnect() {
4614
+ this._client.messenger.socket.stopAutoConnect();
4615
+ }
4616
+ /**
4617
+ * Resumes automatic reconnection to the server after a previous call to stopAutoConnect.
4618
+ */
4619
+ startAutoConnect() {
4620
+ this._client.messenger.socket.startAutoConnect();
4621
+ }
4622
+ /**
4623
+ * Disconnects from the server, unsubscribes/unpublishes all topics, and drops this instance
4624
+ * (and the underlying PubSubClient/Messenger/Socket singletons) so they do not leak.
4625
+ */
4626
+ close() {
4627
+ defaultLogger.info('Closing NetworkTables instance', { uri: this.uri, port: this.port });
4628
+ this.retainCount = 0;
4629
+ this._client.cleanup();
4630
+ this._client.releaseInstance();
4631
+ NetworkTables._instances.delete(`${this.uri}:${this.port}`);
4632
+ }
4633
+ /**
4634
+ * Increments the retain count used by NtcoreProvider so multiple trees can share
4635
+ * this singleton without tearing it down on the first unmount.
4636
+ */
4637
+ retain() {
4638
+ this.retainCount++;
4639
+ }
4640
+ /**
4641
+ * Decrements the retain count and calls {@link close} when it reaches zero.
4642
+ */
4643
+ release() {
4644
+ this.retainCount--;
4645
+ if (this.retainCount <= 0) {
4646
+ this.close();
4647
+ }
4648
+ }
4649
+ createTopic(name, typeInfo, defaultValue) {
4650
+ if (typeInfo === NetworkTablesTypeInfos.kProtobuf || typeInfo[1] === 'protobuf') {
4651
+ throw new Error(`Protobuf types are not allowed in createTopic. Use getProtobufTopic('${name}', options) instead for proper encoding/decoding support.`);
4652
+ }
4653
+ if (typeof typeInfo[1] === 'string' && typeInfo[1].startsWith('struct:')) {
4654
+ throw new Error(`Struct types are not allowed in createTopic. Use getStructTopic('${name}', options) instead for proper encoding/decoding support.`);
4655
+ }
4656
+ defaultLogger.debug('Topic created', { topicName: name, type: typeInfo[1] });
4657
+ if (typeInfo === NetworkTablesTypeInfos.kJson || typeInfo[1] === 'json') {
4658
+ return this.getJsonTopic(name, defaultValue);
4659
+ }
4660
+ return new NetworkTablesTopic(this._client, name, typeInfo, defaultValue);
4661
+ }
4662
+ /**
4663
+ * Creates a boolean topic.
4664
+ * @param name - Topic name (e.g. `/MyTable/Enabled`).
4665
+ * @param defaultValue - Optional value shown before the first server update.
4666
+ */
4667
+ getBooleanTopic(name, defaultValue) {
4668
+ return this.createTopic(name, NetworkTablesTypeInfos.kBoolean, defaultValue);
4669
+ }
4670
+ /**
4671
+ * Creates a double (IEEE-754) topic.
4672
+ * @param name - Topic name (e.g. `/MyTable/Gyro`).
4673
+ * @param defaultValue - Optional value shown before the first server update.
4674
+ */
4675
+ getDoubleTopic(name, defaultValue) {
4676
+ return this.createTopic(name, NetworkTablesTypeInfos.kDouble, defaultValue);
4677
+ }
4678
+ /**
4679
+ * Creates an integer topic.
4680
+ * @param name - Topic name.
4681
+ * @param defaultValue - Optional value shown before the first server update.
4682
+ */
4683
+ getIntegerTopic(name, defaultValue) {
4684
+ return this.createTopic(name, NetworkTablesTypeInfos.kInteger, defaultValue);
4685
+ }
4686
+ /**
4687
+ * Creates a float topic.
4688
+ * @param name - Topic name.
4689
+ * @param defaultValue - Optional value shown before the first server update.
4690
+ */
4691
+ getFloatTopic(name, defaultValue) {
4692
+ return this.createTopic(name, NetworkTablesTypeInfos.kFloat, defaultValue);
4693
+ }
4694
+ /**
4695
+ * Creates a string topic.
4696
+ * @param name - Topic name (e.g. `/MyTable/AutoMode`).
4697
+ * @param defaultValue - Optional value shown before the first server update.
4698
+ */
4699
+ getStringTopic(name, defaultValue) {
4700
+ return this.createTopic(name, NetworkTablesTypeInfos.kString, defaultValue);
4701
+ }
4702
+ /**
4703
+ * Creates a boolean-array topic.
4704
+ * @param name - Topic name.
4705
+ * @param defaultValue - Optional value shown before the first server update.
4706
+ */
4707
+ getBooleanArrayTopic(name, defaultValue) {
4708
+ return this.createTopic(name, NetworkTablesTypeInfos.kBooleanArray, defaultValue);
4709
+ }
4710
+ /**
4711
+ * Creates a double-array topic.
4712
+ * @param name - Topic name.
4713
+ * @param defaultValue - Optional value shown before the first server update.
4714
+ */
4715
+ getDoubleArrayTopic(name, defaultValue) {
4716
+ return this.createTopic(name, NetworkTablesTypeInfos.kDoubleArray, defaultValue);
4717
+ }
4718
+ /**
4719
+ * Creates an integer-array topic.
4720
+ * @param name - Topic name.
4721
+ * @param defaultValue - Optional value shown before the first server update.
4722
+ */
4723
+ getIntegerArrayTopic(name, defaultValue) {
4724
+ return this.createTopic(name, NetworkTablesTypeInfos.kIntegerArray, defaultValue);
4725
+ }
4726
+ /**
4727
+ * Creates a float-array topic.
4728
+ * @param name - Topic name.
4729
+ * @param defaultValue - Optional value shown before the first server update.
4730
+ */
4731
+ getFloatArrayTopic(name, defaultValue) {
4732
+ return this.createTopic(name, NetworkTablesTypeInfos.kFloatArray, defaultValue);
4733
+ }
4734
+ /**
4735
+ * Creates a string-array topic.
4736
+ * @param name - Topic name.
4737
+ * @param defaultValue - Optional value shown before the first server update.
4738
+ */
4739
+ getStringArrayTopic(name, defaultValue) {
4740
+ return this.createTopic(name, NetworkTablesTypeInfos.kStringArray, defaultValue);
4741
+ }
4742
+ /**
4743
+ * Creates a raw (`uint8[]`) topic. Values are `Uint8Array`.
4744
+ * @param name - Topic name.
4745
+ * @param defaultValue - Optional value shown before the first server update.
4746
+ */
4747
+ getRawTopic(name, defaultValue) {
4748
+ return this.createTopic(name, NetworkTablesTypeInfos.kUint8Array, defaultValue);
4749
+ }
4750
+ /**
4751
+ * Creates a JSON topic with optional Zod validation.
4752
+ * If a topic with the same name already exists, the existing instance is returned and options are applied.
4753
+ * @param name - Topic name.
4754
+ * @param defaultValue - Optional value shown before the first server update.
4755
+ * @param options - Optional `validator` schema.
4756
+ */
4757
+ getJsonTopic(name, defaultValue, options) {
4758
+ const existingTopic = this._client.getTopicFromName(name);
4759
+ if (existingTopic instanceof NetworkTablesJsonTopic) {
4760
+ existingTopic.applyOptions({
4761
+ defaultValue,
4762
+ validator: options?.validator,
4763
+ });
4764
+ return existingTopic;
4765
+ }
4766
+ return new NetworkTablesJsonTopic(this._client, name, defaultValue, options);
4767
+ }
4768
+ /**
4769
+ * Creates a protobuf topic.
4770
+ * If a topic with the same name already exists, the existing instance is returned and options are applied.
4771
+ */
4772
+ getProtobufTopic(name, options) {
4773
+ const existingTopic = this._client.getTopicFromName(name);
4774
+ if (existingTopic instanceof NetworkTablesProtobufTopic) {
4775
+ existingTopic.applyOptions(options);
4776
+ return existingTopic;
4777
+ }
4778
+ return new NetworkTablesProtobufTopic(this._client, name, options);
4779
+ }
4780
+ getStructTopic(name, typeOrOptions, maybeOptions) {
4781
+ const options = isStructTypeDescriptor(typeOrOptions)
4782
+ ? {
4783
+ typeName: typeOrOptions.typeName,
4784
+ validator: typeOrOptions.schema,
4785
+ defaultValue: maybeOptions?.defaultValue,
4786
+ schema: maybeOptions?.schema,
4787
+ }
4788
+ : (typeOrOptions ?? {});
4789
+ const existingTopic = this._client.getTopicFromName(name);
4790
+ if (existingTopic instanceof NetworkTablesStructTopic) {
4791
+ if (options.typeName) {
4792
+ const requestedType = `struct:${options.typeName}`;
4793
+ const existingType = existingTopic.typeInfo[1];
4794
+ if (existingType !== requestedType) {
4795
+ throw new Error(`getStructTopic: type mismatch for topic "${name}". ` +
4796
+ `Existing type "${existingType}" does not match requested typeName "${options.typeName}". ` +
4797
+ `A struct topic's type is immutable once created.`);
4798
+ }
4799
+ }
4800
+ existingTopic.applyOptions(options);
4801
+ return existingTopic;
4802
+ }
4803
+ if (!options.typeName) {
4804
+ throw new Error(`getStructTopic: cannot create topic "${name}" without a type. Pass a descriptor (e.g. Pose2d) or { typeName }.`);
4805
+ }
4806
+ return new NetworkTablesStructTopic(this._client, name, options);
4807
+ }
4808
+ /**
4809
+ * Creates a prefix topic (wildcard subscription).
4810
+ * @param prefix - The prefix of the topic.
4811
+ * @returns The topic.
4812
+ */
4813
+ getPrefixTopic(prefix) {
4814
+ defaultLogger.debug('Prefix topic created', { prefix });
4815
+ return new NetworkTablesPrefixTopic(this._client, prefix);
4816
+ }
4817
+ /**
4818
+ * Sets the global log level for all modules.
4819
+ * @param level - The log level to set.
4820
+ */
4821
+ static setLogLevel(level) {
4822
+ setLogLevel(level);
4823
+ }
4824
+ /**
4825
+ * Sets the log level for a specific module.
4826
+ * @param module - The module name ('socket', 'messenger', 'pubsub', or 'default').
4827
+ * @param level - The log level to set.
4828
+ */
4829
+ static setModuleLogLevel(module, level) {
4830
+ setModuleLogLevel(module, level);
4831
+ }
4832
+ /**
4833
+ * Gets the current log level for a specific module.
4834
+ * @param module - The module name ('socket', 'messenger', 'pubsub', or 'default').
4835
+ * @returns The current log level.
4836
+ */
4837
+ static getModuleLogLevel(module = 'default') {
4838
+ return getModuleLogLevel(module);
4839
+ }
4840
+ }
4841
+ /** The instance of the NetworkTables class. */
4842
+ NetworkTables._instances = new Map();
4843
+
4844
+ export { BUILT_IN_STRUCT_TYPE_NAMES, LogLevel, NetworkTables, NetworkTablesJsonTopic, NetworkTablesPrefixTopic, NetworkTablesProtobufTopic, NetworkTablesStructTopic, NetworkTablesTopic, NetworkTablesTypeInfos, Pose2d, Pose2dSchema, Pose3d, Pose3dSchema, Quaternion, QuaternionSchema, Rotation2d, Rotation2dSchema, Rotation3d, Rotation3dSchema, SYSTEMCORE_MDNS_HOST, Transform2d, Transform2dSchema, Transform3d, Transform3dSchema, Translation2d, Translation2dSchema, Translation3d, Translation3dSchema, Twist2d, Twist2dSchema, Twist3d, Twist3dSchema, getBuiltInDescriptor, getModuleLogLevel, getRobotAddress, getTeamIpAddress, isStructTypeDescriptor, pack, parseRobotAddress, setLogLevel, setModuleLogLevel, unpack };
4845
+ //# sourceMappingURL=index.esm.js.map