node-red-contrib-ntrip 0.2.3 → 0.2.7
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/.github/dependabot.yml +23 -0
- package/.github/workflows/node.js.yml +22 -21
- package/.github/workflows/release.yml +65 -0
- package/.prettierignore +20 -0
- package/.prettierrc.json +9 -0
- package/CHANGELOG.md +61 -0
- package/CLAUDE.md +73 -0
- package/README.md +145 -18
- package/doc/architecture/README.md +19 -0
- package/doc/architecture/adr/0001-single-registration-file.md +64 -0
- package/doc/architecture/adr/0002-ntrip-uploader-extension.md +71 -0
- package/doc/architecture/adr/0003-two-output-decoder-design.md +73 -0
- package/doc/architecture/adr/0004-stateful-handshake-interception.md +74 -0
- package/doc/architecture/adr/0005-rtcm-partial-frame-buffer.md +87 -0
- package/doc/architecture/adr/0006-nmea-multi-sentence-split.md +66 -0
- package/doc/architecture/adr/0007-mocha-test-helper-stack.md +67 -0
- package/doc/architecture/adr/0008-coordinate-gating-sentinel.md +76 -0
- package/doc/architecture/adr/README.md +18 -0
- package/doc/architecture/architecture-decisions.md +60 -0
- package/doc/architecture/behavioural-design.md +226 -0
- package/doc/architecture/errors-and-weaknesses.md +71 -0
- package/doc/architecture/future-improvements.md +130 -0
- package/doc/architecture/overview.md +77 -0
- package/doc/architecture/refactoring-recommendations.md +114 -0
- package/doc/architecture/statistics.md +118 -0
- package/doc/architecture/structural-design.md +141 -0
- package/eslint.config.js +36 -0
- package/ntrip/99-ntrip.html +207 -31
- package/ntrip/99-ntrip.js +12 -12
- package/ntrip/lib/ntrip-client.js +23 -18
- package/ntrip/nodes/nmea-decoder-node.js +77 -34
- package/ntrip/nodes/nmea-encoder-node.js +68 -47
- package/ntrip/nodes/ntrip-client-node.js +143 -123
- package/ntrip/nodes/rtcm-decoder-node.js +65 -47
- package/package.json +20 -2
- package/test/nmea-decoder.spec.js +146 -0
- package/test/nmea-encoder.spec.js +104 -0
- package/test/rtcm-decoder.spec.js +116 -0
- package/.github/workflows/npm-publish.yml +0 -33
|
@@ -1,158 +1,178 @@
|
|
|
1
1
|
module.exports = function (RED) {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const NtripClient = require(
|
|
4
|
+
const NtripClient = require('../lib/ntrip-client.js');
|
|
5
5
|
const NtripServerOkReply = 'ICY 200 OK';
|
|
6
6
|
const NtripServerNotOkReply = 'ICY 406';
|
|
7
7
|
const NtripServerMissingMountpoint = 'SOURCETABLE 200 OK';
|
|
8
|
+
const HandshakePrefixBytes = 64;
|
|
8
9
|
|
|
9
10
|
function NtripClientNode(config) {
|
|
10
11
|
RED.nodes.createNode(this, config);
|
|
11
12
|
let node = this;
|
|
12
13
|
node.messagesReceived = 0;
|
|
14
|
+
node.messagesSent = 0;
|
|
13
15
|
node.passthrough = config.passthrough || false;
|
|
14
|
-
|
|
15
|
-
let host = config.host;
|
|
16
|
-
let port = config.port || 2101;
|
|
16
|
+
|
|
17
|
+
let host = typeof config.host === 'string' ? config.host.trim() : '';
|
|
18
|
+
let port = parseInt(config.port, 10) || 2101;
|
|
17
19
|
let mountpoint = config.mountpoint;
|
|
18
20
|
let mode = config.mode || 'download';
|
|
19
21
|
let authmode = config.authmode || 'legacy';
|
|
20
|
-
|
|
22
|
+
let interval = parseInt(config.interval, 10) || 1000;
|
|
23
|
+
|
|
24
|
+
if (host === '') {
|
|
25
|
+
node.status({ fill: 'red', shape: 'ring', text: 'Host not configured' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let options = {
|
|
30
|
+
host: host,
|
|
31
|
+
port: port,
|
|
32
|
+
mountpoint: mountpoint,
|
|
33
|
+
username: node.credentials.username,
|
|
34
|
+
password: node.credentials.password,
|
|
35
|
+
interval: interval,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Depending on the caster the clients need to provide the location via GGA sentence.
|
|
39
|
+
let x = parseFloat(config.xcoordinate);
|
|
40
|
+
let y = parseFloat(config.ycoordinate);
|
|
41
|
+
let z = parseFloat(config.zcoordinate);
|
|
42
|
+
|
|
43
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) && !(x === 0 && y === 0 && z === 0)) {
|
|
44
|
+
options.xyz = [x, y, z];
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
let client;
|
|
22
|
-
if(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
mountpoint: mountpoint,
|
|
28
|
-
username: node.credentials.username,
|
|
29
|
-
password: node.credentials.password,
|
|
30
|
-
interval: config.interval,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// Depending on the caster the clients need to provide the location via GGA sentence.
|
|
34
|
-
let x = parseFloat(config.xcoordinate || 0);
|
|
35
|
-
let y = parseFloat(config.ycoordinate || 0);
|
|
36
|
-
let z = parseFloat(config.zcoordinate || 0);
|
|
37
|
-
|
|
38
|
-
if (x !== 0 && y !== 0 && z !== 0) {
|
|
39
|
-
options.xyz = [x, y, z];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (mode === 'download') {
|
|
43
|
-
// Clients only consume ntrip data.
|
|
44
|
-
client = NtripClient.createDownloader(options);
|
|
45
|
-
}
|
|
46
|
-
else if (mode === 'upload') {
|
|
47
|
-
// Uploaders send RTCM data to a mountpoint so that clients can consume it.
|
|
48
|
-
options.authmode = authmode;
|
|
48
|
+
if (mode === 'download') {
|
|
49
|
+
client = NtripClient.createDownloader(options);
|
|
50
|
+
} else if (mode === 'upload') {
|
|
51
|
+
options.authmode = authmode;
|
|
52
|
+
try {
|
|
49
53
|
client = NtripClient.createUploader(options);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
node.status({ fill: 'red', shape: 'ring', text: String(error.message || error) });
|
|
56
|
+
node.error('Failed to create uploader: ' + (error.message || error));
|
|
57
|
+
return;
|
|
50
58
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
} else {
|
|
60
|
+
node.status({ fill: 'red', shape: 'ring', text: 'Unsupported mode: ' + mode });
|
|
61
|
+
node.error('Mode not supported: ' + mode);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
fill: 'green',
|
|
59
|
-
shape: 'ring',
|
|
60
|
-
text: 'Messages ' + node.messagesReceived,
|
|
61
|
-
});
|
|
65
|
+
// Track handshake state so we only intercept control replies during the handshake,
|
|
66
|
+
// not on every subsequent data packet. Reset on socket close so reconnects work.
|
|
67
|
+
let connected = false;
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
function updateStatus() {
|
|
70
|
+
node.status({
|
|
71
|
+
fill: 'green',
|
|
72
|
+
shape: 'ring',
|
|
73
|
+
text: 'Rx ' + node.messagesReceived + ' Tx ' + node.messagesSent,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
66
76
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
client.on('data', (data) => {
|
|
78
|
+
if (!connected) {
|
|
79
|
+
let prefix = data.toString('utf8', 0, Math.min(data.length, HandshakePrefixBytes));
|
|
80
|
+
if (prefix.startsWith(NtripServerOkReply)) {
|
|
81
|
+
connected = true;
|
|
82
|
+
node.status({ fill: 'green', shape: 'ring', text: 'NTRIP server connected.' });
|
|
83
|
+
// Forward any RTCM bytes that arrived in the same TCP segment as the handshake reply.
|
|
84
|
+
let headerEnd = data.indexOf('\r\n\r\n');
|
|
85
|
+
if (headerEnd !== -1 && headerEnd + 4 < data.length) {
|
|
86
|
+
node.messagesReceived++;
|
|
87
|
+
node.send({ payload: data.slice(headerEnd + 4) });
|
|
88
|
+
updateStatus();
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
75
91
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
node.
|
|
79
|
-
|
|
80
|
-
shape: 'ring',
|
|
81
|
-
text: 'NTRIP server rejected connection.',
|
|
82
|
-
});
|
|
83
|
-
node.error('Server rejected connect: ' + response);
|
|
84
|
-
}
|
|
85
|
-
else if (response.startsWith(NtripServerMissingMountpoint)) {
|
|
86
|
-
node.status({
|
|
87
|
-
fill: 'red',
|
|
88
|
-
shape: 'ring',
|
|
89
|
-
text: 'No mountpont ' + mountpoint,
|
|
90
|
-
});
|
|
91
|
-
node.error('No mountpoint: ' + response);
|
|
92
|
+
if (prefix.startsWith(NtripServerNotOkReply)) {
|
|
93
|
+
node.status({ fill: 'red', shape: 'ring', text: 'NTRIP server rejected connection.' });
|
|
94
|
+
node.error('Server rejected connect: ' + prefix);
|
|
95
|
+
return;
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
node.
|
|
97
|
+
if (prefix.startsWith(NtripServerMissingMountpoint)) {
|
|
98
|
+
node.status({ fill: 'red', shape: 'ring', text: 'No mountpoint ' + mountpoint });
|
|
99
|
+
node.error('No mountpoint: ' + prefix);
|
|
100
|
+
return;
|
|
95
101
|
}
|
|
96
|
-
|
|
102
|
+
// Unexpected: data arrived before any recognised handshake reply.
|
|
103
|
+
// Treat as connected so we don't drop further frames.
|
|
104
|
+
connected = true;
|
|
105
|
+
}
|
|
97
106
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
node.messagesReceived++;
|
|
108
|
+
node.send({ payload: data });
|
|
109
|
+
updateStatus();
|
|
110
|
+
});
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
node.error('Error: ' + e.message);
|
|
107
|
-
});
|
|
108
|
-
} else {
|
|
109
|
-
node.log(error);
|
|
110
|
-
node.error('Error: ' + error);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
112
|
+
client.on('close', () => {
|
|
113
|
+
connected = false;
|
|
114
|
+
node.log('Closed NTRIP connection.');
|
|
115
|
+
});
|
|
113
116
|
|
|
117
|
+
client.on('error', (error) => {
|
|
118
|
+
connected = false;
|
|
119
|
+
if (typeof AggregateError !== 'undefined' && error instanceof AggregateError) {
|
|
120
|
+
error.errors.forEach((e, i) => {
|
|
121
|
+
node.log('Error ' + (i + 1) + ': ' + e.message);
|
|
122
|
+
node.error('Error: ' + e.message);
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
let text = error && error.message ? error.message : String(error);
|
|
126
|
+
node.log(text);
|
|
127
|
+
node.error('Error: ' + text);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
114
132
|
client.run();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
} catch (error) {
|
|
134
|
+
node.status({ fill: 'red', shape: 'ring', text: String(error.message || error) });
|
|
135
|
+
node.error('Failed to start client: ' + (error.message || error));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'connecting...' });
|
|
140
|
+
|
|
141
|
+
this.on('input', function (msg) {
|
|
142
|
+
let payload = msg.payload;
|
|
143
|
+
if (payload === undefined || payload === null) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
let data = msg.payload;
|
|
132
|
-
if (Array.isArray(data)) {
|
|
133
|
-
client.setXYZ(data);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
client.write(data);
|
|
137
|
-
|
|
138
|
-
if(node.passthrough) {
|
|
139
|
-
node.send(msg);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
} catch (error) {
|
|
143
|
-
node.status({ fill: 'red', shape: 'ring', text: error });
|
|
144
|
-
node.error('Failed to write data: ' + error, error);
|
|
147
|
+
try {
|
|
148
|
+
if (Array.isArray(payload)) {
|
|
149
|
+
client.setXYZ(payload);
|
|
150
|
+
} else {
|
|
151
|
+
client.write(payload);
|
|
152
|
+
node.messagesSent++;
|
|
153
|
+
if (node.passthrough) {
|
|
154
|
+
node.send(msg);
|
|
145
155
|
}
|
|
156
|
+
updateStatus();
|
|
146
157
|
}
|
|
147
|
-
})
|
|
158
|
+
} catch (error) {
|
|
159
|
+
let text = error && error.message ? error.message : String(error);
|
|
160
|
+
node.status({ fill: 'red', shape: 'ring', text: text });
|
|
161
|
+
node.error('Failed to write data: ' + text, msg);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
148
164
|
|
|
149
|
-
|
|
165
|
+
this.on('close', function (done) {
|
|
166
|
+
try {
|
|
167
|
+
client.removeAllListeners();
|
|
150
168
|
client.close();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore — node is shutting down anyway
|
|
171
|
+
}
|
|
172
|
+
node.status({});
|
|
173
|
+
done();
|
|
174
|
+
});
|
|
155
175
|
}
|
|
156
|
-
|
|
176
|
+
|
|
157
177
|
return NtripClientNode;
|
|
158
|
-
};
|
|
178
|
+
};
|
|
@@ -1,71 +1,89 @@
|
|
|
1
1
|
module.exports = function (RED) {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { RtcmTransport
|
|
5
|
-
|
|
4
|
+
const { RtcmTransport } = require('@gnss/rtcm');
|
|
5
|
+
|
|
6
|
+
// Cap on pending bytes when a decode keeps failing — guards against runaway memory
|
|
7
|
+
// if upstream feeds us garbage rather than truly-partial RTCM frames.
|
|
8
|
+
const MaxPendingBytes = 65536;
|
|
9
|
+
|
|
6
10
|
function RtcmDecoderNode(config) {
|
|
7
11
|
RED.nodes.createNode(this, config);
|
|
8
12
|
let node = this;
|
|
9
13
|
node.rtcmMessagesReceived = 0;
|
|
10
14
|
node.invalidMessagesReceived = 0;
|
|
15
|
+
node.pendingBuffer = Buffer.alloc(0);
|
|
16
|
+
|
|
17
|
+
function updateStatus() {
|
|
18
|
+
node.status({
|
|
19
|
+
fill: 'green',
|
|
20
|
+
shape: 'ring',
|
|
21
|
+
text: 'RTCM: ' + node.rtcmMessagesReceived + ' Invalid: ' + node.invalidMessagesReceived,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
11
24
|
|
|
12
25
|
this.on('input', function (msg) {
|
|
26
|
+
if (msg.payload === undefined || msg.payload === null) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
13
29
|
|
|
14
|
-
let
|
|
15
|
-
let buffer =
|
|
16
|
-
try
|
|
17
|
-
{
|
|
18
|
-
do {
|
|
19
|
-
let message;
|
|
20
|
-
let length;
|
|
21
|
-
[message, length] = RtcmTransport.decode(buffer);
|
|
22
|
-
|
|
23
|
-
let messageType = message.constructor.name.replace('RtcmMessage', '');
|
|
30
|
+
let chunk = Buffer.isBuffer(msg.payload) ? msg.payload : Buffer.from(msg.payload);
|
|
31
|
+
let buffer = node.pendingBuffer.length > 0 ? Buffer.concat([node.pendingBuffer, chunk]) : chunk;
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
while (buffer.length > 0) {
|
|
34
|
+
let message;
|
|
35
|
+
let length;
|
|
36
|
+
try {
|
|
37
|
+
[message, length] = RtcmTransport.decode(buffer);
|
|
38
|
+
} catch (ex) {
|
|
39
|
+
// Likely a partial frame at the tail of this chunk — keep the bytes
|
|
40
|
+
// for the next input event. If we've accumulated too much without a
|
|
41
|
+
// successful decode, drop and emit an error.
|
|
42
|
+
if (buffer.length > MaxPendingBytes) {
|
|
43
|
+
let errMsg = {
|
|
44
|
+
payload: {
|
|
45
|
+
error: ex,
|
|
46
|
+
input: buffer,
|
|
47
|
+
inputString: buffer.toString(),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
node.send([null, errMsg]);
|
|
51
|
+
node.invalidMessagesReceived++;
|
|
52
|
+
buffer = Buffer.alloc(0);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
if (!Number.isInteger(length) || length <= 0 || length > buffer.length) {
|
|
58
|
+
// Defensive — guarantees forward progress even if the decoder ever
|
|
59
|
+
// returns a non-positive length.
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
let msg = {
|
|
47
|
-
payload : {
|
|
48
|
-
error : ex,
|
|
49
|
-
input : buffer,
|
|
50
|
-
inputString : buffer.toString()
|
|
51
|
-
}
|
|
63
|
+
let messageType = message.constructor.name.replace('RtcmMessage', '');
|
|
64
|
+
let outMsg = {
|
|
65
|
+
payload: {
|
|
66
|
+
rtcm: message.messageType,
|
|
67
|
+
messageType: messageType,
|
|
68
|
+
message: message,
|
|
69
|
+
input: buffer.slice(0, length),
|
|
70
|
+
},
|
|
52
71
|
};
|
|
53
|
-
node.send([
|
|
54
|
-
node.
|
|
72
|
+
node.send([outMsg, null]);
|
|
73
|
+
node.rtcmMessagesReceived++;
|
|
74
|
+
buffer = buffer.slice(length);
|
|
55
75
|
}
|
|
56
76
|
|
|
57
|
-
node.
|
|
58
|
-
|
|
59
|
-
shape: 'ring',
|
|
60
|
-
text: 'RTCM: ' + node.rtcmMessagesReceived + ' Invalid: ' + node.invalidMessagesReceived,
|
|
61
|
-
});
|
|
77
|
+
node.pendingBuffer = buffer;
|
|
78
|
+
updateStatus();
|
|
62
79
|
});
|
|
63
80
|
|
|
64
|
-
this.on('close', function(done) {
|
|
81
|
+
this.on('close', function (done) {
|
|
82
|
+
node.pendingBuffer = Buffer.alloc(0);
|
|
65
83
|
node.status({});
|
|
66
84
|
done();
|
|
67
85
|
});
|
|
68
86
|
}
|
|
69
|
-
|
|
87
|
+
|
|
70
88
|
return RtcmDecoderNode;
|
|
71
|
-
};
|
|
89
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-ntrip",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Node for ntrip and rtcm handling.",
|
|
5
5
|
"node-red": {
|
|
6
6
|
"version": ">=0.1.0",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=
|
|
12
|
+
"node": ">=18.0.0"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"node-red",
|
|
@@ -24,9 +24,27 @@
|
|
|
24
24
|
},
|
|
25
25
|
"author": "Karl-Heinz Wind",
|
|
26
26
|
"license": "MIT",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "mocha test/**/*.spec.js --timeout 5000",
|
|
29
|
+
"lint": "eslint .",
|
|
30
|
+
"lint:fix": "eslint . --fix",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"format:check": "prettier --check ."
|
|
33
|
+
},
|
|
27
34
|
"dependencies": {
|
|
28
35
|
"@gnss/nmea": "^0.1.2",
|
|
29
36
|
"@gnss/rtcm": "^0.1.5",
|
|
30
37
|
"ntrip-client": "^1.1.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"chai": "^4.5.0",
|
|
42
|
+
"eslint": "^10.3.0",
|
|
43
|
+
"eslint-config-prettier": "^10.1.8",
|
|
44
|
+
"globals": "^17.6.0",
|
|
45
|
+
"mocha": "^11.7.5",
|
|
46
|
+
"node-red": "^4.1.10",
|
|
47
|
+
"node-red-node-test-helper": "^0.3.6",
|
|
48
|
+
"prettier": "^3.8.3"
|
|
31
49
|
}
|
|
32
50
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const helper = require('node-red-node-test-helper');
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const ntripModule = require('../ntrip/99-ntrip.js');
|
|
6
|
+
|
|
7
|
+
helper.init(require.resolve('node-red'));
|
|
8
|
+
|
|
9
|
+
const GGA = '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47';
|
|
10
|
+
const GLL = '$GPGLL,4916.45,N,12311.12,W,225444,A,*1D';
|
|
11
|
+
|
|
12
|
+
function flow() {
|
|
13
|
+
return [
|
|
14
|
+
{ id: 'n1', type: 'NmeaDecoder', wires: [['ok'], ['err']] },
|
|
15
|
+
{ id: 'ok', type: 'helper' },
|
|
16
|
+
{ id: 'err', type: 'helper' },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('NmeaDecoder', function () {
|
|
21
|
+
before(() => helper.startServer());
|
|
22
|
+
after(() => helper.stopServer());
|
|
23
|
+
afterEach(() => helper.unload());
|
|
24
|
+
|
|
25
|
+
it('decodes a valid GGA sentence on the success output', function () {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
helper.load(ntripModule, flow(), () => {
|
|
28
|
+
const n1 = helper.getNode('n1');
|
|
29
|
+
const ok = helper.getNode('ok');
|
|
30
|
+
ok.on('input', (msg) => {
|
|
31
|
+
try {
|
|
32
|
+
expect(msg.payload.messageType).to.equal('GGA');
|
|
33
|
+
expect(msg.payload.nmeaMessage).to.have.property('latitude');
|
|
34
|
+
expect(msg.payload.input).to.equal(GGA);
|
|
35
|
+
resolve();
|
|
36
|
+
} catch (e) {
|
|
37
|
+
reject(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
n1.receive({ payload: GGA });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('splits a multi-sentence chunk into separate output messages', function () {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
helper.load(ntripModule, flow(), () => {
|
|
48
|
+
const n1 = helper.getNode('n1');
|
|
49
|
+
const ok = helper.getNode('ok');
|
|
50
|
+
const types = [];
|
|
51
|
+
ok.on('input', (msg) => {
|
|
52
|
+
types.push(msg.payload.messageType);
|
|
53
|
+
if (types.length === 2) {
|
|
54
|
+
try {
|
|
55
|
+
expect(types).to.deep.equal(['GGA', 'GLL']);
|
|
56
|
+
resolve();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
reject(e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
n1.receive({ payload: GGA + '\r\n' + GLL + '\r\n' });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('accepts a Buffer payload and decodes it as UTF-8', function () {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
helper.load(ntripModule, flow(), () => {
|
|
70
|
+
const n1 = helper.getNode('n1');
|
|
71
|
+
const ok = helper.getNode('ok');
|
|
72
|
+
ok.on('input', (msg) => {
|
|
73
|
+
try {
|
|
74
|
+
expect(msg.payload.messageType).to.equal('GGA');
|
|
75
|
+
resolve();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
reject(e);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
n1.receive({ payload: Buffer.from(GGA, 'utf8') });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reads payload.nmeaMessage when payload is an object', function () {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
helper.load(ntripModule, flow(), () => {
|
|
88
|
+
const n1 = helper.getNode('n1');
|
|
89
|
+
const ok = helper.getNode('ok');
|
|
90
|
+
ok.on('input', (msg) => {
|
|
91
|
+
try {
|
|
92
|
+
expect(msg.payload.messageType).to.equal('GGA');
|
|
93
|
+
resolve();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
reject(e);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
n1.receive({ payload: { nmeaMessage: GGA } });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('error output preserves Buffer input as `input`, utf8 as `inputString`', function () {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
helper.load(ntripModule, flow(), () => {
|
|
106
|
+
const n1 = helper.getNode('n1');
|
|
107
|
+
const err = helper.getNode('err');
|
|
108
|
+
const garbage = Buffer.from([0xd3, 0x00, 0x13, 0x3e, 0xd0, 0x00]);
|
|
109
|
+
err.on('input', (msg) => {
|
|
110
|
+
try {
|
|
111
|
+
expect(Buffer.isBuffer(msg.payload.input)).to.equal(true);
|
|
112
|
+
expect(msg.payload.input).to.deep.equal(garbage);
|
|
113
|
+
expect(msg.payload.inputString).to.be.a('string');
|
|
114
|
+
resolve();
|
|
115
|
+
} catch (e) {
|
|
116
|
+
reject(e);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
n1.receive({ payload: garbage });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not call node.error on decode failure (regression: log flood)', function () {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
helper.load(ntripModule, flow(), () => {
|
|
127
|
+
const n1 = helper.getNode('n1');
|
|
128
|
+
let errorCalled = false;
|
|
129
|
+
const originalError = n1.error.bind(n1);
|
|
130
|
+
n1.error = function (...args) {
|
|
131
|
+
errorCalled = true;
|
|
132
|
+
return originalError(...args);
|
|
133
|
+
};
|
|
134
|
+
n1.receive({ payload: Buffer.from([0xd3, 0x00, 0x13, 0x3e]) });
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
try {
|
|
137
|
+
expect(errorCalled).to.equal(false);
|
|
138
|
+
resolve();
|
|
139
|
+
} catch (e) {
|
|
140
|
+
reject(e);
|
|
141
|
+
}
|
|
142
|
+
}, 50);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|