Add navigation, Orin runtime and data flow design; rework repo naming

Navigation: self-hosted MapLibre + Valhalla + PMTiles, offline-first, with
Japan specifics (left-hand traffic via valhalla_build_admins, data volume,
CJK labels). Map matching pulled forward as Phase 2 ground truth.

Orin runtime: new document covering the 8 GB shared-memory budget,
capture-first startup tiers, failure isolation, storage and the audio graph.

Data flow: direction belongs to topics inside the Orin; the display boundary
is two simplex channels rather than one duplex socket; cloud links are
outbound-initiated only; services mock themselves rather than having separate
stub implementations.

Naming: repositories are <group>-<thing> with no project prefix, so lexical
order matches semantic grouping. Indexes the five new repos.

Decisions 8-19 recorded; 14 (recording retention) left open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013kj58Jugkic783b8ZrtTVZ
This commit is contained in:
2026-09-04 08:25:26 +09:00
parent 62ac7bd09d
commit 449aa0c99e
5 changed files with 365 additions and 14 deletions
+163
View File
@@ -37,6 +37,9 @@ 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
@@ -71,6 +74,8 @@ 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
@@ -81,6 +86,75 @@ Topic names and payload schemas are a **contract shared across repos** — Andro
and ESP32 clients depend on them — so they are versioned in their own repo
rather than duplicated per client.
## 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
Three display paths, in two classes:
@@ -103,6 +177,83 @@ 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. It is a separate step *before* the tile build, and the database is used only at build time, not by the running server. Skip it and Valhalla routes and narrates as though Japan drove on the right. |
| **Data volume** | The whole-Japan PBF is **2.3 GB** — Japan is among the more densely mapped countries in OSM. Routing tiles and basemap scale with it. Geofabrik publishes per-region extracts (Kantō, Chūbu, Hokkaidō, …); start with the region actually driven, not the whole country. |
| **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. |
### Known risk
Valhalla's arm64 container story is unsettled upstream. Confirming that Valhalla
builds and runs on the Jetson is the **first** task in this workstream, before
any data pipeline work is done against it.
## Decisions
Recorded here so fast-moving choices stay visible. Flip a row rather than
@@ -117,3 +268,15 @@ rewriting the document.
| 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 Orin owns the car's aux input; the display unit carries no permanent cable | Accepted | 2026-09-03 |
| 13 | Reasoning stays in the cloud — a local LLM does not fit beside the voice stack | Accepted | 2026-09-03 |
| 14 | Recording retention policy — unbounded growth silently stops capture | **Open** | — |
| 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 |