Implement Phase 1 tasks 1.1-1.4 (scaffold + core shell + Teltonika framing)
- Project scaffold (Node 22 + TS 5 + pnpm + vitest + ESLint flat config) - Core shell: TCP server, session loop, adapter registry, types - Configuration (zod-validated env) and pino logger - Teltonika adapter: IMEI handshake, frame envelope, CRC-16/IBM, codec dispatch registry, DeviceAuthority seam (AllowAllAuthority default) Codec data parsers (1.5-1.7), Redis publisher (1.8), and downstream tasks remain. 36 tests covering CRC, framing, handshake, device authority, config, and core server. typecheck/lint/test/build all clean.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import type * as net from 'node:net';
|
||||
import type { Adapter, AdapterContext } from '../../core/types.js';
|
||||
import type { DeviceAuthority } from './device-authority.js';
|
||||
import { AllowAllAuthority } from './device-authority.js';
|
||||
import { readImeiHandshake, HandshakeError } from './handshake.js';
|
||||
import { BufferedReader, readNextFrame, FrameDropError } from './frame.js';
|
||||
import { CodecRegistry } from './codec/registry.js';
|
||||
|
||||
export type TeltonikaAdapterOptions = {
|
||||
readonly port: number;
|
||||
readonly deviceAuthority?: DeviceAuthority;
|
||||
readonly strictDeviceAuth?: boolean;
|
||||
readonly codecRegistry?: CodecRegistry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and returns the Teltonika adapter. The adapter:
|
||||
* 1. Performs the IMEI handshake (reads; consults DeviceAuthority; writes 0x01/0x00)
|
||||
* 2. Runs the AVL frame read loop (preamble → length → body → CRC → dispatch)
|
||||
* 3. ACKs accepted frames with the 4-byte big-endian record count
|
||||
*
|
||||
* Codec handlers are registered externally and passed in via `codecRegistry`.
|
||||
* Tasks 1.5–1.7 populate the registry; this task ships it empty (any frame
|
||||
* triggers the unknown-codec path and drops the connection, per spec).
|
||||
*/
|
||||
export function createTeltonikaAdapter(options: TeltonikaAdapterOptions): Adapter {
|
||||
const authority: DeviceAuthority = options.deviceAuthority ?? new AllowAllAuthority();
|
||||
const strictDeviceAuth = options.strictDeviceAuth ?? false;
|
||||
const codecRegistry = options.codecRegistry ?? new CodecRegistry();
|
||||
|
||||
return {
|
||||
name: 'teltonika',
|
||||
ports: [options.port],
|
||||
|
||||
async handleSession(socket: net.Socket, ctx: AdapterContext): Promise<void> {
|
||||
// ------------------------------------------------------------------ //
|
||||
// Phase 1: IMEI handshake
|
||||
// ------------------------------------------------------------------ //
|
||||
let imei: string;
|
||||
|
||||
try {
|
||||
imei = await readImeiHandshake(socket);
|
||||
} catch (err) {
|
||||
if (err instanceof HandshakeError) {
|
||||
ctx.logger.warn(
|
||||
{ err, raw_bytes: err.rawBytes },
|
||||
'IMEI handshake failed; destroying socket',
|
||||
);
|
||||
} else {
|
||||
ctx.logger.warn({ err }, 'unexpected error during IMEI handshake');
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionLogger = ctx.logger.child({ imei });
|
||||
|
||||
// Consult DeviceAuthority — errors default to 'unknown' (safe, observable)
|
||||
let knownLabel: 'known' | 'unknown';
|
||||
try {
|
||||
knownLabel = await authority.check(imei);
|
||||
} catch (authorityErr) {
|
||||
sessionLogger.warn(
|
||||
{ err: authorityErr },
|
||||
'DeviceAuthority.check failed; defaulting to unknown',
|
||||
);
|
||||
knownLabel = 'unknown';
|
||||
}
|
||||
|
||||
ctx.metrics.inc('teltonika_handshake_total', {
|
||||
result: 'accepted',
|
||||
known: knownLabel,
|
||||
});
|
||||
|
||||
if (knownLabel === 'unknown' && strictDeviceAuth) {
|
||||
// Reject path (opt-in via STRICT_DEVICE_AUTH)
|
||||
socket.write(Buffer.from([0x00]));
|
||||
sessionLogger.warn({ imei }, 'rejected unknown device under STRICT_DEVICE_AUTH');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept the device
|
||||
socket.write(Buffer.from([0x01]));
|
||||
sessionLogger.debug({ known: knownLabel }, 'IMEI handshake accepted');
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Phase 2: AVL frame read loop
|
||||
// ------------------------------------------------------------------ //
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
while (!socket.destroyed) {
|
||||
let frame;
|
||||
try {
|
||||
frame = await readNextFrame(reader);
|
||||
} catch (err) {
|
||||
if (err instanceof FrameDropError) {
|
||||
if (err.reason === 'socket_closed') {
|
||||
// Normal disconnect — no warning needed
|
||||
sessionLogger.debug('socket closed during frame read');
|
||||
} else {
|
||||
sessionLogger.warn(
|
||||
{ reason: err.reason, err },
|
||||
'malformed frame; dropping connection',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sessionLogger.warn({ err }, 'unexpected error reading frame; dropping connection');
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frame.crcValid) {
|
||||
sessionLogger.warn(
|
||||
{
|
||||
expected_crc: `0x${frame.expectedCrc.toString(16).padStart(4, '0')}`,
|
||||
computed_crc: `0x${frame.computedCrc.toString(16).padStart(4, '0')}`,
|
||||
frame_length: frame.payload.length,
|
||||
},
|
||||
'CRC mismatch; not ACKing (device will retransmit)',
|
||||
);
|
||||
ctx.metrics.inc('teltonika_frames_total', {
|
||||
codec: `0x${frame.codecId.toString(16)}`,
|
||||
result: 'crc_fail',
|
||||
});
|
||||
// Do NOT ACK — connection stays open for device retransmit
|
||||
continue;
|
||||
}
|
||||
|
||||
const handler = codecRegistry.get(frame.codecId);
|
||||
if (handler === undefined) {
|
||||
sessionLogger.warn(
|
||||
{
|
||||
codec_id: `0x${frame.codecId.toString(16).padStart(2, '0')}`,
|
||||
header: frame.payload.subarray(0, 16).toString('hex'),
|
||||
},
|
||||
'unknown codec; dropping connection',
|
||||
);
|
||||
ctx.metrics.inc('teltonika_unknown_codec_total', {
|
||||
codec_id: `0x${frame.codecId.toString(16).padStart(2, '0')}`,
|
||||
});
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let result: { recordCount: number };
|
||||
try {
|
||||
result = await handler.handle(frame.payload, {
|
||||
imei,
|
||||
publish: ctx.publish,
|
||||
logger: sessionLogger,
|
||||
});
|
||||
} catch (handlerErr) {
|
||||
sessionLogger.warn(
|
||||
{ err: handlerErr, codec_id: `0x${frame.codecId.toString(16).padStart(2, '0')}` },
|
||||
'codec handler threw; dropping connection',
|
||||
);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.metrics.inc('teltonika_frames_total', {
|
||||
codec: `0x${frame.codecId.toString(16).padStart(2, '0')}`,
|
||||
result: 'ok',
|
||||
});
|
||||
|
||||
// ACK: 4-byte big-endian record count
|
||||
const ack = Buffer.alloc(4);
|
||||
ack.writeUInt32BE(result.recordCount, 0);
|
||||
socket.write(ack);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user