Implement Phase 1 tasks 1.5-1.7 + 1.9 (Codec 8/8E/16 parsers + fixture suite)

- Codec 8 parser (1-byte IO IDs, no NX/Generation Type)
- Codec 8 Extended parser (2-byte IO IDs + variable-length NX section)
- Codec 16 parser (mixed widths + Generation Type, supports IO IDs > 255)
- Shared GPS element / timestamp helpers in gps-element.ts
- Fixture loader with bigint/Buffer sentinel encoding and auto-discovery
- 12 fixture pairs across codec8/8E/16 (canonical doc + synthetic edge cases)
- Cross-checked Codec 8 against Traccar's TeltonikaProtocolDecoder (no discrepancies)

26 new tests. Total 62 passing across 10 test files.
typecheck/lint/test/build all clean.
This commit is contained in:
2026-04-30 16:15:27 +02:00
parent 1e9219d14a
commit 381287bacc
34 changed files with 1672 additions and 1 deletions
@@ -0,0 +1,165 @@
import type { Position } from '../../../../core/types.js';
import type { CodecDataHandler, CodecHandlerContext } from '../registry.js';
import { parseTimestamp, parseGpsElement } from './gps-element.js';
/**
* Codec 16 (`0x10`) AVL data parser.
*
* Mixed-width layout — the trap:
* - Counts (N total, N1, N2, N4, N8): 1 byte ← same as Codec 8
* - IO IDs: 2 bytes ← same as Codec 8E
* - Event IO ID: 2 bytes ← same as Codec 8E
* - Generation Type: 1 byte ← unique to Codec 16
*
* Full IO element layout:
* [Event IO ID 2B]
* [Generation Type 1B]
* [N total 1B]
* [N1 1B] then N1 × ([IO ID 2B][Value 1B])
* [N2 1B] then N2 × ([IO ID 2B][Value 2B])
* [N4 1B] then N4 × ([IO ID 2B][Value 4B])
* [N8 1B] then N8 × ([IO ID 2B][Value 8B — bigint])
*
* No NX section (unlike Codec 8E).
*
* Generation Type (07) is stored as attributes['__generation_type'].
* Values 3 (Reserved) are accepted without rejection — logged at debug level only.
*
* The body received here is the full frame payload:
* [CodecID 1B][N1 1B][AVL records...][N2 1B]
*/
function parsePriority(raw: number, ctx: CodecHandlerContext): 0 | 1 | 2 {
if (raw === 0 || raw === 1 || raw === 2) {
return raw;
}
// TODO(1.10): emit metric teltonika_priority_out_of_range_total
ctx.logger.debug({ raw_priority: raw }, 'priority value out of range [0,2]; clamping to 2 (Panic)');
return 2;
}
function parseIoElements16(
body: Buffer,
offset: number,
ctx: CodecHandlerContext,
): {
attributes: Record<string, number | bigint | Buffer>;
nextOffset: number;
} {
const attributes: Record<string, number | bigint | Buffer> = {};
// Event IO ID — 2 bytes (same as 8E)
const eventIoId = body.readUInt16BE(offset);
offset += 2;
attributes['__event'] = eventIoId;
// Generation Type — 1 byte (unique to Codec 16)
const generationType = body.readUInt8(offset);
offset += 1;
if (generationType === 3) {
ctx.logger.debug({ generation_type: generationType }, 'reserved Generation Type value 3 observed');
}
attributes['__generation_type'] = generationType;
// N total — 1 byte (like Codec 8, unlike Codec 8E)
offset += 1; // read but skip — Nk sections carry individual counts
// N1 — 1B count, 2B IO ID, 1B value
const n1 = body.readUInt8(offset);
offset += 1;
for (let i = 0; i < n1; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt8(offset);
offset += 1;
}
// N2 — 1B count, 2B IO ID, 2B value
const n2 = body.readUInt8(offset);
offset += 1;
for (let i = 0; i < n2; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt16BE(offset);
offset += 2;
}
// N4 — 1B count, 2B IO ID, 4B value
const n4 = body.readUInt8(offset);
offset += 1;
for (let i = 0; i < n4; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt32BE(offset);
offset += 4;
}
// N8 — 1B count, 2B IO ID, 8B value (bigint)
const n8 = body.readUInt8(offset);
offset += 1;
for (let i = 0; i < n8; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readBigUInt64BE(offset);
offset += 8;
}
return { attributes, nextOffset: offset };
}
function parseOneRecord(
body: Buffer,
offset: number,
imei: string,
ctx: CodecHandlerContext,
): { position: Position; nextOffset: number } {
const { value: timestamp, nextOffset: off1 } = parseTimestamp(body, offset);
const rawPriority = body.readUInt8(off1);
const priority = parsePriority(rawPriority, ctx);
const { value: gps, nextOffset: off2 } = parseGpsElement(body, off1 + 1);
const { attributes, nextOffset: off3 } = parseIoElements16(body, off2, ctx);
const position: Position = {
device_id: imei,
timestamp,
latitude: gps.latitude,
longitude: gps.longitude,
altitude: gps.altitude,
angle: gps.angle,
speed: gps.speed,
satellites: gps.satellites,
priority,
attributes,
};
return { position, nextOffset: off3 };
}
export const codec16Handler: CodecDataHandler = {
codec_id: 0x10,
async handle(body: Buffer, ctx: CodecHandlerContext): Promise<{ recordCount: number }> {
// Body layout: [CodecID 1B][N1 1B][records...][N2 1B]
const recordCount = body.readUInt8(1);
const n2Offset = body.length - 1;
let offset = 2; // start of first record
for (let i = 0; i < recordCount; i++) {
const { position, nextOffset } = parseOneRecord(body, offset, ctx.imei, ctx);
offset = nextOffset;
await ctx.publish(position);
}
// Cursor invariant: after all records, cursor must be exactly at N2
if (offset !== n2Offset) {
throw new Error(
`Codec 16 cursor invariant violated: expected offset ${n2Offset} after ${recordCount} records, got ${offset}. ` +
`Body length: ${body.length}`,
);
}
return { recordCount };
},
};
+159
View File
@@ -0,0 +1,159 @@
import type { Position } from '../../../../core/types.js';
import type { CodecDataHandler, CodecHandlerContext } from '../registry.js';
import { parseTimestamp, parseGpsElement } from './gps-element.js';
/**
* Codec 8 (`0x08`) AVL data parser.
*
* IO element layout — 1-byte everything:
* [Event IO ID 1B]
* [N total 1B]
* [N1 1B] then N1 × ([IO ID 1B][Value 1B])
* [N2 1B] then N2 × ([IO ID 1B][Value 2B])
* [N4 1B] then N4 × ([IO ID 1B][Value 4B])
* [N8 1B] then N8 × ([IO ID 1B][Value 8B — bigint])
*
* The body received here is the full frame payload:
* [CodecID 1B][N1 1B][AVL records...][N2 1B]
*
* After parsing all records the cursor must sit exactly at the N2 byte
* (body.length - 1). Any drift means a parser bug — we throw with offset
* details rather than silently emit corrupt positions.
*/
function parsePriority(raw: number, ctx: CodecHandlerContext): 0 | 1 | 2 {
if (raw === 0 || raw === 1 || raw === 2) {
return raw;
}
// TODO(1.10): emit metric teltonika_priority_out_of_range_total
ctx.logger.debug({ raw_priority: raw }, 'priority value out of range [0,2]; clamping to 2 (Panic)');
return 2;
}
function parseIoElements(
body: Buffer,
offset: number,
ioIdWidth: 1 | 2,
countWidth: 1 | 2,
): {
attributes: Record<string, number | bigint | Buffer>;
eventIoId: number;
nextOffset: number;
} {
const readId = ioIdWidth === 1
? (off: number) => ({ id: body.readUInt8(off), next: off + 1 })
: (off: number) => ({ id: body.readUInt16BE(off), next: off + 2 });
const readCount = countWidth === 1
? (off: number) => ({ count: body.readUInt8(off), next: off + 1 })
: (off: number) => ({ count: body.readUInt16BE(off), next: off + 2 });
// Event IO ID
const { id: eventIoId, next: off1 } = readId(offset);
// N total (read but not validated — individual Nk sections carry the real counts)
const { next: off2 } = readCount(off1);
const attributes: Record<string, number | bigint | Buffer> = {};
attributes['__event'] = eventIoId;
let off = off2;
// N1 — 1-byte values
const { count: n1, next: afterN1 } = readCount(off);
off = afterN1;
for (let i = 0; i < n1; i++) {
const { id, next: afterId } = readId(off);
off = afterId;
attributes[String(id)] = body.readUInt8(off);
off += 1;
}
// N2 — 2-byte values
const { count: n2, next: afterN2 } = readCount(off);
off = afterN2;
for (let i = 0; i < n2; i++) {
const { id, next: afterId } = readId(off);
off = afterId;
attributes[String(id)] = body.readUInt16BE(off);
off += 2;
}
// N4 — 4-byte values
const { count: n4, next: afterN4 } = readCount(off);
off = afterN4;
for (let i = 0; i < n4; i++) {
const { id, next: afterId } = readId(off);
off = afterId;
attributes[String(id)] = body.readUInt32BE(off);
off += 4;
}
// N8 — 8-byte values (bigint)
const { count: n8, next: afterN8 } = readCount(off);
off = afterN8;
for (let i = 0; i < n8; i++) {
const { id, next: afterId } = readId(off);
off = afterId;
attributes[String(id)] = body.readBigUInt64BE(off);
off += 8;
}
return { attributes, eventIoId, nextOffset: off };
}
function parseOneRecord(
body: Buffer,
offset: number,
imei: string,
ctx: CodecHandlerContext,
): { position: Position; nextOffset: number } {
const { value: timestamp, nextOffset: off1 } = parseTimestamp(body, offset);
const rawPriority = body.readUInt8(off1);
const priority = parsePriority(rawPriority, ctx);
const { value: gps, nextOffset: off2 } = parseGpsElement(body, off1 + 1);
const { attributes, nextOffset: off3 } = parseIoElements(body, off2, 1, 1);
const position: Position = {
device_id: imei,
timestamp,
latitude: gps.latitude,
longitude: gps.longitude,
altitude: gps.altitude,
angle: gps.angle,
speed: gps.speed,
satellites: gps.satellites,
priority,
attributes,
};
return { position, nextOffset: off3 };
}
export const codec8Handler: CodecDataHandler = {
codec_id: 0x08,
async handle(body: Buffer, ctx: CodecHandlerContext): Promise<{ recordCount: number }> {
// Body layout: [CodecID 1B][N1 1B][records...][N2 1B]
const recordCount = body.readUInt8(1);
const n2Offset = body.length - 1;
let offset = 2; // start of first record
for (let i = 0; i < recordCount; i++) {
const { position, nextOffset } = parseOneRecord(body, offset, ctx.imei, ctx);
offset = nextOffset;
await ctx.publish(position);
}
// Cursor invariant: after all records, cursor must be exactly at N2
if (offset !== n2Offset) {
throw new Error(
`Codec 8 cursor invariant violated: expected offset ${n2Offset} after ${recordCount} records, got ${offset}. ` +
`Body length: ${body.length}`,
);
}
return { recordCount };
},
};
@@ -0,0 +1,162 @@
import type { Position } from '../../../../core/types.js';
import type { CodecDataHandler, CodecHandlerContext } from '../registry.js';
import { parseTimestamp, parseGpsElement } from './gps-element.js';
/**
* Codec 8 Extended (`0x8E`) AVL data parser.
*
* IO element layout — 2-byte fields + variable-length NX section:
* [Event IO ID 2B]
* [N total 2B]
* [N1 2B] then N1 × ([IO ID 2B][Value 1B])
* [N2 2B] then N2 × ([IO ID 2B][Value 2B])
* [N4 2B] then N4 × ([IO ID 2B][Value 4B])
* [N8 2B] then N8 × ([IO ID 2B][Value 8B — bigint])
* [NX 2B] then NX × ([IO ID 2B][Length 2B][Value <Length> bytes — Buffer])
*
* The body received here is the full frame payload:
* [CodecID 1B][N1 1B][AVL records...][N2 1B]
*
* NX values are stored as Buffer using buf.subarray() — zero-copy views into
* the underlying frame buffer. This is safe because the frame buffer outlives
* the publish call.
*/
function parsePriority(raw: number, ctx: CodecHandlerContext): 0 | 1 | 2 {
if (raw === 0 || raw === 1 || raw === 2) {
return raw;
}
// TODO(1.10): emit metric teltonika_priority_out_of_range_total
ctx.logger.debug({ raw_priority: raw }, 'priority value out of range [0,2]; clamping to 2 (Panic)');
return 2;
}
function parseIoElements8E(
body: Buffer,
offset: number,
): {
attributes: Record<string, number | bigint | Buffer>;
nextOffset: number;
} {
const attributes: Record<string, number | bigint | Buffer> = {};
// Event IO ID — 2 bytes
const eventIoId = body.readUInt16BE(offset);
offset += 2;
attributes['__event'] = eventIoId;
// N total — 2 bytes (read and skip; individual Nk sections carry counts)
offset += 2;
// N1 — 2B count, 2B IO ID, 1B value
const n1 = body.readUInt16BE(offset);
offset += 2;
for (let i = 0; i < n1; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt8(offset);
offset += 1;
}
// N2 — 2B count, 2B IO ID, 2B value
const n2 = body.readUInt16BE(offset);
offset += 2;
for (let i = 0; i < n2; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt16BE(offset);
offset += 2;
}
// N4 — 2B count, 2B IO ID, 4B value
const n4 = body.readUInt16BE(offset);
offset += 2;
for (let i = 0; i < n4; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readUInt32BE(offset);
offset += 4;
}
// N8 — 2B count, 2B IO ID, 8B value (bigint)
const n8 = body.readUInt16BE(offset);
offset += 2;
for (let i = 0; i < n8; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
attributes[String(ioId)] = body.readBigUInt64BE(offset);
offset += 8;
}
// NX — 2B count, 2B IO ID, 2B length, <length> bytes value (Buffer, zero-copy)
const nx = body.readUInt16BE(offset);
offset += 2;
for (let i = 0; i < nx; i++) {
const ioId = body.readUInt16BE(offset);
offset += 2;
const valueLength = body.readUInt16BE(offset);
offset += 2;
// Zero-copy view; safe because the frame buffer outlives publish
attributes[String(ioId)] = body.subarray(offset, offset + valueLength);
offset += valueLength;
}
return { attributes, nextOffset: offset };
}
function parseOneRecord(
body: Buffer,
offset: number,
imei: string,
ctx: CodecHandlerContext,
): { position: Position; nextOffset: number } {
const { value: timestamp, nextOffset: off1 } = parseTimestamp(body, offset);
const rawPriority = body.readUInt8(off1);
const priority = parsePriority(rawPriority, ctx);
const { value: gps, nextOffset: off2 } = parseGpsElement(body, off1 + 1);
const { attributes, nextOffset: off3 } = parseIoElements8E(body, off2);
const position: Position = {
device_id: imei,
timestamp,
latitude: gps.latitude,
longitude: gps.longitude,
altitude: gps.altitude,
angle: gps.angle,
speed: gps.speed,
satellites: gps.satellites,
priority,
attributes,
};
return { position, nextOffset: off3 };
}
export const codec8eHandler: CodecDataHandler = {
codec_id: 0x8e,
async handle(body: Buffer, ctx: CodecHandlerContext): Promise<{ recordCount: number }> {
// Body layout: [CodecID 1B][N1 1B][records...][N2 1B]
const recordCount = body.readUInt8(1);
const n2Offset = body.length - 1;
let offset = 2; // start of first record
for (let i = 0; i < recordCount; i++) {
const { position, nextOffset } = parseOneRecord(body, offset, ctx.imei, ctx);
offset = nextOffset;
await ctx.publish(position);
}
// Cursor invariant: after all records, cursor must be exactly at N2
if (offset !== n2Offset) {
throw new Error(
`Codec 8E cursor invariant violated: expected offset ${n2Offset} after ${recordCount} records, got ${offset}. ` +
`Body length: ${body.length}`,
);
}
return { recordCount };
},
};
@@ -0,0 +1,53 @@
/**
* Shared GPS element and timestamp parsers used by all three AVL codecs
* (Codec 8, 8E, 16). Extracted here to keep each codec file focused on its
* IO element layout differences.
*/
/**
* The 15-byte GPS element parsed out of every AVL record.
*/
export type GpsElement = {
readonly longitude: number; // decimal degrees, signed
readonly latitude: number; // decimal degrees, signed
readonly altitude: number; // meters above sea level, signed
readonly angle: number; // heading 0360°, unsigned
readonly satellites: number; // satellites in use
readonly speed: number; // km/h; 0x0000 may mean "GPS invalid" — preserved verbatim
};
/**
* Parses an 8-byte big-endian unsigned timestamp (milliseconds since UNIX epoch)
* starting at `offset`. Uses BigInt read to avoid any precision loss, then
* converts to Number for Date construction — values are well within safe range
* until ~year 285,000.
*/
export function parseTimestamp(buf: Buffer, offset: number): { value: Date; nextOffset: number } {
const ms = buf.readBigUInt64BE(offset);
return {
value: new Date(Number(ms)),
nextOffset: offset + 8,
};
}
/**
* Parses the 15-byte GPS element block:
* [Lon 4B][Lat 4B][Alt 2B][Angle 2B][Sats 1B][Speed 2B]
*
* Longitude and latitude are two's-complement signed 32-bit integers.
* Node's readInt32BE interprets the sign bit correctly; dividing by 1e7
* converts the fixed-point integer to decimal degrees.
*/
export function parseGpsElement(buf: Buffer, offset: number): { value: GpsElement; nextOffset: number } {
const longitude = buf.readInt32BE(offset) / 1e7;
const latitude = buf.readInt32BE(offset + 4) / 1e7;
const altitude = buf.readInt16BE(offset + 8);
const angle = buf.readUInt16BE(offset + 10);
const satellites = buf.readUInt8(offset + 12);
const speed = buf.readUInt16BE(offset + 13);
return {
value: { longitude, latitude, altitude, angle, satellites, speed },
nextOffset: offset + 15,
};
}
+11 -1
View File
@@ -5,6 +5,9 @@ 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';
import { codec8Handler } from './codec/data/codec8.js';
import { codec8eHandler } from './codec/data/codec8e.js';
import { codec16Handler } from './codec/data/codec16.js';
export type TeltonikaAdapterOptions = {
readonly port: number;
@@ -26,7 +29,14 @@ export type TeltonikaAdapterOptions = {
export function createTeltonikaAdapter(options: TeltonikaAdapterOptions): Adapter {
const authority: DeviceAuthority = options.deviceAuthority ?? new AllowAllAuthority();
const strictDeviceAuth = options.strictDeviceAuth ?? false;
const codecRegistry = options.codecRegistry ?? new CodecRegistry();
// Build default registry with all three Phase 1 data codecs registered.
// Callers can pass their own registry (e.g. in tests) to override.
const defaultRegistry = new CodecRegistry();
defaultRegistry.register(codec8Handler);
defaultRegistry.register(codec8eHandler);
defaultRegistry.register(codec16Handler);
const codecRegistry = options.codecRegistry ?? defaultRegistry;
return {
name: 'teltonika',