Files
docs/raw/teltonika-ingestion-architecture.md
T
julian 22b1b069df Bootstrap LLM-maintained wiki with TRM architecture knowledge
Initialize CLAUDE.md schema, index, and log; ingest three architecture
sources (system overview, Teltonika ingestion design, official Teltonika
data-sending protocols) into 7 entity pages, 8 concept pages, and 3
source pages with wikilink cross-references.
2026-04-30 13:20:17 +02:00

499 lines
33 KiB
Markdown

# Teltonika Ingestion — Architecture Reference
**Document type:** Architecture reference
**Scope:** Design of the Teltonika protocol adapter inside the TCP Ingestion service
**Audience:** Engineering, future contributors
**Related:** [`gps-tracking-architecture.md`](./gps-tracking-architecture.md) — system-level overview
**Project location:** [`tcp-ingestion/`](../tcp-ingestion/) — repository root for this service
---
## 1. Purpose and scope
This document describes how the TCP Ingestion service handles **Teltonika** GPS hardware. It defines the protocol surface the service must support, how the parser is structured, and the boundary between framing concerns (which belong here) and interpretation concerns (which belong in the Processor).
The design goal is simple and load-bearing: **ingest telemetry from any Teltonika device, including models we have never seen, without code changes to the parser.** This is achievable because the Teltonika protocol is self-describing — the codec is announced in the frame header, and the device-specific telemetry is carried as an opaque key-value bag that the Ingestion layer is not responsible for interpreting.
This document does **not** cover:
- The vendor-agnostic Ingestion shell (covered in the system architecture).
- IO element semantics per device model (a Processor concern, driven by configuration).
- Business logic, geofencing, or downstream consumers.
---
## 2. Project location and layout
This service lives at the repository path **`tcp-ingestion/`** — a single Node.js/TypeScript project that contains both the vendor-agnostic shell (TCP listeners, Redis publishing, configuration loading, observability) and all per-vendor protocol adapters. Today the only adapter is Teltonika; future vendors are added as sibling folders under `adapters/`.
All file paths in this document are relative to `tcp-ingestion/` unless explicitly prefixed otherwise. When a section refers to `adapters/teltonika/codec/data/`, the absolute path is `tcp-ingestion/adapters/teltonika/codec/data/`.
The recommended layout:
```
tcp-ingestion/
├── src/
│ ├── core/ # vendor-agnostic, no adapter imports
│ │ ├── types.ts # Position type, Codec/Adapter interfaces
│ │ ├── publish.ts # Redis Streams publisher
│ │ ├── registry.ts # codec-ID → handler registry
│ │ ├── session.ts # generic socket session loop
│ │ └── server.ts # net.createServer bootstrap
│ ├── adapters/
│ │ └── teltonika/
│ │ ├── index.ts # adapter entry — wires codecs into the registry
│ │ ├── handshake.ts # IMEI handshake (length-prefixed ASCII)
│ │ ├── frame.ts # outer envelope: preamble, length, codec ID, CRC
│ │ ├── crc.ts # CRC-16/IBM
│ │ └── codec/
│ │ ├── data/ # Phase 1 — device → server telemetry
│ │ │ ├── codec8.ts
│ │ │ ├── codec8e.ts
│ │ │ └── codec16.ts
│ │ └── command/ # Phase 2 — server → device commands
│ │ ├── codec12.ts
│ │ ├── codec13.ts
│ │ └── codec14.ts
│ ├── config/
│ │ └── load.ts # port/adapter bindings from env
│ ├── observability/
│ │ ├── logger.ts
│ │ └── metrics.ts # Prometheus exporter
│ └── main.ts # process entry point
├── test/
│ └── fixtures/
│ └── teltonika/ # hex captures + expected Position[] (§5.6)
│ ├── codec8/
│ ├── codec8e/
│ └── codec16/
├── Dockerfile
├── package.json
├── tsconfig.json
└── README.md
```
Three rules govern this layout:
1. **`core/` never imports from `adapters/`.** The shell is vendor-agnostic by construction. If a piece of code in `core/` needs to know about Teltonika, it belongs in `adapters/teltonika/` instead.
2. **Adapters never import from one another.** `adapters/teltonika/` and any future `adapters/queclink/` are independent. Shared utilities (e.g. length-prefix buffer accumulation) live in `core/`, not in a sibling adapter.
3. **The Teltonika folder is self-contained.** Lifting Teltonika out into its own service later (see the system architecture's discussion of monolith-vs-services) is a `git mv adapters/teltonika/ ../gps-ingest-teltonika/src/adapter/` plus a copy of `core/`. No untangling required.
The Phase 2 command codecs (`codec/command/`) are listed here so the directory structure is allocated up front. The folder is empty in Phase 1; nothing imports from it. This is deliberate — when Phase 2 lands, contributors do not need to invent a location for the new code or risk drift from the parsers in `codec/data/`.
---
## 3. Scope of protocol support
Teltonika defines two families of codecs:
- **Data-sending codecs** — device → server AVL telemetry (positions and IO elements).
- **GPRS command codecs** — bidirectional command channel (server-issued configuration commands and device responses).
The Ingestion service supports these in two phases:
| Phase | Codecs | Purpose | Status |
|-------|--------|---------|--------|
| **Phase 1 — Now** | Codec 8, Codec 8 Extended (8E), Codec 16 | Device-to-server telemetry ingestion | In scope |
| **Phase 2 — Later** | Codec 12, Codec 13, Codec 14 | Server-to-device commands (configuration, remote actions) | Reserved, not implemented |
Phase 1 covers essentially the entire deployed Teltonika telemetry fleet. Phase 2 is a distinct feature with different security implications (writing to devices, not just reading from them) and is intentionally deferred until the platform actually needs to issue commands. The architecture leaves room for it without requiring it now — see [§8. Forward compatibility for command codecs](#8-forward-compatibility-for-command-codecs).
---
## 4. Why the parser is model-agnostic
The Teltonika protocol is structured so that the device's **model** is irrelevant to the parser. What the parser cares about is the **codec ID**, which is announced in the first byte of every AVL data payload.
Every Teltonika TCP AVL data frame has the same outer envelope:
```
┌──────────────┬──────────────┬───────────┬─────────────┬─────┐
│ Preamble │ Data length │ Codec ID │ AVL data │ CRC │
│ 4 bytes (0) │ 4 bytes │ 1 byte │ N bytes │ 4 B │
└──────────────┴──────────────┴───────────┴─────────────┴─────┘
```
The codec ID byte selects the parser for the AVL data section. Inside an AVL record, the fixed fields (timestamp, latitude, longitude, altitude, angle, satellites, speed) are always present in the same shape for a given codec; only the **IO element bag** varies between device models, and the bag is carried as `{ id → value }` pairs that the parser reads byte-correctly without needing to know what each `id` means.
This means a brand-new Teltonika model the team has never seen will:
- Parse correctly at the frame and codec layers (envelope, CRC, codec dispatch all unchanged).
- Produce a correct `Position` record (lat, lon, speed, timestamp are codec-defined, not model-defined).
- Carry its IO elements through as raw integer keys until a model-aware mapping is added downstream.
The Ingestion service's job ends at the normalized `Position`. Naming and interpreting IO elements is explicitly a Processor concern, driven by per-model configuration.
---
## 5. Design principles
The following six principles govern the Teltonika adapter. They are listed in priority order — when they conflict, earlier principles win.
### 5.1 Implement Codec 8, 8E, and 16 — the closed set for telemetry
Phase 1 ships with three codec parsers and no others on the data-sending side:
- **Codec 8** — the baseline AVL data format. 8-bit IO element IDs. Older firmware.
- **Codec 8 Extended (8E)** — same record shape as Codec 8, but 16-bit IO element IDs (Teltonika ran out of 8-bit slots) and variable-length IO values. This is the default on most modern FMB/FMC/FMM/FMU devices.
- **Codec 16** — adds a generation type per record. Less common but appears in some configurations.
These three cover the deployed Teltonika fleet for telemetry purposes. The set is closed: Teltonika releases new data-sending codecs rarely, and when they do, support is added by registering a new parser keyed on the codec ID byte — the rest of the pipeline is unaffected.
The parser dispatch is a flat lookup, not an inheritance hierarchy. There is no "shared base codec" — codec parsers are independent because their record shapes diverge in ways that abstraction would obscure rather than help.
### 5.2 Defer Codec 12, 13, and 14 — server-to-device commands
The command codecs are out of scope for Phase 1:
- **Codec 12** — server-issued commands and device responses (text strings).
- **Codec 13** — same as 12, with timestamps.
- **Codec 14** — same as 12, addressed by IMEI (multi-device server scenarios).
These are deferred because they are a **distinct feature, not an incremental codec**. Supporting them requires:
- A way to enqueue commands targeted at specific devices.
- Routing those commands to whichever Ingestion instance currently holds the device's connection.
- Permissioned APIs upstream so commands cannot be issued by unauthorized callers.
- Audit trails for every command issued and every response received.
None of this is needed to read telemetry. Building it speculatively would either ship dead code or, worse, ship half-built infrastructure that someone later mistakes for usable. The Phase 1 service ignores command codecs entirely; Phase 2 will introduce them as a deliberate addition. See [§8. Forward compatibility for command codecs](#8-forward-compatibility-for-command-codecs) for the seams that make this addition cheap.
### 5.3 Pass the IO map through unchanged
The Ingestion service does **not** name, interpret, or filter IO elements. It produces records of this shape:
```ts
type Position = {
device_id: string // IMEI from the handshake
timestamp: Date // from the AVL record's GPS timestamp
latitude: number
longitude: number
altitude: number
angle: number // heading, 0-360
speed: number // km/h
satellites: number
priority: number // codec-defined priority field
attributes: {
[io_id: string]: number | bigint | Buffer
}
}
```
`attributes` is a verbatim representation of the IO element bag from the AVL record, keyed by the numeric IO element ID as a string. No renaming. No unit conversion. No model lookup.
This is deliberate. Two reasons:
- **Model-specific interpretation belongs where the model is known.** The Processor configures per-model IO mappings (e.g. `{ "FMB920": { "16": "odometer_km", "240": "movement" } }`); doing this in the parser would couple Ingestion to a registry of every device model in the fleet.
- **A new model never breaks Ingestion.** If we receive a packet with `IO 1234` from a device whose config we have not yet imported, the parser stores the raw value under `"1234"` and moves on. The position is still useful; the attribute is recoverable later.
Adapters do not own the IO dictionary. They produce raw IO maps.
### 5.4 Log unknown codec IDs and drop the connection
When a packet's codec ID byte does not match a parser the service knows about, the connection is closed without sending an ACK. Specifically:
- A `WARN`-level log entry is emitted with the IMEI, the offending codec ID, and the raw header bytes.
- The socket is destroyed.
- No partial attempt is made to "skip ahead" or guess the record layout.
The reasoning: a Teltonika device sending an unrecognized codec is **misconfigured**, not subtly broken. Silently truncating its data — or worse, mis-parsing it — produces records with plausible-looking but wrong coordinates. A loud failure (the device reconnects, fails again, shows up in logs) is strictly better than a quiet corruption.
Phase 2 will widen the recognized set to include 12/13/14 on the same socket; until then, those codec IDs are treated like any other unknown value.
### 5.5 Validate the CRC; NACK on mismatch
Every AVL data frame ends with a 4-byte CRC-16/IBM (the lower 2 bytes carry the CRC; the upper 2 are zero). The parser computes the CRC over the data section and compares.
On mismatch:
- The frame is **not** ACK'd. Teltonika devices retransmit unacknowledged data on the next session, so a missing ACK is the correct way to ask for a resend.
- A `WARN` log entry records the IMEI, expected CRC, computed CRC, and frame length.
- The connection remains open — a single corrupt frame is a transient transmission issue, not a reason to drop the session.
Repeated CRC failures from the same device within a short window indicate a deeper problem (firmware bug, line-quality issue) and should be surfaced through metrics, not just logs. See [§7. Observability](#7-observability).
### 5.6 Maintain a fixture suite of real packet captures
Binary protocol bugs are silent. A wrong byte offset produces wrong coordinates, not an exception. The parser cannot rely on type checking, schema validation, or runtime errors to catch its own mistakes — the only reliable defense is regression tests against known-good captures.
The fixture suite consists of:
- **Hex dumps of real frames** from devices we have ingested in production, one per codec (8, 8E, 16) at minimum, ideally several per codec covering different IO element shapes.
- **Hex dumps from Teltonika's official protocol documentation** — these are the canonical reference captures and should always pass.
- **Synthetic edge cases** — empty IO bags, maximum-size IO values, multi-record frames, frames near the length limit.
Each fixture is paired with the expected parsed output (a `Position[]`). Tests run on every CI build. New captures are added whenever a new device model is onboarded or a parser bug is fixed in production — the fix and the regression fixture are always committed together.
A fixture suite is not optional infrastructure. It is the only place the parser's correctness is actually verified.
---
## 6. The TCP session lifecycle
For reference, the full happy-path session flow under Phase 1:
1. **Device connects** to the Teltonika port (per-vendor port, see system architecture §3).
2. **IMEI handshake**:
- Device sends 2 bytes of length followed by the ASCII IMEI.
- Server responds with `0x01` to accept (or `0x00` to reject; current Phase 1 always accepts).
3. **AVL data loop** — repeatedly:
- Read 4-byte preamble (must be `0x00000000`; anything else is a framing error).
- Read 4-byte data length.
- Read `length` bytes of payload.
- Read 4-byte CRC.
- Validate CRC. On mismatch, do not ACK; log; continue reading.
- Inspect byte 0 of payload — the codec ID.
- Dispatch to the matching codec parser.
- For each AVL record produced, build a normalized `Position` and `publish` it to the Redis Stream.
- Send 4-byte big-endian record-count ACK back to the device.
4. **Connection close** — device-initiated or due to network failure. The session ends; the device will reconnect when it has more data. No state is preserved across sessions.
Phase 2 will add a parallel branch after step 3's codec dispatch: codec IDs 12/13/14 will route to a command-codec handler instead of the AVL parser. The framing envelope (steps 1, 2, and the read part of 3) is shared.
---
## 7. Observability
Per-adapter metrics for Teltonika:
- **`teltonika_connections_active`** — gauge of currently open device sessions.
- **`teltonika_handshake_total{result="accepted|rejected|malformed"}`** — IMEI handshake outcomes.
- **`teltonika_frames_total{codec="8|8E|16|unknown", result="ok|crc_fail|truncated"}`** — frame-level outcomes, partitioned by codec.
- **`teltonika_records_published_total{codec}`** — AVL records emitted to the Redis Stream.
- **`teltonika_parse_duration_seconds{codec}`** — histogram of per-frame parse time.
- **`teltonika_unknown_codec_total{codec_id}`** — counter of dropped connections due to unrecognized codec IDs. A non-zero rate here is an alert: it means devices are configured for codecs the service does not support.
The "unknown codec" counter is the canary for codec coverage drift — if Teltonika ships a new codec or a customer reconfigures devices to use one we have not implemented, this metric will surface it immediately.
---
## 8. Forward compatibility for command codecs
Phase 2 adds outbound command support (codecs 12, 13, 14). The Phase 1 design accommodates this without requiring upfront work, by respecting three seams:
### 8.1 The codec dispatch is a registry, not a switch
The codec ID byte indexes into a registered set of handlers. Each handler implements the same interface:
```ts
interface CodecHandler {
codec_id: number
handle(
payload: Buffer,
ctx: { imei: string; publish: (p: Position) => Promise<void> }
): Promise<{ ack_count: number }>
}
```
Phase 1 registers handlers for IDs 8, 8E (`0x8E`), and 16. Phase 2 registers additional handlers for 12, 13, 14 — these handlers will use a different shape of `ctx` (they need to send bytes back, not just `publish`), but the registry shape is the same.
### 8.2 The session owns the socket; the codec handler borrows it
Today, codec handlers receive the parsed payload and emit `Position` records via `publish`. They do not write to the socket directly — the session loop handles ACKs.
Phase 2's command handlers will need to **write to the socket** (to send commands to the device). This is supported by passing a `respond(bytes: Buffer)` callback into the handler context for command codecs, alongside `publish`. The session retains ownership of the socket; handlers borrow write access through a narrow interface.
### 8.3 Connection-to-device routing is a future-Phase-2 concern
To send a command to a specific device, the platform must route the command to whichever Ingestion instance currently holds that device's open socket. Phase 1 does not need this (no commands exist). Phase 2 will introduce a connection registry (likely a Redis hash mapping `imei → instance_id`) and a command queue per instance. None of this affects Phase 1 code; it is additive.
The Phase 1 design intentionally keeps the per-device session state local to the socket (no shared state, no registry). When Phase 2 lands, the registry is added alongside, not woven through.
---
## 9. Phase 2 — Outbound commands
This section specifies the command-delivery design that Phase 2 will introduce. It is included now so that Phase 1 code can respect the seams; no Phase 2 code is shipped until the feature is actually needed.
### 9.1 Architectural posture
The Ingestion service **does not expose user-facing HTTP endpoints**, in Phase 1 or Phase 2. This is non-negotiable: the system architecture (`gps-tracking-architecture.md` §5.1, §7.5) places all user-facing API surface inside Directus, and any deviation would split the auth surface, couple deployment lifecycles, and contaminate failure domains.
Commands originate at the SPA, are authorized and persisted by Directus, and reach the Ingestion instance that holds the device's socket via Redis. The Ingestion service only learns about commands by consuming its own stream — it never accepts inbound user-facing traffic.
End-to-end flow:
```
SPA ──HTTPS+JWT──▶ Directus ──XADD──▶ Redis Streams ──XREAD──▶ Ingestion
│ │
│ │ writes Codec 12 frame
│ │ to device socket
│ │
SPA ◀──WSS subscription── Directus ◀──hook on insert── commands:responses (XADD by Ingestion)
```
Five properties to hold onto:
1. **Single auth surface.** Directus enforces "can this user issue commands to this device?" — the same machinery that authorizes every other write in the system. The Ingestion service treats anything that arrives on its command stream as already-authorized.
2. **Commands are data before they are transport.** Every command is a row in a `commands` collection before it is delivered. The Redis stream is the transport, not the source of truth.
3. **Symmetric to inbound telemetry.** Inbound: device → Ingestion → Redis → Processor → Directus → SPA. Outbound: SPA → Directus → Redis → Ingestion → device. Same plane boundary, same seam, same operational tools.
4. **Per-instance routing via a connection registry.** Only the instance holding the device's socket can deliver the command, so the registry maps `imei → instance_id` and the issuer publishes to that instance's stream specifically.
5. **Real-time status updates for free.** Directus's WebSocket subscriptions on the `commands` collection push delivery status to the SPA without any new transport.
### 9.2 The `commands` collection
Owned by Directus. The schema:
| Field | Type | Notes |
|-------|------|-------|
| `id` | uuid | Primary key. Used as the correlation ID across the system. |
| `target_imei` | string | The device the command is bound for. |
| `batch_id` | uuid (nullable) | Set when the command is part of a fleet operation; null for single-device commands. |
| `codec` | enum (`12`, `13`, `14`) | Which Teltonika command codec to use. Defaults to `12`. |
| `payload` | string | The command text (Teltonika command codecs carry ASCII strings — e.g. `"setdigout 1"`). |
| `status` | enum | `pending`, `routed`, `delivered`, `responded`, `failed`, `expired`. |
| `requested_by` | user fk | Set automatically by Directus from the JWT. |
| `requested_at` | timestamp | Insert time. |
| `routed_at` | timestamp | When the issuer published to Redis. |
| `delivered_at` | timestamp | When the Ingestion instance wrote bytes to the socket. |
| `responded_at` | timestamp | When the device's response arrived. |
| `response` | text | The device's response string, if any. |
| `failure_reason` | string | Set on `failed` or `expired`. Free text. |
| `expires_at` | timestamp | When the command should be abandoned if not delivered. Default: `requested_at + 5 minutes`. |
Permissions: the `commands` collection is writable by roles that have command-issuing privileges (operator, admin) and readable by the requester plus admin roles. The SPA inserts via the Directus SDK; nothing else writes to this collection except the Ingestion service updating delivery status (via a service token with restricted scope).
The collection is a hypertable candidate if command volume becomes high — but at expected rates (occasional operator actions, periodic fleet pushes), a regular table with an index on `(target_imei, requested_at desc)` is sufficient.
### 9.3 The connection registry
A Redis hash, keyed by IMEI, valued by Ingestion instance identifier:
```
HSET connections:registry {imei} {instance_id}
EXPIRE connections:registry 0 # the hash itself does not expire
```
Per-entry freshness is maintained by **heartbeat**, not by hash-level TTL (Redis does not support per-field TTLs on hashes). Each Ingestion instance:
- On device handshake (after IMEI is known), writes `HSET connections:registry {imei} {instance_id}` and records the IMEI in a local `Set<string>` of held devices.
- Every 30 seconds, refreshes a per-instance heartbeat key: `SET instance:heartbeat:{instance_id} {now} EX 90`.
- On socket close, `HDEL connections:registry {imei}` and removes the IMEI from the local set.
- On graceful shutdown, `HDEL` for every held IMEI.
Crash recovery — when an instance dies without cleanup, its registry entries are stale. A small **registry janitor** (a periodic Directus Flow, or a dedicated lightweight process) runs every minute:
- For each `instance_id` referenced in `connections:registry`, check `EXISTS instance:heartbeat:{instance_id}`.
- If the heartbeat is missing, the instance is dead. Scan the registry for entries pointing to it and `HDEL` them.
Devices held by the dead instance will reconnect (devices are configured to retry on disconnect; system architecture §3.2), and the new instance will write a fresh registry entry on handshake. Pending commands targeting those devices will be re-routed when the device reappears (see §9.5).
### 9.4 Issuing a command
Two paths, depending on whether the command is single-device or fleet:
**Single device** — the SPA inserts a row into `commands` via the Directus SDK. A Directus Flow on `items.create` for the `commands` collection:
1. Looks up the instance: `instance_id = HGET connections:registry {target_imei}`.
2. If found:
- `XADD commands:outbound:{instance_id} * command_id {id} target_imei {target_imei} codec {codec} payload {payload} expires_at {expires_at}`.
- Updates the row to `status = routed`, `routed_at = now()`.
3. If not found (device is offline):
- Updates the row to `status = pending`. The command will be retried by the **pending-command sweeper** (§9.5).
**Fleet (list of devices)** — the SPA calls a custom Directus endpoint, `POST /commands/batch`, which:
1. Validates the batch (max size, target devices the user is authorized for).
2. Generates a `batch_id`.
3. In a single transaction, inserts N rows into `commands` with the shared `batch_id`.
4. After commit, for each row, performs the registry lookup and stream publish as in the single-device path.
5. Returns `{ batch_id, command_ids: [...] }` to the SPA.
The custom endpoint exists for fleet operations specifically because the transactional insert + the routing fan-out is cleaner in code than in a Flow. Single-device commands stay on the Flow path; the endpoint is a layer above for batch ergonomics.
The SPA tracks progress by subscribing to `commands` filtered by `batch_id` (or `id`) — Directus pushes WebSocket updates as the rows transition through statuses.
### 9.5 The pending-command sweeper
When a device is offline at command time, the row sits in `status = pending`. A sweeper Flow runs every 30 seconds:
1. Selects rows where `status = pending` and `expires_at > now()`.
2. For each row, attempts the registry lookup again.
3. If the device is now online, publishes to the instance's stream and transitions `status → routed`.
4. Selects rows where `status IN ('pending', 'routed')` and `expires_at <= now()`, transitions them to `status = expired` with `failure_reason = 'device offline'` (for pending) or `'no delivery confirmation'` (for routed).
The sweeper is also what handles the case where an Ingestion instance crashes after publishing-to-stream but before delivering — those rows sit in `routed` past their `expires_at` and get expired. The next time the device reconnects, the operator can re-issue.
(There's a subtler retry option — re-route stale `routed` rows when their original instance has died according to the registry janitor — but it's an enhancement, not a Phase 2 requirement. Expire-and-let-the-operator-retry is acceptable v1 behavior and avoids accidental duplicate delivery.)
### 9.6 The Ingestion-side command consumer
Each Ingestion instance, alongside its TCP listener, runs a **command consumer** that reads from `commands:outbound:{instance_id}`:
```
loop:
msgs = XREADGROUP commands:outbound:{instance_id} GROUP ingest {instance_id} COUNT 16 BLOCK 1000
for msg in msgs:
handle_command(msg)
XACK commands:outbound:{instance_id} ingest msg.id
```
`handle_command` does:
1. Look up the in-memory map of `imei → socket` for this instance. If the device has disconnected since the registry lookup at issue time, publish a failure to `commands:responses` with `reason = 'socket_closed'` and stop.
2. Check `expires_at`. If already expired, publish a failure with `reason = 'expired_before_delivery'`.
3. Encode the Codec 12/13/14 frame from the payload (see §9.7).
4. Write the frame bytes to the socket. Record `delivered_at` by publishing an interim status.
5. Register a pending-response entry keyed by `command_id` with a timeout (default: 30 seconds).
6. Return to the read loop.
The consumer never blocks the TCP read path — it runs in parallel. The shared resource (the socket) is accessed through a per-socket write queue so that command writes do not interleave with codec ACKs in a way that confuses the device.
### 9.7 Encoding command frames (Codec 12 reference)
For Codec 12, the over-the-wire frame is:
```
┌──────────┬──────────┬───────┬──────┬──────────┬─────────┬───────┬──────┐
│ Preamble │ Length │ Codec │ Cmd │ Cmd size │ Cmd │ Count │ CRC │
│ 4 bytes │ 4 bytes │ 0x0C │ Qty │ 4 bytes │ N bytes │ 1 byte│ 4 B │
│ (zero) │ │ │ 1 B │ │ (ASCII) │ │ │
└──────────┴──────────┴───────┴──────┴──────────┴─────────┴───────┴──────┘
▲ ▲
└─── usually 0x05 ─────────────────┘
(Type = command from server)
```
Codec 13 adds a 4-byte timestamp; Codec 14 prepends an 8-byte IMEI. The encoder is a small set of pure functions in the `adapters/teltonika/codec/command/` folder, mirroring the AVL parsers in `adapters/teltonika/codec/data/`. They are tested against captures from Teltonika's documentation and from real device exchanges, exactly like the data codec parsers (§5.6).
### 9.8 Response correlation
When the device responds to a command, it sends a Codec 12 frame back over the same socket with `Type = 0x06` (response from device). The session's codec dispatch routes this to the command response handler.
The handler:
1. Reads the response payload (ASCII string).
2. Looks up the pending command for this socket. Teltonika's command codecs do not carry an explicit correlation ID — the protocol assumes one outstanding command per connection at a time. The Ingestion service enforces this: only one command may be in-flight per socket. Subsequent commands for the same device queue on the per-socket write queue and are sent only after the previous response arrives or its timeout fires.
3. Publishes to `commands:responses`: `XADD commands:responses * command_id {id} response {text} responded_at {now}`.
4. Cancels the pending-response timeout.
A Directus hook on the `commands:responses` stream (or a dedicated small consumer that updates Directus via SDK) updates the row to `status = responded`, fills `response` and `responded_at`, and Directus broadcasts the change to subscribed SPA clients.
If the timeout fires without a response, the consumer publishes `command_id, reason = 'no_device_response'` to `commands:responses`, the row transitions to `status = failed`, and the per-socket write queue is freed for the next command.
### 9.9 What this adds to Phase 1 code
Concretely, the Phase 1 implementation must respect these shapes so Phase 2 is a pure addition:
- **Codec dispatch is a registry** keyed on codec ID byte (already specified in §8.1). Phase 2 registers handlers for `0x0C`, `0x0D`, `0x0E`.
- **The session loop owns the socket; handlers borrow it** through a `respond(bytes)` callback (already specified in §8.2). Phase 1 handlers simply do not use it.
- **Per-device runtime state is local to the socket and the holding instance.** No shared state today (§8.3). Phase 2 adds the connection registry and per-instance command stream alongside, not woven through.
- **The `Position` shape and the inbound stream are unchanged.** Outbound commands use entirely separate streams (`commands:outbound:{instance_id}`, `commands:responses`) and a separate Directus collection. There is no conflation with the telemetry path.
When Phase 2 ships, no Phase 1 code is rewritten — Phase 1 continues to do exactly what it does today, with the command consumer running alongside it.
---
## 10. Summary
The Teltonika adapter handles model diversity by leaning on the protocol's own self-description. The codec ID announces the framing; the IO bag carries model-specific telemetry without the parser interpreting it. Six principles keep the implementation honest:
1. **Implement Codec 8, 8E, 16** — the closed set for telemetry today.
2. **Defer Codec 12, 13, 14** — command codecs reserved for Phase 2; design leaves room without building it.
3. **Pass the IO map through unchanged** — naming and interpretation are downstream concerns.
4. **Drop on unknown codec IDs** — loud failure beats silent corruption.
5. **Validate the CRC; NACK on mismatch** — devices retransmit on missing ACKs.
6. **Maintain a fixture suite** — the only real defense against silent binary-protocol bugs.
Together, these mean any Teltonika device shipping codec 8, 8E, or 16 is supported on day one, and the path to outbound commands later is a clean addition rather than a rewrite.