@canboat/canboatjs 3.18.0 → 3.19.0-beta.2

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 (50) hide show
  1. package/README.md +4 -0
  2. package/build/Makefile +2 -2
  3. package/build/canSocket.target.mk +14 -14
  4. package/dist/actisense-serial.d.ts.map +1 -1
  5. package/dist/actisense-serial.js +46 -38
  6. package/dist/actisense-serial.js.map +1 -1
  7. package/dist/actisense-serial.test.js +106 -0
  8. package/dist/actisense-serial.test.js.map +1 -1
  9. package/dist/bin/maretron-ipgjs.d.ts +26 -0
  10. package/dist/bin/maretron-ipgjs.d.ts.map +1 -0
  11. package/dist/bin/maretron-ipgjs.js +159 -0
  12. package/dist/bin/maretron-ipgjs.js.map +1 -0
  13. package/dist/fromPgn.d.ts.map +1 -1
  14. package/dist/fromPgn.js +22 -1
  15. package/dist/fromPgn.js.map +1 -1
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/maretron-ipg.d.ts +135 -0
  21. package/dist/maretron-ipg.d.ts.map +1 -0
  22. package/dist/maretron-ipg.js +660 -0
  23. package/dist/maretron-ipg.js.map +1 -0
  24. package/dist/maretron-ipg.test.d.ts +2 -0
  25. package/dist/maretron-ipg.test.d.ts.map +1 -0
  26. package/dist/maretron-ipg.test.js +703 -0
  27. package/dist/maretron-ipg.test.js.map +1 -0
  28. package/dist/n2kDevice.d.ts.map +1 -1
  29. package/dist/n2kDevice.js +58 -5
  30. package/dist/n2kDevice.js.map +1 -1
  31. package/dist/n2kDevice.test.d.ts +2 -0
  32. package/dist/n2kDevice.test.d.ts.map +1 -0
  33. package/dist/n2kDevice.test.js +154 -0
  34. package/dist/n2kDevice.test.js.map +1 -0
  35. package/dist/n2kIpGateway.d.ts +40 -0
  36. package/dist/n2kIpGateway.d.ts.map +1 -0
  37. package/dist/n2kIpGateway.js +464 -0
  38. package/dist/n2kIpGateway.js.map +1 -0
  39. package/dist/n2kIpGateway.test.d.ts +2 -0
  40. package/dist/n2kIpGateway.test.d.ts.map +1 -0
  41. package/dist/n2kIpGateway.test.js +349 -0
  42. package/dist/n2kIpGateway.test.js.map +1 -0
  43. package/dist/stringMsg.d.ts +1 -1
  44. package/dist/stringMsg.d.ts.map +1 -1
  45. package/dist/stringMsg.js +16 -3
  46. package/dist/stringMsg.js.map +1 -1
  47. package/dist/ydgw02.js +9 -1
  48. package/dist/ydgw02.js.map +1 -1
  49. package/package.json +3 -2
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,703 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const events_1 = require("events");
4
+ const maretron_ipg_1 = require("./maretron-ipg");
5
+ // 260 bytes: i mod 256 for i = 0..259.
6
+ function buildPayload260Hex() {
7
+ const out = [];
8
+ for (let i = 0; i < 260; i++) {
9
+ out.push((i & 0xff).toString(16).padStart(2, '0'));
10
+ }
11
+ return out.join('');
12
+ }
13
+ const VECTORS = [
14
+ // PGN 127488 (Engine Parameters, Rapid Update) from SA 0x0F, priority 2,
15
+ // PDU2 broadcast, single frame.
16
+ {
17
+ name: 'rx-pgn127488-pdu2-singleframe',
18
+ direction: 'rx',
19
+ wireHex: 'a5a3f2000f0800a8160000 00ffff',
20
+ decoded: {
21
+ pgn: 127488,
22
+ src: 0x0f,
23
+ dst: 0xff,
24
+ priority: 2,
25
+ dp: 1,
26
+ edp: 0,
27
+ msg_type: 1,
28
+ payload_length: 8,
29
+ payload_hex: '00a8160000 00ffff'
30
+ }
31
+ },
32
+ // PGN 59904 (ISO Request) PDU1 directed to SA 0x23 from SA 0x05.
33
+ {
34
+ name: 'rx-pgn59904-pdu1-singleframe',
35
+ direction: 'rx',
36
+ wireHex: 'a5e2ea2305030 0ee01',
37
+ decoded: {
38
+ pgn: 59904,
39
+ src: 5,
40
+ dst: 0x23,
41
+ priority: 6,
42
+ dp: 0,
43
+ edp: 0,
44
+ msg_type: 1,
45
+ payload_length: 3,
46
+ payload_hex: '00ee01'
47
+ }
48
+ },
49
+ // PGN 60928 (ISO Address Claim) PDU1 broadcast (dst=0xFF) from SA 0x29.
50
+ {
51
+ name: 'rx-pgn60928-pdu1-broadcast',
52
+ direction: 'rx',
53
+ wireHex: 'a5e2eeff290801020304 05060708',
54
+ decoded: {
55
+ pgn: 60928,
56
+ src: 41,
57
+ dst: 0xff,
58
+ priority: 6,
59
+ dp: 0,
60
+ edp: 0,
61
+ msg_type: 1,
62
+ payload_length: 8,
63
+ payload_hex: '0102030405060708'
64
+ }
65
+ },
66
+ // PGN 126996 (Product Information) reassembled fast-packet envelope.
67
+ {
68
+ name: 'rx-pgn126996-pdu2-fastpacket',
69
+ direction: 'rx',
70
+ wireHex: 'a5e5f014294001020304050607 08090a0b0c0d0e0f10111213141516171819 1a1b1c1d1e1f202122232425262728292a 2b2c2d2e2f303132333435363738393a3b 3c3d3e3f40',
71
+ decoded: {
72
+ pgn: 126996,
73
+ src: 41,
74
+ dst: 0xff,
75
+ priority: 6,
76
+ dp: 1,
77
+ edp: 0,
78
+ msg_type: 2,
79
+ payload_length: 64,
80
+ payload_hex: '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40'
81
+ }
82
+ },
83
+ // PGN 130820 with msg_type=3 and a 260-byte payload — the only case
84
+ // that exercises the 16-bit length encoding (LL=0x04 LH=0x01 = 260).
85
+ {
86
+ name: 'rx-pgn130820-pdu2-transport',
87
+ direction: 'rx',
88
+ wireHex: 'a5e7ff04290401' + buildPayload260Hex(),
89
+ decoded: {
90
+ pgn: 130820,
91
+ src: 41,
92
+ dst: 0xff,
93
+ priority: 6,
94
+ dp: 1,
95
+ edp: 0,
96
+ msg_type: 3,
97
+ payload_length: 260,
98
+ payload_hex: buildPayload260Hex()
99
+ }
100
+ },
101
+ // TX: ISO Request with SA=0xFF sentinel.
102
+ {
103
+ name: 'tx-pgn59904-iso-request-sa0xff',
104
+ direction: 'tx',
105
+ wireHex: 'a5e2ea23ff0300ee01',
106
+ decoded: {
107
+ pgn: 59904,
108
+ src: 0xff,
109
+ dst: 0x23,
110
+ priority: 6,
111
+ dp: 0,
112
+ edp: 0,
113
+ msg_type: 1,
114
+ payload_length: 3,
115
+ payload_hex: '00ee01'
116
+ }
117
+ },
118
+ // TX: PGN 126720 — PDU1 (PF=0xEF, just below the PDU2 boundary), so
119
+ // PS carries the destination SA. msg_type=2 fast packet.
120
+ {
121
+ name: 'tx-pgn126720-fastpacket',
122
+ direction: 'tx',
123
+ wireHex: 'a5b5ef14ff108924038000006400000000000000001f',
124
+ decoded: {
125
+ pgn: 126720,
126
+ src: 0xff,
127
+ dst: 0x14,
128
+ priority: 3,
129
+ dp: 1,
130
+ edp: 0,
131
+ msg_type: 2,
132
+ payload_length: 16,
133
+ payload_hex: '8924038000006400000000000000001f'
134
+ }
135
+ }
136
+ ];
137
+ function hexStringToBuffer(hex) {
138
+ return Buffer.from(hex.replace(/\s+/g, ''), 'hex');
139
+ }
140
+ describe('Maretron IPG wire format — vectors', () => {
141
+ for (const v of VECTORS) {
142
+ const wire = hexStringToBuffer(v.wireHex);
143
+ if (v.direction === 'rx') {
144
+ test(`${v.name} — parseMaretronFrame decodes correctly`, () => {
145
+ const result = (0, maretron_ipg_1.parseMaretronFrame)(wire, 0);
146
+ expect(result.invalid).toBeFalsy();
147
+ expect(result.consumed).toBe(wire.length);
148
+ const frame = result.frame;
149
+ expect(frame.pgn).toBe(v.decoded.pgn);
150
+ expect(frame.src).toBe(v.decoded.src);
151
+ expect(frame.dst).toBe(v.decoded.dst);
152
+ expect(frame.priority).toBe(v.decoded.priority);
153
+ expect(frame.dp).toBe(v.decoded.dp);
154
+ expect(frame.edp).toBe(v.decoded.edp);
155
+ expect(frame.msg_type).toBe(v.decoded.msg_type);
156
+ expect(frame.payload_length).toBe(v.decoded.payload_length);
157
+ expect(frame.payload.toString('hex')).toBe(v.decoded.payload_hex.replace(/\s+/g, ''));
158
+ });
159
+ }
160
+ else {
161
+ test(`${v.name} — buildMaretronFrame produces exact wire bytes`, () => {
162
+ const payload = hexStringToBuffer(v.decoded.payload_hex);
163
+ const built = (0, maretron_ipg_1.buildMaretronFrame)({
164
+ pgn: v.decoded.pgn,
165
+ src: v.decoded.src,
166
+ dst: v.decoded.dst,
167
+ priority: v.decoded.priority,
168
+ msg_type: v.decoded.msg_type,
169
+ edp: v.decoded.edp,
170
+ payload
171
+ });
172
+ expect(built.toString('hex')).toBe(v.wireHex.replace(/\s+/g, '').toLowerCase());
173
+ });
174
+ // Feeding the built bytes back through the parser must yield the
175
+ // exact same `decoded` fields. Ensures parse/build are inverses.
176
+ test(`${v.name} — parse(build(decoded)) round-trips`, () => {
177
+ const payload = hexStringToBuffer(v.decoded.payload_hex);
178
+ const built = (0, maretron_ipg_1.buildMaretronFrame)({
179
+ pgn: v.decoded.pgn,
180
+ src: v.decoded.src,
181
+ dst: v.decoded.dst,
182
+ priority: v.decoded.priority,
183
+ msg_type: v.decoded.msg_type,
184
+ edp: v.decoded.edp,
185
+ payload
186
+ });
187
+ const reparsed = (0, maretron_ipg_1.parseMaretronFrame)(built, 0).frame;
188
+ expect(reparsed.pgn).toBe(v.decoded.pgn);
189
+ expect(reparsed.src).toBe(v.decoded.src);
190
+ expect(reparsed.dst).toBe(v.decoded.dst);
191
+ expect(reparsed.priority).toBe(v.decoded.priority);
192
+ expect(reparsed.dp).toBe(v.decoded.dp);
193
+ expect(reparsed.msg_type).toBe(v.decoded.msg_type);
194
+ expect(reparsed.payload_length).toBe(v.decoded.payload_length);
195
+ });
196
+ }
197
+ }
198
+ });
199
+ // ---------------------------------------------------------------------------
200
+ // Parser edge cases
201
+ // ---------------------------------------------------------------------------
202
+ describe('parseMaretronFrame — incremental input', () => {
203
+ // A PGN 127488 PDU2 frame, used as the seed for partial-input cases.
204
+ const VECTOR_01 = Buffer.from('a5a3f2000f0800a8160000 00ffff'.replace(/\s+/g, ''), 'hex');
205
+ test('returns consumed=0 (not invalid) when fewer than 6 bytes are present', () => {
206
+ for (let i = 0; i < 6; i++) {
207
+ const slice = VECTOR_01.subarray(0, i);
208
+ const r = (0, maretron_ipg_1.parseMaretronFrame)(slice, 0);
209
+ expect(r.consumed).toBe(0);
210
+ expect(r.frame).toBeUndefined();
211
+ expect(r.invalid).toBeFalsy();
212
+ }
213
+ });
214
+ test('returns consumed=0 when payload is incomplete', () => {
215
+ // 6-byte header advertises 8-byte payload but we only have 6+4 bytes.
216
+ const slice = VECTOR_01.subarray(0, 10);
217
+ const r = (0, maretron_ipg_1.parseMaretronFrame)(slice, 0);
218
+ expect(r.consumed).toBe(0);
219
+ expect(r.frame).toBeUndefined();
220
+ });
221
+ test('rejects frames with F1 sync bit unset as invalid', () => {
222
+ const bad = Buffer.from(VECTOR_01);
223
+ bad[1] = bad[1] & 0x7f; // clear sync bit
224
+ const r = (0, maretron_ipg_1.parseMaretronFrame)(bad, 0);
225
+ expect(r.invalid).toBe(true);
226
+ });
227
+ test('rejects frames not starting with 0xA5 as invalid', () => {
228
+ const bad = Buffer.from(VECTOR_01);
229
+ bad[0] = 0x00;
230
+ const r = (0, maretron_ipg_1.parseMaretronFrame)(bad, 0);
231
+ expect(r.invalid).toBe(true);
232
+ });
233
+ test('msg_type=3 needs 7 bytes of header before length is known', () => {
234
+ // Construct a minimal msg_type=3 frame with a 1-byte payload.
235
+ const built = (0, maretron_ipg_1.buildMaretronFrame)({
236
+ pgn: 130820,
237
+ src: 41,
238
+ priority: 6,
239
+ msg_type: 3,
240
+ payload: Buffer.from([0xaa])
241
+ });
242
+ expect((0, maretron_ipg_1.parseMaretronFrame)(built.subarray(0, 6), 0).consumed).toBe(0);
243
+ expect((0, maretron_ipg_1.parseMaretronFrame)(built, 0).frame?.payload[0]).toBe(0xaa);
244
+ });
245
+ test('PDU2 broadcast: PS becomes PGN low byte, dst=0xFF regardless of input dst', () => {
246
+ const built = (0, maretron_ipg_1.buildMaretronFrame)({
247
+ pgn: 127488,
248
+ src: 0x0f,
249
+ dst: 0x42, // ignored because PDU2
250
+ priority: 2,
251
+ msg_type: 1,
252
+ payload: Buffer.alloc(8)
253
+ });
254
+ expect(built[3]).toBe(0x00); // PS = PGN low byte (0x1F200 & 0xFF)
255
+ expect((0, maretron_ipg_1.parseMaretronFrame)(built, 0).frame?.dst).toBe(0xff);
256
+ });
257
+ test('PDU1 directed: PS carries dst, dst is preserved through round-trip', () => {
258
+ const built = (0, maretron_ipg_1.buildMaretronFrame)({
259
+ pgn: 59904,
260
+ src: 0xff,
261
+ dst: 0x23,
262
+ priority: 6,
263
+ msg_type: 1,
264
+ payload: Buffer.from([0x00, 0xee, 0x01])
265
+ });
266
+ expect(built[3]).toBe(0x23);
267
+ expect((0, maretron_ipg_1.parseMaretronFrame)(built, 0).frame?.dst).toBe(0x23);
268
+ });
269
+ test('rejects payloads > 255 bytes when msg_type != 3', () => {
270
+ expect(() => (0, maretron_ipg_1.buildMaretronFrame)({
271
+ pgn: 127488,
272
+ msg_type: 1,
273
+ payload: Buffer.alloc(256)
274
+ })).toThrow(/Transport Protocol/);
275
+ });
276
+ test('handles back-to-back frames in a single buffer', () => {
277
+ const a = (0, maretron_ipg_1.buildMaretronFrame)({
278
+ pgn: 127488,
279
+ src: 0x0f,
280
+ priority: 2,
281
+ msg_type: 1,
282
+ payload: Buffer.from('00a816000000ffff', 'hex')
283
+ });
284
+ const b = (0, maretron_ipg_1.buildMaretronFrame)({
285
+ pgn: 60928,
286
+ src: 0x29,
287
+ priority: 6,
288
+ msg_type: 1,
289
+ payload: Buffer.from('0102030405060708', 'hex')
290
+ });
291
+ const both = Buffer.concat([a, b]);
292
+ const r1 = (0, maretron_ipg_1.parseMaretronFrame)(both, 0);
293
+ expect(r1.frame?.pgn).toBe(127488);
294
+ const r2 = (0, maretron_ipg_1.parseMaretronFrame)(both, r1.consumed);
295
+ expect(r2.frame?.pgn).toBe(60928);
296
+ expect(r1.consumed + r2.consumed).toBe(both.length);
297
+ });
298
+ });
299
+ // ---------------------------------------------------------------------------
300
+ // Handshake helpers
301
+ // ---------------------------------------------------------------------------
302
+ describe('handshake helpers', () => {
303
+ test('buildConnectMessage wraps the password in double quotes and NUL-terminates', () => {
304
+ const buf = (0, maretron_ipg_1.buildConnectMessage)('');
305
+ expect(buf.toString('utf8')).toBe('CONNECT\t""\t\tMOBILE\0');
306
+ expect(buf[buf.length - 1]).toBe(0);
307
+ });
308
+ test('buildConnectMessage carries the supplied password verbatim inside the quotes', () => {
309
+ expect((0, maretron_ipg_1.buildConnectMessage)('hunter2').toString('utf8')).toBe('CONNECT\t"hunter2"\t\tMOBILE\0');
310
+ });
311
+ test('buildConnectMessage rejects passwords with handshake-breaking characters', () => {
312
+ for (const bad of [
313
+ 'has"quote',
314
+ 'has\ttab',
315
+ 'has\0nul',
316
+ 'has\rcr',
317
+ 'has\nlf'
318
+ ]) {
319
+ expect(() => (0, maretron_ipg_1.buildConnectMessage)(bad)).toThrow(/quotes, tabs, or NUL/);
320
+ }
321
+ });
322
+ test('SET_MODE_BINARY matches the documented wire bytes', () => {
323
+ expect(maretron_ipg_1.SET_MODE_BINARY.toString('utf8')).toBe('SET_MODE\tBINARY\0');
324
+ });
325
+ });
326
+ // ---------------------------------------------------------------------------
327
+ // Stream wiring — drive the parser via a fake socket (no real TCP)
328
+ // ---------------------------------------------------------------------------
329
+ class FakeSocket extends events_1.EventEmitter {
330
+ written = [];
331
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
332
+ write(data) {
333
+ if (typeof data === 'string') {
334
+ this.written.push(Buffer.from(data, 'utf8'));
335
+ }
336
+ else {
337
+ this.written.push(Buffer.from(data));
338
+ }
339
+ return true;
340
+ }
341
+ end() {
342
+ this.emit('close');
343
+ }
344
+ destroy() {
345
+ this.emit('close');
346
+ }
347
+ }
348
+ describe('MaretronIPGStream — handshake & frame routing', () => {
349
+ test('sends CONNECT on connect, SET_MODE BINARY on CONNECTED, then emits parsed frames', () => {
350
+ const fake = new FakeSocket();
351
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
352
+ host: 'fakehost',
353
+ port: 6543,
354
+ password: '',
355
+ reconnect: false,
356
+ _socketFactory: () => fake
357
+ });
358
+ const frames = [];
359
+ stream.on('n2kFrame', (f) => frames.push(f));
360
+ // 1. Fake socket connects.
361
+ fake.emit('connect');
362
+ expect(fake.written.length).toBe(1);
363
+ expect(fake.written[0].toString('utf8')).toBe('CONNECT\t""\t\tMOBILE\0');
364
+ // 2. Daemon streams the four handshake replies in order.
365
+ fake.emit('data', Buffer.from('SERVER_VERSION\t4.2.0.1\tIPG100\0' +
366
+ 'INSTANCE_DATA\t41\t1\0' +
367
+ 'LICENSES_USED\t1\t1\t1\t1\0', 'utf8'));
368
+ // Still awaiting CONNECTED — no SET_MODE yet.
369
+ expect(fake.written.length).toBe(1);
370
+ fake.emit('data', Buffer.from('CONNECTED\t12345\0', 'utf8'));
371
+ expect(fake.written.length).toBe(2);
372
+ expect(fake.written[1].toString('utf8')).toBe('SET_MODE\tBINARY\0');
373
+ expect(stream.state).toBe('streaming');
374
+ expect(stream.ipgBusAddress).toBe(41);
375
+ expect(stream.deviceSerial).toBe('12345');
376
+ // 3. Inject a binary frame (Vector 01's bytes).
377
+ fake.emit('data', Buffer.from('a5a3f2000f0800a816000000ffff', 'hex'));
378
+ expect(frames.length).toBe(1);
379
+ expect(frames[0].pgn).toBe(127488);
380
+ expect(frames[0].src).toBe(0x0f);
381
+ expect(frames[0].priority).toBe(2);
382
+ });
383
+ test('sendPGN writes a 0xA5-framed buffer to the socket when streaming', () => {
384
+ const fake = new FakeSocket();
385
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
386
+ host: 'fakehost',
387
+ reconnect: false,
388
+ _socketFactory: () => fake
389
+ });
390
+ fake.emit('connect');
391
+ fake.emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
392
+ fake.written.length = 0;
393
+ // sendPGN with a minimal handcrafted PGN — toPgn will pad/format it.
394
+ stream.sendPGN({
395
+ pgn: 59904,
396
+ prio: 6,
397
+ src: 0,
398
+ dst: 0x23,
399
+ fields: { PGN: 126464 }
400
+ });
401
+ expect(fake.written.length).toBeGreaterThan(0);
402
+ const wire = fake.written[fake.written.length - 1];
403
+ expect(wire[0]).toBe(0xa5);
404
+ // PF = byte 2 = 0xEA, PS = byte 3 = 0x23 (PDU1 directed)
405
+ expect(wire[2]).toBe(0xea);
406
+ expect(wire[3]).toBe(0x23);
407
+ // SA = byte 4 = 0xFF (always 0xFF on TX — IPG substitutes its claim)
408
+ expect(wire[4]).toBe(0xff);
409
+ });
410
+ test('sendString accepts canboat plain CSV and frames it', () => {
411
+ const fake = new FakeSocket();
412
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
413
+ host: 'fakehost',
414
+ reconnect: false,
415
+ _socketFactory: () => fake
416
+ });
417
+ fake.emit('connect');
418
+ fake.emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
419
+ fake.written.length = 0;
420
+ stream.sendString('2026-05-13-10:00:00.000,6,59904,5,35,3,00,ee,01');
421
+ expect(fake.written.length).toBe(1);
422
+ const wire = fake.written[0];
423
+ expect(wire.toString('hex')).toBe('a5e2ea23ff0300ee01');
424
+ });
425
+ test('drops outbound PGNs before handshake completes', () => {
426
+ const fake = new FakeSocket();
427
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
428
+ host: 'fakehost',
429
+ reconnect: false,
430
+ _socketFactory: () => fake
431
+ });
432
+ fake.emit('connect'); // not yet CONNECTED
433
+ fake.written.length = 0;
434
+ stream.sendPGN({
435
+ pgn: 59904,
436
+ prio: 6,
437
+ dst: 0x23,
438
+ src: 0,
439
+ fields: {}
440
+ });
441
+ expect(fake.written.length).toBe(0);
442
+ });
443
+ test('reassembles frames split across multiple data chunks', () => {
444
+ const fake = new FakeSocket();
445
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
446
+ host: 'fakehost',
447
+ reconnect: false,
448
+ _socketFactory: () => fake
449
+ });
450
+ const frames = [];
451
+ stream.on('n2kFrame', (f) => frames.push(f));
452
+ fake.emit('connect');
453
+ fake.emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
454
+ const whole = Buffer.from('a5a3f2000f0800a816000000ffff', 'hex');
455
+ // Split arbitrarily — first chunk doesn't even contain a full header.
456
+ fake.emit('data', whole.subarray(0, 3));
457
+ expect(frames.length).toBe(0);
458
+ fake.emit('data', whole.subarray(3, 8));
459
+ expect(frames.length).toBe(0);
460
+ fake.emit('data', whole.subarray(8));
461
+ expect(frames.length).toBe(1);
462
+ expect(frames[0].pgn).toBe(127488);
463
+ });
464
+ test('resyncs past a junk high-bit byte preceding a valid 0xA5 frame', () => {
465
+ const fake = new FakeSocket();
466
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
467
+ host: 'fakehost',
468
+ reconnect: false,
469
+ _socketFactory: () => fake
470
+ });
471
+ const frames = [];
472
+ stream.on('n2kFrame', (f) => frames.push(f));
473
+ fake.emit('connect');
474
+ fake.emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
475
+ // 0x90 has the high bit set so it can't be parsed as an ASCII text
476
+ // frame, and isn't 0xA5 so it can't start a binary frame either.
477
+ // Real-world cause: brief desync after the SET_MODE BINARY toggle.
478
+ // The driver must skip exactly one byte and find the real frame.
479
+ const good = Buffer.from('a5a3f2000f0800a816000000ffff', 'hex');
480
+ const junk = Buffer.from([0x90]);
481
+ fake.emit('data', Buffer.concat([junk, good]));
482
+ expect(frames.length).toBe(1);
483
+ expect(frames[0].pgn).toBe(127488);
484
+ });
485
+ test('NO authentication reply emits authfail and ends the socket', () => {
486
+ const fake = new FakeSocket();
487
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
488
+ host: 'fakehost',
489
+ reconnect: false,
490
+ _socketFactory: () => fake
491
+ });
492
+ let authfailed = false;
493
+ stream.on('authfail', () => (authfailed = true));
494
+ fake.emit('connect');
495
+ fake.emit('data', Buffer.from('NO\0', 'utf8'));
496
+ expect(authfailed).toBe(true);
497
+ });
498
+ test('NO authentication failure does not schedule a reconnect or emit a fail-fast error', () => {
499
+ jest.useFakeTimers();
500
+ let factoryCalls = 0;
501
+ const sockets = [];
502
+ const factory = () => {
503
+ factoryCalls += 1;
504
+ const s = new FakeSocket();
505
+ sockets.push(s);
506
+ return s;
507
+ };
508
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
509
+ host: 'fakehost',
510
+ reconnect: true, // would otherwise schedule a retry on close
511
+ _socketFactory: factory
512
+ });
513
+ const errors = [];
514
+ stream.on('error', (e) => errors.push(e));
515
+ let authfailed = false;
516
+ stream.on('authfail', () => (authfailed = true));
517
+ sockets[0].emit('connect');
518
+ sockets[0].emit('data', Buffer.from('NO\0', 'utf8'));
519
+ // FakeSocket.end() synchronously emits 'close', which would normally
520
+ // run the reconnect / fail-fast paths.
521
+ expect(authfailed).toBe(true);
522
+ expect(stream.reconnectTimer).toBeNull();
523
+ expect(errors.length).toBe(0);
524
+ jest.advanceTimersByTime(60_000);
525
+ expect(factoryCalls).toBe(1);
526
+ jest.useRealTimers();
527
+ });
528
+ });
529
+ // ---------------------------------------------------------------------------
530
+ // Reconnect semantics — fail fast on initial connect (standalone),
531
+ // retry after success, with exponential backoff
532
+ // ---------------------------------------------------------------------------
533
+ describe('MaretronIPGStream — reconnect policy', () => {
534
+ test('initial connection failure emits stream error and does not schedule a retry', () => {
535
+ jest.useFakeTimers();
536
+ const fake = new FakeSocket();
537
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
538
+ host: 'fakehost',
539
+ reconnect: true,
540
+ _socketFactory: () => fake
541
+ });
542
+ const errors = [];
543
+ stream.on('error', (e) => errors.push(e));
544
+ // Simulate ECONNREFUSED with no prior 'connect' event.
545
+ fake.emit('error', Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' }));
546
+ fake.emit('close');
547
+ expect(errors.length).toBe(1);
548
+ expect(errors[0].message).toBe('ECONNREFUSED');
549
+ expect(stream.reconnectTimer).toBeNull();
550
+ // Advancing time should not trigger a new socket factory call.
551
+ jest.advanceTimersByTime(60_000);
552
+ expect(stream.reconnectTimer).toBeNull();
553
+ jest.useRealTimers();
554
+ });
555
+ test('post-handshake socket close schedules a reconnect with the timer ref-d', () => {
556
+ jest.useFakeTimers();
557
+ let factoryCalls = 0;
558
+ const sockets = [];
559
+ const factory = () => {
560
+ factoryCalls += 1;
561
+ const s = new FakeSocket();
562
+ sockets.push(s);
563
+ return s;
564
+ };
565
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
566
+ host: 'fakehost',
567
+ reconnect: true,
568
+ reconnectInitialMs: 5000,
569
+ _socketFactory: factory
570
+ });
571
+ expect(factoryCalls).toBe(1);
572
+ // Complete the handshake on socket 0.
573
+ sockets[0].emit('connect');
574
+ sockets[0].emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
575
+ expect(stream.hasEverConnected).toBe(true);
576
+ // Socket drops mid-session.
577
+ sockets[0].emit('close');
578
+ expect(stream.reconnectTimer).not.toBeNull();
579
+ // Critically: the timer must NOT be unref'd. We verify by checking
580
+ // hasRef() — if the implementation regresses to unref(), this fails.
581
+ expect(stream.reconnectTimer.hasRef()).toBe(true);
582
+ // Advance time to fire the reconnect.
583
+ jest.advanceTimersByTime(5000);
584
+ expect(factoryCalls).toBe(2);
585
+ jest.useRealTimers();
586
+ });
587
+ test('SignalK-mode initial failure (app provided) retries instead of emitting error', () => {
588
+ jest.useFakeTimers();
589
+ let factoryCalls = 0;
590
+ const sockets = [];
591
+ const factory = () => {
592
+ factoryCalls += 1;
593
+ const s = new FakeSocket();
594
+ sockets.push(s);
595
+ return s;
596
+ };
597
+ const app = new events_1.EventEmitter();
598
+ app.setProviderError = jest.fn();
599
+ app.setProviderStatus = jest.fn();
600
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
601
+ app,
602
+ providerId: 'maretron',
603
+ host: 'fakehost',
604
+ reconnect: true,
605
+ reconnectInitialMs: 5000,
606
+ _socketFactory: factory
607
+ });
608
+ const errors = [];
609
+ stream.on('error', (e) => errors.push(e));
610
+ // Initial connect fails — no 'connect' event ever fired.
611
+ sockets[0].emit('error', Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' }));
612
+ sockets[0].emit('close');
613
+ // No stream-level error — SignalK keeps trying.
614
+ expect(errors.length).toBe(0);
615
+ expect(stream.reconnectTimer).not.toBeNull();
616
+ expect(app.setProviderError).toHaveBeenCalled();
617
+ // Advance time; retry should fire and call the factory again.
618
+ jest.advanceTimersByTime(5000);
619
+ expect(factoryCalls).toBe(2);
620
+ jest.useRealTimers();
621
+ });
622
+ test('failFastOnInitialConnect:true override forces fail-fast even with an app', () => {
623
+ jest.useFakeTimers();
624
+ const fake = new FakeSocket();
625
+ const app = new events_1.EventEmitter();
626
+ app.setProviderError = jest.fn();
627
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
628
+ app,
629
+ providerId: 'maretron',
630
+ failFastOnInitialConnect: true,
631
+ reconnect: true,
632
+ _socketFactory: () => fake
633
+ });
634
+ const errors = [];
635
+ stream.on('error', (e) => errors.push(e));
636
+ fake.emit('error', Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' }));
637
+ fake.emit('close');
638
+ expect(errors.length).toBe(1);
639
+ expect(stream.reconnectTimer).toBeNull();
640
+ jest.useRealTimers();
641
+ });
642
+ test('initial failure without an error listener logs to stderr instead of throwing', () => {
643
+ const fake = new FakeSocket();
644
+ const consoleErr = jest.spyOn(console, 'error').mockImplementation(() => { });
645
+ // No stream.on('error') listener — emit('error') would otherwise crash.
646
+ (0, maretron_ipg_1.MaretronIPGStream)({
647
+ host: 'fakehost',
648
+ reconnect: true,
649
+ _socketFactory: () => fake
650
+ });
651
+ expect(() => {
652
+ fake.emit('error', Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' }));
653
+ fake.emit('close');
654
+ }).not.toThrow();
655
+ expect(consoleErr).toHaveBeenCalled();
656
+ consoleErr.mockRestore();
657
+ });
658
+ test('reconnect delay doubles on each failure, caps at reconnectMaxMs, resets on CONNECTED', () => {
659
+ jest.useFakeTimers();
660
+ const sockets = [];
661
+ const factory = () => {
662
+ const s = new FakeSocket();
663
+ sockets.push(s);
664
+ return s;
665
+ };
666
+ const app = new events_1.EventEmitter();
667
+ app.setProviderError = () => { };
668
+ app.setProviderStatus = () => { };
669
+ const stream = (0, maretron_ipg_1.MaretronIPGStream)({
670
+ app,
671
+ providerId: 'maretron',
672
+ reconnect: true,
673
+ reconnectInitialMs: 100,
674
+ reconnectMaxMs: 800,
675
+ _socketFactory: factory
676
+ });
677
+ // First socket constructed in the constructor.
678
+ expect(stream.reconnectDelayMs).toBe(100);
679
+ // Walk through six failed attempts: 100, 200, 400, 800, 800, 800.
680
+ const expected = [100, 200, 400, 800, 800, 800];
681
+ for (let i = 0; i < expected.length; i++) {
682
+ const delay = expected[i];
683
+ sockets[i].emit('error', Object.assign(new Error('flap'), { code: 'ECONNREFUSED' }));
684
+ sockets[i].emit('close');
685
+ // Next scheduled delay is doubled, capped.
686
+ expect(stream.reconnectDelayMs).toBe(Math.min(delay * 2, 800));
687
+ jest.advanceTimersByTime(delay);
688
+ expect(sockets.length).toBe(i + 2);
689
+ }
690
+ // Successful handshake on the next socket resets the delay.
691
+ sockets[sockets.length - 1].emit('connect');
692
+ sockets[sockets.length - 1].emit('data', Buffer.from('CONNECTED\t1\0', 'utf8'));
693
+ expect(stream.reconnectDelayMs).toBe(100);
694
+ // A subsequent close starts the backoff cycle over from initial.
695
+ const lastIndex = sockets.length - 1;
696
+ sockets[lastIndex].emit('close');
697
+ expect(stream.reconnectDelayMs).toBe(200);
698
+ jest.advanceTimersByTime(100);
699
+ expect(sockets.length).toBe(lastIndex + 2);
700
+ jest.useRealTimers();
701
+ });
702
+ });
703
+ //# sourceMappingURL=maretron-ipg.test.js.map