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