The companion link
The device is a self-contained navigator, but a route is usually planned on a phone and a ride is worth keeping once it's ridden. A small iOS companion app bridges the two over Bluetooth Low Energy: push a planned route to the device, pull tracked rides back, rename the device, read its diagnostics. Once you've paired, powered, and are in range, it just works — no accounts, no cloud, nothing leaves the two devices.
This page is the shape of that link. The normative, byte-level reference is the BLE interface spec (the same tier as the OBCM / OBCR format specs); here we cover the design and the why. Five ideas run through all of it:
- Two planes. Small typed control state rides GATT; bulk bytes ride a single
L2CAP channel. Nothing large ever crosses GATT.
- Objects are files the device already speaks. A route crosses the wire as
an OBCR file and is written to storage verbatim; the phone does every format conversion, so the device never parses XML.
- One CRC, end to end. A whole-object checksum is verified once, at commit —
the check the on-air link CRC can't give you.
- Interrupted transfers restart, not resume. Objects are small enough that
re-sending one whole is simpler and safer than continuing from an offset.
- One device → app channel. Every message the device sends back — a transfer
result, a store-change signal, a download's announce — rides a single status notify characteristic, so there is one subscription and one ordering domain.
Two planes: control and data
A BLE GATT attribute is capped at 512 bytes — a hard wall, not a soft budget. A route is tens of kilobytes. So the link is split in two: GATT carries the small, typed control state (identity, config, the orchestration of a transfer, notifications), and a single L2CAP connection-oriented channel (CoC) is the bulk data pipe. GATT says what is about to happen and how it went; the CoC carries the bytes.
config blob, so renaming the device is a plain characteristic write; everything bigger goes over the CoC.The CoC is a raw byte pipe — deliberately. The BLE Link Layer already CRCs and retransmits every packet, so the channel is reliable and ordered. That means bulk transfer needs no per-chunk framing: a control-plane descriptor announces the transfer, the CoC then carries exactly the object's payload bytes, and the device sinks them straight to storage while updating a running checksum. There is no reassembly buffer — which is the whole point on a RAM-limited microcontroller.
It also buys something the design didn't set out to get. A channel with no framing of its own makes no demands on what carries it beyond "reliable and ordered" — and a USB bulk endpoint is exactly that. So the object model above, the 12-byte descriptor, the status envelope and the whole-object CRC-32 all transplant onto a cable without a byte changing: USB is a second transport, not a second protocol, and it reads the same specs/vectors/ fixtures. The host half lives in the web builder's USB client, which drives the whole contract over an in-memory device; the device half — the LM20's USB peripheral, and the small matter of which control characteristic a frame belongs to when there is no GATT to say so — is obc-fw-nrf54l/src/usb/, and it ships in every firmware build rather than behind a flag. The plane now comes up on hardware, and the fix that got it there is worth knowing about: it must be built when a cable appears, never at boot — see below.
That host client has two transports under it, and only one of them is a browser. WebUSB is Chromium-only, so the desktop app drives the cable itself through nusb and is the universal path, including for Safari and Firefox. Everything above the byte pipe is the same code: the same object model, the same descriptors, the same CRC, the same fixtures. The native side moves bytes and nothing else — it does not know what an object id is — which is the property that keeps one protocol implementation rather than two drifting ones.
There is one place the two hosts genuinely differ, and it is about size. A browser holds a map in a scratch file and streams it through the tab; the desktop app hands the file path to the transport and the bytes go disk → endpoint without ever entering the webview — no reason to copy a few hundred megabytes through JavaScript just to hand them straight back.
The browser side already writes over it: a map, a dropped GPX and a firmware image, from the map builder's device step. The cable changes exactly one thing about the object set, and it is the interesting one. A map was never an object, because a 200 MB file was never going over BLE — so USB adds a map type carrying an OBCM file, uploaded and committed by the same six steps as everything else. Two consequences follow from the size rather than from the format: the browser cannot hold the artifact (it streams the download into a scratch file, checksums it against the catalog manifest and only then opens the transfer, because the descriptor has to announce a whole-object CRC before the first byte moves), and the transfer takes minutes — the ceiling is the SD card at a few hundred KB/s, not the cable. How maps are named and enumerated on the card is the device side's answer to the same size, and it is worth its own section below.
Objects are files the device already speaks
Every bulk payload is a typed object. The set is small and closed:
type | Object | Direction | Payload |
|---|---|---|---|
1 | route | app → device (upload) · device → app (detail read) | an OBCR route file, verbatim |
2 | ride | device → app | the compact ride object (a tracked ride) — v1, or v2 when it carries recorded sensor data |
4 | diagnostics | device → app | an opaque text blob (boot count, link + storage counters, stack high-water…) |
6 / 7 | routeList / rideList | device → app | the store catalogs — fixed-size entries (routeList 84 B, rideList 72 B) |
9 | trip | app → device (upload) · device → app (detail read) | a trip — tiny metadata that references member routes by object id in ride order (spec §7.7); routes stay standalone OBCR files |
10 | tripList | device → app | the trip catalog — fixed-size 76 B entries, mirroring routeList's core (no auto-expiry tail) |
5 | fwImage | app → device (upload) | a firmware update image — an OBCU UPDATE.BIN container, staged to the card verbatim (see below) |
3 | config | — | reserved on the CoC; the Config blob crosses GATT |
16 | map | host → device (upload) | an OBCM map — the cable only, see below |
map is the one type Bluetooth could never have carried, and it is the clearest illustration of what a second transport buys: a map is hundreds of megabytes, so the type would have been dead weight until a wire existed that could move one.
A map is the one object that does not fit the pattern
Every other upload gets its atomicity the same way: the bytes stream into a temp file the catalog scans never match, and the commit copies that temp to its final name, holding the 4-byte format magic back until the body is durable. A power cut leaves either an invisible temp or a magic-less file every reader rejects. Nothing half-written is ever visible.
A map cannot pay for that copy. At a few hundred megabytes it would double both the write time — already minutes — and the free space the card must have, to buy a guarantee for the one object that can always be built again. So a map streams straight into its final file, and earns the same commit point a different way: the file opens with four zero bytes where the magic goes, the stream's own first four bytes are held aside, and they are written last, after the whole-object checksum and the header have both checked out. The interrupted state is byte-for-byte the one the copy leaves — a magic-less file the map catalog refuses and a boot sweep reclaims.
Three more rules fall out of the same size:
- A map upload is new-only. Writing into a stored map's file would destroy it
as the replacement arrives, and "a failed checksum never touches the old copy" is not a promise to break on the one file the rider needs to see where they are. Replacing a map is send the new one, then delete the old one.
- Free space is checked before the first byte, not discovered at the last.
A card that cannot fit the announced map is told so at the announce, with a reserve left over so a map can never take the last cluster and strand the ride log.
- The device knows the map arrived, but not what it is. The descriptor has no
name field, the payload is opaque bytes, and the OBCM header carries no name and no build date. A device can list the maps it holds — id, filename, size, format version, bounding box, all read off the card — and cannot say where any of them came from. That is a gap in the protocol, not in the filesystem, and closing it means a new command rather than a new object.
Because the FAT layer the firmware uses creates 8.3 filenames only, a received map lands as MP7.OBM — the same trick the reserved computed-route file plays with .OBR, and the same convention that puts a stored object's durable id in its filename. Which map the renderer streams from is recorded in a small file beside them, and a map that has just arrived becomes that choice; it takes effect at the next start-up, because the map's tables are parsed once at boot and held for the whole session.
The key move is that a route on the wire is the same bytes as a route on the card. The phone converts an imported GPX or TCX to an OBCR file and streams that; the device writes it to storage byte-for-byte and later serves it back the same way. There is no separate "detail" codec — the app's route-detail screen decodes the very OBCR bytes it uploaded. One layout, one truth. A tracked ride is the mirror in the other direction: the device stores each finished ride as the exact bytes it will later stream, so a ride download is a verbatim file copy.
One object is not a stored file but a firmware update: a fwImage upload carries an OBCU UPDATE.BIN container, which the device writes to the card root verbatim — the transfer layer stays format-blind, exactly as with a route's OBCR bytes. Staging is not installing. A committed fwImage only places the file; the app then sends a separate installFw command to request an install, and the device runs its own scan and shows a confirm card that the rider must approve with a physical Select press. The phone can never arm or reboot the device on its own — the same on-glass gate the pairing passkey uses. The whole trust model, the two delivery paths, and the RRAM layout are on the firmware updates page.
Object ids are durable. Each stored object has a u16 id the device assigns and keeps stable across reboots — the reference firmware encodes it right in the filename (RT{id}.OBR for routes, RD{id}.ORD for rides). Durability is what lets the phone remember "I uploaded route 7" and later ask "is 7 still there?" or replace it in place — and it's what a ride sync's already-have-this-one set keys on. That promise holds within an id era; when the id space itself resets, a store epoch names the new era so the phone never mistakes a reused id for the old object — see Store epochs.
A transfer, end to end
Every bulk exchange is the same three-beat shape: announce over GATT, stream over the CoC, confirm over GATT. Here is an upload — a route leaving the phone:
transferResult closes it. transferControl is write-only — the app writes it to open a transfer, the device never notifies it. A fresh upload sends object id 0xFFFF ("new") and the device reports the assigned id in the result. A download is the mirror: the app asks with an op=download descriptor, and the device answers on the status channel with a download announce (a status message carrying the same descriptor, now with the size + CRC filled in), then streams the object back. Routing the announce through status is what keeps every device → app message on one characteristic.The checksum is a whole-object CRC-32/IEEE, verified once at commit — the same variant as gzip/PNG. It is deliberately not a per-packet CRC (the link already covers the air); it catches what the link can't — an encode bug, a storage error — end to end, from the phone's request to the device's flash and back.
Restart, not resume. An object is tens of kilobytes — a couple of seconds on the wire — so a dropped or aborted transfer is simply re-sent (or re-requested) whole, never continued from a durable offset. The device discards a partial upload the moment the link drops or an
abortarrives, and the app re-sends from byte zero. (A suffix couldn't be checked against the whole-object CRC anyway — which is why the descriptor carries no resume offset at all; the field v1 kept permanently zero is gone in v2.) A multi-object flow — syncing several rides — resumes at whole-object granularity: the rides that fully landed are kept, and the rest re-send from byte zero.
Full means full — up front. The device holds a bounded route catalog (64 routes). A new-route upload that would overflow it is refused the instant the descriptor arrives — before the device consumes any payload — with a distinct
storageFullresult, so the phone can tell the rider to delete routes on the device rather than wait out a doomed transfer. Since the raw sender may already have queued bytes before that asynchronous result arrives, the app resets the CoC on the reject. Re-uploading an existing route (a replace by id) is exempt: it reuses a slot rather than growing the catalog, so updating the route you're actively navigating never hits the cap.
Retries converge — never twin. Restart-not-resume is only safe if the first attempt can't half-count, and there is one window where it could: the device commits an upload, then the link dies before the phone hears the
committedresult. The phone, none the wiser, re-sends the object as new — and without a guard the device would mint a same-content twin. So a fresh upload whose whole-object CRC (and length) match an object the device already stores answerscommittedwith the existing object's id, storing nothing: a lost ack costs one re-send, never a duplicate. The phone closes the same window from its side without re-sending a byte — on every catalog reconcile, an unlinked entry whosecrc32matches a library route's (or trip's) current encoding is adopted as that object's device copy, and a whole-trip upload re-reads both catalogs right before planning, so a retry sees what actually landed.
Answers are bounded — a lost notify never wedges. Every solicited answer the app waits on rides the
statusnotify, and the device deliberately abandons a notify it can't deliver in time rather than stall a plane — a lost notification is the app's to recover by re-reading. The app holds the same posture: each wait is time-bounded, because it holds the app's single transfer slot, and an unbounded wait on one lost verdict would wedge every later list read, sync, and upload behind it. On a timeout, cancellation, or reject the app closes and reopens the CoC (the channel is unframed, so a reset is what discards queued bytes), and the device treats the drop as an implicit abort. A committed close is correlated by object id and byte count, so the close of a preceding catalog read can never complete an upload; a data-plane stall under a live link is failed by a watchdog and surfaces as a plain retryable failure, never a progress bar parked at 99 %.
When a route lands — the device's side
A committed upload isn't silent on the device. A route usually arrives because the rider just pressed send on the phone sitting next to it, so the display wakes and shows a short prompt — then returns to warm sleep. The prompt is strictly advisory: the route is already in the store (and the Route menu) before it appears, so dismissing it loses nothing. It auto-closes after 30 seconds, and that timeout is a dismiss. What the prompt offers depends on what the rider is doing:
- Not riding → "Route received — View route / Dismiss." The card shows the
route's name, its distance/climb, and a mini elevation sparkline; View route opens the same Route overview picking the route in the Route menu opens (where START RIDE is one press away) — it never starts a ride directly.
- Riding → the same guarded swap shape a mid-ride route pick uses (*Swap
route / Finish & new / Cancel*), retitled for a received route and carrying the route's distance/climb — so an uploaded route mid-ride can't silently take over navigation.
- Replacing the route you're navigating → an info-only card. The device
has no choice here: the replace-commit already overwrote the file on the card, so the old bytes are gone. The device adopts the new version immediately — it reopens the geometry handle, re-runs map-matching from the current fix, and recomputes progress — and the card just tells the rider it happened. The recording session is untouched.
- A whole trip → one "Trip received — View trip / Dismiss" card. The member
routes commit first (each raising the prompt above, the newest replacing the last), then the trip object lands and its card takes the prompt's place — so the transfer ends on a single card showing the trip's name, summed distance/climb, and stage count; View trip opens the trip's folder in the Route menu. Same card whether idle or riding (a trip is a folder, not a navigable route — there is nothing to swap onto).
Two rules keep the prompt from ever doing harm. It never lands while a hold gesture is charging — a popup appearing under a half-completed Finish & new hold could complete onto the wrong action, so it waits a tick (the same stack-change hold-cancel the UI page describes). And consecutive uploads replace the prompt rather than stacking — most recent wins, carried by object id, not menu position, so a live rescan can't point View route at whatever route slid into the slot. A pending prompt is also outranked by the passkey card: if pairing starts, the route prompt is dropped (not queued) — it's only advisory, and the route is safe in the menu.
Staying in sync — the change signal
After anything changes on the device — a route uploaded, a ride finished, an object deleted — the phone needs to know what to re-fetch, cheaply. Re-reading the full catalogs on every reconnect would burn the CoC for nothing. So the device fires a tiny storeChanged message on the status channel: a byte naming which store moved (route or ride) plus a revision counter bumped on every change to it. It is the sole change signal — one notification says "the ride store moved; re-list it", and the app reads nothing else to learn something changed.
storeChanged is the cheap "did anything change?" signal — one notification per change, naming which store moved so a route upload never triggers a ride re-list. On it (or on connect) the app pulls the relevant list object, then downloads only the objects new to it; changes arriving during a list transfer coalesce behind it. Because BLE notifications are best-effort, the app also runs a low-cadence 60-second catalog audit — a lost edge can delay a checkmark, never leave it stale until restart. A change is a change whether the phone caused it or the rider deleted something on the device: both go through the one object store and fire the same signal. The lower lane runs the other way: an ackRides command carries the phone's held ride ids to the device, which marks them synced (below).The device is the other half of this loop. A change doesn't only come from the phone — the rider can delete a stored route from the device's Route overview or a tracked ride from its Ride detail, each with the same guarded hold-to-delete row (see the UI system). A device-side delete goes through the same object store the wire commits do, so it bumps the revision, fires storeChanged, and shows up to the phone as "the ride store moved" on the next notify — no separate "the device deleted something" message, and no way for the two to disagree about what's on the card. The phone reconciles by re-reading the list and tombstoning what vanished, exactly as it would after any other change.
Trips ride the same loop. A trip (type 9) is a third store alongside routes and rides — tiny metadata that references member routes by object id — and its catalog (tripList, type 10) reconciles exactly like routeList: the device fires storeChanged for the trip store, the phone pulls the list, and each entry's crc32 is the fingerprint that decides whether a stored trip is current. A whole-trip upload is stages first, trip object last — every member route commits, then the trip that references them — so an interrupted push never leaves a trip pointing at a route that isn't there, and re-running skips whatever already landed. A device-side "delete this whole folder" is a cascade the device composes from ordinary object deletes (the member routes, then the trip), each flowing back as its own storeChanged; the wire trip delete itself is non-cascading. The byte layout is the BLE interface spec §7.4 / §7.7.
Ids are never reused — which is what keeps the bookkeeping honest. The phone persists "I uploaded route 7" and "I've synced ride 12" by durable object id. If a delete freed id 7 and the next upload re-took it, the phone's note would now point at a different route. So the device mints strictly above a persistent floor — an SD filename guards a stored id, an RRAM floor guards a deleted id — and a freed id stays retired. That invariant is what a trustworthy storeChanged rests on — as long as the id space itself never resets underneath it. When it does — a chip wipe, a factory reset, a freshly-formatted card — a store epoch makes the reset visible, so the phone never mistakes a reborn id for the old object (next section).
Synced rides — reconciled state, not event inference
A tracked ride is precious (unlike a route, the phone can't re-upload it), so the device keeps a "synced" flag per ride — does a durable copy of this one exist somewhere off the device? It drives the small check mark on a synced Rides-list row (an unsynced ride shows nothing there) and the "synced" / "not synced" slot in the Ride detail's title bar, so a rider deleting an un-downloaded ride is told what they're about to lose.
The naïve way to set that flag is to flip it when a ride download completes. But that makes it an event inference — and events are lossy. A ride synced before the device tracked the flag, a card reflashed, an app reinstalled: any of these leaves the device's flag out of step with what the phone actually holds, and permanently, because a ride the phone already has is never re-downloaded to correct it. So the flag is instead reconciled state. The phone's library is the ground truth for "I have this ride", and on every connect it sends the device the list of ride ids it holds — a small ackRides command. The device sets (never clears) the synced flag for each; a change bumps the ride revision so the Rides screen's cue updates live. The flag becomes "a peer has confirmed it holds this", self-healing on every reconnect rather than riding on a single download event landing.
Who is allowed to say it. Read the flag by what it does — guard a delete, colour a cue, and anchor the auto-expiry countdown (#638, whose synced_at stamp is written beside it) — and it is a durability predicate, not a statement about iPhones. Which makes "who may set it" a real question, because the answer decides whether a ride can be auto-deleted while it exists nowhere else. Three peers can pull a ride; only two of them may say anything about it:
| Peer | Acks? | Why |
|---|---|---|
| The phone, over BLE | yes — and re-sends its whole set on every connect | it keeps a library, so it can heal the flag as well as set it |
| The desktop app, over USB | yes, after fsync | it writes into a folder the rider chose and can back up |
| The hosted site, over WebUSB | never | a browser download is a file the rider may cancel at the save dialog, and the site keeps no record of what it handed over |
The desktop app acking is what keeps auto-expiry alive for a rider with no iPhone; it costs no protocol change, because ackRides lives in the object store rather than the BLE plane and is monotonic, so a phone's heal and a desktop ack merge in either order. The browser deliberately gives that up: its ride export (#904) is a pure read that leaves the flag, the sidecar and the countdown exactly as it found them, and says so on screen — an export is not a backup.
The invariant underneath all three is ack after the copy is durable, never on transfer completion. Acking when the last byte arrives starts a countdown against a ride that is not yet on anyone's disk, which is the single way this feature can lose data.
"Durable" is a syscall, not a hope. On the desktop side a pulled ride is written to a temporary sibling, fsynced, renamed over its destination, and the directory is fsynced too — that last step is what makes the rename survive a power cut, and skipping it is the classic failure where the bytes are on the disk and the name pointing at them is not. Only then does the index that lists the ride commit, by the same four steps; only then does the ack go out. A crash anywhere in the middle leaves an index that does not mention the ride, so the next pull fetches it again and the device was never told anything — the direction that costs a re-download rather than a ride. And because the ack list is computed by asking the filesystem which rides are there, rather than by remembering which ones were just written, a file the rider deleted in Finder drops out of it on its own.
Two more rules fall out of the same reasoning, and both are about fetching rather than acking. The peer always pulls the whole ride list and dedupes against its own library: the device's synced flag is a statement about durability somewhere else, so using it to decide what to download would skip exactly the rides a second peer has never seen. And a ride is keyed by (serial, epoch, id) — the id era below — never by the bare id, because a bare-id library silently discards a new ride that reused a recycled one.
The trusted clock and route retention
Two more things ride every connect, both from the storage auto-expiry work (#638). First: immediately after encryption and before the first ackRides, the phone sends a setClock command — the current UTC plus the phone's local offset. The device has no battery-backed clock, so this (or a GPS fix) is what establishes a trusted wall clock for the boot, the safety gate the device's auto-delete sweep won't act without. The ordering is deliberate: because setClock lands first, the ackRides that flags a ride synced runs under a trusted clock, so the moment it stamps as that ride's synced-at — the anchor for the ride's eventual auto-delete — is a real timestamp, not a stale set-point.
Second: a route's retention — its "delete after this long unused" window — is set from the app with a setRouteRetention command (object id + level), not by re-uploading the route. That split is the interesting part. The OBCR route file is byte-pinned — an upload's payload is exactly the route's bytes, and stays that way — but retention is mutable device-local state that changes without the geometry changing. So it never enters the file: it lives in an SD sidecar (route id → level + last-used), travels as a command, and the device reports each route's computed expires_at back in its routeList entry — which grew a small tail to carry it. Formats stay pinned; the mutable state routes around them. The command layouts, the connect-ordering rules, and the 84-byte list entry are the BLE interface spec §4.4 / §7.4.
A whole trip is one retention choice. When you upload a trip, the confirm sheet shows a single Auto-delete picker, and that one choice is the postcondition for every member route — a trip is one unit, so the trip-level pick overrides whatever level a member route carried on its own. The subtlety is that a whole-trip upload skips the bytes of any stage the device already holds (same content, nothing to re-send) — but a skipped stage is skipped only for transfer, never for policy: the retention command still lands on it, so the one trip choice reaches the already-current stages exactly as it reaches the freshly-uploaded ones. A re-run at the level a stage already holds sends nothing (idempotent), and an old device with no expiry support shows no picker and receives no retention command at all.
Store epochs — which id era you're talking to
Everything above trusts durable ids to keep meaning the same object next connect. Within a store that holds — but an id space can reset. A full-chip reflash, a factory reset, a torn settings write, a freshly-formatted card: any of these can lose the floor that guards deleted ids, and the device's next upload re-mints ids that months-old phone-side state still points at. Reuse id 7, and the phone's "route 7" silently aliases a different route — a green "up to date" badge for the wrong thing, or worse, an upload that replace-by-ids over the wrong route on the device. (This bit the bench on 2026-07-12: new rides filtered as "already synced" while the app insisted everything was up to date.)
The fix names each id era with something the phone can watch change: a store epoch — a u32 random nonce. The device serves it in the same open, pre-pairing protocolVersion read the app already performs first on every connect, widened from a bare version to version u16 · store_epoch u32 · obcm_version u8. So before ackRides or any reconcile write fires, the app knows the protocol version, which era it is looking at, and which map format the device reads (below).
The epoch lives on the card. It is persisted as a tiny EPOCH.OBE file in the card root — the record layout and its torn-file → fresh-era conventions are in the BLE interface spec §1 — so the store carries its own era name. Swap the card and you transplant the store: the epoch travels with it, so the same device never conflates two cards' id spaces — and a card written by a different device presents its own epoch, a distinct era on this device by construction. A lost RRAM floor still stamps a fresh epoch onto the card even when the card's epoch file survived intact: a compromised id namespace is a new era regardless of where the name is stored.
The app scopes every id-keyed fact by (device serial, store epoch). Ride entries, the synced set, delete tombstones, route links — all keyed by that triple, valid only when all three match the connected device. An era change then needs no migration code: the old era's keys simply never match again — they go archival by construction — and the new era starts empty. There is no multi-step re-key for an app kill to tear halfway.
No store ⇒ no epoch ⇒ nothing stamped. A device with no card mounted has nothing to name and nothing to prove, so its protocolVersion read degrades to the 2-byte, version-only form. The app reads the missing epoch as a failed identity read — not as epoch 0, which is a legal value — and fails closed: no ackRides, no reconcile writes, no badges (plain library browsing is unaffected). The same gate catches a read that genuinely failed, so a device whose era can't be established can never stamp a checkmark under an unknown id space.
The widened read's bytes, the exact mint rule, and the full list of era events live in the BLE interface spec §1; the design rationale is epic #632 item 5, with the card-resident decision in #776.
One more field, and why it isn't a version bump. The read carries a third value: obcm_version, the OBCM map-format version this firmware's reader reads. It is a different number in a different sequence from the protocol version beside it — one is this wire contract, the other is the file format on the card — and neither can be derived from the other, nor from the firmware revision string, which maps to a format version only through a table nobody keeps. A site that hands out pre-baked maps needs it, and had nothing to read it from.
Appending it changed the read's length, and a read whose length changed sounds like a protocol break. It isn't, because the length has always been the version mechanism here: a device with no card already served a short read, so the decode was never "expect exactly n bytes" — it is "take each field if that many bytes arrived, ignore anything past the ones you know". Three lengths now exist: 7 with a mounted store, 6 from a firmware predating this field, 2 with no card. A field that didn't arrive reads as unknown, never as a fabricated 0 — 0 is a legal store epoch, and OBCM 0 would read as "supports map format v0" and refuse every real map. So an old app against a new device loses nothing it had, and a new site against an old device gets unknown and falls back to offering the download with its version stated. Bumping the protocol version would instead stop both — a hard mutual "we can't talk" for a field that is allowed to be missing.
Verified badges — presence you can prove
A route the phone uploaded earns an "on device" badge in the app. A badge is only honest, though, if the app can prove the device still holds that route — not merely a route wearing the same id. v2 makes the proof cheap: each routeList entry carries the whole-object CRC-32 the device computed at upload commit (persisted in a small /routes sidecar; 0 means not yet known, filled lazily the first time a side-loaded file is listed).
Proof-only presence. The badge lights only when the per-serial link is valid and the catalog CRC matches the CRC the app recorded at upload — never on a matching id alone. Combined with epoch scoping, a stale link that outlived an era change simply fails to match and shows nothing. No checkmark without proof.
Adopt by content. The CRC also heals the reverse case. After an app reinstall (or on a second phone) the device still holds routes the app has no link to — but an unlinked catalog entry whose CRC matches a route the app holds is adopted: the badge lights with no upload, and a later upload of that route replaces by id rather than creating a duplicate. Anything the app can't prove shows no badge — the worst case is a needless re-upload, which adoption makes rare.
The list never lies "up to date". Past the device's catalog cap the store scan drops the excess in FAT order — in v1, silently. The v2 list header carries a total alongside the count actually returned: total > count means the object was truncated, and the app surfaces a one-line "some items couldn't be listed" warning instead of quietly reporting everything is synced.
The routeList entry's crc32, the 6-byte list header with total, and their exact byte layout are the BLE interface spec §7.4; the proof-only badge and adopt-by-content behaviour are epic #632 item 6.
Pairing, and staying paired
Access is gated by a bond — a one-time, mutually-authenticated pairing. The device is a display-only peer: it shows a 6-digit passkey on its screen that the rider types into the phone's system dialog. That's LE Secure Connections passkey entry — man-in-the-middle-protected — and the on-screen code is what makes it safe: physical possession of the device is the control. On the device that code is a full-screen passkey card: the host pushes it the instant the radio raises a passkey and pops it the instant pairing ends, and it's deliberately non-dismissible — no button can lose the code mid-pairing — because the SMP handshake time-boxes the window anyway.
There is exactly one bonded peer — and while that bond exists the device rejects any new pairing attempt: a stranger's phone gets a generic pairing failure and the device screen shows nothing (no passkey card, because there's no pairing to complete). Re-pairing (a new or reset phone) goes through the hold-guarded Forget phone action in the device's Settings ▸ Bluetooth, which clears the bond and re-opens pairing — so physical possession still gates the swap, at the clear step. This reverses an earlier "a fresh pairing replaces the stored bond" rule: a lost or wiped phone can no longer silently re-pair. There is one more way to clear the bond, and it needs no on-device step: the bonded phone can send a forgetBond command over its own encrypted link, so the app's "Forget device" dissolves the device's side of the bond too rather than leaving the pair wedged (a one-sided app forget would otherwise keep hitting the reject). It's safe precisely because it rides the bonded link — only the paired phone can issue it, a stranger never can. The same screen carries the Bluetooth off switch: off stops advertising and drops the link, while the bond survives for when the radio comes back.
What the bond protects, and what stays open:
| Surface | Before pairing |
|---|---|
Device Information · Battery · protocolVersion (version + store epoch) | open — so the app reads identity, version, and the store epoch before pairing |
| every other OBC Control characteristic | denied — needs the encrypted, authenticated link |
| the L2CAP CoC | denied — opening it on an unencrypted link is refused |
Leaving identity and the protocol version readable pre-bond is deliberate: the app checks it's talking to a compatible device (and surfaces a mismatch as a banner rather than trapping) before it ever asks to pair. The same open read now also carries the store epoch, so it lands before any ackRides — the app always knows the id era before it stamps anything, which is exactly what the fail-closed gate needs.
Sensors — the device as BLE central
The phone link is only half of the device's Bluetooth life. To a phone the device is a peripheral — the phone scans, connects, and drives it. To a heart-rate strap, power meter, or cadence sensor it is the opposite: the central, the side that scans, connects, and subscribes. Both roles run on the one radio. trouble-host 0.7 runs a peripheral and a central role concurrently on a single Stack, and MPSL time-slices the airtime between them — so a sensor link and the phone link coexist with no second radio and no mode switch. The whole feature is BLE-only; there is no ANT+.
The manager loop: scan → connect → subscribe → decode → dispatch. A small central-role task runs beside the peripheral lifecycle. Given the radio on and a sensor saved, it connects to the stored address, discovers the profile's measurement characteristic, reads the battery level once, subscribes to the notifications, and then just pumps them: each notification is decoded and its raw value dispatched. The decode is pure no_std byte→value parsing that lives in the radio-free obc-ble crate — Heart Rate Measurement (0x2A37), Cycling Power Measurement (0x2A63), CSC Measurement (0x2A5B), and Battery Level (0x2A19), plus a crank-revolution→rpm accumulator — so it is host-tested with no radio in the loop. The dispatched value lands in an obc-platform mailbox the app drains like any other sensor — the same mailbox the simulator's sliders and the USB-injection H/P/R lines feed, so the app can't tell a real strap from an injected one (last-writer-wins). The app never learns BLE exists; to it a sensor is just a thing that produces bpm.
No bonding — sensors are open. A strap or power meter is an open GATT server: no pairing, no passkey, no encryption. The manager connects by the address the rider saved and that's it. The phone-bond machinery is completely untouched — the single bond slot is the phone's alone, and a sensor never consumes it. (Sensor bonding, encrypted sensors, and ANT+ are all deliberately out of scope.)
One slot per quantity. There are three fixed slots — heart rate, power, cadence — one saved sensor each. Cadence is the one arbitrated quantity: a saved dedicated cadence sensor owns it, but with none saved the crank data a power meter already reports fills the cadence slot, so a power meter doubles as a cadence source for free.
What a link costs, and the cap. Every central link the host tracks costs real controller memory — about 2.3 KB of SoftDevice-Controller buffers plus host arena — so the number of concurrent sensor links is a pinned constant, SENSOR_LINKS, arbitrated by the same compile-time RAM budget assert as the rest of the BLE statics (the #677 rule: everything sizeable is a summed .bss static). On the 256 KB DK it is 1 — the phone plus one sensor, enough to bring up a Garmin watch broadcasting HR; the 512 KB LM20 raises it to 3, so all three quantities can be live at once. One link is about +7 KB of RAM over the phone-only build; three is about +12 KB. Runtime behaviour matches the phone link's discipline: the manager auto-reconnects with a ~15 s backoff whenever the radio is on and a slot is saved, a sensor link parks with the radio switch exactly like the phone link, and a value older than 5 s renders -- and records as absent — a dropped strap must never freeze its last reading into the log.
Where the values go. Live, they drive three stat tiles — heart rate, power, cadence — plus per-ride averages and maxima. Recorded, they widen the ride's on-disk records: the freshest sample is stamped onto each logged track point and, at Finish, carried into the ride object v2 — the very object the phone downloads. There is deliberately no live sensor streaming to the phone; like everything else, the phone gets the numbers after the ride, inside the ride object it syncs. Those recording formats — the track log and the v1/v2 ride object — are the recorded-rides section of the data-formats page (normative bytes in the BLE interface spec §7.2).
The same link, down a cable
The nRF54LM20 has a real USB device peripheral, and pointing it at this protocol turns out to cost almost nothing — because of a decision made long before USB was on the table.
Look again at what a transfer actually needs from its transport. The bulk plane needs a channel that is reliable, ordered, and unframed; that is exactly what principle two asks of the CoC, and exactly what a USB bulk endpoint is. So the object stream — the descriptors, the payload bytes, the single whole-object CRC — crosses a cable with no translation whatsoever. The same state machine, the same fixtures, the same bytes.
Only the control plane needs anything at all, and it needs one byte. GATT gives each control message its own addressed characteristic, so "which message is this?" is answered by the transport rather than by any byte of ours. USB has one endpoint pair, so that routing becomes a leading selector byte — and the rest of the frame is the exact payload the matching characteristic would have carried. That is the whole delta. USB is a second transport, not a second protocol.
Three consequences are worth naming, because they are what makes the wired path a real product rather than a demo:
- The cable is what brings the plane into existence. A bike computer spends
almost all of its life with nothing plugged into it, so the wired plane is built when a cable appears and parked again when it goes — not armed at boot and left waiting. That ordering is not tidiness: the USB core is unpowered until the hardware reports bus voltage, and a device that reached into it anyway would trade the common case (riding) for the rare one (a transfer). The device says which state it is in on every boot, because "no cable" and "USB broken" must never look the same from the outside. And the waiting is free: the parked plane is asleep on the bus-voltage interrupt rather than asking a timer how things are, so a bike ridden all day with nothing plugged in spends no energy at all on the possibility of a cable. On a device running off a battery, "poll for the rare case" is a cost paid by every ride.
- The device keeps working while a map lands. Storage is arbitrated behind
one async mutex, and each store call takes it only for its own duration — so the ride loop's redraws interleave between chunks. This is why USB Mass Storage was rejected outright: handing a host raw block access would force the firmware to release the card entirely (two filesystem writers is corruption), and you would be back to a device that becomes a disk instead of remaining a bike computer.
- One transfer at a time means one across both wires. The gate is shared
rather than per-transport, because what it protects is the device's single upload temp and open download source — not the wire. Start a transfer over the cable while the phone is mid-upload and you get the same typed busy the phone would get from another phone.
- A cancel has to reach the wire, not just the caller. A browser cannot
cancel a USB transfer it has already submitted, so it releases the caller and lets the transfer settle into nothing — which is survivable only because an interrupted exchange is always followed by a channel reset. A native host can cancel for real, and must: after an abort the device stops sending by design, so a read left waiting on it would hold the endpoint forever and the link would be dead while still looking alive.
Everything else about the link is unchanged by the choice of wire: the same objects, the same restart-don't-resume rule, the same change signal, the same status ordering domain. Pairing is the one genuine exception — encryption and bonding are BLE mechanisms, and the cable's authentication is that someone is holding it.
Where this lives
- The wire contract, normative:
obc-ble-interface-spec.md(§10 is the USB binding) - The host-tested, radio-free core — descriptor codecs, CRC-32, the transfer state machine:
obc-ble(transfer.rs·descriptor.rs) - On the device, shared by every transport — the command handler, descriptor classification, the identity blobs, the one object store, and the cross-transport transfer gate:
obc-fw-nrf54l/src/link/ - On the device — the GATT server, connection lifecycle, and the CoC data plane:
obc-fw-nrf54l/src/ble/ - On the device — the USB vendor interface, the selector envelope, and the bulk object stream:
obc-fw-nrf54l/src/usb/ - The central-role sensor manager — scan / connect / subscribe / decode / dispatch, the
SENSOR_LINKScap and its budget:obc-fw-nrf54l/src/ble/sensors.rs - The radio-free sensor profile codecs, the advertisement classifier, and the crank→rpm accumulator:
obc-ble(sensors.rs) - The app-facing sensor mailboxes both the radio manager and the injection path feed — one instance-owned
SensorHub, handed to each task at spawn:obc-platform/src/sensor_hub.rs - The device UI's link seam — the connected indicator, passkey card, and upload prompts consume this:
obc-app/src/ble.rs(and the UI system) - The phone side — the SwiftUI companion app and its transport layer:
companion-ios/ - The host side — the same object model over a USB byte pipe, with a simulated device for the paths hardware can't be made to take:
web_builder/frontend/src/lib/usb/ - The desktop app's transport —
nusb, hot-plug, and the file-path bulk plane, under the same client:obc-desktop/src/usb/andlib/desktop/usb.ts - The browser's flows over that client — sending a map, a route or a firmware image, and the read-only ride export whose device handle has no way to ack:
web_builder/frontend/src/lib/device/ - The desktop app's ride library — the visible GPX folder, the internal archive with its index, and the temp-fsync-rename-fsync write the ack waits on:
obc-desktop/src/rides.rs; the pull that fills it, and the ack list it computes from the disk:lib/device/library.ts - Shared fixtures pinning the byte layouts every implementation must agree on — the firmware, the phone, and the web builder's wasm converter and USB client:
specs/vectors/ - The route and ride formats that cross the link: Data formats