Tasks 1.1-1.9 marked done with their landing commit SHAs. Tasks 1.10 (observability), 1.12 (production hardening), and 1.13 (device authority) marked paused with explicit resume triggers — pilot deployment on real Teltonika hardware takes priority. Task 1.11 remains as next, in slimmed form for the pilot (no /readyz healthcheck since the metrics endpoint is part of paused 1.10).
4.9 KiB
Task 1.2 — Core shell & framing types
Phase: 1 — Inbound telemetry
Status: 🟩 Done — landed in commit 1e9219d
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:Positiontype matching the position-record shape exactly.Adapterinterface:{ name: string; ports: number[]; handleSession(socket: net.Socket, ctx: AdapterContext): Promise<void> }.AdapterContextinterface:{ 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:AdapterRegistryclass (or simple module) holdingMap<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 wrapsadapter.handleSessionwith:- Initial socket configuration (
setNoDelay,setKeepAlivewith a sane delay, e.g. 60s). - Standard error handling:
error,close,endevents all logged atdebuglevel 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.
- Initial socket configuration (
- Crucially,
runSessiondoes 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. Usesnet.createServer((socket) => runSession(socket, adapter, ctx)).- Logs server bind, accepts, and close events.
src/core/publish.ts:- Stub
publishPosition(position)returningPromise<void>. Real implementation lands in task 1.8. For now, it accepts aPositionand logs at debug. The shape should already match what task 1.8 will produce soAdaptertypes stabilize early.
- Stub
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; // 0–360
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 typecheckandpnpm lintpass.src/core/server.tscan be imported andstartServerreturns anet.Serverthat 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'shandleSessionis invoked with a real socket. - ESLint enforces the no-restricted-paths rule — verified by adding a temporary import-from-adapter into
src/core/server.tsand 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. BufferinPosition.attributesrequires 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.)