System architecture
The whole project is shaped by one decision: everything device-specific lives at the edges, and everything in the middle is shared. The map reader, the route reader, the renderer, and the application logic are one body of no_std code that runs byte-for-byte identically on the desktop simulator and on the microcontroller. Only the outermost shell — where pixels land, where bytes come from, what a "fix" is — differs between them.
That's why the landing page's browser demo (the obc-web-demo wasm host) runs the same code as the device, and why the nRF54L firmware reuses the entire stack unchanged. This page is the map of that structure.
The runtime stack
The crates form a stack with dependencies pointing one way — downward. The foundation fixes byte contracts and semantic boundaries; each layer up adds capability; the hosts sit on top. Nothing in the shared core ever depends on a host.
App.)The one-way rule is the load-bearing constraint. obc-app builds for the bare-metal target (thumbv8m.main-none-eabihf) with no host present; the simulator and the firmware are just two different things that link against it. Swap the host, keep the core.
Inside the crate, the App a host constructs is a composition root, not the implementation site: one plain struct per responsibility, composed by value and driven through the same façade methods hosts have always called.
- The ride engine owns everything derived from the sensors and the active route — the matcher, the once-per-load elevation/climb/waypoint caches, the breadcrumb.
- The UI runtime owns the screen stack, timers, repaint accumulation, and the delivery discipline for host-pushed cards.
- The catalog state owns the route/ride/trip catalogs and their durable ids; the id ↔ summary pairing and the rescan remap live in one place, so an inserted or deleted file can never silently retarget what's navigated.
- The host module owns the typed command/event vocabulary and its pending state.
- The render-only scratch — the dominant slice of the app's resident footprint — sits behind a placement-initialized wrapper, because on the device the whole
Appis initialized field-by-field straight into reserved RAM rather than built on the stack.
Components talk through parameters, not back-references — a delivery rule that needs to know "is a ride being tracked?" is handed that fact — so the dependency direction inside the crate is as one-way as the crate graph around it.
At the bottom, obc-formats is deliberately smaller than a reader: no allocator, storage adapter, cache, converter, or rendering policy. The format specs stay the normative byte contracts; this crate is their code authority for versions, fixed lengths, flags, sentinels, endian primitives, and the neutral byte-source/sink traits. Every persistent-format producer and consumer — including obc-pack and the deliberately independent obcm-testkit byte builder — imports those facts from here, so each byte fact has exactly one import path; obc-reader and obc-route keep the parsing and streaming algorithms.
obc-map-scene is the equally small boundary between a map source and rasterisation: neutral bounds, geometry/style metadata, LOD selection, candidate visitation, and selected-feature decode into caller-owned buffers. The normal obc-reader adapter is monomorphised into the renderer's hot path; a static test scene can implement the same contract with no map file at all. Opaque six-byte candidate tokens let a source find winners again without leaking byte offsets.
Beside it, obc-ports owns the dependency-free values and traits that cross the app/host boundary. The app and every implementation — platform, board, simulator, replay, browser host, USB — bind to it directly; obc-app::hal remains only a compatibility re-export. SettingsStore uses an associated value type so the foundation never depends upward on the app's Settings model.
Two hosts, one core — and the seams between them
A "host" is whatever constructs an App and drives it. The simulator (obc-sim) is an eframe/egui desktop shell; the device firmware (obc-fw-nrf54l, via obc-platform) is bare-metal on the nRF54LM20. A third host, obc-web-demo, puts the same core behind a small wasm API for the landing page's demo — no GUI framework; the page's JS owns the canvas and the frame loop. Seam-wise it's a minimal sibling of the simulator, with in-memory stores and a GPX replay for sensors; the host logic the two share — replay stepping, the frame-interleaved route planner, the stores, and the command/event orchestration below — lives in obc-host-core. (A second wasm crate, obc-web-convert, is deliberately not a host: it constructs no App, it just exposes obc-route's GPX ↔ OBCR converters to the web builder's JavaScript.)
Each host owns its window/panel, its storage, and its sensors — and hands the core four small abstractions. Those four seams are the entire device-specific surface area; find them and you've found every boundary that matters.
Two of these deserve a closer look.
ByteSource — reading bytes you don't have room for. A full map is far larger than the device's RAM, so the reader never holds the whole file. It reads through a tiny random-access trait, a chunk at a time, backed by an in-memory slice on the host and by a FatFs handle on the SD card on the device:
pub trait ByteSource {
fn read_at(&self, offset: u32, buf: &mut [u8]) -> Result<(), Error>;
fn len(&self) -> u32;
}
read_at takes &self (not &mut self) so the reader can hold one shared &dyn ByteSource and stay simple; a seeking medium uses interior mutability behind it. The same seam serves the route reader, and a small resident cache keeps recently-read chunks hot across frames so streaming doesn't re-hit the card.
The seam is also where the device buys back the cost of random access on a FAT filesystem. FAT stores a file's location as a singly-linked cluster chain, so seeking backward in a 300 MB map means re-walking the chain from the front — over a hundred extra sector reads per 2 KB chunk, which once made the on-device router two orders of magnitude slower than the search itself. Since the map is opened read-only and never moves, the firmware resolves its chain once at open into a small table of contiguous extents; every later read_at is then plain arithmetic plus the data blocks. The reader above never learns any of this — it still just calls read_at.
The HAL — the world, abstracted. GPS fixes, GPS time, barometric altitude, temperature, the compass heading, the heart rate / power / cadence from BLE sensors, the track log, settings persistence, and raw button events each arrive through their own trait in obc-ports, with live sensors bundled into a Sensors set the app polls once per tick. The implementations live where they belong: board-agnostic adapters in obc-platform, the pure sensor-chip decoders in obc-sensors, the SD storage adapters in obc-storage, recorded-ride sources in obc-replay, and shared desktop/browser stepping in obc-host-core. The app is oblivious to whether a fix came from a satellite or a GPX replay — or a heart rate from a real strap or an injected test line. The three BLE-sensor sources are fed by the radio's central-role manager (or the simulator's sliders), and a value older than 5 s drains as None — a dropped strap reads -- and records as absent rather than freezing its last number into the log.
Each poll is a mailbox drain, not a bus transaction: it returns Some only on the tick a fresh sample arrived and None between. On the device this is event-driven — a high-priority task drives the I²C bus (a u-blox SAM-M10Q GPS + a Bosch BMP581 altimeter + an electronic compass on one shared bus) only when the GPS signals a fix is ready, so there is zero bus traffic at the frame rate. That task also makes position and altitude coherent: it reads the barometer on each GPS fix, so the two share one instant — they're written together into the track log. The one tradeoff is that climb then accrues only while fixes arrive — a GPS outage (a tunnel) pauses it — but during an outage there's no position to log anyway. A cold start or a dropout simply yields None, so the camera never teleports onto a stale fix.
The compass earns its place by covering for the GPS: a receiver only reports a course while you're actually moving, so a stopped rider's heading-up map would otherwise snap to north. A magnetometer gives the heading independent of motion, so the orientation holds while you're stopped; once moving, the GPS course takes over again. Because the heading is never logged — it only orients the live map — it isn't tied to the fix like the altimeter is: the task reads it on its own faster cadence while stopped (so the map stays lively as you turn the device by hand, even when the fix rate is slow for power saving), and not at all while moving or idle. Only the magnetometer's three axes are used (a flat heading — no tilt compensation), so the source is a plain 3-axis compass even though the bring-up board reads it from a 9-axis IMU's magnetometer; the heading geometry is kept separate from the chip's register map, so the expected swap to a dedicated magnetometer touches only the chip half.
The same task also power-manages the receiver. Continuous tracking draws far more than an idle device should, so once it has a boot fix the GPS is put into deep sleep (a backup mode that keeps its clock and almanac on microamps) whenever a ride isn't running — drawing essentially nothing until you start one, when a poke wakes it for a fast warm fix. While riding it runs full-power, or, with the Power screen's power-saver on, the receiver's own low-power tracking mode.
The receiver's UTC time rides the same mailbox model but is published independent of the position fix — the GPS resolves time before a 3D lock, so the clock is set during acquisition. There is no battery-backed RTC: the wall clock is a stored set-point advanced by the monotonic timer, and a GPS stamp re-establishes that set-point. A fresh boot's set-point is stale by however long the device was powered down, so it is display-only until a real source re-establishes it this boot — a GPS fix, or the phone's setClock on connect. That trusted-this-boot distinction is the safety gate the storage auto-expiry sweep won't delete without; manual date/time editing was removed so nothing else can establish trust, and the remaining UTC-offset stepper shifts only the displayed hour.
The per-frame loop
Every host runs the same four-step loop. The crucial part is the last step: the app tells the host what actually changed, and the host redraws only that.
tick folds sensor samples into the camera and ride stats; handle_input turns the four buttons into gestures that drive the screen stack; take_dirty reports a { map, overlay } change set; the host renders only the parts that changed. On a static screen nothing dirties, so render_map — the expensive part — is skipped entirely.In code, the host loop is almost exactly that diagram:
loop {
app.tick(RideClock(now), sensors, route); // GPS + baro → camera, map-match, ride stats
app.handle_input(InputClock(now), &mut controls); // four buttons → gestures → screen stack
let dirty = app.take_dirty();
if dirty.map { app.render_map(&mut display, &reader, route, w, h, color_fn); }
if dirty.overlay { app.render_overlay(&mut display, w, h, color_fn); }
}
This render-on-demand is the main power lever. The reflective panel holds its image without power, so the goal is to not draw: a parked bike on the Home screen issues no map renders frame after frame — the lone exception is one cheap chrome repaint a minute, when the displayed HH:MM ticks over. The map only dirties when something the picture shows actually changes — a fresh fix on a riding screen, an applied gesture, a route load, a new minute — so redraws happen exactly when the picture would change and never otherwise. (The two clocks are deliberate: ride stats use a sample-relative RideClock so a fast GPX replay doesn't distort moving-time, while button holds use a real-time InputClock.)
On the device the loop goes one step further: it doesn't just skip the drawing when nothing changed, it skips the waking. Rather than tick on a fixed timer, the loop sleeps until a real event — a button edge, a fresh sensor sample, or the next animation deadline — and the processor idles (WFI) in between. The app reports that deadline as a single "next wake time" (the soonest timed repaint a visible screen needs: the Home clock's next minute, a settling cursor, the idle-return timeout), so the host arms exactly one timer and selects it against the input and sensor wakes. When nothing is animating and the GPS is asleep, the only timer left is a ~10 s guard tick that feeds the hardware watchdog — the last-resort net that reboots the device if a plane wedges. A parked computer wakes a handful of times a minute, not a hundred times a second. (One subtlety: a press-and-hold emits no gesture until it completes, so the input plane also nudges the loop the moment a hold starts charging — otherwise the hold animation would sleep through its own charge.) The one unavoidable heartbeat is the panel's COM electrode wave — the memory-in-pixel cells must never see a DC bias, so a ~60 Hz square wave runs the whole time the panel is powered. On the production board it is handed to a hardware timer (a TIMER→DPPI→GPIOTE toggle chain) so it free-runs with no CPU at all.
selects over three wake sources — input, sensor sample, and the soonest animation deadline the app reports — and the processor sleeps (WFI) until one fires; it then runs one iteration and arms the next wake. Idle, the only timer left is the watchdog-feed guard tick, so a parked Home screen wakes a handful of times a minute instead of 125 times a second. Only the panel's COM wave never stops — and on the production board that's a hardware timer, not the CPU.Beyond rendering, each loop pass also carries the app's requests to the host and the host's answers back. That traffic speaks one typed vocabulary (host.rs): everything the app can ask — delete this route, run this plan, persist the settings, rescan the store — is a HostCommand the host drains once per pass, and every answer or fact is a HostEvent it applies back. The protocol encodes each request's semantics rather than leaving them to convention: one-shots drain exactly once, store-change bursts ride as a count that can't be lost, cancellation drains before new work, and a full mailbox back-pressures instead of dropping anything. Commands and events carry only bounded ids and small results — bulk data such as catalogs and elevation profiles stays in app-owned buffers the host fills directly — and the input/overlay plane deliberately stays off this channel, so a button edge never queues behind store work.
The sequencing of that traffic — drain the mailbox, delete a route and re-feed the catalog, step the resumable planner one bounded pass, reconcile the ride log — is written once, for every frame-stepped host, in obc-host-core. A single dispatcher drives the drain against narrow repository interfaces that the simulator's folder-backed stores and the web demo's in-memory stores both satisfy, with one shared conformance suite proving they behave alike. The board keeps its own asynchronous execution but consumes the identical HostCommand/HostEvent vocabulary, and shared protocol tests pin that its ordering matches the dispatcher's.
On-device routing: the router seam
The device can compute its own route to a POI — no phone, no pre-planned GPX. That capability arrives as a fifth seam in the same shape as the others: the shared core asks, the host does, and the answer flows back through one narrow call. The core never learns whether the route came off the SD card, over Bluetooth, or out of the on-device router — by the time it navigates, all three are the same OBCR bytes.
The trick that keeps it simple is that the router's output is a route file like any other. It writes a normal OBCR to a reserved path — /routes/_nav.obcr (the 8.3 face _NAV.OBR on the card), overwritten in place on every plan — which the existing catalog scan picks up and the existing stream path loads. From the per-frame loop's point of view, a computed route and an uploaded GPX are indistinguishable: same matcher, same elevation profile, same "distance to go." The mid-ride Detour commits through the same slot: the spliced route it produces is an ordinary OBCR written to the reserved path, rescanned and re-adopted like any plan — even when the route being detoured is the previous occupant of that slot.
A plan takes seconds on the SD-bound device, so the flow visibly breathes instead of blocking: accepting the confirm opens a planning screen — a spinning compass needle over plain copy — while the host steps the planner one bounded slice per loop pass (snap, then a miss-budgeted burst of A\ settles a step — the step ends after a small fixed number of SD chunk reads, so a warm cache does far more useful search per pass — then a few emit hops a step). Render, input and the watchdog all run normally between* steps, and Back cancels: the screen pops back to the POI detail and the host simply stops stepping — nothing reaches the reserved file until the emit phase, and a cancelled plan's partial file is discarded.
The miss budget is sized from measurement. The router reads the §8 graph through a small eight-slot, 512-byte tile cache — about 4 kB of static RAM. An earlier design used two larger slots, on the theory that A\ settles nodes in spatial runs; profiling showed the opposite — the frontier pops the globally* best-f node and hops between several simultaneously-active leaves, so two slots thrashed. Eight slots in the same RAM lifted the hit rate on a representative 8 km plan from ~33 % to ~56 %, and because a search step is bounded by cache misses (roughly one SD read each) rather than a fixed settle count, a warmer cache does more useful search per pass — that plan's search dropped from 101 steps to 58.
Three bounds are worth stating, because the router trades exactness and range for fitting in fixed RAM next to the map cache and the render scratch:
- No distance cap — running out of memory is the range limit. There is no crow-flies pre-check; the limiter is the fixed A\ table. A full table doesn't abort on sight: the first failed insert latches a
table_fullflag and the search keeps going without adding new nodes, so a goal discovered before the table filled is still returned. Only once the frontier drains does the plan fail — surfaced asExhausted→ "Too far to route here.", while every other failure (no routable node within the 250 m snap radius, an empty frontier, a failed read) is the generic "Couldn't find a route." tier. Range is therefore terrain-dependent, not a radius: a sparse alpine network plans into the double-digit kilometres, a dense urban grid exhausts far sooner — the table holds a node count*, and terrain decides how much ground that count covers. - Profile-weighted A\ with an escalating ε — not shortest path. The priority is
f = g + ε·h.gis distance scaled by the chosen bike profile's weight for each edge's way-kind, so the search optimises "shortest under your bike type" (profiles and a worked example: the packer page). ε inflates the heuristic to make the search goal-greedy — a narrow corridor toward the POI instead of plain A\'s outward exploration — which is what lets the fixed scratch reach multi-kilometre routes. It starts at 1.3× and, only if that bound exhausts the table, escalates 1.3 → 2.0 → 3.0, retrying the same snapped endpoints over a still-warm cache. The returned path is at most the successful rung's ε times the best route under the profile. - A ~40 kB nav budget, and the simulator sits inside it. The A\ scratch is a fixed table sized per target: ~20 kB on the 256 kB dev board, and on the host/simulator exactly the LM20's 40 kB cap — so the simulator's plannable range is* the shipping device's range by construction. The scratch lives in static memory, never a stack frame, so a long plan can't blow the device's tight stack next to the render peak; on glass, planning is SD-bound at roughly 100 ms per chunk read.
The router itself — snap, the weighted-A\* settle loop, and the OBCR emit — is obc-route/src/nav.rs; the §8 graph it reads is described on the data formats page and built by the packer; the app-side request/answer seam is the typed host protocol — the host drains a PlanRoute command and answers with a NavPlanned event through apply_event, alongside the BLE-upload events it deliberately mirrors.
Staying responsive: the two planes
There's a tension on the device. A dense map frame can take the better part of a tenth of a second to render and push to glass, but a button press must feel instant. If both lived on one thread, a press during a long render would stutter. So the device splits the work into two cooperating planes.
The split is a behaviour-preserving relocation: the same InputPlane either runs inline (the simulator) or stands alone on the high-priority executor (the device). Because gesture recognition depends only on the raw events plus a clock — never on app state — buffering a gesture and applying it a moment later is identical to applying it inline. That's the property that makes the whole split safe.
Where this lives
- The per-frame driver, the screen stack, and the dirty tracking:
obc-app/src/app.rs - The semantic hardware/host ports:
obc-ports/src/lib.rs; direct platform implementations:obc-platform/src/; app compatibility re-exports:obc-app/src/hal.rs - The two-plane input model:
obc-app/src/input_plane.rs - The on-device router (snap · weighted A\* · OBCR emit):
obc-route/src/nav.rs - Persistent-format constants, primitive codecs, and the byte-streaming seam:
obc-formats - The device host adapters over real hardware: the DrawTarget / present seam in
obc-display, the SDByteSource/ByteSinkinobc-storage, the sensor chip decoders inobc-sensors, and the source/handoff bridges inobc-platform
For how the renderer in the middle of this stack actually draws a frame, see the rendering pipeline. For the binary formats the reader streams, see data formats. For the screen stack and gestures the loop drives, see the UI system.