Skip to content

Latest commit

 

History

History
553 lines (434 loc) · 34.1 KB

File metadata and controls

553 lines (434 loc) · 34.1 KB

Native API

JS bridge surface exposed by VescapeCore Expo module. Android: full impl. iOS: bridge stub until the native CoreBluetooth and storage subsystems land.

Source of truth: modules/vescape-core/src/index.ts (types), VescapeCoreModule.kt (Android), VescapeCoreModule.swift (iOS bridge stub).

Term map

Domain (CONTEXT.md) Native/API name
Board board_id in DB, boardId in API
Telemetry Sample telemetry_frames (DB), TelemetrySample (JS)
Ride Recording frames + buckets + markers in DB
Ride History getRideHistoryPage (complete rides), getHistoryRange (full)
Tune Profile tune_profiles table, TuneProfile type
Tune Snapshot RefloatConfigSnapshot
Alert Rule alerts table, AlertRule type
User Profile Stats ProfileStatsSnapshot, getProfileStatsSnapshot

Scan

fn sync returns
scan() sync void. Emits onDevice events per advertisement
stopScan() sync void

Accessory discovery

Read-only. Scanning matches the Vescape Accessory service UUID, never a name. One inspection runs at a time; it writes one hello, reads the manifest, and disconnects, so nothing on an accessory is activated by finding it. Contract: accessory-protocol.md.

fn sync returns
startAccessoryScan() sync void. Emits onAccessoryDevice per advertisement
stopAccessoryScan() sync void
inspectAccessory(deviceId) async AccessoryInspection{deviceId, advertisedName, manifest, error}
cancelAccessoryInspection() sync void

AccessoryManifest shape

{
  accessoryId: string       // persistent identity; saved settings key on it, never on the BLE handle
  name: string
  firmwareVersion: string
  protocolVersion: number | null   // null = no common version
  supportedVersions: number[]      // what the accessory offers instead, only when none was agreed
  compatibility: 'supported' | 'unsupported-version' | 'unsupported-capabilities'
  capabilities: { id, type, supported, unit, rangeMin, rangeMax, ratesHz }[]
}

compatibility and each capability's supported are native's verdict, not JS's to re-derive.

Enrolled Accessories

Durable. Only an Accessory the rider added gets a session, and native keeps that session running with the JS runtime dead — Android from CoreForegroundService, iOS from a restore-identified central created in didFinishLaunchingWithOptions. JS sends intents and renders onAccessoryState.

enrollAccessory takes a device handle, never an identity: native performs its own handshake and saves what the hardware actually said, so an enrollment cannot record a manifest JS invented.

fn sync returns
enrollAccessory(deviceId) async AccessoryEnrollment{accessoryId, error}
forgetAccessory(accessoryId) async boolean — whether a saved Accessory was removed
getAccessories() sync SavedAccessory[] — the same snapshot onAccessoryState pushes

SavedAccessory shape

{
  accessoryId: string        // manifest identity; the row's primary key
  name: string               // live manifest name while connected, else the saved one
  firmwareVersion: string
  protocolVersion: number | null
  deviceId: string | null    // where it answered last; a reconnect hint, never identity
  enrolledAt: number
  lastConnectedAt: number | null
  phase: 'idle' | 'connecting' | 'handshaking' | 'connected' | 'unavailable' | 'incompatible'
  error: string | null       // native's wire string for the last failure
  compatibility: AccessoryCompatibility | null   // null until a session reads a manifest
  capabilities: AccessoryCapability[]
  capabilitiesChanged: boolean   // declared limits moved since enrollment; saved settings suspect
  leaseHeldMs: number | null     // since the accessory last acknowledged a command
}

A drop is connecting, not an error: both platforms keep the reconnect alive on their own.

Ground clearance

JS asks for measurements and offers numbers; native decides whether the sensor runs and whether the numbers are a calibration. There is no Save step for the rider: send what they have as they change it and read the answer.

fn sync returns
setAccessoryPreview(accessoryId, capabilityId, open) sync void. Demand to measure, never to tilt
saveGroundClearanceCalibration(accessoryId, capabilityId, calibration) async {saved, problem}problem names the rule it broke
clearGroundClearanceCalibration(accessoryId, capabilityId) async boolean — whether a calibration was removed

Measurement demand is the union of an open preview and the rider riding a board this capability is calibrated for. Neither alone permits sensor-driven tilt: a preview shows numbers on a parked board and commands nothing. Dropping both demands sends configure{enabled:false}, which stops the accessory's continuous measurement while its BLE session stays up. Riding is decided natively from the Board Session's own engagement predicate, never from a value that crossed the bridge.

