node-red-contrib-ax25 1.0.0 → 1.0.2

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.
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("connect", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
package/nodes/decode.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("decode", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  name: { value: "" },
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("disconnect", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  name: { value: "" }
package/nodes/encode.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("encode", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  name: { value: "" },
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("monitor-in", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
package/nodes/raw-in.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("raw-in", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("raw-out", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
package/nodes/send.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("send", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  name: { value: "" },
package/nodes/ui-in.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("ui-in", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
package/nodes/ui-out.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType("ui-out", {
3
- category: "agwpe",
3
+ category: "ax25",
4
4
  color: "#d4edda",
5
5
  defaults: {
6
6
  client: { value: "", type: "agwpe-client", required: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ax25",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Node-RED nodes for AX.25 connectivity and tooling",
5
5
  "main": "nodes/agwpe-client.js",
6
6
  "license": "MIT",
@@ -11,11 +11,19 @@
11
11
  "aprs",
12
12
  "packet-radio"
13
13
  ],
14
- "author": {
14
+ "author": {
15
15
  "name": "Robert Ambrose",
16
16
  "email": "npm@muttsoft.com",
17
17
  "url": "https://github.com/n7get/node-red-contrib-ax25"
18
18
  },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/n7get/node-red-contrib-ax25.git"
22
+ },
23
+ "homepage": "https://github.com/n7get/node-red-contrib-ax25",
24
+ "bugs": {
25
+ "url": "https://github.com/n7get/node-red-contrib-ax25/issues"
26
+ },
19
27
  "engines": {
20
28
  "node": ">=20"
21
29
  },
@@ -1,135 +0,0 @@
1
- [
2
- {
3
- "id": "5b244fa604fea472",
4
- "type": "debug",
5
- "z": "efec118fdfbb44e1",
6
- "name": "debug 4",
7
- "active": false,
8
- "tosidebar": true,
9
- "console": false,
10
- "tostatus": false,
11
- "complete": "true",
12
- "targetType": "full",
13
- "statusVal": "",
14
- "statusType": "auto",
15
- "x": 380,
16
- "y": 480,
17
- "wires": []
18
- },
19
- {
20
- "id": "21825cd2b6399cdb",
21
- "type": "raw-in",
22
- "z": "efec118fdfbb44e1",
23
- "name": "",
24
- "x": 170,
25
- "y": 480,
26
- "wires": [
27
- [
28
- "5b244fa604fea472",
29
- "7c55133426e0bf75",
30
- "fa0970eb0abcbd27"
31
- ]
32
- ]
33
- },
34
- {
35
- "id": "7c55133426e0bf75",
36
- "type": "decode",
37
- "z": "efec118fdfbb44e1",
38
- "name": "",
39
- "x": 380,
40
- "y": 540,
41
- "wires": [
42
- [
43
- "716c85ad8fa5ab9e",
44
- "16f45c05ab12108d"
45
- ]
46
- ]
47
- },
48
- {
49
- "id": "665950798e589835",
50
- "type": "debug",
51
- "z": "efec118fdfbb44e1",
52
- "name": "debug 5",
53
- "active": false,
54
- "tosidebar": true,
55
- "console": false,
56
- "tostatus": false,
57
- "complete": "true",
58
- "targetType": "full",
59
- "statusVal": "",
60
- "statusType": "auto",
61
- "x": 740,
62
- "y": 540,
63
- "wires": []
64
- },
65
- {
66
- "id": "716c85ad8fa5ab9e",
67
- "type": "function",
68
- "z": "efec118fdfbb44e1",
69
- "name": "function 2",
70
- "func": "msg.data = String.fromCharCode(...msg.payload);\nreturn msg;",
71
- "outputs": 1,
72
- "timeout": 0,
73
- "noerr": 0,
74
- "initialize": "",
75
- "finalize": "",
76
- "libs": [],
77
- "x": 560,
78
- "y": 540,
79
- "wires": [
80
- [
81
- "665950798e589835"
82
- ]
83
- ]
84
- },
85
- {
86
- "id": "16f45c05ab12108d",
87
- "type": "encode",
88
- "z": "efec118fdfbb44e1",
89
- "name": "",
90
- "x": 560,
91
- "y": 600,
92
- "wires": [
93
- [
94
- "fa0970eb0abcbd27"
95
- ]
96
- ]
97
- },
98
- {
99
- "id": "d12d061e1569391f",
100
- "type": "debug",
101
- "z": "efec118fdfbb44e1",
102
- "name": "debug 6",
103
- "active": true,
104
- "tosidebar": true,
105
- "console": false,
106
- "tostatus": false,
107
- "complete": "true",
108
- "targetType": "full",
109
- "statusVal": "",
110
- "statusType": "auto",
111
- "x": 760,
112
- "y": 680,
113
- "wires": []
114
- },
115
- {
116
- "id": "fa0970eb0abcbd27",
117
- "type": "function",
118
- "z": "efec118fdfbb44e1",
119
- "name": "function 3",
120
- "func": "if (msg.event === 'raw') {\n flow.set('raw', msg.payload);\n}\n\nif (msg.event === 'encoded') {\n const raw = flow.get('raw');\n const encoded = msg.payload;\n\n // Find differing indices\n const maxLen = Math.max(raw.length, encoded.length);\n const diffs = [];\n for (let i = 0; i < maxLen; i++) {\n if (raw[i] !== encoded[i]) {\n diffs.push({ index: i, raw: raw[i], encoded: encoded[i] });\n }\n }\n\n if (diffs.length > 0) {\n node.warn(`Buffer mismatch!`);\n node.warn(`raw: ${JSON.stringify(raw)}`);\n node.warn(`encoded: ${JSON.stringify(encoded)}`);\n node.warn(`diffs: ${JSON.stringify(diffs)}`);\n\n msg.payload = {\n raw,\n encoded,\n diffs\n };\n return msg;\n }\n}",
121
- "outputs": 1,
122
- "timeout": 0,
123
- "noerr": 0,
124
- "initialize": "",
125
- "finalize": "",
126
- "libs": [],
127
- "x": 580,
128
- "y": 680,
129
- "wires": [
130
- [
131
- "d12d061e1569391f"
132
- ]
133
- ]
134
- }
135
- ]
@@ -1,84 +0,0 @@
1
- "use strict";
2
- const { makeDataFrame } = require("../lib/agwpe-frame-builder");
3
- const agwpeClientModule = require("../nodes/agwpe-client");
4
- const connInModule = require("../nodes/conn-in");
5
- const store = require("../lib/runtime-store");
6
- const EventEmitter = require("events");
7
-
8
- const flowData = {};
9
- function makeRED() {
10
- const types = {};
11
- const RED = {
12
- nodes: {
13
- createNode(node, config) {
14
- const ee = new EventEmitter();
15
- node.on = ee.on.bind(ee);
16
- node.emit = ee.emit.bind(ee);
17
- node.status = () => {};
18
- node._sent = [];
19
- node._errors = [];
20
- node.send = m => node._sent.push(m);
21
- node.error = () => {};
22
- node.warn = m => console.log("WARN:", m);
23
- node.log = m => console.log("LOG:", m);
24
- node.context = () => ({
25
- flow: {
26
- get: k => flowData[k] || null,
27
- set: (k, v) => { flowData[k] = v; }
28
- }
29
- });
30
- },
31
- registerType(t, ctor) { types[t] = ctor; }
32
- },
33
- _instantiate(type, config) {
34
- const n = { id: config.id || type, _flowId: config._flowId || "flow-1", _sent: [], _errors: [] };
35
- types[type].call(n, config);
36
- return n;
37
- }
38
- };
39
- return RED;
40
- }
41
-
42
- const RED = makeRED();
43
- agwpeClientModule(RED);
44
- connInModule(RED);
45
-
46
- const mockTransport = new EventEmitter();
47
- mockTransport.sendFrame = (f, cb) => { if (cb) cb(); };
48
-
49
- const flowId = "flow-1";
50
- const client = RED._instantiate("agwpe-client", { id: "client-1", _flowId: flowId });
51
- const connIn = RED._instantiate("conn-in", { id: "conn-in-1", _flowId: flowId });
52
-
53
- // Open
54
- client.emit("input", { command: "open", host: "127.0.0.1", port: 8000, callsigns: ["N1CALL"], _testTransport: mockTransport });
55
- console.log("client sent after open:", JSON.stringify(client._sent.map(m => m.event)));
56
-
57
- // Connect
58
- client.emit("input", { command: "connect", source: "N1CALL", destination: "N1CALL-1", sessionId: "s1" });
59
- console.log("client sent after connect:", JSON.stringify(client._sent.map(m => m.event)));
60
- console.log("connIn sent after connect:", JSON.stringify(connIn._sent.map(m => m.event)));
61
-
62
- // Check session in registry
63
- const ctx = store.getInstance("client-1");
64
- if (ctx) {
65
- const sessions = ctx.registry.list("client-1");
66
- console.log("sessions in registry:", JSON.stringify(sessions.map(s => ({
67
- sessionId: s.sessionId, state: s.state,
68
- src: s.sourceCallsign, dst: s.destinationCallsign
69
- }))));
70
- console.log("router instances:", Array.from(ctx.router.instances.keys()));
71
- console.log("bus listeners conn-data:", ctx.bus.listenerCount("conn-data"));
72
- console.log("bus listeners conn-lifecycle:", ctx.bus.listenerCount("conn-lifecycle"));
73
- } else {
74
- console.log("ERROR: no context in store for client-1");
75
- }
76
-
77
- // Fire a D frame
78
- const dFrame = makeDataFrame("N1CALL-1", "N1CALL", Buffer.from("BBS READY>"));
79
- console.log("\nEmitting D frame on mockTransport...");
80
- mockTransport.emit("frame", dFrame);
81
-
82
- console.log("connIn sent after D frame:", JSON.stringify(connIn._sent.map(m => ({
83
- event: m.event, hasPayload: m.payload != null, payloadType: m.payload ? typeof m.payload : "none"
84
- }))));
@@ -1,128 +0,0 @@
1
- [
2
- {
3
- "id": "1faf64b0a23c2b10",
4
- "type": "inject",
5
- "z": "efec118fdfbb44e1",
6
- "name": "",
7
- "props": [
8
- {
9
- "p": "payload"
10
- }
11
- ],
12
- "repeat": "",
13
- "crontab": "",
14
- "once": false,
15
- "onceDelay": 0.1,
16
- "topic": "",
17
- "payload": "Test Frame",
18
- "payloadType": "str",
19
- "x": 165,
20
- "y": 540,
21
- "wires": [
22
- [
23
- "e70c9de10439d31f"
24
- ]
25
- ],
26
- "l": false
27
- },
28
- {
29
- "id": "e70c9de10439d31f",
30
- "type": "encode",
31
- "z": "efec118fdfbb44e1",
32
- "name": "",
33
- "source": "N1CALL-7",
34
- "destination": "TEST",
35
- "digipeaters": "",
36
- "control": 3,
37
- "pid": 240,
38
- "frameType": "U",
39
- "payload": "",
40
- "x": 320,
41
- "y": 540,
42
- "wires": [
43
- [
44
- "c3cecbe84e55b30f",
45
- "e8d5bbd844110be9",
46
- "b304e6d9f1e00d48"
47
- ]
48
- ]
49
- },
50
- {
51
- "id": "c3cecbe84e55b30f",
52
- "type": "raw-out",
53
- "z": "efec118fdfbb44e1",
54
- "name": "",
55
- "x": 500,
56
- "y": 540,
57
- "wires": [
58
- [
59
- "15ab7cb28d409d6e"
60
- ]
61
- ]
62
- },
63
- {
64
- "id": "15ab7cb28d409d6e",
65
- "type": "debug",
66
- "z": "efec118fdfbb44e1",
67
- "name": "debug 4",
68
- "active": true,
69
- "tosidebar": true,
70
- "console": false,
71
- "tostatus": false,
72
- "complete": "true",
73
- "targetType": "full",
74
- "statusVal": "",
75
- "statusType": "auto",
76
- "x": 680,
77
- "y": 540,
78
- "wires": []
79
- },
80
- {
81
- "id": "e8d5bbd844110be9",
82
- "type": "debug",
83
- "z": "efec118fdfbb44e1",
84
- "name": "debug 5",
85
- "active": false,
86
- "tosidebar": true,
87
- "console": false,
88
- "tostatus": false,
89
- "complete": "true",
90
- "targetType": "full",
91
- "statusVal": "",
92
- "statusType": "auto",
93
- "x": 500,
94
- "y": 480,
95
- "wires": []
96
- },
97
- {
98
- "id": "b304e6d9f1e00d48",
99
- "type": "decode",
100
- "z": "efec118fdfbb44e1",
101
- "name": "",
102
- "payloadOutput": "string",
103
- "x": 500,
104
- "y": 600,
105
- "wires": [
106
- [
107
- "b4244f4d5601b40f"
108
- ]
109
- ]
110
- },
111
- {
112
- "id": "b4244f4d5601b40f",
113
- "type": "debug",
114
- "z": "efec118fdfbb44e1",
115
- "name": "debug 6",
116
- "active": true,
117
- "tosidebar": true,
118
- "console": false,
119
- "tostatus": false,
120
- "complete": "true",
121
- "targetType": "full",
122
- "statusVal": "",
123
- "statusType": "auto",
124
- "x": 680,
125
- "y": 600,
126
- "wires": []
127
- }
128
- ]
@@ -1,871 +0,0 @@
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
- };