@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.
- 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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/n2kDevice.d.ts.map +1 -1
- package/dist/n2kDevice.js +58 -5
- package/dist/n2kDevice.js.map +1 -1
- package/dist/n2kDevice.test.d.ts +2 -0
- package/dist/n2kDevice.test.d.ts.map +1 -0
- package/dist/n2kDevice.test.js +154 -0
- package/dist/n2kDevice.test.js.map +1 -0
- package/dist/n2kIpGateway.d.ts +40 -0
- package/dist/n2kIpGateway.d.ts.map +1 -0
- package/dist/n2kIpGateway.js +464 -0
- package/dist/n2kIpGateway.js.map +1 -0
- package/dist/n2kIpGateway.test.d.ts +2 -0
- package/dist/n2kIpGateway.test.d.ts.map +1 -0
- package/dist/n2kIpGateway.test.js +349 -0
- package/dist/n2kIpGateway.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,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
|