node-red-contrib-ntrip 0.2.2 → 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.
Files changed (41) 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 +64 -0
  7. package/CLAUDE.md +73 -0
  8. package/README.md +145 -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/nmea-decode.json +1 -0
  29. package/examples/nmea-encode.json +1 -0
  30. package/ntrip/99-ntrip.html +248 -27
  31. package/ntrip/99-ntrip.js +14 -11
  32. package/ntrip/lib/ntrip-client.js +23 -18
  33. package/ntrip/nodes/nmea-decoder-node.js +80 -37
  34. package/ntrip/nodes/nmea-encoder-node.js +172 -0
  35. package/ntrip/nodes/ntrip-client-node.js +143 -123
  36. package/ntrip/nodes/rtcm-decoder-node.js +65 -47
  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/.github/workflows/npm-publish.yml +0 -33
@@ -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
+ });
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const helper = require('node-red-node-test-helper');
4
+ const { expect } = require('chai');
5
+ const { NmeaTransport } = require('@gnss/nmea');
6
+ const ntripModule = require('../ntrip/99-ntrip.js');
7
+
8
+ helper.init(require.resolve('node-red'));
9
+
10
+ const GGA = '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47';
11
+
12
+ function flow() {
13
+ return [
14
+ { id: 'n1', type: 'NmeaEncoder', wires: [['ok'], ['err']] },
15
+ { id: 'ok', type: 'helper' },
16
+ { id: 'err', type: 'helper' },
17
+ ];
18
+ }
19
+
20
+ describe('NmeaEncoder', function () {
21
+ before(() => helper.startServer());
22
+ after(() => helper.stopServer());
23
+ afterEach(() => helper.unload());
24
+
25
+ it('encodes a pre-constructed NmeaMessage instance (instanceof passthrough)', 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
+ const instance = NmeaTransport.decode(GGA);
31
+ ok.on('input', (msg) => {
32
+ try {
33
+ expect(msg.payload.nmeaMessage).to.be.a('string');
34
+ expect(msg.payload.nmeaMessage).to.match(/^\$.+\*[0-9A-Fa-f]{2}\r?\n?$/);
35
+ expect(msg.payload.nmeaMessage).to.include('GGA');
36
+ resolve();
37
+ } catch (e) {
38
+ reject(e);
39
+ }
40
+ });
41
+ // Even with a bogus messageType, an instance must take precedence and
42
+ // flow straight through to NmeaTransport.encode without re-construction.
43
+ n1.receive({ payload: { messageType: 'NONSENSE', nmeaMessage: instance } });
44
+ });
45
+ });
46
+ });
47
+
48
+ it('routes unknown messageType to the error output (no silent success)', function () {
49
+ return new Promise((resolve, reject) => {
50
+ helper.load(ntripModule, flow(), () => {
51
+ const n1 = helper.getNode('n1');
52
+ const ok = helper.getNode('ok');
53
+ const err = helper.getNode('err');
54
+ ok.on('input', () => reject(new Error('unexpected success output')));
55
+ err.on('input', (msg) => {
56
+ try {
57
+ expect(msg.payload.error).to.exist;
58
+ expect(String(msg.payload.error).toLowerCase()).to.include('unsupported');
59
+ resolve();
60
+ } catch (e) {
61
+ reject(e);
62
+ }
63
+ });
64
+ n1.receive({ payload: { messageType: 'XYZ', nmeaMessage: { foo: 1 } } });
65
+ });
66
+ });
67
+ });
68
+
69
+ it('routes missing nmeaMessage to the error output without crashing', function () {
70
+ return new Promise((resolve, reject) => {
71
+ helper.load(ntripModule, flow(), () => {
72
+ const n1 = helper.getNode('n1');
73
+ const err = helper.getNode('err');
74
+ err.on('input', (msg) => {
75
+ try {
76
+ expect(msg.payload.error).to.exist;
77
+ resolve();
78
+ } catch (e) {
79
+ reject(e);
80
+ }
81
+ });
82
+ n1.receive({ payload: { messageType: 'GGA' } });
83
+ });
84
+ });
85
+ });
86
+
87
+ it('routes empty payload to the error output without crashing', function () {
88
+ return new Promise((resolve, reject) => {
89
+ helper.load(ntripModule, flow(), () => {
90
+ const n1 = helper.getNode('n1');
91
+ const err = helper.getNode('err');
92
+ err.on('input', (msg) => {
93
+ try {
94
+ expect(msg.payload.error).to.exist;
95
+ resolve();
96
+ } catch (e) {
97
+ reject(e);
98
+ }
99
+ });
100
+ n1.receive({ payload: {} });
101
+ });
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,116 @@
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
+ // A real RTCM 3 message type 1005 (Stationary RTK Reference Station ARP).
10
+ // 25 bytes total: 3-byte header + 19-byte payload + 3-byte CRC.
11
+ const RTCM_1005 = Buffer.from('D300133ED7D30202980EDEEF34B4BD62AC0941986F33360B98', 'hex');
12
+
13
+ function flow() {
14
+ return [
15
+ { id: 'n1', type: 'RtcmDecoder', wires: [['ok'], ['err']] },
16
+ { id: 'ok', type: 'helper' },
17
+ { id: 'err', type: 'helper' },
18
+ ];
19
+ }
20
+
21
+ describe('RtcmDecoder', function () {
22
+ before(() => helper.startServer());
23
+ after(() => helper.stopServer());
24
+ afterEach(() => helper.unload());
25
+
26
+ it('decodes a valid RTCM 1005 frame on the success output', function () {
27
+ return new Promise((resolve, reject) => {
28
+ helper.load(ntripModule, flow(), () => {
29
+ const n1 = helper.getNode('n1');
30
+ const ok = helper.getNode('ok');
31
+ ok.on('input', (msg) => {
32
+ try {
33
+ expect(msg.payload.rtcm).to.equal(1005);
34
+ expect(msg.payload.messageType).to.be.a('string');
35
+ expect(msg.payload.message).to.be.an('object');
36
+ expect(Buffer.isBuffer(msg.payload.input)).to.equal(true);
37
+ resolve();
38
+ } catch (e) {
39
+ reject(e);
40
+ }
41
+ });
42
+ n1.receive({ payload: RTCM_1005 });
43
+ });
44
+ });
45
+ });
46
+
47
+ it('decodes two concatenated frames as two separate output messages', function () {
48
+ return new Promise((resolve) => {
49
+ helper.load(ntripModule, flow(), () => {
50
+ const n1 = helper.getNode('n1');
51
+ const ok = helper.getNode('ok');
52
+ let count = 0;
53
+ ok.on('input', () => {
54
+ count++;
55
+ if (count === 2) resolve();
56
+ });
57
+ n1.receive({ payload: Buffer.concat([RTCM_1005, RTCM_1005]) });
58
+ });
59
+ });
60
+ });
61
+
62
+ it('reassembles a frame split across two input events (carry-over buffer)', function () {
63
+ return new Promise((resolve, reject) => {
64
+ helper.load(ntripModule, flow(), () => {
65
+ const n1 = helper.getNode('n1');
66
+ const ok = helper.getNode('ok');
67
+ let received = false;
68
+ ok.on('input', (msg) => {
69
+ if (received) return;
70
+ received = true;
71
+ try {
72
+ expect(msg.payload.rtcm).to.equal(1005);
73
+ resolve();
74
+ } catch (e) {
75
+ reject(e);
76
+ }
77
+ });
78
+ const half = Math.floor(RTCM_1005.length / 2);
79
+ n1.receive({ payload: RTCM_1005.slice(0, half) });
80
+ setImmediate(() => n1.receive({ payload: RTCM_1005.slice(half) }));
81
+ });
82
+ });
83
+ });
84
+
85
+ it('does not call node.error on decode failure (regression: log flood)', function () {
86
+ return new Promise((resolve, reject) => {
87
+ helper.load(ntripModule, flow(), () => {
88
+ const n1 = helper.getNode('n1');
89
+ const err = helper.getNode('err');
90
+ let errorCalled = false;
91
+ const originalError = n1.error.bind(n1);
92
+ n1.error = function (...args) {
93
+ errorCalled = true;
94
+ return originalError(...args);
95
+ };
96
+ // A buffer of garbage larger than the pending-buffer cap triggers the
97
+ // error-output path. node.error must NOT be called for that.
98
+ const big = Buffer.alloc(70000, 0xff);
99
+ let errEmitted = false;
100
+ err.on('input', () => {
101
+ errEmitted = true;
102
+ });
103
+ n1.receive({ payload: big });
104
+ setTimeout(() => {
105
+ try {
106
+ expect(errEmitted).to.equal(true);
107
+ expect(errorCalled).to.equal(false);
108
+ resolve();
109
+ } catch (e) {
110
+ reject(e);
111
+ }
112
+ }, 200);
113
+ });
114
+ });
115
+ });
116
+ });
@@ -1,33 +0,0 @@
1
- # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2
- # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3
-
4
- name: Node.js Package
5
-
6
- on:
7
- release:
8
- types: [created]
9
-
10
- jobs:
11
- build:
12
- runs-on: ubuntu-latest
13
- steps:
14
- - uses: actions/checkout@v4
15
- - uses: actions/setup-node@v4
16
- with:
17
- node-version: 20
18
- - run: npm ci
19
- # - run: npm test
20
-
21
- publish-npm:
22
- needs: build
23
- runs-on: ubuntu-latest
24
- steps:
25
- - uses: actions/checkout@v4
26
- - uses: actions/setup-node@v4
27
- with:
28
- node-version: 20
29
- registry-url: https://registry.npmjs.org/
30
- - run: npm ci
31
- - run: npm publish
32
- env:
33
- NODE_AUTH_TOKEN: ${{secrets.npm_token}}