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:
@@ -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<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 };
|
||||
},
|
||||
};
|
||||
@@ -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 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
await codec16Handler.handle(body, ctx);
|
||||
|
||||
expect(positions[0]?.attributes['__generation_type']).toBe(3);
|
||||
// Debug should have been called at least once
|
||||
expect(debugFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+242
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Fixture loader for codec parser tests.
|
||||
*
|
||||
* Each fixture is a pair of files:
|
||||
* <name>.hex — hex-encoded AVL body (CodecID + N1 + records + N2)
|
||||
* <name>.expected.json — parsed expected output as { positions, ack_record_count }
|
||||
*
|
||||
* Special JSON sentinel values:
|
||||
* "__bigint:<decimal>" → BigInt(decimal)
|
||||
* "__buffer_b64:<base64>" → 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<string, JsonAttributeValue>;
|
||||
};
|
||||
|
||||
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<string, number | bigint | Buffer> = {};
|
||||
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;
|
||||
}
|
||||
Vendored
+79
@@ -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 |
|
||||
|------|---------|
|
||||
| `<name>.hex` | Hex-encoded AVL body (CodecID + N1 + records + N2) |
|
||||
| `<name>.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:<decimal>"` | `bigint` | `"__bigint:893700218"` |
|
||||
| `"__buffer_b64:<base64>"` | `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 `<name>.hex`.
|
||||
3. Manually trace the expected parse output and write `<name>.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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
10020000016BDBC7833000000000000000000000000000000000000B05040200010000030002000B00270042563A00000000016BDBC7871800000000000000000000000000000000000B05040200010000030002000B00260042563A000002
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
10080000016B40D8EA3001000000000000000000000000000000000A00010100F0000000000000016B40D8EE1801000000000000000000000000000000000A01010100F0010000000000016B40D8F20001000000000000000000000000000000000A02010100F0020000000000016B40D8F5E801000000000000000000000000000000000A03010100F0030000000000016B40D8F9D001000000000000000000000000000000000A04010100F0040000000000016B40D8FDB801000000000000000000000000000000000A05010100F0050000000000016B40D901A001000000000000000000000000000000000A06010100F0060000000000016B40D9058801000000000000000000000000000000000A07010100F00700000008
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
10010000016B40D8EA300100000000000000000000000000000004000501010400FF00000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
08010000016B40D8EA30010000000000000000000000000000000105021503010101425E0F01F10000601A014E000000000000000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
08010000016B40D9AD80010000000000000000000000000000000103021503010101425E10000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
08020000016B40D57B480100000000000000000000000000000001010101000000000000016B40D5C19801000000000000000000000000000000010101010100000002
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
08010000016B40D8EA300000000000000000000000000000000000000000000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
080A0000016B40D8EA300100000000000000000000000000000001010101000000000000016B40D8EE180100000000000000000000000000000001010101010000000000016B40D8F2000100000000000000000000000000000001010101020000000000016B40D8F5E80100000000000000000000000000000001010101030000000000016B40D8F9D00100000000000000000000000000000001010101040000000000016B40D8FDB80100000000000000000000000000000001010101050000000000016B40D901A00100000000000000000000000000000001010101060000000000016B40D905880100000000000000000000000000000001010101070000000000016B40D909700100000000000000000000000000000001010101080000000000016B40D90D580100000000000000000000000000000001010101090000000A
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8E010000016B412CEE000100000000000000000000000000000000010005000100010100010011001D00010010015E2C880002000B000000003544C87A000E000000001DD7E06A000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8E010000016B40D8EA3001000000000000000000000000000000000000030000000000000000000300010001AB00020008DEADBEEF01020304000300406162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8E010000016B40D8EA300000000000000000000000000000000000000001000000000000000000010005000001
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8E010000016B40D8EA300000000000000000000000000000000000000001000000000000000000010007012C000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F606162636465666768696A6B6C6D6E6F707172737475767778797A7B7C7D7E7F808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9FA0A1A2A3A4A5A6A7A8A9AAABACADAEAFB0B1B2B3B4B5B6B7B8B9BABBBCBDBEBFC0C1C2C3C4C5C6C7C8C9CACBCCCDCECFD0D1D2D3D4D5D6D7D8D9DADBDCDDDEDFE0E1E2E3E4E5E6E7E8E9EAEBECEDEEEFF0F1F2F3F4F5F6F7F8F9FAFBFCFDFEFF000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B01
|
||||
Reference in New Issue
Block a user