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.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: AVL Data Format (Teltonika canonical)
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-data-sending-protocols, teltonika-ingestion-architecture]
|
||||
tags: [teltonika, protocol, parser, data-model]
|
||||
---
|
||||
|
||||
# AVL Data Format
|
||||
|
||||
The canonical Teltonika AVL packet structure across codecs 8, 8E, and 16. This is the byte-level reference the [[teltonika]] adapter implements. See [[teltonika-data-sending-protocols]] for the official source and [[codec-dispatch]] for how the codec ID byte selects between the three formats.
|
||||
|
||||
## Outer envelope (TCP)
|
||||
|
||||
```
|
||||
┌────────────────┬──────────────────┬──────────┬────┬──────────┬────┬───────┐
|
||||
│ Preamble │ Data Field Length│ Codec ID │ N1 │ AVL Data │ N2 │ CRC-16│
|
||||
│ 4B = 0x00000000│ 4B │ 1B │ 1B │ X bytes │ 1B │ 4B │
|
||||
└────────────────┴──────────────────┴──────────┴────┴──────────┴────┴───────┘
|
||||
```
|
||||
|
||||
- **Data Field Length** is computed from the start of `Codec ID` through `N2` (not the whole packet).
|
||||
- **CRC-16/IBM** is computed over the same range. Lower 2 bytes of the 4-byte CRC field carry the value; upper 2 bytes are zero.
|
||||
- **N1 must equal N2** — record count repeated for integrity.
|
||||
- Server ACK is a **4-byte big-endian integer** equal to the number of records accepted. Mismatched count → device retransmits.
|
||||
|
||||
## AVL record (one of N records)
|
||||
|
||||
```
|
||||
[Timestamp 8B][Priority 1B][GPS Element 15B][IO Element X B]
|
||||
```
|
||||
|
||||
- **Timestamp**: UNIX milliseconds since epoch (UTC), big-endian.
|
||||
- **Priority**: enum — `0` Low, `1` High, `2` Panic.
|
||||
|
||||
### GPS Element (15B)
|
||||
|
||||
```
|
||||
[Longitude 4B][Latitude 4B][Altitude 2B][Angle 2B][Satellites 1B][Speed 2B]
|
||||
```
|
||||
|
||||
- **Lat/Lon**: signed 32-bit integer = `((d + m/60 + s/3600 + ms/3600000) × 10⁷)`, where d/m/s/ms are degrees/minutes/seconds/milliseconds. **Two's complement** — first bit = sign (0 positive, 1 negative).
|
||||
- **Altitude**: meters above sea level, signed 16-bit.
|
||||
- **Angle**: heading 0–360°, unsigned 16-bit.
|
||||
- **Satellites**: count of satellites in use, unsigned 8-bit.
|
||||
- **Speed**: km/h, unsigned 16-bit. **`0x0000` when GPS data is invalid** — distinct from "stationary."
|
||||
|
||||
## IO Element — codec-specific
|
||||
|
||||
The IO Element layout is where the three codecs diverge. The parser must dispatch on codec ID to read these correctly.
|
||||
|
||||
### Codec 8 (`0x08`) — 1-byte everything
|
||||
|
||||
```
|
||||
[Event IO ID 1B]
|
||||
[N total 1B]
|
||||
[N1 1B] [IO ID 1B][Value 1B] × N1
|
||||
[N2 1B] [IO ID 1B][Value 2B] × N2
|
||||
[N4 1B] [IO ID 1B][Value 4B] × N4
|
||||
[N8 1B] [IO ID 1B][Value 8B] × N8
|
||||
```
|
||||
|
||||
### Codec 8 Extended (`0x8E`) — 2-byte fields + variable-length
|
||||
|
||||
```
|
||||
[Event IO ID 2B]
|
||||
[N total 2B]
|
||||
[N1 2B] [IO ID 2B][Value 1B] × N1
|
||||
[N2 2B] [IO ID 2B][Value 2B] × N2
|
||||
[N4 2B] [IO ID 2B][Value 4B] × N4
|
||||
[N8 2B] [IO ID 2B][Value 8B] × N8
|
||||
[NX 2B] [IO ID 2B][Length 2B][Value <Length>B] × NX ← variable-length section
|
||||
```
|
||||
|
||||
The **NX section** is unique to 8E. Carries arbitrary-length values (e.g. ICCID-class data, BLE payloads). Each entry self-describes its length. The parser must be length-aware here — getting it wrong silently corrupts subsequent records.
|
||||
|
||||
### Codec 16 (`0x10`) — Generation Type, mixed widths
|
||||
|
||||
```
|
||||
[Event IO ID 2B]
|
||||
[Generation Type 1B] ← unique to Codec 16
|
||||
[N total 1B]
|
||||
[N1 1B] [IO ID 2B][Value 1B] × N1
|
||||
[N2 1B] [IO ID 2B][Value 2B] × N2
|
||||
[N4 1B] [IO ID 2B][Value 4B] × N4
|
||||
[N8 1B] [IO ID 2B][Value 8B] × N8
|
||||
```
|
||||
|
||||
- **No NX section** — Codec 16 does not include variable-size IO elements.
|
||||
- **Generation Type** values: `0`=On Exit, `1`=On Entrance, `2`=On Both, `3`=Reserved, `4`=Hysteresis, `5`=On Change, `6`=Eventual, `7`=Periodical.
|
||||
- Codec 16 is the channel for AVL IDs > 255 on FMB630/FM63XY.
|
||||
|
||||
### Side-by-side
|
||||
|
||||
| | Codec 8 | Codec 8 Extended | Codec 16 |
|
||||
|---|---------|------------------|----------|
|
||||
| Codec ID | 0x08 | 0x8E | 0x10 |
|
||||
| Event IO ID width | 1B | 2B | 2B |
|
||||
| N total / Nk count widths | 1B / 1B | 2B / 2B | 1B / 1B |
|
||||
| IO ID width | 1B | 2B | 2B |
|
||||
| Generation Type | — | — | 1B |
|
||||
| Variable-length IO (NX) | — | yes | — |
|
||||
| AVL IDs > 255 supported | no | yes | yes |
|
||||
|
||||
## Size limits
|
||||
|
||||
- **Minimum AVL record**: 45 bytes (all IO elements disabled).
|
||||
- **Maximum AVL record**: 255 bytes.
|
||||
- **Maximum AVL packet**: 512 bytes for FMB640/FMB641/FMC640/FMM640; **1280 bytes for other devices**.
|
||||
|
||||
The parser should treat oversized packets as malformed (drop the connection per the [[codec-dispatch]] unknown-codec policy — though this is a different malformation, the same loud-failure principle applies).
|
||||
|
||||
## UDP envelope
|
||||
|
||||
When the device runs over UDP instead of TCP:
|
||||
|
||||
```
|
||||
[UDP Channel Header 5B] [AVL Packet Header (1+2+15)B] [AVL Data Array — same as TCP]
|
||||
```
|
||||
|
||||
- **UDP Channel Header**: `Length 2B + Packet ID 2B + Not Usable Byte 1B`.
|
||||
- **AVL Packet Header**: `AVL Packet ID 1B + IMEI Length 2B = 0x000F + IMEI 15B`.
|
||||
- Server ACK over UDP is short: `[UDP Channel Header 5B][AVL Packet ID 1B][Number Accepted 1B]`.
|
||||
|
||||
The AVL Data Array (Codec ID byte + records + N2) is identical to TCP. **The Phase 1 implementation is TCP-only; UDP is documented here for completeness and future evaluation.**
|
||||
|
||||
## Mapping to [[position-record]]
|
||||
|
||||
The parser writes:
|
||||
|
||||
- `device_id` ← IMEI from the handshake (or from the UDP AVL Packet Header).
|
||||
- `timestamp` ← AVL record `Timestamp` (UNIX ms → `Date`).
|
||||
- `latitude`, `longitude`, `altitude`, `angle`, `speed`, `satellites` ← GPS Element fields, with two's-complement decoding for lat/lon and `Speed === 0x0000 ⇒ "GPS invalid"` retained as-is (the Processor decides how to surface it).
|
||||
- `priority` ← AVL record `Priority` (0/1/2).
|
||||
- `attributes` ← every IO element from N1/N2/N4/N8 (and NX for Codec 8E), keyed by **numeric IO ID as string**, value as `number | bigint | Buffer` per IO width. See [[io-element-bag]] for why naming/units stay out of the parser.
|
||||
|
||||
> Note: Codec 16's `Generation Type` and 8E's NX `Length` are not currently in the [[position-record]] shape. Generation Type is codec-defined (not model-defined) and may deserve a typed field — flagged as an open question on the source page.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Codec Dispatch (Registry)
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-ingestion-architecture, teltonika-data-sending-protocols]
|
||||
tags: [teltonika, parser, design-seam]
|
||||
---
|
||||
|
||||
# Codec Dispatch
|
||||
|
||||
How the [[teltonika]] adapter routes incoming frames to the right parser, and the seam that makes [[phase-2-commands]] additive instead of a rewrite.
|
||||
|
||||
## The mechanism
|
||||
|
||||
The codec ID (byte 0 of the AVL data payload) indexes into a **flat registry** of handlers. Each handler implements:
|
||||
|
||||
```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 `0x08`, `0x8E`, and `0x10`. Phase 2 will register additional handlers for `0x0C`, `0x0D`, `0x0E` — these will use a different `ctx` shape (they need to write bytes back, not just `publish`), but the registry shape is identical.
|
||||
|
||||
Per-codec directionality matters for the Phase 2 handler shape:
|
||||
|
||||
| Codec | ID | Direction | Handler needs |
|
||||
|-------|-----|-----------|---------------|
|
||||
| 8 / 8E / 16 | `0x08` / `0x8E` / `0x10` | device → server | `publish(Position)` only |
|
||||
| 12 | `0x0C` | bidirectional | `respond(bytes)` for outbound + parse responses |
|
||||
| 13 | `0x0D` | device → server only | parse-only (no `respond`) |
|
||||
| 14 | `0x0E` | bidirectional | `respond(bytes)` + ACK type `0x06` / nACK type `0x11` |
|
||||
| 15 | `0x0F` | device → server only | out of scope (FMX6 RS232 only) |
|
||||
|
||||
Codec 13 and 15, despite living in the GPRS-message family, are *one-way* — handlers for them never write to the socket. This matters for handler-shape design: the Phase 2 outbound family is not "all command codecs," it's specifically `0x0C` and `0x0E`.
|
||||
|
||||
## Why a registry, not a switch
|
||||
|
||||
- Adding a new codec is a registration, not a code change in dispatch logic.
|
||||
- Phase 2 command codecs slot in alongside Phase 1 data codecs without modifying Phase 1 paths.
|
||||
- Codec parsers are **independent** — there is no shared base class. Their record shapes diverge in ways abstraction would obscure rather than help.
|
||||
|
||||
## The "session owns the socket; handler borrows it" rule
|
||||
|
||||
Phase 1 handlers receive payload + a `publish(Position)` callback and emit records via Redis. They never write to the socket directly — the session loop handles ACKs.
|
||||
|
||||
Phase 2 command handlers will need to **write to the socket** (to send commands). They borrow write access through a `respond(bytes: Buffer)` callback added to `ctx` for command codecs. The session retains socket ownership; handlers borrow write access through a narrow interface.
|
||||
|
||||
## Unknown codec policy
|
||||
|
||||
When the codec ID does not match a registered handler:
|
||||
|
||||
- `WARN` log entry with IMEI, offending codec ID, raw header bytes.
|
||||
- Socket is destroyed.
|
||||
- No ACK sent.
|
||||
- No attempt to "skip ahead" or guess record layout.
|
||||
|
||||
Reasoning: a Teltonika device sending an unrecognized codec is **misconfigured**, not subtly broken. Silently truncating its data — or worse, mis-parsing — produces records with plausible-looking but wrong coordinates. Loud failure beats quiet corruption.
|
||||
|
||||
The `teltonika_unknown_codec_total{codec_id}` counter is the canary for codec coverage drift.
|
||||
|
||||
## CRC failure policy
|
||||
|
||||
Different from unknown-codec. CRC mismatch = transient transmission issue:
|
||||
|
||||
- Frame is **not** ACK'd → device retransmits on next session.
|
||||
- `WARN` log with IMEI, expected CRC, computed CRC, frame length.
|
||||
- Connection stays open.
|
||||
|
||||
Repeated CRC failures from the same device in a short window indicate a deeper problem (firmware, line quality) — surface via metrics, not just logs.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Failure Domains
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [architecture, reliability]
|
||||
---
|
||||
|
||||
# Failure Domains
|
||||
|
||||
Each component of the platform fails independently. The architecture deliberately concentrates operational risk in one place — the database — and keeps everything else restartable, replaceable, or naturally redundant.
|
||||
|
||||
## Per-component failure behavior
|
||||
|
||||
| Component | Crash behavior | Data loss |
|
||||
|-----------|---------------|-----------|
|
||||
| [[tcp-ingestion]] | Devices reconnect; in-flight frames retransmitted by the device per protocol | None beyond unacknowledged frames |
|
||||
| [[redis-streams]] | Streams are persisted; restart resumes from disk | Recoverable from device retransmits + Processor checkpointing |
|
||||
| [[processor]] | Consumer-group offsets ensure next instance picks up; in-memory state rehydrated from DB | None |
|
||||
| [[directus]] | Telemetry continues to flow into DB; admin UI/SPA unavailable | None |
|
||||
| [[postgres-timescaledb]] | System stops accepting writes | The single point of failure |
|
||||
| [[react-spa]] | UI unavailable | N/A — no state owned |
|
||||
|
||||
## The discipline behind this
|
||||
|
||||
- **No component reaches across two plane boundaries** — see [[plane-separation]]. A failure in one plane cannot cascade through another.
|
||||
- **The TCP handler never blocks on downstream work.** Slow Processor or DB pressure is absorbed by [[redis-streams]], not by device sockets.
|
||||
- **Per-device session state lives only on the open socket** — Ingestion is trivially restartable.
|
||||
- **The Processor's hot state can always be rehydrated** from the DB.
|
||||
|
||||
## Operational consequence
|
||||
|
||||
The database gets careful operational attention — replication, backups, point-in-time recovery via TimescaleDB. Everything else can be restarted, redeployed, or scaled without ceremony.
|
||||
|
||||
## Canary metric
|
||||
|
||||
**Redis Streams consumer lag.** It reflects the health of the entire telemetry pipeline in one number.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: IO element bag
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-ingestion-architecture]
|
||||
tags: [data-model, teltonika, principle]
|
||||
---
|
||||
|
||||
# IO element bag
|
||||
|
||||
The model-specific telemetry inside a Teltonika AVL record. Carried as `{ id → value }` pairs that [[tcp-ingestion]] reads byte-correctly without interpreting.
|
||||
|
||||
## The principle: pass through unchanged
|
||||
|
||||
The Ingestion service does **not** name, interpret, or filter IO elements. It produces a [[position-record]] whose `attributes` field is a verbatim representation of the IO bag, keyed by the numeric IO element ID as a string.
|
||||
|
||||
- No renaming.
|
||||
- No unit conversion.
|
||||
- No model lookup.
|
||||
|
||||
Per-model interpretation lives in the [[processor]], driven by configuration like `{ "FMB920": { "16": "odometer_km", "240": "movement" } }`.
|
||||
|
||||
## Why this boundary matters
|
||||
|
||||
1. **Model-specific interpretation belongs where the model is known.** Doing it in the parser would couple Ingestion to a registry of every device model in the fleet — exactly the coupling the [[protocol-adapter]] is designed to prevent.
|
||||
2. **A new model never breaks Ingestion.** A packet with `IO 1234` from an unknown device produces a parser that stores the raw value under `"1234"` and moves on. The position is still useful; the attribute is recoverable later once the model's mapping is imported.
|
||||
|
||||
## Structural implications
|
||||
|
||||
- 8-bit IO IDs in Codec 8.
|
||||
- 16-bit IO IDs in Codec 8E (Teltonika ran out of 8-bit slots) plus variable-length values.
|
||||
- Codec 16 introduces a per-record generation type but the IO bag itself behaves the same.
|
||||
|
||||
The parser handles all three identically with respect to interpretation: read bytes per the codec spec, store under string key, hand off.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Phase 2 — Outbound Commands
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-ingestion-architecture, teltonika-data-sending-protocols]
|
||||
tags: [teltonika, phase-2, commands, future]
|
||||
---
|
||||
|
||||
# Phase 2 — Outbound Commands
|
||||
|
||||
The deferred design for server-to-device commands using [[teltonika]] codecs 12 (`0x0C`) and 14 (`0x0E`). Codec 13 (`0x0D`) is one-way device→server and is not part of the outbound design; codec 15 (`0x0F`) is FMX6-only and out of scope. Specified now so Phase 1 code respects the seams; **no Phase 2 code ships until the platform actually needs to issue commands.**
|
||||
|
||||
## Codec selection: 12 vs 14
|
||||
|
||||
- **Codec 12** — generic command/response. Server sends with Type `0x05`; device responds with Type `0x06`. No IMEI in the frame; the device assumed to be the one on the other end of the socket.
|
||||
- **Codec 14** — IMEI-addressed command/response. Server sends with Type `0x05` and an 8-byte IMEI in HEX; device returns Type **`0x06` (ACK)** if its physical IMEI matches, **`0x11` (nACK)** if not. Available from FMB.Ver.03.25.04.Rev.00. Useful as a defense-in-depth check that the connection registry is routing to the device we think we are.
|
||||
|
||||
A reasonable default is Codec 12 for routine ops (the connection registry already guarantees we're talking to the right device's socket), with Codec 14 reserved for situations where IMEI reconfirmation matters (e.g. infrequent high-impact commands).
|
||||
|
||||
## Why deferred
|
||||
|
||||
Command codecs are a **distinct feature**, not an incremental codec, and require:
|
||||
|
||||
- A way to enqueue commands targeted at specific devices.
|
||||
- Routing 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 mistaken for usable.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
SPA ──HTTPS+JWT──▶ Directus ──XADD──▶ Redis Streams ──XREAD──▶ Ingestion ──▶ device
|
||||
│ │
|
||||
SPA ◀──WSS subscription── Directus ◀──hook on insert── commands:responses
|
||||
```
|
||||
|
||||
Five properties:
|
||||
|
||||
1. **Single auth surface** — [[directus]] enforces "can this user command this device?" Same machinery as every other write.
|
||||
2. **Commands are data before transport** — every command is a row in the `commands` collection before it hits Redis.
|
||||
3. **Symmetric to inbound telemetry** — same plane boundary, same seam, same operational tools.
|
||||
4. **Per-instance routing** via a connection registry mapping `imei → instance_id`.
|
||||
5. **Real-time status updates for free** — Directus WebSocket subscriptions on `commands` push delivery status to the SPA.
|
||||
|
||||
## Architectural posture
|
||||
|
||||
[[tcp-ingestion]] **does not expose user-facing HTTP endpoints**, in Phase 1 or Phase 2. All user-facing API surface is in [[directus]] (see [[plane-separation]]). Ingestion learns about commands by consuming its own Redis stream — never accepts inbound user-facing traffic.
|
||||
|
||||
## The `commands` collection (Directus)
|
||||
|
||||
Key fields: `id` (uuid, correlation ID), `target_imei`, `batch_id` (nullable, for fleet ops), `codec` (`12` or `14` — `13`/`15` are device-originated, not server-issued), `payload` (ASCII text), `status` (`pending` | `routed` | `delivered` | `responded` | `failed` | `nack` | `expired`), `requested_by`, timestamp fields, `response`, `failure_reason`, `expires_at` (default `requested_at + 5 min`). The `nack` status captures Codec 14's IMEI-mismatch case (Type `0x11`).
|
||||
|
||||
Permissions: writable by operator/admin roles; readable by requester + admin. The SPA inserts via SDK; Ingestion updates delivery status via a service token.
|
||||
|
||||
## Connection registry
|
||||
|
||||
Redis hash `connections:registry`, keyed by IMEI, valued by Ingestion instance ID:
|
||||
|
||||
- On handshake: `HSET connections:registry {imei} {instance_id}` + record IMEI in local `Set<string>`.
|
||||
- Every 30s: `SET instance:heartbeat:{instance_id} {now} EX 90`.
|
||||
- On socket close: `HDEL connections:registry {imei}`.
|
||||
- Graceful shutdown: `HDEL` all held IMEIs.
|
||||
|
||||
**Crash recovery via janitor.** Redis hashes don't support per-field TTL, so a registry janitor (Directus Flow or small process) runs every minute: for each `instance_id` in the registry, `EXISTS instance:heartbeat:{instance_id}` — if missing, scan the registry for entries pointing to it and `HDEL` them.
|
||||
|
||||
## Issuing commands
|
||||
|
||||
**Single device** — SPA inserts a `commands` row; Directus Flow on `items.create`:
|
||||
|
||||
1. Lookup `instance_id = HGET connections:registry {target_imei}`.
|
||||
2. If found: `XADD commands:outbound:{instance_id} ...`; status → `routed`.
|
||||
3. If not found: status stays `pending`; sweeper retries.
|
||||
|
||||
**Fleet** — SPA calls custom endpoint `POST /commands/batch`:
|
||||
|
||||
1. Validate (size, authorization).
|
||||
2. Generate `batch_id`.
|
||||
3. Transactional insert of N rows sharing `batch_id`.
|
||||
4. Per row: registry lookup + stream publish.
|
||||
5. Return `{ batch_id, command_ids }`.
|
||||
|
||||
The custom endpoint exists for fleet operations because transactional insert + routing fan-out is cleaner in code than in a Flow.
|
||||
|
||||
## Pending-command sweeper
|
||||
|
||||
Flow runs every 30s:
|
||||
|
||||
- `pending` rows where `expires_at > now()` → retry registry lookup; if device now online, publish + transition to `routed`.
|
||||
- `pending` or `routed` rows where `expires_at <= now()` → `expired` with `failure_reason`.
|
||||
|
||||
Also handles the case where Ingestion crashes after publish but before delivery — those rows sit in `routed` past `expires_at` and get expired. Operator re-issues. (A subtler retry option exists — re-route stale `routed` rows when the original instance has died — but is an enhancement, not v1.)
|
||||
|
||||
## Ingestion-side command consumer
|
||||
|
||||
Each Ingestion instance runs a parallel consumer reading `commands:outbound:{instance_id}` (XREADGROUP, COUNT 16, BLOCK 1000):
|
||||
|
||||
1. Lookup local `imei → socket` map. If gone: publish failure (`socket_closed`).
|
||||
2. Check `expires_at`. If past: publish failure (`expired_before_delivery`).
|
||||
3. Encode codec 12/13/14 frame from payload.
|
||||
4. Write bytes to the socket (via per-socket write queue to avoid interleaving with codec ACKs).
|
||||
5. Register pending-response entry keyed by `command_id` with timeout (default 30s).
|
||||
|
||||
The consumer never blocks the TCP read path.
|
||||
|
||||
## Response correlation
|
||||
|
||||
Teltonika's command codecs carry **no correlation ID** — the protocol assumes one outstanding command per connection. The Ingestion service enforces this; subsequent commands queue on the per-socket write queue.
|
||||
|
||||
When the device responds (Codec 12 with `Type = 0x06`), the codec dispatch routes to a response handler that publishes to `commands:responses`; a Directus hook (or small consumer) updates the row to `status = responded`. Timeout fires → `status = failed` with `reason = 'no_device_response'`; write queue is freed.
|
||||
|
||||
## What this requires of Phase 1
|
||||
|
||||
Phase 1 must respect these shapes so Phase 2 is purely additive:
|
||||
|
||||
- [[codec-dispatch]] is a registry keyed on codec ID byte — Phase 2 registers `0x0C`, `0x0D`, `0x0E`.
|
||||
- Session loop owns the socket; handlers borrow it via a `respond(bytes)` callback (Phase 1 handlers don't use it).
|
||||
- Per-device runtime state is local to the socket and the holding instance — no shared registry today.
|
||||
- The [[position-record]] shape and the inbound stream are unchanged. Outbound uses entirely separate streams (`commands:outbound:{instance_id}`, `commands:responses`) and a separate Directus collection.
|
||||
|
||||
When Phase 2 ships, no Phase 1 code is rewritten — the command consumer runs alongside.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Plane Separation (Telemetry / Business / Presentation)
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [architecture, principle]
|
||||
---
|
||||
|
||||
# Plane Separation
|
||||
|
||||
The system is organized as **three concentric concerns**, deliberately split along **data velocity** and **failure domain**.
|
||||
|
||||
## The three planes
|
||||
|
||||
1. **Telemetry plane** — [[tcp-ingestion]] + [[redis-streams]] + [[processor]]. Optimized for throughput, low latency, resilience to bursty input. Stateless or nearly so.
|
||||
2. **Business plane** — [[directus]] over [[postgres-timescaledb]]. Owns schema, API surface, permissions, back-office workflows.
|
||||
3. **Presentation plane** — [[react-spa]]. Consumes business-plane APIs and real-time subscriptions; never talks to the telemetry plane directly.
|
||||
|
||||
## How the boundaries are enforced
|
||||
|
||||
- **Redis Streams** is the boundary between Ingestion and the Processor side of the telemetry plane.
|
||||
- **The database + Directus API** is the boundary between the telemetry and business planes.
|
||||
- **Directus REST/GraphQL/WSS** is the boundary between business and presentation planes.
|
||||
|
||||
**No component reaches across two boundaries.** This is the rule that makes the architecture coherent.
|
||||
|
||||
## Why this matters
|
||||
|
||||
- Each component scales independently (Ingestion horizontally per TCP load; Directus horizontally per HTTP load; database vertically + read replicas).
|
||||
- Each component fails independently — see [[failure-domains]].
|
||||
- Adding new device vendors does not touch the business plane. Adding new front-end surfaces does not touch the telemetry plane.
|
||||
- Security model stays coherent: only Directus exposes user-facing APIs.
|
||||
|
||||
## The discipline that holds it together
|
||||
|
||||
The clearest articulation of the plane discipline: **the TCP handler never blocks on downstream work.** If the Processor or DB is slow, the queue absorbs pressure; Ingestion keeps accepting and acknowledging. Without this, the planes leak into each other under load.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Position Record
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture, teltonika-data-sending-protocols]
|
||||
tags: [data-model, boundary-contract]
|
||||
---
|
||||
|
||||
# Position Record
|
||||
|
||||
The normalized record produced by [[tcp-ingestion]] and consumed by [[processor]]. The boundary contract between vendor adapters and the rest of the system.
|
||||
|
||||
## Shape (Teltonika reference)
|
||||
|
||||
```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: 0 | 1 | 2 // 0=Low, 1=High, 2=Panic (Teltonika spec)
|
||||
attributes: {
|
||||
[io_id: string]: number | bigint | Buffer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The system architecture doc lists a slimmer canonical form — `device_id`, `timestamp`, `lat`, `lon`, `speed`, `heading`, plus an attribute bag — and the Teltonika doc extends it with `altitude`, `satellites`, `priority`. Treat the Teltonika shape as the current concrete contract.
|
||||
|
||||
## The `attributes` bag — pass through unchanged
|
||||
|
||||
The Ingestion service does **not** name, interpret, filter, or unit-convert IO elements. `attributes` is a verbatim representation of the IO element bag from the AVL record, keyed by the numeric IO element ID **as a string**.
|
||||
|
||||
Two reasons:
|
||||
|
||||
- **Model-specific interpretation belongs where the model is known.** The [[processor]] configures per-model IO mappings; doing this in the parser would couple Ingestion to a registry of every device model in the fleet.
|
||||
- **A new model never breaks Ingestion.** A packet with `IO 1234` from a device whose config we have not yet imported gets stored under `"1234"` and the position is still useful; the attribute is recoverable later.
|
||||
|
||||
This is the property that makes the [[protocol-adapter]] model-agnostic.
|
||||
|
||||
## What's codec-defined vs. model-defined
|
||||
|
||||
For [[teltonika]]:
|
||||
|
||||
- **Codec-defined** (always present, same shape per codec): timestamp, lat/lon/alt, angle, satellites, speed, priority. These fill out the typed fields above.
|
||||
- **Model-defined** (varies between models, opaque to parser): everything in the IO bag.
|
||||
|
||||
### Codec-specific quirks
|
||||
|
||||
- **Speed = `0x0000` means GPS data is invalid**, not "stationary." The parser preserves this verbatim (speed = 0); the [[processor]] decides whether to surface it as "no GPS fix" or coalesce with the stationary case.
|
||||
- **Lat/Lon are two's complement** signed integers — first bit = sign. Parsing must be sign-aware.
|
||||
- **Codec 16 carries a Generation Type** (0–7: On Exit, On Entrance, On Both, Reserved, Hysteresis, On Change, Eventual, Periodical) that is codec-defined but not currently in the Position shape. Open question on whether to promote it to a typed field. See [[avl-data-format]].
|
||||
- **Codec 8 Extended NX section** carries variable-length IO values — these land in `attributes` as `Buffer`, alongside the fixed-width N1/N2/N4/N8 entries.
|
||||
|
||||
## Downstream contract
|
||||
|
||||
[[processor]] is responsible for naming IO elements (e.g. `"16"` → `"odometer_km"`), unit conversions, and any filtering. It writes the typed fields to the positions hypertable and may write derived/named attributes to other tables.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Protocol Adapter Pattern
|
||||
type: concept
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture]
|
||||
tags: [architecture, ingestion, vendor-abstraction]
|
||||
---
|
||||
|
||||
# Protocol Adapter Pattern
|
||||
|
||||
The strategy used by [[tcp-ingestion]] to keep vendor-specific code from leaking into the rest of the system.
|
||||
|
||||
## The interface
|
||||
|
||||
- **Input**: a stream of bytes from a TCP socket.
|
||||
- **Output**: normalized [[position-record]] values with a stable shape — `device_id`, `timestamp`, `lat`, `lon`, `speed`, `heading`, plus a free-form attribute bag for vendor-specific telemetry.
|
||||
|
||||
That's the entire boundary contract. Adding a new device vendor (Queclink, Concox, etc.) means writing a new adapter. **Nothing downstream of Ingestion changes** — not [[redis-streams]], not [[processor]], not the schema, not the SPA.
|
||||
|
||||
This is "the single most important property of [the Ingestion] layer for long-term maintenance."
|
||||
|
||||
## Layout rules
|
||||
|
||||
In `tcp-ingestion/`:
|
||||
|
||||
1. **`src/core/` never imports from `src/adapters/`.** The vendor-agnostic shell (TCP listeners, Redis publishing, configuration, observability) is built without knowing about any vendor.
|
||||
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/`.
|
||||
3. **Each adapter folder is self-contained.** Lifting [[teltonika]] out into its own service later is a `git mv adapters/teltonika/ ../gps-ingest-teltonika/src/adapter/` plus a copy of `core/`. No untangling required.
|
||||
|
||||
## The deeper move: model-agnostic via self-description
|
||||
|
||||
For [[teltonika]] specifically, the adapter is also **model-agnostic** — what matters is the codec ID, not the device model. Fixed fields are codec-defined; model-specific telemetry is carried in an opaque IO bag the parser passes through verbatim. New models work without code changes. See [[teltonika]] and [[codec-dispatch]].
|
||||
|
||||
This is a property of the protocol, not the pattern, but it pairs well: the adapter pattern isolates vendors; the codec dispatch isolates models within a vendor.
|
||||
|
||||
## What stays out of adapters
|
||||
|
||||
Per the Teltonika reference:
|
||||
|
||||
- **Naming/interpreting telemetry** is a [[processor]] concern, not an adapter concern.
|
||||
- **Filtering** is also downstream — adapters produce raw records.
|
||||
- **Per-model configuration** lives in the Processor, not the adapter.
|
||||
|
||||
Adapters do one thing: turn vendor bytes into normalized records.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Directus
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture]
|
||||
tags: [service, business-plane, api]
|
||||
---
|
||||
|
||||
# Directus
|
||||
|
||||
The **business plane**. Owns the relational schema, exposes it through auto-generated REST/GraphQL APIs, enforces role-based permissions, and provides the admin UI for back-office users.
|
||||
|
||||
## What Directus owns
|
||||
|
||||
- **Schema management** — collections, fields, relations, migrations.
|
||||
- **API generation** — REST and GraphQL endpoints, no boilerplate.
|
||||
- **Authentication and authorization** — users, roles, permissions, JWT issuance.
|
||||
- **Real-time** — WebSocket subscriptions on collections for live UIs.
|
||||
- **Workflow automation** — Flows for orchestrating side effects (notifications, integrations).
|
||||
- **Admin UI** — complete back-office interface for operators.
|
||||
|
||||
## What Directus is NOT
|
||||
|
||||
Not in the telemetry hot path. Does not accept device connections, run a geofence engine, or hold per-device runtime state. Mixing those responsibilities into the same process would couple deployment lifecycles and contaminate failure domains. See [[plane-separation]].
|
||||
|
||||
## Schema ownership vs. write access
|
||||
|
||||
Directus is the schema **owner** even though [[processor]] writes directly to the database. New tables, columns, and relations are defined through Directus. Reasons:
|
||||
|
||||
- Auto-generated admin UI and APIs are derived from the schema Directus knows about. Tables created outside Directus are invisible to it.
|
||||
- Permissions are configured per-collection in Directus.
|
||||
- Audit columns (created_at, updated_at, user_created) follow Directus conventions; bypassing them inconsistently leads to subtle UI bugs.
|
||||
|
||||
This is a normal Directus deployment pattern — it does not require sole write access, only schema authority.
|
||||
|
||||
## Extensions
|
||||
|
||||
Used for things that genuinely belong in the business layer:
|
||||
|
||||
- **Hooks** that react to data changes (e.g. on event-write, trigger a notification Flow).
|
||||
- **Custom endpoints** for permission-gated, audited operations that are not throughput-critical.
|
||||
- **Custom admin UI panels** for back-office workflows (data review, manual overrides, bulk ops).
|
||||
- **Flows** for declarative orchestration.
|
||||
|
||||
**Not** used for long-running listeners, persistent network sockets, or anything in the telemetry hot path.
|
||||
|
||||
## Real-time delivery
|
||||
|
||||
Directus's WebSocket subscriptions push live data to the [[react-spa]]. When [[processor]] writes a row, Directus broadcasts the change to subscribed clients. Sufficient for tens to low hundreds of concurrent subscribers. If fan-out becomes a bottleneck, a dedicated WebSocket gateway can read directly from [[redis-streams]] and push to clients, bypassing Directus for the live channel only — REST/GraphQL stays in Directus.
|
||||
|
||||
## Phase 2 role
|
||||
|
||||
Directus owns the `commands` collection and is the **single auth surface** for outbound device commands. The SPA inserts command rows; a Directus Flow routes them via Redis to the Ingestion instance holding the device's socket. See [[phase-2-commands]].
|
||||
|
||||
## Failure mode
|
||||
|
||||
Crash → telemetry continues to flow into the database; admin UI and SPA are unavailable; no telemetry is lost. See [[failure-domains]].
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: PostgreSQL + TimescaleDB
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [infrastructure, business-plane, database]
|
||||
---
|
||||
|
||||
# PostgreSQL + TimescaleDB
|
||||
|
||||
The durable storage layer. PostgreSQL with the TimescaleDB extension. Holds the positions hypertable and all business schema owned by [[directus]].
|
||||
|
||||
## Writers
|
||||
|
||||
- **[[processor]]** — sole writer for high-volume telemetry (positions hypertable) and writer for derived business entities (events, violations, alerts).
|
||||
- **[[directus]]** — writes from the admin UI, custom endpoints, and Flows. Owns schema definition and migrations.
|
||||
|
||||
[[react-spa]] never writes (or reads) directly. [[tcp-ingestion]] does not touch the database.
|
||||
|
||||
## Schema authority
|
||||
|
||||
Schema is **defined and migrated through [[directus]]** — see that page for why. The Processor inserts rows respecting that schema; it does not create tables.
|
||||
|
||||
## Operational note
|
||||
|
||||
The database is the **only single point of failure** in the architecture. Everything else is restartable, replaceable, or naturally redundant. Operational attention concentrates here:
|
||||
|
||||
- Replication
|
||||
- Backups
|
||||
- Point-in-time recovery via TimescaleDB
|
||||
|
||||
## Scaling
|
||||
|
||||
- **Vertical** for write throughput.
|
||||
- **Read replicas** for analytics workloads.
|
||||
|
||||
## Deployment
|
||||
|
||||
Internal-only container. Persistence volume. Regular backups. Accessed only by [[directus]] and [[processor]].
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Processor
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [service, telemetry-plane, domain-logic]
|
||||
---
|
||||
|
||||
# Processor
|
||||
|
||||
The service where domain logic lives. Consumes normalized telemetry from [[redis-streams]] and is responsible for per-device runtime state, applying domain rules, and writing durable state to [[postgres-timescaledb]].
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Maintain **per-device runtime state** — last position, derived metrics, current zone, accumulators.
|
||||
- Apply **domain rules** that turn raw telemetry into meaningful events.
|
||||
- Write **durable state** — both raw position history and any derived events.
|
||||
- Emit events for downstream consumers (Directus Flows, notification services, dashboards).
|
||||
|
||||
Where [[tcp-ingestion]] is about throughput and protocol correctness, the Processor is about correctness of meaning. It is the component most likely to evolve as requirements grow, which is why it is isolated from the sockets on one side and the API surface on the other.
|
||||
|
||||
## State management
|
||||
|
||||
- **Static reference data** (spatial assets, configurations) loaded at startup; refreshed on a known cadence or via explicit invalidation.
|
||||
- **Per-device state** held in memory keyed by device identifier (last seen, current segment, accumulators).
|
||||
- **Durable state** written asynchronously to the database.
|
||||
|
||||
The database is the source of truth for replay/analysis; in-memory state is the source of truth for the current decision. On restart, hot state is rehydrated from the DB — this is a recovery path, not a hot path.
|
||||
|
||||
## Database writes
|
||||
|
||||
- The Processor is the **only writer** for high-volume telemetry tables (e.g. the positions hypertable). [[directus]] does not insert positions; it reads them.
|
||||
- For derived business entities (events, violations, alerts), the Processor writes directly to tables [[directus]] also knows about. Schema is owned by Directus; the Processor inserts rows respecting that schema.
|
||||
- This keeps the hot write path off the Directus HTTP stack while still letting Directus expose the data through API and admin UI.
|
||||
|
||||
## IO element interpretation
|
||||
|
||||
Per-model IO mappings live here, not in the Ingestion layer. Example: `{ "FMB920": { "16": "odometer_km", "240": "movement" } }`. This is the boundary set by the [[teltonika]] adapter — Ingestion produces raw IO maps; the Processor names and interprets them.
|
||||
|
||||
## Scaling
|
||||
|
||||
Multiple Processor instances join a Redis Streams consumer group and split the load across device IDs. Consumer-group offsets ensure a crashed instance's work is picked up by the next one.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Crash → consumer-group offsets ensure the next instance picks up where the last left off. In-memory state is rehydrated from the database. See [[failure-domains]].
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: React SPA
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [service, presentation-plane, frontend]
|
||||
---
|
||||
|
||||
# React SPA
|
||||
|
||||
The end-user experience for operators and external participants. Single React application, role-based views, served as a static bundle.
|
||||
|
||||
## Why a separate SPA (not just the Directus admin UI)
|
||||
|
||||
The Directus admin UI is generic CRUD over collections — right for back-office editing and operators who think in records, wrong for end users who think in domain concepts. A dedicated SPA delivers:
|
||||
|
||||
- **Domain-shaped UX** — screens organized around the user's mental model.
|
||||
- **Independent deployment** — front-end ships on its own cadence.
|
||||
- **Targeted access control** — public/partner-facing routes without exposing the admin surface.
|
||||
- **Mobile/offline tuning** — bundles tuned for the actual user environment.
|
||||
|
||||
## Single app, role-based views
|
||||
|
||||
One application serves multiple user types via role-based routing and conditional UI. All users authenticate through [[directus]]; the SPA receives a JWT, reads role, and renders appropriate navigation/screens. Splitting into multiple apps is only justified when user populations are genuinely disjoint (public site vs. authenticated console) or when bundle size for one audience harms another.
|
||||
|
||||
## Data access pattern
|
||||
|
||||
The SPA talks **exclusively** to Directus:
|
||||
|
||||
- REST/GraphQL via `@directus/sdk`.
|
||||
- WebSocket subscriptions via the same SDK.
|
||||
- JWT auth managed by the SDK; refresh handled transparently.
|
||||
|
||||
**Never** talks to the [[processor]], [[tcp-ingestion]], [[redis-streams]], or [[postgres-timescaledb]] directly. This boundary lets the back-end evolve internally and keeps the security model coherent — every request goes through Directus's permission system.
|
||||
|
||||
## Recommended stack
|
||||
|
||||
- **Vite + React + TypeScript**
|
||||
- **TanStack Router** — better TS support than React Router; optional file-based routing
|
||||
- **TanStack Query** — server state, caching, invalidation, optimistic updates
|
||||
- **@directus/sdk** — typed access + real-time
|
||||
- **MapLibre GL + react-map-gl** — open-source WebGL maps, no token needed
|
||||
- **shadcn/ui + Tailwind** — UI primitives
|
||||
- **Zustand** — client-only state (filters, UI prefs)
|
||||
- **react-hook-form + Zod** — forms and validation
|
||||
|
||||
Covers the spectrum from form-heavy admin screens to real-time map dashboards without architectural changes between them.
|
||||
|
||||
## Real-time rendering
|
||||
|
||||
- **Live maps with many markers**: React reconciler is not the bottleneck — drawing happens in WebGL via MapLibre, which manages features outside React's tree. The React layer manages subscriptions and feeds the map updates.
|
||||
- **High-frequency tabular updates** (live leaderboards, event feeds): split components so high-update areas re-render in isolation; use TanStack Query for live data; memoize at component boundaries that receive frequent updates.
|
||||
|
||||
## Failure mode
|
||||
|
||||
UI unavailable → back-end unaffected. See [[failure-domains]].
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Redis Streams
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture]
|
||||
tags: [infrastructure, telemetry-plane, queue]
|
||||
---
|
||||
|
||||
# Redis Streams
|
||||
|
||||
The durable in-flight queue between [[tcp-ingestion]] and [[processor]]. Also the transport for Phase 2 outbound commands.
|
||||
|
||||
## What it provides
|
||||
|
||||
- **Buffering** — temporary slowness in [[processor]] does not push back on Ingestion sockets.
|
||||
- **Replayability** — Streams retain messages, so a Processor crash does not lose telemetry; consumer-group offsets resume from the last position.
|
||||
- **Horizontal scaling** — multiple Processor instances join a consumer group and split load across device IDs.
|
||||
|
||||
## Why Redis (and not Kafka/NATS)
|
||||
|
||||
Sufficient at current scale and adds minimal operational burden. NATS or Kafka are reasonable upgrades when **multi-region durability** or **very high throughput** become real concerns. Until then, Redis is the right choice.
|
||||
|
||||
## Phase 2 usage
|
||||
|
||||
Outbound commands ride on per-instance streams: `commands:outbound:{instance_id}`. Responses ride on `commands:responses`. Redis is the transport; the source of truth for commands is the Directus `commands` collection. See [[phase-2-commands]].
|
||||
|
||||
The connection registry (`connections:registry` hash) and per-instance heartbeats (`instance:heartbeat:{instance_id}` keys with `EX 90`) also live in Redis.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Streams are persisted; restart resumes from disk. Complete Redis loss is recoverable from device retransmits and Processor checkpointing. See [[failure-domains]].
|
||||
|
||||
## Operational note
|
||||
|
||||
**Consumer lag is the canary metric** for the entire telemetry pipeline. Observability dashboards should make it prominent.
|
||||
|
||||
## Deployment
|
||||
|
||||
Internal-only container. Persistence enabled. Never exposed externally.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: TCP Ingestion
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture, teltonika-ingestion-architecture]
|
||||
tags: [service, telemetry-plane]
|
||||
---
|
||||
|
||||
# TCP Ingestion
|
||||
|
||||
The service that maintains persistent TCP connections with GPS devices, parses vendor binary protocols, ACKs frames per protocol, and hands off normalized records to the [[redis-streams]] queue.
|
||||
|
||||
## Responsibility
|
||||
|
||||
Single concern: **protocol I/O**. Explicitly **not**:
|
||||
|
||||
- Apply business rules
|
||||
- Write to PostgreSQL
|
||||
- Perform geospatial computation
|
||||
- Serve any user-facing API
|
||||
|
||||
The narrow scope is what keeps the process fast, predictable, and safely restartable.
|
||||
|
||||
## Connection model
|
||||
|
||||
- Built around `net.createServer()` (Node.js) — each socket is an independent session.
|
||||
- Per-connection state is small: identifier (e.g. IMEI), parser instance, partial-frame buffer.
|
||||
- Devices reconnect automatically on network failure → connection loss is routine → service is trivially restartable.
|
||||
|
||||
## Vendor abstraction
|
||||
|
||||
Each device vendor (Teltonika, Queclink, Concox, etc.) ships its own binary protocol. Vendor-specific code is isolated behind a [[protocol-adapter]] interface:
|
||||
|
||||
- **Input**: byte stream from a TCP socket
|
||||
- **Output**: normalized [[position-record]] (`device_id`, `timestamp`, `lat`, `lon`, `speed`, `heading`, plus a free-form `attributes` bag)
|
||||
|
||||
Adding a new vendor = writing a new adapter. Nothing downstream changes.
|
||||
|
||||
## Handoff discipline
|
||||
|
||||
For every parsed frame:
|
||||
|
||||
1. Send protocol-required ACK to the device.
|
||||
2. Push normalized record to a Redis Stream.
|
||||
3. Return to reading the socket.
|
||||
|
||||
**The TCP handler never blocks on downstream work.** Backpressure is absorbed by the Stream; Ingestion keeps accepting and acknowledging. This is the discipline that keeps the system alive under load.
|
||||
|
||||
## Project layout
|
||||
|
||||
Lives at `tcp-ingestion/` — single Node.js/TypeScript project. Layout:
|
||||
|
||||
```
|
||||
tcp-ingestion/
|
||||
├── src/core/ # vendor-agnostic shell (no adapter imports)
|
||||
├── src/adapters/ # per-vendor adapters
|
||||
│ └── teltonika/ # see [[teltonika]]
|
||||
├── src/config/
|
||||
├── src/observability/
|
||||
└── test/fixtures/ # real packet captures per codec
|
||||
```
|
||||
|
||||
Three layout rules: `core/` never imports `adapters/`; adapters never import each other; each adapter folder is self-contained so it can be lifted into its own service later via `git mv`.
|
||||
|
||||
## Scaling shape
|
||||
|
||||
- Single Node.js process handles thousands of concurrent connections at typical telemetry rates.
|
||||
- Horizontal scaling: multiple instances behind a TCP-aware load balancer (HAProxy, NGINX stream module).
|
||||
- TCP guarantees session stickiness for the duration of the connection.
|
||||
- No shared state between instances required — per-device state lives entirely on the open socket.
|
||||
|
||||
The pattern ports cleanly to higher-throughput runtimes (Go, Elixir) if a future rewrite is warranted.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Crash → devices reconnect → in-flight frames are retransmitted by the device per protocol → no data is lost beyond what was unacknowledged. See [[failure-domains]].
|
||||
|
||||
## Phase 2 addition
|
||||
|
||||
Each Ingestion instance will run a parallel **command consumer** reading from `commands:outbound:{instance_id}` and writing command frames to device sockets. The TCP read path is not blocked. See [[phase-2-commands]].
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Teltonika
|
||||
type: entity
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-ingestion-architecture, teltonika-data-sending-protocols]
|
||||
tags: [vendor, gps-hardware, protocol]
|
||||
---
|
||||
|
||||
# Teltonika
|
||||
|
||||
Lithuanian GPS hardware vendor. First (and currently only) device family supported by [[tcp-ingestion]]. Device families seen include FMB / FMC / FMM / FMU.
|
||||
|
||||
## Protocol overview
|
||||
|
||||
Binary frames, self-describing via codec ID byte. Both **TCP and UDP transports** are supported by the protocol; our [[tcp-ingestion]] implementation is TCP-only by deliberate choice (connection-oriented reliability + persistent session for [[phase-2-commands|outbound commands]]).
|
||||
|
||||
Codec inventory (canonical, from the official Teltonika wiki):
|
||||
|
||||
| Codec | Hex ID | Direction | Purpose |
|
||||
|-------|--------|-----------|---------|
|
||||
| 8 | `0x08` | device → server | AVL telemetry; 1-byte IO IDs |
|
||||
| 8 Extended | `0x8E` | device → server | AVL telemetry; 2-byte IO IDs + variable-length IO (NX section) |
|
||||
| 16 | `0x10` | device → server | AVL telemetry; 2-byte IO IDs + Generation Type; AVL IDs > 255 |
|
||||
| 12 | `0x0C` | bidirectional | Server commands + device responses (ASCII text) |
|
||||
| 13 | `0x0D` | device → server | One-way command upload with timestamp |
|
||||
| 14 | `0x0E` | bidirectional | Like 12, IMEI-addressed; ACK `0x06` / nACK `0x11` |
|
||||
| 15 | `0x0F` | device → server | One-way; FMX6 professional devices via RS232 — out of scope |
|
||||
| 4 | `0x04` | SMS | 24-position daily SMS, bit-field compressed — out of scope |
|
||||
|
||||
See [[avl-data-format]] for the byte-level packet structure across the three telemetry codecs.
|
||||
|
||||
### Notes on individual codecs
|
||||
|
||||
- **Codec 8** — baseline AVL format. 8-bit IO element IDs. Older firmware.
|
||||
- **Codec 8 Extended** — 16-bit IO IDs and a **variable-length IO section (NX)** carrying length-prefixed values. Default on modern FMB/FMC/FMM/FMU devices.
|
||||
- **Codec 16** — adds a 1-byte **Generation Type** field per record (values 0–7: On Exit, On Entrance, On Both, Reserved, Hysteresis, On Change, Eventual, Periodical). Required for AVL IDs > 255 on FMB630/FM63XY firmware ≥ 00.03.xx.
|
||||
- **Codec 13** — Device→Server only. Used only when "Message Timestamp" is enabled in RS232 settings.
|
||||
- **Codec 14** — Device returns nACK (Type `0x11`) when the command's IMEI doesn't match. Available from FMB.Ver.03.25.04.Rev.00.
|
||||
- **Codec 15** — FMX6 professional devices only, via RS232 modes (TCP/UDP Ascii/Binary, ±Buffered). Includes both timestamp (4B, **seconds**) and IMEI (8B HEX). Not applicable to the deployed FMB/FMC/FMM/FMU fleet.
|
||||
|
||||
## Frame envelope (data codecs)
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────┬───────────┬─────────────┬─────┐
|
||||
│ Preamble │ Data length │ Codec ID │ AVL data │ CRC │
|
||||
│ 4 bytes (0) │ 4 bytes │ 1 byte │ N bytes │ 4 B │
|
||||
└──────────────┴──────────────┴───────────┴─────────────┴─────┘
|
||||
```
|
||||
|
||||
CRC is CRC-16/IBM (lower 2 bytes carry the value; upper 2 are zero).
|
||||
|
||||
## Self-describing → model-agnostic parser
|
||||
|
||||
The codec ID announces framing; fixed AVL fields (timestamp, lat/lon/alt, angle, satellites, speed) are codec-defined and identical across models for a given codec; only the **IO element bag** varies between models. The bag is `{ id → value }` pairs the parser reads byte-correctly without knowing what each `id` means.
|
||||
|
||||
A brand-new Teltonika model will:
|
||||
|
||||
- Parse correctly at frame and codec layers (envelope, CRC, codec dispatch unchanged).
|
||||
- Produce a correct [[position-record]] (codec-defined fields).
|
||||
- Carry IO elements through as raw integer keys until a model-aware mapping is added in the [[processor]].
|
||||
|
||||
This is the load-bearing property of the [[protocol-adapter]] design.
|
||||
|
||||
## IMEI handshake
|
||||
|
||||
Device connects → sends 2-byte length + ASCII IMEI → server responds `0x01` to accept (or `0x00` to reject). Phase 1 always accepts.
|
||||
|
||||
## ACK protocol
|
||||
|
||||
After parsing a data frame, server sends 4-byte big-endian record count. Devices retransmit unacknowledged frames on the next session — this is the protocol-correct way to handle CRC failures (just don't ACK).
|
||||
|
||||
## Packet size limits
|
||||
|
||||
- Minimum AVL record: **45 bytes** (all IO elements disabled).
|
||||
- Maximum AVL record: **255 bytes**.
|
||||
- Maximum AVL packet: **512 bytes** for FMB640/FMB641/FMC640/FMM640; **1280 bytes** for other devices.
|
||||
|
||||
## Phase 1 vs Phase 2
|
||||
|
||||
| Phase | Codecs | Purpose | Status |
|
||||
|-------|--------|---------|--------|
|
||||
| Phase 1 (now) | 8, 8E, 16 | Device → server telemetry | In scope |
|
||||
| Phase 2 (later) | 12, 13, 14 | Server → device commands | Reserved |
|
||||
|
||||
Phase 1 covers essentially the entire deployed Teltonika telemetry fleet. Codec 15 and SMS-based protocols (Codec 4, binary SMS) are out of scope for now. See [[phase-2-commands]] for the deferred command-codec design.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: GPS Tracking Platform — Architecture Overview
|
||||
type: source
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [gps-tracking-architecture]
|
||||
tags: [architecture, system-design, overview]
|
||||
source_path: raw/gps-tracking-architecture.md
|
||||
source_kind: note
|
||||
---
|
||||
|
||||
# GPS Tracking Platform — Architecture Overview
|
||||
|
||||
## TL;DR
|
||||
|
||||
System-level reference for a real-time GPS telemetry platform built around four cooperating components: a TCP Ingestion service, a Processor, a Directus instance, and a React SPA. The architecture splits the system along **data velocity** and **failure domain**: a hot telemetry plane (Ingestion → Redis Streams → Processor), a business plane (Directus over PostgreSQL/TimescaleDB), and a presentation plane (React SPA). Each component scales, fails, and deploys independently.
|
||||
|
||||
## Key claims
|
||||
|
||||
- The system is structured as **three concentric planes** — telemetry, business, presentation. Boundaries are enforced by Redis Streams on one side and the database/API on the other; no component reaches across two boundaries. See [[plane-separation]].
|
||||
- **TCP Ingestion** maintains persistent device connections, parses vendor binary protocols, ACKs frames, and pushes normalized records to a Redis Stream. It does not apply business rules, write to Postgres, or serve user APIs. See [[tcp-ingestion]].
|
||||
- **Vendor abstraction** lives at the Ingestion layer via a protocol adapter interface (bytes in → normalized `Position` out). Adding a new device family means writing a new adapter; nothing downstream changes. See [[protocol-adapter]].
|
||||
- **The TCP handler never blocks on downstream work.** If the Processor or DB is slow, the Stream absorbs pressure; Ingestion keeps accepting and acknowledging. This single discipline is "the difference between a system that survives a bad day and one that doesn't."
|
||||
- **Processor** owns domain logic: hot per-device state in memory, durable writes to PostgreSQL/TimescaleDB, derived events. It is the **only writer** for high-volume telemetry tables (positions hypertable). See [[processor]].
|
||||
- **Directus** owns the schema, REST/GraphQL APIs, permissions, WebSockets, admin UI, and Flows — but is **not** in the telemetry hot path. The Processor writes directly to Directus-owned tables; schema authority stays with Directus. See [[directus]].
|
||||
- **React SPA** is the only user-facing surface. It talks exclusively to Directus (REST/GraphQL/WSS via the official SDK); it never touches Ingestion, Processor, Redis, or the database. See [[react-spa]].
|
||||
- **Redis Streams** between Ingestion and Processor provides buffering, replayability (consumer-group offsets), and horizontal scaling. NATS or Kafka are reasonable upgrades for multi-region or very high throughput; Redis is the right choice at current scale. See [[redis-streams]].
|
||||
- **Failure domains are isolated.** Ingestion crash → devices reconnect. Processor crash → resume from offset. Directus crash → telemetry continues; UI offline. The database is the only single point of failure. See [[failure-domains]].
|
||||
- **Recommended SPA stack**: Vite + React + TS, TanStack Router, TanStack Query, @directus/sdk, MapLibre GL + react-map-gl, shadcn/ui + Tailwind, Zustand, react-hook-form + Zod.
|
||||
- **Observability**: Redis Stream consumer lag is the single most important metric — it reflects the health of the entire telemetry pipeline in one number.
|
||||
|
||||
## Notable quotes
|
||||
|
||||
> "The architecture is deliberately split along the lines of **data velocity** and **failure domain**."
|
||||
|
||||
> "The TCP handler **never blocks on downstream work**. … This single discipline is the difference between a system that survives a bad day and one that doesn't."
|
||||
|
||||
> "The architecture deliberately makes the database the **only** part of the system that requires careful operational attention. Everything else is restartable, replaceable, or naturally redundant."
|
||||
|
||||
## Open questions / follow-ups
|
||||
|
||||
- No specific business domain modeled here ("intentionally generic"). Future sources should clarify the actual TRM domain (events, alerts, geofencing rules).
|
||||
- WebSocket fan-out limits cited as "tens to low hundreds" of subscribers — what's the expected scale for TRM?
|
||||
- Multi-region is mentioned as a possible future evolution. Is it on the roadmap?
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Teltonika Data Sending Protocols (official wiki)
|
||||
type: source
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-data-sending-protocols]
|
||||
tags: [teltonika, protocol, canonical, reference]
|
||||
source_path: raw/Teltonika Data Sending Protocols - Teltonika Telematics Wiki.md
|
||||
source_url: https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols
|
||||
source_kind: article
|
||||
---
|
||||
|
||||
# Teltonika Data Sending Protocols
|
||||
|
||||
Official Teltonika Telematics wiki page documenting all codec families: data-sending (8, 8E, 16), GPRS command (12, 13, 14, **15**), and SMS-based (24-position, binary SMS). Canonical reference; supersedes our internal summaries where they conflict.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The complete codec inventory: **Codec 8 (0x08), 8 Extended (0x8E), 16 (0x10)** for device-to-server telemetry; **Codec 12 (0x0C), 13 (0x0D), 14 (0x0E), 15 (0x0F)** for GPRS-message-based device/server communication; plus **Codec 4 (0x04)** for 24-position SMS and a separate binary-SMS data-sending protocol. All telemetry codecs work over both TCP and UDP. See [[avl-data-format]] for the canonical frame structure and [[teltonika]] for the per-codec summary.
|
||||
|
||||
## What's new vs. our prior wiki
|
||||
|
||||
This source corrects/extends earlier pages with several concrete details we hadn't captured:
|
||||
|
||||
- **Codec 16 hex ID = 0x10** (not just decimal 16).
|
||||
- **UDP is officially supported** for codecs 8/8E/16 — separate envelope with UDP Channel Header (Length 2B + Packet ID 2B + Not Usable Byte 1B), AVL Packet Header (AVL Packet ID 1B + IMEI Length 2B + IMEI 15B), then the AVL Data Array. Server ACK over UDP carries (AVL Packet ID 1B + Number of Accepted 1B), much shorter than the TCP 4-byte big-endian count.
|
||||
- **Codec 8 Extended has variable-size IO elements** (NX section), separate from N1/N2/N4/N8 — IO ID 2B + Length 2B + Value (variable).
|
||||
- **Codec 16 Generation Type** is a 1-byte field with 8 defined values: 0=On Exit, 1=On Entrance, 2=On Both, 3=Reserved, 4=Hysteresis, 5=On Change, 6=Eventual, 7=Periodical.
|
||||
- **Priority is an enum** with values 0=Low, 1=High, 2=Panic.
|
||||
- **Codec 14 has a nACK response type** `0x11` (not just the `0x06` ACK we previously documented). nACK is sent when the command's IMEI does not match the device's actual IMEI. Available from firmware FMB.Ver.03.25.04.Rev.00.
|
||||
- **Codec 13** is **Device→Server only** (one-way), Type always `0x06`, includes a 4-byte timestamp prepended to the command. Used only when the "Message Timestamp" parameter in RS232 settings is enabled.
|
||||
- **Codec 15 (0x0F)** is **new to our wiki** — one-way Device→Server, FMX6 professional devices, RS232 modes (TCP/UDP Ascii/Binary, ±Buffered). Includes both timestamp (4B, **seconds**, not ms) and IMEI (8B HEX). Out of scope for our deployed FMB/FMC/FMM/FMU fleet.
|
||||
- **Packet size limits**: min AVL record 45B (all IO elements disabled); max AVL record 255B; **max AVL packet 512B for FMB640/FMB641/FMC640/FMM640, 1280B for other devices**.
|
||||
- **AVL ID range**: AVL IDs > 255 are only available in Codec 16 (and, by inference from the spec, Codec 8E with its 2-byte IO ID width).
|
||||
- **Coordinate encoding**: Lat/Lon are 4-byte integers built from `((d + m/60 + s/3600 + ms/3600000) × 10⁷)` per axis. Negative coords use **two's complement** — first bit = sign. Speed = `0x0000` when GPS data is invalid.
|
||||
- **CRC scope** is precisely "from Codec ID to second Number of Data" (we had said roughly "the data section"). CRC is CRC-16/IBM, lower 2 bytes of the 4-byte field carry the value.
|
||||
- **Codec 12 GPRS command session prerequisite**: device must have sent AVL data and received a correct ACK before commands can be sent over the same socket. Recommended `Active datalink timeout` is 259200 (max) so the session stays open.
|
||||
|
||||
## Key claims (canonical)
|
||||
|
||||
### Codec table
|
||||
|
||||
| Codec | Hex ID | Direction | Purpose |
|
||||
|-------|--------|-----------|---------|
|
||||
| 8 | 0x08 | device → server | AVL telemetry; 1-byte IO IDs |
|
||||
| 8 Extended | 0x8E | device → server | AVL telemetry; 2-byte IO IDs + variable-length IO |
|
||||
| 16 | 0x10 | device → server | AVL telemetry; 2-byte IO IDs + Generation Type; supports IO IDs > 255 |
|
||||
| 12 | 0x0C | bidirectional | Server commands + device responses (ASCII text) |
|
||||
| 13 | 0x0D | device → server | One-way command upload with timestamp |
|
||||
| 14 | 0x0E | bidirectional | Like 12, addressed by IMEI; ACK 0x06 / nACK 0x11 |
|
||||
| 15 | 0x0F | device → server | One-way; timestamp + IMEI; FMX6 RS232 modes only |
|
||||
| 4 | 0x04 | SMS (one-way) | 24-position daily SMS, bit-field compressed |
|
||||
|
||||
### AVL outer envelope (TCP, codecs 8/8E/16)
|
||||
|
||||
```
|
||||
[Preamble 4B = 0x00000000][DataFieldLength 4B][CodecID 1B][N1 1B][AVL Data X B][N2 1B][CRC 4B]
|
||||
```
|
||||
|
||||
- `DataFieldLength` = bytes from `CodecID` through `N2`.
|
||||
- `CRC-16/IBM` calculated over the same range.
|
||||
- `N1` MUST equal `N2` (record count, repeated).
|
||||
- TCP server ACK = **4-byte big-endian integer** = number of records accepted.
|
||||
|
||||
### AVL record (all three telemetry codecs)
|
||||
|
||||
```
|
||||
[Timestamp 8B (UNIX ms)][Priority 1B][GPS Element 15B][IO Element X B]
|
||||
```
|
||||
|
||||
- **GPS Element**: `Longitude 4B + Latitude 4B + Altitude 2B + Angle 2B + Satellites 1B + Speed 2B` — total 15 bytes.
|
||||
- **Priority**: 0=Low, 1=High, 2=Panic.
|
||||
- IO Element layout differs per codec — see [[avl-data-format]].
|
||||
|
||||
### IMEI handshake
|
||||
|
||||
Device → server: `2-byte length` + ASCII IMEI (e.g. `000F` + 15 ASCII bytes for a 15-digit IMEI).
|
||||
Server → device: `0x01` (accept) or `0x00` (reject).
|
||||
|
||||
### UDP envelope (data codecs)
|
||||
|
||||
```
|
||||
[Length 2B][Packet ID 2B][Not Usable Byte 1B] | [AVL Packet ID 1B][IMEI Length 2B = 0x000F][IMEI 15B] | [AVL Data Array (same as TCP)]
|
||||
```
|
||||
|
||||
UDP server ACK: `[Length 2B = 0x0005][Packet ID][Not Usable 1B] | [AVL Packet ID 1B][Number of Accepted 1B]`.
|
||||
|
||||
### Codec 12 message structure
|
||||
|
||||
```
|
||||
[Preamble 4B][DataSize 4B][CodecID 0x0C][CmdQty1 1B][Type 1B][CmdSize 4B][Command X B][CmdQty2 1B][CRC 4B]
|
||||
```
|
||||
|
||||
- `Type`: `0x05` for command (server → device), `0x06` for response (device → server).
|
||||
- `CRC-16/IBM` from `CodecID` through `CmdQty2`.
|
||||
- Command/Response field is ASCII text encoded as HEX (e.g. `getinfo` → `67 65 74 69 6E 66 6F`).
|
||||
|
||||
### Codec 14 specifics
|
||||
|
||||
```
|
||||
[...][CodecID 0x0E][CmdQty 1B][Type 1B][Size 4B = command + 8 IMEI][IMEI HEX 8B][Command X B][CmdQty 1B][CRC 4B]
|
||||
```
|
||||
|
||||
- IMEI is encoded as 8-byte HEX (e.g. IMEI `123456789123456` → `01 23 45 67 89 12 34 56`).
|
||||
- **Type 0x06 = ACK** (IMEI matched, command executed); **Type 0x11 = nACK** (IMEI mismatch, command rejected).
|
||||
- Available from FMB.Ver.03.25.04.Rev.00.
|
||||
|
||||
## Notable quotes
|
||||
|
||||
> "Note that the GPRS session should remain active between the device and server, while GPRS commands are sent. For this reason, active datalink timeout (global parameters in device configuration) is recommended to be set to 259200 (maximum value)."
|
||||
|
||||
> "AVL IDs that are higher than 255 will can be used only in the Codec16 protocol."
|
||||
|
||||
> "If command message IMEI is equal to actual device IMEI, received command will be executed and response will be sent with ACK (0x06) … If the command message IMEI doesn't match … response to the server will be sent with nACK (0x11)."
|
||||
|
||||
## Open questions / follow-ups
|
||||
|
||||
- Codec 8 Extended NX (variable-size IO) parser handling — what shape do those values take in our [[position-record]] `attributes` bag? Likely raw `Buffer`, length-aware. Needs explicit handling in the parser fixture suite ([[teltonika-ingestion-architecture]] §5.6).
|
||||
- Codec 16 Generation Type — should this be promoted into a typed field in [[position-record]] (since it's codec-defined, not model-defined)? Currently we only specify `priority`; Generation Type is similar in nature.
|
||||
- Should we plan to support the SMS-based protocols (Codec 4 / binary SMS) for fallback connectivity? Probably not for now — SMS is rare in the deployed fleet, and adding it means an SMS gateway integration well outside the TCP service.
|
||||
- Codec 15 is FMX6-only and out of scope. But if the platform ever onboards an FMX6 fleet, this becomes a Phase-3 line item.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Teltonika Ingestion — Architecture Reference
|
||||
type: source
|
||||
created: 2026-04-30
|
||||
updated: 2026-04-30
|
||||
sources: [teltonika-ingestion-architecture]
|
||||
tags: [teltonika, ingestion, protocol, codec]
|
||||
source_path: raw/teltonika-ingestion-architecture.md
|
||||
source_kind: note
|
||||
---
|
||||
|
||||
# Teltonika Ingestion — Architecture Reference
|
||||
|
||||
## TL;DR
|
||||
|
||||
Design for the Teltonika protocol adapter inside the [[tcp-ingestion]] service. Goal: ingest telemetry from any Teltonika device — including unseen models — without parser changes, by leaning on the protocol's self-description (codec ID announces framing; IO bag carries opaque model-specific telemetry). Phase 1 implements data-sending codecs **8, 8E, 16**; Phase 2 will add command codecs **12, 13, 14**. Six design principles govern the adapter; the codec dispatch is a registry built so Phase 2 is additive, not a rewrite.
|
||||
|
||||
## Key claims
|
||||
|
||||
- **Project lives at `tcp-ingestion/`** — a single Node.js/TypeScript project containing the vendor-agnostic shell (`src/core/`) and per-vendor adapters (`src/adapters/`). Today the only adapter is Teltonika.
|
||||
- **Layout rules**: `core/` never imports from `adapters/`; adapters never import from each other; the Teltonika folder is self-contained so it can be lifted into its own service via `git mv` later.
|
||||
- **Phase 1 scope** = data-sending codecs 8, 8E, 16 (covers the deployed Teltonika telemetry fleet).
|
||||
- **Phase 2 scope** = GPRS command codecs 12, 13, 14 (server → device). Deferred because they are a **distinct feature** with different security implications, not an incremental codec. See [[phase-2-commands]].
|
||||
- **The parser is model-agnostic** — what matters is the codec ID byte, not the device model. Fixed AVL fields (timestamp, lat/lon/alt, angle, satellites, speed) are codec-defined; only the IO element bag varies, and it's passed through verbatim. New Teltonika models work on day one.
|
||||
- **`Position` shape** is the boundary contract — `device_id`, `timestamp`, `lat`, `lon`, `alt`, `angle`, `speed`, `satellites`, `priority`, `attributes` (raw IO map keyed by numeric ID as string). See [[position-record]].
|
||||
- **Six design principles** (priority order):
|
||||
1. Implement Codec 8, 8E, 16 — the closed set.
|
||||
2. Defer Codec 12, 13, 14.
|
||||
3. Pass the IO map through unchanged — naming/interpretation is a Processor concern.
|
||||
4. Log unknown codec IDs and drop the connection (loud failure > silent corruption).
|
||||
5. Validate CRC-16/IBM; NACK (no ACK) on mismatch — devices retransmit.
|
||||
6. Maintain a fixture suite of real packet captures + Teltonika-doc captures + synthetic edge cases.
|
||||
- **Codec dispatch is a flat registry** keyed on codec ID — not an inheritance hierarchy. Codec parsers are independent because their record shapes diverge. See [[codec-dispatch]].
|
||||
- **Phase 1 forward-compatibility seams** for Phase 2:
|
||||
- Codec dispatch is a registry, not a switch (§8.1).
|
||||
- Session owns the socket; handlers borrow write access via a `respond(bytes)` callback (§8.2).
|
||||
- Per-device state is local to the socket; no shared registry today. Phase 2 adds the connection registry alongside, not woven through (§8.3).
|
||||
- **Phase 2 outbound command flow**: `SPA → Directus → Redis Streams → Ingestion → device`. Directus enforces single auth surface; commands are persisted as rows in a `commands` collection before being routed; Redis is transport, not source of truth.
|
||||
- **Phase 2 connection registry**: Redis hash `connections:registry` mapping `imei → instance_id`. Per-instance heartbeat keys (`SET instance:heartbeat:{instance_id} EX 90`) plus a registry janitor handle crash recovery — Redis hashes don't support per-field TTL.
|
||||
- **Phase 2 command correlation**: Teltonika's command codecs carry no correlation ID, so the protocol assumes one outstanding command per connection. The Ingestion service enforces this via a per-socket write queue.
|
||||
|
||||
## TCP session lifecycle (Phase 1 happy path)
|
||||
|
||||
1. Device connects to the Teltonika port.
|
||||
2. IMEI handshake: device sends 2-byte length + ASCII IMEI; server responds `0x01` to accept.
|
||||
3. AVL data loop: read preamble (4×0x00) + length + payload + 4-byte CRC; validate CRC; dispatch on codec ID byte; emit `Position` records to Redis; ACK with 4-byte big-endian record count.
|
||||
4. Session ends on disconnect; no state preserved across sessions.
|
||||
|
||||
## Notable quotes
|
||||
|
||||
> "Ingest telemetry from any Teltonika device, including models we have never seen, without code changes to the parser."
|
||||
|
||||
> "Naming and interpreting IO elements is explicitly a Processor concern, driven by per-model configuration."
|
||||
|
||||
> "A loud failure (the device reconnects, fails again, shows up in logs) is strictly better than a quiet corruption."
|
||||
|
||||
> "A fixture suite is not optional infrastructure. It is the only place the parser's correctness is actually verified."
|
||||
|
||||
## Open questions / follow-ups
|
||||
|
||||
- Per-model IO dictionary: where does it live downstream — Directus collection, static config in the Processor, or both?
|
||||
- Phase 2 timing: no commitment given. Driver will be the first real need to issue commands (configuration, remote actions).
|
||||
- Pending-command sweeper cadence (30s) and command default TTL (5 min) — operational defaults, may want tuning once command volume is real.
|
||||
Reference in New Issue
Block a user