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,53 @@
|
||||
import type { Logger } from 'pino';
|
||||
import type { Position } from '../../../core/types.js';
|
||||
|
||||
/**
|
||||
* Context passed to codec data handlers. Narrow contract: parsers receive
|
||||
* only what they need (IMEI for device_id, publish for emitting records,
|
||||
* logger for structured output).
|
||||
*
|
||||
* Phase 2 will add a `respond(bytes: Buffer) => void` to this ctx for command
|
||||
* codecs that write back to the socket. Data codecs (8, 8E, 16) never write.
|
||||
*/
|
||||
export type CodecHandlerContext = {
|
||||
readonly imei: string;
|
||||
readonly publish: (position: Position) => Promise<void>;
|
||||
readonly logger: Logger;
|
||||
};
|
||||
|
||||
/**
|
||||
* A codec data handler registered in the flat dispatch registry.
|
||||
* Each handler owns one codec ID and is responsible for:
|
||||
* - Parsing N1/N2 count validation (within the body it receives)
|
||||
* - Producing Position records via ctx.publish
|
||||
* - Returning the number of records accepted (used for the TCP ACK)
|
||||
*/
|
||||
export interface CodecDataHandler {
|
||||
readonly codec_id: number;
|
||||
handle(
|
||||
body: Buffer, // Full body: CodecID (1B) + N1 (1B) + records + N2 (1B)
|
||||
ctx: CodecHandlerContext,
|
||||
): Promise<{ recordCount: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat registry mapping codec IDs to their handlers.
|
||||
* Phase 1 registers 0x08, 0x8E, 0x10. Phase 2 will register 0x0C, 0x0E
|
||||
* alongside without modifying Phase 1 paths.
|
||||
*/
|
||||
export class CodecRegistry {
|
||||
private readonly handlers = new Map<number, CodecDataHandler>();
|
||||
|
||||
register(handler: CodecDataHandler): void {
|
||||
if (this.handlers.has(handler.codec_id)) {
|
||||
throw new Error(
|
||||
`Codec 0x${handler.codec_id.toString(16).toUpperCase()} is already registered.`,
|
||||
);
|
||||
}
|
||||
this.handlers.set(handler.codec_id, handler);
|
||||
}
|
||||
|
||||
get(codecId: number): CodecDataHandler | undefined {
|
||||
return this.handlers.get(codecId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* CRC-16/IBM (also known as CRC-16/ARC).
|
||||
* Polynomial: 0xA001 (reflected form of 0x8005).
|
||||
* Initial value: 0x0000. No final XOR.
|
||||
*
|
||||
* Teltonika uses this to protect the AVL data body from CodecID through N2
|
||||
* inclusive. The 4-byte CRC field in the frame carries the result in the lower
|
||||
* 2 bytes; upper 2 bytes are always zero.
|
||||
*
|
||||
* Implementation uses a precomputed 256-entry lookup table for performance —
|
||||
* protocol parsing is on the hot path and frame bodies can be up to ~1280 bytes.
|
||||
*/
|
||||
|
||||
// Build lookup table at module load time (one-off cost, not per-frame)
|
||||
const CRC_TABLE: Uint16Array = buildTable();
|
||||
|
||||
function buildTable(): Uint16Array {
|
||||
const table = new Uint16Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if ((crc & 0x0001) !== 0) {
|
||||
crc = (crc >>> 1) ^ 0xa001;
|
||||
} else {
|
||||
crc = crc >>> 1;
|
||||
}
|
||||
}
|
||||
table[i] = crc;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes CRC-16/IBM over the given buffer.
|
||||
* Returns a 16-bit unsigned integer in the range [0x0000, 0xFFFF].
|
||||
*/
|
||||
export function crc16Ibm(buf: Buffer): number {
|
||||
let crc = 0x0000;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const byte = buf[i] ?? 0;
|
||||
const tableIndex = (crc ^ byte) & 0xff;
|
||||
crc = (crc >>> 8) ^ (CRC_TABLE[tableIndex] ?? 0);
|
||||
}
|
||||
return crc & 0xffff;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Determines whether a connecting device is a recognized member of the fleet.
|
||||
*
|
||||
* Phase 1 default: AllowAllAuthority — every IMEI returns 'known'. This keeps
|
||||
* all current devices working while making the label available for metrics.
|
||||
*
|
||||
* Phase 1.13 will add RedisAllowListAuthority, which reads a Directus-published
|
||||
* allow-list from Redis and returns 'unknown' for devices not in the list.
|
||||
*
|
||||
* The STRICT_DEVICE_AUTH flag causes 'unknown' devices to be rejected (0x00
|
||||
* handshake response + socket destroy). It is off by default.
|
||||
*/
|
||||
export interface DeviceAuthority {
|
||||
check(imei: string): Promise<'known' | 'unknown'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive default authority — always returns 'known'.
|
||||
* Used until a real authority is configured (task 1.13).
|
||||
*/
|
||||
export class AllowAllAuthority implements DeviceAuthority {
|
||||
async check(_imei: string): Promise<'known'> {
|
||||
return 'known';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type * as net from 'node:net';
|
||||
import { crc16Ibm } from './crc.js';
|
||||
|
||||
/**
|
||||
* Maximum permitted DataFieldLength value. Covers the 1280-byte max AVL packet
|
||||
* for most devices, with headroom. Frames above this threshold are structurally
|
||||
* malformed — we drop the connection rather than allocating unbounded memory.
|
||||
*/
|
||||
export const MAX_AVL_PACKET_SIZE = 1300;
|
||||
|
||||
/**
|
||||
* Reason a frame read attempt failed in a way that should close the connection.
|
||||
*/
|
||||
export type FrameDropReason =
|
||||
| 'invalid_preamble'
|
||||
| 'oversized_frame'
|
||||
| 'n1_n2_mismatch'
|
||||
| 'socket_closed';
|
||||
|
||||
export class FrameDropError extends Error {
|
||||
constructor(
|
||||
public readonly reason: FrameDropReason,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FrameDropError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed AVL frame envelope. CRC validity is surfaced here so the session loop
|
||||
* can decide whether to ACK without the frame handler needing to re-compute.
|
||||
*/
|
||||
export type Frame = {
|
||||
readonly codecId: number;
|
||||
readonly payload: Buffer; // full body: CodecID + N1 + records + N2
|
||||
readonly crcValid: boolean;
|
||||
readonly expectedCrc: number;
|
||||
readonly computedCrc: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BufferedReader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PendingRead = {
|
||||
needed: number;
|
||||
resolve: (buf: Buffer) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Accumulates bytes arriving across multiple `data` events and satisfies
|
||||
* exact-byte-count read requests. This handles the reality that TCP is a stream
|
||||
* — a 100-byte read may arrive as five 20-byte chunks.
|
||||
*
|
||||
* Design: a single pending read at a time. The session loop is sequential
|
||||
* (each `await reader.readExact(n)` completes before the next starts) so
|
||||
* concurrent reads are not needed.
|
||||
*/
|
||||
export class BufferedReader {
|
||||
private readonly chunks: Buffer[] = [];
|
||||
private buffered = 0;
|
||||
private pending: PendingRead | null = null;
|
||||
private closed = false;
|
||||
private closeError: Error | null = null;
|
||||
|
||||
constructor(private readonly socket: net.Socket) {
|
||||
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
||||
socket.on('close', () => this.onClose());
|
||||
socket.on('error', (err) => this.onSocketError(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads exactly `n` bytes. Returns a Promise that resolves once the bytes are
|
||||
* available. Rejects if the socket closes before the read completes.
|
||||
*/
|
||||
readExact(n: number): Promise<Buffer> {
|
||||
// Fast path: data already buffered
|
||||
if (this.buffered >= n) {
|
||||
return Promise.resolve(this.consume(n));
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
return Promise.reject(
|
||||
this.closeError ?? new FrameDropError('socket_closed', 'Socket closed before read completed'),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
this.pending = { needed: n, resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer): void {
|
||||
this.chunks.push(chunk);
|
||||
this.buffered += chunk.length;
|
||||
this.tryFlush();
|
||||
}
|
||||
|
||||
private tryFlush(): void {
|
||||
if (this.pending !== null && this.buffered >= this.pending.needed) {
|
||||
const { needed, resolve } = this.pending;
|
||||
this.pending = null;
|
||||
resolve(this.consume(needed));
|
||||
}
|
||||
}
|
||||
|
||||
private consume(n: number): Buffer {
|
||||
// Concatenate chunks into a single contiguous buffer, take n bytes, put any
|
||||
// remainder back as the first chunk.
|
||||
const combined = Buffer.concat(this.chunks);
|
||||
this.chunks.length = 0;
|
||||
const taken = combined.subarray(0, n);
|
||||
const rest = combined.subarray(n);
|
||||
if (rest.length > 0) {
|
||||
this.chunks.push(rest);
|
||||
}
|
||||
this.buffered = rest.length;
|
||||
return Buffer.from(taken); // copy so callers own the memory
|
||||
}
|
||||
|
||||
private onClose(): void {
|
||||
this.closed = true;
|
||||
if (this.pending !== null) {
|
||||
const { reject } = this.pending;
|
||||
this.pending = null;
|
||||
reject(new FrameDropError('socket_closed', 'Socket closed before read completed'));
|
||||
}
|
||||
}
|
||||
|
||||
private onSocketError(err: Error): void {
|
||||
this.closed = true;
|
||||
this.closeError = err;
|
||||
if (this.pending !== null) {
|
||||
const { reject } = this.pending;
|
||||
this.pending = null;
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame envelope reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads the next complete AVL frame envelope from the socket via `reader`.
|
||||
* Validates preamble, length sanity, and CRC. Does NOT dispatch on codec ID —
|
||||
* that is the session loop's responsibility.
|
||||
*
|
||||
* Throws `FrameDropError` when the connection must be closed (invalid preamble,
|
||||
* oversized frame, N1≠N2). On CRC mismatch the frame is returned with
|
||||
* `crcValid: false` — the caller should not ACK but should keep the connection.
|
||||
*/
|
||||
export async function readNextFrame(reader: BufferedReader): Promise<Frame> {
|
||||
// 1. Preamble: 4 zero bytes
|
||||
const preamble = await reader.readExact(4);
|
||||
if (preamble.readUInt32BE(0) !== 0) {
|
||||
throw new FrameDropError(
|
||||
'invalid_preamble',
|
||||
`Expected preamble 0x00000000, got 0x${preamble.toString('hex')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. DataFieldLength: number of bytes from CodecID through N2
|
||||
const lengthBuf = await reader.readExact(4);
|
||||
const dataFieldLength = lengthBuf.readUInt32BE(0);
|
||||
|
||||
if (dataFieldLength < 2 || dataFieldLength > MAX_AVL_PACKET_SIZE) {
|
||||
throw new FrameDropError(
|
||||
'oversized_frame',
|
||||
`DataFieldLength ${dataFieldLength} out of bounds [2, ${MAX_AVL_PACKET_SIZE}]`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Body: CodecID (1B) + N1 (1B) + AVL records + N2 (1B)
|
||||
const body = await reader.readExact(dataFieldLength);
|
||||
|
||||
// 4. CRC field: 4 bytes, value in lower 2 bytes (upper 2 are always 0)
|
||||
const crcField = await reader.readExact(4);
|
||||
const expectedCrc = crcField.readUInt16BE(2); // bytes 2–3 carry the 16-bit value
|
||||
const computedCrc = crc16Ibm(body);
|
||||
|
||||
// 5. Validate N1 === N2 (record count repeated for integrity)
|
||||
// Body layout: [CodecID 1B][N1 1B][...records...][N2 1B]
|
||||
// N2 is the last byte of body.
|
||||
if (body.length < 2) {
|
||||
throw new FrameDropError('oversized_frame', 'Body too short to contain CodecID and N1');
|
||||
}
|
||||
const n1 = body[1] ?? 0;
|
||||
const n2 = body[body.length - 1] ?? 0;
|
||||
if (n1 !== n2) {
|
||||
throw new FrameDropError(
|
||||
'n1_n2_mismatch',
|
||||
`N1 (${n1}) !== N2 (${n2}): structural mismatch, dropping connection`,
|
||||
);
|
||||
}
|
||||
|
||||
const codecId = body[0] ?? 0;
|
||||
|
||||
return {
|
||||
codecId,
|
||||
payload: body,
|
||||
crcValid: expectedCrc === computedCrc,
|
||||
expectedCrc,
|
||||
computedCrc,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type * as net from 'node:net';
|
||||
import { BufferedReader } from './frame.js';
|
||||
|
||||
/**
|
||||
* IMEI validation pattern. Teltonika devices send 15-digit IMEIs; we allow
|
||||
* 14–16 to cover edge cases (14-digit test units, 16-digit future variants).
|
||||
*/
|
||||
const IMEI_PATTERN = /^\d{14,16}$/;
|
||||
|
||||
/**
|
||||
* Maximum IMEI byte length we accept. Teltonika spec says 15; we allow up to
|
||||
* 32 as headroom before rejecting as malformed.
|
||||
*/
|
||||
const MAX_IMEI_LENGTH = 32;
|
||||
|
||||
export class HandshakeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly rawBytes?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HandshakeError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates the Teltonika IMEI handshake from the socket.
|
||||
* Wire format: [length 2B big-endian][IMEI ASCII bytes (length B)]
|
||||
*
|
||||
* Returns the IMEI string on success. Does NOT write the accept/reject byte —
|
||||
* that decision belongs to the session loop after consulting DeviceAuthority.
|
||||
*
|
||||
* Throws HandshakeError for any malformed input without writing to the socket.
|
||||
*/
|
||||
export async function readImeiHandshake(socket: net.Socket): Promise<string> {
|
||||
const reader = new BufferedReader(socket);
|
||||
|
||||
// 1. Read 2-byte length field
|
||||
const lengthBuf = await reader.readExact(2);
|
||||
const imeiLength = lengthBuf.readUInt16BE(0);
|
||||
|
||||
if (imeiLength === 0 || imeiLength > MAX_IMEI_LENGTH) {
|
||||
throw new HandshakeError(
|
||||
`IMEI length ${imeiLength} is outside valid range [1, ${MAX_IMEI_LENGTH}]`,
|
||||
lengthBuf.toString('hex'),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Read IMEI bytes
|
||||
const imeiBuf = await reader.readExact(imeiLength);
|
||||
const imei = imeiBuf.toString('ascii');
|
||||
|
||||
// 3. Validate: must be all digits, 14–16 chars
|
||||
if (!IMEI_PATTERN.test(imei)) {
|
||||
throw new HandshakeError(
|
||||
`IMEI "${imei}" does not match expected pattern (14–16 digits)`,
|
||||
imeiBuf.toString('hex'),
|
||||
);
|
||||
}
|
||||
|
||||
return imei;
|
||||
}
|
||||
@@ -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