→ index
Native owns live truth. JS sends intents and renders native snapshots.
JS must not optimistically set board connection status. If UI shows connecting,
connected, stale, or error, that value came from native LiveState.
Removal boundaries and code markers are tracked in legacy Float support removal.
Link Integrity compares INFO responses using the precision captured by the saved Board Link.
INFO v1 supplied only major/minor and no package name. Older app versions saved that response as
Refloat 1.2, even when the package was Float; current versions display Float/Refloat 1.2.
Both saved forms accept Float or Refloat observations with the same major/minor, including richer
INFO v2 versions such as Refloat 1.2.7. This avoids invalidating an unchanged Board after an app
upgrade. A legacy link cannot detect a package or patch change that INFO v1 never recorded.
Saved INFO v2 identities retain exact package, patch, and suffix checks. Compatibility does not allow an observation to drop those known facts. VESC firmware, BMS, and Board Link Version checks still apply. The comparison also accepts the corresponding derived base-version precision change; it does not rewrite persisted versions or change Tune Compatibility keys.
Native emits onLiveState and exposes getLiveState():
type LiveState = {
board: {
phase:
| 'idle'
| 'connecting'
| 'discovering'
| 'subscribing'
| 'waiting_for_telemetry'
| 'connected'
| 'stale'
| 'reconnecting'
| 'disconnecting'
| 'error'
selectedBoardId: string | null
connectedBoardId: string | null
bleId: string | null
name: string | null
connectionSeq: number
lastTelemetryAt: number | null
recentTelemetry: TelemetryEvent[]
error: string | null
autoConnect: boolean
}
gps: {
phase: 'idle' | 'starting' | 'active' | 'error'
latestFix: LocationEvent | null
recentLocations: LocationEvent[]
error: string | null
}
scan: {
phase: 'idle' | 'scanning' | 'error'
devices: DeviceFoundEvent[]
error: string | null
}
recording: {
enabled: boolean
activeBoardId: string | null
startedAt: number | null
}
}Native has separate live runtimes:
- board runtime: BLE GATT, VESC polling, telemetry, reconnect, board recording
- GPS runtime: location listener, latest fix, recent fixes, map data
- scan runtime: BLE scanner owned by the Expo module bridge
Board connect/disconnect must not clear GPS fixes. GPS is app-level map data.
On iOS, GpsMonitor owns the location manager and LocationTracker owns fix state, course
derivation and the recent-fix window. BoardSessionController reads the tracker and wires its
consumers. Replay teardown alone clears recorded fixes and restores the parked live monitor.
Native decides the phase; JS renders it and never derives one from a boolean.
idle— no location manager is held.starting— a manager is held but updates are not running: the iOS permission dialog is open, or the Android foreground service that arms the monitor is still starting.active— location updates were actually requested and fixes can arrive.error— the monitor refused or failed. Always carries the same string asgps.error.
src/modules/board/store/bleStore.ts mirrors native state:
syncNativeState()readsgetLiveState()onLiveStatereplaces lifecycle statusonTelemetryappends telemetry only whenconnectionSeqmatchesonLocationappends GPS fixes- foreground restore hydrates recent telemetry from native
getLiveState()
Commands call native only:
connect(boardId)→selectBoard(boardId)disconnect()→stopBoard()startGpsTracking()→startLocationUpdates()startTelemetryRecording()→setTelemetryRecordingEnabled(true)
Auto-connect is triggered by process launch on both platforms, never by the JS runtime coming up:
- Android:
AutoConnectProvider(aContentProvider) →CoreForegroundService.autoConnectSelectedBoard→BoardSessionController.autoConnectSelectedBoard. - iOS:
VescapeLaunchSubscriberindidFinishLaunchingWithOptions→BoardSessionController.autoConnectSelectedBoard, called right afterprepareForLaunch().
On iOS the order inside the launch hook is fixed: CoreBluetooth state restoration (ADR 0034) decides first, and auto-connect starts a session only when no live session is being resumed. A JS reload creates a new module but no new process, so it never restarts or duplicates a live session.
Auto-connect no-ops when the autoConnect setting is off, no board is selected, the
board is unlinked, or the board is gated by a manual-stop tombstone. Starting a Board
Session clears that tombstone on both platforms, so a manual stop followed by a real
reconnect auto-connects on the next launch.
JS never triggers auto-connect. Its only part is writing the autoConnect setting and
prompting for BLE permissions; the launch path reads the persisted setting and connects
on its own, with or without a JS runtime.
Native owns the connection throughout. On Android the foreground service keeps BLE work alive while JS is backgrounded or frozen.
Enrolled Accessories ride the same two launch triggers and are otherwise independent of the Board:
they come up with no Board selected, with the autoConnect setting off, and after a manual Board
stop, because the rider enrolled the Accessory rather than the Board it happens to ride with.
- Android:
AutoConnectProvider→CoreForegroundService.autoConnectAccessories→AccessorySessionManager. The service is started only when something is actually enrolled, and once started, live Accessory sessions keep it alive the way a Board Session or GPS does — a rider with a light and no Board still has a link that must stay up. - iOS:
VescapeLaunchSubscriber→AccessorySessionController.prepareForLaunch, after the Board's prepare. Its central carries its own restore identifier, so CoreBluetooth can relaunch the app for an Accessory link; like the Board's, it only works when the central is re-created insidedidFinishLaunchingWithOptions.
An Accessory link never optimistically reports connected. Each reconnect reads the manifest again
and checks it against the enrolled identity before any saved setting is used; a different unit
answering on a remembered handle is refused rather than driven. A drop reports connecting, not an
error — Android's autoConnect GATT and CoreBluetooth's open-ended connect both keep trying — and
AccessorySessionManager / AccessorySessionController push every change as onAccessoryState.
The fastest stable path is not to wait longer; it is to avoid competing native writes during startup.
connectedmeans first valid telemetry arrived, not just GATT ready.- The runtime connect path is dumb: it seeds direct/CAN mode from the stored Board Transport and starts telemetry polling directly, with no startup discovery probes. CAN id resolution happens once at setup via Board Probe, not on connect.
- GATT descriptor timeout fallbacks must be canceled after successful CCCD writes, otherwise a stale timeout can double-resolve the connection.
- Tune/config reads should not compete with initial telemetry startup. If a config read starts while the board is still settling, prefer gating/queuing over adding long connection delays.
Recording means real Ride Recording. A connected Board starts it; from then on it is a capture with its own durable identity, not a property of the Board Link.
- A connected Board must have started it. Standalone GPS never starts one — GPS without a Board is map/status only, and creates no ride history.
- It saves Board telemetry and a Ride Track of GPS fixes, on two separate clocks (ADR 0038).
- It outlives the Board Link. An unexpected drop does not end it. GPS fixes keep landing in the same recording for the whole reconnect loop, however long that takes, and telemetry rejoins the same recording when the Board returns — one ride, one history entry, with an honest gap where the telemetry was missing.
- There is no hard timeout and no GPS-based Idle Pause. Neither elapsed disconnection time nor GPS inactivity ends or pauses a recording.
- It ends only on explicit rider Stop Recording, explicit Disconnect, a fatal board error, or service stop — plus an explicit Connect to a different Board (below).
ride_recordings.ended_at_ms is the durable record that a recording ended, and nothing clears it.
Only a row with ended_at_ms IS NULL can be rejoined, which is what stops a late reconnect callback,
a stale delegate call, or an iOS state-restoration relaunch from reviving a ride the rider ended.
Starting again after a stop mints a new identity, even within the same minute.
Within a live Board Session the same intent is held in memory: auto-recording fires at the first board-ready of a session only, so a reconnect's board-ready never restarts a recording the rider stopped, nor mints a second one beside the recording still open across the drop. Auto-recording is therefore a connect rule: enabling the setting mid-session does not start a recording, and a later reconnect's board-ready does not either. The next Board Session picks it up.
A recording left open by a process that died is closed as disconnected at the first moment nothing
can rejoin it — on Android at process start, on iOS when the launch resume window expires without a
state restoration — and its end is stamped at its own last durable write, not at the sweep. Ride
History only shows finished recordings, so a row left open is a ride missing from history.
While connected, the Board controls Idle Pause (ADR 0021): the first disengaged Refloat sample pauses both telemetry and Ride Track writes. RUNNING, TILTBACK, and WHEELSLIP are engaged states; the next sample in any of those states resumes both streams, even at zero speed. Polling remains at about 1 Hz while paused, accepting about a second of resume latency to save battery. Phone movement cannot override the Board, and paused samples are never backfilled.
On unexpected disconnection the pause gate is released, because it halts GPS too and off the link there is no Board engagement signal to ever reopen it. Recording continues on GPS alone until the rider stops it. On reconnection the detector takes over again from the next board-ready.
An explicit Connect targeting the same Board — the rider tapping Connect to hurry a reconnect
loop along — rejoins the recording still open for that Board rather than ending it. One ride stays
one identity and one history entry, and nothing is labelled stopped that the rider never stopped.
Capture stays armed from that connect, not from the board-ready after it, so no GPS Fix is dropped
in between.
An explicit Connect targeting a different Board ends the previous Board Session and Ride Recording
immediately, as board_change — including while the old Board is disconnected and reconnecting, and
even if the new connection then fails. Merely browsing or selecting another Board does not end capture. Writes
already admitted are flushed under their original Board and recording identities; fixes captured
between two recordings are never backfilled into either. Old reconnect work is cancelled and late
callbacks for the old Board cannot revive its recording or contaminate the new one.
Stopping a recording releases only its own GPS demand. An independent live GPS consumer — Group Ride, the map — keeps its own lifetime and stays armed.
Debug raw BLE recording is separate. Android Dev → Debug recordings can capture raw chunks, connection states, and location for diagnosis, then list and export the JSONL files. Debug replay playback is intentionally removed from the app.
On app foreground/resume, JS calls syncNativeState() and shows a restoring state
until the first native snapshot arrives. The restored state comes from native
service truth, not from cached JS status.
A CoreBluetooth state-restoration relaunch (ADR 0034) rebuilds the Board Session that was live when the process died, and its recording rejoins the open Ride Recording instead of starting a second one — a process death the rider never asked for must not split their ride into two history entries.
The resume marker only says the rider had recording on; whether a recording is still open is the database's answer, so an explicitly stopped or disconnected recording is not revived and a new one is started instead. The dead interval stays a real gap in both streams: nothing is fabricated for time the process could not run.
A recording left open by a process that died and was never restored is closed (disconnected) when
the next recording is minted — the one moment it is known to be unrejoinable.
@platform-diff Android has no peer. CoreForegroundService keeps the process alive, so there is no
restoration relaunch, and its launch auto-connect is an ordinary cold start that may be days later.