# Task 2.6 — Codec 14 encoder + ACK/nACK handler **Phase:** 2 — Outbound commands **Status:** ⬜ Not started **Depends on:** 2.5 (shares utility code), 2.3, 2.4 **Wiki refs:** `docs/wiki/sources/teltonika-data-sending-protocols.md` § Codec 14, `docs/wiki/concepts/phase-2-commands.md` ## Goal Encode Codec 14 (`0x0E`) command frames with embedded IMEI; parse responses with both ACK (`0x06`) and **nACK (`0x11`)** types. ## Deliverables - `src/adapters/teltonika/codec/command/codec14.ts`: - `encodeCodec14Command(imei: string, payload: string): Buffer`. - `parseCodec14Response(buf: Buffer): { kind: 'ack'; imei: string; text: string } | { kind: 'nack'; imei: string } | { kind: 'unexpected'; reason: string }`. - `codec14CommandHandler: CodecDataHandler` registered for codec ID `0x0E`. - Test file `test/codec14.test.ts` covering: doc canonical example (`getver` round trip with both ACK and nACK responses). ## Specification ### Frame structure (server → device) ``` [Preamble 4B] [DataSize 4B] ← from CodecID through CmdQty2 [CodecID 1B = 0x0E] [CmdQty1 1B = 0x01] [Type 1B = 0x05] [CmdSize 4B] ← command bytes + 8 (IMEI size) [IMEI 8B HEX] ← e.g. IMEI 123456789123456 → 0x0123456789123456 [Command X B] ← ASCII command bytes [CmdQty2 1B = 0x01] [CRC 4B] ``` **IMEI encoding rule:** the device IMEI is encoded as 8 bytes in HEX. For a 15-digit IMEI like `352093081452251`, prepend a leading zero (`0352093081452251`) and parse as a 16-hex-char value → 8 bytes: `0x03 52 09 30 81 45 22 51`. **Not** ASCII like the handshake. ```ts function imeiToHex(imei: string): Buffer { // 15 digits → prepend "0" → 16 hex chars → 8 bytes const padded = imei.padStart(16, '0'); if (!/^\d{16}$/.test(padded)) throw new Error(`bad IMEI: ${imei}`); return Buffer.from(padded, 'hex'); } ``` ### Response structure (device → server) Two cases: **ACK** (`Type = 0x06`): IMEI matched; command executed. ``` [Preamble][DataSize][CodecID 0x0E][RspQty1][Type 0x06][RspSize][IMEI 8B][Response X B][RspQty2][CRC] ``` **nACK** (`Type = 0x11`): IMEI did not match; command not executed. ``` [Preamble][DataSize][CodecID 0x0E][RspQty1][Type 0x11][RspSize=0x08][IMEI 8B][RspQty2][CRC] ``` Note: nACK has `RspSize = 8` (the IMEI itself counts), no Response bytes. ### Parser ```ts export function parseCodec14Response(body: Buffer): | { kind: 'ack'; imei: string; text: string } | { kind: 'nack'; imei: string } | { kind: 'unexpected'; reason: string } { const codecId = body[0]; if (codecId !== 0x0E) return { kind: 'unexpected', reason: `wrong codec 0x${codecId.toString(16)}` }; const type = body[2]; const rspSize = body.readUInt32BE(3); const imeiHex = body.subarray(7, 15).toString('hex'); const imei = imeiHex.replace(/^0+/, ''); // strip leading zero used for padding if (type === 0x06) { const text = body.subarray(15, 15 + rspSize - 8).toString('ascii'); return { kind: 'ack', imei, text }; } if (type === 0x11) { return { kind: 'nack', imei }; } return { kind: 'unexpected', reason: `unknown response type 0x${type.toString(16)}` }; } ``` ### Mapping to `commands:responses` The command consumer (task 2.4) handles all three outcomes: - `ack` → `status = 'responded'`, `response = text`. - `nack` → `status = 'failed'`, `failure_reason = 'imei_mismatch'`. The command was *delivered* but rejected — important nuance for operator dashboards. - `unexpected` → `status = 'failed'`, `failure_reason = 'protocol_error'`. ### Firmware version requirement Codec 14 requires FMB.Ver.03.25.04.Rev.00 or newer. Older firmware will not understand the codec ID and may close the connection. The Phase 2 design relies on Directus knowing which devices support which codecs (potentially a `firmware_version` column on a `devices` collection). The Ingestion service does not enforce this; it just sends what it's told. > **Open question:** should we expose a metric `teltonika_codec14_unexpected_total` to detect cases where Codec 14 was sent but the device closed the connection (suggesting outdated firmware)? Probably yes; add to task 2.6 deliverables and the metrics inventory. ## Acceptance criteria - [ ] `encodeCodec14Command('352093081452251', 'getver')` produces the canonical doc bytes exactly: `00000000000000160E01050000000E0352093081452251676574766572010000D2C1`. - [ ] `parseCodec14Response` correctly decodes the doc's ACK response (IMEI + version string). - [ ] `parseCodec14Response` correctly decodes the doc's nACK response (IMEI mismatch case). - [ ] An end-to-end test simulates a device that ACKs Codec 14 and a device that nACKs; verify both terminal statuses land in `commands:responses` correctly. - [ ] IMEI HEX encoding round-trips through `imeiToHex` and the response parser. ## Risks / open questions - nACK with `RspSize` not equal to 8 is malformed but we should fail safe (treat as `unexpected`) rather than read past buffer bounds. - Should the Ingestion service also log the IMEI from the nACK response (which is the *server's claim*) and compare to the *actual* IMEI of the connection (from handshake)? If they differ, something seriously wrong is happening. **Yes — log at error if they differ.** Add to acceptance criteria. ## Done (Fill in once complete.)