@canboat/canboatjs 3.18.0-beta.1 → 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.
@@ -0,0 +1,660 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright 2026 Signal K contributors
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ *
17
+ * Transport driver for the Maretron IPG100 over its 0xA5-framed TCP
18
+ * protocol.
19
+ *
20
+ * Notes:
21
+ *
22
+ * * The IPG100 caps the total number of simultaneous client TCP
23
+ * connections at 20. A 21st client is refused.
24
+ * * The 4th token in CONNECT is a client-type. Sending `MOBILE`
25
+ * does NOT consume one of the limited licensed-client slots, so
26
+ * this driver is safe to run alongside Maretron N2KView etc.
27
+ * * The IPG100 performs NO device-side PGN filtering in either
28
+ * direction. Every frame on the bus reaches every connected client,
29
+ * and every frame any client writes is forwarded both to the bus
30
+ * and to all *other* connected clients.
31
+ * * Binary mode is mandatory for anything beyond a small set of
32
+ * well-known PGNs: the IPG's default ASCII output depends on its
33
+ * internal PGN dictionary, which can't represent newer / vendor
34
+ * PGNs. We send SET_MODE BINARY immediately after CONNECTED.
35
+ * * The IPG handles fast-packet reassembly itself, so each frame
36
+ * carries a full logical N2K payload.
37
+ * * On TX, source address on the wire is always 0xFF — the IPG
38
+ * substitutes its own claimed SA.
39
+ */
40
+ var __importDefault = (this && this.__importDefault) || function (mod) {
41
+ return (mod && mod.__esModule) ? mod : { "default": mod };
42
+ };
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.SET_MODE_BINARY = exports.IPG_PORT = void 0;
45
+ exports.parseMaretronFrame = parseMaretronFrame;
46
+ exports.buildMaretronFrame = buildMaretronFrame;
47
+ exports.buildConnectMessage = buildConnectMessage;
48
+ exports.MaretronIPGStream = MaretronIPGStream;
49
+ const stream_1 = require("stream");
50
+ const net_1 = __importDefault(require("net"));
51
+ const util_1 = __importDefault(require("util"));
52
+ const utilities_1 = require("./utilities");
53
+ const toPgn_1 = require("./toPgn");
54
+ const stringMsg_1 = require("./stringMsg");
55
+ // ---------------------------------------------------------------------------
56
+ // Wire-protocol constants
57
+ // ---------------------------------------------------------------------------
58
+ const FRAME_BINARY = 0xa5; // dispatch byte for 0xA5 binary frames
59
+ const FRAME_VIDEO = 0x33; // '3' — IP-camera proxy; skipped
60
+ const F1_SYNC_BIT = 0x80; // high bit of F1 must be set
61
+ const NUL = 0x00; // text-frame terminator
62
+ exports.IPG_PORT = 6543;
63
+ // Reconnect uses exponential backoff (1, 2, 4, 8, 16, 32 s — doubling each
64
+ // failure, capped at 32 s, reset on CONNECTED) so a freshly-rebooting IPG
65
+ // is picked up promptly and a missing host doesn't hammer DNS. Idle
66
+ // teardown closes the socket after 30 s of no inbound data.
67
+ const DEFAULT_RECONNECT_INITIAL_MS = 1_000;
68
+ const DEFAULT_RECONNECT_MAX_MS = 32_000;
69
+ const DEFAULT_IDLE_TEARDOWN_MS = 30_000;
70
+ const IDLE_CHECK_INTERVAL_MS = 10_000;
71
+ const MSG_TYPE_NAMES = {
72
+ 0: 'Reserved',
73
+ 1: 'Single Frame',
74
+ 2: 'Fast Packet',
75
+ 3: 'Transport Protocol'
76
+ };
77
+ function pduFormat(pf) {
78
+ return pf < 0xf0 ? 'PDU1' : 'PDU2';
79
+ }
80
+ /**
81
+ * Parse a single Maretron 0xA5 binary frame starting at `buf[offset]`.
82
+ *
83
+ * Header layout:
84
+ * byte 0 SYNC = 0xA5
85
+ * byte 1 F1 = [sync:1][prio:3][edp:1][msgType:2][dp:1]
86
+ * byte 2 PF
87
+ * byte 3 PS PDU1 → destination SA; PDU2 → PGN low byte
88
+ * byte 4 SA 0xFF = IPG substitutes its claimed SA
89
+ * byte 5 LL msgType != 3 → 8-bit length, payload starts at 6
90
+ * byte 6 LH msgType == 3 only → length high byte, payload at 7
91
+ */
92
+ function parseMaretronFrame(buf, offset = 0) {
93
+ if (buf.length - offset < 6)
94
+ return { consumed: 0 };
95
+ if (buf[offset] !== FRAME_BINARY)
96
+ return { consumed: 0, invalid: true };
97
+ const f1 = buf[offset + 1];
98
+ if ((f1 & F1_SYNC_BIT) === 0)
99
+ return { consumed: 0, invalid: true };
100
+ const priority = (f1 >> 4) & 0x07; // 0=Highest
101
+ const edp = (f1 >> 3) & 0x01; // Extended Data Page
102
+ const msg_type = (f1 >> 1) & 0x03; // 1=Single, 2=Fast Packet, 3=Transport
103
+ const dp = f1 & 0x01; // Data Page
104
+ const pf = buf[offset + 2]; // PDU Format
105
+ const ps = buf[offset + 3]; // PDU Specific
106
+ const sa = buf[offset + 4]; // Source Address
107
+ let payloadStart;
108
+ let payloadLength;
109
+ if (msg_type === 3) {
110
+ if (buf.length - offset < 7)
111
+ return { consumed: 0 };
112
+ payloadLength = buf[offset + 5] | (buf[offset + 6] << 8);
113
+ payloadStart = offset + 7;
114
+ }
115
+ else {
116
+ payloadLength = buf[offset + 5];
117
+ payloadStart = offset + 6;
118
+ }
119
+ const total = payloadStart - offset + payloadLength;
120
+ if (buf.length - offset < total)
121
+ return { consumed: 0 };
122
+ const payload = buf.subarray(payloadStart, payloadStart + payloadLength);
123
+ let pgn;
124
+ let dst;
125
+ if (pf < 240) {
126
+ /* PDU1 format, the PS contains the destination address */
127
+ pgn = (dp << 16) | (pf << 8);
128
+ dst = ps;
129
+ }
130
+ else {
131
+ /* PDU2 format, the destination is implied global and the PGN is extended */
132
+ pgn = (dp << 16) | (pf << 8) | ps;
133
+ dst = 0xff;
134
+ }
135
+ return {
136
+ consumed: total,
137
+ frame: {
138
+ pgn,
139
+ pdu_format: pduFormat(pf),
140
+ src: sa,
141
+ dst,
142
+ priority,
143
+ dp,
144
+ edp,
145
+ msg_type,
146
+ msg_type_name: MSG_TYPE_NAMES[msg_type] ?? 'Unknown',
147
+ payload_length: payloadLength,
148
+ payload: Buffer.from(payload)
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * Serialize a single 0xA5 frame from a structured description.
154
+ *
155
+ * PDU1 puts `dst` in the PS byte; PDU2 puts the PGN low byte in PS and
156
+ * ignores the caller-supplied `dst`.
157
+ */
158
+ function buildMaretronFrame(input) {
159
+ const { pgn, src = 0xff, dst = 0xff, priority = 6, msg_type = 1, edp = 0 // Extended Data Page
160
+ } = input;
161
+ const payload = Buffer.isBuffer(input.payload)
162
+ ? input.payload
163
+ : Buffer.from(input.payload);
164
+ const dp = (pgn >> 16) & 0x01; // Data Page
165
+ const pf = (pgn >> 8) & 0xff; // PDU Format
166
+ let ps; // PDU Specific
167
+ if (pf < 240) {
168
+ /* PDU1 format, the PS contains the destination address */
169
+ ps = dst & 0xff;
170
+ }
171
+ else {
172
+ /* PDU2 format, the destination is implied global and the PGN is extended */
173
+ ps = pgn & 0xff;
174
+ }
175
+ const f1 = F1_SYNC_BIT |
176
+ ((priority & 0x07) << 4) |
177
+ ((edp & 0x01) << 3) |
178
+ ((msg_type & 0x03) << 1) |
179
+ (dp & 0x01);
180
+ const len = payload.length;
181
+ let header;
182
+ if (msg_type === 3) {
183
+ header = Buffer.from([
184
+ FRAME_BINARY,
185
+ f1,
186
+ pf,
187
+ ps,
188
+ src & 0xff,
189
+ len & 0xff,
190
+ (len >> 8) & 0xff
191
+ ]);
192
+ }
193
+ else {
194
+ if (len > 0xff) {
195
+ throw new Error(`Maretron payload of ${len} bytes requires msg_type=3 (Transport Protocol); got msg_type=${msg_type}`);
196
+ }
197
+ header = Buffer.from([FRAME_BINARY, f1, pf, ps, src & 0xff, len & 0xff]);
198
+ }
199
+ return Buffer.concat([header, payload]);
200
+ }
201
+ /**
202
+ * Build the 4-token CONNECT handshake message.
203
+ *
204
+ * The IPG strips a leading and trailing character from the password
205
+ * token before matching, so the password is always wrapped in double
206
+ * quotes on the wire. A stock IPG with no configured password is matched
207
+ * by the literal two-character string `""`.
208
+ *
209
+ * The 4th token is a client-type label that the IPG parses but does not
210
+ * act on. Hard-coded to "MOBILE" to match the convention used elsewhere.
211
+ */
212
+ function buildConnectMessage(password) {
213
+ // The password is wrapped in quotes and tab-delimited; embedded
214
+ // quotes, tabs, NULs, or newlines would retokenize or truncate the
215
+ // CONNECT frame on the wire and the IPG would silently reject auth.
216
+ if (/["\t\0\r\n]/.test(password)) {
217
+ throw new Error('Maretron IPG password cannot contain quotes, tabs, or NUL/newline characters');
218
+ }
219
+ return Buffer.from(`CONNECT\t"${password}"\t\tMOBILE\0`, 'utf8');
220
+ }
221
+ exports.SET_MODE_BINARY = Buffer.from('SET_MODE\tBINARY\0', 'utf8');
222
+ function MaretronIPGStream(options = {}) {
223
+ // Support plain-function calls via CommonJS re-export — `this === undefined`
224
+ // doesn't hold there (it's the exports object), so we key off `new.target`.
225
+ if (new.target === undefined) {
226
+ return new MaretronIPGStream(options);
227
+ }
228
+ stream_1.Transform.call(this, { objectMode: true });
229
+ this.debug = (0, utilities_1.createDebug)('canboatjs:maretron-ipg', options);
230
+ this.debugOut = (0, utilities_1.createDebug)('canboatjs:n2k-out', options);
231
+ this.debugData = (0, utilities_1.createDebug)('canboatjs:maretron-ipg-data', options);
232
+ this.options = options;
233
+ this.host = options.host ?? 'ipg100';
234
+ this.port = options.port ?? exports.IPG_PORT;
235
+ this.password = options.password ?? '';
236
+ this.reconnect = options.reconnect !== false;
237
+ this.reconnectInitialMs =
238
+ options.reconnectInitialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
239
+ this.reconnectMaxMs = options.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
240
+ // Current delay for the next attempt — doubled after each failure,
241
+ // reset on CONNECTED. See scheduleReconnect.
242
+ this.reconnectDelayMs = this.reconnectInitialMs;
243
+ this.idleTeardownMs = options.idleTeardownMs ?? DEFAULT_IDLE_TEARDOWN_MS;
244
+ // Fail fast on initial connect for standalone use (CLI / scripts): a
245
+ // typo'd hostname or wrong port surfaces immediately. SignalK-hosted
246
+ // use (options.app present) flips this off — the IPG may come
247
+ // online minutes after signalk-server boots, so the provider keeps
248
+ // retrying. Callers can override either way.
249
+ this.failFastOnInitialConnect =
250
+ options.failFastOnInitialConnect ?? !options.app;
251
+ this.state = 'closed';
252
+ this.rx = Buffer.alloc(0);
253
+ this.lastDataAt = 0;
254
+ this.socket = null;
255
+ this.idleTimer = null;
256
+ this.reconnectTimer = null;
257
+ this.hasEverConnected = false;
258
+ this.authFailed = false;
259
+ this.setProviderStatus =
260
+ options.app && options.app.setProviderStatus
261
+ ? (msg) => {
262
+ options.app.setProviderStatus(options.providerId, msg);
263
+ }
264
+ : () => { };
265
+ // Standalone use (no SignalK app) routes errors to stderr so socket-level
266
+ // failures during reconnect waits are visible. SignalK use stays on the
267
+ // app's setProviderError channel.
268
+ this.setProviderError =
269
+ options.app && options.app.setProviderError
270
+ ? (msg) => {
271
+ options.app.setProviderError(options.providerId, msg);
272
+ }
273
+ : (msg) => {
274
+ console.error(`maretron-ipg: ${msg}`);
275
+ };
276
+ if (options.app) {
277
+ const outEvents = (options.outEvent ?? 'nmea2000out')
278
+ .split(',')
279
+ .map((e) => e.trim());
280
+ outEvents.forEach((event) => {
281
+ options.app.on(event, (msg) => {
282
+ if (typeof msg === 'string') {
283
+ this.sendString(msg);
284
+ }
285
+ else {
286
+ this.sendPGN(msg);
287
+ }
288
+ options.app.emit('connectionwrite', {
289
+ providerId: options.providerId
290
+ });
291
+ });
292
+ });
293
+ const jsonOutEvents = (options.jsonOutEvent ?? 'nmea2000JsonOut')
294
+ .split(',')
295
+ .map((e) => e.trim());
296
+ jsonOutEvents.forEach((event) => {
297
+ options.app.on(event, (msg) => {
298
+ this.sendPGN(msg);
299
+ options.app.emit('connectionwrite', {
300
+ providerId: options.providerId
301
+ });
302
+ });
303
+ });
304
+ }
305
+ this.debug(`MaretronIPGStream constructed host=${this.host} port=${this.port}`);
306
+ this.start();
307
+ }
308
+ util_1.default.inherits(MaretronIPGStream, stream_1.Transform);
309
+ MaretronIPGStream.prototype.start = function () {
310
+ if (this.socket) {
311
+ // Detach our handlers before destroying so the impending 'close'
312
+ // doesn't re-enter scheduleReconnect on top of the fresh start.
313
+ this.socket.removeAllListeners();
314
+ try {
315
+ this.socket.destroy();
316
+ }
317
+ catch {
318
+ // ignore
319
+ }
320
+ this.socket = null;
321
+ }
322
+ this.state = 'connecting';
323
+ this.rx = Buffer.alloc(0);
324
+ // Per-socket scratch — `error` and `close` fire as a pair, with the
325
+ // error always first. Closing over it here means the field doesn't
326
+ // outlive the socket and can't be confused for state about a later one.
327
+ let lastErr = null;
328
+ const factory = this.options._socketFactory ??
329
+ ((host, port) => net_1.default.createConnection({ host, port }));
330
+ let socket;
331
+ try {
332
+ socket = factory(this.host, this.port);
333
+ }
334
+ catch (err) {
335
+ this.debug(`socket factory failed: ${err.message}`);
336
+ this.setProviderError(err.message);
337
+ this.scheduleReconnect(err);
338
+ return;
339
+ }
340
+ this.socket = socket;
341
+ socket.on('connect', () => {
342
+ this.debug(`TCP connected to ${this.host}:${this.port}`);
343
+ this.setProviderStatus(`Connected to ${this.host}:${this.port}`);
344
+ this.state = 'awaiting';
345
+ this.lastDataAt = Date.now();
346
+ const handshake = buildConnectMessage(this.password);
347
+ this.debugOut(`-> ${handshake.toString('utf8').replace(/\0/g, '\\0')}`);
348
+ socket.write(handshake);
349
+ this.startIdleTimer();
350
+ });
351
+ socket.on('data', (chunk) => {
352
+ this.lastDataAt = Date.now();
353
+ this.handleIncoming(chunk);
354
+ });
355
+ socket.on('error', (err) => {
356
+ this.debug(`socket error: ${err.message}`);
357
+ lastErr = err;
358
+ this.setProviderError(err.message);
359
+ });
360
+ socket.on('close', () => {
361
+ this.debug('socket closed');
362
+ this.stopIdleTimer();
363
+ this.state = 'closed';
364
+ // Suppress reconnect after auth failure — bad credentials won't get
365
+ // better by retrying, and in app mode this would otherwise loop forever.
366
+ if (!this.authFailed) {
367
+ this.scheduleReconnect(lastErr);
368
+ }
369
+ });
370
+ };
371
+ MaretronIPGStream.prototype.handleIncoming = function (chunk) {
372
+ this.rx = this.rx.length === 0 ? chunk : Buffer.concat([this.rx, chunk]);
373
+ // Drain framed messages until we run out of bytes or hit a partial frame.
374
+ while (this.rx.length > 0) {
375
+ const first = this.rx[0];
376
+ if (first === FRAME_BINARY) {
377
+ const result = parseMaretronFrame(this.rx, 0);
378
+ if (result.invalid) {
379
+ this.debug(`0xA5 with invalid F1 (0x${this.rx[1]?.toString(16)}); resyncing`);
380
+ this.rx = this.rx.subarray(1);
381
+ continue;
382
+ }
383
+ if (result.consumed === 0)
384
+ return; // need more bytes
385
+ this.rx = this.rx.subarray(result.consumed);
386
+ this.emitFrame(result.frame);
387
+ continue;
388
+ }
389
+ if (first === FRAME_VIDEO) {
390
+ const nul = this.rx.indexOf(NUL);
391
+ if (nul < 0)
392
+ return;
393
+ this.debug(`skipping video frame (${nul} bytes)`);
394
+ this.rx = this.rx.subarray(nul + 1);
395
+ continue;
396
+ }
397
+ if ((first & F1_SYNC_BIT) === 0) {
398
+ // ASCII / text control frame, NUL-terminated.
399
+ const nul = this.rx.indexOf(NUL);
400
+ if (nul < 0)
401
+ return;
402
+ const line = this.rx.subarray(0, nul).toString('utf8');
403
+ this.rx = this.rx.subarray(nul + 1);
404
+ if (line.length > 0)
405
+ this.handleText(line);
406
+ continue;
407
+ }
408
+ // High-bit set but not 0xA5 — out of sync. Drop one byte and retry.
409
+ this.debug(`out-of-sync byte 0x${first.toString(16)}; resyncing`);
410
+ this.rx = this.rx.subarray(1);
411
+ }
412
+ };
413
+ MaretronIPGStream.prototype.emitFrame = function (frame) {
414
+ if (this.debugData.enabled) {
415
+ this.debugData(`rx pgn=${frame.pgn} src=${frame.src} dst=${frame.dst} prio=${frame.priority} type=${frame.msg_type} len=${frame.payload_length}`);
416
+ }
417
+ this.emit('n2kFrame', frame);
418
+ // Pipeline payload: actisense-style canboat plain CSV. Downstream
419
+ // signalk-server pipelines consume this directly, and the Log
420
+ // provider records it as readable text.
421
+ const csv = (0, stringMsg_1.encodeActisense)({
422
+ pgn: frame.pgn,
423
+ prio: frame.priority,
424
+ src: frame.src,
425
+ dst: frame.dst,
426
+ data: frame.payload
427
+ });
428
+ if (this.options.app?.listenerCount?.('canboatjs:rawoutput') > 0) {
429
+ this.options.app.emit('canboatjs:rawoutput', csv);
430
+ }
431
+ this.push(csv);
432
+ };
433
+ MaretronIPGStream.prototype.handleText = function (line) {
434
+ const parts = line.split('\t');
435
+ const head = parts[0];
436
+ this.debug(`text: ${head}${parts.length > 1 ? '\t' + parts.slice(1).join('\t') : ''}`);
437
+ switch (head) {
438
+ case 'SERVER_VERSION':
439
+ this.serverVersion = parts[1];
440
+ this.serverProduct = parts[2]; // always "IPG100"
441
+ this.emit('version', this.serverVersion, this.serverProduct);
442
+ break;
443
+ case 'INSTANCE_DATA':
444
+ this.ipgBusAddress = parseInt(parts[1], 10);
445
+ this.clientInstance = parseInt(parts[2], 10);
446
+ this.emit('instance', this.ipgBusAddress, this.clientInstance);
447
+ break;
448
+ case 'CONNECTED':
449
+ // 4th and final handshake reply — switch to binary now.
450
+ this.deviceSerial = parts[1];
451
+ this.debugOut(`-> SET_MODE\\tBINARY\\0`);
452
+ this.socket?.write(exports.SET_MODE_BINARY);
453
+ this.state = 'streaming';
454
+ this.hasEverConnected = true;
455
+ this.reconnectDelayMs = this.reconnectInitialMs;
456
+ this.setProviderStatus(`Streaming from ${this.host}:${this.port} (serial ${this.deviceSerial ?? '?'})`);
457
+ if (this.options.app?.emit) {
458
+ this.options.app.emit('nmea2000OutAvailable');
459
+ }
460
+ this.emit('connected', { serial: this.deviceSerial });
461
+ break;
462
+ case 'NO':
463
+ this.debug('authentication failed');
464
+ this.setProviderError('Maretron IPG authentication failed (NO)');
465
+ this.authFailed = true;
466
+ this.emit('authfail');
467
+ this.socket?.end();
468
+ break;
469
+ case 'LICENSES_USED':
470
+ case 'DETAILED_LICENSES_USED':
471
+ case 'BASELICENSE':
472
+ case 'NOLICENSE':
473
+ case 'WAITING_TO_RECONNECT':
474
+ case 'DISCONNECTED':
475
+ case 'CONNECTING':
476
+ case 'CONNECTED_FILE':
477
+ case 'CATALOG':
478
+ case 'FILE_LENGTH':
479
+ case 'FILE_COMPLETE':
480
+ case 'MODES':
481
+ // informational — log only
482
+ break;
483
+ case '2':
484
+ case '3':
485
+ // ASCII-mode N2K data frame, arriving in the brief window between
486
+ // us sending SET_MODE BINARY and the IPG honoring it. Not useful
487
+ // because we don't know the field mappings, so we drop.
488
+ break;
489
+ default:
490
+ this.debug(`unrecognized text: ${head}`);
491
+ }
492
+ };
493
+ MaretronIPGStream.prototype.startIdleTimer = function () {
494
+ this.stopIdleTimer();
495
+ this.idleTimer = setInterval(() => {
496
+ if (this.state !== 'closed' &&
497
+ Date.now() - this.lastDataAt > this.idleTeardownMs) {
498
+ this.debug(`idle for ${this.idleTeardownMs}ms with no inbound data; tearing down`);
499
+ this.setProviderError('Idle teardown — no data received');
500
+ try {
501
+ this.socket?.end();
502
+ this.socket?.destroy();
503
+ }
504
+ catch {
505
+ // ignore
506
+ }
507
+ }
508
+ }, IDLE_CHECK_INTERVAL_MS);
509
+ if (this.idleTimer.unref)
510
+ this.idleTimer.unref();
511
+ };
512
+ MaretronIPGStream.prototype.stopIdleTimer = function () {
513
+ if (this.idleTimer) {
514
+ clearInterval(this.idleTimer);
515
+ this.idleTimer = null;
516
+ }
517
+ };
518
+ MaretronIPGStream.prototype.scheduleReconnect = function (lastErr) {
519
+ if (!this.reconnect)
520
+ return;
521
+ if (this.reconnectTimer)
522
+ return;
523
+ if (!this.hasEverConnected && this.failFastOnInitialConnect) {
524
+ // Standalone use (CLI, no SignalK app): initial connect failed
525
+ // (bad host, refused port, DNS error). Surface the underlying
526
+ // socket error and don't loop — the operator needs to fix the
527
+ // address. SignalK-hosted use keeps retrying instead (the IPG
528
+ // may not be online at server boot).
529
+ const err = lastErr ??
530
+ new Error(`Failed to connect to Maretron IPG at ${this.host}:${this.port}`);
531
+ if (this.listenerCount('error') > 0) {
532
+ this.emit('error', err);
533
+ }
534
+ else {
535
+ // No listener — log instead of crashing the process with an
536
+ // unhandled 'error' event.
537
+ console.error(`maretron-ipg: ${err.message}`);
538
+ }
539
+ return;
540
+ }
541
+ const delay = this.reconnectDelayMs;
542
+ this.debug(`scheduling reconnect in ${delay}ms`);
543
+ this.setProviderStatus(`Reconnecting in ${(delay / 1000).toFixed(0)}s`);
544
+ this.reconnectTimer = setTimeout(() => {
545
+ this.reconnectTimer = null;
546
+ this.start();
547
+ }, delay);
548
+ // Exponential backoff: 1→2→4→8→16→32 s by default. Reset to
549
+ // reconnectInitialMs in the CONNECTED handler so the next disconnect
550
+ // starts fresh.
551
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
552
+ // Intentionally not unref'd — after a successful first session this
553
+ // timer is the only thing keeping a standalone process alive across
554
+ // the reconnect gap. Unref'ing would let Node exit before the retry
555
+ // fires, defeating reconnect: true in CLI mode.
556
+ };
557
+ MaretronIPGStream.prototype.sendPGN = function (pgn) {
558
+ if (this.state !== 'streaming') {
559
+ this.debug(`sendPGN ${pgn.pgn} dropped — not streaming yet`);
560
+ return;
561
+ }
562
+ const data = (0, toPgn_1.toPgn)(pgn);
563
+ if (!data) {
564
+ this.debug(`toPgn returned no data for pgn ${pgn.pgn}`);
565
+ return;
566
+ }
567
+ // Single-frame CAN payloads fit in 8 bytes; anything larger goes
568
+ // out as Fast Packet (msg_type=2). We never emit msg_type=3 (ISO
569
+ // Transport Protocol) from the client side — the IPG handles
570
+ // fast-packet bucket splitting itself, and tagging an outbound
571
+ // message as Transport Protocol would push it into a different
572
+ // bus-side TX path that's not appropriate for ordinary client
573
+ // traffic. The 16-bit length encoding in build/parse exists only
574
+ // for inbound frames the IPG generates.
575
+ const msg_type = data.length > 8 ? 2 : 1;
576
+ const dst = pgn.dst ?? 0xff;
577
+ const prio = pgn.prio ?? 6;
578
+ const frame = buildMaretronFrame({
579
+ pgn: pgn.pgn,
580
+ // Always 0xFF — the IPG substitutes its own claimed SA.
581
+ src: 0xff,
582
+ dst,
583
+ priority: prio,
584
+ msg_type,
585
+ payload: data
586
+ });
587
+ this.writeFrame(frame, { pgn: pgn.pgn, dst, prio });
588
+ };
589
+ /**
590
+ * Send via a canboat plain-CSV string:
591
+ * `YYYY-MM-DD-HH:MM:SS.mmm,prio,pgn,src,dst,len,b0,b1,…`
592
+ *
593
+ * Source-address from the caller is ignored — the IPG decides.
594
+ */
595
+ MaretronIPGStream.prototype.sendString = function (msg) {
596
+ if (this.state !== 'streaming') {
597
+ this.debug(`sendString dropped — not streaming yet`);
598
+ return;
599
+ }
600
+ const parsed = (0, stringMsg_1.parseActisense)(msg);
601
+ if (!parsed || parsed.error) {
602
+ this.debug(`sendString ignored — ${parsed?.error ?? 'parse failed'}: ${msg}`);
603
+ return;
604
+ }
605
+ const { prio, pgn, dst, data } = parsed;
606
+ // See sendPGN: never emit msg_type=3 from the client side.
607
+ const msg_type = data.length > 8 ? 2 : 1;
608
+ const frame = buildMaretronFrame({
609
+ pgn,
610
+ src: 0xff,
611
+ dst,
612
+ priority: prio,
613
+ msg_type,
614
+ payload: data
615
+ });
616
+ this.writeFrame(frame, { pgn, prio, dst });
617
+ };
618
+ MaretronIPGStream.prototype.writeFrame = function (frame, ctx) {
619
+ if (!this.socket || this.state !== 'streaming') {
620
+ this.debug(`writeFrame dropped — state=${this.state}`);
621
+ return;
622
+ }
623
+ if (this.debugOut.enabled) {
624
+ this.debugOut(`tx pgn=${ctx?.pgn} dst=${ctx?.dst} prio=${ctx?.prio} bytes=${frame.length}`);
625
+ }
626
+ if (this.options.app?.listenerCount?.('canboatjs:rawsend') > 0) {
627
+ this.options.app.emit('canboatjs:rawsend', { data: frame });
628
+ }
629
+ this.socket.write(frame);
630
+ };
631
+ MaretronIPGStream.prototype._transform = function (chunk, _encoding, done) {
632
+ // Allow callers to also pipe raw bytes in (used by the unit tests that
633
+ // don't open a real TCP socket).
634
+ if (Buffer.isBuffer(chunk)) {
635
+ this.handleIncoming(chunk);
636
+ }
637
+ done();
638
+ };
639
+ MaretronIPGStream.prototype.end = function () {
640
+ // Must come before socket.destroy(): the async 'close' event will call
641
+ // scheduleReconnect, which short-circuits when this.reconnect is false.
642
+ this.reconnect = false;
643
+ if (this.reconnectTimer) {
644
+ clearTimeout(this.reconnectTimer);
645
+ this.reconnectTimer = null;
646
+ }
647
+ this.stopIdleTimer();
648
+ if (this.socket) {
649
+ try {
650
+ this.socket.end();
651
+ this.socket.destroy();
652
+ }
653
+ catch {
654
+ // ignore
655
+ }
656
+ this.socket = null;
657
+ }
658
+ this.state = 'closed';
659
+ };
660
+ //# sourceMappingURL=maretron-ipg.js.map