onAccessoryReading pushes one accepted sample, and only while that capability has a preview open — nothing else in the app consumes single samples. Every sample is range-checked against the live manifest before it crosses:

{
  accessoryId: string
  capabilityId: string
  seq: number // per capability, restarts with each protocol session
  sampleTimeMs: number // the accessory's own monotonic clock; orders samples, nothing else
  status: 'ok' | 'out_of_range' | 'error'
  valueCm: number | null // non-null ONLY when status is 'ok'
  staleAfterMs: number // how long this sample stays evidence, from the acked rate
}

staleAfterMs travels with every sample so a screen can drop the number the moment it stops describing the ground, without re-deriving native's window. A frozen distance presented as a live one is the same lie as an invalid reading shown as the maximum range, just slower.

A preview is the only demand JS owns, so it dies with JS: native releases every preview when the module is destroyed, because a runtime that reloaded or crashed with the screen open would otherwise leave the accessory measuring forever — native's own renewals keep the lease alive. Riding demand is untouched by that, since it comes from the Board Session.

The one rule everything else rests on: a missing or unreadable measurement is never a distance, and never the maximum of the declared range. A value outside the declared window arrives as out_of_range with no value rather than clamped to the nearest limit; an ok carrying no number, a null, text, or a status this build does not know all arrive as error.

Each AccessoryCapability in the snapshot carries calibration (with its own problem, re-decided against the live manifest on every push) and measuring, the demand native actually resolved. Saving a calibration that fits the current manifest is also how the rider accepts declared limits that moved since enrollment — it rewrites the frozen capabilities_json baseline and clears capabilitiesChanged.

Ground-clearance tilt

The binding that turns those readings into Remote Tilt is entirely native: a 100 ms timer inside the Board Session, not a reaction to samples. A sensor that stops sending produces no events to react to, and releasing on silence is the whole point.

fn sync returns
getGroundClearanceTilt() async GroundClearanceTiltState — see below
{
  bound: boolean // a configured ground-clearance Accessory is connected → the tilt pad is read-only
  driving: boolean // the binding is commanding tilt right now
  release: GroundClearanceRelease | null // why it is not, or null while it is
}

Polled, not pushed: the only consumer is the tilt pad, which already reads the commanded tilt on its own interval. bound is independent of driving — a binding waiting for the rider to set off still owns the pad, because manual input is not this Board's input method any more.

release is the full list of ways the binding lets go. The first six are the Accessory's own, decided by the capability runtime; the last five are the Board Session's, and did not exist before sensor readings could command tilt:

release means
not-riding The Board is not engaged. A parked Board is not corrected.
no-link No Accessory session, or one not acknowledging commands.
not-calibrated Nothing saved, or what is saved no longer fits the declared limits.
stale Samples stopped arriving inside the acked rate's window.
out-of-range The sensor answered, and the answer is not a distance.
sensor-error The sensor could not measure, or sent something unreadable.
board-untrusted The Board is not connected, or its Board Link is not Trusted.
board-stale The Board is connected but has stopped answering.
contested More than one calibrated ground-clearance capability wants the slot.
board-move Board Move holds the remote-input slot.
manual-tilt A rider-commanded tilt still holds the slot while the binding arms.

Every path that writes the Board's one remote-input slot — the pad, Board Move, and the sensor — goes through a single native arbiter. remoteTilt.owner on the live state and on getRemoteTiltState() names the winner (none | manual | sensor | move). See remote-tilt.md.

Location

fn sync returns
startLocationUpdates() sync void. Emits onLocation. Independent of board session
stopLocationUpdates() sync void

Board session

fn sync returns
selectBoard(boardId) async void. Native reads the Board Link from DB, owns connect. Emits onLiveState, onTelemetry
stopBoard() async void. GPS may continue independently
probeBoardLink(bleId) async BoardProbeResult. Probes a peripheral, returns resolved transport when unique plus BoardCandidate[] (transport + hasBms + firmware identity when available) confirmed by telemetry. Emits onBoardProbeProgress
getLiveState() sync LiveStateEvent. UI should mirror, not invent state
setSelectedBoard(boardId | null) sync void. Persists auto-connect target. Native uses while JS frozen

LiveStateEvent shape

{
  board: { phase, selectedBoardId, connectedBoardId, bleId, name,
           connectionSeq, lastTelemetryAt, recentTelemetry[], error, autoConnect }
  gps:   { phase, latestFix, latestApproximateFix?, latestPreciseFix?, recentLocations[], error }
  scan:  { phase, devices[], error }
  recording: { enabled, activeBoardId, startedAt }
}

