node-red-contrib-ax25 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.
Files changed (55) hide show
  1. package/.eslintignore +5 -0
  2. package/.prettierignore +7 -0
  3. package/ARCHITECTURE.md +174 -0
  4. package/CONTEXT.md +90 -0
  5. package/MESSAGES.md +314 -0
  6. package/README.md +317 -0
  7. package/examples/beacons.json +130 -0
  8. package/examples/beacons.png +0 -0
  9. package/examples/bye_subflow.json +107 -0
  10. package/examples/bye_subflow.png +0 -0
  11. package/examples/delete_all_my_messages.json +491 -0
  12. package/examples/delete_all_my_messages.png +0 -0
  13. package/examples/get_message_list_subflow.json +129 -0
  14. package/examples/get_message_list_subflow.png +0 -0
  15. package/examples/send_message_subflow.json +367 -0
  16. package/examples/send_message_subflow.png +0 -0
  17. package/examples/send_test_message.json +643 -0
  18. package/examples/send_test_message.png +0 -0
  19. package/jsconfig.json +37 -0
  20. package/lib/agwpe-client-transport.js +99 -0
  21. package/lib/agwpe-frame-builder.js +176 -0
  22. package/lib/agwpe-frame-pretty.js +107 -0
  23. package/lib/ax25-codec.js +382 -0
  24. package/lib/frame-router.js +95 -0
  25. package/lib/frame-segmentation.js +53 -0
  26. package/lib/message-utils.js +59 -0
  27. package/lib/runtime-store.js +94 -0
  28. package/lib/session-registry.js +142 -0
  29. package/local/buffer_compare.json +135 -0
  30. package/local/debug-d-frame.js +84 -0
  31. package/local/raw-out-test.json +128 -0
  32. package/nodes/agwpe-client.html +70 -0
  33. package/nodes/agwpe-client.js +771 -0
  34. package/nodes/agwpe-client.js.bak +871 -0
  35. package/nodes/connect.html +128 -0
  36. package/nodes/connect.js +450 -0
  37. package/nodes/decode.html +83 -0
  38. package/nodes/decode.js +56 -0
  39. package/nodes/disconnect.html +55 -0
  40. package/nodes/disconnect.js +47 -0
  41. package/nodes/encode.html +117 -0
  42. package/nodes/encode.js +164 -0
  43. package/nodes/monitor-in.html +48 -0
  44. package/nodes/monitor-in.js +42 -0
  45. package/nodes/raw-in.html +50 -0
  46. package/nodes/raw-in.js +72 -0
  47. package/nodes/raw-out.html +76 -0
  48. package/nodes/raw-out.js +144 -0
  49. package/nodes/send.html +91 -0
  50. package/nodes/send.js +373 -0
  51. package/nodes/ui-in.html +64 -0
  52. package/nodes/ui-in.js +68 -0
  53. package/nodes/ui-out.html +80 -0
  54. package/nodes/ui-out.js +133 -0
  55. package/package.json +47 -0
