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,76 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("raw-out", {
|
|
3
|
+
category: "agwpe",
|
|
4
|
+
color: "#d4edda",
|
|
5
|
+
defaults: {
|
|
6
|
+
client: { value: "", type: "agwpe-client", required: true },
|
|
7
|
+
name: { value: "" },
|
|
8
|
+
agwpePort: { value: 0 }
|
|
9
|
+
},
|
|
10
|
+
inputs: 1,
|
|
11
|
+
outputs: 1,
|
|
12
|
+
icon: "font-awesome/fa-upload",
|
|
13
|
+
oneditprepare: function () {
|
|
14
|
+
const value = this.agwpePort !== undefined ? this.agwpePort : this.flag;
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
const normalized = Number.isInteger(parsed) && parsed >= 0 && parsed <= 255 ? parsed : 0;
|
|
17
|
+
$("#node-input-agwpePort").val(normalized);
|
|
18
|
+
},
|
|
19
|
+
oneditsave: function () {
|
|
20
|
+
const parsed = Number($("#node-input-agwpePort").val());
|
|
21
|
+
this.agwpePort = Number.isInteger(parsed) && parsed >= 0 && parsed <= 255 ? parsed : 0;
|
|
22
|
+
},
|
|
23
|
+
label: function () {
|
|
24
|
+
return this.name || "raw-out";
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<script type="text/html" data-template-name="raw-out">
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-client"><i class="fa fa-server"></i> Client</label>
|
|
32
|
+
<input type="text" id="node-input-client" />
|
|
33
|
+
</div>
|
|
34
|
+
<div class="form-row">
|
|
35
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
36
|
+
<input type="text" id="node-input-name" />
|
|
37
|
+
</div>
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-agwpePort"><i class="fa fa-flag"></i> AGWPE Port</label>
|
|
40
|
+
<input type="number" id="node-input-agwpePort" min="0" max="255" step="1" />
|
|
41
|
+
</div>
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<script type="text/html" data-help-name="raw-out">
|
|
45
|
+
<p>Sends a raw AX.25 frame when raw mode is enabled on the AGWPE client node.</p>
|
|
46
|
+
|
|
47
|
+
<h3>Input</h3>
|
|
48
|
+
<dl class="message-properties">
|
|
49
|
+
<dt>payload <span class="property-type">Buffer | array | Uint8Array | string | object</span></dt>
|
|
50
|
+
<dd>The raw AX.25 frame bytes to transmit. Accepted formats:
|
|
51
|
+
<ul>
|
|
52
|
+
<li><b>Buffer</b> — used as-is.</li>
|
|
53
|
+
<li><b>byte array</b> or <b>Uint8Array</b> — converted to a Buffer.</li>
|
|
54
|
+
<li><b>hex string</b> — pairs of hex digits, optionally separated by spaces or commas
|
|
55
|
+
(e.g. <code>"82 a0 a8"</code> or <code>"82a0a8"</code>). <code>0x</code> prefixes are stripped.</li>
|
|
56
|
+
<li><b>encode envelope</b> — if <code>msg.payload</code> is an output message from the
|
|
57
|
+
<b>encode</b> node, its nested <code>payload</code> Buffer is used automatically.</li>
|
|
58
|
+
</ul>
|
|
59
|
+
</dd>
|
|
60
|
+
<dt class="optional">agwpePort <span class="property-type">number</span></dt>
|
|
61
|
+
<dd>AGWPE port byte (0–255) identifying the TNC port to transmit on. Overrides the editor
|
|
62
|
+
value. Defaults to <code>0</code> if neither the message nor the editor provides a value.</dd>
|
|
63
|
+
</dl>
|
|
64
|
+
|
|
65
|
+
<h3>Output</h3>
|
|
66
|
+
<dl class="message-properties">
|
|
67
|
+
<dt>status <span class="property-type">string</span></dt>
|
|
68
|
+
<dd><code>"ok"</code> on success; <code>"error"</code> if the send fails. Check this field first.</dd>
|
|
69
|
+
<dt>event <span class="property-type">string</span></dt>
|
|
70
|
+
<dd>When <code>status</code> is <code>"ok"</code>: always <code>"raw-sent"</code>.</dd>
|
|
71
|
+
<dt class="optional">errorCode <span class="property-type">string</span></dt>
|
|
72
|
+
<dd>When <code>status</code> is <code>"error"</code>: <code>CLIENT_NOT_FOUND</code>, <code>RAW_MODE_DISABLED</code>, or <code>RAW_FRAME_INVALID</code>.</dd>
|
|
73
|
+
<dt class="optional">errorText <span class="property-type">string</span></dt>
|
|
74
|
+
<dd>When <code>status</code> is <code>"error"</code>: human-readable description of the error.</dd>
|
|
75
|
+
</dl>
|
|
76
|
+
</script>
|
package/nodes/raw-out.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { okEnvelope, errorEnvelope } = require("../lib/message-utils");
|
|
4
|
+
|
|
5
|
+
function toByte(value) {
|
|
6
|
+
const n = Number(value);
|
|
7
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) {
|
|
8
|
+
throw new Error("RAW_FRAME_INVALID_BYTE");
|
|
9
|
+
}
|
|
10
|
+
return n;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isBufferJsonObject(value) {
|
|
14
|
+
return (
|
|
15
|
+
value &&
|
|
16
|
+
typeof value === "object" &&
|
|
17
|
+
value.type === "Buffer" &&
|
|
18
|
+
Array.isArray(value.data)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseHexString(input) {
|
|
23
|
+
const cleaned = String(input || "")
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/0x/gi, "")
|
|
26
|
+
.replace(/[\s,]+/g, "");
|
|
27
|
+
|
|
28
|
+
if (!cleaned) {
|
|
29
|
+
throw new Error("RAW_FRAME_EMPTY");
|
|
30
|
+
}
|
|
31
|
+
if (!/^[0-9a-fA-F]+$/.test(cleaned) || cleaned.length % 2 !== 0) {
|
|
32
|
+
throw new Error("RAW_FRAME_INVALID_HEX");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Buffer.from(cleaned, "hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coercePayloadToBuffer(payload) {
|
|
39
|
+
if (Buffer.isBuffer(payload)) {
|
|
40
|
+
return payload;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isBufferJsonObject(payload)) {
|
|
44
|
+
return Buffer.from(payload.data.map(toByte));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (payload instanceof Uint8Array) {
|
|
48
|
+
return Buffer.from(payload);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(payload)) {
|
|
52
|
+
return Buffer.from(payload.map(toByte));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof payload === "string") {
|
|
56
|
+
return parseHexString(payload);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error("RAW_FRAME_INVALID");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getRawFrameInput(msg) {
|
|
63
|
+
const direct = msg ? msg.payload : undefined;
|
|
64
|
+
if (
|
|
65
|
+
direct &&
|
|
66
|
+
typeof direct === "object" &&
|
|
67
|
+
!Buffer.isBuffer(direct) &&
|
|
68
|
+
!Array.isArray(direct) &&
|
|
69
|
+
!(direct instanceof Uint8Array) &&
|
|
70
|
+
!isBufferJsonObject(direct) &&
|
|
71
|
+
Object.prototype.hasOwnProperty.call(direct, "payload")
|
|
72
|
+
) {
|
|
73
|
+
// Accept nested envelopes (for example: msg.payload = <encode output message>)
|
|
74
|
+
return direct.payload;
|
|
75
|
+
}
|
|
76
|
+
return direct;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveAgwpePort(msg, node) {
|
|
80
|
+
const msgPort = msg ? msg.agwpePort : undefined;
|
|
81
|
+
const msgFlag = msg ? msg.flag : undefined; // backward-compatible alias
|
|
82
|
+
const configuredPort = node.agwpePort;
|
|
83
|
+
const selected = msgPort !== undefined
|
|
84
|
+
? msgPort
|
|
85
|
+
: (msgFlag !== undefined ? msgFlag : configuredPort);
|
|
86
|
+
return toByte(selected === undefined || selected === null || selected === "" ? 0 : selected);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = function (RED) {
|
|
90
|
+
function RawOutNode(config) {
|
|
91
|
+
RED.nodes.createNode(this, config);
|
|
92
|
+
const node = this;
|
|
93
|
+
node.agwpePort = config.agwpePort !== undefined ? config.agwpePort : config.flag;
|
|
94
|
+
|
|
95
|
+
const cfg = RED.nodes.getNode(config.client);
|
|
96
|
+
const context = cfg ? cfg.instance : null;
|
|
97
|
+
|
|
98
|
+
node.on("input", function (msg, send, done) {
|
|
99
|
+
const localSend = send || function (m) {
|
|
100
|
+
node.send(m);
|
|
101
|
+
};
|
|
102
|
+
const localDone = done || function () {};
|
|
103
|
+
|
|
104
|
+
if (!context) {
|
|
105
|
+
localSend(errorEnvelope("CLIENT_NOT_FOUND", "AGWPE Client instance not found"));
|
|
106
|
+
localDone();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!context.rawEnabled) {
|
|
111
|
+
localSend(errorEnvelope("RAW_MODE_DISABLED", "Raw mode is disabled"));
|
|
112
|
+
localDone();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let rawPayload;
|
|
117
|
+
let agwpePort;
|
|
118
|
+
try {
|
|
119
|
+
rawPayload = coercePayloadToBuffer(getRawFrameInput(msg));
|
|
120
|
+
agwpePort = resolveAgwpePort(msg, node);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
localSend(
|
|
123
|
+
errorEnvelope(
|
|
124
|
+
"RAW_FRAME_INVALID",
|
|
125
|
+
"Raw frame payload/agwpePort is invalid; payload must be Buffer, byte array, Uint8Array, hex string, or encode envelope"
|
|
126
|
+
)
|
|
127
|
+
);
|
|
128
|
+
localDone();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
context.bus.emit("raw-data", {
|
|
133
|
+
instanceId: context.instanceId,
|
|
134
|
+
payload: rawPayload,
|
|
135
|
+
agwpePort,
|
|
136
|
+
direction: "tx"
|
|
137
|
+
});
|
|
138
|
+
localSend(okEnvelope({ instanceId: context.instanceId, event: "raw-sent" }));
|
|
139
|
+
localDone();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
RED.nodes.registerType("raw-out", RawOutNode);
|
|
144
|
+
};
|
package/nodes/send.html
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("send", {
|
|
3
|
+
category: "agwpe",
|
|
4
|
+
color: "#d4edda",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
timeout: { value: 0 },
|
|
8
|
+
waitFor: { value: "" }
|
|
9
|
+
},
|
|
10
|
+
inputs: 1,
|
|
11
|
+
outputs: 2,
|
|
12
|
+
outputLabels: ["events", "data"],
|
|
13
|
+
icon: "font-awesome/fa-paper-plane",
|
|
14
|
+
label: function () {
|
|
15
|
+
return this.name || "send";
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script type="text/html" data-template-name="send">
|
|
21
|
+
<div class="form-row">
|
|
22
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
23
|
+
<input type="text" id="node-input-name" />
|
|
24
|
+
</div>
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
|
|
27
|
+
<input type="number" id="node-input-timeout" placeholder="0 = disabled (overridden by msg.timeout)" min="0" style="width:auto" />
|
|
28
|
+
</div>
|
|
29
|
+
<div class="form-row">
|
|
30
|
+
<label for="node-input-waitFor"><i class="fa fa-search"></i> Wait For</label>
|
|
31
|
+
<input type="text" id="node-input-waitFor" placeholder="regex e.g. >$" />
|
|
32
|
+
</div>
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<script type="text/html" data-help-name="send">
|
|
36
|
+
<p>Sends data over an AX.25 connected session, and receives inbound data in response.</p>
|
|
37
|
+
<p>Emits session lifecycle events on output 1 and inbound data from the remote station on output 2.</p>
|
|
38
|
+
|
|
39
|
+
<h3>Input</h3>
|
|
40
|
+
<dl class="message-properties">
|
|
41
|
+
<dt>sessionId <span class="property-type">string</span></dt>
|
|
42
|
+
<dd>Session to act on.</dd>
|
|
43
|
+
<dt class="optional">payload <span class="property-type">string | Buffer</span></dt>
|
|
44
|
+
<dd>Data to send. Required for <code>send</code>. In line mode a trailing <code>\r</code> is appended automatically.</dd>
|
|
45
|
+
<dt class="optional">waitFor <span class="property-type">string</span></dt>
|
|
46
|
+
<dd>Regex pattern (overrides node config). After the send, inbound lines are buffered until one matches, then emitted on output 2 as <code>msg.payload</code> (array) with the matching line in <code>msg.match</code>.</dd>
|
|
47
|
+
<dt class="optional">timeout <span class="property-type">number</span></dt>
|
|
48
|
+
<dd>Inactivity timeout in milliseconds. Overrides node config. Resets the timer for the session. If no data arrives within this window, a <code>timeout</code> event is emitted on output 1 of <em>this</em> node. Set to <code>0</code> to disable.</dd>
|
|
49
|
+
</dl>
|
|
50
|
+
|
|
51
|
+
<h3>Outputs</h3>
|
|
52
|
+
<ol class="node-ports">
|
|
53
|
+
<li>Events
|
|
54
|
+
<dl class="message-properties">
|
|
55
|
+
<dt>status <span class="property-type">string</span></dt>
|
|
56
|
+
<dd><code>"ok"</code> on success; <code>"error"</code> on error.</dd>
|
|
57
|
+
<dt>event <span class="property-type">string</span></dt>
|
|
58
|
+
<dd>When <code>status</code> is <code>"ok"</code>: <code>sent</code>.</dd>
|
|
59
|
+
<dt>sessionId <span class="property-type">string</span></dt>
|
|
60
|
+
<dd>Identifies the session.</dd>
|
|
61
|
+
<dt class="optional">messageId <span class="property-type">string</span></dt>
|
|
62
|
+
<dd>Unique ID for the sent message. Present on <code>sent</code> events.</dd>
|
|
63
|
+
<dt class="optional">chunkCount <span class="property-type">number</span></dt>
|
|
64
|
+
<dd>Number of AGWPE frames the payload was split into. Present on <code>sent</code> events.</dd>
|
|
65
|
+
<dt class="optional">errorCode <span class="property-type">string</span></dt>
|
|
66
|
+
<dd>When <code>status</code> is <code>"error"</code>: <code>SESSION_NOT_FOUND</code>, <code>SESSION_NOT_CONNECTED</code>, <code>PAYLOAD_INVALID</code>, or <code>TIMEOUT</code> (inactivity timeout; <code>event</code> is also set to <code>"timeout"</code>).</dd>
|
|
67
|
+
<dt class="optional">errorText <span class="property-type">string</span></dt>
|
|
68
|
+
<dd>When <code>status</code> is <code>"error"</code>: human-readable description of the error.</dd>
|
|
69
|
+
</dl>
|
|
70
|
+
</li>
|
|
71
|
+
<li>Data
|
|
72
|
+
<dl class="message-properties">
|
|
73
|
+
<dt>event <span class="property-type">string</span></dt>
|
|
74
|
+
<dd>Always <code>"data"</code>.</dd>
|
|
75
|
+
<dt>payload <span class="property-type">string | Buffer | array</span></dt>
|
|
76
|
+
<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>
|
|
77
|
+
<dt class="optional">match <span class="property-type">string</span></dt>
|
|
78
|
+
<dd>The line that triggered the <code>waitFor</code> pattern. Only present when <code>waitFor</code> is set and matched.</dd>
|
|
79
|
+
<dt>sessionId <span class="property-type">string</span></dt>
|
|
80
|
+
<dd>Identifies the session this data belongs to.</dd>
|
|
81
|
+
</dl>
|
|
82
|
+
</li>
|
|
83
|
+
</ol>
|
|
84
|
+
|
|
85
|
+
<h3>Details</h3>
|
|
86
|
+
<p>When a <code>send</code> command is processed, this node re-claims the data output for the
|
|
87
|
+
session. Inbound data received after the send is routed through this node's output 2
|
|
88
|
+
until another Send node takes over or the session closes.</p>
|
|
89
|
+
<p><b>Timeout</b> and <b>Wait For</b> can be set on the node and overridden per-message via <code>msg.timeout</code> and <code>msg.waitFor</code>.
|
|
90
|
+
Leave <b>Wait For</b> blank to emit one message per line.</p>
|
|
91
|
+
</script>
|
package/nodes/send.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { splitPayload } = require("../lib/frame-segmentation");
|
|
4
|
+
const { okEnvelope, errorEnvelope, makeMessageId } = require("../lib/message-utils");
|
|
5
|
+
const store = require("../lib/runtime-store");
|
|
6
|
+
|
|
7
|
+
// Maximum number of unacknowledged I-frames the TNC may hold before we pause.
|
|
8
|
+
// Matches the standard AX.25 modulo-8 window.
|
|
9
|
+
const MAX_OUTSTANDING = 7;
|
|
10
|
+
|
|
11
|
+
// How long to wait (ms) before re-querying the TNC when its buffer is full.
|
|
12
|
+
const Y_RETRY_DELAY_MS = 200;
|
|
13
|
+
|
|
14
|
+
// How long to wait (ms) for a Y response before giving up and sending anyway.
|
|
15
|
+
const Y_RESPONSE_TIMEOUT_MS = 2000;
|
|
16
|
+
|
|
17
|
+
function parseWaitFor(pattern) {
|
|
18
|
+
if (!pattern || typeof pattern !== "string" || pattern.trim() === "") return null;
|
|
19
|
+
try {
|
|
20
|
+
return new RegExp(pattern);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeSend(node, send) {
|
|
27
|
+
return send || function (msg) {
|
|
28
|
+
node.send(msg);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mirror of the helper in connect.js; shared logic for inbound frame delivery.
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
function deliverFrame(context, node, sessionId, frame) {
|
|
40
|
+
const session = context.registry.get(context.instanceId, sessionId);
|
|
41
|
+
const mode = (session && session.mode) || "binary";
|
|
42
|
+
|
|
43
|
+
if (mode === "binary") {
|
|
44
|
+
node.send([null, okEnvelope({
|
|
45
|
+
instanceId: context.instanceId,
|
|
46
|
+
sessionId,
|
|
47
|
+
event: "data",
|
|
48
|
+
payload: frame.payload,
|
|
49
|
+
source: frame.source,
|
|
50
|
+
destination: frame.destination,
|
|
51
|
+
via: frame.via || []
|
|
52
|
+
})]);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const prev = context.lineBuffers.get(sessionId) || "";
|
|
57
|
+
const combined = prev + frame.payload.toString();
|
|
58
|
+
const lines = combined.split(/\r\n|\r/);
|
|
59
|
+
context.lineBuffers.set(sessionId, lines.pop());
|
|
60
|
+
|
|
61
|
+
const claim = context.outputClaims.get(sessionId);
|
|
62
|
+
const waitFor = claim ? claim.waitFor : null;
|
|
63
|
+
|
|
64
|
+
if (!waitFor) {
|
|
65
|
+
lines.forEach(function (line) {
|
|
66
|
+
node.send([null, okEnvelope({
|
|
67
|
+
instanceId: context.instanceId,
|
|
68
|
+
sessionId,
|
|
69
|
+
event: "data",
|
|
70
|
+
payload: line,
|
|
71
|
+
source: frame.source,
|
|
72
|
+
destination: frame.destination,
|
|
73
|
+
via: frame.via || []
|
|
74
|
+
})]);
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
const buf = context.waitForBuffers.get(sessionId) || [];
|
|
78
|
+
buf.push.apply(buf, lines);
|
|
79
|
+
|
|
80
|
+
let matchIdx = -1;
|
|
81
|
+
for (let i = 0; i < buf.length; i++) {
|
|
82
|
+
if (waitFor.test(buf[i])) {
|
|
83
|
+
matchIdx = i;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (matchIdx >= 0) {
|
|
89
|
+
const outLines = buf.splice(0, matchIdx + 1);
|
|
90
|
+
const match = outLines.pop();
|
|
91
|
+
context.waitForBuffers.set(sessionId, buf);
|
|
92
|
+
const timerEntry = context.sessionTimers && context.sessionTimers.get(sessionId);
|
|
93
|
+
if (timerEntry) { clearTimeout(timerEntry.t); context.sessionTimers.delete(sessionId); }
|
|
94
|
+
node.send([null, okEnvelope({
|
|
95
|
+
instanceId: context.instanceId,
|
|
96
|
+
sessionId,
|
|
97
|
+
event: "data",
|
|
98
|
+
payload: outLines,
|
|
99
|
+
match,
|
|
100
|
+
source: frame.source,
|
|
101
|
+
destination: frame.destination,
|
|
102
|
+
via: frame.via || []
|
|
103
|
+
})]);
|
|
104
|
+
} else {
|
|
105
|
+
// Also check the current line fragment (e.g. a BBS prompt with no trailing CR/LF).
|
|
106
|
+
const fragment = context.lineBuffers.get(sessionId) || "";
|
|
107
|
+
if (fragment && waitFor.test(fragment)) {
|
|
108
|
+
context.lineBuffers.set(sessionId, "");
|
|
109
|
+
context.waitForBuffers.delete(sessionId);
|
|
110
|
+
const timerEntry = context.sessionTimers && context.sessionTimers.get(sessionId);
|
|
111
|
+
if (timerEntry) { clearTimeout(timerEntry.t); context.sessionTimers.delete(sessionId); }
|
|
112
|
+
node.send([null, okEnvelope({
|
|
113
|
+
instanceId: context.instanceId,
|
|
114
|
+
sessionId,
|
|
115
|
+
event: "data",
|
|
116
|
+
payload: buf,
|
|
117
|
+
match: fragment,
|
|
118
|
+
source: frame.source,
|
|
119
|
+
destination: frame.destination,
|
|
120
|
+
via: frame.via || []
|
|
121
|
+
})]);
|
|
122
|
+
} else {
|
|
123
|
+
context.waitForBuffers.set(sessionId, buf);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Emit a single data chunk onto the instance bus after confirming the TNC has
|
|
131
|
+
* room for it. Sends a 'y' query, waits for the 'Y' response and, if the TNC
|
|
132
|
+
* reports fewer than MAX_OUTSTANDING pending frames, emits the 'conn-data'
|
|
133
|
+
* event. If the TNC is full the query is retried after Y_RETRY_DELAY_MS.
|
|
134
|
+
* If no 'Y' response arrives within Y_RESPONSE_TIMEOUT_MS the chunk is sent
|
|
135
|
+
* unconditionally so a non-responsive TNC does not stall the node forever.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} ctx - Instance context from runtime-store.
|
|
138
|
+
* @param {string} source - Our callsign.
|
|
139
|
+
* @param {string} destination - Remote callsign.
|
|
140
|
+
* @param {object} chunkEvent - The full conn-data event payload to emit.
|
|
141
|
+
* @param {Function} callback - Called with no arguments when the chunk has
|
|
142
|
+
* been emitted (or the timeout fires).
|
|
143
|
+
*/
|
|
144
|
+
function sendChunkWithFlowControl(ctx, source, destination, chunkEvent, callback) {
|
|
145
|
+
const normSource = (source || "").toUpperCase();
|
|
146
|
+
const normDest = (destination || "").toUpperCase();
|
|
147
|
+
|
|
148
|
+
function attempt() {
|
|
149
|
+
let settled = false;
|
|
150
|
+
let retryTimer = null;
|
|
151
|
+
|
|
152
|
+
const responseTimeout = setTimeout(function () {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
ctx.bus.off("conn-y-response", onYResponse);
|
|
156
|
+
// No response from TNC — send without flow control.
|
|
157
|
+
ctx.bus.emit("conn-data", chunkEvent);
|
|
158
|
+
callback();
|
|
159
|
+
}, Y_RESPONSE_TIMEOUT_MS);
|
|
160
|
+
|
|
161
|
+
function onYResponse(response) {
|
|
162
|
+
const rSrc = (response.source || "").toUpperCase();
|
|
163
|
+
const rDest = (response.destination || "").toUpperCase();
|
|
164
|
+
if (rSrc !== normSource || rDest !== normDest) return;
|
|
165
|
+
|
|
166
|
+
if (settled) return;
|
|
167
|
+
settled = true;
|
|
168
|
+
ctx.bus.off("conn-y-response", onYResponse);
|
|
169
|
+
clearTimeout(responseTimeout);
|
|
170
|
+
|
|
171
|
+
if (response.outstanding >= MAX_OUTSTANDING) {
|
|
172
|
+
// TNC buffer full — back off and retry.
|
|
173
|
+
retryTimer = setTimeout(attempt, Y_RETRY_DELAY_MS);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ctx.bus.emit("conn-data", chunkEvent);
|
|
178
|
+
callback();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.bus.on("conn-y-response", onYResponse);
|
|
182
|
+
ctx.bus.emit("conn-y-query", {
|
|
183
|
+
instanceId: ctx.instanceId,
|
|
184
|
+
source,
|
|
185
|
+
destination,
|
|
186
|
+
direction: "tx"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
attempt();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Send all chunks for one payload item sequentially through Y-based flow
|
|
195
|
+
* control. Calls callback(null) when all chunks have been queued on the bus.
|
|
196
|
+
*/
|
|
197
|
+
function sendChunksWithFlowControl(ctx, source, destination, sessionId, messageId, chunks, callback) {
|
|
198
|
+
let index = 0;
|
|
199
|
+
|
|
200
|
+
function nextChunk() {
|
|
201
|
+
if (index >= chunks.length) {
|
|
202
|
+
callback(null);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const chunkIndex = index;
|
|
207
|
+
const chunk = chunks[index++];
|
|
208
|
+
const chunkEvent = {
|
|
209
|
+
instanceId: ctx.instanceId,
|
|
210
|
+
sessionId,
|
|
211
|
+
messageId,
|
|
212
|
+
chunkIndex,
|
|
213
|
+
chunkCount: chunks.length,
|
|
214
|
+
direction: "tx",
|
|
215
|
+
payload: chunk,
|
|
216
|
+
source,
|
|
217
|
+
destination
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
sendChunkWithFlowControl(ctx, source, destination, chunkEvent, nextChunk);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
nextChunk();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = function (RED) {
|
|
227
|
+
function SendNode(config) {
|
|
228
|
+
RED.nodes.createNode(this, config);
|
|
229
|
+
const node = this;
|
|
230
|
+
|
|
231
|
+
// config.client is no longer required for routing. The agwpe-client instance is
|
|
232
|
+
// derived at runtime from the sessionId via the global session index, so a Send
|
|
233
|
+
// node works automatically with whatever agwpe-client the Connect node used.
|
|
234
|
+
node.status({});
|
|
235
|
+
|
|
236
|
+
// Inbound data handler: fires for all instances via the global bus.
|
|
237
|
+
// Only processes frames for sessions where this node holds the output claim.
|
|
238
|
+
const onData = function (frame) {
|
|
239
|
+
if (frame.direction === "tx") return;
|
|
240
|
+
if (frame.event === "connect" || frame.event === "disconnect") return;
|
|
241
|
+
|
|
242
|
+
const sessionId = frame.sessionId;
|
|
243
|
+
// frame.instanceId is set by all real bus emissions; fall back to the session
|
|
244
|
+
// index for test events that omit it.
|
|
245
|
+
const iid = frame.instanceId || store.instanceIdForSession(sessionId);
|
|
246
|
+
if (!iid) return;
|
|
247
|
+
|
|
248
|
+
const context = store.getInstance(iid);
|
|
249
|
+
if (!context) return;
|
|
250
|
+
|
|
251
|
+
ensureConnBuffers(context);
|
|
252
|
+
const claim = context.outputClaims.get(sessionId);
|
|
253
|
+
if (!claim || claim.node !== node) return;
|
|
254
|
+
|
|
255
|
+
deliverFrame(context, node, sessionId, frame);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
store.globalBus.on("conn-data", onData);
|
|
259
|
+
|
|
260
|
+
node.on("input", function (msg, send, done) {
|
|
261
|
+
const localSend = normalizeSend(node, send);
|
|
262
|
+
const localDone = done || function () {};
|
|
263
|
+
|
|
264
|
+
const iid = store.instanceIdForSession(msg.sessionId);
|
|
265
|
+
const ctx = iid ? store.getInstance(iid) : null;
|
|
266
|
+
const session = ctx ? ctx.registry.get(ctx.instanceId, msg.sessionId) : null;
|
|
267
|
+
if (!session) {
|
|
268
|
+
localSend([errorEnvelope("SESSION_NOT_FOUND", "Session not found", { sessionId: msg.sessionId }), null]);
|
|
269
|
+
localDone();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (session.state !== "connected") {
|
|
273
|
+
localSend([errorEnvelope("SESSION_NOT_CONNECTED", "Session is not connected", { sessionId: msg.sessionId }), null]);
|
|
274
|
+
localDone();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Allow array payloads: each item is sent as a separate D frame.
|
|
278
|
+
const payloadItems = Array.isArray(msg.payload) ? msg.payload : [msg.payload];
|
|
279
|
+
for (let i = 0; i < payloadItems.length; i++) {
|
|
280
|
+
if (typeof payloadItems[i] !== "string" && !Buffer.isBuffer(payloadItems[i])) {
|
|
281
|
+
localSend([errorEnvelope("PAYLOAD_INVALID", "payload items must be string or Buffer", { sessionId: msg.sessionId }), null]);
|
|
282
|
+
localDone();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
ensureConnBuffers(ctx);
|
|
288
|
+
// Claim output for this session; flush any pending waitFor buffer if claim is changing.
|
|
289
|
+
const currentClaim = ctx.outputClaims.get(msg.sessionId);
|
|
290
|
+
if (currentClaim && currentClaim.node !== node) {
|
|
291
|
+
const pendingBuf = ctx.waitForBuffers.get(msg.sessionId);
|
|
292
|
+
if (pendingBuf && pendingBuf.length > 0) {
|
|
293
|
+
currentClaim.node.send([null, okEnvelope({
|
|
294
|
+
instanceId: ctx.instanceId,
|
|
295
|
+
sessionId: msg.sessionId,
|
|
296
|
+
event: "data",
|
|
297
|
+
payload: pendingBuf
|
|
298
|
+
})]);
|
|
299
|
+
ctx.waitForBuffers.delete(msg.sessionId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
ctx.outputClaims.set(msg.sessionId, { node, waitFor: parseWaitFor(msg.waitFor || config.waitFor) });
|
|
303
|
+
|
|
304
|
+
const resolvedTimeout = (typeof msg.timeout === "number" && msg.timeout > 0) ? msg.timeout
|
|
305
|
+
: (typeof config.timeout === "number" && config.timeout > 0) ? config.timeout : null;
|
|
306
|
+
if (resolvedTimeout) {
|
|
307
|
+
ctx.registry.update(ctx.instanceId, msg.sessionId, { timeoutMs: resolvedTimeout });
|
|
308
|
+
ctx.bus.emit("conn-timeout-set", { sessionId: msg.sessionId, timeoutMs: resolvedTimeout });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sessionMode = session.mode || "binary";
|
|
312
|
+
const source = session.source || session.sourceCallsign;
|
|
313
|
+
const destination = session.destination || session.destinationCallsign;
|
|
314
|
+
|
|
315
|
+
// Build all (item, chunks) pairs up front so we know totalChunkCount before
|
|
316
|
+
// any async work begins, and can report the correct value in the 'sent' event.
|
|
317
|
+
const itemJobs = payloadItems.map(function (item) {
|
|
318
|
+
let sendItem = item;
|
|
319
|
+
if (sessionMode === "line") {
|
|
320
|
+
const itemStr = Buffer.isBuffer(sendItem) ? sendItem.toString() : String(sendItem);
|
|
321
|
+
sendItem = itemStr + "\r";
|
|
322
|
+
}
|
|
323
|
+
return { messageId: makeMessageId("conn"), chunks: splitPayload(sendItem, 256) };
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const totalChunkCount = itemJobs.reduce(function (acc, j) { return acc + j.chunks.length; }, 0);
|
|
327
|
+
const lastMessageId = itemJobs.length > 0 ? itemJobs[itemJobs.length - 1].messageId : undefined;
|
|
328
|
+
|
|
329
|
+
// Send payload items sequentially, each with Y-based per-chunk flow control.
|
|
330
|
+
let jobIndex = 0;
|
|
331
|
+
function nextJob() {
|
|
332
|
+
if (jobIndex >= itemJobs.length) {
|
|
333
|
+
localSend([
|
|
334
|
+
okEnvelope({
|
|
335
|
+
instanceId: ctx.instanceId,
|
|
336
|
+
event: "sent",
|
|
337
|
+
sessionId: msg.sessionId,
|
|
338
|
+
messageId: lastMessageId,
|
|
339
|
+
chunkCount: totalChunkCount
|
|
340
|
+
}),
|
|
341
|
+
null
|
|
342
|
+
]);
|
|
343
|
+
localDone();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const job = itemJobs[jobIndex++];
|
|
348
|
+
sendChunksWithFlowControl(ctx, source, destination, msg.sessionId, job.messageId, job.chunks, function () {
|
|
349
|
+
nextJob();
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
nextJob();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
node.on("close", function (removed, done) {
|
|
357
|
+
store.globalBus.off("conn-data", onData);
|
|
358
|
+
// Release output claims held by this node across all instances.
|
|
359
|
+
store.getAllInstances().forEach(function (context) {
|
|
360
|
+
if (context.outputClaims) {
|
|
361
|
+
context.outputClaims.forEach(function (claim, sessionId) {
|
|
362
|
+
if (claim.node === node) {
|
|
363
|
+
context.outputClaims.delete(sessionId);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
done();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
RED.nodes.registerType("send", SendNode);
|
|
373
|
+
};
|