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.
- package/.eslintignore +5 -0
- package/.prettierignore +7 -0
- package/ARCHITECTURE.md +174 -0
- package/CONTEXT.md +90 -0
- package/MESSAGES.md +314 -0
- package/README.md +317 -0
- package/examples/beacons.json +130 -0
- package/examples/beacons.png +0 -0
- package/examples/bye_subflow.json +107 -0
- package/examples/bye_subflow.png +0 -0
- package/examples/delete_all_my_messages.json +491 -0
- package/examples/delete_all_my_messages.png +0 -0
- package/examples/get_message_list_subflow.json +129 -0
- package/examples/get_message_list_subflow.png +0 -0
- package/examples/send_message_subflow.json +367 -0
- package/examples/send_message_subflow.png +0 -0
- package/examples/send_test_message.json +643 -0
- package/examples/send_test_message.png +0 -0
- package/jsconfig.json +37 -0
- package/lib/agwpe-client-transport.js +99 -0
- package/lib/agwpe-frame-builder.js +176 -0
- package/lib/agwpe-frame-pretty.js +107 -0
- package/lib/ax25-codec.js +382 -0
- package/lib/frame-router.js +95 -0
- package/lib/frame-segmentation.js +53 -0
- package/lib/message-utils.js +59 -0
- package/lib/runtime-store.js +94 -0
- package/lib/session-registry.js +142 -0
- package/local/buffer_compare.json +135 -0
- package/local/debug-d-frame.js +84 -0
- package/local/raw-out-test.json +128 -0
- package/nodes/agwpe-client.html +70 -0
- package/nodes/agwpe-client.js +771 -0
- package/nodes/agwpe-client.js.bak +871 -0
- package/nodes/connect.html +128 -0
- package/nodes/connect.js +450 -0
- package/nodes/decode.html +83 -0
- package/nodes/decode.js +56 -0
- package/nodes/disconnect.html +55 -0
- package/nodes/disconnect.js +47 -0
- package/nodes/encode.html +117 -0
- package/nodes/encode.js +164 -0
- package/nodes/monitor-in.html +48 -0
- package/nodes/monitor-in.js +42 -0
- package/nodes/raw-in.html +50 -0
- package/nodes/raw-in.js +72 -0
- package/nodes/raw-out.html +76 -0
- package/nodes/raw-out.js +144 -0
- package/nodes/send.html +91 -0
- package/nodes/send.js +373 -0
- package/nodes/ui-in.html +64 -0
- package/nodes/ui-in.js +68 -0
- package/nodes/ui-out.html +80 -0
- package/nodes/ui-out.js +133 -0
- 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 };
|