c8a5f4cd68
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.
119 lines
5.3 KiB
Markdown
119 lines
5.3 KiB
Markdown
# 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.)
|