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

4.9 KiB
Raw Blame History

Task 1.2 — Core shell & framing types

Phase: 1 — Inbound telemetry Status: Not started Depends on: 1.1 Wiki refs: docs/wiki/concepts/protocol-adapter.md, docs/wiki/concepts/codec-dispatch.md, docs/wiki/concepts/position-record.md

Goal

Build the vendor-agnostic shell: TCP server bootstrap, per-socket session loop, and the type/registry definitions that adapters plug into. No Teltonika-specific code in this task.

Deliverables

  • src/core/types.ts:
    • Position type matching the position-record shape exactly.
    • Adapter interface: { name: string; ports: number[]; handleSession(socket: net.Socket, ctx: AdapterContext): Promise<void> }.
    • AdapterContext interface: { publish: (p: Position) => Promise<void>; logger: Logger; metrics: Metrics } — a narrow contract giving adapters what they need without leaking shell internals.
  • src/core/registry.ts:
    • AdapterRegistry class (or simple module) holding Map<port, Adapter>. Methods: register(adapter), get(port).
    • This is the adapter registry. The codec registry (per-vendor, in Teltonika's case) is internal to the adapter — it lives in src/adapters/teltonika/ (task 1.4).
  • src/core/session.ts:
    • runSession(socket, adapter, ctx) that wraps adapter.handleSession with:
      • Initial socket configuration (setNoDelay, setKeepAlive with a sane delay, e.g. 60s).
      • Standard error handling: error, close, end events all logged at debug level with the connection's remote address.
      • A try { await handleSession() } catch (e) { logger.warn(e) } that ensures the socket is destroyed if the handler throws.
    • Crucially, runSession does not know about IMEI, framing, or codecs — those are entirely the adapter's business.
  • src/core/server.ts:
    • startServer(port, adapter, ctx) returning a closable handle. Uses net.createServer((socket) => runSession(socket, adapter, ctx)).
    • Logs server bind, accepts, and close events.
  • src/core/publish.ts:
    • Stub publishPosition(position) returning Promise<void>. Real implementation lands in task 1.8. For now, it accepts a Position and logs at debug. The shape should already match what task 1.8 will produce so Adapter types stabilize early.

Specification

Vendor-agnostic discipline (re-stated)

src/core/ must not import from src/adapters/ — ever. This is enforced by ESLint with eslint-plugin-import's no-restricted-paths rule. Add the rule in this task; failure to follow it should be a CI error.

// in eslint.config.js
'import/no-restricted-paths': ['error', {
  zones: [{ target: './src/core', from: './src/adapters' }]
}]

Adapters can import from core/; the reverse is forbidden.

TCP socket settings

  • socket.setNoDelay(true) — disable Nagle so ACKs are not batched. We're sending small ACKs; latency matters more than packet count.
  • socket.setKeepAlive(true, 60_000) — TCP keepalive with 60s probe. Defends against idle NAT timeouts; safe because devices already retransmit on disconnect.
  • No socket.setTimeout() at the shell level. The protocol does not specify per-frame timing; idle sockets are fine. Adapters can impose timeouts if their protocol demands.

Position type

Mirror position-record precisely:

export type Position = {
  device_id: string;
  timestamp: Date;
  latitude: number;
  longitude: number;
  altitude: number;
  angle: number;        // 0360
  speed: number;        // km/h, 0 may mean "GPS invalid" — caller preserves verbatim
  satellites: number;
  priority: 0 | 1 | 2;  // Low | High | Panic
  attributes: Record<string, number | bigint | Buffer>;
};

Use Date not number for timestamp — the value is a Date from the moment it leaves Ingestion, downstream is responsible for serialization choice.

Acceptance criteria

  • pnpm typecheck and pnpm lint pass.
  • src/core/server.ts can be imported and startServer returns a net.Server that listens on a configurable port.
  • A trivial test (in test/core/server.test.ts) starts a server with a stub adapter, opens a TCP client, and verifies the adapter's handleSession is invoked with a real socket.
  • ESLint enforces the no-restricted-paths rule — verified by adding a temporary import-from-adapter into src/core/server.ts and confirming the lint error, then removing it.

Risks / open questions

  • The AdapterContext's metrics interface is sketched but not fully specified until task 1.10. Make a minimal placeholder ({ inc: (name, labels?) => void; observe: ... }) and tighten in 1.10.
  • Buffer in Position.attributes requires JSON serialization handling at the publish boundary (task 1.8). Decide there: base64-encode buffers, or serialize via msgpack. Recommendation: base64 with a sentinel in the JSON, e.g. { "_b64": "..." }. Defer the decision to task 1.8 and revisit if simpler options surface.

Done

(Fill in once complete.)