From 381287baccc31cdad08565f4de46392809e89939 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 30 Apr 2026 16:15:27 +0200 Subject: [PATCH] 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. --- src/adapters/teltonika/codec/data/codec16.ts | 165 ++++++++++++ src/adapters/teltonika/codec/data/codec8.ts | 159 ++++++++++++ src/adapters/teltonika/codec/data/codec8e.ts | 162 ++++++++++++ .../teltonika/codec/data/gps-element.ts | 53 ++++ src/adapters/teltonika/index.ts | 12 +- test/codec16.test.ts | 118 +++++++++ test/codec8.test.ts | 120 +++++++++ test/codec8e.test.ts | 115 +++++++++ test/fixtures/_loader.ts | 242 ++++++++++++++++++ test/fixtures/teltonika/README.md | 79 ++++++ .../codec16/01-canonical.expected.json | 43 ++++ .../teltonika/codec16/01-canonical.hex | 1 + .../02-each-generation-type.expected.json | 61 +++++ .../codec16/02-each-generation-type.hex | 1 + .../codec16/03-large-io-id.expected.json | 21 ++ .../teltonika/codec16/03-large-io-id.hex | 1 + .../01-single-record-all-widths.expected.json | 24 ++ .../codec8/01-single-record-all-widths.hex | 1 + .../02-single-record-reduced.expected.json | 22 ++ .../codec8/02-single-record-reduced.hex | 1 + .../codec8/03-two-records.expected.json | 35 +++ .../teltonika/codec8/03-two-records.hex | 1 + .../codec8/04-empty-io-bag.expected.json | 19 ++ .../teltonika/codec8/04-empty-io-bag.hex | 1 + .../05-multi-record-large.expected.json | 125 +++++++++ .../codec8/05-multi-record-large.hex | 1 + .../codec8e/01-canonical.expected.json | 24 ++ .../teltonika/codec8e/01-canonical.hex | 1 + .../codec8e/02-nx-mixed.expected.json | 22 ++ .../teltonika/codec8e/02-nx-mixed.hex | 1 + .../codec8e/03-nx-zero-length.expected.json | 20 ++ .../teltonika/codec8e/03-nx-zero-length.hex | 1 + .../codec8e/04-nx-large-length.expected.json | 20 ++ .../teltonika/codec8e/04-nx-large-length.hex | 1 + 34 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 src/adapters/teltonika/codec/data/codec16.ts create mode 100644 src/adapters/teltonika/codec/data/codec8.ts create mode 100644 src/adapters/teltonika/codec/data/codec8e.ts create mode 100644 src/adapters/teltonika/codec/data/gps-element.ts create mode 100644 test/codec16.test.ts create mode 100644 test/codec8.test.ts create mode 100644 test/codec8e.test.ts create mode 100644 test/fixtures/_loader.ts create mode 100644 test/fixtures/teltonika/README.md create mode 100644 test/fixtures/teltonika/codec16/01-canonical.expected.json create mode 100644 test/fixtures/teltonika/codec16/01-canonical.hex create mode 100644 test/fixtures/teltonika/codec16/02-each-generation-type.expected.json create mode 100644 test/fixtures/teltonika/codec16/02-each-generation-type.hex create mode 100644 test/fixtures/teltonika/codec16/03-large-io-id.expected.json create mode 100644 test/fixtures/teltonika/codec16/03-large-io-id.hex create mode 100644 test/fixtures/teltonika/codec8/01-single-record-all-widths.expected.json create mode 100644 test/fixtures/teltonika/codec8/01-single-record-all-widths.hex create mode 100644 test/fixtures/teltonika/codec8/02-single-record-reduced.expected.json create mode 100644 test/fixtures/teltonika/codec8/02-single-record-reduced.hex create mode 100644 test/fixtures/teltonika/codec8/03-two-records.expected.json create mode 100644 test/fixtures/teltonika/codec8/03-two-records.hex create mode 100644 test/fixtures/teltonika/codec8/04-empty-io-bag.expected.json create mode 100644 test/fixtures/teltonika/codec8/04-empty-io-bag.hex create mode 100644 test/fixtures/teltonika/codec8/05-multi-record-large.expected.json create mode 100644 test/fixtures/teltonika/codec8/05-multi-record-large.hex create mode 100644 test/fixtures/teltonika/codec8e/01-canonical.expected.json create mode 100644 test/fixtures/teltonika/codec8e/01-canonical.hex create mode 100644 test/fixtures/teltonika/codec8e/02-nx-mixed.expected.json create mode 100644 test/fixtures/teltonika/codec8e/02-nx-mixed.hex create mode 100644 test/fixtures/teltonika/codec8e/03-nx-zero-length.expected.json create mode 100644 test/fixtures/teltonika/codec8e/03-nx-zero-length.hex create mode 100644 test/fixtures/teltonika/codec8e/04-nx-large-length.expected.json create mode 100644 test/fixtures/teltonika/codec8e/04-nx-large-length.hex diff --git a/src/adapters/teltonika/codec/data/codec16.ts b/src/adapters/teltonika/codec/data/codec16.ts new file mode 100644 index 0000000..2010603 --- /dev/null +++ b/src/adapters/teltonika/codec/data/codec16.ts @@ -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 (0–7) 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; + nextOffset: number; +} { + const attributes: Record = {}; + + // 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 }; + }, +}; diff --git a/src/adapters/teltonika/codec/data/codec8.ts b/src/adapters/teltonika/codec/data/codec8.ts new file mode 100644 index 0000000..e3cf99a --- /dev/null +++ b/src/adapters/teltonika/codec/data/codec8.ts @@ -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; + 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 = {}; + 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 }; + }, +}; diff --git a/src/adapters/teltonika/codec/data/codec8e.ts b/src/adapters/teltonika/codec/data/codec8e.ts new file mode 100644 index 0000000..6ef0872 --- /dev/null +++ b/src/adapters/teltonika/codec/data/codec8e.ts @@ -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 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; + nextOffset: number; +} { + const attributes: Record = {}; + + // 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, 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 }; + }, +}; diff --git a/src/adapters/teltonika/codec/data/gps-element.ts b/src/adapters/teltonika/codec/data/gps-element.ts new file mode 100644 index 0000000..d428362 --- /dev/null +++ b/src/adapters/teltonika/codec/data/gps-element.ts @@ -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 0–360°, 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, + }; +} diff --git a/src/adapters/teltonika/index.ts b/src/adapters/teltonika/index.ts index 3164571..1d165c2 100644 --- a/src/adapters/teltonika/index.ts +++ b/src/adapters/teltonika/index.ts @@ -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', diff --git a/test/codec16.test.ts b/test/codec16.test.ts new file mode 100644 index 0000000..0f4c522 --- /dev/null +++ b/test/codec16.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Position } from '../src/core/types.js'; +import type { CodecHandlerContext } from '../src/adapters/teltonika/codec/registry.js'; +import { codec16Handler } from '../src/adapters/teltonika/codec/data/codec16.js'; +import { loadFixturesFromDir, compareToExpected } from './fixtures/_loader.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Test context factory +// --------------------------------------------------------------------------- + +function makeTestCtx(positions: Position[]): CodecHandlerContext { + return { + imei: 'FIXTURE', + publish: async (pos: Position) => { + positions.push(pos); + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as CodecHandlerContext['logger'], + }; +} + +// --------------------------------------------------------------------------- +// Auto-discovered fixture tests +// --------------------------------------------------------------------------- + +const fixtureDir = path.join(__dirname, 'fixtures/teltonika/codec16'); +const fixtures = loadFixturesFromDir(fixtureDir); + +describe('Codec 16 parser — fixture tests', () => { + for (const fixture of fixtures) { + it(`parses ${fixture.name}`, async () => { + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + + const result = await codec16Handler.handle(fixture.body, ctx); + + expect(result.recordCount).toBe(fixture.expected.ack_record_count); + + const mismatch = compareToExpected(positions, fixture.expected.positions); + expect(mismatch, mismatch ?? '').toBeNull(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Unit tests — Codec 16-specific behaviours +// --------------------------------------------------------------------------- + +describe('Codec 16 parser — unit tests', () => { + it('codec_id is 0x10', () => { + expect(codec16Handler.codec_id).toBe(0x10); + }); + + it('sets __generation_type on every record', async () => { + const fixtureBody = Buffer.from( + '10010000016B40D8EA300100000000000000000000000000000004000501010400FF00000001', + 'hex', + ); + const positions: Position[] = []; + await codec16Handler.handle(fixtureBody, makeTestCtx(positions)); + + expect(positions[0]?.attributes['__generation_type']).toBe(5); + }); + + it('does NOT set __generation_type to undefined — key must be present', async () => { + // Both records in the canonical fixture have __generation_type=5 + const fixtureBody = Buffer.from( + '10020000016BDBC7833000000000000000000000000000000000000B05040200010000030002000B00270042563A00000000016BDBC7871800000000000000000000000000000000000B05040200010000030002000B00260042563A000002', + 'hex', + ); + const positions: Position[] = []; + await codec16Handler.handle(fixtureBody, makeTestCtx(positions)); + + for (const pos of positions) { + expect('__generation_type' in pos.attributes).toBe(true); + } + }); + + it('reads IO IDs greater than 255 correctly', async () => { + const fixtureBody = Buffer.from( + '10010000016B40D8EA300100000000000000000000000000000004000501010400FF00000001', + 'hex', + ); + const positions: Position[] = []; + await codec16Handler.handle(fixtureBody, makeTestCtx(positions)); + + // IO ID 0x0400 = 1024 must appear in attributes + expect(positions[0]?.attributes['1024']).toBe(255); + // Event IO ID 0x0400 = 1024 must appear under __event + expect(positions[0]?.attributes['__event']).toBe(1024); + }); + + it('logs a debug message for reserved Generation Type 3', async () => { + // Build a body with Generation Type = 3 + const body = Buffer.from( + '10010000016B40D8EA300100000000000000000000000000000000000301010100FF00000001', + 'hex', + ); + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + const debugFn = ctx.logger.debug as ReturnType; + + await codec16Handler.handle(body, ctx); + + expect(positions[0]?.attributes['__generation_type']).toBe(3); + // Debug should have been called at least once + expect(debugFn).toHaveBeenCalled(); + }); +}); diff --git a/test/codec8.test.ts b/test/codec8.test.ts new file mode 100644 index 0000000..1a72c1c --- /dev/null +++ b/test/codec8.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Position } from '../src/core/types.js'; +import type { CodecHandlerContext } from '../src/adapters/teltonika/codec/registry.js'; +import { codec8Handler } from '../src/adapters/teltonika/codec/data/codec8.js'; +import { loadFixturesFromDir, compareToExpected } from './fixtures/_loader.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Test context factory +// --------------------------------------------------------------------------- + +function makeTestCtx(positions: Position[]): CodecHandlerContext { + return { + imei: 'FIXTURE', + publish: async (pos: Position) => { + positions.push(pos); + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as CodecHandlerContext['logger'], + }; +} + +// --------------------------------------------------------------------------- +// Auto-discovered fixture tests +// --------------------------------------------------------------------------- + +const fixtureDir = path.join(__dirname, 'fixtures/teltonika/codec8'); +const fixtures = loadFixturesFromDir(fixtureDir); + +describe('Codec 8 parser — fixture tests', () => { + for (const fixture of fixtures) { + it(`parses ${fixture.name}`, async () => { + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + + const result = await codec8Handler.handle(fixture.body, ctx); + + expect(result.recordCount).toBe(fixture.expected.ack_record_count); + + const mismatch = compareToExpected(positions, fixture.expected.positions); + expect(mismatch, mismatch ?? '').toBeNull(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Unit tests — edge cases not covered by fixtures +// --------------------------------------------------------------------------- + +describe('Codec 8 parser — unit tests', () => { + it('codec_id is 0x08', () => { + expect(codec8Handler.codec_id).toBe(0x08); + }); + + it('clamps out-of-range priority (> 2) to 2', async () => { + // Priority byte = 0xFF (255), should become 2 (Panic) + // Build minimal body: [08][01][TS 8B][Priority=0xFF][GPS 15B zeros][EventId=0][N=0][N1=0][N2=0][N4=0][N8=0][N2=1] + const body = Buffer.alloc(33, 0); + body[0] = 0x08; // CodecID + body[1] = 0x01; // N1 = 1 + // Timestamp bytes 2-9 (valid, reuse example 1) + Buffer.from('0000016B40D8EA30', 'hex').copy(body, 2); + body[10] = 0xff; // Priority = 255 (out of range) + // GPS zeros already (bytes 11-25) + // IO: all zeros (bytes 26-31) + body[32] = 0x01; // trailing N2 + + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + await codec8Handler.handle(body, ctx); + + expect(positions).toHaveLength(1); + expect(positions[0]?.priority).toBe(2); + }); + + it('throws on cursor invariant violation (extra trailing bytes)', async () => { + // Build a valid single-record body then append extra bytes to trigger the + // cursor invariant (cursor ends before N2 position). + // Body: [CodecID=08][N1=1][record][N2=1] with extra padding at end. + // We pad the body so N2 is not at body.length-1. + const validBody = Buffer.from( + '08010000016B40D8EA30010000000000000000000000000000000000000000000001', + 'hex', + ); + // Append an extra byte to shift the expected N2 position + const tamperedBody = Buffer.concat([validBody, Buffer.from([0x00])]); + // Now body.length-1 = 34, but the parser ends at 32 (end of record), + // triggering the cursor invariant. + + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + + await expect(codec8Handler.handle(tamperedBody, ctx)).rejects.toThrow( + /cursor invariant violated/, + ); + }); + + it('preserves speed=0 verbatim (does not interpret as GPS-invalid)', async () => { + const body = Buffer.alloc(33, 0); + body[0] = 0x08; + body[1] = 0x01; + Buffer.from('0000016B40D8EA30', 'hex').copy(body, 2); + body[10] = 0x01; + // GPS all zeros, including speed = 0x0000 + body[32] = 0x01; + + const positions: Position[] = []; + await codec8Handler.handle(body, makeTestCtx(positions)); + + expect(positions[0]?.speed).toBe(0); + }); +}); diff --git a/test/codec8e.test.ts b/test/codec8e.test.ts new file mode 100644 index 0000000..06b2c5b --- /dev/null +++ b/test/codec8e.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Position } from '../src/core/types.js'; +import type { CodecHandlerContext } from '../src/adapters/teltonika/codec/registry.js'; +import { codec8eHandler } from '../src/adapters/teltonika/codec/data/codec8e.js'; +import { loadFixturesFromDir, compareToExpected } from './fixtures/_loader.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Test context factory +// --------------------------------------------------------------------------- + +function makeTestCtx(positions: Position[]): CodecHandlerContext { + return { + imei: 'FIXTURE', + publish: async (pos: Position) => { + positions.push(pos); + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as CodecHandlerContext['logger'], + }; +} + +// --------------------------------------------------------------------------- +// Auto-discovered fixture tests +// --------------------------------------------------------------------------- + +const fixtureDir = path.join(__dirname, 'fixtures/teltonika/codec8e'); +const fixtures = loadFixturesFromDir(fixtureDir); + +describe('Codec 8E parser — fixture tests', () => { + for (const fixture of fixtures) { + it(`parses ${fixture.name}`, async () => { + const positions: Position[] = []; + const ctx = makeTestCtx(positions); + + const result = await codec8eHandler.handle(fixture.body, ctx); + + expect(result.recordCount).toBe(fixture.expected.ack_record_count); + + const mismatch = compareToExpected(positions, fixture.expected.positions); + expect(mismatch, mismatch ?? '').toBeNull(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Unit tests — 8E-specific behaviours +// --------------------------------------------------------------------------- + +describe('Codec 8E parser — unit tests', () => { + it('codec_id is 0x8E', () => { + expect(codec8eHandler.codec_id).toBe(0x8e); + }); + + it('NX values are Buffer instances (not numbers or strings)', async () => { + // Use fixture 02 (nx-mixed) directly — just re-load and check types + const fixtureBody = Buffer.from( + '8E010000016B40D8EA3001000000000000000000000000000000000000030000000000000000000300010001AB00020008DEADBEEF01020304000300406162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA001', + 'hex', + ); + + const positions: Position[] = []; + await codec8eHandler.handle(fixtureBody, makeTestCtx(positions)); + + expect(positions).toHaveLength(1); + const attrs = positions[0]?.attributes; + expect(Buffer.isBuffer(attrs?.['1'])).toBe(true); + expect(Buffer.isBuffer(attrs?.['2'])).toBe(true); + expect(Buffer.isBuffer(attrs?.['3'])).toBe(true); + }); + + it('NX zero-length value is an empty Buffer', async () => { + const fixtureBody = Buffer.from( + '8E010000016B40D8EA300000000000000000000000000000000000000001000000000000000000010005000001', + 'hex', + ); + const positions: Position[] = []; + await codec8eHandler.handle(fixtureBody, makeTestCtx(positions)); + + const attrVal = positions[0]?.attributes['5']; + expect(Buffer.isBuffer(attrVal)).toBe(true); + expect((attrVal as Buffer).length).toBe(0); + }); + + it('does not set __generation_type attribute', async () => { + // Codec 8E positions must NOT have __generation_type + const fixtureBody = Buffer.from( + '8E010000016B412CEE000100000000000000000000000000000000010005000100010100010011001D00010010015E2C880002000B000000003544C87A000E000000001DD7E06A000001', + 'hex', + ); + const positions: Position[] = []; + await codec8eHandler.handle(fixtureBody, makeTestCtx(positions)); + + expect(positions[0]?.attributes['__generation_type']).toBeUndefined(); + }); + + it('throws on cursor invariant violation', async () => { + // A body with N1=1 but deliberately short + const body = Buffer.from( + '8E010000016B412CEE000100000000000000000000000000000000010005000100010100010011001D00010010015E2C880002000B000000003544C87A000E000000001DD7E06A0000', + 'hex', + ); + // The above is missing the last byte (trailing N2), so body.length - 1 won't be reached + const positions: Position[] = []; + await expect(codec8eHandler.handle(body, makeTestCtx(positions))).rejects.toThrow(); + }); +}); diff --git a/test/fixtures/_loader.ts b/test/fixtures/_loader.ts new file mode 100644 index 0000000..41710c6 --- /dev/null +++ b/test/fixtures/_loader.ts @@ -0,0 +1,242 @@ +/** + * Fixture loader for codec parser tests. + * + * Each fixture is a pair of files: + * .hex — hex-encoded AVL body (CodecID + N1 + records + N2) + * .expected.json — parsed expected output as { positions, ack_record_count } + * + * Special JSON sentinel values: + * "__bigint:" → BigInt(decimal) + * "__buffer_b64:" → Buffer.from(base64, 'base64') + * + * The loader returns typed fixture objects; the auto-discovery helper + * `loadFixturesFromDir` reads all pairs in a directory so adding a new + * fixture file automatically produces a new test. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import type { Position } from '../../src/core/types.js'; + +// --------------------------------------------------------------------------- +// JSON representation types (what lives on disk) +// --------------------------------------------------------------------------- + +type JsonAttributeValue = number | string; // "__bigint:..." | "__buffer_b64:..." | number + +type JsonPosition = { + readonly device_id: string; + readonly timestamp: string; // ISO 8601 + readonly latitude: number; + readonly longitude: number; + readonly altitude: number; + readonly angle: number; + readonly speed: number; + readonly satellites: number; + readonly priority: 0 | 1 | 2; + readonly attributes: Record; +}; + +type FixtureJson = { + readonly positions: readonly JsonPosition[]; + readonly ack_record_count: number; +}; + +// --------------------------------------------------------------------------- +// Sentinel decoders +// --------------------------------------------------------------------------- + +function decodeAttributeValue(raw: JsonAttributeValue): number | bigint | Buffer { + if (typeof raw === 'number') { + return raw; + } + if (raw.startsWith('__bigint:')) { + return BigInt(raw.slice('__bigint:'.length)); + } + if (raw.startsWith('__buffer_b64:')) { + const b64 = raw.slice('__buffer_b64:'.length); + return Buffer.from(b64, 'base64'); + } + throw new Error(`Unknown fixture sentinel value: ${JSON.stringify(raw)}`); +} + +function decodePosition(json: JsonPosition): Position { + const attributes: Record = {}; + for (const [key, value] of Object.entries(json.attributes)) { + attributes[key] = decodeAttributeValue(value); + } + return { + device_id: json.device_id, + timestamp: new Date(json.timestamp), + latitude: json.latitude, + longitude: json.longitude, + altitude: json.altitude, + angle: json.angle, + speed: json.speed, + satellites: json.satellites, + priority: json.priority, + attributes, + }; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type LoadedFixture = { + readonly name: string; + readonly body: Buffer; + readonly expected: { + readonly positions: readonly Position[]; + readonly ack_record_count: number; + }; +}; + +// --------------------------------------------------------------------------- +// Loader +// --------------------------------------------------------------------------- + +/** + * Loads a single fixture pair by base path (without extension). + */ +export function loadFixture(basePath: string): LoadedFixture { + const hexPath = `${basePath}.hex`; + const jsonPath = `${basePath}.expected.json`; + + const rawHex = fs.readFileSync(hexPath, 'utf8'); + // Strip all non-hex characters (whitespace, newlines) + const cleanHex = rawHex.replace(/[^0-9a-fA-F]/g, ''); + const body = Buffer.from(cleanHex, 'hex'); + + const rawJson = fs.readFileSync(jsonPath, 'utf8'); + const parsed = JSON.parse(rawJson) as FixtureJson; + + const positions = parsed.positions.map(decodePosition); + + return { + name: path.basename(basePath), + body, + expected: { + positions, + ack_record_count: parsed.ack_record_count, + }, + }; +} + +/** + * Discovers all fixture pairs in a directory. + * A "fixture pair" is a `.hex` file that also has a corresponding + * `.expected.json` file with the same base name. + * + * Returns fixtures sorted by filename so test order is deterministic. + */ +export function loadFixturesFromDir(dirPath: string): readonly LoadedFixture[] { + const entries = fs.readdirSync(dirPath); + const hexFiles = entries + .filter((f) => f.endsWith('.hex')) + .sort(); + + return hexFiles.map((hexFile) => { + const baseName = hexFile.slice(0, -'.hex'.length); + return loadFixture(path.join(dirPath, baseName)); + }); +} + +// --------------------------------------------------------------------------- +// Deep-equality comparator (bigint + Buffer aware) +// --------------------------------------------------------------------------- + +/** + * Compares two attribute values for equality. Handles number, bigint, and + * Buffer types correctly (vitest's expect().toEqual does not handle Buffer + * and bigint mixed comparisons out of the box in all edge cases). + */ +function attributeValuesEqual( + actual: number | bigint | Buffer, + expected: number | bigint | Buffer, +): boolean { + if (Buffer.isBuffer(actual) && Buffer.isBuffer(expected)) { + return actual.equals(expected); + } + if (typeof actual === 'bigint' && typeof expected === 'bigint') { + return actual === expected; + } + if (typeof actual === 'number' && typeof expected === 'number') { + return actual === expected; + } + return false; +} + +/** + * Deep-equality check between actual parsed positions and expected positions. + * Returns a descriptive mismatch string on failure, or null on success. + */ +export function compareToExpected( + actual: readonly Position[], + expected: readonly Position[], +): string | null { + if (actual.length !== expected.length) { + return `Position count mismatch: got ${actual.length}, expected ${expected.length}`; + } + + for (let i = 0; i < actual.length; i++) { + const a = actual[i]; + const e = expected[i]; + + if (a === undefined || e === undefined) { + return `Undefined position at index ${i}`; + } + + if (a.device_id !== e.device_id) { + return `[${i}] device_id: got "${a.device_id}", expected "${e.device_id}"`; + } + if (a.timestamp.getTime() !== e.timestamp.getTime()) { + return `[${i}] timestamp: got "${a.timestamp.toISOString()}", expected "${e.timestamp.toISOString()}"`; + } + if (a.latitude !== e.latitude) { + return `[${i}] latitude: got ${a.latitude}, expected ${e.latitude}`; + } + if (a.longitude !== e.longitude) { + return `[${i}] longitude: got ${a.longitude}, expected ${e.longitude}`; + } + if (a.altitude !== e.altitude) { + return `[${i}] altitude: got ${a.altitude}, expected ${e.altitude}`; + } + if (a.angle !== e.angle) { + return `[${i}] angle: got ${a.angle}, expected ${e.angle}`; + } + if (a.speed !== e.speed) { + return `[${i}] speed: got ${a.speed}, expected ${e.speed}`; + } + if (a.satellites !== e.satellites) { + return `[${i}] satellites: got ${a.satellites}, expected ${e.satellites}`; + } + if (a.priority !== e.priority) { + return `[${i}] priority: got ${a.priority}, expected ${e.priority}`; + } + + const actualKeys = Object.keys(a.attributes).sort(); + const expectedKeys = Object.keys(e.attributes).sort(); + + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + return `[${i}] attribute keys mismatch: got [${actualKeys.join(', ')}], expected [${expectedKeys.join(', ')}]`; + } + + for (const key of actualKeys) { + const av = a.attributes[key]; + const ev = e.attributes[key]; + if (av === undefined || ev === undefined) { + return `[${i}] attribute "${key}" undefined`; + } + if (!attributeValuesEqual(av, ev)) { + return ( + `[${i}] attribute "${key}": ` + + `got ${Buffer.isBuffer(av) ? `Buffer<${av.toString('hex')}>` : String(av)}, ` + + `expected ${Buffer.isBuffer(ev) ? `Buffer<${ev.toString('hex')}>` : String(ev)}` + ); + } + } + } + + return null; +} diff --git a/test/fixtures/teltonika/README.md b/test/fixtures/teltonika/README.md new file mode 100644 index 0000000..330c4ae --- /dev/null +++ b/test/fixtures/teltonika/README.md @@ -0,0 +1,79 @@ +# Teltonika Fixture Suite + +Binary protocol fixtures for the Codec 8, 8E, and 16 parsers. + +## File format + +Each fixture is a pair: + +| File | Purpose | +|------|---------| +| `.hex` | Hex-encoded AVL body (CodecID + N1 + records + N2) | +| `.expected.json` | Expected `Position[]` output and ACK record count | + +### .hex format + +The body slice only — **not** the full TCP packet (no preamble, no DataFieldLength, no CRC). +This is exactly what `frame.payload` contains and what `handler.handle(body, ctx)` receives. + +Whitespace and newlines are stripped before parsing, so you can write multi-line hex for readability. + +### .expected.json format + +```json +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "21": 3, + "78": "__bigint:0", + "100": "__buffer_b64:AAEC" + } + } + ], + "ack_record_count": 1 +} +``` + +`device_id` is always `"FIXTURE"` — the IMEI comes from the session context, not the body. + +#### Attribute value sentinels + +| Sentinel | Decoded type | Example | +|---------|-------------|---------| +| number literal | `number` | `24079` | +| `"__bigint:"` | `bigint` | `"__bigint:893700218"` | +| `"__buffer_b64:"` | `Buffer` | `"__buffer_b64:qw=="` | + +For a zero-length Buffer, use `"__buffer_b64:"` (empty base64 string). + +## How to add a new fixture + +1. Get the AVL body bytes (from a live capture, a device emulator, or hand-computed). +2. Write the body as hex into `.hex`. +3. Manually trace the expected parse output and write `.expected.json`. +4. Run `pnpm test` — the new fixture is automatically discovered and tested. + +No changes to any test file are needed. + +## Bootstrap vs. synthetic fixtures + +- **Bootstrap** fixtures (01-canonical, etc.) are sourced directly from the Teltonika documentation hex examples. Their expected outputs are read from the doc's parsed tables. +- **Synthetic** fixtures are hand-constructed for edge cases not covered by the canonical examples. Their expected outputs are computed manually and cross-checked. + +## Cross-check methodology + +Synthetic Codec 8 fixtures were cross-checked against the Traccar open-source +decoder (`TeltonikaProtocolDecoder.java`) for field widths and IO section parsing. +No discrepancies were found for the basic N1/N2/N4/N8 sections. +The NX section in Codec 8E has no equivalent in older Traccar versions; it was +verified by byte-level manual trace only. diff --git a/test/fixtures/teltonika/codec16/01-canonical.expected.json b/test/fixtures/teltonika/codec16/01-canonical.expected.json new file mode 100644 index 0000000..130365f --- /dev/null +++ b/test/fixtures/teltonika/codec16/01-canonical.expected.json @@ -0,0 +1,43 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-07-10T12:06:54.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 0, + "attributes": { + "__event": 11, + "__generation_type": 5, + "1": 0, + "3": 0, + "11": 39, + "66": 22074 + } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-07-10T12:06:55.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 0, + "attributes": { + "__event": 11, + "__generation_type": 5, + "1": 0, + "3": 0, + "11": 38, + "66": 22074 + } + } + ], + "ack_record_count": 2 +} diff --git a/test/fixtures/teltonika/codec16/01-canonical.hex b/test/fixtures/teltonika/codec16/01-canonical.hex new file mode 100644 index 0000000..dc6ab35 --- /dev/null +++ b/test/fixtures/teltonika/codec16/01-canonical.hex @@ -0,0 +1 @@ +10020000016BDBC7833000000000000000000000000000000000000B05040200010000030002000B00270042563A00000000016BDBC7871800000000000000000000000000000000000B05040200010000030002000B00260042563A000002 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec16/02-each-generation-type.expected.json b/test/fixtures/teltonika/codec16/02-each-generation-type.expected.json new file mode 100644 index 0000000..3e045cf --- /dev/null +++ b/test/fixtures/teltonika/codec16/02-each-generation-type.expected.json @@ -0,0 +1,61 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 0, "240": 0 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:47.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 1, "240": 1 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:48.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 2, "240": 2 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:49.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 3, "240": 3 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:50.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 4, "240": 4 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:51.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 5, "240": 5 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:52.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 6, "240": 6 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:53.000Z", + "latitude": 0, "longitude": 0, "altitude": 0, "angle": 0, "speed": 0, "satellites": 0, + "priority": 1, + "attributes": { "__event": 10, "__generation_type": 7, "240": 7 } + } + ], + "ack_record_count": 8 +} diff --git a/test/fixtures/teltonika/codec16/02-each-generation-type.hex b/test/fixtures/teltonika/codec16/02-each-generation-type.hex new file mode 100644 index 0000000..d186cb8 --- /dev/null +++ b/test/fixtures/teltonika/codec16/02-each-generation-type.hex @@ -0,0 +1 @@ +10080000016B40D8EA3001000000000000000000000000000000000A00010100F0000000000000016B40D8EE1801000000000000000000000000000000000A01010100F0010000000000016B40D8F20001000000000000000000000000000000000A02010100F0020000000000016B40D8F5E801000000000000000000000000000000000A03010100F0030000000000016B40D8F9D001000000000000000000000000000000000A04010100F0040000000000016B40D8FDB801000000000000000000000000000000000A05010100F0050000000000016B40D901A001000000000000000000000000000000000A06010100F0060000000000016B40D9058801000000000000000000000000000000000A07010100F00700000008 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec16/03-large-io-id.expected.json b/test/fixtures/teltonika/codec16/03-large-io-id.expected.json new file mode 100644 index 0000000..e28034f --- /dev/null +++ b/test/fixtures/teltonika/codec16/03-large-io-id.expected.json @@ -0,0 +1,21 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1024, + "__generation_type": 5, + "1024": 255 + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec16/03-large-io-id.hex b/test/fixtures/teltonika/codec16/03-large-io-id.hex new file mode 100644 index 0000000..bb62a67 --- /dev/null +++ b/test/fixtures/teltonika/codec16/03-large-io-id.hex @@ -0,0 +1 @@ +10010000016B40D8EA300100000000000000000000000000000004000501010400FF00000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8/01-single-record-all-widths.expected.json b/test/fixtures/teltonika/codec8/01-single-record-all-widths.expected.json new file mode 100644 index 0000000..e1eb1d7 --- /dev/null +++ b/test/fixtures/teltonika/codec8/01-single-record-all-widths.expected.json @@ -0,0 +1,24 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1, + "21": 3, + "1": 1, + "66": 24079, + "241": 24602, + "78": "__bigint:0" + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8/01-single-record-all-widths.hex b/test/fixtures/teltonika/codec8/01-single-record-all-widths.hex new file mode 100644 index 0000000..3ddc7b6 --- /dev/null +++ b/test/fixtures/teltonika/codec8/01-single-record-all-widths.hex @@ -0,0 +1 @@ +08010000016B40D8EA30010000000000000000000000000000000105021503010101425E0F01F10000601A014E000000000000000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8/02-single-record-reduced.expected.json b/test/fixtures/teltonika/codec8/02-single-record-reduced.expected.json new file mode 100644 index 0000000..bfd24f8 --- /dev/null +++ b/test/fixtures/teltonika/codec8/02-single-record-reduced.expected.json @@ -0,0 +1,22 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:05:36.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1, + "21": 3, + "1": 1, + "66": 24080 + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8/02-single-record-reduced.hex b/test/fixtures/teltonika/codec8/02-single-record-reduced.hex new file mode 100644 index 0000000..c4ec1af --- /dev/null +++ b/test/fixtures/teltonika/codec8/02-single-record-reduced.hex @@ -0,0 +1 @@ +08010000016B40D9AD80010000000000000000000000000000000103021503010101425E10000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8/03-two-records.expected.json b/test/fixtures/teltonika/codec8/03-two-records.expected.json new file mode 100644 index 0000000..add2ae1 --- /dev/null +++ b/test/fixtures/teltonika/codec8/03-two-records.expected.json @@ -0,0 +1,35 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:01:01.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1, + "1": 0 + } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:01:19.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1, + "1": 1 + } + } + ], + "ack_record_count": 2 +} diff --git a/test/fixtures/teltonika/codec8/03-two-records.hex b/test/fixtures/teltonika/codec8/03-two-records.hex new file mode 100644 index 0000000..5f8a983 --- /dev/null +++ b/test/fixtures/teltonika/codec8/03-two-records.hex @@ -0,0 +1 @@ +08020000016B40D57B480100000000000000000000000000000001010101000000000000016B40D5C19801000000000000000000000000000000010101010100000002 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8/04-empty-io-bag.expected.json b/test/fixtures/teltonika/codec8/04-empty-io-bag.expected.json new file mode 100644 index 0000000..66dbd13 --- /dev/null +++ b/test/fixtures/teltonika/codec8/04-empty-io-bag.expected.json @@ -0,0 +1,19 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 0, + "attributes": { + "__event": 0 + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8/04-empty-io-bag.hex b/test/fixtures/teltonika/codec8/04-empty-io-bag.hex new file mode 100644 index 0000000..65d54b1 --- /dev/null +++ b/test/fixtures/teltonika/codec8/04-empty-io-bag.hex @@ -0,0 +1 @@ +08010000016B40D8EA300000000000000000000000000000000000000000000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8/05-multi-record-large.expected.json b/test/fixtures/teltonika/codec8/05-multi-record-large.expected.json new file mode 100644 index 0000000..2f7962a --- /dev/null +++ b/test/fixtures/teltonika/codec8/05-multi-record-large.expected.json @@ -0,0 +1,125 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 0 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:47.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 1 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:48.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 2 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:49.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 3 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:50.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 4 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:51.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 5 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:52.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 6 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:53.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 7 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:54.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 8 } + }, + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:55.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { "__event": 1, "1": 9 } + } + ], + "ack_record_count": 10 +} diff --git a/test/fixtures/teltonika/codec8/05-multi-record-large.hex b/test/fixtures/teltonika/codec8/05-multi-record-large.hex new file mode 100644 index 0000000..db5e41c --- /dev/null +++ b/test/fixtures/teltonika/codec8/05-multi-record-large.hex @@ -0,0 +1 @@ +080A0000016B40D8EA300100000000000000000000000000000001010101000000000000016B40D8EE180100000000000000000000000000000001010101010000000000016B40D8F2000100000000000000000000000000000001010101020000000000016B40D8F5E80100000000000000000000000000000001010101030000000000016B40D8F9D00100000000000000000000000000000001010101040000000000016B40D8FDB80100000000000000000000000000000001010101050000000000016B40D901A00100000000000000000000000000000001010101060000000000016B40D905880100000000000000000000000000000001010101070000000000016B40D909700100000000000000000000000000000001010101080000000000016B40D90D580100000000000000000000000000000001010101090000000A \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8e/01-canonical.expected.json b/test/fixtures/teltonika/codec8e/01-canonical.expected.json new file mode 100644 index 0000000..678fe38 --- /dev/null +++ b/test/fixtures/teltonika/codec8e/01-canonical.expected.json @@ -0,0 +1,24 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T11:36:32.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 1, + "1": 1, + "17": 29, + "16": 22949000, + "11": "__bigint:893700218", + "14": "__bigint:500686954" + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8e/01-canonical.hex b/test/fixtures/teltonika/codec8e/01-canonical.hex new file mode 100644 index 0000000..32e5bef --- /dev/null +++ b/test/fixtures/teltonika/codec8e/01-canonical.hex @@ -0,0 +1 @@ +8E010000016B412CEE000100000000000000000000000000000000010005000100010100010011001D00010010015E2C880002000B000000003544C87A000E000000001DD7E06A000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8e/02-nx-mixed.expected.json b/test/fixtures/teltonika/codec8e/02-nx-mixed.expected.json new file mode 100644 index 0000000..9f45f08 --- /dev/null +++ b/test/fixtures/teltonika/codec8e/02-nx-mixed.expected.json @@ -0,0 +1,22 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 1, + "attributes": { + "__event": 0, + "1": "__buffer_b64:qw==", + "2": "__buffer_b64:3q2+7wECAwQ=", + "3": "__buffer_b64:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoOEhYaHiImKi4yNjo+QkZKTlJWWl5iZmpucnZ6foA==" + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8e/02-nx-mixed.hex b/test/fixtures/teltonika/codec8e/02-nx-mixed.hex new file mode 100644 index 0000000..41841c1 --- /dev/null +++ b/test/fixtures/teltonika/codec8e/02-nx-mixed.hex @@ -0,0 +1 @@ +8E010000016B40D8EA3001000000000000000000000000000000000000030000000000000000000300010001AB00020008DEADBEEF01020304000300406162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8e/03-nx-zero-length.expected.json b/test/fixtures/teltonika/codec8e/03-nx-zero-length.expected.json new file mode 100644 index 0000000..3e412dd --- /dev/null +++ b/test/fixtures/teltonika/codec8e/03-nx-zero-length.expected.json @@ -0,0 +1,20 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 0, + "attributes": { + "__event": 0, + "5": "__buffer_b64:" + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8e/03-nx-zero-length.hex b/test/fixtures/teltonika/codec8e/03-nx-zero-length.hex new file mode 100644 index 0000000..cca4d1b --- /dev/null +++ b/test/fixtures/teltonika/codec8e/03-nx-zero-length.hex @@ -0,0 +1 @@ +8E010000016B40D8EA300000000000000000000000000000000000000001000000000000000000010005000001 \ No newline at end of file diff --git a/test/fixtures/teltonika/codec8e/04-nx-large-length.expected.json b/test/fixtures/teltonika/codec8e/04-nx-large-length.expected.json new file mode 100644 index 0000000..2632161 --- /dev/null +++ b/test/fixtures/teltonika/codec8e/04-nx-large-length.expected.json @@ -0,0 +1,20 @@ +{ + "positions": [ + { + "device_id": "FIXTURE", + "timestamp": "2019-06-10T10:04:46.000Z", + "latitude": 0, + "longitude": 0, + "altitude": 0, + "angle": 0, + "speed": 0, + "satellites": 0, + "priority": 0, + "attributes": { + "__event": 0, + "7": "__buffer_b64:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/wABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSor" + } + } + ], + "ack_record_count": 1 +} diff --git a/test/fixtures/teltonika/codec8e/04-nx-large-length.hex b/test/fixtures/teltonika/codec8e/04-nx-large-length.hex new file mode 100644 index 0000000..5e20860 --- /dev/null +++ b/test/fixtures/teltonika/codec8e/04-nx-large-length.hex @@ -0,0 +1 @@ +8E010000016B40D8EA300000000000000000000000000000000000000001000000000000000000010007012C000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B01 \ No newline at end of file