Files
project-yukart/docs/ARCHITECTURE.md
T
mikkeli 2fe9665664 Keep streaming TTS open; name the swappable-provider pattern
Utterances cross as chunks with an explicit terminator, so whole-utterance is
a stream of one chunk and a streaming TTS needs no protocol change. Records
what must not be built — a single-body download-then-play player — since that
forecloses streaming silently.

Also notes the transport is fully exercisable with espeak-ng or a canned WAV,
so audio transport need not wait on GPT-SoVITS.

The provider-behind-an-interface shape has now been picked three times
independently, so it is named as the project idiom rather than re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kj58Jugkic783b8ZrtTVZ
2026-09-04 14:02:41 +09:00

16 KiB
Raw Blame History

Architecture

The one rule

Everything is a bus participant. Acquisition services publish; displays, loggers and analysis tools subscribe. No service knows which display exists, and no display talks to hardware.

This is what lets the three display options coexist without forking the system, and what lets a service be restarted or replaced mid-drive without taking the rest down.

Shape

   CAN (can1) ──▶ can-bridge ──┐
   OBD-II      ──▶ obd-poller ─┤
   GNSS        ──▶ gnss        ├──▶  message bus  ──┬──▶ recorder (disk)
   camera      ──▶ vision*     ─┘                   ├──▶ hud (local video out)
                                                    └──▶ telemetry-gw ──▶ remote displays
                          * later phase, separate task

Services

Service Responsibility Transmits on CAN
can-bridge Read SocketCAN frames, timestamp, publish raw No
obd-poller Active OBD-II PID request/response Yes
gnss gpsd client → position, speed, time fix No
decoder Raw frames → named signals via DBC No
recorder Persist raw + decoded sessions to disk No
telemetry-gw Serve external display clients No
vision Camera capture and on-device inference (later) No

Each ships as a systemd unit. The host-level prerequisites they depend on (kernel modules, device permissions, gpsd) are the environment repo's job, not theirs.

How these coexist on one 8 GB board — memory budget, startup ordering, failure isolation, audio — is in ORIN-RUNTIME.md.

CAN bus scope

Passive sniffing plus active OBD-II queries. OBD-II is a request/response protocol, so it necessarily transmits onto a 2008 car's bus. Constraints that follow:

  • can-bridge runs listen-only and must never transmit.
  • obd-poller is the single writer. No other service opens a TX-capable socket.
  • Transmission is rate-limited and restricted to standard OBD-II diagnostic IDs.
  • TX is a distinct, explicitly enabled mode — not the default state on boot.

Reverse-engineering of Z27AG-specific frame IDs proceeds purely from observed traffic; discovered signals are recorded as a DBC rather than as code.

Message bus

Proposed: MQTT (mosquitto) on the Jetson. Rationale:

  • First-class client libraries on every display target — ESP32 (Arduino PubSubClient / ESP-IDF) and Android (Paho) — which is the deciding factor.
  • Broker handles fan-out and retained "last known value", so a display that connects mid-drive immediately has state to render.
  • Trivially inspectable with mosquitto_sub while debugging in a car.

Rejected: NNG/ZeroMQ (lower latency, no broker, but a poor ESP32 story and no retained values); DDS (capable, disproportionate).

Topics

yukart/can/raw            high-rate raw frames, not retained
yukart/vehicle/<signal>   decoded signals (rpm, speed, coolant, …), retained
yukart/gnss/fix           position/speed/time, retained
yukart/system/<service>   health and status, retained
yukart/nav/guidance       next manoeuvre, distance, street name, retained
yukart/nav/route          active route geometry, retained

Payloads: JSON for low-rate signals (readable, debuggable); compact binary or CBOR for can/raw, since a 500 kbps bus produces far more traffic than JSON should carry.

Topic names and payload schemas are a contract shared across repos — Android and ESP32 clients depend on them — so they are versioned in their own repo rather than duplicated per client.

Swappable providers

The same shape has now been chosen independently three times, so it is the project's idiom rather than three coincidences:

Component Interface Real Trivial
svc-nav routing provider local Valhalla cloud routing API
svc-gw input source the bus synthetic state
voice TTS provider GPT-SoVITS espeak-ng, or a canned WAV

The rule in each case: one implementation of the contract, a swappable source behind it. That is what lets a service mock itself without a second codebase to drift (decision 19), and what keeps a heavyweight dependency from blocking work on everything downstream of it.

A new component that has an expensive or not-yet-existing dependency should assume this shape rather than rediscovering it.

Data flow and direction

Every on-Orin component is a bus participant, so there are no directed links between services to design. decoder subscribing to raw frames and publishing decoded ones is not a duplex link with anything — it is two independent one-way relationships that happen to share a process.

