@2702rebels/ntcore 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,935 @@
1
+ import { Decoder, Encoder } from "@msgpack/msgpack";
2
+
3
+ import { Logger } from "@2702rebels/logger";
4
+
5
+ import { DataTypeCodes, NetworkSpecs } from "./NT4Spec";
6
+ import { NT4Subscription } from "./NT4Subscription";
7
+ import { NT4Topic } from "./NT4Topic";
8
+ import { NT4WebSocketAsync } from "./NT4WebSocketAsync";
9
+
10
+ import type { DataType, TextDataFrame } from "./NT4Spec";
11
+ import type { SubscriptionOptions, TopicProperties } from "./NT4Types";
12
+
13
+ export type NT4ClientConnectionState = "disconnected" | "connecting" | "connected";
14
+
15
+ export type NT4ClientOptions = {
16
+ /** Indicates that secure channel should be used. */
17
+ secure?: boolean;
18
+
19
+ /**
20
+ * Invoked when the client connects.
21
+ */
22
+ onConnect?: () => void;
23
+
24
+ /**
25
+ * Invoked when the client disconnects.
26
+ */
27
+ onDisconnect?: () => void;
28
+
29
+ /**
30
+ * Invoked when new topic is announced by the server.
31
+ *
32
+ * @param topic topic announced
33
+ */
34
+ onTopicAnnounced?: (topic: NT4Topic) => void;
35
+
36
+ /**
37
+ * Invoked when a topic is removed by the server.
38
+ *
39
+ * @param topic topic removed
40
+ */
41
+ onTopicRemoved?: (topic: NT4Topic) => void;
42
+
43
+ /**
44
+ * Invoked when a topic properties are updated by the server.
45
+ *
46
+ * @param topic topic updated
47
+ */
48
+ onTopicUpdated?: (topic: NT4Topic) => void;
49
+
50
+ /**
51
+ * Invoked when the topic receives new data.
52
+ *
53
+ * @param topic topic receiving data
54
+ * @param value value received
55
+ * @param timestamp server timestamp in microseconds
56
+ */
57
+ onDataReceived?: (topic: NT4Topic, value: unknown, timestamp: number) => void;
58
+
59
+ /**
60
+ * Retry policy that accepts the current number of connection attempts
61
+ * and returns the timeout in milliseconds or -1 to abort.
62
+ *
63
+ * @param attempts number of subsequent connection attempts
64
+ * @returns timeout in milliseconds or -1 to abort
65
+ */
66
+ retryPolicy?: (attempts: number) => number;
67
+
68
+ /**
69
+ * Overrides default timeout for ping-pong packets.
70
+ *
71
+ * Defaults:
72
+ * - NT 4.0: 3000 milliseconds
73
+ * - NT 4.1: 1000 milliseconds
74
+ */
75
+ pingTimeoutMilliseconds?: number;
76
+ };
77
+
78
+ /** Determines whether the specified predicate callback returns true for any element of a map. */
79
+ function some<K, V>(map: Map<K, V>, predicate: (entry: V) => boolean) {
80
+ for (const v of map.values()) {
81
+ if (predicate(v)) {
82
+ return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * Implements a NetworkTable4 client that is responsible for establishing and maintaining a connection.
90
+ */
91
+ export class NT4Client {
92
+ private readonly port: number;
93
+ private readonly clientId: string;
94
+ private readonly dataChannel: NT4WebSocketAsync;
95
+ private readonly rttChannel: NT4WebSocketAsync;
96
+ private readonly decoder = new Decoder();
97
+ private readonly encoder = new Encoder();
98
+ private readonly onConnect?: NT4ClientOptions["onConnect"];
99
+ private readonly onDisconnect?: NT4ClientOptions["onDisconnect"];
100
+ private readonly onTopicAnnounced?: NT4ClientOptions["onTopicAnnounced"];
101
+ private readonly onTopicRemoved?: NT4ClientOptions["onTopicRemoved"];
102
+ private readonly onTopicUpdated?: NT4ClientOptions["onTopicUpdated"];
103
+ private readonly onDataReceived?: NT4ClientOptions["onDataReceived"];
104
+ private readonly retryPolicy: Required<NT4ClientOptions>["retryPolicy"];
105
+ private pingTimeoutOverride_ms?: NT4ClientOptions["pingTimeoutMilliseconds"];
106
+
107
+ private subscriptions = new Map<number, NT4Subscription>();
108
+ private publishedTopics = new Map<string, NT4Topic>();
109
+ private announcedTopics = new Map<string, NT4Topic>();
110
+ private announcedTopicsIndex = new Map<number, NT4Topic>();
111
+
112
+ private connectionEstablished = false;
113
+ private connectionInitiated = false;
114
+ private connectionAttempts = 0;
115
+ private connectionState: NT4ClientConnectionState = "disconnected";
116
+ private networkLatency_μs: number | null = null;
117
+ private serverTimeOffset_μs: number | null = null;
118
+ private pingTimeout_ms = 0;
119
+ private pongTimestamp_μs = 0;
120
+ private pingIntervalId: ReturnType<typeof setInterval> | null = null;
121
+ private pongIntervalId: ReturnType<typeof setInterval> | null = null;
122
+
123
+ /** Truncated at 15 seconds exponential backoff retry policy starting at 1 second. */
124
+ public static defaultRetryPolicy(attempts: number) {
125
+ return Math.round(Math.min(15, Math.max(1, 1.2 ** Math.min(15, attempts))) * 1000);
126
+ }
127
+
128
+ /** Cleans up closed data channel. */
129
+ private cleanup() {
130
+ Logger.Default.debug(`[NT4Client] cleaning up`);
131
+
132
+ if (this.pingIntervalId) {
133
+ clearInterval(this.pingIntervalId);
134
+ this.pingIntervalId = null;
135
+ }
136
+
137
+ if (this.pongIntervalId) {
138
+ clearInterval(this.pongIntervalId);
139
+ this.pongIntervalId = null;
140
+ }
141
+
142
+ this.rttChannel.close();
143
+
144
+ if (this.connectionEstablished && this.onDisconnect) {
145
+ this.onDisconnect();
146
+ }
147
+
148
+ this.connectionEstablished = false;
149
+ this.connectionState = "disconnected";
150
+
151
+ this.announcedTopics.clear();
152
+ this.announcedTopicsIndex.clear();
153
+
154
+ for (const topic of this.publishedTopics.values()) {
155
+ topic.resetRetainedTimestamp();
156
+ }
157
+
158
+ this.pongTimestamp_μs = 0;
159
+ this.serverTimeOffset_μs = null;
160
+ this.networkLatency_μs = null;
161
+ }
162
+
163
+ /** Attempts to establish a connection, continuously retrying using policy. */
164
+ private async connectWithRetry() {
165
+ this.connectionState = "connecting";
166
+
167
+ // check for cancellation after each await, since disconnect could have been called
168
+ // while we were awaiting and therefore we should abort here or below
169
+ if (!this.connectionInitiated) {
170
+ Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
171
+ return;
172
+ }
173
+
174
+ // probe remote endpoint status (liveness check)
175
+ let response: Response | null = null;
176
+ try {
177
+ response = await fetch(`http://${this.serverAddress}:${this.port}`, {
178
+ signal: AbortSignal.timeout(250),
179
+ });
180
+ } catch {
181
+ // swallow exception here; we are checking for response status below
182
+ }
183
+
184
+ if (!this.connectionInitiated) {
185
+ Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
186
+ return;
187
+ }
188
+
189
+ // if endpoint seems to be alive try to establish websocket connection,
190
+ // otherwise, wait and retry again
191
+ if (response && response.ok) {
192
+ try {
193
+ Logger.Default.debug(`[NT4Client] opening channel to ${this.serverAddress}:${this.port}`);
194
+ await this.dataChannel.connect();
195
+
196
+ // reset connection attempts once the data channel is successfully connected
197
+ this.connectionAttempts = 0;
198
+
199
+ if (!this.connectionInitiated) {
200
+ Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
201
+ return;
202
+ }
203
+
204
+ this.connectionEstablished = true;
205
+ this.connectionState = "connected";
206
+
207
+ Logger.Default.debug(
208
+ `[NT4Client] connected to ${this.serverAddress}:${this.port}, subprotocol = ${this.dataChannel.protocol}`
209
+ );
210
+
211
+ let pingPeriod_ms = NetworkSpecs.pingPeriod40;
212
+ this.pingTimeout_ms = this.pingTimeoutOverride_ms ?? NetworkSpecs.pingTimeout40;
213
+
214
+ if (this.dataChannel.protocol === NetworkSpecs.protocol41) {
215
+ Logger.Default.debug(`[NT4Client] server speaks v4.1, creating separate RTT channel`);
216
+ await this.rttChannel.connect();
217
+
218
+ if (!this.connectionInitiated) {
219
+ Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
220
+ return;
221
+ }
222
+
223
+ pingPeriod_ms = NetworkSpecs.pingPeriod41;
224
+ this.pingTimeout_ms = this.pingTimeoutOverride_ms ?? NetworkSpecs.pingTimeout41;
225
+ }
226
+
227
+ Logger.Default.debug(`[NT4Client] sending timestamp and re-establishing subscriptions`);
228
+
229
+ // send timestamp to establish RTT
230
+ this.sendRTT();
231
+
232
+ // and start periodic ping
233
+ this.pongTimestamp_μs = 0;
234
+ this.pingIntervalId = setInterval(() => this.sendRTT(), pingPeriod_ms);
235
+ this.pongIntervalId = setInterval(() => this.checkTimeout(), pingPeriod_ms);
236
+
237
+ // restore client subscriptions
238
+ for (const subscription of this.subscriptions.values()) {
239
+ this.subscribeCore(subscription);
240
+ }
241
+
242
+ // restore client publishers
243
+ // retained values won't be sent until clock is synchronized
244
+ for (const topic of this.publishedTopics.values()) {
245
+ this.publishCore(topic);
246
+ }
247
+
248
+ if (this.onConnect) {
249
+ this.onConnect();
250
+ }
251
+
252
+ return;
253
+ } catch (exception) {
254
+ Logger.Default.error(`[NT4Client] failed to open channel`, exception);
255
+
256
+ await this.rttChannel.close();
257
+ await this.dataChannel.close();
258
+ }
259
+ }
260
+
261
+ // retry logic
262
+ this.connectionAttempts++;
263
+ const timeout = this.retryPolicy(this.connectionAttempts);
264
+
265
+ if (timeout < 0) {
266
+ Logger.Default.debug(`[NT4Client] aborting per retry policy after ${this.connectionAttempts} attempts`);
267
+ this.disconnect();
268
+ return;
269
+ }
270
+
271
+ Logger.Default.debug(`[NT4Client] retrying connection in ${timeout.toFixed(0)}ms`);
272
+ setTimeout(() => this.connectWithRetry(), timeout);
273
+ }
274
+
275
+ /** Invoked once the client-server clock is synchronized. */
276
+ private onClockSynchronized() {
277
+ Logger.Default.debug(`[NT4Client] clock synchronized, offset = ${this.serverTimeOffset_μs}`);
278
+
279
+ for (const topic of this.publishedTopics.values()) {
280
+ if (topic.retainedTimestamp != null && topic.retainedValue != null) {
281
+ this.sendValueCore(
282
+ topic,
283
+ topic.retainedValue,
284
+ topic.retainedTimestamp === 1 ? (this.serverTimeMicroseconds() ?? 1) : topic.retainedTimestamp
285
+ );
286
+ }
287
+ }
288
+ }
289
+
290
+ /** Consumes received data frame. */
291
+ private processDataFrame(data: string | BufferSource) {
292
+ if (typeof data === "string") {
293
+ let array: Array<TextDataFrame> | undefined;
294
+ try {
295
+ array = JSON.parse(data);
296
+ if (!Array.isArray(array)) {
297
+ Logger.Default.error(`[NT4Client] unexpected text frame, JSON must be an array, frame ignored`);
298
+ return;
299
+ }
300
+ } catch (exception) {
301
+ Logger.Default.error(`[NT4Client] unexpected text frame, JSON is malformed, frame ignored`, exception);
302
+ return;
303
+ }
304
+
305
+ array.forEach((d) => {
306
+ if (typeof d !== "object" || !("method" in d) || !("params" in d)) {
307
+ Logger.Default.error(`[NT4Client] malformed message in text frame, message ignored`);
308
+ return;
309
+ }
310
+
311
+ const { method, params } = d;
312
+ if (typeof method !== "string" || typeof params !== "object") {
313
+ Logger.Default.error(`[NT4Client] malformed message in text frame, message ignored`);
314
+ return;
315
+ }
316
+
317
+ switch (method) {
318
+ case "announce": {
319
+ const topic = new NT4Topic(params.id, params.name, params.type, params.properties, params.pubuid);
320
+ this.announcedTopics.set(topic.name, topic);
321
+ this.announcedTopicsIndex.set(topic.uid, topic);
322
+
323
+ if (this.onTopicAnnounced) {
324
+ this.onTopicAnnounced(topic);
325
+ }
326
+ return;
327
+ }
328
+
329
+ case "unannounce": {
330
+ const topic = this.announcedTopics.get(params.name);
331
+
332
+ // ignore topics that we haven't seen being announced
333
+ if (topic) {
334
+ this.publishedTopics.get(topic.name)?.removeRetainedValue();
335
+ this.announcedTopics.delete(topic.name);
336
+ this.announcedTopicsIndex.delete(topic.uid);
337
+
338
+ if (this.onTopicRemoved) {
339
+ this.onTopicRemoved(topic);
340
+ }
341
+ }
342
+ return;
343
+ }
344
+
345
+ case "properties": {
346
+ const topic = this.announcedTopics.get(params.name);
347
+ // ignore topics that we haven't seen being announced
348
+ if (topic) {
349
+ // https://github.com/wpilibsuite/allwpilib/blob/main/ntcore/doc/networktables4.adoc#properties-update-message-properties
350
+ // The client shall handle the update value as follows.
351
+ // If a property is not included in the update map, its value is not changed.
352
+ // If a property is provided in the update map with a value of null, the property is deleted.
353
+ topic.mergeProperties(params.update);
354
+
355
+ if (this.onTopicUpdated) {
356
+ this.onTopicUpdated(topic);
357
+ }
358
+ }
359
+ return;
360
+ }
361
+
362
+ default:
363
+ Logger.Default.error(`[NT4Client] unexpected method '${method}' in text frame message, message ignored`);
364
+ return;
365
+ }
366
+ });
367
+ } else {
368
+ for (const d of this.decoder.decodeMulti(data)) {
369
+ const [topicId, timestamp, typeCode, value] = d as [number, number, number, unknown];
370
+
371
+ // sanity checks
372
+ if (typeof topicId !== "number") {
373
+ Logger.Default.error(`[NT4Client] unexpected non-numeric topic identifier, frame ignored`);
374
+ return;
375
+ }
376
+
377
+ if (typeof timestamp !== "number") {
378
+ Logger.Default.error(`[NT4Client] unexpected non-numeric timestamp, frame ignored`);
379
+ return;
380
+ }
381
+
382
+ if (typeof typeCode !== "number") {
383
+ Logger.Default.error(`[NT4Client] unexpected non-numeric typeCode, frame ignored`);
384
+ return;
385
+ }
386
+
387
+ if (topicId === -1) {
388
+ // RTT measurement
389
+ if (typeof value !== "number") {
390
+ Logger.Default.error(`[NT4Client] unexpected non-numeric value in RTT frame, message ignored`);
391
+ continue;
392
+ }
393
+
394
+ const clientTime_μs = this.getClientTimestamp_μs();
395
+ const latency_μs = (clientTime_μs - value) / 2.0; // network latency is estimated as half of RTT
396
+
397
+ // if the RTT is less than that from previous measurements, the client shall use
398
+ // the timestamp in the message plus ½ the RTT as the server time equivalent to
399
+ // the current local time
400
+ if (this.networkLatency_μs == null || latency_μs < this.networkLatency_μs) {
401
+ const clockSynchronized = this.serverTimeOffset_μs == null;
402
+
403
+ // server timestamp is in microseconds
404
+ this.serverTimeOffset_μs = Math.round(timestamp + latency_μs - clientTime_μs);
405
+
406
+ if (clockSynchronized) {
407
+ this.onClockSynchronized();
408
+ }
409
+ }
410
+
411
+ this.networkLatency_μs = latency_μs;
412
+
413
+ // update pong timestamp (used for checking timeouts)
414
+ this.pongTimestamp_μs = clientTime_μs;
415
+ } else if (topicId > 0) {
416
+ const topic = this.announcedTopicsIndex.get(topicId);
417
+ if (topic == null) {
418
+ Logger.Default.error(`[NT4Client] unexpected topic identifier ${topicId}, message ignored`);
419
+ continue;
420
+ } else if (this.onDataReceived) {
421
+ // update locally retained value on topics published by the client
422
+ this.publishedTopics.get(topic.name)?.updateRetainedValue(value, timestamp);
423
+ this.onDataReceived(topic, value, timestamp);
424
+ }
425
+ } else {
426
+ Logger.Default.error(`[NT4Client] invalid topic identifier ${topicId}, message ignored`);
427
+ continue;
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ /** Handles general data frame received. */
434
+ private onChannelMessage = (ev: MessageEvent) => {
435
+ this.processDataFrame(ev.data);
436
+ };
437
+
438
+ /** Handles RTT data frame received. */
439
+ private onChannelMessageRTT = (ev: MessageEvent) => {
440
+ if (typeof ev.data === "string") {
441
+ Logger.Default.debug(`[NT4Client] unexpected text data frame on RTT channel, frame ignored`);
442
+ return;
443
+ }
444
+
445
+ this.processDataFrame(ev.data);
446
+ };
447
+
448
+ /** Handles channel closing for whatever reason. */
449
+ private onChannelClose = (ev: CloseEvent) => {
450
+ Logger.Default.debug(`[NT4Client] channel closed`, ev);
451
+
452
+ this.cleanup();
453
+ this.connectWithRetry();
454
+ };
455
+
456
+ /** Returns current timestamp in microseconds. This is NOT wall clock time. */
457
+ private getClientTimestamp_μs() {
458
+ // monotonically increasing, microsecond precision
459
+ return Math.round(performance.now() * 1000);
460
+ }
461
+
462
+ /** Sends data frame to publish the topic. */
463
+ private publishCore(topic: NT4Topic) {
464
+ this.dataChannel.sendText({
465
+ method: "publish",
466
+ params: {
467
+ pubuid: topic.uid,
468
+ name: topic.name,
469
+ type: topic.type,
470
+ properties: topic.properties,
471
+ },
472
+ });
473
+ }
474
+
475
+ /** Sends data frame to unpublish the topic. */
476
+ private unpublishCore(topic: NT4Topic) {
477
+ this.dataChannel.sendText({
478
+ method: "unpublish",
479
+ params: {
480
+ pubuid: topic.uid,
481
+ },
482
+ });
483
+ }
484
+
485
+ /** Sends data frame to set topic properties. */
486
+ private setPropertiesCore(name: string, properties: TopicProperties) {
487
+ this.dataChannel.sendText({
488
+ method: "setproperties",
489
+ params: {
490
+ name: name,
491
+ update: properties,
492
+ },
493
+ });
494
+ }
495
+
496
+ /** Sends data frame to subscribe the subscription. */
497
+ private subscribeCore(subscription: NT4Subscription) {
498
+ this.dataChannel.sendText({
499
+ method: "subscribe",
500
+ params: {
501
+ subuid: subscription.uid,
502
+ topics: Array.from(subscription.topics),
503
+ options: subscription.options,
504
+ },
505
+ });
506
+ }
507
+
508
+ /** Sends data frame to unsubscribe the subscription. */
509
+ private unsubscribeCore(subscription: NT4Subscription) {
510
+ this.dataChannel.sendText({
511
+ method: "unsubscribe",
512
+ params: {
513
+ subuid: subscription.uid,
514
+ },
515
+ });
516
+ }
517
+
518
+ /** Sends data frame to update topic value. */
519
+ private sendValueCore(topic: NT4Topic, value: unknown, timestamp: number) {
520
+ const frame = this.encoder.encode([topic.uid, timestamp, topic.typeCode, value]);
521
+ this.dataChannel.sendBinary(frame);
522
+ }
523
+
524
+ /**
525
+ * Sends RTT measurement binary frame.
526
+ * Uses RTT channel if available, falls back to data channel in NT4.0.
527
+ */
528
+ private sendRTT() {
529
+ // https://github.com/wpilibsuite/allwpilib/blob/main/ntcore/doc/networktables4.adoc#timestamps
530
+ // The topic ID of -1 is reserved for timestamp communication.
531
+ // Clients shall periodically (e.g. every few seconds) send, in a manner that
532
+ // minimizes transmission delays, a MessagePack message with ID of -1, a timestamp of 0,
533
+ // and an implementation-selected type and data value (typically int or float 64)
534
+ // containing its (the client’s) current local time
535
+ const timestamp = this.getClientTimestamp_μs();
536
+ const frame = this.encoder.encode([-1, 0, DataTypeCodes.double, timestamp]);
537
+ (this.rttChannel ?? this.dataChannel).sendBinary(frame);
538
+ }
539
+
540
+ /**
541
+ * Determines whether connection times out if no message has been received
542
+ * within the timeout interval since the last pong.
543
+ */
544
+ private async checkTimeout() {
545
+ if (!this.connectionEstablished || this.pongTimestamp_μs === 0) {
546
+ return;
547
+ }
548
+
549
+ const delta_μs = this.getClientTimestamp_μs() - this.pongTimestamp_μs;
550
+ if (delta_μs > this.pingTimeout_ms * 1000) {
551
+ Logger.Default.debug(
552
+ `[NT4Client] no data received in the last ${(delta_μs / 1000).toFixed(0)}ms, connection timed out`
553
+ );
554
+
555
+ await this.dataChannel.close(4001, "timeout");
556
+ this.cleanup();
557
+ this.connectWithRetry();
558
+ }
559
+ }
560
+
561
+ /** Generates random UID. */
562
+ private generateUID() {
563
+ return Math.floor(Math.random() * 0x7fffffff);
564
+ }
565
+
566
+ /** Generates random safe UID avoiding possible clashes. */
567
+ private generateSafeUID(has: (id: number) => boolean) {
568
+ let uid: number;
569
+ for (uid = this.generateUID(); has(uid); uid = this.generateUID());
570
+ return uid;
571
+ }
572
+
573
+ /** Determines whether published topic with the specified identifier exists. */
574
+ private readonly hasPublishedTopicWithId = (id: number) => some(this.publishedTopics, (_) => _.uid === id);
575
+
576
+ /** Determines whether subscription with the specified identifier exists. */
577
+ private readonly hasSubscriptionWithId = (id: number) => this.subscriptions.has(id);
578
+
579
+ /**
580
+ * Constructs an instance of NT4Client without initiating a connection.
581
+ *
582
+ * @param serverAddress NT4 server network address
583
+ * @param clientId this client identifier (cannot contain '@')
584
+ * @param options options
585
+ */
586
+ constructor(serverAddress: string, clientId: string, options: NT4ClientOptions) {
587
+ // https://github.com/wpilibsuite/allwpilib/blob/main/ntcore/doc/networktables4.adoc#websockets-protocol-configuration
588
+ // ...
589
+ // The client name does not need to be unique; multiple connections to the same name are allowed;
590
+ // the server shall ensure the name is unique (for the purposes of meta-topics) by appending a '@'
591
+ // and a unique number (if necessary). To support this, the name provided by the client should not
592
+ // contain an embedded '@'. Clients should provide a way to specify the resource name (in particular,
593
+ // the client name portion).
594
+ if (clientId.includes("@")) {
595
+ throw new Error("Client identifier must not contain '@' character");
596
+ }
597
+
598
+ this.serverAddress = serverAddress;
599
+ this.port = options.secure ? NetworkSpecs.portSecure : NetworkSpecs.portUnsecure;
600
+ this.clientId = clientId;
601
+ this.onConnect = options.onConnect;
602
+ this.onDisconnect = options.onDisconnect;
603
+ this.onTopicAnnounced = options.onTopicAnnounced;
604
+ this.onTopicRemoved = options.onTopicRemoved;
605
+ this.onTopicUpdated = options.onTopicUpdated;
606
+ this.onDataReceived = options.onDataReceived;
607
+ this.retryPolicy = options.retryPolicy ?? NT4Client.defaultRetryPolicy;
608
+ this.pingTimeoutOverride_ms = options.pingTimeoutMilliseconds;
609
+
610
+ // data messages channel
611
+ this.dataChannel = new NT4WebSocketAsync(this.serverAddress, this.port, this.clientId, {
612
+ secure: options.secure,
613
+ onMessage: this.onChannelMessage,
614
+ onClose: this.onChannelClose,
615
+ });
616
+
617
+ // RTT messages channel (only used in NT4.1)
618
+ this.rttChannel = new NT4WebSocketAsync(this.serverAddress, this.port, this.clientId, {
619
+ rtt: true,
620
+ secure: options.secure,
621
+ onMessage: this.onChannelMessageRTT,
622
+ });
623
+ }
624
+
625
+ /**
626
+ * Returns NT4 server network address the client is using.
627
+ */
628
+ public readonly serverAddress: string;
629
+
630
+ /**
631
+ * Returns current connection state.
632
+ */
633
+ public get state() {
634
+ return this.connectionState;
635
+ }
636
+
637
+ /**
638
+ * Returns current network latency in microseconds if known.
639
+ */
640
+ public get networkLatencyMicroseconds() {
641
+ return this.networkLatency_μs;
642
+ }
643
+
644
+ /**
645
+ * Returns server time in microseconds that is current or
646
+ * based on the specified client (local) time.
647
+ *
648
+ * Returns `null` if the server time offset is not established.
649
+ */
650
+ public serverTimeMicroseconds(clientTimeMicroseconds?: number) {
651
+ return this.serverTimeOffset_μs != null
652
+ ? Math.round(
653
+ (clientTimeMicroseconds != null ? clientTimeMicroseconds : this.getClientTimestamp_μs()) +
654
+ this.serverTimeOffset_μs
655
+ )
656
+ : null;
657
+ }
658
+
659
+ /**
660
+ * Initiates the connection.
661
+ * Once established the connection will be automatically kept alive by reconnecting.
662
+ */
663
+ public connect() {
664
+ if (this.connectionInitiated) {
665
+ return;
666
+ }
667
+
668
+ Logger.Default.debug(`[NT4Client] connection initiated`);
669
+ this.connectionInitiated = true;
670
+ this.connectWithRetry();
671
+ }
672
+
673
+ /**
674
+ * Terminates the connection.
675
+ * The client can be reconnected manually by calling {@link connect} again.
676
+ */
677
+ public disconnect() {
678
+ if (!this.connectionInitiated) {
679
+ return;
680
+ }
681
+
682
+ Logger.Default.debug(`[NT4Client] connection cancelled`);
683
+ this.connectionInitiated = false;
684
+ this.dataChannel.close();
685
+ this.rttChannel.close();
686
+ }
687
+
688
+ /**
689
+ * Creates a new subscription.
690
+ *
691
+ * The subscription can be created regardless of connectivity state.
692
+ * All client subscriptions are automatically restored once connection
693
+ * has been established. This is also true for reconnects following
694
+ * loss of connectivity or deliberate disconnects.
695
+ *
696
+ * Caller can {@link unsubscribe} by passing the return value of this call or
697
+ * invoke {@link unsubscribeAll} to remove all existing client subscriptions.
698
+ *
699
+ * @param topics topics or prefixes to include in the subscription
700
+ * @param options subscription options
701
+ * @returns A subscription instance
702
+ */
703
+ public subscribe(topics: Iterable<string>, options: SubscriptionOptions) {
704
+ const uid = this.generateSafeUID(this.hasSubscriptionWithId);
705
+ const subscription = new NT4Subscription(uid, topics, options);
706
+ this.subscriptions.set(subscription.uid, subscription);
707
+
708
+ if (this.connectionEstablished) {
709
+ this.subscribeCore(subscription);
710
+ }
711
+
712
+ return subscription;
713
+ }
714
+
715
+ /**
716
+ * Unsubscribes an existing subscription.
717
+ *
718
+ * @param arg subscription or subscription identifier
719
+ * @returns `true` if the subscription was successfully unsubscribed, `false` if the subscription was not found
720
+ */
721
+ public unsubscribe(arg: NT4Subscription | number) {
722
+ const subscription = typeof arg === "number" ? this.subscriptions.get(arg) : arg;
723
+ if (!subscription) {
724
+ return false;
725
+ }
726
+
727
+ this.subscriptions.delete(subscription.uid);
728
+ if (this.connectionEstablished) {
729
+ this.unsubscribeCore(subscription);
730
+ }
731
+
732
+ return true;
733
+ }
734
+
735
+ /**
736
+ * Unsubscribes all existing subscriptions.
737
+ */
738
+ public unsubscribeAll() {
739
+ for (const subscription of this.subscriptions.values()) {
740
+ this.unsubscribe(subscription);
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Publishes a topic with the specified name and type.
746
+ *
747
+ * This method registers this client as a publisher for the topic.
748
+ * It should be called before {@link setValue} or {@link setValueAt}
749
+ * can be used.
750
+ *
751
+ * Publishing a topic on a disconnected client is allowed.
752
+ * Client will automatically register such publishers once the connection
753
+ * has been established and send default or retained value as appropriate.
754
+ *
755
+ * @param topic topic name
756
+ * @param type data type
757
+ * @param properties properties
758
+ */
759
+ public publishTopic(topic: string, type: DataType | string, properties?: TopicProperties) {
760
+ let publishedTopic = this.publishedTopics.get(topic);
761
+ if (publishedTopic) {
762
+ return publishedTopic;
763
+ }
764
+
765
+ const uid = this.generateSafeUID(this.hasPublishedTopicWithId);
766
+ publishedTopic = new NT4Topic(uid, topic, type, properties, uid);
767
+ this.publishedTopics.set(publishedTopic.name, publishedTopic);
768
+
769
+ if (this.connectionEstablished) {
770
+ this.publishCore(publishedTopic);
771
+ }
772
+
773
+ return publishedTopic;
774
+ }
775
+
776
+ /**
777
+ * Un-publishes previously published topic.
778
+ *
779
+ * @param topic topic name
780
+ * @returns `true` if the topic was successfully unpublished, `false` if the topic was not published
781
+ */
782
+ public unpublishTopic(topic: string) {
783
+ const publishedTopic = this.publishedTopics.get(topic);
784
+ if (!publishedTopic) {
785
+ return false;
786
+ }
787
+
788
+ this.publishedTopics.delete(publishedTopic.name);
789
+ if (this.connectionEstablished) {
790
+ this.unpublishCore(publishedTopic);
791
+ }
792
+
793
+ return true;
794
+ }
795
+
796
+ /**
797
+ * Determines whether client already publishes the topic.
798
+ *
799
+ * @param topic topic name
800
+ */
801
+ public isTopicPublished(topic: string) {
802
+ return this.publishedTopics.has(topic);
803
+ }
804
+
805
+ /**
806
+ * Sets properties for the specified topic.
807
+ *
808
+ * @param topic topic name
809
+ * @param properties new properties
810
+ */
811
+ public setTopicProperties(topic: string, properties: TopicProperties) {
812
+ const publishedTopic = this.publishedTopics.get(topic);
813
+ if (publishedTopic) {
814
+ publishedTopic.mergeProperties(properties);
815
+ }
816
+
817
+ const announcedTopic = this.announcedTopics.get(topic);
818
+ if (announcedTopic) {
819
+ announcedTopic.mergeProperties(properties);
820
+ }
821
+
822
+ if (this.connectionEstablished) {
823
+ this.setPropertiesCore(topic, properties);
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Sets persistent flag for the topic.
829
+ *
830
+ * @param topic topic name
831
+ * @param value value to set
832
+ */
833
+ public setTopicPersistent(topic: string, value: boolean) {
834
+ this.setTopicProperties(topic, { persistent: value });
835
+ }
836
+
837
+ /**
838
+ * Sets retained flag for the topic.
839
+ *
840
+ * @param topic topic name
841
+ * @param value value to set
842
+ */
843
+ public setTopicRetained(topic: string, value: boolean) {
844
+ this.setTopicProperties(topic, { retained: value });
845
+ }
846
+
847
+ /**
848
+ * Sends a value to the published topic using the current time.
849
+ *
850
+ * This client must publish the topic with {@link publishTopic} first.
851
+ *
852
+ * @param topic topic name
853
+ * @param value value to send
854
+ */
855
+ public setValue(topic: string, value: unknown) {
856
+ this.setValueAt(topic, value, this.serverTimeMicroseconds() ?? 1);
857
+ }
858
+
859
+ /**
860
+ * Sends a default ("weak") value to the published topic.
861
+ *
862
+ * This client must publish the topic with {@link publishTopic} first.
863
+ *
864
+ * @param topic topic name
865
+ * @param value value to send
866
+ */
867
+ public setDefaultValue(topic: string, value: unknown) {
868
+ this.setValueAt(topic, value, 0);
869
+ }
870
+
871
+ /**
872
+ * Sends a value to the published topic using the specified timestamp.
873
+ *
874
+ * This client must publish the topic with {@link publishTopic} first.
875
+ *
876
+ * @param topic topic name
877
+ * @param value value to send
878
+ * @param timestamp timestamp
879
+ */
880
+ public setValueAt(topic: string, value: unknown, timestamp: number) {
881
+ const publishedTopic = this.publishedTopics.get(topic);
882
+ if (!publishedTopic) {
883
+ throw new Error("Topic must be published first");
884
+ }
885
+
886
+ // timestamps in messages always use the server time base,
887
+ // if the offset has not been established, the client retains
888
+ // the last value and will send it once connection is restored
889
+ publishedTopic.updateRetainedValue(value, timestamp);
890
+
891
+ // update locally for announced topics
892
+ if (this.onDataReceived) {
893
+ const announcedTopic = this.announcedTopics.get(topic);
894
+ if (announcedTopic) {
895
+ this.onDataReceived(announcedTopic, value, timestamp);
896
+ }
897
+ }
898
+
899
+ if (this.connectionEstablished && this.serverTimeOffset_μs != null) {
900
+ this.sendValueCore(publishedTopic, value, timestamp);
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Returns current ping timeout.
906
+ *
907
+ * @returns A value in milliseconds or 0 if the timeout has not been set yet.
908
+ */
909
+ public get pingTimeoutMilliseconds() {
910
+ return this.pingTimeout_ms;
911
+ }
912
+
913
+ /**
914
+ * Sets ping timeout.
915
+ *
916
+ * @param value Timeout in milliseconds or `undefined` to use default protocol timeout value.
917
+ */
918
+ public setPingTimeoutMilliseconds(value: number | undefined) {
919
+ Logger.Default.debug(
920
+ value != null
921
+ ? `[NT4Client] ping timeout is set to ${value.toFixed(0)}ms`
922
+ : `[NT4Client] ping timeout is reset to protocol-based default`
923
+ );
924
+
925
+ this.pingTimeoutOverride_ms = value;
926
+
927
+ // update actual timeout value
928
+ if (value != null) {
929
+ this.pingTimeout_ms = value;
930
+ } else {
931
+ this.pingTimeout_ms =
932
+ this.dataChannel.protocol === NetworkSpecs.protocol41 ? NetworkSpecs.pingTimeout41 : NetworkSpecs.pingPeriod40;
933
+ }
934
+ }
935
+ }