@canboat/canboatjs 3.18.0 → 3.19.0-beta.1
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/README.md +4 -0
- package/build/Makefile +2 -2
- package/build/canSocket.target.mk +14 -14
- package/dist/actisense-serial.d.ts.map +1 -1
- package/dist/actisense-serial.js +46 -38
- package/dist/actisense-serial.js.map +1 -1
- package/dist/actisense-serial.test.js +106 -0
- package/dist/actisense-serial.test.js.map +1 -1
- package/dist/bin/maretron-ipgjs.d.ts +26 -0
- package/dist/bin/maretron-ipgjs.d.ts.map +1 -0
- package/dist/bin/maretron-ipgjs.js +159 -0
- package/dist/bin/maretron-ipgjs.js.map +1 -0
- package/dist/fromPgn.d.ts.map +1 -1
- package/dist/fromPgn.js +22 -1
- package/dist/fromPgn.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/maretron-ipg.d.ts +135 -0
- package/dist/maretron-ipg.d.ts.map +1 -0
- package/dist/maretron-ipg.js +660 -0
- package/dist/maretron-ipg.js.map +1 -0
- package/dist/maretron-ipg.test.d.ts +2 -0
- package/dist/maretron-ipg.test.d.ts.map +1 -0
- package/dist/maretron-ipg.test.js +703 -0
- package/dist/maretron-ipg.test.js.map +1 -0
- package/dist/stringMsg.d.ts +1 -1
- package/dist/stringMsg.d.ts.map +1 -1
- package/dist/stringMsg.js +16 -3
- package/dist/stringMsg.js.map +1 -1
- package/dist/ydgw02.js +9 -1
- package/dist/ydgw02.js.map +1 -1
- package/package.json +3 -2
- 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
|