0b929d30ca
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kj58Jugkic783b8ZrtTVZ
326 lines
16 KiB
Markdown
326 lines
16 KiB
Markdown
---
|
||
type: reference
|
||
---
|
||
# 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](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.
|
||
|
||
### 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
|
||
|
||
A component with an expensive or not-yet-existing dependency puts it behind an
|
||
interface, with a trivial implementation alongside the real one:
|
||
|
||
| 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
|
||
|
||
### Inside the Orin, direction belongs to topics, not links
|
||
|
||
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.
|
||
|
||
### Cloud links are outbound-initiated
|
||
|
||
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
|
||
|
||
**Every display is data-only.** The Orin sends no video to anything. Each device
|
||
subscribes to the bus and renders itself.
|
||
|
||
| Path | Transport | Notes |
|
||
|---|---|---|
|
||
| `app-flutter` | USB (RNDIS), or WiFi | The display. Full client, richest UI, also the audio sink |
|
||
| `app-esp32` + RLCD/e-ink | WiFi, or USB/BT serial | Low-rate glanceable subset. Not in current scope |
|
||
|
||
Displays reaching the bus over IP connect to the broker through `svc-gw`, which
|
||
owns the boundary. For transports without IP — BT SPP, plain USB serial —
|
||
`svc-gw` bridges serial ↔ bus rather than each device inventing its own protocol.
|
||
|
||
Because every display is a bus subscriber, adding, removing or swapping one
|
||
touches no acquisition code.
|
||
|
||
### Directly-driven screens
|
||
|
||
A screen driven over DP or HDMI is viable when it is **permanently installed and
|
||
powered by the car**. A dash-integrated panel qualifies: plug in DP and it is a
|
||
screen, and it adds a renderer without touching the contract, since the bus does
|
||
not care what draws it.
|
||
|
||
It is not viable for a removable display unit, which would need a second cable:
|
||
|
||
| Supply | Available | A panel needs |
|
||
|---|---|---|
|
||
| DisplayPort `DP_PWR` | 3.3 V / 500 mA, and VESA cables leave it unconnected at the sink | 5–15 W |
|
||
| HDMI +5 V | 55 mA | |
|
||
|
||
DSI is unavailable on this carrier board — the module carries it on SODIMM pins,
|
||
but the developer kit does not break them out, so DSI needs a custom carrier.
|
||
|
||
## 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 `svc-gw`. 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. |
|
||
|
||
### Verified on the Jetson
|
||
|
||
`ghcr.io/valhalla/valhalla` publishes `linux/arm64` and runs natively on
|
||
L4T R36.4.7. Measured:
|
||
|
||
| | |
|
||
|---|---|
|
||
| Shikoku PBF → routing tiles | 49 MB → 149 MB |
|
||
| Whole-Japan PBF | 2.4 GB |
|
||
| Admin DB, whole Japan | 17 MB |
|
||
| Timezone DB | 116 MB |
|
||
| Takamatsu → Matsuyama | 157 km, 112 min |
|
||
|
||
Japanese street names survive intact in the route narrative.
|
||
|
||
The server logs `tiles.tar No such file or directory` at startup and falls back to
|
||
the tile directory, which is the form decision 10 selects. Not an error.
|
||
|
||
## 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 | A service mocks itself behind its own input interface; there is one implementation of each contract | Accepted | 2026-09-04 |
|
||
| 20 | Every display is data-only. Directly-driven screens are for permanently installed, car-powered panels only | Accepted | 2026-09-04 |
|