@canboat/canboatjs
Version:
Native javascript version of canboat
464 lines • 16.5 kB
JavaScript
"use strict";
/**
* Copyright 2025 Scott Bender <scott@scottbender.net>
*
* 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.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.N2kIpGateway = N2kIpGateway;
const stream_1 = require("stream");
const net_1 = __importDefault(require("net"));
const lodash_1 = __importDefault(require("lodash"));
const util_1 = __importDefault(require("util"));
const candevice_1 = require("./candevice");
const canId_1 = require("./canId");
const toPgn_1 = require("./toPgn");
const stringMsg_1 = require("./stringMsg");
const utilities_1 = require("./utilities");
const DEFAULT_PORT = 2599;
const DEFAULT_RECONNECT_MS = 5000;
const DEFAULT_FORMAT = 'candump3';
// Anything before 2000-01-01 UTC is treated as the device's uptime clock,
// not wall-clock time, and dropped so the downstream analyzer can fall
// back to `new Date()`. This is needed because devices like the SensESP
// candump gateway fall back to uptime when their RTC has not been synced
// (no NTP yet), and stamping deltas with 1970-01-01 + uptime ends up in
// historical timeseries databases as either way-future or way-past rows.
const MIN_VALID_TIMESTAMP_MS = Date.UTC(2000, 0, 1);
// Convert a candump3-style "(sec.usec)" or "(sec.usec) " timestamp into an
// ISO-8601 string. Returns undefined if the input doesn't look like a
// candump epoch timestamp, or if the value clearly comes from a device
// whose clock has not been set — the caller should drop the field in
// either case.
function candumpTimestampToIso(ts) {
const m = /^\(?(\d+)(?:\.(\d+))?\)?$/.exec(ts);
if (!m)
return undefined;
const sec = Number(m[1]);
if (!Number.isFinite(sec))
return undefined;
// Pad the fractional part to microseconds so we always interpret it as such.
const usec = m[2] ? Number(m[2].slice(0, 6).padEnd(6, '0')) : 0;
if (!Number.isFinite(usec))
return undefined;
const ms = sec * 1000 + Math.floor(usec / 1000);
if (ms < MIN_VALID_TIMESTAMP_MS)
return undefined;
const d = new Date(ms);
if (Number.isNaN(d.getTime()))
return undefined;
return d.toISOString();
}
function encodeCandump3Line(pgn, _canid, buffer) {
return (0, stringMsg_1.encodeCandump3)({
data: buffer,
pgn: pgn.pgn,
src: pgn.src,
dst: pgn.dst,
prio: pgn.prio
});
}
function encodeCandump2Line(pgn, _canid, buffer) {
return (0, stringMsg_1.encodeCandump2)({
data: buffer,
pgn: pgn.pgn,
src: pgn.src,
dst: pgn.dst,
prio: pgn.prio
});
}
function encodeCandump1Line(pgn, _canid, buffer) {
return (0, stringMsg_1.encodeCandump1)({
data: buffer,
pgn: pgn.pgn,
src: pgn.src,
dst: pgn.dst,
prio: pgn.prio
});
}
function encodeYDRAWLine(pgn, _canid, buffer) {
return (0, stringMsg_1.encodeYDRAW)({
data: buffer,
pgn: pgn.pgn,
src: pgn.src,
dst: pgn.dst,
prio: pgn.prio
});
}
function encodeActisenseLine(pgn, _canid, _buffer) {
// Actisense format carries the entire (possibly long) PGN payload — no
// fast-packet split needed. The caller passes the full unsplit buffer.
return (0, toPgn_1.pgnToN2KActisenseFormat)(pgn);
}
function encodeActisenseN2KAsciiLine(pgn, _canid, _buffer) {
return (0, toPgn_1.pgnToActisenseN2KAsciiFormat)(pgn);
}
function encodePCDINLine(pgn, _canid, buffer) {
return (0, stringMsg_1.encodePCDIN)({
pgn: pgn.pgn,
data: buffer,
src: pgn.src,
dst: pgn.dst,
prio: pgn.prio
});
}
const FORMATS = {
candump3: {
parse: stringMsg_1.parseCandump3,
encode: encodeCandump3Line,
splitFastPacket: true,
terminator: '\n'
},
candump2: {
parse: stringMsg_1.parseCandump2,
encode: encodeCandump2Line,
splitFastPacket: true,
terminator: '\n'
},
candump1: {
parse: stringMsg_1.parseCandump1,
encode: encodeCandump1Line,
splitFastPacket: true,
terminator: '\n'
},
ydraw: {
parse: stringMsg_1.parseYDRAW,
encode: encodeYDRAWLine,
splitFastPacket: true,
terminator: '\r\n'
},
actisense: {
parse: stringMsg_1.parseActisense,
encode: encodeActisenseLine,
splitFastPacket: false,
terminator: '\r\n'
},
'actisense-n2k-ascii': {
parse: stringMsg_1.parseActisenseN2KASCII,
encode: encodeActisenseN2KAsciiLine,
splitFastPacket: false,
terminator: '\r\n'
},
pcdin: {
parse: stringMsg_1.parsePCDIN,
encode: encodePCDINLine,
splitFastPacket: false,
terminator: '\r\n'
}
};
function N2kIpGateway(options) {
if (this === undefined) {
return new N2kIpGateway(options);
}
stream_1.Transform.call(this, { objectMode: true });
if (!options || !options.host) {
throw new Error('N2kIpGateway: options.host is required');
}
this.options = options;
this.host = options.host;
this.port = options.port ?? DEFAULT_PORT;
this.reconnectIntervalMs = options.reconnectIntervalMs ?? DEFAULT_RECONNECT_MS;
const fmt = (options.format ?? DEFAULT_FORMAT);
if (!FORMATS[fmt]) {
throw new Error(`N2kIpGateway: unsupported format "${fmt}"`);
}
this.format = fmt;
this.formatSpec = FORMATS[fmt];
// Default is "be a CAN device" — matches the user's KISS expectation.
this.actAsCanDevice = options.actAsCanDevice ?? true;
this.debug = (0, utilities_1.createDebug)('canboatjs:n2k-ip-gateway', options);
this.debugData = (0, utilities_1.createDebug)('canboatjs:n2k-ip-gateway-data', options);
this.debugOut = (0, utilities_1.createDebug)('canboatjs:n2k-out', options);
this.plainText = false;
this.rxBuffer = '';
this.reconnecting = false;
this.stopping = false;
this.sentAvailable = false;
this.setProviderStatus =
options.app && options.app.setProviderStatus
? (msg) => options.app.setProviderStatus(options.providerId, msg)
: () => { };
this.setProviderError =
options.app && options.app.setProviderError
? (msg) => options.app.setProviderError(options.providerId, msg)
: () => { };
if (options.app) {
const outEvents = (options.outEvent || 'nmea2000out')
.split(',')
.map((event) => event.trim());
outEvents.forEach((event) => {
options.app.on(event, (msg) => {
this.sendPGN(msg, false);
});
});
const jsonOutEvents = (options.jsonOutEvent || 'nmea2000JsonOut')
.split(',')
.map((event) => event.trim());
jsonOutEvents.forEach((event) => {
options.app.on(event, (msg) => {
this.sendPGN(msg, false);
});
});
}
this.connect();
}
util_1.default.inherits(N2kIpGateway, stream_1.Transform);
N2kIpGateway.prototype.connect = function () {
if (this.reconnecting || this.stopping) {
return;
}
this.reconnecting = true;
this.debug(`connecting to ${this.host}:${this.port}`);
const socket = new net_1.default.Socket();
this.socket = socket;
const onError = (err) => {
this.debug(`socket error: ${err.message}`);
this.setProviderError(err.message);
};
const onClose = () => {
this.debug('socket closed');
if (this.candevice) {
try {
this.candevice.stop();
}
catch (_e) {
// best-effort cleanup
}
this.candevice = undefined;
}
this.socket = undefined;
this.reconnecting = false;
if (!this.stopping) {
this.setProviderError('disconnected, reconnecting...');
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectIntervalMs);
}
};
socket.on('error', onError);
socket.on('close', onClose);
socket.on('data', (chunk) => this._onData(chunk));
socket.connect(this.port, this.host, () => {
if (this.stopping || this.socket !== socket) {
// We were torn down (or replaced) before the connect callback fired —
// don't spin up a CanDevice whose timers would then leak.
return;
}
this.debug(`connected to ${this.host}:${this.port}`);
this.setProviderStatus(`Connected to ${this.host}:${this.port}`);
this.reconnecting = false;
if (this.actAsCanDevice && this.options.app && !this.candevice) {
this.candevice = new candevice_1.CanDevice(this, this.options);
this.candevice.start();
}
if (!this.sentAvailable && this.options.app) {
this.options.app.emit('nmea2000OutAvailable');
this.sentAvailable = true;
}
});
};
N2kIpGateway.prototype._onData = function (chunk) {
this.rxBuffer += chunk.toString('utf8');
let nl;
while ((nl = this.rxBuffer.indexOf('\n')) >= 0) {
let line = this.rxBuffer.slice(0, nl);
this.rxBuffer = this.rxBuffer.slice(nl + 1);
if (line.endsWith('\r')) {
line = line.slice(0, -1);
}
if (line.length === 0)
continue;
this._handleLine(line);
}
};
N2kIpGateway.prototype._handleLine = function (line) {
if (this.debugData.enabled) {
this.debugData(line);
}
let parsed;
try {
parsed = this.formatSpec.parse(line);
}
catch (e) {
this.debug(`parse error (${this.format}): ${e.message}`);
return;
}
if (!parsed || parsed.error || !parsed.data) {
return;
}
// parseXxx() returns a flat CanID-like object: { pgn, src, dst, prio, data }.
// Downstream consumers expect { pgn: <that object>, length, data }.
const data = parsed.data;
// Normalize the per-frame timestamp. parseCandump3() puts the raw
// "(<sec>.<usec>)" string in parsed.timestamp; downstream code that does
// `new Date(parsed.timestamp)` would get an Invalid Date and produce NaN
// when consumers (e.g. ILP writers) try to extract a numeric timestamp.
// Convert to an ISO string if we can, otherwise drop the field so the
// analyzer falls back to its own `new Date()`.
if (typeof parsed.timestamp === 'string') {
const iso = candumpTimestampToIso(parsed.timestamp);
if (iso) {
parsed.timestamp = iso;
}
else {
delete parsed.timestamp;
}
}
// Drop frames we sent ourselves so we don't re-process loopback / echoed
// outbound traffic as if it were bus input.
if (this.candevice &&
this.candevice.cansend &&
parsed.src === this.candevice.address) {
return;
}
const out = { pgn: parsed, length: data.length, data };
if (this.options.app &&
this.options.app.listenerCount &&
this.options.app.listenerCount('canboatjs:rawoutput') > 0) {
this.options.app.emit('canboatjs:rawoutput', out);
}
this.push(out);
};
N2kIpGateway.prototype.send = function (line) {
if (!this.socket) {
this.debug('drop send: socket not connected');
return;
}
this.debugOut('sending %s', line);
this.socket.write(line + this.formatSpec.terminator);
};
N2kIpGateway.prototype.sendPGN = function (msg, force) {
if (this.actAsCanDevice) {
if (!this.candevice) {
// Not connected yet — drop. The server will retry as needed.
return;
}
if (!this.candevice.cansend && force !== true) {
// Address claim not yet completed; only CanDevice's own probes are
// allowed through with force=true.
return;
}
}
if (this.options.app) {
this.options.app.emit('connectionwrite', {
providerId: this.options.providerId
});
}
let pgn;
if (lodash_1.default.isString(msg)) {
// Inbound message is in some text format. We accept it as-is when it
// already matches our wire format; otherwise reparse via Actisense (the
// canonical comma-delimited form used by signalk-server).
pgn = (0, stringMsg_1.parseActisense)(msg);
}
else {
pgn = msg;
if (lodash_1.default.isUndefined(pgn.prio))
pgn.prio = 3;
if (lodash_1.default.isUndefined(pgn.dst))
pgn.dst = 255;
if (this.actAsCanDevice && this.candevice && !pgn.forceSrc) {
pgn.src = this.candevice.address;
}
else if (lodash_1.default.isUndefined(pgn.src)) {
pgn.src = 0;
}
}
// Prefer an already-encoded buffer when the caller supplied one — this lets
// upstream code forward frames that have already been serialized (e.g.
// verbatim relays from another analyzer) without re-encoding fields.
let buffer;
if (lodash_1.default.isString(msg)) {
buffer = pgn.data;
}
else if (Buffer.isBuffer(pgn.data)) {
buffer = pgn.data;
}
else {
buffer = (0, toPgn_1.toPgn)(pgn);
}
if (!buffer) {
this.debug("can't convert %j", msg);
return;
}
const canid = (0, canId_1.encodeCanId)(pgn);
let payloads;
if (this.formatSpec.splitFastPacket &&
(buffer.length > 8 || pgn.pgn === 126720)) {
payloads = (0, utilities_1.getPlainPGNs)(buffer);
}
else {
payloads = [buffer];
}
payloads.forEach((payload) => {
const encoded = this.formatSpec.encode(pgn, canid, payload);
const lines = Array.isArray(encoded) ? encoded : [encoded];
lines.forEach((line) => this.send(line));
if (this.options.app &&
this.options.app.listenerCount &&
this.options.app.listenerCount('canboatjs:rawsend') > 0) {
this.options.app.emit('canboatjs:rawsend', {
knownSrc: true,
data: { pgn, length: payload.length, data: (0, utilities_1.byteStringArray)(payload) }
});
}
// Forward self-emitted device info back into the analyzer pipe so the
// server sees its own address claim / product info / config info, the
// same way CanbusStream does. CRITICALLY this must be done once per
// per-frame `payload` (≤ 8 bytes), not once with the full reassembled
// buffer — handing a > 8 byte chunk to FromPgn flips the analyzer
// into FORMAT_COALESCED for the rest of the session and corrupts
// every subsequent fast-packet reassembly (e.g. AIS PGN 129038/9
// bytes get read at the wrong offset, producing phantom vessels).
if (pgn.pgn === 60928 || pgn.pgn === 126996 || pgn.pgn === 126998) {
this.push({ pgn, length: payload.length, data: payload });
}
});
};
N2kIpGateway.prototype._transform = function (_chunk, _encoding, done) {
done();
};
N2kIpGateway.prototype.pipe = function (pipeTo) {
if (!pipeTo.fromPgn) {
this.plainText = true;
}
return N2kIpGateway.super_.prototype.pipe.call(this, pipeTo);
};
N2kIpGateway.prototype.end = function () {
this.stopping = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
if (this.candevice) {
try {
this.candevice.stop();
}
catch (_e) {
// best-effort
}
this.candevice = undefined;
}
if (this.socket) {
try {
this.socket.end();
this.socket.destroy();
}
catch (_e) {
// best-effort
}
this.socket = undefined;
}
};
//# sourceMappingURL=n2kIpGateway.js.map