node-red-contrib-ntrip 0.2.3 → 0.2.8

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 (42) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/node.js.yml +22 -21
  3. package/.github/workflows/release.yml +65 -0
  4. package/.prettierignore +20 -0
  5. package/.prettierrc.json +9 -0
  6. package/CHANGELOG.md +69 -0
  7. package/CLAUDE.md +73 -0
  8. package/README.md +185 -18
  9. package/doc/architecture/README.md +19 -0
  10. package/doc/architecture/adr/0001-single-registration-file.md +64 -0
  11. package/doc/architecture/adr/0002-ntrip-uploader-extension.md +71 -0
  12. package/doc/architecture/adr/0003-two-output-decoder-design.md +73 -0
  13. package/doc/architecture/adr/0004-stateful-handshake-interception.md +74 -0
  14. package/doc/architecture/adr/0005-rtcm-partial-frame-buffer.md +87 -0
  15. package/doc/architecture/adr/0006-nmea-multi-sentence-split.md +66 -0
  16. package/doc/architecture/adr/0007-mocha-test-helper-stack.md +67 -0
  17. package/doc/architecture/adr/0008-coordinate-gating-sentinel.md +76 -0
  18. package/doc/architecture/adr/README.md +18 -0
  19. package/doc/architecture/architecture-decisions.md +60 -0
  20. package/doc/architecture/behavioural-design.md +226 -0
  21. package/doc/architecture/errors-and-weaknesses.md +71 -0
  22. package/doc/architecture/future-improvements.md +130 -0
  23. package/doc/architecture/overview.md +77 -0
  24. package/doc/architecture/refactoring-recommendations.md +114 -0
  25. package/doc/architecture/statistics.md +118 -0
  26. package/doc/architecture/structural-design.md +141 -0
  27. package/eslint.config.js +36 -0
  28. package/examples/rtcm-encode.json +1 -0
  29. package/ntrip/99-ntrip.html +289 -31
  30. package/ntrip/99-ntrip.js +15 -12
  31. package/ntrip/lib/ntrip-client.js +23 -18
  32. package/ntrip/nodes/nmea-decoder-node.js +77 -34
  33. package/ntrip/nodes/nmea-encoder-node.js +68 -47
  34. package/ntrip/nodes/ntrip-client-node.js +143 -123
  35. package/ntrip/nodes/rtcm-decoder-node.js +65 -47
  36. package/ntrip/nodes/rtcm-encoder-node.js +90 -0
  37. package/package.json +20 -2
  38. package/test/nmea-decoder.spec.js +146 -0
  39. package/test/nmea-encoder.spec.js +104 -0
  40. package/test/rtcm-decoder.spec.js +116 -0
  41. package/test/rtcm-encoder.spec.js +144 -0
  42. package/.github/workflows/npm-publish.yml +0 -33