@@ -0,0 +1,771 @@
1
+ "use strict";
2
+
3
+ const Transport = require("../lib/agwpe-client-transport");
4
+ const store = require("../lib/runtime-store");
5
+ const { makeMessageId } = require("../lib/message-utils");
6
+ const {
7
+ makeRegistrationFrame,
8
+ makeConnectFrame,
9
+ makeViaConnectFrame,
10
+ makeDisconnectFrame,
11
+ makeDataFrame,
12
+ makeUiFrame,
13
+ makeRawFrame,
14
+ makeMonitorToggleFrame,
15
+ makeRawToggleFrame,
16
+ makeOutstandingQueryFrame
17
+ } = require("../lib/agwpe-frame-builder");
18
+ const { decodeWireAx25 } = require("../lib/ax25-codec");
19
+
20
+ const DEFAULT_HOST = "127.0.0.1";
21
+ const DEFAULT_PORT = 8000;
22
+ const DEFAULT_RECONNECT_DELAY = 5000;
23
+
24
+ function normalizeCallsigns(callsigns) {
25
+ if (typeof callsigns === "string") {
26
+ return [callsigns];
27
+ }
28
+ if (Array.isArray(callsigns)) {
29
+ return callsigns.slice();
30
+ }
31
+ return [];
32
+ }
33
+
34
+ function makeAgwpeRegistrationFrame(callsign) {
35
+ return makeRegistrationFrame(callsign);
36
+ }
37
+
38
+ function decodeAgwpeCallsign(frame, offset) {
39
+ const raw = frame.subarray(offset, offset + 10);
40
+ const nul = raw.indexOf(0x00);
41
+ const end = nul >= 0 ? nul : raw.length;
42
+ return raw.subarray(0, end).toString("ascii").trim();
43
+ }
44
+
45
+ function decodeInboundAgwpeFrame(instanceId, frame) {
46
+ const dataKind = String.fromCharCode(frame.readUInt8(4));
47
+ const source = decodeAgwpeCallsign(frame, 8);
48
+ const destination = decodeAgwpeCallsign(frame, 18);
49
+ const payloadLen = frame.readUInt32LE(28);
50
+ const payload = frame.subarray(36, 36 + payloadLen);
51
+
52
+ // AGWPE sends incoming unproto/UI traffic as 'U' frames.
53
+ if (dataKind === "U") {
54
+ return {
55
+ kind: "ui",
56
+ direction: "rx",
57
+ instanceId,
58
+ source,
59
+ destination,
60
+ payload,
61
+ messageId: makeMessageId("ui"),
62
+ chunkIndex: 0,
63
+ chunkCount: 1
64
+ };
65
+ }
66
+
67
+ // AGWPE sends a 'C' frame to confirm an inbound or outbound connection is established.
68
+ if (dataKind === "C") {
69
+ return {
70
+ kind: "connected",
71
+ direction: "rx",
72
+ instanceId,
73
+ source,
74
+ destination,
75
+ payload
76
+ };
77
+ }
78
+
79
+ // AGWPE sends a 'd' frame when a connected session is terminated (remote or TNC-initiated).
80
+ if (dataKind === "d") {
81
+ return {
82
+ kind: "disconnected",
83
+ direction: "rx",
84
+ instanceId,
85
+ source,
86
+ destination,
87
+ payload
88
+ };
89
+ }
90
+
91
+ // AGWPE sends incoming raw AX.25 frames as 'K' frames.
92
+ // Some AGWPE servers (e.g. Kantronics KA-Node) never send 'C' (connected) or 'D' (data)
93
+ // frames — all connected-session traffic arrives inside 'K' frames instead. Decode the
94
+ // embedded AX.25 to recover connection and data events so session routing still applies.
95
+ if (dataKind === "K") {
96
+ try {
97
+ const ax25 = decodeWireAx25(payload);
98
+ if (ax25.frameType === "I") {
99
+ // AX.25 I-frame: connected data from an established session.
100
+ // _kFrameOrigin marks this as derived from a raw K-frame so
101
+ // parseInboundAgwpeStream can suppress it when a D-frame for the
102
+ // same session also appears in the same TCP segment.
103
+ return {
104
+ kind: "connected-data",
105
+ direction: "rx",
106
+ instanceId,
107
+ source: ax25.source,
108
+ destination: ax25.destination,
109
+ via: ax25.via || [],
110
+ payload: ax25.payload,
111
+ _kFrameOrigin: true,
112
+ _ax25Control: ax25.control
113
+ };
114
+ }
115
+ // UA (0x63 with F=0 / 0x73 with F=1): remote station accepted our SABM.
116
+ if (ax25.frameType === "U" && (ax25.control & 0xEF) === 0x63) {
117
+ return {
118
+ kind: "connected",
119
+ direction: "rx",
120
+ instanceId,
121
+ source: ax25.source,
122
+ destination: ax25.destination,
123
+ payload
124
+ };
125
+ }
126
+ } catch (_) {
127
+ // Not a decodable AX.25 frame; deliver as raw.
128
+ }
129
+ return {
130
+ kind: "raw",
131
+ direction: "rx",
132
+ instanceId,
133
+ source,
134
+ destination,
135
+ payload,
136
+ dataKind
137
+ };
138
+ }
139
+
140
+ // 'Y' response: the TNC reports the number of outstanding (unacknowledged) I-frames
141
+ // for the callsign pair identified by source/destination. The outstanding count is
142
+ // encoded in the DataLen field (bytes 28-31); there is no payload body.
143
+ if (dataKind === "Y") {
144
+ return {
145
+ kind: "outstanding-response",
146
+ direction: "rx",
147
+ instanceId,
148
+ source,
149
+ destination,
150
+ outstanding: payloadLen
151
+ };
152
+ }
153
+
154
+ // AGWPE sends connected session data as 'D' frames (standard-compliant TNCs).
155
+ if (dataKind === "D") {
156
+ return {
157
+ kind: "connected-data",
158
+ direction: "rx",
159
+ instanceId,
160
+ source,
161
+ destination,
162
+ payload
163
+ };
164
+ }
165
+
166
+ // Ignore other frame types (connection lifecycle, monitor, etc.)
167
+ return null;
168
+ }
169
+
170
+ function parseInboundAgwpeStream(context, chunk) {
171
+ context._rxBuffer = Buffer.concat([context._rxBuffer || Buffer.alloc(0), chunk]);
172
+ const parsed = [];
173
+
174
+ while (context._rxBuffer.length >= 36) {
175
+ // AGWPE 'Y' (0x59) frames carry the outstanding-frame count in the DataLen
176
+ // field but have no payload body. Treat their frame size as exactly 36 bytes
177
+ // so the stream parser does not stall waiting for phantom payload bytes.
178
+ const frameKind = context._rxBuffer.readUInt8(4);
179
+ const dataLen = frameKind === 0x59 ? 0 : context._rxBuffer.readUInt32LE(28);
180
+ const totalLen = 36 + dataLen;
181
+ if (context._rxBuffer.length < totalLen) {
182
+ break;
183
+ }
184
+
185
+ const one = context._rxBuffer.subarray(0, totalLen);
186
+ context._rxBuffer = context._rxBuffer.subarray(totalLen);
187
+ const decoded = decodeInboundAgwpeFrame(context.instanceId, one);
188
+ if (decoded) {
189
+ parsed.push(decoded);
190
+ }
191
+ }
192
+
193
+ // Some AGWPE servers (e.g. soundmodem) emit both a raw K-frame (embedded AX.25
194
+ // I-frame) and a parsed D-frame for the same connected-session data. Delivering
195
+ // both causes the flow to receive each message twice.
196
+ //
197
+ // Two complementary dedup strategies:
198
+ //
199
+ // Same-batch — K-frame and D-frame arrive in the same TCP segment: suppress
200
+ // the K-frame immediately; no persistent state needed.
201
+ //
202
+ // Cross-segment — K-frame and D-frame arrive in separate TCP segments (common
203
+ // in practice). context._kFrameDedup tracks per (source>destination) state:
204
+ // pendingPayload payload of the last delivered K-frame I-frame
205
+ // dFrameMode once true, all future K-frame I-frames for this pair are
206
+ // suppressed (D-frames are then the authoritative source)
207
+ if (!context._kFrameDedup) {
208
+ context._kFrameDedup = new Map();
209
+ }
210
+
211
+ // Pass 1: collect source>destination keys for D-frames in this batch (same-batch dedup).
212
+ const dFramePairs = new Set();
213
+ for (const f of parsed) {
214
+ if (f.kind === "connected-data" && !f._kFrameOrigin) {
215
+ dFramePairs.add(
216
+ (f.source || "").toUpperCase() + ">" + (f.destination || "").toUpperCase()
217
+ );
218
+ }
219
+ }
220
+
221
+ // Pass 2: apply dedup rules and build result.
222
+ const result = [];
223
+ for (const f of parsed) {
224
+ if (f.kind === "connected-data") {
225
+ const srcKey = (f.source || "").toUpperCase();
226
+ const dstKey = (f.destination || "").toUpperCase();
227
+ const pairKey = srcKey + ">" + dstKey;
228
+
229
+ if (f._kFrameOrigin) {
230
+ // Same-batch: a D-frame for this pair is in the same batch — suppress K-frame.
231
+ if (dFramePairs.has(pairKey)) {
232
+ delete f._kFrameOrigin;
233
+ continue;
234
+ }
235
+ // Cross-segment: check session-level dFrameMode.
236
+ const dedup = context._kFrameDedup.get(pairKey) || { dFrameMode: false, pendingPayload: null };
237
+ if (dedup.dFrameMode) {
238
+ // Session has confirmed it uses D-frames; suppress K-frame I-frames.
239
+ delete f._kFrameOrigin;
240
+ continue;
241
+ }
242
+ // K-K digipeater dedup: suppress the digipeated copy of a K-I frame we
243
+ // already delivered (same AX.25 N(S) sequence number from the same pair
244
+ // within a 10-second window).
245
+ if (f._ax25Control !== undefined) {
246
+ const seq = (f._ax25Control >> 1) & 0x07;
247
+ if (dedup.lastKSeq === seq && typeof dedup.lastKTime === "number" &&
248
+ (Date.now() - dedup.lastKTime) < 10000) {
249
+ delete f._kFrameOrigin;
250
+ delete f._ax25Control;
251
+ continue;
252
+ }
253
+ context._kFrameDedup.set(pairKey, { dFrameMode: false, pendingPayload: f.payload, lastKSeq: seq, lastKTime: Date.now() });
254
+ } else {
255
+ // Deliver this K-frame and stash its payload for cross-segment dedup.
256
+ context._kFrameDedup.set(pairKey, { dFrameMode: false, pendingPayload: f.payload });
257
+ }
258
+ } else {
259
+ // D-frame: check for a pending K-frame payload to suppress.
260
+ const dedup = context._kFrameDedup.get(pairKey) || { dFrameMode: false, pendingPayload: null };
261
+ const pending = dedup.pendingPayload;
262
+ if (pending && Buffer.isBuffer(pending) && pending.equals(f.payload)) {
263
+ // K-frame already delivered this payload cross-segment; suppress D-frame.
264
+ context._kFrameDedup.set(pairKey, { dFrameMode: true, pendingPayload: null });
265
+ continue;
266
+ }
267
+ // D-frame with no matching pending K-frame: deliver and record dFrameMode.
268
+ context._kFrameDedup.set(pairKey, { dFrameMode: true, pendingPayload: null });
269
+ }
270
+ }
271
+
272
+ delete f._kFrameOrigin;
273
+ delete f._ax25Control;
274
+ result.push(f);
275
+ }
276
+
277
+ return result;
278
+ }
279
+
280
+ function createRouterHandlers(context) {
281
+ function routeInboundConnData(source, destination, payload, via) {
282
+ // Match received frame to a registered connected session.
283
+ // In a received frame: AX.25 destination = our callsign, AX.25 source = remote callsign.
284
+ const sessions = context.registry.list(context.instanceId);
285
+ const session = sessions.find(function (s) {
286
+ return s.state === "connected" &&
287
+ s.sourceCallsign.toUpperCase() === destination.toUpperCase() &&
288
+ s.destinationCallsign.toUpperCase() === source.toUpperCase();
289
+ });
290
+ if (!session) return;
291
+ context.bus.emit("conn-data", {
292
+ direction: "rx",
293
+ instanceId: context.instanceId,
294
+ sessionId: session.sessionId,
295
+ payload,
296
+ source,
297
+ destination,
298
+ via: via || []
299
+ });
300
+ }
301
+
302
+ return {
303
+ onUi: function (frame) {
304
+ context.bus.emit("ui-data", frame);
305
+ },
306
+ onConnected: function (frame) {
307
+ // Find the pending session this C frame confirms. Accept both callsign orientations:
308
+ // some TNCs reply with source=remote/destination=us, others echo back source=us/destination=remote.
309
+ const sessions = context.registry.list(context.instanceId);
310
+ const session = sessions.find(function (s) {
311
+ if (s.state !== "connecting") return false;
312
+ const a = s.sourceCallsign.toUpperCase();
313
+ const b = s.destinationCallsign.toUpperCase();
314
+ const x = frame.source.toUpperCase();
315
+ const y = frame.destination.toUpperCase();
316
+ return (a === y && b === x) || (a === x && b === y);
317
+ });
318
+ if (!session) return;
319
+ // Resolve which callsign is ours (sourceCallsign) regardless of frame orientation.
320
+ const ourCallsign = session.sourceCallsign;
321
+ const remoteCallsign = session.destinationCallsign;
322
+ context.registry.update(context.instanceId, session.sessionId, { state: "connected" });
323
+ context.bus.emit("conn-lifecycle", {
324
+ event: "connected",
325
+ instanceId: context.instanceId,
326
+ sessionId: session.sessionId,
327
+ source: ourCallsign,
328
+ destination: remoteCallsign,
329
+ called: remoteCallsign
330
+ });
331
+ },
332
+ onDisconnected: function (frame) {
333
+ // Find the session this d frame terminates. Match either direction for robustness.
334
+ const sessions = context.registry.list(context.instanceId);
335
+ const session = sessions.find(function (s) {
336
+ const a = s.sourceCallsign.toUpperCase();
337
+ const b = s.destinationCallsign.toUpperCase();
338
+ const x = frame.source.toUpperCase();
339
+ const y = frame.destination.toUpperCase();
340
+ return (a === y && b === x) || (a === x && b === y);
341
+ });
342
+ if (!session) return;
343
+ context.registry.remove(context.instanceId, session.sessionId);
344
+ // Clear K-frame dedup state so a future reconnect starts fresh.
345
+ if (context._kFrameDedup) {
346
+ const dedupKey =
347
+ session.destinationCallsign.toUpperCase() + ">" + session.sourceCallsign.toUpperCase();
348
+ context._kFrameDedup.delete(dedupKey);
349
+ }
350
+ context.bus.emit("conn-lifecycle", {
351
+ event: "disconnected",
352
+ instanceId: context.instanceId,
353
+ sessionId: session.sessionId,
354
+ source: session.sourceCallsign,
355
+ destination: session.destinationCallsign
356
+ });
357
+ },
358
+ onConnectedBySession: function (sessionId, frame) {
359
+ context.bus.emit("conn-data", frame);
360
+ },
361
+ onConnectedData: function (frame) {
362
+ routeInboundConnData(frame.source, frame.destination, frame.payload, frame.via);
363
+ },
364
+ onMonitor: function (frame) {
365
+ context.bus.emit("monitor-data", frame);
366
+ },
367
+ onRaw: function (frame) {
368
+ context.bus.emit("raw-data", frame);
369
+ },
370
+ onOutstandingResponse: function (frame) {
371
+ context.bus.emit("conn-y-response", frame);
372
+ },
373
+ onLifecycle: function (frame) {
374
+ context.bus.emit("conn-lifecycle", frame);
375
+ }
376
+ };
377
+ }
378
+
379
+ function sendCallsignRegistrations(node, context) {
380
+ if (!context.transport || typeof context.transport.sendFrame !== "function") {
381
+ return;
382
+ }
383
+
384
+ context.callsigns.forEach(function (callsign) {
385
+ const frame = makeAgwpeRegistrationFrame(callsign);
386
+ context.transport.sendFrame(frame, function (error) {
387
+ if (error) {
388
+ node.warn(`AGWPE callsign registration failed for ${callsign}: ${error.message}`);
389
+ }
390
+ });
391
+ });
392
+ }
393
+
394
+ function syncMonitorMode(node, context) {
395
+ if (!context.transport || typeof context.transport.sendFrame !== "function") {
396
+ return;
397
+ }
398
+
399
+ // AGWPE 'm' is a toggle command. Keep a local wire-state mirror and only send when needed.
400
+ const desiredEnabled = Boolean(context.monitorEnabled);
401
+ const currentlyEnabled = Boolean(context.monitorWireEnabled);
402
+ if (desiredEnabled === currentlyEnabled) {
403
+ return;
404
+ }
405
+
406
+ context.transport.sendFrame(makeMonitorToggleFrame(), function (error) {
407
+ if (error) {
408
+ node.warn(`AGWPE monitor toggle TX failed: ${error.message}`);
409
+ return;
410
+ }
411
+ context.monitorWireEnabled = desiredEnabled;
412
+ });
413
+ }
414
+
415
+ function syncRawMode(node, context) {
416
+ if (!context.transport || typeof context.transport.sendFrame !== "function") {
417
+ return;
418
+ }
419
+
420
+ // AGWPE 'k' is a toggle command. Keep a local wire-state mirror and only send when needed.
421
+ const desiredEnabled = Boolean(context.rawEnabled);
422
+ const currentlyEnabled = Boolean(context.rawWireEnabled);
423
+ if (desiredEnabled === currentlyEnabled) {
424
+ return;
425
+ }
426
+
427
+ context.transport.sendFrame(makeRawToggleFrame(), function (error) {
428
+ if (error) {
429
+ node.warn(`AGWPE raw toggle TX failed: ${error.message}`);
430
+ return;
431
+ }
432
+ context.rawWireEnabled = desiredEnabled;
433
+ });
434
+ }
435
+
436
+ function bindTransportBridge(node, context) {
437
+ if (context.transportBridgeBound) {
438
+ return;
439
+ }
440
+
441
+ function sendWireFrame(wireFrame, label) {
442
+ if (!context.transport || typeof context.transport.sendFrame !== "function") {
443
+ return;
444
+ }
445
+ context.transport.sendFrame(wireFrame, function (error) {
446
+ if (error) {
447
+ node.warn(`AGWPE ${label} TX failed: ${error.message}`);
448
+ }
449
+ });
450
+ }
451
+
452
+ context._onConnTx = function (frame) {
453
+ if (!frame || frame.direction !== "tx") {
454
+ return;
455
+ }
456
+ if (frame.event === "connect") {
457
+ const viaPath = Array.isArray(frame.via) && frame.via.length > 0 ? frame.via : null;
458
+ sendWireFrame(
459
+ viaPath
460
+ ? makeViaConnectFrame(frame.source, frame.destination, viaPath)
461
+ : makeConnectFrame(frame.source, frame.destination),
462
+ "conn-connect"
463
+ );
464
+ } else if (frame.event === "disconnect") {
465
+ sendWireFrame(makeDisconnectFrame(frame.source, frame.destination), "conn-disconnect");
466
+ } else {
467
+ // data chunk
468
+ const payload = Buffer.isBuffer(frame.payload)
469
+ ? frame.payload
470
+ : Buffer.from(frame.payload || "", "utf8");
471
+ sendWireFrame(makeDataFrame(frame.source, frame.destination, payload), "conn-data");
472
+ }
473
+ };
474
+
475
+ context._onUiTx = function (frame) {
476
+ if (!frame || frame.direction !== "tx") {
477
+ return;
478
+ }
479
+ const payload = Buffer.isBuffer(frame.payload)
480
+ ? frame.payload
481
+ : Buffer.from(frame.payload || "", "utf8");
482
+ sendWireFrame(makeUiFrame(frame.source, frame.destination, payload), "ui-data");
483
+ };
484
+
485
+ context._onRawTx = function (frame) {
486
+ if (!frame || frame.direction !== "tx") {
487
+ return;
488
+ }
489
+ // Raw frames carry AX.25 wire bytes inside AGWPE 'K' frames.
490
+ // Many AGWPE implementations include a leading 0x00 flag byte before
491
+ // the AX.25 address chain for K payloads; preserve provided prefix or
492
+ // prepend 0x00 by default for interoperability.
493
+ const ax25Payload = Buffer.isBuffer(frame.payload)
494
+ ? frame.payload
495
+ : typeof frame.payload === "string"
496
+ ? Buffer.from(frame.payload, "utf8")
497
+ : null;
498
+ if (!ax25Payload) {
499
+ node.warn("AGWPE raw-data TX frame skipped: invalid payload");
500
+ return;
501
+ }
502
+
503
+ const providedPort = frame.agwpePort !== undefined ? frame.agwpePort : frame.agwpePrefix;
504
+ let portByte = null;
505
+ if (Buffer.isBuffer(providedPort)) {
506
+ portByte = providedPort.length > 0 ? providedPort.readUInt8(0) : 0;
507
+ } else {
508
+ const numeric = Number(providedPort);
509
+ if (Number.isInteger(numeric) && numeric >= 0 && numeric <= 255) {
510
+ portByte = numeric;
511
+ }
512
+ }
513
+
514
+ const payload = portByte !== null
515
+ ? Buffer.concat([Buffer.from([portByte]), ax25Payload])
516
+ : ax25Payload[0] === 0x00
517
+ ? ax25Payload
518
+ : Buffer.concat([Buffer.from([0x00]), ax25Payload]);
519
+
520
+ sendWireFrame(makeRawFrame(frame.source, frame.destination, payload), "raw-data");
521
+ };
522
+
523
+ context._onYQuery = function (frame) {
524
+ // Only send a real 'y' query when the transport is a two-way EventEmitter that
525
+ // can emit the TNC's 'Y' response back. Plain-object test stubs and bare {}
526
+ // transports have no .on(), so we reply immediately with outstanding=0 to keep
527
+ // the send node's flow-control path synchronous in tests.
528
+ if (!context.transport || typeof context.transport.on !== "function") {
529
+ context.bus.emit("conn-y-response", {
530
+ kind: "outstanding-response",
531
+ direction: "rx",
532
+ instanceId: context.instanceId,
533
+ source: frame.source,
534
+ destination: frame.destination,
535
+ outstanding: 0
536
+ });
537
+ return;
538
+ }
539
+ sendWireFrame(makeOutstandingQueryFrame(frame.source, frame.destination), "conn-y-query");
540
+ };
541
+
542
+ context.bus.on("conn-data", context._onConnTx);
543
+ context.bus.on("ui-data", context._onUiTx);
544
+ context.bus.on("raw-data", context._onRawTx);
545
+ context.bus.on("conn-y-query", context._onYQuery);
546
+ context.transportBridgeBound = true;
547
+ }
548
+
549
+ function unbindTransportBridge(context) {
550
+ if (!context.transportBridgeBound) {
551
+ return;
552
+ }
553
+
554
+ if (context._onConnTx) {
555
+ context.bus.off("conn-data", context._onConnTx);
556
+ }
557
+ if (context._onUiTx) {
558
+ context.bus.off("ui-data", context._onUiTx);
559
+ }
560
+ if (context._onRawTx) {
561
+ context.bus.off("raw-data", context._onRawTx);
562
+ }
563
+ if (context._onYQuery) {
564
+ context.bus.off("conn-y-query", context._onYQuery);
565
+ }
566
+
567
+ context._onConnTx = null;
568
+ context._onUiTx = null;
569
+ context._onRawTx = null;
570
+ context._onYQuery = null;
571
+ context.transportBridgeBound = false;
572
+ }
573
+
574
+ function validateConfig(config) {
575
+ if (!config.host) return "CONNECT_REQUIRES_HOST";
576
+ if (!Number.isInteger(config.port)) return "CONNECT_REQUIRES_PORT";
577
+ return null;
578
+ }
579
+
580
+ // Cancel all active session timers, emit synthetic 'disconnected' for every
581
+ // live session so consumer nodes can flush buffered data and release claims,
582
+ // then wipe all shared per-session state from context and the registry.
583
+ // Called whenever the transport drops unexpectedly (server crash or restart).
584
+ function cleanupActiveSessions(context) {
585
+ if (context.sessionTimers) {
586
+ context.sessionTimers.forEach(function (entry) {
587
+ if (entry && entry.t) clearTimeout(entry.t);
588
+ });
589
+ context.sessionTimers.clear();
590
+ }
591
+
592
+ const sessions = context.registry.list(context.instanceId);
593
+ sessions.forEach(function (session) {
594
+ context.bus.emit("conn-lifecycle", {
595
+ event: "disconnected",
596
+ sessionId: session.sessionId,
597
+ instanceId: context.instanceId,
598
+ source: session.sourceCallsign,
599
+ destination: session.destinationCallsign
600
+ });
601
+ });
602
+
603
+ if (context.outputClaims) context.outputClaims.clear();
604
+ if (context.lineBuffers) context.lineBuffers.clear();
605
+ if (context.waitForBuffers) context.waitForBuffers.clear();
606
+ if (context.lifecycleClaims) context.lifecycleClaims.clear();
607
+ context.registry.clearInstance(context.instanceId);
608
+ }
609
+
610
+ function scheduleReconnect(node, context) {
611
+ if (context._closing || !context.reconnect || context._testTransport) {
612
+ return;
613
+ }
614
+ if (context._reconnectTimer) {
615
+ return;
616
+ }
617
+ node.status({ fill: "yellow", shape: "ring", text: "reconnecting..." });
618
+ context.bus.emit("conn-lifecycle", { event: "transport-reconnecting" });
619
+ context._reconnectTimer = setTimeout(function () {
620
+ context._reconnectTimer = null;
621
+ context.transport = null;
622
+ connectToTnc(node, context);
623
+ }, context.reconnectDelay);
624
+ }
625
+
626
+ function connectToTnc(node, context) {
627
+ unbindTransportBridge(context);
628
+ context.router.unregisterInstance(context.instanceId);
629
+
630
+ if (!context.host || !Number.isInteger(context.port)) {
631
+ const err = validateConfig(context);
632
+ node.status({ fill: "red", shape: "ring", text: "config error" });
633
+ node.warn("agwpe-client: " + err);
634
+ return;
635
+ }
636
+
637
+ context.monitorWireEnabled = false;
638
+ context.rawWireEnabled = false;
639
+ context.state = "connecting";
640
+ node.status({ fill: "yellow", shape: "dot", text: "connecting" });
641
+ context.bus.emit("conn-lifecycle", { event: "transport-connecting" });
642
+
643
+ const transportLogger = typeof node.log === "function" ? node.log.bind(node) : undefined;
644
+ context.transport = context._testTransport || new Transport({ logger: transportLogger });
645
+
646
+ if (typeof context.transport.on === "function") {
647
+ context.transport.on("error", function (error) {
648
+ context.state = "failed";
649
+ node.status({ fill: "red", shape: "dot", text: "failed" });
650
+ context.bus.emit("conn-lifecycle", {
651
+ event: "failed",
652
+ errorCode: "TRANSPORT_ERROR",
653
+ errorText: error.message
654
+ });
655
+ scheduleReconnect(node, context);
656
+ });
657
+ context.transport.on("closed", function () {
658
+ context.state = "disconnected";
659
+ cleanupActiveSessions(context);
660
+ context.bus.emit("conn-lifecycle", { event: "transport-closed" });
661
+ scheduleReconnect(node, context);
662
+ if (!context._reconnectTimer) {
663
+ node.status({ fill: "grey", shape: "ring", text: "disconnected" });
664
+ }
665
+ });
666
+ context.transport.on("frame", function (data) {
667
+ if (Buffer.isBuffer(data)) {
668
+ parseInboundAgwpeStream(context, data).forEach(function (decodedFrame) {
669
+ context.router.route(context.instanceId, decodedFrame);
670
+ });
671
+ return;
672
+ }
673
+ context.router.route(context.instanceId, data);
674
+ });
675
+ }
676
+
677
+ bindTransportBridge(node, context);
678
+
679
+ if (context._testTransport) {
680
+ context.router.registerInstance(context.instanceId, createRouterHandlers(context));
681
+ context.state = "connected";
682
+ node.status({ fill: "green", shape: "dot", text: "connected" });
683
+ context.bus.emit("conn-lifecycle", { event: "transport-connected" });
684
+ sendCallsignRegistrations(node, context);
685
+ syncMonitorMode(node, context);
686
+ syncRawMode(node, context);
687
+ return;
688
+ }
689
+
690
+ context.transport.open(context.host, context.port, function (error) {
691
+ if (error) {
692
+ context.state = "failed";
693
+ node.status({ fill: "red", shape: "dot", text: "failed" });
694
+ context.bus.emit("conn-lifecycle", {
695
+ event: "failed",
696
+ errorCode: "OPEN_FAILED",
697
+ errorText: error.message
698
+ });
699
+ scheduleReconnect(node, context);
700
+ return;
701
+ }
702
+
703
+ context.router.registerInstance(context.instanceId, createRouterHandlers(context));
704
+ context.state = "connected";
705
+ node.status({ fill: "green", shape: "dot", text: "connected" });
706
+ context.bus.emit("conn-lifecycle", { event: "transport-connected" });
707
+ sendCallsignRegistrations(node, context);
708
+ syncMonitorMode(node, context);
709
+ syncRawMode(node, context);
710
+ });
711
+ }
712
+
713
+ module.exports = function (RED) {
714
+ function AgwpeClientConfig(config) {
715
+ RED.nodes.createNode(this, config);
716
+ const node = this;
717
+ const logger = typeof node.log === "function" ? node.log.bind(node) : undefined;
718
+ const context = store.createInstance(node.id, logger);
719
+ context.instanceId = node.id;
720
+
721
+ // Expose instance so consumer nodes can reach it via RED.nodes.getNode(id).instance
722
+ node.instance = context;
723
+
724
+ // Keep node reference on context for warn/status calls in transport helpers
725
+ context.node = node;
726
+
727
+ context.host = config.host != null ? config.host : DEFAULT_HOST;
728
+ context.port = Number(config.port) || DEFAULT_PORT;
729
+ context.monitorEnabled = Boolean(config.monitor);
730
+ context.monitorWireEnabled = false;
731
+ context.rawEnabled = Boolean(config.raw);
732
+ context.rawWireEnabled = false;
733
+ context.callsigns = normalizeCallsigns(config.callsigns);
734
+ context.auth = (config.username && config.password)
735
+ ? { username: config.username, password: config.password }
736
+ : null;
737
+ context.reconnect = config.reconnect !== false; // default true
738
+ context.reconnectDelay = Number(config.reconnectDelay) || DEFAULT_RECONNECT_DELAY;
739
+ context._testTransport = config._testTransport || null;
740
+ context._closing = false;
741
+ context._reconnectTimer = null;
742
+
743
+ node.status({ fill: "grey", shape: "ring", text: "disconnected" });
744
+
745
+ connectToTnc(node, context);
746
+
747
+ node.on("close", function (removed, done) {
748
+ context._closing = true;
749
+ if (context._reconnectTimer) {
750
+ clearTimeout(context._reconnectTimer);
751
+ context._reconnectTimer = null;
752
+ }
753
+ context.router.unregisterInstance(context.instanceId);
754
+ unbindTransportBridge(context);
755
+
756
+ if (context.transport && typeof context.transport.close === "function") {
757
+ context.transport.close(function () {
758
+ store.removeInstance(node.id);
759
+ done();
760
+ });
761
+ return;
762
+ }
763
+ store.removeInstance(node.id);
764
+ done();
765
+ });
766
+ }
767
+
768
+ RED.nodes.registerType("agwpe-client", AgwpeClientConfig);
769
+ };
770
+
771
+ module.exports._internal = { validateConfig };