Files
tcp-ingestion/.planning/phase-1-telemetry/05-codec-8.md
T
julian 90d6a73a60 Sync ROADMAP statuses with landed work; mark 1.10/1.12/1.13 as paused
Tasks 1.1-1.9 marked done with their landing commit SHAs. Tasks 1.10
(observability), 1.12 (production hardening), and 1.13 (device
authority) marked paused with explicit resume triggers — pilot
deployment on real Teltonika hardware takes priority. Task 1.11
remains as next, in slimmed form for the pilot (no /readyz healthcheck
since the metrics endpoint is part of paused 1.10).
2026-04-30 16:49:07 +02:00

5.3 KiB
Raw Blame History

Task 1.5 — Codec 8 parser

Phase: 1 — Inbound telemetry Status: 🟩 Done — landed in commit 381287b Depends on: 1.4, 1.9 (fixture infra) Wiki refs: docs/wiki/concepts/avl-data-format.md § Codec 8, docs/wiki/sources/teltonika-data-sending-protocols.md § Codec 8

Goal

Parse Codec 8 (0x08) AVL data bodies into Position records and publish them via ctx.publish.

Deliverables

  • src/adapters/teltonika/codec/data/codec8.ts exporting codec8Handler: CodecDataHandler with codec_id: 0x08.
  • Helper functions in the same file (or in a sibling gps-element.ts if shared with codec 8E and 16):
    • parseGpsElement(buf, offset): { value: GpsElement; nextOffset: number }
    • parseTimestamp(buf, offset): { value: Date; nextOffset: number }
  • Test file test/codec8.test.ts with at least the three fixtures from the canonical Teltonika example doc plus a synthetic empty-IO fixture and a multi-record fixture.

Specification

AVL record layout (Codec 8)

[Timestamp 8B] [Priority 1B] [GPS Element 15B] [IO Element ...]

Timestamp

8-byte big-endian unsigned integer = milliseconds since UNIX epoch UTC. Convert to Date via new Date(Number(buf.readBigUInt64BE(offset))). Use BigInt arithmetic to avoid the Number precision concern; values are well within safe range until ~year 285k, but be explicit.

Priority

1-byte enum: 0 = Low, 1 = High, 2 = Panic. Reject unexpected values? Decision: accept any value 0255 and pass through — the Teltonika spec lists 02, but treating "unexpected priority" as parser failure feels hostile. Log a debug line if value > 2 but proceed.

Type-narrow to 0 | 1 | 2 only when value is in range; otherwise record as 2 (Panic, the most conservative) and emit a metric teltonika_priority_out_of_range_total. Open question: confirm with operations the right fallback.

GPS Element (15 bytes)

[Longitude 4B][Latitude 4B][Altitude 2B][Angle 2B][Satellites 1B][Speed 2B]
  • Longitude / Latitude: signed 32-bit big-endian integer (two's complement), divided by 1e7 to get decimal degrees. Negative bit handling: buf.readInt32BE(offset) / 1e7 does the right thing because Node's readInt32BE interprets the value as signed.
  • Altitude: 2-byte signed big-endian, meters above sea level.
  • Angle: 2-byte unsigned big-endian, degrees from north pole (0360).
  • Satellites: 1-byte unsigned.
  • Speed: 2-byte unsigned, km/h. Pass through verbatim0x0000 may mean "GPS invalid" but that semantic decision belongs to the Processor.

IO Element (Codec 8 layout)

[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 BE unsigned])
[N4 1B]   then N4 × ([IO ID 1B][Value 4B BE unsigned])
[N8 1B]   then N8 × ([IO ID 1B][Value 8B BE — store as bigint])

Iterate each section and write into position.attributes:

attributes[String(ioId)] = value;

Values:

  • 1-byte → number (read with readUInt8)
  • 2-byte → number (read with readUInt16BE)
  • 4-byte → number (read with readUInt32BE)
  • 8-byte → bigint (read with readBigUInt64BE)

Do not decode signedness for IO values. The spec is silent on per-IO signedness; downstream model-aware code in the Processor handles that. If a downstream interpretation needs signed, it can (unsigned > 0x7FFFFFFF) ? unsigned - 0x100000000 : unsigned itself.

The Event IO ID value is captured into a separate event_io_id attribute — propose: store as attributes['__event'] (or as a typed sibling field on the Position; recommendation: store under attributes['__event'] to keep the Position shape stable and avoid adding a Codec-8-specific field).

Open question: is __event the right key? Alternatives: '_event_io_id', '0' (it's IO ID 0 in some interpretations, but that's a different "0"). Decide before merging task 1.5.

Record loop

After parsing N1 (number of records from the framing layer), loop N1 times producing one Position per record. Validate that the position of the cursor at the end equals the start of the trailing N2 byte; mismatch is a parser bug → throw with a structured error including offset.

Acceptance criteria

  • All three canonical doc examples (single record with all IO widths; single record with reduced IO; two records) parse to the expected Position[] byte-for-byte (verified via the fixture suite from task 1.9).
  • CRC validation already happened upstream (task 1.4); this task does not re-check.
  • Cursor-end-equals-N2 invariant holds for every fixture.
  • Position.timestamp round-trips: new Date(...).toISOString() matches the doc example's GMT: Monday, June 10, 2019, 10:04:46 AM for the first fixture.
  • All IO IDs from the fixture appear in attributes with correct numeric/bigint types.

Risks / open questions

  • The Event IO ID field semantics. Storing under '__event' keeps things flexible but adds a magic key. Discuss with Processor implementer before settling.
  • 8-byte values as bigint complicate JSON serialization. Task 1.8 (publisher) must handle this — recommend serializing as a string with a sentinel, e.g. "123n" or { "_bigint": "123" }. Keep parser side clean (real bigint); push encoding to the publish boundary.

Done

(Fill in once complete.)