Files
julian c8a5f4cd68 Add Phase 1 and Phase 2 planning documents
ROADMAP plus granular task files per phase. Phase 1 (12 tasks + 1.13
device authority) covers Codec 8/8E/16 telemetry ingestion; Phase 2
(6 tasks) covers Codec 12/14 outbound commands; Phase 3 enumerates
deferred items.
2026-04-30 15:50:49 +02:00

6.0 KiB

Task 2.5 — Codec 12 encoder + handler

Phase: 2 — Outbound commands Status: Not started Depends on: 2.3, 2.4 Wiki refs: docs/wiki/sources/teltonika-data-sending-protocols.md § Codec 12, docs/wiki/concepts/phase-2-commands.md

Goal

Encode Codec 12 (0x0C) command frames for outbound delivery; parse Codec 12 response frames coming back from devices.

Deliverables

  • src/adapters/teltonika/codec/command/codec12.ts:
    • encodeCodec12Command(payload: string): Buffer produces the on-the-wire byte sequence.
    • parseCodec12Response(buf: Buffer): { kind: 'ack' | 'unexpected'; text: string } parses an inbound response frame.
    • A codec12CommandHandler: CodecDataHandler that the inbound framing layer (task 1.4) registers for codec ID 0x0C. This handler does not produce Position records; it routes the response payload to the per-socket write queue's notifyResponse.
  • Test file test/codec12.test.ts with at least:
    • The two canonical doc examples (getinfo request + response, getio request + response).
    • One synthetic command with non-ASCII bytes in the payload to verify HEX encoding.

Specification

Frame structure (server → device)

[Preamble 4B = 0x00000000]
[DataSize 4B BE]                    ← from CodecID through CmdQty2 inclusive
[CodecID 1B = 0x0C]
[CmdQty1 1B = 0x01]
[Type 1B = 0x05]                    ← 0x05 = command from server
[CmdSize 4B BE]                     ← length of command payload bytes
[Command X B]                       ← ASCII command, encoded as raw bytes (NOT hex-encoded)
[CmdQty2 1B = 0x01]
[CRC 4B BE]                         ← CRC-16/IBM, lower 2 bytes; computed over CodecID..CmdQty2

Encoder pseudocode:

export function encodeCodec12Command(payload: string): Buffer {
  const cmd = Buffer.from(payload, 'ascii');
  const cmdSize = cmd.length;
  const dataSize = 1 + 1 + 1 + 4 + cmdSize + 1; // CodecID + CmdQty1 + Type + CmdSize + Command + CmdQty2
  const out = Buffer.alloc(4 + 4 + dataSize + 4); // Preamble + DataSize + body + CRC
  let off = 0;
  out.writeUInt32BE(0, off); off += 4;
  out.writeUInt32BE(dataSize, off); off += 4;
  out.writeUInt8(0x0C, off); off += 1;
  out.writeUInt8(0x01, off); off += 1;
  out.writeUInt8(0x05, off); off += 1;
  out.writeUInt32BE(cmdSize, off); off += 4;
  cmd.copy(out, off); off += cmdSize;
  out.writeUInt8(0x01, off); off += 1;
  const body = out.subarray(8, 8 + dataSize); // CodecID through CmdQty2
  const crc = crc16Ibm(body);
  out.writeUInt32BE(crc, off);
  return out;
}

Verify against the canonical doc's getinfo example: input getinfo → output 000000000000000F0C010500000007676574696E666F0100004312.

Response structure (device → server)

Identical frame shape, but Type = 0x06:

[Preamble 4B][DataSize 4B][CodecID 0x0C][RspQty1 1B][Type 0x06][RspSize 4B][Response X B][RspQty2 1B][CRC 4B]

The response field is ASCII text, e.g. INI:2019/7/22 7:22 RTC:....

Parser:

export function parseCodec12Response(body: Buffer): { kind: 'ack'; text: string } | { kind: 'unexpected'; reason: string } {
  // body is post-framing-layer: starts at CodecID
  const codecId = body[0];
  if (codecId !== 0x0C) return { kind: 'unexpected', reason: `wrong codec ${codecId.toString(16)}` };
  const rspQty1 = body[1];
  const type = body[2];
  if (type !== 0x06) return { kind: 'unexpected', reason: `expected response type 0x06, got 0x${type.toString(16)}` };
  const rspSize = body.readUInt32BE(3);
  const text = body.subarray(7, 7 + rspSize).toString('ascii');
  // body[7 + rspSize] is RspQty2; CRC was already validated upstream.
  return { kind: 'ack', text };
}

Routing inbound responses to the right command

The inbound framing layer (task 1.4) sees a frame with codec 0x0C and dispatches to codec12CommandHandler. That handler retrieves the session's SocketWriteQueue (from the session context) and calls queue.notifyResponse(rawBody). The write queue's awaitResponse promise resolves with the body; the command consumer (task 2.4) then calls parseCodec12Response to extract the text.

This is the seam where Phase 2 plugs into Phase 1's framing layer. Phase 1 already supports it because:

  1. The codec dispatch is a registry — Phase 2 just registers a new handler.
  2. Phase 1's handler interface returns { recordCount: number } for ACK count. For Codec 12, the device does not expect a record-count ACK — responses are inherently their own ACK. The handler returns { recordCount: 0 } and the framing layer's ACK send path skips the write when recordCount is 0. Update task 1.4 to honor this if not already.

Open question: is recordCount: 0 the right signal to skip ACK? Or should the handler interface return { ack: Buffer | null } instead? The latter is cleaner. Recommendation: add an explicit ack return slot to CodecDataHandler in this task and update the data codec handlers to return { ack: makeRecordCountAck(n) }. Phase 2's command handlers return { ack: null }.

Acceptance criteria

  • encodeCodec12Command('getinfo') produces the canonical doc bytes exactly (compare hex strings).
  • parseCodec12Response correctly decodes the doc's getinfo response into the INI:2019/7/22... ASCII string.
  • An end-to-end test: simulate a device that responds to a Codec 12 command, verify the round-trip command_id → encoded frame → device response → parsed text → published to commands:responses.
  • CRC of every encoded frame validates against crc16Ibm.
  • An incoming Codec 12 frame with Type != 0x06 is logged at warn (unexpected protocol direction) and not surfaced to the command consumer.

Risks / open questions

  • The interface change (returning { ack } instead of { recordCount }) is a Phase 1 retrofit. Cost: minor — three Phase 1 codec handlers update their return shape. Benefit: cleaner Phase 2 plug-in.
  • The getinfo canonical CRC in the doc is 0x00004312. Verify the encoder matches before declaring done.

Done

(Fill in once complete.)