@canboat/canboatjs
Version:
Native javascript version of canboat
349 lines • 13.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const events_1 = require("events");
// A minimal stand-in for net.Socket. The constructor (called via `new
// net.Socket()` inside the gateway) hands the test a reference via the global
// hook below so the test can drive its data events and inspect everything
// that's written to it.
class MockSocket extends events_1.EventEmitter {
written = [];
connectedTo;
destroyed = false;
connect(port, host, cb) {
this.connectedTo = { port, host };
// Defer to the next tick so listeners attached after .connect() still see
// subsequent events.
setImmediate(cb);
return this;
}
write(chunk) {
this.written.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
return true;
}
end() { }
destroy() {
this.destroyed = true;
}
// Test helpers
feed(data) {
this.emit('data', Buffer.from(data, 'utf8'));
}
}
let lastSocket;
jest.mock('net', () => ({
Socket: jest.fn().mockImplementation(() => {
lastSocket = new MockSocket();
return lastSocket;
})
}));
// Avoid touching the real on-disk persisted unique number when CanDevice
// starts up — it writes/reads from process cwd which we don't want in tests.
jest.mock('./persist', () => ({
getPersistedData: jest.fn(() => undefined),
savePersistedData: jest.fn()
}));
const n2kIpGateway_1 = require("./n2kIpGateway");
function makeApp() {
const app = new events_1.EventEmitter();
app.setProviderStatus = jest.fn();
app.setProviderError = jest.fn();
return app;
}
async function waitForConnect() {
// setImmediate inside MockSocket.connect — flush microtasks + one macrotask.
await new Promise((r) => setImmediate(r));
}
describe('N2kIpGateway', () => {
const created = [];
beforeEach(() => {
lastSocket = undefined;
created.length = 0;
});
afterEach(() => {
// Tear down any gateways created during the test so their CanDevice's
// setTimeout(0) for address-claim and any reconnect timers don't keep
// the Jest event loop alive.
while (created.length) {
const gw = created.pop();
try {
gw.end();
}
catch (_e) {
// ignore
}
}
});
function newGateway(opts) {
const gw = new n2kIpGateway_1.N2kIpGateway(opts);
created.push(gw);
return gw;
}
test('requires options.host', () => {
expect(() => new n2kIpGateway_1.N2kIpGateway({})).toThrow(/host/);
});
test('rejects unknown format', () => {
expect(() => new n2kIpGateway_1.N2kIpGateway({
app: makeApp(),
host: 'h',
format: 'totally-fake',
actAsCanDevice: false
})).toThrow(/unsupported format/);
});
test('connects to default port 2599', async () => {
newGateway({
app: makeApp(),
host: 'gw.local',
actAsCanDevice: false
});
await waitForConnect();
expect(lastSocket.connectedTo).toEqual({ port: 2599, host: 'gw.local' });
});
test('honors explicit port', async () => {
newGateway({
app: makeApp(),
host: 'gw.local',
port: 9999,
actAsCanDevice: false
});
await waitForConnect();
expect(lastSocket.connectedTo).toEqual({ port: 9999, host: 'gw.local' });
});
test('RX candump3: parses a line and pushes a frame downstream', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
// CAN ID 09F11274 = prio 2, PGN 127250, src 116, dst (PDU2) implied 255.
lastSocket.feed('(1502979132.106111) can0 09F11274#0001020304050607\n');
expect(pushed).toHaveLength(1);
expect(pushed[0].pgn.pgn).toBe(127250);
expect(pushed[0].pgn.src).toBe(0x74);
expect(pushed[0].pgn.prio).toBe(2);
expect(pushed[0].data.length).toBe(8);
expect(pushed[0].data.toString('hex')).toBe('0001020304050607');
});
test('RX candump3: rewrites the raw "(sec.usec)" timestamp to an ISO date', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
// 1502979132.106111 → 2017-08-17T14:12:12.106Z
lastSocket.feed('(1502979132.106111) can0 09F11274#0001020304050607\n');
expect(pushed).toHaveLength(1);
expect(pushed[0].pgn.timestamp).toBe('2017-08-17T14:12:12.106Z');
expect(new Date(pushed[0].pgn.timestamp).getTime()).not.toBeNaN();
});
test('RX candump3: drops timestamps from devices with unsynced clocks (pre-2000)', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
// Epoch 56702 = uptime-derived timestamp (1970-01-01 + ~15h45m), which
// is what the SensESP gateway emits before NTP has synced. We drop it
// so downstream code falls back to the server's own clock.
lastSocket.feed('(56702.123456) can0 09F11274#0001020304050607\n');
expect(pushed).toHaveLength(1);
expect(pushed[0].pgn.timestamp).toBeUndefined();
});
test('RX candump3: drops the timestamp field when it cannot be parsed', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
// Pathological line: "(invalid)" — the parser will still split it, but
// the timestamp should be dropped (not left as Invalid Date) so the
// analyzer can fall back to "now".
lastSocket.feed('(invalid) can0 09F11274#0001020304050607\n');
expect(pushed).toHaveLength(1);
expect(pushed[0].pgn.timestamp).toBeUndefined();
});
test('RX candump3: re-buffers partial lines across socket chunks', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
lastSocket.feed('(1502979132.106111) can0 09F1127');
expect(pushed).toHaveLength(0);
lastSocket.feed('4#0001020304050607\n');
expect(pushed).toHaveLength(1);
});
test('TX candump3: encodes outbound PGN to a candump line on the socket', async () => {
const app = makeApp();
const gw = newGateway({
app,
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
// Drive the sendPGN path through a raw Buffer in `data` so this test
// doesn't depend on field encoding for any specific PGN definition.
gw.sendPGN({
pgn: 127245,
prio: 2,
src: 17,
dst: 255,
data: Buffer.from('0001020304050607', 'hex')
});
expect(lastSocket.written.length).toBeGreaterThanOrEqual(1);
const line = lastSocket.written[0];
// Shape: "(<ts>) <iface> <8 hex CAN ID>#<hex data>\n". The CAN ID is
// lower-case (canIdString), the hex bytes are upper-case (encodeCandump3).
expect(line).toMatch(/^\([0-9.]+\) \S+ [0-9a-f]{8}#[0-9A-Fa-f]+\n$/);
});
test('TX candump3: splits fast-packet payloads into multiple lines', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
// 12-byte raw payload — > 8 bytes triggers the fast-packet split.
gw.sendPGN({
pgn: 129029,
prio: 3,
src: 17,
dst: 255,
data: Buffer.from('000102030405060708090a0b', 'hex')
});
expect(lastSocket.written.length).toBeGreaterThan(1);
lastSocket.written.forEach((line) => {
expect(line).toMatch(/^\([0-9.]+\) \S+ [0-9a-f]{8}#[0-9A-Fa-f]+\n$/);
});
});
test('actAsCanDevice: true creates a CanDevice and emits address claim', async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
try {
const app = makeApp();
const gw = newGateway({
app,
providerId: 't',
host: 'gw.local',
format: 'candump3',
actAsCanDevice: true,
manufacturerCode: 999,
uniqueNumber: 12345,
preferredAddress: 100
});
await waitForConnect();
// n2kDevice.start() schedules sendAddressClaim() via setTimeout(1000);
// advance fake timers to fire it.
jest.advanceTimersByTime(1100);
expect(gw.candevice).toBeDefined();
// Address claim is PGN 60928. In the encoded CAN ID it appears as
// PDU1 form `prio EE dst src`, e.g. "18eeff64" (dst=255, src=100).
const seenClaim = lastSocket.written.some((line) => /\s[0-9a-f]{2}ee[0-9a-f]{2}64#/i.test(line));
expect(seenClaim).toBe(true);
}
finally {
jest.useRealTimers();
}
});
test('actAsCanDevice: false skips CanDevice and still encodes outbound PGNs', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
expect(gw.candevice).toBeUndefined();
gw.sendPGN({
pgn: 127245,
prio: 2,
src: 17,
dst: 255,
data: Buffer.from('0001020304050607', 'hex')
});
expect(lastSocket.written.length).toBeGreaterThanOrEqual(1);
});
test('format selection: ydraw uses YDRAW encoder', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'ydraw',
actAsCanDevice: false
});
await waitForConnect();
gw.sendPGN({
pgn: 127245,
prio: 2,
src: 17,
dst: 255,
data: Buffer.from('0001020304050607', 'hex')
});
// YDRAW: "<CANID> <hex bytes space-separated>\r\n", no leading "(".
expect(lastSocket.written[0]).not.toMatch(/^\(/);
expect(lastSocket.written[0]).toMatch(/^[0-9a-f]{8} ([0-9A-Fa-f]{2} ?)+\r\n$/);
});
test('TX self-PGN forwarded to analyzer is split per CAN frame, never > 8 bytes', async () => {
// Regression: a 134-byte PGN 126996 (Product Info) had been pushed
// downstream as a single chunk with length=134, which flipped the
// canboatjs FromPgn instance into FORMAT_COALESCED for the rest of
// the session and corrupted fast-packet reassembly for unrelated
// PGNs (e.g. AIS 129038/129039 — producing phantom vessels with
// misaligned MMSI bytes).
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const pushed = [];
gw.on('data', (frame) => pushed.push(frame));
// 32-byte raw "Product Info" payload — over 8 bytes so it must split.
const raw = Buffer.from('0102030405060708090a0b0c0d0e0f1011121314151617181920212223', 'hex');
gw.sendPGN({
pgn: 126996,
prio: 6,
src: 17,
dst: 255,
data: raw
});
expect(pushed.length).toBeGreaterThan(1); // multi-frame
pushed.forEach((frame) => {
expect(frame.length).toBeLessThanOrEqual(8);
expect(frame.data.length).toBeLessThanOrEqual(8);
});
});
test('end() closes socket', async () => {
const gw = newGateway({
app: makeApp(),
host: 'gw.local',
format: 'candump3',
actAsCanDevice: false
});
await waitForConnect();
const sock = lastSocket;
gw.end();
expect(sock.destroyed).toBe(true);
});
});
//# sourceMappingURL=n2kIpGateway.test.js.map