Phases: idle|connecting|discovering|subscribing|waiting_for_telemetry|connected|stale|reconnecting|disconnecting|error

Telemetry recording

fn sync returns
setTelemetryRecordingEnabled(enabled) sync void. Toggle SQLite writes

Write pipeline internals

  1. BLE packet -> TelemetryCapture (human units)
  2. Scale to integer state (FullTelemetryState) for lossless storage
  3. Delta-encode against previous -> TelemetryFrameEntity (nulls = unchanged)
  4. Keyframe every 60s or on gap. Flags: KEYFRAME=1, HAS_LOCATION=4. Bit 2 is retired.
  5. Queue in-memory (max 1000 pending). Flush on 25 frames or 5s delay
  6. On flush: insert frames + upsert buckets (60s aggregates) + insert markers
  7. Gap marker auto-inserted when sample gap > 90s

Delta encoding thresholds

Field omitted (null) when change < threshold from previous:

field threshold
speed 5 centi-km/h
voltage 20 mV
motor/battery current 100 mA
duty 2 permille
pitch/roll/balancePitch 5 centi-deg
balance current 100 mA
adc1/adc2 10 milli
odometer 25 cm
temp mosfet/motor 5 deci-C
location >2m moved OR >2m accuracy change OR >5s elapsed

Telemetry queries

fn returns notes
getTelemetryHistory(opts?) TelemetryMinuteBucket[] 60s bucket aggregates. Pagination via cursorBeforeMs. Default limit 100, max 500
getRideHistoryPage({limit?,cursorBeforeMs?}) RideHistoryPage Complete stable rides with coarse route points; cursor never cuts through a ride
getTelemetrySamples({fromMs,toMs,boardId?,limit?}) TelemetrySample[] Decoded from compressed frames. Reconstructs state from nearest keyframe. Default 2000, max 10000
getHistoryRange({fromMs,toMs,boardId?,limit?}) {boardSamples, chartSamples, gpsSamples, markers} Full decoded range plus a native-decimated chart overview (max 600 samples)
getTelemetrySummary() {sampleCount, gpsPointCount, firstAtMs, lastAtMs, droppedPendingSamples} DB-wide stats
getDatabaseSizeBytes() number File size of vescape.db

TelemetryMinuteBucket (bucket shape)

{
  id, startAtMs, endAtMs, bucketStartMs, boardId, boardName,
  sampleCount, gpsPointCount, preciseGpsPointCount,
  maxAbsSpeedKmh, maxGpsSpeedKmh?, avgSpeedKmh, avgSpeedSampleCount,
  minBatteryVoltage?, maxMotorCurrent, maxBatteryCurrent, maxDuty,
  distanceDeltaM?, gpsDistanceM?,
  maxTempMosfet?, maxTempMotor?,
  firstLatitude?, firstLongitude?,
  boundaryBefore: 'none'|'connected'|'disconnected'|'error'|'gap'|'app_stop',
  boundaryMessage?, gapBeforeMs?
}

TelemetrySample (decoded frame shape)

{
  id, capturedAtMs, boardId, boardName,
  speedKmh, batteryVoltage, motorCurrent, batteryCurrent, dutyCycle,
  pitch, roll, balancePitch, balanceCurrent, erpm,
  state, switchState, adc1, adc2, odometer?,
  tempMosfet?, tempMotor?,
  latitude?, longitude?
}

VESC faults

Native owns durable, Board-scoped live fault occurrences and their past telemetry captures. They are independent of Ride History and Board Warnings. Refloat fault-only responses do not produce telemetry samples or minute-bucket counts.

fn returns notes
getVescFaults() Promise<VescFaultOccurrence[]> All Boards; JS groups the result by Board.
setVescFaultDismissed(id, dismissed) Promise<void> Acknowledges or restores one occurrence without deleting evidence.
getVescFaultCapture(occurrenceId) Promise<VescFaultCaptureDetail | null> Past snapshot, samples oldest first.
readVescFaultLog(boardId) Promise<string> Fixed read-only faults command; matching Board must be connected and report a finite speed at most 1 km/h. Rejects unavailable/busy reads.
VescFaultOccurrence = {
  id, boardId, code, occurredAtMs, lastObservedAtMs,
  clearedAtMs: number | null, dismissed: boolean
}
VescFaultCaptureDetail = {
  occurrenceId, boardId, startedAtMs, openedAtMs, sampleCount,
  samples: VescFaultCaptureSample[]
}

Each capture copies up to five seconds from the existing native live window once, at detection. It has no future tail or GPS. Sample fields and nullability are defined in modules/vescape-core/src/index.ts, VescFaultCaptureSample.