The only place direction is real is the Orin/display boundary.

The boundary is two simplex channels, not one duplex one

Both directions carry traffic, but they want opposite guarantees:

Orin → app app → Orin
Content signals, guidance, position route request, PTT, session mark, prefs
Rate continuous, 1–50 Hz bursty, human-paced, under 1 Hz
Loss tolerance high — the next sample supersedes none — a dropped mark is data lost silently
Semantics latest-wins, drop under backpressure at-least-once, acknowledged, retried
On reconnect push current state at once retry whatever is pending

One duplex socket with uniform semantics forces a choice between stalling the stream and losing commands. So: downstream a push stream that drops oldest under pressure, upstream plain request/response. That is decision 9 generalised from route requests to everything the app sends.

The app always initiates. The Orin listens and never dials out to a display, so reconnection is entirely the client's problem.

The Orin calls out; nothing calls in. This is forced anyway — an IoT SIM behind CGNAT has no inbound path — but stating it as a rule buys the CAN safety property for free. A cloud client that cannot initiate can never reach obd-poller, so the single-writer rule survives contact with the internet with no special casing.

Fixtures publish the real schema

Hardware arrives incrementally, so some sources are stubs for now. A stub publishes the real topic with the real payload schema: yukart/gnss/fix fed from a replayed trace must be indistinguishable, at the bus, from the same topic fed by a real fix. Swapping the real source in is then a no-op for every subscriber.

A stub that invents its own topic or shape is worse than no stub — it defers the integration work rather than removing it.

svc-gw holds two roles, and one must never block the other

svc-gw is both the local aggregation hub and the app gateway. Combining them is acceptable because the hub role holds no durable state: everything it knows is derived from retained topics, so a restart repopulates within one round of retained messages and costs nothing.

Keeping the roles separable requires four rules:

  • The hub reaches steady state with zero clients connected, always. Nothing in the aggregation path may wait 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. A slow client is dropped at the boundary, never absorbed by slowing the bus subscription.
  • Per-client state lives per-connection and never touches the aggregate.

Follow these and splitting the gateway out later is a refactor rather than a rewrite: the seam stays visible even while the process is one.

Display contract

Three display paths, in two classes:

Class A — video out. A screen driven directly by the Orin over SPI/DP/HDMI. The renderer is a local process subscribing to the bus over loopback.

Class B — data only. The Orin sends no video, only data; the device renders itself.

Path Transport Notes
Android app WiFi, or USB (RNDIS) Full-featured client, richest UI
ESP32 + RLCD/e-ink WiFi, or USB/BT serial Low-rate, glanceable subset of signals

Class B devices reaching the bus over IP connect to the broker directly. For transports without IP (BT SPP, plain USB serial), telemetry-gw bridges serial ↔ bus rather than each device inventing its own protocol.

Because both classes are bus subscribers, adding, removing or swapping a display touches no acquisition code.

Navigation

Self-hosted and offline-first, rather than a cloud maps SDK. Japan has good LTE coverage, so this is not the argument it would be in a sparse country — it rests on tunnels, of which Japan has a great many, plus mountain roads and urban canyon. Independently: a map that round-trips to a cloud API in order to pan is the wrong shape for a car whatever the coverage, and holding the data locally is what makes map matching against recorded sessions possible at all.

Piece Choice Role
Basemap tiles Protomaps PMTiles, Japan extract Single file, static-served; no tile server process
Renderer MapLibre GL Basemap, own position, trace, route line
Routing Valhalla Route and turn-by-turn, from OSM tiles built offline

Valhalla over GraphHopper for three reasons: it is C++ with memory-mapped tiles rather than a JVM holding a resident graph, which suits an 8 GB Jetson; it targets embedded and automotive use explicitly; and it ships Meili map matching.

That last point is why this is not a detour from the roadmap. Meili's trace_attributes snaps a recorded GNSS trace onto OSM and returns road attribution along it — speed limit, road class, geometry. That is ground truth for validating reverse-engineered CAN signals in Phase 2, and it pays off before any navigation UI exists.

A moving map — position, heading and trace, no routing — is most of the value in a car and needs only the renderer and a GNSS fix. Routing is the expensive 20% and comes last.

Guidance on the bus, route requests over HTTP

Guidance is state, so it belongs on the bus: yukart/nav/guidance carries the next manoeuvre, retained, and a display connecting mid-drive renders immediately.

Route requests are request/response and fit MQTT badly. Displays call the Valhalla HTTP API through telemetry-gw instead of inventing a request/reply topic pair. Recorded as decision 9.

