UNPKG

@canboat/canboatjs

Version:

Native javascript version of canboat

464 lines 16.5 kB
"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