OBC OpenBikeComputer / docs

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.

The runtime stack — every arrow points down obc-sim · obc-web-demo desktop sim · browser demo obc-fw-nrf54l device · obc-platform adapters hosts obc-app camera · screen stack · input · ride tracking — the per-frame driver obc-render projection · culling · rasterising obc-reader OBCM · quadtree · chunk decode obc-route OBCR · GPX · map-match obc-map-scene styles · candidate visits obc-formats layouts · codecs · byte seam obc-ports semantic traits · no deps
Hosts depend on obc-app; the app on obc-render — and directly on obc-reader + obc-route. Render and reader meet only through obc-map-scene, so the renderer never learns OBCM offsets, quadtrees, or cache policy. Beneath them, obc-formats owns persistent byte facts and obc-ports owns the semantic sensor/input/track/settings traits; host and platform implementations bind to those directly, never upward to app policy. Because every arrow points down, the shared core compiles and runs without any host. (Two crates sit outside this stack: obc-pack, the offline map producer, and obc-web-convert, a wasm shim over obc-route's converters — neither constructs an 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 App is 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.

Everything device-specific lives at four seams obc-sim host device host shared core reader·route·render·app DrawTarget pixels out RGB222 FB · self-diffed RGB222 FB · banded push color_fn u16 → pixel 64-colour (PNG: true-colour) native RGB222 (64) ByteSource bytes in in-memory slice FatFs on the SD card obc-ports semantic HAL panel · GPX · keys GPS · baro · mag · GPIO
The core is generic over a DrawTarget (where pixels go) and takes a colour function (16-bit style colour → this panel's pixel); it reads every map and route through a ByteSource and gets the world through the semantic traits owned by obc-ports. Implement those four seams for a new board and the whole stack runs on it — no changes to the core.

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.

One frame — then redraw only what changed tick sensors → camera handle_input controls → gestures take_dirty what changed? render_map render_overlay if map dirty if overlay dirty next frame
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.

On the device: sleep until a real event three wake sources button edge a gesture · a charging hold sensor sample GPS fix · baro · heading animation deadline next clock minute · cursor select — first to fire asleep · WFI CPU idle between events z z z wake run one iteration apply gestures · advance animations tick → take_dirty → render only what changed arm the next wake, sleep again idle (nothing animating · GPS asleep): just the ~10 s watchdog-feed guard tick
The device loop 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.

The core asks, the host routes, the answer re-enters the load path shared core (obc-app) host POI detail → press "Create a route?" confirm NavRequest (one-shot) from = rider fix · to = POI coord · name PlanRoute command plan against the resident map obc-route::NavPlanner — snap 250 m, profile-weighted A* (ε-ladder) over §8 graph → stream OBCR into /routes/_nav.obcr → rescan catalog, resolve durable id stepped once per pass — the loop's watchdog covers it NavPlanned(Result) event Ok(id) → NEW ROUTE overview + preview, route activates → normal load/nav path Err → two-tier card: Exhausted → "Too far…" · else "Couldn't find…" re-enters the load path the reserved file is just another route in the catalog — same RouteReader, matcher, profile as a loaded GPX
The flow mirrors a BLE route upload: the core records a one-shot request, the host does the slow work and rescans-then-resolves before answering with a durable id, and the answer opens either an overview or a failure card. The overview's small route-shape preview comes through the same seam — the host decimates the planned polyline to at most 64 points, so the sketch costs a fixed ~512 B. Everything downstream is the unchanged navigation pipeline: a computed route rides through the same matcher and profile as any other.

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_full flag 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 as Exhausted → "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. g is 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.

Input never waits on the map render input plane high-priority executor sample buttons · recognise gesture · animate overlay — every few ms map plane the expensive render render base map + push · ~44 ms preempts the render gesture channel → Gestures flow one way; the shared panel + framebuffer are serialised by a bus mutex. On the simulator both halves run inline.
The input plane samples buttons, recognises the five gestures, and repaints the hold-progress overlay — re-pushing just its small screen window, the bulge composited over the map read back from the framebuffer — on a high-priority executor that preempts the CPU-bound map plane every few milliseconds. Recognition is coupled to the map plane only by a one-way gesture channel, so a press lands a frame later without ever blocking on the render; the shared SPI panel + framebuffer are serialised by a bus mutex, so a long render can briefly hold off the overlay repaint (never the recognition). On the simulator both halves run inline; the recognition logic is the same struct either way, so behaviour is identical.

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

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.