@canboat/canboatjs
Version:
Native javascript version of canboat
660 lines • 25.9 kB
JavaScript
;
/**
* Copyright 2026 Signal K contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Transport driver for the Maretron IPG100 over its 0xA5-framed TCP
* protocol.
*
* Notes:
*
* * The IPG100 caps the total number of simultaneous client TCP
* connections at 20. A 21st client is refused.
* * The 4th token in CONNECT is a client-type. Sending `MOBILE`
* does NOT consume one of the limited licensed-client slots, so
* this driver is safe to run alongside Maretron N2KView etc.
* * The IPG100 performs NO device-side PGN filtering in either
* direction. Every frame on the bus reaches every connected client,
* and every frame any client writes is forwarded both to the bus
* and to all *other* connected clients.
* * Binary mode is mandatory for anything beyond a small set of
* well-known PGNs: the IPG's default ASCII output depends on its
* internal PGN dictionary, which can't represent newer / vendor
* PGNs. We send SET_MODE BINARY immediately after CONNECTED.
* * The IPG handles fast-packet reassembly itself, so each frame
* carries a full logical N2K payload.
* * On TX, source address on the wire is always 0xFF — the IPG
* substitutes its own claimed SA.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SET_MODE_BINARY = exports.IPG_PORT = void 0;
exports.parseMaretronFrame = parseMaretronFrame;
exports.buildMaretronFrame = buildMaretronFrame;
exports.buildConnectMessage = buildConnectMessage;
exports.MaretronIPGStream = MaretronIPGStream;
const stream_1 = require("stream");
const net_1 = __importDefault(require("net"));
const util_1 = __importDefault(require("util"));
const utilities_1 = require("./utilities");
const toPgn_1 = require("./toPgn");
const stringMsg_1 = require("./stringMsg");
// ---------------------------------------------------------------------------
// Wire-protocol constants
// ---------------------------------------------------------------------------
const FRAME_BINARY = 0xa5; // dispatch byte for 0xA5 binary frames
const FRAME_VIDEO = 0x33; // '3' — IP-camera proxy; skipped
const F1_SYNC_BIT = 0x80; // high bit of F1 must be set
const NUL = 0x00; // text-frame terminator
exports.IPG_PORT = 6543;
// Reconnect uses exponential backoff (1, 2, 4, 8, 16, 32 s — doubling each
// failure, capped at 32 s, reset on CONNECTED) so a freshly-rebooting IPG
// is picked up promptly and a missing host doesn't hammer DNS. Idle
// teardown closes the socket after 30 s of no inbound data.
const DEFAULT_RECONNECT_INITIAL_MS = 1_000;
const DEFAULT_RECONNECT_MAX_MS = 32_000;
const DEFAULT_IDLE_TEARDOWN_MS = 30_000;
const IDLE_CHECK_INTERVAL_MS = 10_000;
const MSG_TYPE_NAMES = {
0: 'Reserved',
1: 'Single Frame',
2: 'Fast Packet',
3: 'Transport Protocol'
};
function pduFormat(pf) {
return pf < 0xf0 ? 'PDU1' : 'PDU2';
}
/**
* Parse a single Maretron 0xA5 binary frame starting at `buf[offset]`.
*
* Header layout:
* byte 0 SYNC = 0xA5
* byte 1 F1 = [sync:1][prio:3][edp:1][msgType:2][dp:1]
* byte 2 PF
* byte 3 PS PDU1 → destination SA; PDU2 → PGN low byte
* byte 4 SA 0xFF = IPG substitutes its claimed SA
* byte 5 LL msgType != 3 → 8-bit length, payload starts at 6
* byte 6 LH msgType == 3 only → length high byte, payload at 7
*/
function parseMaretronFrame(buf, offset = 0) {
if (buf.length - offset < 6)
return { consumed: 0 };
if (buf[offset] !== FRAME_BINARY)
return { consumed: 0, invalid: true };
const f1 = buf[offset + 1];
if ((f1 & F1_SYNC_BIT) === 0)
return { consumed: 0, invalid: true };
const priority = (f1 >> 4) & 0x07; // 0=Highest
const edp = (f1 >> 3) & 0x01; // Extended Data Page
const msg_type = (f1 >> 1) & 0x03; // 1=Single, 2=Fast Packet, 3=Transport
const dp = f1 & 0x01; // Data Page
const pf = buf[offset + 2]; // PDU Format
const ps = buf[offset + 3]; // PDU Specific
const sa = buf[offset + 4]; // Source Address
let payloadStart;
let payloadLength;
if (msg_type === 3) {
if (buf.length - offset < 7)
return { consumed: 0 };
payloadLength = buf[offset + 5] | (buf[offset + 6] << 8);
payloadStart = offset + 7;
}
else {
payloadLength = buf[offset + 5];
payloadStart = offset + 6;
}
const total = payloadStart - offset + payloadLength;
if (buf.length - offset < total)
return { consumed: 0 };
const payload = buf.subarray(payloadStart, payloadStart + payloadLength);
let pgn;
let dst;
if (pf < 240) {
/* PDU1 format, the PS contains the destination address */
pgn = (dp << 16) | (pf << 8);
dst = ps;
}
else {
/* PDU2 format, the destination is implied global and the PGN is extended */
pgn = (dp << 16) | (pf << 8) | ps;
dst = 0xff;
}
return {
consumed: total,
frame: {
pgn,
pdu_format: pduFormat(pf),
src: sa,
dst,
priority,
dp,
edp,
msg_type,
msg_type_name: MSG_TYPE_NAMES[msg_type] ?? 'Unknown',
payload_length: payloadLength,
payload: Buffer.from(payload)
}
};
}
/**
* Serialize a single 0xA5 frame from a structured description.
*
* PDU1 puts `dst` in the PS byte; PDU2 puts the PGN low byte in PS and
* ignores the caller-supplied `dst`.
*/
function buildMaretronFrame(input) {
const { pgn, src = 0xff, dst = 0xff, priority = 6, msg_type = 1, edp = 0 // Extended Data Page
} = input;
const payload = Buffer.isBuffer(input.payload)
? input.payload
: Buffer.from(input.payload);
const dp = (pgn >> 16) & 0x01; // Data Page
const pf = (pgn >> 8) & 0xff; // PDU Format
let ps; // PDU Specific
if (pf < 240) {
/* PDU1 format, the PS contains the destination address */
ps = dst & 0xff;
}
else {
/* PDU2 format, the destination is implied global and the PGN is extended */
ps = pgn & 0xff;
}
const f1 = F1_SYNC_BIT |
((priority & 0x07) << 4) |
((edp & 0x01) << 3) |
((msg_type & 0x03) << 1) |
(dp & 0x01);
const len = payload.length;
let header;
if (msg_type === 3) {
header = Buffer.from([
FRAME_BINARY,
f1,
pf,
ps,
src & 0xff,
len & 0xff,
(len >> 8) & 0xff
]);
}
else {
if (len > 0xff) {
throw new Error(`Maretron payload of ${len} bytes requires msg_type=3 (Transport Protocol); got msg_type=${msg_type}`);
}
header = Buffer.from([FRAME_BINARY, f1, pf, ps, src & 0xff, len & 0xff]);
}
return Buffer.concat([header, payload]);
}
/**
* Build the 4-token CONNECT handshake message.
*
* The IPG strips a leading and trailing character from the password
* token before matching, so the password is always wrapped in double
* quotes on the wire. A stock IPG with no configured password is matched
* by the literal two-character string `""`.
*
* The 4th token is a client-type label that the IPG parses but does not
* act on. Hard-coded to "MOBILE" to match the convention used elsewhere.
*/
function buildConnectMessage(password) {
// The password is wrapped in quotes and tab-delimited; embedded
// quotes, tabs, NULs, or newlines would retokenize or truncate the
// CONNECT frame on the wire and the IPG would silently reject auth.
if (/["\t\0\r\n]/.test(password)) {
throw new Error('Maretron IPG password cannot contain quotes, tabs, or NUL/newline characters');
}
return Buffer.from(`CONNECT\t"${password}"\t\tMOBILE\0`, 'utf8');
}
exports.SET_MODE_BINARY = Buffer.from('SET_MODE\tBINARY\0', 'utf8');
function MaretronIPGStream(options = {}) {
// Support plain-function calls via CommonJS re-export — `this === undefined`
// doesn't hold there (it's the exports object), so we key off `new.target`.
if (new.target === undefined) {
return new MaretronIPGStream(options);
}
stream_1.Transform.call(this, { objectMode: true });
this.debug = (0, utilities_1.createDebug)('canboatjs:maretron-ipg', options);
this.debugOut = (0, utilities_1.createDebug)('canboatjs:n2k-out', options);
this.debugData = (0, utilities_1.createDebug)('canboatjs:maretron-ipg-data', options);
this.options = options;
this.host = options.host ?? 'ipg100';
this.port = options.port ?? exports.IPG_PORT;
this.password = options.password ?? '';
this.reconnect = options.reconnect !== false;
this.reconnectInitialMs =
options.reconnectInitialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
this.reconnectMaxMs = options.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
// Current delay for the next attempt — doubled after each failure,
// reset on CONNECTED. See scheduleReconnect.
this.reconnectDelayMs = this.reconnectInitialMs;
this.idleTeardownMs = options.idleTeardownMs ?? DEFAULT_IDLE_TEARDOWN_MS;
// Fail fast on initial connect for standalone use (CLI / scripts): a
// typo'd hostname or wrong port surfaces immediately. SignalK-hosted
// use (options.app present) flips this off — the IPG may come
// online minutes after signalk-server boots, so the provider keeps
// retrying. Callers can override either way.
this.failFastOnInitialConnect =
options.failFastOnInitialConnect ?? !options.app;
this.state = 'closed';
this.rx = Buffer.alloc(0);
this.lastDataAt = 0;
this.socket = null;
this.idleTimer = null;
this.reconnectTimer = null;
this.hasEverConnected = false;
this.authFailed = false;
this.setProviderStatus =
options.app && options.app.setProviderStatus
? (msg) => {
options.app.setProviderStatus(options.providerId, msg);
}
: () => { };
// Standalone use (no SignalK app) routes errors to stderr so socket-level
// failures during reconnect waits are visible. SignalK use stays on the
// app's setProviderError channel.
this.setProviderError =
options.app && options.app.setProviderError
? (msg) => {
options.app.setProviderError(options.providerId, msg);
}
: (msg) => {
console.error(`maretron-ipg: ${msg}`);
};
if (options.app) {
const outEvents = (options.outEvent ?? 'nmea2000out')
.split(',')
.map((e) => e.trim());
outEvents.forEach((event) => {
options.app.on(event, (msg) => {
if (typeof msg === 'string') {
this.sendString(msg);
}
else {
this.sendPGN(msg);
}
options.app.emit('connectionwrite', {
providerId: options.providerId
});
});
});
const jsonOutEvents = (options.jsonOutEvent ?? 'nmea2000JsonOut')
.split(',')
.map((e) => e.trim());
jsonOutEvents.forEach((event) => {
options.app.on(event, (msg) => {
this.sendPGN(msg);
options.app.emit('connectionwrite', {
providerId: options.providerId
});
});
});
}
this.debug(`MaretronIPGStream constructed host=${this.host} port=${this.port}`);
this.start();
}
util_1.default.inherits(MaretronIPGStream, stream_1.Transform);
MaretronIPGStream.prototype.start = function () {
if (this.socket) {
// Detach our handlers before destroying so the impending 'close'
// doesn't re-enter scheduleReconnect on top of the fresh start.
this.socket.removeAllListeners();
try {
this.socket.destroy();
}
catch {
// ignore
}
this.socket = null;
}
this.state = 'connecting';
this.rx = Buffer.alloc(0);
// Per-socket scratch — `error` and `close` fire as a pair, with the
// error always first. Closing over it here means the field doesn't
// outlive the socket and can't be confused for state about a later one.
let lastErr = null;
const factory = this.options._socketFactory ??
((host, port) => net_1.default.createConnection({ host, port }));
let socket;
try {
socket = factory(this.host, this.port);
}
catch (err) {
this.debug(`socket factory failed: ${err.message}`);
this.setProviderError(err.message);
this.scheduleReconnect(err);
return;
}
this.socket = socket;
socket.on('connect', () => {
this.debug(`TCP connected to ${this.host}:${this.port}`);
this.setProviderStatus(`Connected to ${this.host}:${this.port}`);
this.state = 'awaiting';
this.lastDataAt = Date.now();
const handshake = buildConnectMessage(this.password);
this.debugOut(`-> ${handshake.toString('utf8').replace(/\0/g, '\\0')}`);
socket.write(handshake);
this.startIdleTimer();
});
socket.on('data', (chunk) => {
this.lastDataAt = Date.now();
this.handleIncoming(chunk);
});
socket.on('error', (err) => {
this.debug(`socket error: ${err.message}`);
lastErr = err;
this.setProviderError(err.message);
});
socket.on('close', () => {
this.debug('socket closed');
this.stopIdleTimer();
this.state = 'closed';
// Suppress reconnect after auth failure — bad credentials won't get
// better by retrying, and in app mode this would otherwise loop forever.
if (!this.authFailed) {
this.scheduleReconnect(lastErr);
}
});
};
MaretronIPGStream.prototype.handleIncoming = function (chunk) {
this.rx = this.rx.length === 0 ? chunk : Buffer.concat([this.rx, chunk]);
// Drain framed messages until we run out of bytes or hit a partial frame.
while (this.rx.length > 0) {
const first = this.rx[0];
if (first === FRAME_BINARY) {
const result = parseMaretronFrame(this.rx, 0);
if (result.invalid) {
this.debug(`0xA5 with invalid F1 (0x${this.rx[1]?.toString(16)}); resyncing`);
this.rx = this.rx.subarray(1);
continue;
}
if (result.consumed === 0)
return; // need more bytes
this.rx = this.rx.subarray(result.consumed);
this.emitFrame(result.frame);
continue;
}
if (first === FRAME_VIDEO) {
const nul = this.rx.indexOf(NUL);
if (nul < 0)
return;
this.debug(`skipping video frame (${nul} bytes)`);
this.rx = this.rx.subarray(nul + 1);
continue;
}
if ((first & F1_SYNC_BIT) === 0) {
// ASCII / text control frame, NUL-terminated.
const nul = this.rx.indexOf(NUL);
if (nul < 0)
return;
const line = this.rx.subarray(0, nul).toString('utf8');
this.rx = this.rx.subarray(nul + 1);
if (line.length > 0)
this.handleText(line);
continue;
}
// High-bit set but not 0xA5 — out of sync. Drop one byte and retry.
this.debug(`out-of-sync byte 0x${first.toString(16)}; resyncing`);
this.rx = this.rx.subarray(1);
}
};
MaretronIPGStream.prototype.emitFrame = function (frame) {
if (this.debugData.enabled) {
this.debugData(`rx pgn=${frame.pgn} src=${frame.src} dst=${frame.dst} prio=${frame.priority} type=${frame.msg_type} len=${frame.payload_length}`);
}
this.emit('n2kFrame', frame);
// Pipeline payload: actisense-style canboat plain CSV. Downstream
// signalk-server pipelines consume this directly, and the Log
// provider records it as readable text.
const csv = (0, stringMsg_1.encodeActisense)({
pgn: frame.pgn,
prio: frame.priority,
src: frame.src,
dst: frame.dst,
data: frame.payload
});
if (this.options.app?.listenerCount?.('canboatjs:rawoutput') > 0) {
this.options.app.emit('canboatjs:rawoutput', csv);
}
this.push(csv);
};
MaretronIPGStream.prototype.handleText = function (line) {
const parts = line.split('\t');
const head = parts[0];
this.debug(`text: ${head}${parts.length > 1 ? '\t' + parts.slice(1).join('\t') : ''}`);
switch (head) {
case 'SERVER_VERSION':
this.serverVersion = parts[1];
this.serverProduct = parts[2]; // always "IPG100"
this.emit('version', this.serverVersion, this.serverProduct);
break;
case 'INSTANCE_DATA':
this.ipgBusAddress = parseInt(parts[1], 10);
this.clientInstance = parseInt(parts[2], 10);
this.emit('instance', this.ipgBusAddress, this.clientInstance);
break;
case 'CONNECTED':
// 4th and final handshake reply — switch to binary now.
this.deviceSerial = parts[1];
this.debugOut(`-> SET_MODE\\tBINARY\\0`);
this.socket?.write(exports.SET_MODE_BINARY);
this.state = 'streaming';
this.hasEverConnected = true;
this.reconnectDelayMs = this.reconnectInitialMs;
this.setProviderStatus(`Streaming from ${this.host}:${this.port} (serial ${this.deviceSerial ?? '?'})`);
if (this.options.app?.emit) {
this.options.app.emit('nmea2000OutAvailable');
}
this.emit('connected', { serial: this.deviceSerial });
break;
case 'NO':
this.debug('authentication failed');
this.setProviderError('Maretron IPG authentication failed (NO)');
this.authFailed = true;
this.emit('authfail');
this.socket?.end();
break;
case 'LICENSES_USED':
case 'DETAILED_LICENSES_USED':
case 'BASELICENSE':
case 'NOLICENSE':
case 'WAITING_TO_RECONNECT':
case 'DISCONNECTED':
case 'CONNECTING':
case 'CONNECTED_FILE':
case 'CATALOG':
case 'FILE_LENGTH':
case 'FILE_COMPLETE':
case 'MODES':
// informational — log only
break;
case '2':
case '3':
// ASCII-mode N2K data frame, arriving in the brief window between
// us sending SET_MODE BINARY and the IPG honoring it. Not useful
// because we don't know the field mappings, so we drop.
break;
default:
this.debug(`unrecognized text: ${head}`);
}
};
MaretronIPGStream.prototype.startIdleTimer = function () {
this.stopIdleTimer();
this.idleTimer = setInterval(() => {
if (this.state !== 'closed' &&
Date.now() - this.lastDataAt > this.idleTeardownMs) {
this.debug(`idle for ${this.idleTeardownMs}ms with no inbound data; tearing down`);
this.setProviderError('Idle teardown — no data received');
try {
this.socket?.end();
this.socket?.destroy();
}
catch {
// ignore
}
}
}, IDLE_CHECK_INTERVAL_MS);
if (this.idleTimer.unref)
this.idleTimer.unref();
};
MaretronIPGStream.prototype.stopIdleTimer = function () {
if (this.idleTimer) {
clearInterval(this.idleTimer);
this.idleTimer = null;
}
};
MaretronIPGStream.prototype.scheduleReconnect = function (lastErr) {
if (!this.reconnect)
return;
if (this.reconnectTimer)
return;
if (!this.hasEverConnected && this.failFastOnInitialConnect) {
// Standalone use (CLI, no SignalK app): initial connect failed
// (bad host, refused port, DNS error). Surface the underlying
// socket error and don't loop — the operator needs to fix the
// address. SignalK-hosted use keeps retrying instead (the IPG
// may not be online at server boot).
const err = lastErr ??
new Error(`Failed to connect to Maretron IPG at ${this.host}:${this.port}`);
if (this.listenerCount('error') > 0) {
this.emit('error', err);
}
else {
// No listener — log instead of crashing the process with an
// unhandled 'error' event.
console.error(`maretron-ipg: ${err.message}`);
}
return;
}
const delay = this.reconnectDelayMs;
this.debug(`scheduling reconnect in ${delay}ms`);
this.setProviderStatus(`Reconnecting in ${(delay / 1000).toFixed(0)}s`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.start();
}, delay);
// Exponential backoff: 1→2→4→8→16→32 s by default. Reset to
// reconnectInitialMs in the CONNECTED handler so the next disconnect
// starts fresh.
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
// Intentionally not unref'd — after a successful first session this
// timer is the only thing keeping a standalone process alive across
// the reconnect gap. Unref'ing would let Node exit before the retry
// fires, defeating reconnect: true in CLI mode.
};
MaretronIPGStream.prototype.sendPGN = function (pgn) {
if (this.state !== 'streaming') {
this.debug(`sendPGN ${pgn.pgn} dropped — not streaming yet`);
return;
}
const data = (0, toPgn_1.toPgn)(pgn);
if (!data) {
this.debug(`toPgn returned no data for pgn ${pgn.pgn}`);
return;
}
// Single-frame CAN payloads fit in 8 bytes; anything larger goes
// out as Fast Packet (msg_type=2). We never emit msg_type=3 (ISO
// Transport Protocol) from the client side — the IPG handles
// fast-packet bucket splitting itself, and tagging an outbound
// message as Transport Protocol would push it into a different
// bus-side TX path that's not appropriate for ordinary client
// traffic. The 16-bit length encoding in build/parse exists only
// for inbound frames the IPG generates.
const msg_type = data.length > 8 ? 2 : 1;
const dst = pgn.dst ?? 0xff;
const prio = pgn.prio ?? 6;
const frame = buildMaretronFrame({
pgn: pgn.pgn,
// Always 0xFF — the IPG substitutes its own claimed SA.
src: 0xff,
dst,
priority: prio,
msg_type,
payload: data
});
this.writeFrame(frame, { pgn: pgn.pgn, dst, prio });
};
/**
* Send via a canboat plain-CSV string:
* `YYYY-MM-DD-HH:MM:SS.mmm,prio,pgn,src,dst,len,b0,b1,…`
*
* Source-address from the caller is ignored — the IPG decides.
*/
MaretronIPGStream.prototype.sendString = function (msg) {
if (this.state !== 'streaming') {
this.debug(`sendString dropped — not streaming yet`);
return;
}
const parsed = (0, stringMsg_1.parseActisense)(msg);
if (!parsed || parsed.error) {
this.debug(`sendString ignored — ${parsed?.error ?? 'parse failed'}: ${msg}`);
return;
}
const { prio, pgn, dst, data } = parsed;
// See sendPGN: never emit msg_type=3 from the client side.
const msg_type = data.length > 8 ? 2 : 1;
const frame = buildMaretronFrame({
pgn,
src: 0xff,
dst,
priority: prio,
msg_type,
payload: data
});
this.writeFrame(frame, { pgn, prio, dst });
};
MaretronIPGStream.prototype.writeFrame = function (frame, ctx) {
if (!this.socket || this.state !== 'streaming') {
this.debug(`writeFrame dropped — state=${this.state}`);
return;
}
if (this.debugOut.enabled) {
this.debugOut(`tx pgn=${ctx?.pgn} dst=${ctx?.dst} prio=${ctx?.prio} bytes=${frame.length}`);
}
if (this.options.app?.listenerCount?.('canboatjs:rawsend') > 0) {
this.options.app.emit('canboatjs:rawsend', { data: frame });
}
this.socket.write(frame);
};
MaretronIPGStream.prototype._transform = function (chunk, _encoding, done) {
// Allow callers to also pipe raw bytes in (used by the unit tests that
// don't open a real TCP socket).
if (Buffer.isBuffer(chunk)) {
this.handleIncoming(chunk);
}
done();
};
MaretronIPGStream.prototype.end = function () {
// Must come before socket.destroy(): the async 'close' event will call
// scheduleReconnect, which short-circuits when this.reconnect is false.
this.reconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopIdleTimer();
if (this.socket) {
try {
this.socket.end();
this.socket.destroy();
}
catch {
// ignore
}
this.socket = null;
}
this.state = 'closed';
};
//# sourceMappingURL=maretron-ipg.js.map