onVescFaults emits { boardId, faults: VescFaultOccurrence[] }, a full replacement list for one Board. JS also pulls on startup and foreground to catch changes made while backgrounded. vescFaultCollectionEnabled defaults to true; disabling it stops new live collection and fault indicators, but preserves existing evidence and access to the Controller Fault Log.

The fault drawer requests the Controller Fault Log once when opened. Its raw text is ephemeral; it never creates occurrences, warnings, baselines, or persisted register snapshots. See ADR 0037.

Telemetry deletion

fn returns
deleteTelemetryBefore(beforeMs) frames deleted count. Also deletes matching markers + buckets
deleteTelemetryRange({fromMs,toMs,boardId?}) frames deleted count. Flushes pending first
clearTelemetryHistory() void. Wipes all frames, markers, buckets + resets in-memory state

User Profile Stats

fn returns
getProfileStatsSnapshot({year?,month?}) Lifetime + selected-month stats and available months from one native pass

ProfileStats shape

{ distanceM?, rideCount, rideTimeMs, topSpeedKmh, avgSpeedKmh, longestRideM?, batteryUsedWh?, batteryRegenWh? }

Session grouping logic (internals)

Rides computed from buckets + markers:

  • New session on: device change, gap >10min, or boundary marker (disconnected/app_stop/error)
  • Moving avg speed uses movingSpeedThresholdKmh setting (default 3.0 km/h) to exclude stopped samples
  • Distance prefers odometer delta, falls back to GPS distance
  • Energy: trapezoidal integration (VIdt), max 5s sample gap

Boards

fn sync returns
getBoards() async Board[] sorted by created_at ASC
upsertBoard(board) async void
deleteBoard(id) async void

upsertBoard also starts the real Board Session when the Board being written is the one that just proved a link in finalizeBoardLink — linking connects over a throwaway probe session and drops it, so the persist on Save is what reconnects for real.

Board shape

{ id, name, description?, createdAt, batteryConfig?,
  link: { linkVersion: 4, bleId, transport, hasBms,
          vescFirmwareVersion: string | null,
          refloatVersion: string | null,
          refloatBaseVersion: string | null } | null }

A Board Link is saved whole or not at all: it always carries a proven BLE peripheral id plus a selected Board Transport ('direct' | CAN id). Current links also carry linkVersion: 4, a required hasBms boolean, exact firmware identity keys, and normalized refloatBaseVersion for Tune Compatibility. Native finalization reads and persists Last Known Board Config Values before returning a saveable v4 link. Missing or null required identity values keep telemetry available but require re-link before firmware-dependent commands.

Stored Board Links are normalized defensively: missing or malformed newer fields default to safe values, unknown old fields are ignored, and outdated or incomplete link facts keep telemetry available while forcing re-link before firmware-dependent commands. Malformed reachability (bleId or transport) is treated as unlinked rather than crashing. link: null means the board is unlinked (offline-only). Mutable per-board fields (description, batteryConfig, transport, probe-confirmed link facts) live in the board_settings key-value table; the boards row holds only stable identity (id, name, ble_id, created_at).

Alert rules

fn sync returns
getAlertRules() async AlertRule[] by created_at ASC
upsertAlertRule(rule) async void. Reloads foreground service rules
setAlertRuleEnabled(id,enabled) async void. Reloads rules
deleteAlertRule(id) async void. Reloads rules
getAlertSounds() sync AlertSound[]. Falls back to hardcoded if native unavailable
previewAlertSound(soundType) sync void
startGeigerSimulation(soundType,rangeDepth) sync void
stopGeigerSimulation() sync void
reloadAlertRules() sync void. Force foreground service re-read

AlertRule shape

{ id, controlId, threshold, thresholdMax?, enabled, soundType, createdAt }

Single threshold -> one-shot alert. Both threshold+thresholdMax -> geiger (progressive ticking).

AlertSound shape

{ name, uri, category: 'single'|'geiger' }

Presets: beep, urgent, notify (single); tick, tick_hard, gamma (geiger)

Tune profiles

fn returns
getTuneProfiles(boardId) TuneProfile[]
getTuneProfile(profileId) TuneProfile?
createProfile(boardId,name,icon,color,fields) TuneProfile
renameProfile(profileId,name,icon,color) TuneProfile
deleteProfile(profileId) void. Fails if last profile for board
saveProfile(profileId,fields) TuneProfile. Creates history entry before save
getProfileHistory(profileId) TuneHistoryEntry[] newest-first
rollbackProfile(profileId,historyEntryId) TuneProfile. Snapshots current before rollback
copyProfileToBoard(profileId,targetBoardId,newName) TuneProfile on target board
pushProfileToBoard(profileId) RefloatConfigSnapshot. Writes to connected board via BLE
getRefloatConfigSnapshot() RefloatConfigSnapshot. Reads current board config

