node-red-contrib-ax25 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.eslintignore +5 -0
  2. package/.prettierignore +7 -0
  3. package/ARCHITECTURE.md +174 -0
  4. package/CONTEXT.md +90 -0
  5. package/MESSAGES.md +314 -0
  6. package/README.md +317 -0
  7. package/examples/beacons.json +130 -0
  8. package/examples/beacons.png +0 -0
  9. package/examples/bye_subflow.json +107 -0
  10. package/examples/bye_subflow.png +0 -0
  11. package/examples/delete_all_my_messages.json +491 -0
  12. package/examples/delete_all_my_messages.png +0 -0
  13. package/examples/get_message_list_subflow.json +129 -0
  14. package/examples/get_message_list_subflow.png +0 -0
  15. package/examples/send_message_subflow.json +367 -0
  16. package/examples/send_message_subflow.png +0 -0
  17. package/examples/send_test_message.json +643 -0
  18. package/examples/send_test_message.png +0 -0
  19. package/jsconfig.json +37 -0
  20. package/lib/agwpe-client-transport.js +99 -0
  21. package/lib/agwpe-frame-builder.js +176 -0
  22. package/lib/agwpe-frame-pretty.js +107 -0
  23. package/lib/ax25-codec.js +382 -0
  24. package/lib/frame-router.js +95 -0
  25. package/lib/frame-segmentation.js +53 -0
  26. package/lib/message-utils.js +59 -0
  27. package/lib/runtime-store.js +94 -0
  28. package/lib/session-registry.js +142 -0
  29. package/local/buffer_compare.json +135 -0
  30. package/local/debug-d-frame.js +84 -0
  31. package/local/raw-out-test.json +128 -0
  32. package/nodes/agwpe-client.html +70 -0
  33. package/nodes/agwpe-client.js +771 -0
  34. package/nodes/agwpe-client.js.bak +871 -0
  35. package/nodes/connect.html +128 -0
  36. package/nodes/connect.js +450 -0
  37. package/nodes/decode.html +83 -0
  38. package/nodes/decode.js +56 -0
  39. package/nodes/disconnect.html +55 -0
  40. package/nodes/disconnect.js +47 -0
  41. package/nodes/encode.html +117 -0
  42. package/nodes/encode.js +164 -0
  43. package/nodes/monitor-in.html +48 -0
  44. package/nodes/monitor-in.js +42 -0
  45. package/nodes/raw-in.html +50 -0
  46. package/nodes/raw-in.js +72 -0
  47. package/nodes/raw-out.html +76 -0
  48. package/nodes/raw-out.js +144 -0
  49. package/nodes/send.html +91 -0
  50. package/nodes/send.js +373 -0
  51. package/nodes/ui-in.html +64 -0
  52. package/nodes/ui-in.js +68 -0
  53. package/nodes/ui-out.html +80 -0
  54. package/nodes/ui-out.js +133 -0
  55. package/package.json +47 -0
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const net = require("net");
4
+ const EventEmitter = require("events");
5
+ const { prettyPrintAgwpeFrame } = require("./agwpe-frame-pretty");
6
+
7
+ class AgwpeClientTransport extends EventEmitter {
8
+ constructor(options) {
9
+ super();
10
+ const opts = options || {};
11
+ this.socketFactory = opts.socketFactory || net.createConnection;
12
+ this.logger = opts.logger || function () {};
13
+ this.socket = null;
14
+ this.state = "disconnected";
15
+ }
16
+
17
+ open(host, port, callback) {
18
+ const done = typeof callback === "function" ? callback : function () {};
19
+ if (this.state === "connecting" || this.state === "connected") {
20
+ done(new Error("TRANSPORT_ALREADY_OPEN"));
21
+ return;
22
+ }
23
+
24
+ this.state = "connecting";
25
+ this.logger(`AGWPE transport connecting to ${host}:${port}`);
26
+ const socket = this.socketFactory({ host, port });
27
+ socket.setKeepAlive(true, 10000);
28
+ this.socket = socket;
29
+
30
+ socket.on("connect", () => {
31
+ this.state = "connected";
32
+ this.logger(`AGWPE transport connected to ${host}:${port}`);
33
+ this.emit("connected");
34
+ done(null);
35
+ });
36
+
37
+ socket.on("data", (data) => {
38
+ this.logger(prettyPrintAgwpeFrame(data, { direction: "rx" }));
39
+ this.emit("frame", data);
40
+ });
41
+
42
+ socket.on("error", (error) => {
43
+ this.state = "failed";
44
+ this.logger(`AGWPE transport error: ${error.message}`);
45
+ this.emit("error", error);
46
+ });
47
+
48
+ socket.on("close", () => {
49
+ this.state = "disconnected";
50
+ this.logger(`AGWPE transport closed`);
51
+ this.emit("closed");
52
+ });
53
+ }
54
+
55
+ sendFrame(frame, callback) {
56
+ const done = typeof callback === "function" ? callback : function () {};
57
+ if (!this.socket || this.state !== "connected") {
58
+ done(new Error("TRANSPORT_NOT_CONNECTED"));
59
+ return;
60
+ }
61
+ this.logger(prettyPrintAgwpeFrame(frame, { direction: "tx" }));
62
+ this.socket.write(frame, done);
63
+ }
64
+
65
+ close(callback) {
66
+ const done = typeof callback === "function" ? callback : function () {};
67
+ if (!this.socket) {
68
+ this.state = "disconnected";
69
+ done(null);
70
+ return;
71
+ }
72
+
73
+ const socket = this.socket;
74
+ this.socket = null;
75
+ this.state = "closing";
76
+ this.logger(`AGWPE transport closing`);
77
+
78
+ if (socket.destroyed) {
79
+ this.state = "disconnected";
80
+ this.logger(`AGWPE transport closed`);
81
+ done(null);
82
+ return;
83
+ }
84
+
85
+ let settled = false;
86
+ const settle = () => {
87
+ if (settled) return;
88
+ settled = true;
89
+ this.state = "disconnected";
90
+ this.logger(`AGWPE transport closed`);
91
+ done(null);
92
+ };
93
+
94
+ socket.once("close", settle);
95
+ socket.end(settle);
96
+ }
97
+ }
98
+
99
+ module.exports = AgwpeClientTransport;
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * AGWPE wire frame builder.
5
+ *
6
+ * All AGWPE frames share a 36-byte header:
7
+ * byte 0: Port (0-based)
8
+ * bytes 1-3: reserved
9
+ * byte 4: DataKind (ASCII: 'X'=register, 'C'=connect, 'd'=disconnect, 'D'=data, 'M'=UI)
10
+ * byte 5: reserved
11
+ * byte 6: PID (typically 0xF0 for AX.25 UI/I payloads)
12
+ * byte 7: reserved
13
+ * bytes 8-17: CallSign From (10 bytes, null-padded, uppercase ASCII)
14
+ * bytes 18-27:CallSign To (10 bytes, null-padded, uppercase ASCII)
15
+ * bytes 28-31:DataLength (uint32 LE) — length of data following header
16
+ * bytes 32-35:User/reserved (uint32 LE)
17
+ *
18
+ * Data payload (if any) follows immediately after the 36-byte header.
19
+ */
20
+
21
+ const HEADER_LEN = 36;
22
+
23
+ function encodeCallsign(callsign) {
24
+ const out = Buffer.alloc(10);
25
+ const normalized = String(callsign || "")
26
+ .trim()
27
+ .toUpperCase()
28
+ .slice(0, 9);
29
+ Buffer.from(normalized, "ascii").copy(out, 0);
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * Build an AGWPE frame.
35
+ *
36
+ * @param {object} opts
37
+ * @param {string} opts.kind - DataKind character (e.g. 'C', 'D', 'd', 'M')
38
+ * @param {string} [opts.from] - Source callsign
39
+ * @param {string} [opts.to] - Destination callsign
40
+ * @param {Buffer} [opts.payload] - Data payload (appended after header)
41
+ * @param {number} [opts.port] - Port number (default 0)
42
+ * @param {number} [opts.pid] - AX.25 PID byte (default 0)
43
+ * @param {number} [opts.user] - User reserved field (default 0)
44
+ * @returns {Buffer}
45
+ */
46
+ function buildAgwpeFrame(opts) {
47
+ const kind = String(opts.kind || "").charCodeAt(0);
48
+ const payload = Buffer.isBuffer(opts.payload) ? opts.payload : Buffer.alloc(0);
49
+ const frame = Buffer.alloc(HEADER_LEN + payload.length);
50
+
51
+ frame.writeUInt8(opts.port || 0, 0);
52
+ frame.writeUInt8(kind, 4);
53
+ frame.writeUInt8(opts.pid || 0, 6);
54
+ encodeCallsign(opts.from).copy(frame, 8);
55
+ encodeCallsign(opts.to).copy(frame, 18);
56
+ frame.writeUInt32LE(payload.length, 28);
57
+ frame.writeUInt32LE(opts.user || 0, 32);
58
+
59
+ if (payload.length > 0) {
60
+ payload.copy(frame, HEADER_LEN);
61
+ }
62
+
63
+ return frame;
64
+ }
65
+
66
+ /**
67
+ * Build a 'C' (connect) frame.
68
+ */
69
+ function makeConnectFrame(source, destination) {
70
+ return buildAgwpeFrame({ kind: "C", from: source, to: destination });
71
+ }
72
+
73
+ /**
74
+ * Build a 'v' (connect via digipeaters) frame.
75
+ *
76
+ * The payload contains the digipeater callsigns, each encoded as 10 bytes
77
+ * (null-padded uppercase ASCII), matching the AGWPE callsign field format.
78
+ * DataLen = N * 10 where N is the number of via stations.
79
+ *
80
+ * @param {string} source - Our callsign.
81
+ * @param {string} destination - Remote callsign.
82
+ * @param {string[]|object[]} viaCallsigns - Digipeater callsigns (strings or
83
+ * objects with a .callsign property).
84
+ * @returns {Buffer}
85
+ */
86
+ function makeViaConnectFrame(source, destination, viaCallsigns) {
87
+ const via = Array.isArray(viaCallsigns) ? viaCallsigns : [];
88
+ const viaCount = Buffer.from([via.length & 0xff]);
89
+ const viaBuffers = via.map(function (cs) {
90
+ return encodeCallsign(typeof cs === "object" ? cs.callsign : cs);
91
+ });
92
+ const viaPayload = Buffer.concat([viaCount].concat(viaBuffers));
93
+ return buildAgwpeFrame({ kind: "v", from: source, to: destination, payload: viaPayload });
94
+ }
95
+
96
+ /**
97
+ * Build an 'X' (register callsign) frame.
98
+ */
99
+ function makeRegistrationFrame(callsign) {
100
+ return buildAgwpeFrame({ kind: "X", from: callsign });
101
+ }
102
+
103
+ /**
104
+ * Build a 'd' (disconnect) frame.
105
+ */
106
+ function makeDisconnectFrame(source, destination) {
107
+ return buildAgwpeFrame({ kind: "d", from: source, to: destination });
108
+ }
109
+
110
+ /**
111
+ * Build a 'D' (connected data) frame.
112
+ */
113
+ function makeDataFrame(source, destination, payload) {
114
+ const data = Buffer.isBuffer(payload)
115
+ ? payload
116
+ : Buffer.from(payload || "", "utf8");
117
+ return buildAgwpeFrame({ kind: "D", from: source, to: destination, pid: 0xf0, payload: data });
118
+ }
119
+
120
+ /**
121
+ * Build an 'M' (UI / unconnected) frame.
122
+ */
123
+ function makeUiFrame(source, destination, payload) {
124
+ const data = Buffer.isBuffer(payload)
125
+ ? payload
126
+ : Buffer.from(payload || "", "utf8");
127
+ return buildAgwpeFrame({ kind: "M", from: source, to: destination, pid: 0xf0, payload: data });
128
+ }
129
+
130
+ /**
131
+ * Build a 'K' (raw AX.25) frame.
132
+ */
133
+ function makeRawFrame(source, destination, payload) {
134
+ const data = Buffer.isBuffer(payload)
135
+ ? payload
136
+ : Buffer.from(payload || "", "utf8");
137
+ return buildAgwpeFrame({ kind: "K", from: source, to: destination, payload: data });
138
+ }
139
+
140
+ /**
141
+ * Build a 'y' (query outstanding frames) frame.
142
+ * Asks the TNC how many unacknowledged I-frames are still pending for a
143
+ * specific connected callsign pair. The TNC replies with a 'Y' frame
144
+ * whose DataLen field contains the outstanding count.
145
+ */
146
+ function makeOutstandingQueryFrame(source, destination) {
147
+ return buildAgwpeFrame({ kind: "y", from: source, to: destination });
148
+ }
149
+
150
+ /**
151
+ * Build an 'm' command frame to toggle monitor traffic streaming.
152
+ */
153
+ function makeMonitorToggleFrame() {
154
+ return buildAgwpeFrame({ kind: "m" });
155
+ }
156
+
157
+ /**
158
+ * Build a 'k' command frame to toggle raw frame streaming.
159
+ */
160
+ function makeRawToggleFrame() {
161
+ return buildAgwpeFrame({ kind: "k" });
162
+ }
163
+
164
+ module.exports = {
165
+ buildAgwpeFrame,
166
+ makeRegistrationFrame,
167
+ makeConnectFrame,
168
+ makeViaConnectFrame,
169
+ makeDisconnectFrame,
170
+ makeDataFrame,
171
+ makeUiFrame,
172
+ makeRawFrame,
173
+ makeMonitorToggleFrame,
174
+ makeRawToggleFrame,
175
+ makeOutstandingQueryFrame
176
+ };
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ const AGWPE_HEADER_LEN = 36;
4
+
5
+ function toHexPreview(buffer, maxBytes) {
6
+ const limit = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : 32;
7
+ const view = buffer.subarray(0, limit);
8
+ const hex = view.toString("hex");
9
+ return hex.match(/.{1,2}/g)?.join(" ") || "";
10
+ }
11
+
12
+ function toAsciiPreview(buffer, maxBytes) {
13
+ const limit = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : 32;
14
+ const view = buffer.subarray(0, limit);
15
+ return view
16
+ .toString("utf8")
17
+ .replace(/[\x00-\x1F\x7F]/g, ".");
18
+ }
19
+
20
+ function decodeCallsign(buffer, offset) {
21
+ const raw = buffer.subarray(offset, offset + 10);
22
+ const nul = raw.indexOf(0x00);
23
+ const end = nul >= 0 ? nul : raw.length;
24
+ return raw.subarray(0, end).toString("ascii").trim();
25
+ }
26
+
27
+ function toDataKindChar(byteValue) {
28
+ if (byteValue >= 32 && byteValue <= 126) {
29
+ return String.fromCharCode(byteValue);
30
+ }
31
+ return "?";
32
+ }
33
+
34
+ function prettyPrintAgwpeFrame(frame, options) {
35
+ const opts = options || {};
36
+ const direction = opts.direction ? String(opts.direction) : "?";
37
+
38
+ if (Buffer.isBuffer(frame)) {
39
+ const parts = [
40
+ `AGWPE frame ${direction} buffer`,
41
+ `len=${frame.length}`
42
+ ];
43
+
44
+ if (frame.length >= AGWPE_HEADER_LEN) {
45
+ const dataKindByte = frame.readUInt8(4);
46
+ const dataLen = frame.readUInt32LE(28);
47
+ const payloadAvailable = Math.max(0, frame.length - AGWPE_HEADER_LEN);
48
+ const payloadExtractLen = Math.min(dataLen, payloadAvailable);
49
+ const payload = frame.subarray(AGWPE_HEADER_LEN, AGWPE_HEADER_LEN + payloadExtractLen);
50
+
51
+ parts.push(`port=${frame.readUInt8(0)}`);
52
+ parts.push(`dataKind=${toDataKindChar(dataKindByte)}(0x${dataKindByte.toString(16).padStart(2, "0")})`);
53
+ parts.push(`pid=0x${frame.readUInt8(6).toString(16).padStart(2, "0")}`);
54
+ parts.push(`from=${decodeCallsign(frame, 8) || "-"}`);
55
+ parts.push(`to=${decodeCallsign(frame, 18) || "-"}`);
56
+ parts.push(`dataLen=${dataLen}`);
57
+ parts.push(`user=${frame.readUInt32LE(32)}`);
58
+ parts.push(`payloadAvailable=${payloadAvailable}`);
59
+ parts.push(`complete=${payloadAvailable >= dataLen}`);
60
+
61
+ if (payloadExtractLen > 0) {
62
+ parts.push(`payloadHex=${toHexPreview(payload, opts.maxPreviewBytes)}`);
63
+ parts.push(`payloadAscii=${toAsciiPreview(payload, opts.maxPreviewBytes)}`);
64
+ }
65
+ } else {
66
+ parts.push(`hex=${toHexPreview(frame, opts.maxPreviewBytes)}`);
67
+ parts.push(`ascii=${toAsciiPreview(frame, opts.maxPreviewBytes)}`);
68
+ }
69
+
70
+ return parts.join(" | ");
71
+ }
72
+
73
+ if (!frame || typeof frame !== "object") {
74
+ return `AGWPE frame ${direction} invalid=${String(frame)}`;
75
+ }
76
+
77
+ const parts = [
78
+ `AGWPE frame ${direction}`,
79
+ `kind=${frame.kind || "unknown"}`
80
+ ];
81
+
82
+ if (frame.sessionId !== undefined) {
83
+ parts.push(`sessionId=${frame.sessionId}`);
84
+ }
85
+ if (frame.source) {
86
+ parts.push(`source=${frame.source}`);
87
+ }
88
+ if (frame.destination) {
89
+ parts.push(`destination=${frame.destination}`);
90
+ }
91
+ if (frame.event) {
92
+ parts.push(`event=${frame.event}`);
93
+ }
94
+
95
+ if (Buffer.isBuffer(frame.payload)) {
96
+ parts.push(`payloadLen=${frame.payload.length}`);
97
+ parts.push(`payloadHex=${toHexPreview(frame.payload, opts.maxPreviewBytes)}`);
98
+ } else if (typeof frame.payload === "string") {
99
+ parts.push(`payload="${frame.payload.slice(0, 64)}${frame.payload.length > 64 ? "..." : ""}"`);
100
+ }
101
+
102
+ return parts.join(" | ");
103
+ }
104
+
105
+ module.exports = {
106
+ prettyPrintAgwpeFrame
107
+ };