Skip to content

Latest commit

 

History

History
271 lines (209 loc) · 13.2 KB

File metadata and controls

271 lines (209 loc) · 13.2 KB

Connection state

index

Rule

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.

Legacy firmware identity

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.

Shape

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
  }
}

Runtime split

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.

GPS phase

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 as gps.error.

JS role

src/modules/board/store/bleStore.ts mirrors native state:

  • syncNativeState() reads getLiveState()
  • onLiveState replaces lifecycle status
  • onTelemetry appends telemetry only when connectionSeq matches
  • onLocation appends 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

Auto-connect is triggered by process launch on both platforms, never by the JS runtime coming up:

  • Android: AutoConnectProvider (a ContentProvider) → CoreForegroundService.autoConnectSelectedBoardBoardSessionController.autoConnectSelectedBoard.
  • iOS: VescapeLaunchSubscriber in didFinishLaunchingWithOptionsBoardSessionController.autoConnectSelectedBoard, called right after prepareForLaunch().

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.

Accessories

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: AutoConnectProviderCoreForegroundService.autoConnectAccessoriesAccessorySessionManager. 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: VescapeLaunchSubscriberAccessorySessionController.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 inside didFinishLaunchingWithOptions.

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.

Fast Connect Stability

The fastest stable path is not to wait longer; it is to avoid competing native writes during startup.

  • connected means 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

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).

Persisted end intent

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.

Idle Pause and disconnection

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.

Changing Boards

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.

Restore

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.

iOS state-restoration relaunch

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.