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,128 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("connect", {
3
+ category: "agwpe",
4
+ color: "#d4edda",
5
+ defaults: {
6
+ client: { value: "", type: "agwpe-client", required: true },
7
+ name: { value: "" },
8
+ source: { value: "" },
9
+ destination: { value: "" },
10
+ via: { value: "" },
11
+ mode: { value: "line" },
12
+ timeout: { value: 0 },
13
+ waitFor: { value: "" }
14
+ },
15
+ inputs: 1,
16
+ outputs: 2,
17
+ outputLabels: ["events", "data"],
18
+ icon: "font-awesome/fa-sign-in",
19
+ oneditprepare: function () {
20
+ $("#node-input-mode").val(this.mode || "line");
21
+ },
22
+ label: function () {
23
+ return this.name || "connect";
24
+ }
25
+ });
26
+ </script>
27
+
28
+ <script type="text/html" data-template-name="connect">
29
+ <div class="form-row">
30
+ <label for="node-input-client"><i class="fa fa-server"></i> Client</label>
31
+ <input type="text" id="node-input-client" />
32
+ </div>
33
+ <div class="form-row">
34
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
35
+ <input type="text" id="node-input-name" />
36
+ </div>
37
+ <div class="form-row">
38
+ <label for="node-input-source"><i class="fa fa-user"></i> Source</label>
39
+ <input type="text" id="node-input-source" placeholder="e.g. N0CALL-1 (overridden by msg.source)" />
40
+ </div>
41
+ <div class="form-row">
42
+ <label for="node-input-destination"><i class="fa fa-map-marker"></i> Destination</label>
43
+ <input type="text" id="node-input-destination" placeholder="e.g. N0CALL-1 (overridden by msg.destination)" />
44
+ </div>
45
+ <div class="form-row">
46
+ <label for="node-input-via"><i class="fa fa-random"></i> Via</label>
47
+ <input type="text" id="node-input-via" placeholder="e.g. WIDE1-1 WIDE2-1 (overridden by msg.via)" />
48
+ </div>
49
+ <div class="form-row">
50
+ <label for="node-input-mode"><i class="fa fa-list"></i> Mode</label>
51
+ <select id="node-input-mode">
52
+ <option value="line">line</option>
53
+ <option value="binary">binary</option>
54
+ </select>
55
+ </div>
56
+ <div class="form-row">
57
+ <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
58
+ <input type="number" id="node-input-timeout" placeholder="0 = disabled (overridden by msg.timeout)" min="0" style="width:auto" />
59
+ </div>
60
+ <div class="form-row">
61
+ <label for="node-input-waitFor"><i class="fa fa-search"></i> Wait For</label>
62
+ <input type="text" id="node-input-waitFor" placeholder="regex e.g. >$" />
63
+ </div>
64
+ </script>
65
+
66
+ <script type="text/html" data-help-name="connect">
67
+ <p>Initiates an AX.25 connected session.</p>
68
+ <p>Emits session lifecycle events on output 1 and inbound data from the remote station on output 2.</p>
69
+
70
+ <h3>Input</h3>
71
+ <dl class="message-properties">
72
+ <dt>destination <span class="property-type">string</span></dt>
73
+ <dd>Remote callsign to connect to (e.g. <code>"N0CALL-1"</code>).</dd>
74
+ <dt class="optional">source <span class="property-type">string</span></dt>
75
+ <dd>Local callsign. Overrides node config. Falls back to first callsign from the AGWPE client if neither is set.</dd>
76
+ <dt class="optional">via <span class="property-type">string | array</span></dt>
77
+ <dd>Digipeater path (e.g. <code>"WIDE1-1 WIDE2-1"</code> or an array). Overrides node config. When set, the AGWPE <code>'v'</code> frame is used instead of <code>'C'</code>.</dd>
78
+ <dt class="optional">sessionId <span class="property-type">string</span></dt>
79
+ <dd>Explicit session identifier. Auto-generated if omitted.</dd>
80
+ <dt class="optional">mode <span class="property-type">string</span></dt>
81
+ <dd><code>"line"</code> or <code>"binary"</code>. Overrides node config.</dd>
82
+ <dt class="optional">timeout <span class="property-type">number</span></dt>
83
+ <dd>Inactivity timeout in milliseconds. Overrides node config. A <code>timeout</code> event is emitted on output 1 if no data arrives within this window. Set to <code>0</code> to disable.</dd>
84
+ <dt class="optional">waitFor <span class="property-type">string</span></dt>
85
+ <dd>Regex pattern. Overrides node config. Lines are buffered until one matches, then all buffered lines are emitted as <code>msg.payload</code> (array) with the matching line in <code>msg.match</code>.</dd>
86
+ </dl>
87
+
88
+ <h3>Outputs</h3>
89
+ <ol class="node-ports">
90
+ <li>Events
91
+ <dl class="message-properties">
92
+ <dt>status <span class="property-type">string</span></dt>
93
+ <dd><code>"ok"</code> for normal events; <code>"error"</code> for error conditions.</dd>
94
+ <dt>event <span class="property-type">string</span></dt>
95
+ <dd>When <code>status</code> is <code>"ok"</code>: <code>connecting</code>, <code>connected</code>, <code>disconnecting</code>, or <code>disconnected</code>.</dd>
96
+ <dt>sessionId <span class="property-type">string</span></dt>
97
+ <dd>Identifies the session this event belongs to.</dd>
98
+ <dt class="optional">errorCode <span class="property-type">string</span></dt>
99
+ <dd>When <code>status</code> is <code>"error"</code>: <code>SESSION_ID_CONFLICT</code>, <code>CONNECT_INVALID</code>, <code>CLIENT_NOT_CONNECTED</code>, or <code>TIMEOUT</code> (inactivity timeout; <code>event</code> is also set to <code>"timeout"</code>).</dd>
100
+ <dt class="optional">errorText <span class="property-type">string</span></dt>
101
+ <dd>When <code>status</code> is <code>"error"</code>: human-readable description of the error.</dd>
102
+ </dl>
103
+ </li>
104
+ <li>Data
105
+ <dl class="message-properties">
106
+ <dt>event <span class="property-type">string</span></dt>
107
+ <dd>Always <code>"data"</code>.</dd>
108
+ <dt>payload <span class="property-type">string | Buffer | array</span></dt>
109
+ <dd>In line mode without <code>waitFor</code>: one string per line. With <code>waitFor</code>: array of lines buffered before the match. In binary mode: a Buffer.</dd>
110
+ <dt class="optional">match <span class="property-type">string</span></dt>
111
+ <dd>The line that triggered the <code>waitFor</code> pattern. Only present when <code>waitFor</code> is set and matched.</dd>
112
+ <dt>sessionId <span class="property-type">string</span></dt>
113
+ <dd>Identifies the session this data belongs to.</dd>
114
+ </dl>
115
+ </li>
116
+ </ol>
117
+
118
+ <h3>Details</h3>
119
+ <p>The node claims the data output for the session when the connect command is processed.
120
+ A <b>Send</b> node re-claims the output when it processes a <code>send</code> command,
121
+ routing subsequent inbound data through the Send node instead.</p>
122
+ <p>The <code>timeout</code> event follows the same output claim: it fires from the Connect
123
+ node when this node holds the output, or from the Send node if it has re-claimed it. Setting
124
+ <code>msg.timeout</code> on a Send command updates the timer <em>and</em> redirects future
125
+ timeout events to that Send node.</p>
126
+ <p><b>Source</b>, <b>Destination</b>, <b>Via</b>, <b>Mode</b>, <b>Timeout</b>, and <b>Wait For</b> can all be set on the node and overridden per-message via the corresponding <code>msg</code> properties.
127
+ Leave <b>Wait For</b> blank to emit one message per line.</p>
128
+ </script>
@@ -0,0 +1,450 @@
1
+ "use strict";
2
+
3
+ const { errorEnvelope, okEnvelope } = require("../lib/message-utils");
4
+ const store = require("../lib/runtime-store");
5
+
6
+ function parseWaitFor(pattern) {
7
+ if (!pattern || typeof pattern !== "string" || pattern.trim() === "") return null;
8
+ try {
9
+ return new RegExp(pattern);
10
+ } catch (e) {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function normalizeViaCallsigns(value) {
16
+ if (!value) return [];
17
+ if (typeof value === "string") {
18
+ return value.split(/[\s,]+/).map(function (s) { return s.trim(); }).filter(Boolean);
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return value.map(function (entry) {
22
+ return typeof entry === "string" ? entry.trim() : (entry && entry.callsign ? String(entry.callsign).trim() : "");
23
+ }).filter(Boolean);
24
+ }
25
+ return [];
26
+ }
27
+
28
+ // Lazily initialise shared inbound-data routing maps on context so both connect and
29
+ // send can coordinate without a bus round-trip.
30
+ // outputClaims : sessionId -> { node, waitFor: RegExp|null }
31
+ // lineBuffers : sessionId -> partial-line string
32
+ // waitForBuffers: sessionId -> string[]
33
+ function ensureConnBuffers(context) {
34
+ if (!context.outputClaims) context.outputClaims = new Map();
35
+ if (!context.lineBuffers) context.lineBuffers = new Map();
36
+ if (!context.waitForBuffers) context.waitForBuffers = new Map();
37
+ // lifecycleClaims tracks which connect node receives lifecycle events (port 1) for each
38
+ // session. Unlike outputClaims (data, port 2), this is set at connect time and never
39
+ // transferred to a send node, ensuring disconnected/connected always reach the right node.
40
+ if (!context.lifecycleClaims) context.lifecycleClaims = new Map();
41
+ }
42
+
43
+ // Deliver one inbound frame to `node`, applying line buffering and optional waitFor.
44
+ function deliverFrame(context, node, sessionId, frame) {
45
+ const session = context.registry.get(context.instanceId, sessionId);
46
+ const mode = (session && session.mode) || "binary";
47
+
48
+ if (mode === "binary") {
49
+ node.send([null, okEnvelope({
50
+ instanceId: context.instanceId,
51
+ sessionId,
52
+ event: "data",
53
+ payload: frame.payload,
54
+ source: frame.source,
55
+ destination: frame.destination,
56
+ via: frame.via || []
57
+ })]);
58
+ return;
59
+ }
60
+
61
+ // Line mode: buffer fragments and split on CR or CR+LF.
62
+ const prev = context.lineBuffers.get(sessionId) || "";
63
+ const combined = prev + frame.payload.toString();
64
+ const lines = combined.split(/\r\n|\r/);
65
+ context.lineBuffers.set(sessionId, lines.pop()); // last element is incomplete fragment
66
+
67
+ const claim = context.outputClaims.get(sessionId);
68
+ const waitFor = claim ? claim.waitFor : null;
69
+
70
+ if (!waitFor) {
71
+ lines.forEach(function (line) {
72
+ node.send([null, okEnvelope({
73
+ instanceId: context.instanceId,
74
+ sessionId,
75
+ event: "data",
76
+ payload: line,
77
+ source: frame.source,
78
+ destination: frame.destination,
79
+ via: frame.via || []
80
+ })]);
81
+ });
82
+ } else {
83
+ // Accumulate lines until the first one matching waitFor, then emit all at once.
84
+ const buf = context.waitForBuffers.get(sessionId) || [];
85
+ buf.push.apply(buf, lines);
86
+
87
+ let matchIdx = -1;
88
+ for (let i = 0; i < buf.length; i++) {
89
+ if (waitFor.test(buf[i])) {
90
+ matchIdx = i;
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (matchIdx >= 0) {
96
+ const outLines = buf.splice(0, matchIdx + 1);
97
+ const match = outLines.pop();
98
+ context.waitForBuffers.set(sessionId, buf);
99
+ const timerEntry = context.sessionTimers && context.sessionTimers.get(sessionId);
100
+ if (timerEntry) { clearTimeout(timerEntry.t); context.sessionTimers.delete(sessionId); }
101
+ node.send([null, okEnvelope({
102
+ instanceId: context.instanceId,
103
+ sessionId,
104
+ event: "data",
105
+ payload: outLines,
106
+ match,
107
+ source: frame.source,
108
+ destination: frame.destination,
109
+ via: frame.via || []
110
+ })]);
111
+ } else {
112
+ // Also check the current line fragment (e.g. a BBS prompt with no trailing CR/LF).
113
+ const fragment = context.lineBuffers.get(sessionId) || "";
114
+ if (fragment && waitFor.test(fragment)) {
115
+ context.lineBuffers.set(sessionId, "");
116
+ context.waitForBuffers.delete(sessionId);
117
+ const timerEntry = context.sessionTimers && context.sessionTimers.get(sessionId);
118
+ if (timerEntry) { clearTimeout(timerEntry.t); context.sessionTimers.delete(sessionId); }
119
+ node.send([null, okEnvelope({
120
+ instanceId: context.instanceId,
121
+ sessionId,
122
+ event: "data",
123
+ payload: buf,
124
+ match: fragment,
125
+ source: frame.source,
126
+ destination: frame.destination,
127
+ via: frame.via || []
128
+ })]);
129
+ } else {
130
+ context.waitForBuffers.set(sessionId, buf);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ function syncTransportStatus(node, context) {
137
+ if (context._reconnectTimer) {
138
+ node.status({ fill: "yellow", shape: "ring", text: "reconnecting..." });
139
+ } else if (context.state === "connecting") {
140
+ node.status({ fill: "yellow", shape: "dot", text: "connecting" });
141
+ } else if (context.state === "connected") {
142
+ node.status({ fill: "green", shape: "dot", text: "ready" });
143
+ } else if (context.state === "failed") {
144
+ node.status({ fill: "red", shape: "dot", text: "failed" });
145
+ } else {
146
+ node.status({ fill: "grey", shape: "ring", text: "disconnected" });
147
+ }
148
+ }
149
+
150
+ module.exports = function (RED) {
151
+ function ConnectNode(config) {
152
+ RED.nodes.createNode(this, config);
153
+ const node = this;
154
+
155
+ const cfg = RED.nodes.getNode(config.client);
156
+ const context = cfg ? cfg.instance : null;
157
+ if (!context) {
158
+ node.status({ fill: "red", shape: "ring", text: "client missing" });
159
+ return;
160
+ }
161
+
162
+ ensureConnBuffers(context);
163
+ syncTransportStatus(node, context);
164
+
165
+ // sessionTimers lives on the shared context so multiple Connect nodes in the same
166
+ // flow all see and cancel each other's timers — preventing duplicate timeout events
167
+ // when more than one Connect node subscribes to conn-timeout-set.
168
+ if (!context.sessionTimers) context.sessionTimers = new Map();
169
+
170
+ // Tracks sessions initiated by this node that have not yet received a "connected"
171
+ // confirmation. A "disconnected" event for a pending session means the attempt failed.
172
+ const pendingConnections = new Set();
173
+
174
+ function clearTimer(sessionId) {
175
+ const entry = context.sessionTimers.get(sessionId);
176
+ if (entry !== undefined) {
177
+ clearTimeout(entry.t);
178
+ context.sessionTimers.delete(sessionId);
179
+ }
180
+ }
181
+
182
+ function startTimer(sessionId, timeoutMs) {
183
+ clearTimer(sessionId);
184
+ if (!timeoutMs || timeoutMs <= 0) return;
185
+ const t = setTimeout(function () {
186
+ context.sessionTimers.delete(sessionId);
187
+ // Fire through whichever node currently holds the output claim (may be a Send node).
188
+ const claim = context.outputClaims && context.outputClaims.get(sessionId);
189
+ const targetNode = (claim && claim.node) || node;
190
+ const session = context.registry.get(context.instanceId, sessionId);
191
+ const mode = (session && session.mode) || "binary";
192
+ const fields = { instanceId: context.instanceId, sessionId, event: "timeout" };
193
+ if (mode === "line" && claim && claim.waitFor) {
194
+ fields.waitFor = claim.waitFor.source;
195
+ fields.lineBuffer = context.lineBuffers ? (context.lineBuffers.get(sessionId) || "") : "";
196
+ fields.waitForBuffer = context.waitForBuffers ? (context.waitForBuffers.get(sessionId) || []) : [];
197
+ }
198
+ targetNode.send([errorEnvelope("TIMEOUT", "Inactivity timeout", fields), null]);
199
+ }, timeoutMs);
200
+ if (typeof t.unref === "function") t.unref();
201
+ context.sessionTimers.set(sessionId, { t, nodeId: node.id });
202
+ }
203
+
204
+ function resetTimer(sessionId) {
205
+ if (!context.sessionTimers.has(sessionId)) return;
206
+ const session = context.registry.get(context.instanceId, sessionId);
207
+ if (session && session.timeoutMs > 0) {
208
+ startTimer(sessionId, session.timeoutMs);
209
+ }
210
+ }
211
+
212
+ const onLifecycle = function (event) {
213
+ if (event.event === "transport-connecting") {
214
+ node.status({ fill: "yellow", shape: "dot", text: "connecting" });
215
+ return;
216
+ }
217
+ if (event.event === "transport-connected") {
218
+ node.status({ fill: "green", shape: "dot", text: "ready" });
219
+ return;
220
+ }
221
+ if (event.event === "transport-reconnecting") {
222
+ node.status({ fill: "yellow", shape: "ring", text: "reconnecting..." });
223
+ return;
224
+ }
225
+ if (event.event === "transport-closed") {
226
+ node.status({ fill: "grey", shape: "ring", text: "disconnected" });
227
+ return;
228
+ }
229
+ if (event.event === "failed") {
230
+ node.status({ fill: "red", shape: "dot", text: "failed" });
231
+ return;
232
+ }
233
+
234
+ // Transport-level events (no sessionId) are not session events — don't forward.
235
+ if (!event.sessionId) {
236
+ return;
237
+ }
238
+
239
+ if (event.event === "collision") {
240
+ node.send([
241
+ errorEnvelope("SESSION_ID_REUSED", "Server session ID collision detected", {
242
+ instanceId: context.instanceId,
243
+ sessionId: event.sessionId,
244
+ serverSessionId: event.serverSessionId
245
+ }),
246
+ null
247
+ ]);
248
+ return;
249
+ }
250
+
251
+ // lifecycleClaims tracks which connect node owns lifecycle events (port 1) for
252
+ // each session. It is set at connect time and never transferred to send nodes,
253
+ // ensuring that connected/disconnected always go to the originating connect node
254
+ // regardless of who currently holds the data output claim (outputClaims).
255
+ const lifecycleClaim = context.lifecycleClaims && context.lifecycleClaims.get(event.sessionId);
256
+
257
+ // If the owning handler already ran in this emit() call, it sets the claim to null
258
+ // as a sentinel. Skip to avoid duplicate output from subsequent handlers.
259
+ if (lifecycleClaim !== undefined && lifecycleClaim === null) return;
260
+
261
+ // If a different node owns lifecycle for this session, skip everything.
262
+ if (lifecycleClaim !== undefined && lifecycleClaim !== node) return;
263
+
264
+ if (event.event === "connected") {
265
+ pendingConnections.delete(event.sessionId);
266
+ const session = context.registry.get(context.instanceId, event.sessionId);
267
+ if (session && session.timeoutMs > 0) {
268
+ startTimer(event.sessionId, session.timeoutMs);
269
+ }
270
+ }
271
+
272
+ if (event.event === "disconnected" || event.event === "disconnecting") {
273
+ const sessionId = event.sessionId;
274
+ clearTimer(sessionId);
275
+ // Flush any pending waitFor buffer to whatever node currently holds the data
276
+ // output claim (may be a send node that took over from this connect node).
277
+ const dataClaim = context.outputClaims && context.outputClaims.get(sessionId);
278
+ const pendingBuf = context.waitForBuffers.get(sessionId);
279
+ if (pendingBuf && pendingBuf.length > 0 && dataClaim && dataClaim.node) {
280
+ dataClaim.node.send([null, okEnvelope({
281
+ instanceId: context.instanceId,
282
+ sessionId,
283
+ event: "data",
284
+ payload: pendingBuf
285
+ })]);
286
+ }
287
+ context.waitForBuffers.delete(sessionId);
288
+ context.lineBuffers.delete(sessionId);
289
+ context.outputClaims && context.outputClaims.delete(sessionId);
290
+ // Only set the null sentinel for the final "disconnected" event (prevents duplicate
291
+ // delivery when multiple connect nodes share a bus). Do NOT set it for
292
+ // "disconnecting" — the TNC confirmation ("disconnected") must still be delivered.
293
+ if (event.event === "disconnected") {
294
+ store.unindexSession(sessionId);
295
+ context.lifecycleClaims.set(sessionId, null);
296
+ setImmediate(function () {
297
+ if (context.lifecycleClaims.get(sessionId) === null) {
298
+ context.lifecycleClaims.delete(sessionId);
299
+ }
300
+ });
301
+ }
302
+ }
303
+
304
+ if (event.event === "disconnected" && pendingConnections.has(event.sessionId)) {
305
+ pendingConnections.delete(event.sessionId);
306
+ node.send([
307
+ errorEnvelope("CONNECT_FAILED", "Connection attempt failed", {
308
+ instanceId: context.instanceId,
309
+ sessionId: event.sessionId,
310
+ source: event.source,
311
+ destination: event.destination
312
+ }),
313
+ null
314
+ ]);
315
+ return;
316
+ }
317
+
318
+ node.send([
319
+ okEnvelope({
320
+ instanceId: context.instanceId,
321
+ sessionId: event.sessionId,
322
+ event: event.event,
323
+ source: event.source,
324
+ destination: event.destination,
325
+ called: event.called
326
+ }),
327
+ null
328
+ ]);
329
+ };
330
+
331
+ const onData = function (frame) {
332
+ if (frame.event === "connect" || frame.event === "disconnect") return;
333
+
334
+ const sessionId = frame.sessionId;
335
+
336
+ // Reset the inactivity timer on any activity — both received frames and
337
+ // outbound frames confirmed by the TNC's Y response. This prevents the
338
+ // timer from firing during an active large-body transmission where the
339
+ // remote station is silent but we are continuously sending.
340
+ resetTimer(sessionId);
341
+
342
+ // Tx frames are only used for the timer reset above; don't deliver them.
343
+ if (frame.direction === "tx") return;
344
+
345
+ // If another node (e.g. Send) holds the output claim for this session, skip.
346
+ const claim = context.outputClaims.get(sessionId);
347
+ if (claim && claim.node !== node) return;
348
+
349
+ // If no claim exists yet (e.g. inbound connection), establish it now so that
350
+ // this node's waitFor config is applied during delivery.
351
+ if (!claim) {
352
+ context.outputClaims.set(sessionId, { node, waitFor: parseWaitFor(config.waitFor) });
353
+ context.lifecycleClaims.set(sessionId, node);
354
+ }
355
+
356
+ deliverFrame(context, node, sessionId, frame);
357
+ };
358
+
359
+ const onTimeoutSet = function (event) {
360
+ if (event && event.sessionId && event.timeoutMs > 0) {
361
+ startTimer(event.sessionId, event.timeoutMs);
362
+ }
363
+ };
364
+
365
+ context.bus.on("conn-lifecycle", onLifecycle);
366
+ context.bus.on("conn-data", onData);
367
+ context.bus.on("conn-timeout-set", onTimeoutSet);
368
+
369
+ node.on("input", function (msg, send, done) {
370
+ const localSend = send || function (m) { node.send(m); };
371
+ const localDone = done || function () {};
372
+
373
+ if (context.state !== "connected") {
374
+ localSend([errorEnvelope("CLIENT_NOT_CONNECTED", "AGWPE Client is not open", { instanceId: context.instanceId }), null]);
375
+ localDone();
376
+ return;
377
+ }
378
+
379
+ const destination = msg.destination || config.destination;
380
+ let source = msg.source || config.source;
381
+ if (!source && Array.isArray(context.callsigns) && context.callsigns.length > 0) {
382
+ source = context.callsigns[0];
383
+ }
384
+ if (!destination || !source) {
385
+ localSend([errorEnvelope("CONNECT_INVALID", "connect requires source and destination", { instanceId: context.instanceId }), null]);
386
+ localDone();
387
+ return;
388
+ }
389
+
390
+ let session;
391
+ try {
392
+ session = context.registry.create(context.instanceId, {
393
+ source,
394
+ destination,
395
+ sessionId: msg.sessionId,
396
+ mode: msg.mode || config.mode || "line",
397
+ timeoutMs: (typeof msg.timeout === "number" && msg.timeout > 0) ? msg.timeout
398
+ : (typeof config.timeout === "number" && config.timeout > 0) ? config.timeout : null
399
+ });
400
+ } catch (err) {
401
+ localSend([errorEnvelope("SESSION_ID_CONFLICT", "Session already exists", { instanceId: context.instanceId }), null]);
402
+ localDone();
403
+ return;
404
+ }
405
+
406
+ // Register in the global session index so Send nodes can find the correct
407
+ // agwpe-client instance from just a sessionId, without needing config.client.
408
+ store.indexSession(session.sessionId, context.instanceId);
409
+
410
+ // Claim output for the new session; this node receives inbound data.
411
+ // msg.waitFor takes precedence over the node config (allows per-connection override).
412
+ context.outputClaims.set(session.sessionId, { node, waitFor: parseWaitFor(msg.waitFor || config.waitFor) });
413
+ // Claim lifecycle output (port 1) — never transferred to send nodes.
414
+ context.lifecycleClaims.set(session.sessionId, node);
415
+
416
+ const via = normalizeViaCallsigns(msg.via !== undefined ? msg.via : config.via);
417
+
418
+ context.bus.emit("conn-data", {
419
+ event: "connect",
420
+ direction: "tx",
421
+ instanceId: context.instanceId,
422
+ sessionId: session.sessionId,
423
+ source,
424
+ destination,
425
+ via
426
+ });
427
+ pendingConnections.add(session.sessionId);
428
+ localSend([okEnvelope({ instanceId: context.instanceId, event: "connecting", sessionId: session.sessionId, source, destination, via }), null]);
429
+ localDone();
430
+ });
431
+
432
+ node.on("close", function (removed, done) {
433
+ pendingConnections.clear();
434
+ context.bus.off("conn-lifecycle", onLifecycle);
435
+ context.bus.off("conn-data", onData);
436
+ context.bus.off("conn-timeout-set", onTimeoutSet);
437
+ // Only cancel timers that this node instance started.
438
+ context.sessionTimers.forEach(function (entry, sessionId) {
439
+ if (entry.nodeId === node.id) {
440
+ clearTimeout(entry.t);
441
+ context.sessionTimers.delete(sessionId);
442
+ }
443
+ });
444
+ done();
445
+ });
446
+ }
447
+
448
+ RED.nodes.registerType("connect", ConnectNode);
449
+ };
450
+
@@ -0,0 +1,83 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("decode", {
3
+ category: "agwpe",
4
+ color: "#d4edda",
5
+ defaults: {
6
+ name: { value: "" },
7
+ payloadOutput: { value: "string" }
8
+ },
9
+ inputs: 1,
10
+ outputs: 1,
11
+ icon: "font-awesome/fa-expand",
12
+ oneditprepare: function () {
13
+ const mode = this.payloadOutput === "string" ? "string" : "buffer";
14
+ $("#node-input-payloadOutput").val(mode);
15
+ },
16
+ oneditsave: function () {
17
+ const mode = $("#node-input-payloadOutput").val();
18
+ this.payloadOutput = mode === "string" ? "string" : "buffer";
19
+ },
20
+ label: function () {
21
+ return this.name || "decode";
22
+ }
23
+ });
24
+ </script>
25
+
26
+ <script type="text/html" data-template-name="decode">
27
+ <div class="form-row">
28
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
29
+ <input type="text" id="node-input-name" />
30
+ </div>
31
+ <div class="form-row">
32
+ <label for="node-input-payloadOutput" style="white-space:nowrap;min-width:110px"><i class="fa fa-exchange"></i> Payload</label>
33
+ <select id="node-input-payloadOutput" style="width:auto;min-width:120px">
34
+ <option value="buffer">Buffer</option>
35
+ <option value="string">String (UTF-8)</option>
36
+ </select>
37
+ </div>
38
+ </script>
39
+
40
+ <script type="text/html" data-help-name="decode">
41
+ <p>Decodes raw AX.25 bytes into structured fields and frame type classification.
42
+ Accepts both length-prefixed codec format and standard wire-format frames — the format is auto-detected.</p>
43
+ <p>Payload Output controls whether <code>msg.payload</code> is emitted as a Buffer or converted to a UTF-8 string.</p>
44
+
45
+ <h3>Input</h3>
46
+ <dl class="message-properties">
47
+ <dt>payload <span class="property-type">Buffer</span></dt>
48
+ <dd>Raw AX.25 frame bytes to decode. Must be a Buffer. Passing any other type
49
+ results in a <code>DECODE_INPUT_INVALID</code> error.</dd>
50
+ <dt class="optional">agwpePort <span class="property-type">number</span></dt>
51
+ <dd>AGWPE port byte. Passed through unchanged to the output message.</dd>
52
+ </dl>
53
+
54
+ <h3>Output</h3>
55
+ <dl class="message-properties">
56
+ <dt>status <span class="property-type">string</span></dt>
57
+ <dd><code>"ok"</code> on success; <code>"error"</code> if decoding fails. Check this field first.</dd>
58
+ <dt>source <span class="property-type">string</span></dt>
59
+ <dd>When <code>status</code> is <code>"ok"</code>: source callsign (e.g. <code>"N0CALL-1"</code>).</dd>
60
+ <dt>destination <span class="property-type">string</span></dt>
61
+ <dd>When <code>status</code> is <code>"ok"</code>: destination callsign.</dd>
62
+ <dt class="optional">via <span class="property-type">array</span></dt>
63
+ <dd>When <code>status</code> is <code>"ok"</code>: digipeater list from the AX.25 address chain.
64
+ Each entry is an object with <code>callsign</code> (string) and <code>hasBeenRepeated</code> (boolean).
65
+ Only present when decoding wire-format frames that include a via path.</dd>
66
+ <dt>control <span class="property-type">number</span></dt>
67
+ <dd>When <code>status</code> is <code>"ok"</code>: raw control field byte value.</dd>
68
+ <dt>pid <span class="property-type">number</span></dt>
69
+ <dd>When <code>status</code> is <code>"ok"</code>: protocol identifier byte. Present on I and UI frames; <code>null</code> on S frames.</dd>
70
+ <dt>frameType <span class="property-type">string</span></dt>
71
+ <dd>When <code>status</code> is <code>"ok"</code>: AX.25 frame class derived from the control field —
72
+ <code>"I"</code> (information), <code>"S"</code> (supervisory), or <code>"U"</code> (unnumbered).</dd>
73
+ <dt>payload <span class="property-type">Buffer | string</span></dt>
74
+ <dd>When <code>status</code> is <code>"ok"</code>: frame information field. Emitted as a Buffer or
75
+ UTF-8 string depending on the Payload Output setting.</dd>
76
+ <dt>agwpePort <span class="property-type">number</span></dt>
77
+ <dd>When <code>status</code> is <code>"ok"</code>: AGWPE port byte passed through from the input message.</dd>
78
+ <dt class="optional">errorCode <span class="property-type">string</span></dt>
79
+ <dd>When <code>status</code> is <code>"error"</code>: <code>DECODE_INPUT_INVALID</code> (payload was not a Buffer) or <code>DECODE_FAILED</code> (frame could not be parsed).</dd>
80
+ <dt class="optional">errorText <span class="property-type">string</span></dt>
81
+ <dd>When <code>status</code> is <code>"error"</code>: human-readable description of the error.</dd>
82
+ </dl>
83
+ </script>