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,382 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function classifyControl(control) {
|
|
4
|
+
if ((control & 0x01) === 0) {
|
|
5
|
+
return "I";
|
|
6
|
+
}
|
|
7
|
+
if ((control & 0x03) === 0x01) {
|
|
8
|
+
return "S";
|
|
9
|
+
}
|
|
10
|
+
return "U";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function decodeAddress(buffer) {
|
|
14
|
+
return buffer.toString("ascii");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseCallsign(input) {
|
|
18
|
+
const raw = String(input || "").trim().toUpperCase();
|
|
19
|
+
if (!raw) {
|
|
20
|
+
throw new Error("INVALID_CALLSIGN");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parts = raw.split("-");
|
|
24
|
+
const call = String(parts[0] || "").trim();
|
|
25
|
+
if (!call) {
|
|
26
|
+
throw new Error("INVALID_CALLSIGN");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ssid = parts.length > 1 ? Number.parseInt(parts[1], 10) : 0;
|
|
30
|
+
if (!Number.isInteger(ssid) || ssid < 0 || ssid > 15) {
|
|
31
|
+
throw new Error("INVALID_SSID");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
call,
|
|
36
|
+
ssid
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function encodeAx25Address(callsign, options) {
|
|
41
|
+
const opts = options || {};
|
|
42
|
+
const parsed = parseCallsign(callsign);
|
|
43
|
+
const call = parsed.call.slice(0, 6).padEnd(6, " ");
|
|
44
|
+
|
|
45
|
+
const out = Buffer.alloc(7);
|
|
46
|
+
for (let i = 0; i < 6; i++) {
|
|
47
|
+
out[i] = call.charCodeAt(i) << 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Base AX.25 SSID field keeps reserved bits set.
|
|
51
|
+
let ssidByte = 0x60 | ((parsed.ssid & 0x0f) << 1);
|
|
52
|
+
if (opts.hasBeenRepeated) {
|
|
53
|
+
ssidByte |= 0x80;
|
|
54
|
+
}
|
|
55
|
+
if (opts.isLast) {
|
|
56
|
+
ssidByte |= 0x01;
|
|
57
|
+
}
|
|
58
|
+
out[6] = ssidByte;
|
|
59
|
+
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeVia(value) {
|
|
64
|
+
if (value === undefined || value === null || value === "") {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
return [{ callsign: value, hasBeenRepeated: false }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!Array.isArray(value)) {
|
|
73
|
+
throw new Error("ENCODE_INVALID_VIA");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return value.map(function (entry) {
|
|
77
|
+
if (typeof entry === "string") {
|
|
78
|
+
return {
|
|
79
|
+
callsign: entry,
|
|
80
|
+
hasBeenRepeated: false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!entry || typeof entry !== "object") {
|
|
85
|
+
throw new Error("ENCODE_INVALID_VIA_ENTRY");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!entry.callsign) {
|
|
89
|
+
throw new Error("ENCODE_VIA_CALLSIGN_REQUIRED");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
callsign: entry.callsign,
|
|
94
|
+
hasBeenRepeated: Boolean(entry.hasBeenRepeated)
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decode one AX.25 wire address field (7 bytes).
|
|
101
|
+
*
|
|
102
|
+
* Layout:
|
|
103
|
+
* - bytes 0..5: callsign characters shifted left by 1
|
|
104
|
+
* - byte 6: SSID/flags
|
|
105
|
+
*/
|
|
106
|
+
function decodeAx25Address(buffer) {
|
|
107
|
+
if (buffer.length < 7) {
|
|
108
|
+
throw new Error("INVALID_ADDRESS_LEN");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let call = "";
|
|
112
|
+
for (let i = 0; i < 6; i++) {
|
|
113
|
+
call += String.fromCharCode(buffer[i] >> 1);
|
|
114
|
+
}
|
|
115
|
+
call = call.trim();
|
|
116
|
+
|
|
117
|
+
const ssidByte = buffer[6];
|
|
118
|
+
const ssid = (ssidByte >> 1) & 0x0f;
|
|
119
|
+
const hasBeenRepeated = (ssidByte & 0x80) !== 0;
|
|
120
|
+
const isLast = (ssidByte & 0x01) !== 0;
|
|
121
|
+
|
|
122
|
+
const callsign = ssid > 0 ? `${call}-${ssid}` : call;
|
|
123
|
+
return {
|
|
124
|
+
callsign,
|
|
125
|
+
call,
|
|
126
|
+
ssid,
|
|
127
|
+
hasBeenRepeated,
|
|
128
|
+
isLast,
|
|
129
|
+
raw: Buffer.from(buffer)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Try to detect if buffer is AX.25 wire format or codec format.
|
|
135
|
+
* Codec format starts with short destination length.
|
|
136
|
+
* AX.25 wire address first byte is shifted callsign char, usually >= 0x60.
|
|
137
|
+
* Some AGWPE K payloads may include a leading 0x00 before address bytes.
|
|
138
|
+
*/
|
|
139
|
+
function isWireFormat(buffer) {
|
|
140
|
+
if (buffer.length < 15) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const firstByte = buffer.readUInt8(0);
|
|
145
|
+
|
|
146
|
+
// Legacy compact codec format starts with short destination length.
|
|
147
|
+
if (firstByte > 0 && firstByte <= 10) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Standard AX.25 shifted chars are typically >= 0x60.
|
|
152
|
+
if (firstByte >= 0x60) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// AGWPE K payloads are sometimes observed with a leading 0x00 byte.
|
|
157
|
+
if (firstByte === 0x00 && buffer.length >= 16 && buffer.readUInt8(1) >= 0x60) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasPidField(control) {
|
|
165
|
+
// I-frames always carry PID.
|
|
166
|
+
if ((control & 0x01) === 0) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// U-frames: UI (0x03 with optional P/F bit 0x10) carries PID.
|
|
171
|
+
if ((control & 0x03) === 0x03) {
|
|
172
|
+
return (control & 0xef) === 0x03;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// S-frames do not carry PID.
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseAddressChain(buffer, offset) {
|
|
180
|
+
const addresses = [];
|
|
181
|
+
let cursor = offset;
|
|
182
|
+
|
|
183
|
+
while (cursor + 7 <= buffer.length) {
|
|
184
|
+
const decoded = decodeAx25Address(buffer.subarray(cursor, cursor + 7));
|
|
185
|
+
addresses.push(decoded);
|
|
186
|
+
cursor += 7;
|
|
187
|
+
if (decoded.isLast) {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (addresses.length < 2) {
|
|
193
|
+
throw new Error("AX25_ADDRESS_CHAIN_TOO_SHORT");
|
|
194
|
+
}
|
|
195
|
+
if (!addresses[addresses.length - 1].isLast) {
|
|
196
|
+
throw new Error("AX25_ADDRESS_CHAIN_NOT_TERMINATED");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
addresses,
|
|
201
|
+
nextOffset: cursor
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Decode real AX.25 wire format frame.
|
|
207
|
+
* Structure:
|
|
208
|
+
* - destination (7)
|
|
209
|
+
* - source (7)
|
|
210
|
+
* - 0..N via addresses (7 each)
|
|
211
|
+
* - control (1)
|
|
212
|
+
* - optional PID (1)
|
|
213
|
+
* - information field
|
|
214
|
+
*/
|
|
215
|
+
function decodeWireAx25(raw) {
|
|
216
|
+
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || []);
|
|
217
|
+
if (buffer.length < 15) {
|
|
218
|
+
throw new Error("AX25_FRAME_TOO_SHORT");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Accept optional leading non-address byte seen in some AGWPE K payloads.
|
|
222
|
+
const startOffset = buffer[0] >= 0x60 ? 0 : (buffer[0] === 0x00 && buffer[1] >= 0x60 ? 1 : 0);
|
|
223
|
+
|
|
224
|
+
const chain = parseAddressChain(buffer, startOffset);
|
|
225
|
+
if (chain.nextOffset >= buffer.length) {
|
|
226
|
+
throw new Error("AX25_MISSING_CONTROL");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const control = buffer.readUInt8(chain.nextOffset);
|
|
230
|
+
let cursor = chain.nextOffset + 1;
|
|
231
|
+
|
|
232
|
+
let pid = null;
|
|
233
|
+
if (hasPidField(control)) {
|
|
234
|
+
if (cursor >= buffer.length) {
|
|
235
|
+
throw new Error("AX25_MISSING_PID");
|
|
236
|
+
}
|
|
237
|
+
pid = buffer.readUInt8(cursor);
|
|
238
|
+
cursor += 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const destinationAddr = chain.addresses[0];
|
|
242
|
+
const sourceAddr = chain.addresses[1];
|
|
243
|
+
const destination = destinationAddr.callsign;
|
|
244
|
+
const source = sourceAddr.callsign;
|
|
245
|
+
const destinationHasBeenRepeated = destinationAddr.hasBeenRepeated;
|
|
246
|
+
const sourceHasBeenRepeated = sourceAddr.hasBeenRepeated;
|
|
247
|
+
const via = chain.addresses.slice(2).map(function (entry) {
|
|
248
|
+
return {
|
|
249
|
+
callsign: entry.callsign,
|
|
250
|
+
hasBeenRepeated: entry.hasBeenRepeated
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const payload = buffer.subarray(cursor);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
source,
|
|
258
|
+
destination,
|
|
259
|
+
sourceHasBeenRepeated,
|
|
260
|
+
destinationHasBeenRepeated,
|
|
261
|
+
via,
|
|
262
|
+
control,
|
|
263
|
+
pid,
|
|
264
|
+
frameType: classifyControl(control),
|
|
265
|
+
payload
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function encode(frame) {
|
|
270
|
+
if (!frame || !frame.source || !frame.destination || frame.control === undefined) {
|
|
271
|
+
throw new Error("ENCODE_INVALID_INPUT");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const controlValue = Number(frame.control);
|
|
275
|
+
if (!Number.isInteger(controlValue) || controlValue < 0 || controlValue > 255) {
|
|
276
|
+
throw new Error("ENCODE_INVALID_CONTROL");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const via = normalizeVia(frame.via);
|
|
280
|
+
|
|
281
|
+
const addresses = [];
|
|
282
|
+
addresses.push(
|
|
283
|
+
encodeAx25Address(frame.destination, {
|
|
284
|
+
isLast: false,
|
|
285
|
+
hasBeenRepeated: Boolean(frame.destinationHasBeenRepeated)
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Source is last only when there are no via.
|
|
290
|
+
addresses.push(
|
|
291
|
+
encodeAx25Address(frame.source, {
|
|
292
|
+
isLast: via.length === 0,
|
|
293
|
+
hasBeenRepeated: Boolean(frame.sourceHasBeenRepeated)
|
|
294
|
+
})
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < via.length; i++) {
|
|
298
|
+
const viaEntry = via[i];
|
|
299
|
+
addresses.push(
|
|
300
|
+
encodeAx25Address(viaEntry.callsign, {
|
|
301
|
+
isLast: i === via.length - 1,
|
|
302
|
+
hasBeenRepeated: viaEntry.hasBeenRepeated
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const control = Buffer.from([controlValue]);
|
|
308
|
+
const payload = Buffer.isBuffer(frame.payload)
|
|
309
|
+
? frame.payload
|
|
310
|
+
: Array.isArray(frame.payload)
|
|
311
|
+
? Buffer.from(frame.payload)
|
|
312
|
+
: Buffer.from(frame.payload || "", "utf8");
|
|
313
|
+
|
|
314
|
+
const parts = addresses.concat([control]);
|
|
315
|
+
if (hasPidField(controlValue)) {
|
|
316
|
+
const pidValue = frame.pid === undefined || frame.pid === null ? 0xf0 : Number(frame.pid);
|
|
317
|
+
if (!Number.isInteger(pidValue) || pidValue < 0 || pidValue > 255) {
|
|
318
|
+
throw new Error("ENCODE_INVALID_PID");
|
|
319
|
+
}
|
|
320
|
+
parts.push(Buffer.from([pidValue]));
|
|
321
|
+
}
|
|
322
|
+
parts.push(payload);
|
|
323
|
+
|
|
324
|
+
return Buffer.concat(parts);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function decode(raw) {
|
|
328
|
+
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || []);
|
|
329
|
+
|
|
330
|
+
// Auto-detect format: codec format vs wire format
|
|
331
|
+
if (isWireFormat(buffer)) {
|
|
332
|
+
return decodeWireAx25(buffer);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Codec format (length-prefixed)
|
|
336
|
+
if (buffer.length < 4) {
|
|
337
|
+
throw new Error("DECODE_TOO_SHORT");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const destinationLen = buffer.readUInt8(0);
|
|
341
|
+
const destinationStart = 1;
|
|
342
|
+
const destinationEnd = destinationStart + destinationLen;
|
|
343
|
+
const sourceLenPos = destinationEnd;
|
|
344
|
+
|
|
345
|
+
if (buffer.length < sourceLenPos + 1) {
|
|
346
|
+
throw new Error("DECODE_INVALID_DEST");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sourceLen = buffer.readUInt8(sourceLenPos);
|
|
350
|
+
const sourceStart = sourceLenPos + 1;
|
|
351
|
+
const sourceEnd = sourceStart + sourceLen;
|
|
352
|
+
const controlPos = sourceEnd;
|
|
353
|
+
const pidPos = controlPos + 1;
|
|
354
|
+
const payloadPos = pidPos + 1;
|
|
355
|
+
|
|
356
|
+
if (buffer.length < payloadPos) {
|
|
357
|
+
throw new Error("DECODE_INVALID_SOURCE");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const destination = decodeAddress(buffer.subarray(destinationStart, destinationEnd));
|
|
361
|
+
const source = decodeAddress(buffer.subarray(sourceStart, sourceEnd));
|
|
362
|
+
const control = buffer.readUInt8(controlPos);
|
|
363
|
+
const pid = buffer.readUInt8(pidPos);
|
|
364
|
+
const payload = buffer.subarray(payloadPos);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
source,
|
|
368
|
+
destination,
|
|
369
|
+
control,
|
|
370
|
+
pid,
|
|
371
|
+
frameType: classifyControl(control),
|
|
372
|
+
payload
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
encode,
|
|
378
|
+
decode,
|
|
379
|
+
decodeWireAx25,
|
|
380
|
+
decodeAx25Address,
|
|
381
|
+
classifyControl
|
|
382
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events");
|
|
4
|
+
const { prettyPrintAgwpeFrame } = require("./agwpe-frame-pretty");
|
|
5
|
+
|
|
6
|
+
class FrameRouter extends EventEmitter {
|
|
7
|
+
constructor(logger) {
|
|
8
|
+
super();
|
|
9
|
+
this.logger = logger || function () {};
|
|
10
|
+
this.instances = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
registerInstance(instanceId, handlers) {
|
|
14
|
+
this.instances.set(instanceId, handlers || {});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
unregisterInstance(instanceId) {
|
|
18
|
+
this.instances.delete(instanceId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
route(instanceId, frame) {
|
|
22
|
+
const handlers = this.instances.get(instanceId);
|
|
23
|
+
if (!handlers) {
|
|
24
|
+
this.logger(`Frame routing failed: no handlers for instanceId ${instanceId}`);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.logger(`${prettyPrintAgwpeFrame(frame, { direction: "route" })} | instanceId=${instanceId}`);
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
frame.kind === "connected-data" &&
|
|
32
|
+
typeof handlers.onConnectedData === "function"
|
|
33
|
+
) {
|
|
34
|
+
this.logger(`Frame routing: connected-data to ${instanceId}`);
|
|
35
|
+
handlers.onConnectedData(frame);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
frame.kind === "connected" &&
|
|
41
|
+
frame.sessionId &&
|
|
42
|
+
typeof handlers.onConnectedBySession === "function"
|
|
43
|
+
) {
|
|
44
|
+
this.logger(`Frame routing: connected(sessionId: ${frame.sessionId}) to ${instanceId}`);
|
|
45
|
+
handlers.onConnectedBySession(frame.sessionId, frame);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (frame.kind === "connected" && typeof handlers.onConnected === "function") {
|
|
50
|
+
this.logger(`Frame routing: connected to ${instanceId}`);
|
|
51
|
+
handlers.onConnected(frame);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (frame.kind === "disconnected" && typeof handlers.onDisconnected === "function") {
|
|
56
|
+
this.logger(`Frame routing: disconnected to ${instanceId}`);
|
|
57
|
+
handlers.onDisconnected(frame);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (frame.kind === "ui" && typeof handlers.onUi === "function") {
|
|
62
|
+
this.logger(`Frame routing: ui to ${instanceId}`);
|
|
63
|
+
handlers.onUi(frame);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (frame.kind === "monitor" && typeof handlers.onMonitor === "function") {
|
|
68
|
+
this.logger(`Frame routing: monitor to ${instanceId}`);
|
|
69
|
+
handlers.onMonitor(frame);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (frame.kind === "raw" && typeof handlers.onRaw === "function") {
|
|
74
|
+
this.logger(`Frame routing: raw to ${instanceId}`);
|
|
75
|
+
handlers.onRaw(frame);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (frame.kind === "outstanding-response" && typeof handlers.onOutstandingResponse === "function") {
|
|
80
|
+
this.logger(`Frame routing: outstanding-response to ${instanceId}`);
|
|
81
|
+
handlers.onOutstandingResponse(frame);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (frame.kind === "lifecycle" && typeof handlers.onLifecycle === "function") {
|
|
86
|
+
this.logger(`Frame routing: lifecycle to ${instanceId}`);
|
|
87
|
+
handlers.onLifecycle(frame);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = FrameRouter;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CHUNK_SIZE = 255;
|
|
4
|
+
|
|
5
|
+
function toBuffer(payload) {
|
|
6
|
+
if (Buffer.isBuffer(payload)) {
|
|
7
|
+
return payload;
|
|
8
|
+
}
|
|
9
|
+
if (typeof payload === "string") {
|
|
10
|
+
return Buffer.from(payload, "utf8");
|
|
11
|
+
}
|
|
12
|
+
if (payload === undefined || payload === null) {
|
|
13
|
+
return Buffer.alloc(0);
|
|
14
|
+
}
|
|
15
|
+
throw new TypeError("payload must be a string or Buffer");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function splitPayload(payload, chunkSize) {
|
|
19
|
+
const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE;
|
|
20
|
+
const buffer = toBuffer(payload);
|
|
21
|
+
|
|
22
|
+
if (buffer.length === 0) {
|
|
23
|
+
return [Buffer.alloc(0)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for (let index = 0; index < buffer.length; index += size) {
|
|
28
|
+
chunks.push(buffer.subarray(index, index + size));
|
|
29
|
+
}
|
|
30
|
+
return chunks;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildChunkMetadata(payload, options) {
|
|
34
|
+
const opts = options || {};
|
|
35
|
+
const messageId = opts.messageId;
|
|
36
|
+
const chunks = splitPayload(payload, opts.chunkSize);
|
|
37
|
+
const chunkCount = chunks.length;
|
|
38
|
+
|
|
39
|
+
return chunks.map(function (chunk, chunkIndex) {
|
|
40
|
+
return {
|
|
41
|
+
messageId,
|
|
42
|
+
chunkIndex,
|
|
43
|
+
chunkCount,
|
|
44
|
+
payload: chunk
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
DEFAULT_CHUNK_SIZE,
|
|
51
|
+
splitPayload,
|
|
52
|
+
buildChunkMetadata
|
|
53
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
function nowTimestamp() {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeMessageId(prefix) {
|
|
10
|
+
const head = prefix || "msg";
|
|
11
|
+
return head + "-" + crypto.randomUUID();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function okEnvelope(fields) {
|
|
15
|
+
return Object.assign(
|
|
16
|
+
{
|
|
17
|
+
timestamp: nowTimestamp(),
|
|
18
|
+
status: "ok"
|
|
19
|
+
},
|
|
20
|
+
fields || {}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function errorEnvelope(errorCode, errorText, fields) {
|
|
25
|
+
return Object.assign(
|
|
26
|
+
{
|
|
27
|
+
timestamp: nowTimestamp(),
|
|
28
|
+
status: "error",
|
|
29
|
+
errorCode,
|
|
30
|
+
errorText
|
|
31
|
+
},
|
|
32
|
+
fields || {}
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function chunkEnvelope(fields) {
|
|
37
|
+
const data = Object.assign({ timestamp: nowTimestamp() }, fields || {});
|
|
38
|
+
if (!Number.isInteger(data.chunkIndex) || data.chunkIndex < 0) {
|
|
39
|
+
throw new Error("chunkIndex must be a non-negative integer");
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isInteger(data.chunkCount) || data.chunkCount < 1) {
|
|
42
|
+
throw new Error("chunkCount must be a positive integer");
|
|
43
|
+
}
|
|
44
|
+
if (data.chunkIndex >= data.chunkCount) {
|
|
45
|
+
throw new Error("chunkIndex must be less than chunkCount");
|
|
46
|
+
}
|
|
47
|
+
if (!data.messageId) {
|
|
48
|
+
throw new Error("messageId is required");
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
nowTimestamp,
|
|
55
|
+
makeMessageId,
|
|
56
|
+
okEnvelope,
|
|
57
|
+
errorEnvelope,
|
|
58
|
+
chunkEnvelope
|
|
59
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events");
|
|
4
|
+
const SessionRegistry = require("./session-registry");
|
|
5
|
+
const FrameRouter = require("./frame-router");
|
|
6
|
+
|
|
7
|
+
// Global bus: all instance buses forward conn-data/conn-lifecycle/conn-timeout-set here
|
|
8
|
+
// so nodes without a fixed config.client (e.g. Send) can subscribe once and receive
|
|
9
|
+
// events from any agwpe-client instance.
|
|
10
|
+
const globalBus = new EventEmitter();
|
|
11
|
+
globalBus.setMaxListeners(0);
|
|
12
|
+
|
|
13
|
+
// sessionIndex maps sessionId → instanceId so Send can derive the correct context
|
|
14
|
+
// from just a sessionId, without needing config.client on the node.
|
|
15
|
+
const sessionIndex = new Map();
|
|
16
|
+
|
|
17
|
+
function indexSession(sessionId, instanceId) {
|
|
18
|
+
sessionIndex.set(sessionId, instanceId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unindexSession(sessionId) {
|
|
22
|
+
sessionIndex.delete(sessionId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function instanceIdForSession(sessionId) {
|
|
26
|
+
return sessionIndex.get(sessionId) || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Instance map for direct id-based lookup
|
|
30
|
+
const instances = new Map();
|
|
31
|
+
|
|
32
|
+
function createInstance(instanceId, logger) {
|
|
33
|
+
const log = typeof logger === "function" ? logger : function () {};
|
|
34
|
+
const bus = new EventEmitter();
|
|
35
|
+
bus.setMaxListeners(0);
|
|
36
|
+
const context = {
|
|
37
|
+
instanceId,
|
|
38
|
+
state: "disconnected",
|
|
39
|
+
monitorEnabled: false,
|
|
40
|
+
rawEnabled: false,
|
|
41
|
+
rawWireEnabled: false,
|
|
42
|
+
auth: null,
|
|
43
|
+
callsigns: [],
|
|
44
|
+
host: null,
|
|
45
|
+
port: null,
|
|
46
|
+
transport: null,
|
|
47
|
+
bus,
|
|
48
|
+
registry: new SessionRegistry(),
|
|
49
|
+
router: new FrameRouter(log),
|
|
50
|
+
logger: log
|
|
51
|
+
};
|
|
52
|
+
instances.set(instanceId, context);
|
|
53
|
+
|
|
54
|
+
// Forward instance bus events to the global bus so Send nodes subscribed to
|
|
55
|
+
// globalBus receive events from all instances, not just their configured one.
|
|
56
|
+
["conn-data", "conn-lifecycle", "conn-timeout-set"].forEach(function (evtName) {
|
|
57
|
+
bus.on(evtName, function (evt) { globalBus.emit(evtName, evt); });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return context;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getInstance(instanceId) {
|
|
64
|
+
return instances.get(instanceId) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureInstance(instanceId) {
|
|
68
|
+
let inst = getInstance(instanceId);
|
|
69
|
+
if (!inst) {
|
|
70
|
+
inst = createInstance(instanceId);
|
|
71
|
+
instances.set(instanceId, inst);
|
|
72
|
+
}
|
|
73
|
+
return inst;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeInstance(instanceId) {
|
|
77
|
+
instances.delete(instanceId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getAllInstances() {
|
|
81
|
+
return Array.from(instances.values());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
createInstance,
|
|
86
|
+
getInstance,
|
|
87
|
+
ensureInstance,
|
|
88
|
+
removeInstance,
|
|
89
|
+
getAllInstances,
|
|
90
|
+
globalBus,
|
|
91
|
+
indexSession,
|
|
92
|
+
unindexSession,
|
|
93
|
+
instanceIdForSession
|
|
94
|
+
};
|