Files
mikkeli e01d6c2a32
/ crosscheck (push) Successful in 0s
Rust (L4T) / Clippy, test, build, glibc (push) Successful in 4s
Forward yukart/imu/+; mock IMU values (pull-away, braking, a weave)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-13 23:23:32 +09:00

4.5 KiB

type, status, updated
type status updated
plan active 2026-10-12

Initial plan

Scope

Aggregation and the HTTP surface. Not a telemetry relay: over USB the tablet has an IP link via RNDIS, and a client that can subscribe to the broker does so directly.

Job Why it needs a gateway
Aggregation Raw CAN at 500 kbps must never reach a display; something has to downsample
Nav HTTP proxy Route requests are request/response and fit MQTT badly
Static assets Serving the PMTiles basemap to the display
Non-IP transports BT SPP and plain serial for ESP32 clients have no broker access

Order

  1. Boundary v0 — in place: WebSocket on :7700, hello, heartbeats both ways, mark forwarded to the recorder's control socket, mock state behind --mock. Protocol in contracts docs/BOUNDARY.md. Bus input — in place (2026-10-12): subscribes to yukart/vehicle/+, yukart/host/+, yukart/gnss/+ and yukart/imu/+, keeps the newest envelope per signal, marks stale after 5 s or when known only from a retained copy. Bus or mock, never both.
  2. Static file serving for the basemap — smallest useful thing, unblocks the map.
  3. Nav HTTP proxy to svc-nav.
  4. Aggregation and rate limiting, once there is real CAN traffic to aggregate.
  5. Serial bridge, when an ESP32 client exists.

Two roles in one process

This is both the local aggregation hub and the app gateway. That is allowed because the hub holds no durable state — it is rebuilt from retained topics on restart — but it holds only while these rules do:

  • Steady state is reached with zero clients connected. Nothing in aggregation waits on a display.
  • The boundary is drop-only: bounded per-client queues, drop oldest, never block. A tablet with its screen off must not stall aggregation.
  • Backpressure never propagates inward. Drop at the boundary; never slow the bus subscription to match a slow client.
  • Per-client state is per-connection and never touches the aggregate.

The point of the rules is that the seam stays visible. If the gateway ever needs to be a separate process, that becomes a refactor rather than a rewrite.

Link Shape Initiator
Vehicle and nav state topics subscribe —
Command topics publish —
Downstream to app simplex push stream, lossy-tolerant app connects
Upstream from app simplex request/response, acknowledged app
Route requests onward request/response, HTTP this service

Self-mocking

The input side sits behind an interface. The real implementation subscribes to the bus; the mock synthesises vehicle state. Everything from aggregation outward — the boundary protocol, serialisation, per-client queues, reconnection — is identical in both, so there is no second implementation of the contract to drift.

Same shape as svc-nav's routing provider. A service that can produce its own inputs needs no external stub, and tool-mock deliberately does not contain one.

This is what makes app-flutter developable against a single binary with no broker, no fixtures and no Jetson.

Two rules constrain it:

  • The mock must degrade. Missing, stale and implausible signals are the normal state in a car. A mock that always emits a complete, fresh, plausible dataset trains the app to assume something false, and the assumption breaks on the first real drive.
  • Mock data must be visibly mock downstream. Synthetic vehicle readings indistinguishable from real ones, on a display in a moving car, is a safety problem rather than a tidiness one. The mode is off by default and flagged in the stream, not merely in a log line at startup.

Boundary v0, as built

  • One WebSocket rather than separate push and request/response transports: the two channels keep their different guarantees (drop-oldest down, acked-with-idempotency-key up) on one socket, and the client has one connection to keep alive.
  • Threads, not async: a handful of clients at most, and tens of MB matter more here than connection count.
  • Peers are filtered by address — loopback, RFC 1918, tailnet 100.64/10. The car unit has an LTE uplink, and a command endpoint must not answer on it.
  • Without --mock and with no bus yet, a client sees hello and heartbeats only. That is the honest state of the car today, and still exercises the link, reconnection and marking end to end.
  • Signal units are read from contracts' signals.json at build time from a sibling checkout (../contracts). The build fails without it rather than guess.