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,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>
|
package/nodes/connect.js
ADDED
|
@@ -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>
|