Map data is not source

Basemap and routing tiles are large binary artifacts rebuilt from upstream OSM. They are never committed. tool-mapmint holds the scripts that fetch and build them, plus a report of each build's provenance; svc-nav consumes the output. The artifacts themselves live outside git and never touch the workstation-side repo.

They reach the car by rsync over the LAN only, never over LTE — the IoT SIM is not a sensible transport for a multi-gigabyte tile set. Gate the sync on a reachable LAN target rather than on an SSID, so the check validates the thing it actually needs, and treat NetworkManager's metered flag as a second guard. Cadence is quarterly or on annoyance; road geometry does not move fast enough to justify anything more.

Swap atomically. Build beside the live copy, symlink-swap, restart Valhalla — never overwrite tiles in place while the server is reading them. The basemap is one file, so write-then-mv covers it with no restart.

This also settles the tile form: the directory wins over the .tar, twice over — once for RAM on an 8 GB board, and once because rsync deltas a tree of changed files efficiently where a single multi-gigabyte tar forces a full-file checksum at both ends.

Japan specifics

Concern Consequence
Left-hand traffic valhalla_build_admins builds the admin database that determines driving side. Build it from the whole-Japan extract, never a sub-region — verified on the Orin, a Shikoku extract yields only admin_level=4 prefecture rows with no country record, and every row reports drive_on_right=1. Driving side is a country-level property, so a clipped extract silently gives right-hand-traffic routing even when the step ran correctly.
Data volume The whole-Japan PBF is 2.3 GB — Japan is among the more densely mapped countries in OSM. Regional extracts keep routing tiles small (Shikoku: 49 MB PBF → 149 MB of tiles, measured), but the admin database must still come from the full extract — see above. The two builds are separate invocations and may take different inputs.
CJK labels MapLibre cannot ship Japanese glyph sets as SDF ranges — far too large. Set localIdeographFontFamily so the browser renders CJK from on-device fonts. Protomaps tiles also carry name:en, so romanised labels are an option.
Addressing Japanese addresses are block-based, not street-based. Valhalla does not geocode at all, so destination search is a separate problem — do not assume a western geocoder transfers.

arm64 — resolved

No longer a risk. Verified on mikkeli-orin-nano-2 (L4T R36.4.7, JetPack 6) on 2026-09-04: ghcr.io/valhalla/valhalla publishes a linux/arm64 manifest, the image runs natively, and the full pipeline works end to end — config, timezones, admins, tiles, and a served route.

Measured: Shikoku, 49 MB PBF → 149 MB of routing tiles; Takamatsu → Matsuyama routed in 157 km / 112 min with Japanese street names intact in the narrative.

The server logs a benign tiles.tar No such file or directory warning and falls back to the tile directory, which is the form decision 10 selected anyway.

Decisions

Recorded here so fast-moving choices stay visible. Flip a row rather than rewriting the document.

# Decision Status Date
1 Separate services over a message bus, not one process Accepted 2026-08-14
2 Displays are bus subscribers; no display-specific logic upstream Accepted 2026-08-14
3 Listen-only sniffing + active OBD-II, single writer Accepted 2026-08-14
4 MQTT as the bus Proposed 2026-08-14
5 Implementation language(s) per service Open —
6 Local logging first; off-board LTE telemetry deferred Accepted 2026-08-14
7 Camera/AI inference is a later, separate task Accepted 2026-08-14
8 Self-hosted offline navigation (MapLibre GL + Valhalla + PMTiles) over a cloud maps SDK Accepted 2026-09-03
9 Nav guidance published on the bus; route requests over HTTP via telemetry-gw Proposed 2026-09-03
10 Map artifacts delivered by rsync over LAN only; Valhalla tiles kept as a directory, not a tar Accepted 2026-09-03
11 Boot ordering is capture-first: acquisition reaches ready before serving or voice Accepted 2026-09-03
12 The tablet owns the car's aux input; TTS crosses as chunked utterances, streaming-capable Accepted 2026-09-04
13 Reasoning stays in the cloud — a local LLM does not fit beside the voice stack Accepted 2026-09-03
14 Recordings are bounded with rotation Accepted 2026-09-04
15 Boundary is two simplex channels — lossy stream down, acknowledged request/response up Accepted 2026-09-03
16 Cloud links are outbound-initiated only; nothing calls in Accepted 2026-09-03
17 svc-gw combines local hub and app gateway; no client may block the hub Accepted 2026-09-03
18 Stubs publish the real topic and schema, never a fixture-specific one Accepted 2026-09-03
19 Services mock themselves behind their own input interface; no separate stub implementations exist Accepted 2026-09-04