@@ -1,16 +1,16 @@
1
1
  module.exports = function (RED) {
2
2
  'use strict';
3
3
 
4
- const {
5
- NmeaTransport,
4
+ const {
5
+ NmeaTransport,
6
6
  NmeaMessageUnknown,
7
7
  NmeaMessage,
8
8
  NmeaMessageDtm,
9
9
  NmeaMessageGbs,
10
10
  NmeaMessageGga,
11
11
  NmeaMessageGll,
12
- NmeaMessageGns,
13
- NmeaMessageGrs,
12
+ NmeaMessageGns,
13
+ NmeaMessageGrs,
14
14
  NmeaMessageGsa,
15
15
  NmeaMessageGst,
16
16
  NmeaMessageGsv,
@@ -30,24 +30,53 @@ module.exports = function (RED) {
30
30
  node.nmeaMessagesReceived = 0;
31
31
  node.invalidMessagesReceived = 0;
32
32
 
33
+ function updateStatus(ok) {
34
+ node.status({
35
+ fill: ok ? 'green' : 'red',
36
+ shape: 'ring',
37
+ text: 'NMEA: ' + node.nmeaMessagesReceived + ' Invalid: ' + node.invalidMessagesReceived,
38
+ });
39
+ }
40
+
41
+ function safeToString(value) {
42
+ if (value === undefined || value === null) {
43
+ return '';
44
+ }
45
+ try {
46
+ return String(value);
47
+ } catch {
48
+ return '';
49
+ }
50
+ }
51
+
33
52
  this.on('input', function (msg) {
53
+ let payload = msg.payload;
54
+
55
+ if (payload == null || payload.nmeaMessage == null || payload.messageType == null) {
56
+ let errMsg = {
57
+ payload: {
58
+ error: 'Invalid input. Expected payload with nmeaMessage and messageType properties.',
59
+ input: payload,
60
+ inputString: safeToString(payload),
61
+ },
62
+ };
63
+ node.send([null, errMsg]);
64
+ node.invalidMessagesReceived++;
65
+ updateStatus(false);
66
+ return;
67
+ }
68
+
69
+ let input = payload.nmeaMessage;
70
+
71
+ try {
72
+ let messageType = String(payload.messageType).toUpperCase();
34
73
 
35
- let input = msg.payload.nmeaMessage;
36
- let isNmeaMessage = input.prototype instanceof NmeaMessage;
37
- let messageType = msg.payload.messageType;
38
- messageType = messageType.toUpperCase();
39
-
40
- try
41
- {
42
- let message;
43
74
  let nmeaMessage;
44
- if(isNmeaMessage){
45
- nmeaMessage = input; // is already a NmeaMessage
46
- }
47
- else {
48
-
75
+ if (input instanceof NmeaMessage) {
76
+ nmeaMessage = input;
77
+ } else {
49
78
  switch (messageType) {
50
- case 'Object':
79
+ case 'OBJECT':
51
80
  nmeaMessage = NmeaMessageUnknown.construct(input);
52
81
  break;
53
82
  case 'DTM':
@@ -102,50 +131,42 @@ module.exports = function (RED) {
102
131
  nmeaMessage = NmeaMessageZda.construct(input);
103
132
  break;
104
133
  default:
105
- // message could not be converted.
106
- break;
134
+ throw new Error('Unsupported NMEA message type: ' + messageType);
107
135
  }
108
136
  }
109
137
 
110
- if (nmeaMessage !== undefined) {
111
- message = NmeaTransport.encode(nmeaMessage);
112
- }
113
-
114
- let msg = {
115
- payload : {
116
- nmeaMessage : message,
117
- input : input
138
+ let message = NmeaTransport.encode(nmeaMessage);
139
+
140
+ let outMsg = {
141
+ payload: {
142
+ nmeaMessage: message,
143
+ messageType: payload.messageType,
144
+ input: input,
118
145
  },
119
146
  };
120
147
 
121
- node.send([msg, null]);
148
+ node.send([outMsg, null]);
122
149
  node.nmeaMessagesReceived++;
123
- }
124
- catch(ex)
125
- {
126
- let msg = {
127
- payload : {
128
- error : ex,
129
- input : input,
130
- inputString : input.toString()
131
- }
150
+ updateStatus(true);
151
+ } catch (ex) {
152
+ let errMsg = {
153
+ payload: {
154
+ error: ex,
155
+ input: input,
156
+ inputString: safeToString(input),
157
+ },
132
158
  };
133
- node.send([null, msg]);
159
+ node.send([null, errMsg]);
134
160
  node.invalidMessagesReceived++;
161
+ updateStatus(false);
135
162
  }
136
-
137
- node.status({
138
- fill: 'green',
139
- shape: 'ring',
140
- text: 'NMEA: ' + node.nmeaMessagesReceived + ' Invalid: ' + node.invalidMessagesReceived,
141
- });
142
163
  });
143
164
 
144
- this.on('close', function(done) {
165
+ this.on('close', function (done) {
145
166
  node.status({});
146
167
  done();
147
168
  });
148
169
  }
149
170
 
150
171
  return NmeaEncoderNode;
151
- };
172
+ };
@@ -1,158 +1,178 @@
1
1
  module.exports = function (RED) {
2
2
  'use strict';
3
3
 
4
- const NtripClient = require("../lib/ntrip-client.js");
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(host !== undefined ) {
23
-
24
- let options = {
25
- host: host,
26
- port: port,
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
- else {
52
- node.error('Mode not supported ' + mode);
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
- client.on('data', (data) => {
56
- node.messagesReceived++;
57
- node.status({
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
- let msg = {
64
- payload : data
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
- // check if ICY 200 OK
68
- const response = data.toString();
69
- if (response.startsWith(NtripServerOkReply)) {
70
- node.status({
71
- fill: 'green',
72
- shape: 'ring',
73
- text: 'NTRIP server connected.',
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
- // check if ICY 406 Not Acceptable
77
- else if (response.startsWith(NtripServerNotOkReply)) {
78
- node.status({
79
- fill: 'red',
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
- else {
94
- node.send(msg);
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
- client.on('close', () => {
99
- node.log('Closed NTRIP connection.');
100
- });
107
+ node.messagesReceived++;
108
+ node.send({ payload: data });
109
+ updateStatus();
110
+ });
101
111
 
102
- client.on('error', (error) => {
103
- if (error instanceof AggregateError) {
104
- error.errors.forEach((e, i) => {
105
- node.log(`Error ${i + 1}: ${e.message}`);
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
- node.status({
116
- fill: 'green',
117
- shape: 'ring',
118
- text: 'ok',
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
- this.on('input', async function (msg) {
122
- if (msg.payload) {
123
- node.messagesReceived++;
124
- node.status({
125
- fill: 'green',
126
- shape: 'ring',
127
- text: 'Messages ' + node.messagesReceived,
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
- this.on('close', function(done) {
165
+ this.on('close', function (done) {
166
+ try {
167
+ client.removeAllListeners();
150
168
  client.close();
151
- node.status({});
152
- done();
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, RtcmMessage, RtcmMessageType } = require('@gnss/rtcm');
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 done = false;
15
- let buffer = msg.payload;
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
- let msg = {
26
- payload : {
27
- rtcm : message.messageType,
28
- messageType : messageType,
29
- message : message,
30
- input : buffer
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
- node.send([msg, null]);
35
- node.rtcmMessagesReceived++;
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
- // find next message in buffer.
38
- buffer = buffer.slice(length);
39
- if(buffer.length == 0){
40
- done = true;
41
- }
42
- } while(!done);
43
- }
44
- catch(ex)
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([null, msg]);
54
- node.invalidMessagesReceived++;
72
+ node.send([outMsg, null]);
73
+ node.rtcmMessagesReceived++;
74
+ buffer = buffer.slice(length);
55
75
  }
56
76
 
57
- node.status({
58
- fill: 'green',
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
+ };