@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.
- package/LICENSE +28 -0
- package/README.md +3 -0
- package/dist/index.cjs +817 -0
- package/dist/index.d.cts +428 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +428 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +816 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/NT4Client.ts +935 -0
- package/src/NT4Spec.ts +291 -0
- package/src/NT4Subscription.ts +19 -0
- package/src/NT4Topic.ts +74 -0
- package/src/NT4Types.ts +70 -0
- package/src/NT4WebSocketAsync.ts +145 -0
- package/src/index.ts +4 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
let _msgpack_msgpack = require("@msgpack/msgpack");
|
|
2
|
+
let _2702rebels_logger = require("@2702rebels/logger");
|
|
3
|
+
|
|
4
|
+
//#region src/NT4Spec.ts
|
|
5
|
+
/**
|
|
6
|
+
* Network constants.
|
|
7
|
+
*/
|
|
8
|
+
const NetworkSpecs = {
|
|
9
|
+
portUnsecure: 5810,
|
|
10
|
+
portSecure: 5811,
|
|
11
|
+
protocol41: "v4.1.networktables.first.wpi.edu",
|
|
12
|
+
protocol40: "networktables.first.wpi.edu",
|
|
13
|
+
protocolRTT: "rtt.networktables.first.wpi.edu",
|
|
14
|
+
pingPeriod40: 1e3,
|
|
15
|
+
pingTimeout40: 3e3,
|
|
16
|
+
pingPeriod41: 250,
|
|
17
|
+
pingTimeout41: 1e3
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Supported data types mapped to protocol identifiers.
|
|
21
|
+
*
|
|
22
|
+
* https://github.com/wpilibsuite/allwpilib/blob/main/ntcore/doc/networktables4.adoc#supported-data-types
|
|
23
|
+
*/
|
|
24
|
+
const DataTypeCodes = {
|
|
25
|
+
boolean: 0,
|
|
26
|
+
double: 1,
|
|
27
|
+
int: 2,
|
|
28
|
+
float: 3,
|
|
29
|
+
string: 4,
|
|
30
|
+
json: 4,
|
|
31
|
+
raw: 5,
|
|
32
|
+
rpc: 5,
|
|
33
|
+
msgpack: 5,
|
|
34
|
+
protobuf: 5,
|
|
35
|
+
"boolean[]": 16,
|
|
36
|
+
"double[]": 17,
|
|
37
|
+
"int[]": 18,
|
|
38
|
+
"float[]": 19,
|
|
39
|
+
"string[]": 20
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/NT4Subscription.ts
|
|
44
|
+
/** Contains information about NT4 subscription. */
|
|
45
|
+
var NT4Subscription = class {
|
|
46
|
+
/** Subscription UID. */
|
|
47
|
+
uid;
|
|
48
|
+
/** Subscription options. */
|
|
49
|
+
options;
|
|
50
|
+
/** Subscribed topic names or prefixes. */
|
|
51
|
+
topics;
|
|
52
|
+
constructor(uid, topics, options) {
|
|
53
|
+
this.uid = uid;
|
|
54
|
+
this.topics = new Set(typeof topics === "string" ? [topics] : topics);
|
|
55
|
+
this.options = options ?? {};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/NT4Topic.ts
|
|
61
|
+
/** Contains information about NT4 topic. */
|
|
62
|
+
var NT4Topic = class {
|
|
63
|
+
/** Topic UID. */
|
|
64
|
+
uid;
|
|
65
|
+
/** Topic name. */
|
|
66
|
+
name;
|
|
67
|
+
/** Topic data type. */
|
|
68
|
+
type;
|
|
69
|
+
/** Topic properties. */
|
|
70
|
+
properties;
|
|
71
|
+
/** Publisher UID. */
|
|
72
|
+
pubuid;
|
|
73
|
+
/** Last retained value. */
|
|
74
|
+
retainedValue;
|
|
75
|
+
/** Timestamp of {@link retainedValue}. */
|
|
76
|
+
retainedTimestamp;
|
|
77
|
+
constructor(uid, name, type, properties, pubuid) {
|
|
78
|
+
this.uid = uid;
|
|
79
|
+
this.name = name;
|
|
80
|
+
this.type = type;
|
|
81
|
+
this.properties = properties ?? {};
|
|
82
|
+
this.pubuid = pubuid;
|
|
83
|
+
this.typeCode = DataTypeCodes[type] ?? 5;
|
|
84
|
+
}
|
|
85
|
+
/** Merges properties with this topic. */
|
|
86
|
+
mergeProperties(properties) {
|
|
87
|
+
Object.entries(properties).forEach(([key, value]) => {
|
|
88
|
+
if (value === null) delete this.properties[key];
|
|
89
|
+
else this.properties[key] = value;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Updates retained value if the incoming timestamp is newer (greater-than or equal-to). */
|
|
93
|
+
updateRetainedValue(value, timestamp) {
|
|
94
|
+
if (this.retainedTimestamp == null || timestamp >= this.retainedTimestamp) {
|
|
95
|
+
this.retainedValue = value;
|
|
96
|
+
this.retainedTimestamp = timestamp;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Removes retained value except for "weak" default value. */
|
|
100
|
+
removeRetainedValue() {
|
|
101
|
+
if (this.retainedTimestamp != 0) {
|
|
102
|
+
this.retainedValue = void 0;
|
|
103
|
+
this.retainedTimestamp = void 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Resets retained value timestamp except for "weak" default value. */
|
|
107
|
+
resetRetainedTimestamp() {
|
|
108
|
+
if (this.retainedTimestamp != null && this.retainedTimestamp > 1) this.retainedTimestamp = 1;
|
|
109
|
+
}
|
|
110
|
+
/** Topic type code. For internal use only. */
|
|
111
|
+
typeCode;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/NT4WebSocketAsync.ts
|
|
116
|
+
const PROTOCOL_DATA = [NetworkSpecs.protocol41, NetworkSpecs.protocol40];
|
|
117
|
+
const PROTOCOL_RTT = [NetworkSpecs.protocolRTT];
|
|
118
|
+
/**
|
|
119
|
+
* A wrapper for `WebSocket` that implements async `connect` and `close` methods.
|
|
120
|
+
*
|
|
121
|
+
* This class manages the lifecycle of the raw websocket internally, creating and
|
|
122
|
+
* destroying the websocket as necessary when establishing and closing the connection.
|
|
123
|
+
*/
|
|
124
|
+
var NT4WebSocketAsync = class {
|
|
125
|
+
url;
|
|
126
|
+
rtt;
|
|
127
|
+
socket = null;
|
|
128
|
+
onMessage;
|
|
129
|
+
onClose;
|
|
130
|
+
/**
|
|
131
|
+
* Creates a `WebSocket` wrapper to be used for NT4 communication channel.
|
|
132
|
+
* This constructor does not initiate the connection, see `connect`.
|
|
133
|
+
*
|
|
134
|
+
* @param address server network address
|
|
135
|
+
* @param port sever port
|
|
136
|
+
* @param clientId client identifier
|
|
137
|
+
* @param options connection options
|
|
138
|
+
*/
|
|
139
|
+
constructor(address, port, clientId, options) {
|
|
140
|
+
this.url = `${options.secure ? "wss" : "ws"}://${address}:${port}/nt/${clientId}`;
|
|
141
|
+
this.rtt = options.rtt ?? false;
|
|
142
|
+
this.onMessage = options.onMessage;
|
|
143
|
+
this.onClose = options.onClose;
|
|
144
|
+
}
|
|
145
|
+
onMessageLocal(ev, source) {
|
|
146
|
+
if (this.onMessage && this.socket === source) this.onMessage(ev);
|
|
147
|
+
}
|
|
148
|
+
onCloseLocal(ev, source) {
|
|
149
|
+
if (this.onClose && this.socket === source) this.onClose(ev);
|
|
150
|
+
}
|
|
151
|
+
/** Returns `true` if the socket is in the connected state. */
|
|
152
|
+
get connected() {
|
|
153
|
+
return this.socket != null && this.socket.readyState === WebSocket.OPEN;
|
|
154
|
+
}
|
|
155
|
+
/** Returns `true` if the socket is in the connecting state. */
|
|
156
|
+
get connecting() {
|
|
157
|
+
return this.socket != null && this.socket.readyState === WebSocket.CONNECTING;
|
|
158
|
+
}
|
|
159
|
+
/** Returns the subprotocol negotiated with the server. */
|
|
160
|
+
get protocol() {
|
|
161
|
+
return this.socket?.protocol;
|
|
162
|
+
}
|
|
163
|
+
/** Sends text data frame (JSON serialized). */
|
|
164
|
+
sendText(data) {
|
|
165
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(Array.isArray(data) ? data : [data]));
|
|
166
|
+
}
|
|
167
|
+
/** Sends binary data frame. */
|
|
168
|
+
sendBinary(data) {
|
|
169
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) this.socket.send(data);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Asynchronously terminates the connection.
|
|
173
|
+
*
|
|
174
|
+
* @param code optional code
|
|
175
|
+
* @param reason optional reason
|
|
176
|
+
*/
|
|
177
|
+
async close(code, reason) {
|
|
178
|
+
if (this.socket) {
|
|
179
|
+
const socket = this.socket;
|
|
180
|
+
this.socket = null;
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
socket.addEventListener("close", () => {
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
socket.close(code, reason);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return Promise.resolve();
|
|
189
|
+
}
|
|
190
|
+
/** Asynchronously initiates the connection. */
|
|
191
|
+
async connect() {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const socket = new WebSocket(this.url, this.rtt ? PROTOCOL_RTT : PROTOCOL_DATA);
|
|
194
|
+
socket.binaryType = "arraybuffer";
|
|
195
|
+
this.socket = socket;
|
|
196
|
+
socket.addEventListener("message", (_) => this.onMessageLocal(_, socket));
|
|
197
|
+
socket.addEventListener("close", (_) => this.onCloseLocal(_, socket));
|
|
198
|
+
socket.onopen = () => {
|
|
199
|
+
resolve();
|
|
200
|
+
};
|
|
201
|
+
socket.onerror = (event) => {
|
|
202
|
+
reject(event);
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/NT4Client.ts
|
|
210
|
+
/** Determines whether the specified predicate callback returns true for any element of a map. */
|
|
211
|
+
function some(map, predicate) {
|
|
212
|
+
for (const v of map.values()) if (predicate(v)) return true;
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Implements a NetworkTable4 client that is responsible for establishing and maintaining a connection.
|
|
217
|
+
*/
|
|
218
|
+
var NT4Client = class NT4Client {
|
|
219
|
+
port;
|
|
220
|
+
clientId;
|
|
221
|
+
dataChannel;
|
|
222
|
+
rttChannel;
|
|
223
|
+
decoder = new _msgpack_msgpack.Decoder();
|
|
224
|
+
encoder = new _msgpack_msgpack.Encoder();
|
|
225
|
+
onConnect;
|
|
226
|
+
onDisconnect;
|
|
227
|
+
onTopicAnnounced;
|
|
228
|
+
onTopicRemoved;
|
|
229
|
+
onTopicUpdated;
|
|
230
|
+
onDataReceived;
|
|
231
|
+
retryPolicy;
|
|
232
|
+
pingTimeoutOverride_ms;
|
|
233
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
234
|
+
publishedTopics = /* @__PURE__ */ new Map();
|
|
235
|
+
announcedTopics = /* @__PURE__ */ new Map();
|
|
236
|
+
announcedTopicsIndex = /* @__PURE__ */ new Map();
|
|
237
|
+
connectionEstablished = false;
|
|
238
|
+
connectionInitiated = false;
|
|
239
|
+
connectionAttempts = 0;
|
|
240
|
+
connectionState = "disconnected";
|
|
241
|
+
networkLatency_μs = null;
|
|
242
|
+
serverTimeOffset_μs = null;
|
|
243
|
+
pingTimeout_ms = 0;
|
|
244
|
+
pongTimestamp_μs = 0;
|
|
245
|
+
pingIntervalId = null;
|
|
246
|
+
pongIntervalId = null;
|
|
247
|
+
/** Truncated at 15 seconds exponential backoff retry policy starting at 1 second. */
|
|
248
|
+
static defaultRetryPolicy(attempts) {
|
|
249
|
+
return Math.round(Math.min(15, Math.max(1, 1.2 ** Math.min(15, attempts))) * 1e3);
|
|
250
|
+
}
|
|
251
|
+
/** Cleans up closed data channel. */
|
|
252
|
+
cleanup() {
|
|
253
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] cleaning up`);
|
|
254
|
+
if (this.pingIntervalId) {
|
|
255
|
+
clearInterval(this.pingIntervalId);
|
|
256
|
+
this.pingIntervalId = null;
|
|
257
|
+
}
|
|
258
|
+
if (this.pongIntervalId) {
|
|
259
|
+
clearInterval(this.pongIntervalId);
|
|
260
|
+
this.pongIntervalId = null;
|
|
261
|
+
}
|
|
262
|
+
this.rttChannel.close();
|
|
263
|
+
if (this.connectionEstablished && this.onDisconnect) this.onDisconnect();
|
|
264
|
+
this.connectionEstablished = false;
|
|
265
|
+
this.connectionState = "disconnected";
|
|
266
|
+
this.announcedTopics.clear();
|
|
267
|
+
this.announcedTopicsIndex.clear();
|
|
268
|
+
for (const topic of this.publishedTopics.values()) topic.resetRetainedTimestamp();
|
|
269
|
+
this.pongTimestamp_μs = 0;
|
|
270
|
+
this.serverTimeOffset_μs = null;
|
|
271
|
+
this.networkLatency_μs = null;
|
|
272
|
+
}
|
|
273
|
+
/** Attempts to establish a connection, continuously retrying using policy. */
|
|
274
|
+
async connectWithRetry() {
|
|
275
|
+
this.connectionState = "connecting";
|
|
276
|
+
if (!this.connectionInitiated) {
|
|
277
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
let response = null;
|
|
281
|
+
try {
|
|
282
|
+
response = await fetch(`http://${this.serverAddress}:${this.port}`, { signal: AbortSignal.timeout(250) });
|
|
283
|
+
} catch {}
|
|
284
|
+
if (!this.connectionInitiated) {
|
|
285
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (response && response.ok) try {
|
|
289
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] opening channel to ${this.serverAddress}:${this.port}`);
|
|
290
|
+
await this.dataChannel.connect();
|
|
291
|
+
this.connectionAttempts = 0;
|
|
292
|
+
if (!this.connectionInitiated) {
|
|
293
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
this.connectionEstablished = true;
|
|
297
|
+
this.connectionState = "connected";
|
|
298
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] connected to ${this.serverAddress}:${this.port}, subprotocol = ${this.dataChannel.protocol}`);
|
|
299
|
+
let pingPeriod_ms = NetworkSpecs.pingPeriod40;
|
|
300
|
+
this.pingTimeout_ms = this.pingTimeoutOverride_ms ?? NetworkSpecs.pingTimeout40;
|
|
301
|
+
if (this.dataChannel.protocol === NetworkSpecs.protocol41) {
|
|
302
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] server speaks v4.1, creating separate RTT channel`);
|
|
303
|
+
await this.rttChannel.connect();
|
|
304
|
+
if (!this.connectionInitiated) {
|
|
305
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] pending cancellation, aborting...`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
pingPeriod_ms = NetworkSpecs.pingPeriod41;
|
|
309
|
+
this.pingTimeout_ms = this.pingTimeoutOverride_ms ?? NetworkSpecs.pingTimeout41;
|
|
310
|
+
}
|
|
311
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] sending timestamp and re-establishing subscriptions`);
|
|
312
|
+
this.sendRTT();
|
|
313
|
+
this.pongTimestamp_μs = 0;
|
|
314
|
+
this.pingIntervalId = setInterval(() => this.sendRTT(), pingPeriod_ms);
|
|
315
|
+
this.pongIntervalId = setInterval(() => this.checkTimeout(), pingPeriod_ms);
|
|
316
|
+
for (const subscription of this.subscriptions.values()) this.subscribeCore(subscription);
|
|
317
|
+
for (const topic of this.publishedTopics.values()) this.publishCore(topic);
|
|
318
|
+
if (this.onConnect) this.onConnect();
|
|
319
|
+
return;
|
|
320
|
+
} catch (exception) {
|
|
321
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] failed to open channel`, exception);
|
|
322
|
+
await this.rttChannel.close();
|
|
323
|
+
await this.dataChannel.close();
|
|
324
|
+
}
|
|
325
|
+
this.connectionAttempts++;
|
|
326
|
+
const timeout = this.retryPolicy(this.connectionAttempts);
|
|
327
|
+
if (timeout < 0) {
|
|
328
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] aborting per retry policy after ${this.connectionAttempts} attempts`);
|
|
329
|
+
this.disconnect();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] retrying connection in ${timeout.toFixed(0)}ms`);
|
|
333
|
+
setTimeout(() => this.connectWithRetry(), timeout);
|
|
334
|
+
}
|
|
335
|
+
/** Invoked once the client-server clock is synchronized. */
|
|
336
|
+
onClockSynchronized() {
|
|
337
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] clock synchronized, offset = ${this.serverTimeOffset_μs}`);
|
|
338
|
+
for (const topic of this.publishedTopics.values()) if (topic.retainedTimestamp != null && topic.retainedValue != null) this.sendValueCore(topic, topic.retainedValue, topic.retainedTimestamp === 1 ? this.serverTimeMicroseconds() ?? 1 : topic.retainedTimestamp);
|
|
339
|
+
}
|
|
340
|
+
/** Consumes received data frame. */
|
|
341
|
+
processDataFrame(data) {
|
|
342
|
+
if (typeof data === "string") {
|
|
343
|
+
let array;
|
|
344
|
+
try {
|
|
345
|
+
array = JSON.parse(data);
|
|
346
|
+
if (!Array.isArray(array)) {
|
|
347
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected text frame, JSON must be an array, frame ignored`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
} catch (exception) {
|
|
351
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected text frame, JSON is malformed, frame ignored`, exception);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
array.forEach((d) => {
|
|
355
|
+
if (typeof d !== "object" || !("method" in d) || !("params" in d)) {
|
|
356
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] malformed message in text frame, message ignored`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const { method, params } = d;
|
|
360
|
+
if (typeof method !== "string" || typeof params !== "object") {
|
|
361
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] malformed message in text frame, message ignored`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
switch (method) {
|
|
365
|
+
case "announce": {
|
|
366
|
+
const topic = new NT4Topic(params.id, params.name, params.type, params.properties, params.pubuid);
|
|
367
|
+
this.announcedTopics.set(topic.name, topic);
|
|
368
|
+
this.announcedTopicsIndex.set(topic.uid, topic);
|
|
369
|
+
if (this.onTopicAnnounced) this.onTopicAnnounced(topic);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
case "unannounce": {
|
|
373
|
+
const topic = this.announcedTopics.get(params.name);
|
|
374
|
+
if (topic) {
|
|
375
|
+
this.publishedTopics.get(topic.name)?.removeRetainedValue();
|
|
376
|
+
this.announcedTopics.delete(topic.name);
|
|
377
|
+
this.announcedTopicsIndex.delete(topic.uid);
|
|
378
|
+
if (this.onTopicRemoved) this.onTopicRemoved(topic);
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
case "properties": {
|
|
383
|
+
const topic = this.announcedTopics.get(params.name);
|
|
384
|
+
if (topic) {
|
|
385
|
+
topic.mergeProperties(params.update);
|
|
386
|
+
if (this.onTopicUpdated) this.onTopicUpdated(topic);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
default:
|
|
391
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected method '${method}' in text frame message, message ignored`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
} else for (const d of this.decoder.decodeMulti(data)) {
|
|
396
|
+
const [topicId, timestamp, typeCode, value] = d;
|
|
397
|
+
if (typeof topicId !== "number") {
|
|
398
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected non-numeric topic identifier, frame ignored`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (typeof timestamp !== "number") {
|
|
402
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected non-numeric timestamp, frame ignored`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (typeof typeCode !== "number") {
|
|
406
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected non-numeric typeCode, frame ignored`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (topicId === -1) {
|
|
410
|
+
if (typeof value !== "number") {
|
|
411
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected non-numeric value in RTT frame, message ignored`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const clientTime_μs = this.getClientTimestamp_μs();
|
|
415
|
+
const latency_μs = (clientTime_μs - value) / 2;
|
|
416
|
+
if (this.networkLatency_μs == null || latency_μs < this.networkLatency_μs) {
|
|
417
|
+
const clockSynchronized = this.serverTimeOffset_μs == null;
|
|
418
|
+
this.serverTimeOffset_μs = Math.round(timestamp + latency_μs - clientTime_μs);
|
|
419
|
+
if (clockSynchronized) this.onClockSynchronized();
|
|
420
|
+
}
|
|
421
|
+
this.networkLatency_μs = latency_μs;
|
|
422
|
+
this.pongTimestamp_μs = clientTime_μs;
|
|
423
|
+
} else if (topicId > 0) {
|
|
424
|
+
const topic = this.announcedTopicsIndex.get(topicId);
|
|
425
|
+
if (topic == null) {
|
|
426
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] unexpected topic identifier ${topicId}, message ignored`);
|
|
427
|
+
continue;
|
|
428
|
+
} else if (this.onDataReceived) {
|
|
429
|
+
this.publishedTopics.get(topic.name)?.updateRetainedValue(value, timestamp);
|
|
430
|
+
this.onDataReceived(topic, value, timestamp);
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
_2702rebels_logger.Logger.Default.error(`[NT4Client] invalid topic identifier ${topicId}, message ignored`);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Handles general data frame received. */
|
|
439
|
+
onChannelMessage = (ev) => {
|
|
440
|
+
this.processDataFrame(ev.data);
|
|
441
|
+
};
|
|
442
|
+
/** Handles RTT data frame received. */
|
|
443
|
+
onChannelMessageRTT = (ev) => {
|
|
444
|
+
if (typeof ev.data === "string") {
|
|
445
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] unexpected text data frame on RTT channel, frame ignored`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.processDataFrame(ev.data);
|
|
449
|
+
};
|
|
450
|
+
/** Handles channel closing for whatever reason. */
|
|
451
|
+
onChannelClose = (ev) => {
|
|
452
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] channel closed`, ev);
|
|
453
|
+
this.cleanup();
|
|
454
|
+
this.connectWithRetry();
|
|
455
|
+
};
|
|
456
|
+
/** Returns current timestamp in microseconds. This is NOT wall clock time. */
|
|
457
|
+
getClientTimestamp_μs() {
|
|
458
|
+
return Math.round(performance.now() * 1e3);
|
|
459
|
+
}
|
|
460
|
+
/** Sends data frame to publish the topic. */
|
|
461
|
+
publishCore(topic) {
|
|
462
|
+
this.dataChannel.sendText({
|
|
463
|
+
method: "publish",
|
|
464
|
+
params: {
|
|
465
|
+
pubuid: topic.uid,
|
|
466
|
+
name: topic.name,
|
|
467
|
+
type: topic.type,
|
|
468
|
+
properties: topic.properties
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/** Sends data frame to unpublish the topic. */
|
|
473
|
+
unpublishCore(topic) {
|
|
474
|
+
this.dataChannel.sendText({
|
|
475
|
+
method: "unpublish",
|
|
476
|
+
params: { pubuid: topic.uid }
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/** Sends data frame to set topic properties. */
|
|
480
|
+
setPropertiesCore(name, properties) {
|
|
481
|
+
this.dataChannel.sendText({
|
|
482
|
+
method: "setproperties",
|
|
483
|
+
params: {
|
|
484
|
+
name,
|
|
485
|
+
update: properties
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** Sends data frame to subscribe the subscription. */
|
|
490
|
+
subscribeCore(subscription) {
|
|
491
|
+
this.dataChannel.sendText({
|
|
492
|
+
method: "subscribe",
|
|
493
|
+
params: {
|
|
494
|
+
subuid: subscription.uid,
|
|
495
|
+
topics: Array.from(subscription.topics),
|
|
496
|
+
options: subscription.options
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/** Sends data frame to unsubscribe the subscription. */
|
|
501
|
+
unsubscribeCore(subscription) {
|
|
502
|
+
this.dataChannel.sendText({
|
|
503
|
+
method: "unsubscribe",
|
|
504
|
+
params: { subuid: subscription.uid }
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/** Sends data frame to update topic value. */
|
|
508
|
+
sendValueCore(topic, value, timestamp) {
|
|
509
|
+
const frame = this.encoder.encode([
|
|
510
|
+
topic.uid,
|
|
511
|
+
timestamp,
|
|
512
|
+
topic.typeCode,
|
|
513
|
+
value
|
|
514
|
+
]);
|
|
515
|
+
this.dataChannel.sendBinary(frame);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Sends RTT measurement binary frame.
|
|
519
|
+
* Uses RTT channel if available, falls back to data channel in NT4.0.
|
|
520
|
+
*/
|
|
521
|
+
sendRTT() {
|
|
522
|
+
const timestamp = this.getClientTimestamp_μs();
|
|
523
|
+
const frame = this.encoder.encode([
|
|
524
|
+
-1,
|
|
525
|
+
0,
|
|
526
|
+
DataTypeCodes.double,
|
|
527
|
+
timestamp
|
|
528
|
+
]);
|
|
529
|
+
(this.rttChannel ?? this.dataChannel).sendBinary(frame);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Determines whether connection times out if no message has been received
|
|
533
|
+
* within the timeout interval since the last pong.
|
|
534
|
+
*/
|
|
535
|
+
async checkTimeout() {
|
|
536
|
+
if (!this.connectionEstablished || this.pongTimestamp_μs === 0) return;
|
|
537
|
+
const delta_μs = this.getClientTimestamp_μs() - this.pongTimestamp_μs;
|
|
538
|
+
if (delta_μs > this.pingTimeout_ms * 1e3) {
|
|
539
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] no data received in the last ${(delta_μs / 1e3).toFixed(0)}ms, connection timed out`);
|
|
540
|
+
await this.dataChannel.close(4001, "timeout");
|
|
541
|
+
this.cleanup();
|
|
542
|
+
this.connectWithRetry();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/** Generates random UID. */
|
|
546
|
+
generateUID() {
|
|
547
|
+
return Math.floor(Math.random() * 2147483647);
|
|
548
|
+
}
|
|
549
|
+
/** Generates random safe UID avoiding possible clashes. */
|
|
550
|
+
generateSafeUID(has) {
|
|
551
|
+
let uid;
|
|
552
|
+
for (uid = this.generateUID(); has(uid); uid = this.generateUID());
|
|
553
|
+
return uid;
|
|
554
|
+
}
|
|
555
|
+
/** Determines whether published topic with the specified identifier exists. */
|
|
556
|
+
hasPublishedTopicWithId = (id) => some(this.publishedTopics, (_) => _.uid === id);
|
|
557
|
+
/** Determines whether subscription with the specified identifier exists. */
|
|
558
|
+
hasSubscriptionWithId = (id) => this.subscriptions.has(id);
|
|
559
|
+
/**
|
|
560
|
+
* Constructs an instance of NT4Client without initiating a connection.
|
|
561
|
+
*
|
|
562
|
+
* @param serverAddress NT4 server network address
|
|
563
|
+
* @param clientId this client identifier (cannot contain '@')
|
|
564
|
+
* @param options options
|
|
565
|
+
*/
|
|
566
|
+
constructor(serverAddress, clientId, options) {
|
|
567
|
+
if (clientId.includes("@")) throw new Error("Client identifier must not contain '@' character");
|
|
568
|
+
this.serverAddress = serverAddress;
|
|
569
|
+
this.port = options.secure ? NetworkSpecs.portSecure : NetworkSpecs.portUnsecure;
|
|
570
|
+
this.clientId = clientId;
|
|
571
|
+
this.onConnect = options.onConnect;
|
|
572
|
+
this.onDisconnect = options.onDisconnect;
|
|
573
|
+
this.onTopicAnnounced = options.onTopicAnnounced;
|
|
574
|
+
this.onTopicRemoved = options.onTopicRemoved;
|
|
575
|
+
this.onTopicUpdated = options.onTopicUpdated;
|
|
576
|
+
this.onDataReceived = options.onDataReceived;
|
|
577
|
+
this.retryPolicy = options.retryPolicy ?? NT4Client.defaultRetryPolicy;
|
|
578
|
+
this.pingTimeoutOverride_ms = options.pingTimeoutMilliseconds;
|
|
579
|
+
this.dataChannel = new NT4WebSocketAsync(this.serverAddress, this.port, this.clientId, {
|
|
580
|
+
secure: options.secure,
|
|
581
|
+
onMessage: this.onChannelMessage,
|
|
582
|
+
onClose: this.onChannelClose
|
|
583
|
+
});
|
|
584
|
+
this.rttChannel = new NT4WebSocketAsync(this.serverAddress, this.port, this.clientId, {
|
|
585
|
+
rtt: true,
|
|
586
|
+
secure: options.secure,
|
|
587
|
+
onMessage: this.onChannelMessageRTT
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Returns NT4 server network address the client is using.
|
|
592
|
+
*/
|
|
593
|
+
serverAddress;
|
|
594
|
+
/**
|
|
595
|
+
* Returns current connection state.
|
|
596
|
+
*/
|
|
597
|
+
get state() {
|
|
598
|
+
return this.connectionState;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Returns current network latency in microseconds if known.
|
|
602
|
+
*/
|
|
603
|
+
get networkLatencyMicroseconds() {
|
|
604
|
+
return this.networkLatency_μs;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Returns server time in microseconds that is current or
|
|
608
|
+
* based on the specified client (local) time.
|
|
609
|
+
*
|
|
610
|
+
* Returns `null` if the server time offset is not established.
|
|
611
|
+
*/
|
|
612
|
+
serverTimeMicroseconds(clientTimeMicroseconds) {
|
|
613
|
+
return this.serverTimeOffset_μs != null ? Math.round((clientTimeMicroseconds != null ? clientTimeMicroseconds : this.getClientTimestamp_μs()) + this.serverTimeOffset_μs) : null;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Initiates the connection.
|
|
617
|
+
* Once established the connection will be automatically kept alive by reconnecting.
|
|
618
|
+
*/
|
|
619
|
+
connect() {
|
|
620
|
+
if (this.connectionInitiated) return;
|
|
621
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] connection initiated`);
|
|
622
|
+
this.connectionInitiated = true;
|
|
623
|
+
this.connectWithRetry();
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Terminates the connection.
|
|
627
|
+
* The client can be reconnected manually by calling {@link connect} again.
|
|
628
|
+
*/
|
|
629
|
+
disconnect() {
|
|
630
|
+
if (!this.connectionInitiated) return;
|
|
631
|
+
_2702rebels_logger.Logger.Default.debug(`[NT4Client] connection cancelled`);
|
|
632
|
+
this.connectionInitiated = false;
|
|
633
|
+
this.dataChannel.close();
|
|
634
|
+
this.rttChannel.close();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Creates a new subscription.
|
|
638
|
+
*
|
|
639
|
+
* The subscription can be created regardless of connectivity state.
|
|
640
|
+
* All client subscriptions are automatically restored once connection
|
|
641
|
+
* has been established. This is also true for reconnects following
|
|
642
|
+
* loss of connectivity or deliberate disconnects.
|
|
643
|
+
*
|
|
644
|
+
* Caller can {@link unsubscribe} by passing the return value of this call or
|
|
645
|
+
* invoke {@link unsubscribeAll} to remove all existing client subscriptions.
|
|
646
|
+
*
|
|
647
|
+
* @param topics topics or prefixes to include in the subscription
|
|
648
|
+
* @param options subscription options
|
|
649
|
+
* @returns A subscription instance
|
|
650
|
+
*/
|
|
651
|
+
subscribe(topics, options) {
|
|
652
|
+
const subscription = new NT4Subscription(this.generateSafeUID(this.hasSubscriptionWithId), topics, options);
|
|
653
|
+
this.subscriptions.set(subscription.uid, subscription);
|
|
654
|
+
if (this.connectionEstablished) this.subscribeCore(subscription);
|
|
655
|
+
return subscription;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Unsubscribes an existing subscription.
|
|
659
|
+
*
|
|
660
|
+
* @param arg subscription or subscription identifier
|
|
661
|
+
* @returns `true` if the subscription was successfully unsubscribed, `false` if the subscription was not found
|
|
662
|
+
*/
|
|
663
|
+
unsubscribe(arg) {
|
|
664
|
+
const subscription = typeof arg === "number" ? this.subscriptions.get(arg) : arg;
|
|
665
|
+
if (!subscription) return false;
|
|
666
|
+
this.subscriptions.delete(subscription.uid);
|
|
667
|
+
if (this.connectionEstablished) this.unsubscribeCore(subscription);
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Unsubscribes all existing subscriptions.
|
|
672
|
+
*/
|
|
673
|
+
unsubscribeAll() {
|
|
674
|
+
for (const subscription of this.subscriptions.values()) this.unsubscribe(subscription);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Publishes a topic with the specified name and type.
|
|
678
|
+
*
|
|
679
|
+
* This method registers this client as a publisher for the topic.
|
|
680
|
+
* It should be called before {@link setValue} or {@link setValueAt}
|
|
681
|
+
* can be used.
|
|
682
|
+
*
|
|
683
|
+
* Publishing a topic on a disconnected client is allowed.
|
|
684
|
+
* Client will automatically register such publishers once the connection
|
|
685
|
+
* has been established and send default or retained value as appropriate.
|
|
686
|
+
*
|
|
687
|
+
* @param topic topic name
|
|
688
|
+
* @param type data type
|
|
689
|
+
* @param properties properties
|
|
690
|
+
*/
|
|
691
|
+
publishTopic(topic, type, properties) {
|
|
692
|
+
let publishedTopic = this.publishedTopics.get(topic);
|
|
693
|
+
if (publishedTopic) return publishedTopic;
|
|
694
|
+
const uid = this.generateSafeUID(this.hasPublishedTopicWithId);
|
|
695
|
+
publishedTopic = new NT4Topic(uid, topic, type, properties, uid);
|
|
696
|
+
this.publishedTopics.set(publishedTopic.name, publishedTopic);
|
|
697
|
+
if (this.connectionEstablished) this.publishCore(publishedTopic);
|
|
698
|
+
return publishedTopic;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Un-publishes previously published topic.
|
|
702
|
+
*
|
|
703
|
+
* @param topic topic name
|
|
704
|
+
* @returns `true` if the topic was successfully unpublished, `false` if the topic was not published
|
|
705
|
+
*/
|
|
706
|
+
unpublishTopic(topic) {
|
|
707
|
+
const publishedTopic = this.publishedTopics.get(topic);
|
|
708
|
+
if (!publishedTopic) return false;
|
|
709
|
+
this.publishedTopics.delete(publishedTopic.name);
|
|
710
|
+
if (this.connectionEstablished) this.unpublishCore(publishedTopic);
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Determines whether client already publishes the topic.
|
|
715
|
+
*
|
|
716
|
+
* @param topic topic name
|
|
717
|
+
*/
|
|
718
|
+
isTopicPublished(topic) {
|
|
719
|
+
return this.publishedTopics.has(topic);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Sets properties for the specified topic.
|
|
723
|
+
*
|
|
724
|
+
* @param topic topic name
|
|
725
|
+
* @param properties new properties
|
|
726
|
+
*/
|
|
727
|
+
setTopicProperties(topic, properties) {
|
|
728
|
+
const publishedTopic = this.publishedTopics.get(topic);
|
|
729
|
+
if (publishedTopic) publishedTopic.mergeProperties(properties);
|
|
730
|
+
const announcedTopic = this.announcedTopics.get(topic);
|
|
731
|
+
if (announcedTopic) announcedTopic.mergeProperties(properties);
|
|
732
|
+
if (this.connectionEstablished) this.setPropertiesCore(topic, properties);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Sets persistent flag for the topic.
|
|
736
|
+
*
|
|
737
|
+
* @param topic topic name
|
|
738
|
+
* @param value value to set
|
|
739
|
+
*/
|
|
740
|
+
setTopicPersistent(topic, value) {
|
|
741
|
+
this.setTopicProperties(topic, { persistent: value });
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Sets retained flag for the topic.
|
|
745
|
+
*
|
|
746
|
+
* @param topic topic name
|
|
747
|
+
* @param value value to set
|
|
748
|
+
*/
|
|
749
|
+
setTopicRetained(topic, value) {
|
|
750
|
+
this.setTopicProperties(topic, { retained: value });
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Sends a value to the published topic using the current time.
|
|
754
|
+
*
|
|
755
|
+
* This client must publish the topic with {@link publishTopic} first.
|
|
756
|
+
*
|
|
757
|
+
* @param topic topic name
|
|
758
|
+
* @param value value to send
|
|
759
|
+
*/
|
|
760
|
+
setValue(topic, value) {
|
|
761
|
+
this.setValueAt(topic, value, this.serverTimeMicroseconds() ?? 1);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Sends a default ("weak") value to the published topic.
|
|
765
|
+
*
|
|
766
|
+
* This client must publish the topic with {@link publishTopic} first.
|
|
767
|
+
*
|
|
768
|
+
* @param topic topic name
|
|
769
|
+
* @param value value to send
|
|
770
|
+
*/
|
|
771
|
+
setDefaultValue(topic, value) {
|
|
772
|
+
this.setValueAt(topic, value, 0);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Sends a value to the published topic using the specified timestamp.
|
|
776
|
+
*
|
|
777
|
+
* This client must publish the topic with {@link publishTopic} first.
|
|
778
|
+
*
|
|
779
|
+
* @param topic topic name
|
|
780
|
+
* @param value value to send
|
|
781
|
+
* @param timestamp timestamp
|
|
782
|
+
*/
|
|
783
|
+
setValueAt(topic, value, timestamp) {
|
|
784
|
+
const publishedTopic = this.publishedTopics.get(topic);
|
|
785
|
+
if (!publishedTopic) throw new Error("Topic must be published first");
|
|
786
|
+
publishedTopic.updateRetainedValue(value, timestamp);
|
|
787
|
+
if (this.onDataReceived) {
|
|
788
|
+
const announcedTopic = this.announcedTopics.get(topic);
|
|
789
|
+
if (announcedTopic) this.onDataReceived(announcedTopic, value, timestamp);
|
|
790
|
+
}
|
|
791
|
+
if (this.connectionEstablished && this.serverTimeOffset_μs != null) this.sendValueCore(publishedTopic, value, timestamp);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Returns current ping timeout.
|
|
795
|
+
*
|
|
796
|
+
* @returns A value in milliseconds or 0 if the timeout has not been set yet.
|
|
797
|
+
*/
|
|
798
|
+
get pingTimeoutMilliseconds() {
|
|
799
|
+
return this.pingTimeout_ms;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Sets ping timeout.
|
|
803
|
+
*
|
|
804
|
+
* @param value Timeout in milliseconds or `undefined` to use default protocol timeout value.
|
|
805
|
+
*/
|
|
806
|
+
setPingTimeoutMilliseconds(value) {
|
|
807
|
+
_2702rebels_logger.Logger.Default.debug(value != null ? `[NT4Client] ping timeout is set to ${value.toFixed(0)}ms` : `[NT4Client] ping timeout is reset to protocol-based default`);
|
|
808
|
+
this.pingTimeoutOverride_ms = value;
|
|
809
|
+
if (value != null) this.pingTimeout_ms = value;
|
|
810
|
+
else this.pingTimeout_ms = this.dataChannel.protocol === NetworkSpecs.protocol41 ? NetworkSpecs.pingTimeout41 : NetworkSpecs.pingPeriod40;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
//#endregion
|
|
815
|
+
exports.NT4Client = NT4Client;
|
|
816
|
+
exports.NT4Subscription = NT4Subscription;
|
|
817
|
+
exports.NT4Topic = NT4Topic;
|