--- 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 ──▶ can-bridge ──┐ OBD-II ──▶ obd-poller ─┤ GNSS ──▶ gnss ├──▶ message bus ──┬──▶ recorder (disk) camera ──▶ vision* ─┘ └──▶ telemetry-gw ──▶ remote displays (tablet app) * later phase, separate task The Orin runs headless: no local video out. The tablet app is the display. ``` ## 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; raw CAN and GNSS read directly, not via the bus (decision 25) | No | | `telemetry-gw` | Serve external display clients | No | | `orin-status` | The Orin's own health: temperatures, CPU/GPU load, memory, network links, UPS battery → `yukart/host/*` | No | | `vision` | Camera capture (any V4L2 camera; in practice USB UVC, MJPEG), live view, software H.264 recording; on-device inference later. Repo `svc-vision`, Python (decision 29) | 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 **MQTT (mosquitto) on the Jetson**, listening on localhost only: displays reach the bus through `svc-gw`. 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/ decoded signals (rpm, speed, coolant, …), retained yukart/gnss/fix position/speed/time, retained yukart/host/ the Orin itself: temperatures, load, links, UPS battery, retained yukart/system/ health and status, retained yukart/nav/guidance next manoeuvre, distance, street name, retained yukart/nav/route active route geometry, retained ``` Payloads: a self-describing JSON envelope for decoded signals; compact binary for `can/raw`, since a 500 kbps bus produces far more traffic than JSON should carry. Every envelope carries **two clocks**. `monotonic` is authoritative for ordering and intervals; `timestamp` is the absolute reference and may step, since the car boots without network and NTP corrects the clock once LTE connects mid-drive. The car unit has no RTC battery, so until then the clock sits at roughly the last shutdown time — offset by however long the car was parked. Topic names, payload schemas and the signal registry live in [contracts](gitea:yukart/contracts). 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 re-dialling is the client's job. Noticing the loss and redelivering is the Orin's. ### Link drops are routine, not exceptional Measured on the USB path (tablet ↔ PD hub ↔ Orin, 2026-10-09): cutting PD power resets the hub's data port, and the link is back within ~3.5 s with no intervention; restoring it costs ~2.2 s (a gadget reconnect, no detach). In the car that happens at every ignition change, cranking included. Longer outages measured early on were the tablet sleeping on battery, not the link. Design for drops as normal operation. Requirements, not yet implemented: - **Failure detection on the Orin.** An app-level heartbeat with a timeout of a few seconds, not TCP alone. A connection on a link that went down can stay half-open long after the app has re-dialled. Reap dead connections and their per-client queues promptly. Link state doesn't help either: a tablet that has fallen asleep on battery leaves the Orin's USB interface up while answering nothing. - **The app keeps the tablet awake while it is the display.** Off charger, Android suspends about a minute after screen-off. Then the link is gone in every way that matters. Kept awake, a PD change costs only 2–3.5 s (measured). - **Downstream (latest-wins):** unchanged. On reconnect, push current state; no resend, since stale samples are worthless. - **Reliable channel (TTS chunks, acknowledgements):** pending items survive a drop. Redeliver on reconnect, or expire by age (an utterance that is several seconds late is better dropped than played). Within the window, a reconnect should resume rather than start over. - **Upstream retries cross reconnects**, so commands carry an idempotency key. At-least-once delivery then never means acting twice. ### 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. The one proposed exception is the camera's live view (decision 27): a paced video stream beside the boundary, never on the bus. | Path | Transport | Notes | |---|---|---| | `app-flutter` | USB (NCM: tablet is host, Orin at `192.168.55.1`), 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 | Over USB, Android sends app traffic only via its *default* network, and the USB link, having no internet, never becomes default while WiFi or cellular is up. `app-flutter` must request the Ethernet network (`TRANSPORT_ETHERNET`) and bind to it. The Orin's USB-C port can't charge the tablet (no USB PD), so in the car the link runs through a PD hub that feeds the tablet from the car charger. 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 metered povo 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 (set on the `povo` connection). 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 (raw CAN rate still to confirm) | Accepted | 2026-10-12 | | 5 | Rust for on-Orin services, Python for off-car tooling, Dart for the display client. One exception: decision 29 | Accepted | 2026-09-04 | | 21 | Two clocks per envelope; `monotonic` orders, `timestamp` references | Accepted | 2026-09-05 | | 22 | `signals.json` is the single source for signal name and unit; both are derived, never authored twice | Accepted | 2026-09-05 | | 23 | Services build natively on the development Orin; both units stay on the same L4T release | Accepted | 2026-09-05 | | 24 | Link drops are routine: `svc-gw` detects dead clients by heartbeat and redelivers or expires pending reliable traffic; upstream commands are idempotent | **Proposed** | 2026-10-09 | | 25 | The recorder reads SocketCAN and gpsd directly; raw capture never depends on the broker | **Proposed** | 2026-10-09 | | 26 | Boundary v0 carries both channels of decision 15 on one WebSocket, each keeping its own semantics (drop-oldest down, acked and idempotent up) | **Proposed** | 2026-10-09 | | 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 | | 27 | Camera live view is the one video stream to the tablet: its own connection beside the boundary, paced to the link (≤ ~4 MB/s), never on the bus. Exception to 20 | **Proposed** | 2026-10-11 | | 28 | Every Orin → tablet sender caps its socket (`SO_MAX_PACING_RATE`, ~5 MB/s); unpaced bursts stall the USB link for seconds | **Proposed** | 2026-10-11 | | 29 | `svc-vision` is Python: camera pipelines and inference live in that ecosystem. Exception to 5 | Accepted | 2026-10-12 |