TuneProfile shape

{ id, boardId, name, icon, color, fields: Record<string, number|boolean|string|null>, createdAt, updatedAt }

RefloatConfigSnapshot shape

{ capturedAt, boardId?, canId, schemaHash, rawConfigHash, rawConfigLength,
  groups: { id, title, fields: { id, label, value, unit?, min?, max? }[] }[],
  missingFieldIds[], fwVersion?, refloatVersion? }

Settings

fn returns
getSettings() AppSettings
updateSetting(key,value) void. liveHistoryLimit also updates foreground service

AppSettings shape

{ liveHistoryLimit, autoConnect, autoRecording, selectedBoardId?,
  lastGpsLatitude?, lastGpsLongitude?, movingSpeedThresholdKmh, riderTopSpeedKmh }

Valid keys: liveHistoryLimit, autoConnect, autoRecording, selectedBoardId, lastGpsLatitude, lastGpsLongitude, movingSpeedThresholdKmh (aliases: avgSpeedCutoffKmh, movingAvgSpeedThresholdKmh), riderTopSpeedKmh (Rider Top Speed, km/h; speed gauge full-scale, clamped 5–150, default 50)

Writing default-equivalent value deletes the override row. Unknown keys and type mismatches are silently ignored.

Auto start (companion presence)

Android only — every fn rejects UNSUPPORTED_PLATFORM on iOS, except getCompanionPresenceBoards() which resolves []. All are async.

fn returns
setCompanionPresenceEnabled(enabled) void. Master switch; on also forces autoConnect
getCompanionPresenceBoards() CompanionPresenceBoard[] — linked boards the OS is associated with
addCompanionPresenceBoard(boardId) void. Opens the system chooser, then observes the board
removeCompanionPresenceBoard(boardId) void. Stops observing and drops the association

Associations, not the settings flag, are the source of truth for which boards are armed. Disabling the master switch stops observing but keeps associations, so re-enabling needs no second chooser. Enabling and removing both prune associations no linked board claims.

CompanionPresenceBoard = { boardId, name, bleId }

Rejection codes are rider-facing; src/modules/settings/lib/companionErrors.ts maps them to copy.

Diagnostics

fn sync returns
setDebugRecordingEnabled(enabled) yes void. Android only
listDebugRecordings() no { name, createdAt, sizeBytes }[]
exportDebugRecording(name) no { uri, name, sizeBytes }. Android only
reportUiError(message,source?,stack?) yes void
reportDiagnosticTest() yes DiagnosticStatus
getDiagnosticStatus() yes DiagnosticStatus

DiagnosticStatus shape

{ enabled, host, distinctId?, captureCount, lastEventName?, lastCaptureAt? }

Events

event payload when
onDevice {id, name, rssi, serviceUUIDs[]} BLE scan advertisement
onError {message} Native error
onLiveState LiveStateEvent Connection/GPS/scan/recording state change
onTelemetry TelemetryEvent Real-time board data. Includes firedAlerts[]
onBms BmsEvent Smart-BMS cell-group values, ~1/8 telemetry rate. See vescProtocol.md
onLocation LocationEvent GPS fix from startLocationUpdates()
onAccessoryDevice {id, name, rssi} Vescape Accessory service advertisement
onAccessoryScanError {error} The accessory scan could not run (bluetooth-unavailable, scan-failed)
onAccessoryState {accessories} Every enrolled Accessory and its native link phase, on every change and on subscribe

TelemetryEvent shape (live, not history)

{ generation?, location?,
  pitch, roll, balancePitch, balanceCurrent,
  speed, batteryVoltage, motorCurrent, batteryCurrent, erpm, dutyCycle,
  state, stateName, switchState, adc1, adc2, odometer?, tempMosfet?, tempMotor?,
  avgLatency?, lastPacketAt, firedAlerts?: FiredAlert[] }

Live event has stateName + avgLatency + firedAlerts. History TelemetrySample does not.

BmsEvent shape

{ capturedAt, voltageTotal, current, ampHours, wattHours,
  soc: number | null,        // 0–1, null when firmware omits it
  cellVoltages: number[],    // per cell-group, volts
  balancing: boolean[] }      // per cell-group, aligned with cellVoltages

Not persisted to history and not fed into alerts. bleStore keeps only the latest snapshot (latestBms); UI derives min/max/spread via summarizeBms in src/modules/battery/lib/bms.ts.