90d6a73a60
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).
90 lines
4.9 KiB
Markdown
90 lines
4.9 KiB
Markdown
# 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`:
|
||
- `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.
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```ts
|
||
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 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.)
|