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:
2026-04-30 15:47:26 +02:00
parent c8a5f4cd68
commit 1e9219d14a
35 changed files with 6217 additions and 0 deletions
+175
View File
@@ -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.51.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);
}
},
};
}