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).
5.3 KiB
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.tsexportingcodec8Handler: CodecDataHandlerwithcodec_id: 0x08.- Helper functions in the same file (or in a sibling
gps-element.tsif 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.tswith 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 0–255 and pass through — the Teltonika spec lists 0–2, 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
1e7to get decimal degrees. Negative bit handling:buf.readInt32BE(offset) / 1e7does the right thing because Node'sreadInt32BEinterprets the value as signed. - Altitude: 2-byte signed big-endian, meters above sea level.
- Angle: 2-byte unsigned big-endian, degrees from north pole (0–360).
- Satellites: 1-byte unsigned.
- Speed: 2-byte unsigned, km/h. Pass through verbatim —
0x0000may 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 withreadUInt8) - 2-byte →
number(read withreadUInt16BE) - 4-byte →
number(read withreadUInt32BE) - 8-byte →
bigint(read withreadBigUInt64BE)
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
__eventthe 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.timestampround-trips:new Date(...).toISOString()matches the doc example'sGMT: Monday, June 10, 2019, 10:04:46 AMfor the first fixture.- All IO IDs from the fixture appear in
attributeswith correct numeric/bigint types.
Risks / open questions
- The
Event IO IDfield semantics. Storing under'__event'keeps things flexible but adds a magic key. Discuss with Processor implementer before settling. - 8-byte values as
bigintcomplicate 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 (realbigint); push encoding to the publish boundary.
Done
(Fill in once complete.)