No description
  • Dart 83.1%
  • Swift 12.7%
  • Monkey C 2.6%
  • Python 1.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Lukas Bachschwell 45de05e771
Fix ride review
Signed-off-by: Lukas Bachschwell <lukas@lbsfilm.at>
2026-09-17 21:43:45 +02:00
assets Initial Version 2026-09-17 11:59:23 +02:00
garmin Add garmin app support 2026-09-17 14:51:46 +02:00
ios Add garmin app support 2026-09-17 14:51:46 +02:00
lib Fix ride review 2026-09-17 21:43:45 +02:00
test Add garmin app support 2026-09-17 14:51:46 +02:00
tools Add garmin app support 2026-09-17 14:51:46 +02:00
.gitignore Add garmin app support 2026-09-17 14:51:46 +02:00
.metadata Initial Version 2026-09-17 11:59:23 +02:00
analysis_options.yaml Initial Version 2026-09-17 11:59:23 +02:00
pubspec.lock Initial Version 2026-09-17 11:59:23 +02:00
pubspec.yaml Initial Version 2026-09-17 11:59:23 +02:00
README.md up readme 2026-09-17 19:28:16 +02:00

Bolt app icon

Bolt

iOS app for the Bolt Motion electric skateboard (2015/2017, VESC based). It replaces the metr app, which no longer supports the board's old Bluetooth module for exports:

  • Live dashboard: speed, battery, voltage, currents, power, trip, consumption, temperatures, GPS speed.
  • Rides: recorded automatically when you ride off, with GPS. The app wakes in the background when the board turns on, even after being swiped away.
  • Ride files: each ride is a CSV (5 Hz samples) plus GPX, with a map, charts and summary. Share via AirDrop, find them in the Files app, or let the app copy them to iCloud Drive.
  • Stats: odometer, weekly/monthly distance, and charge statistics (distance, ride time and days per charge, projected full-charge range).
  • Board: controller info, motor/battery configuration, and speed/power profiles (Eco/Normal/Sport/custom).
  • Garmin: optional live display on a Garmin Instinct 2.

Flutter UI, native Swift for Bluetooth. iOS only.

Personal project, not affiliated with Bolt Motion, metr/Voyage Systems or Garmin. The logo is traced from the board's deck sticker (lib/ui/logo.dart).

The board

Controller VESC 4.10 hardware, firmware 3.58, FOC
Drivetrain 14-pole motor, 2.286 : 1 belt, 72 mm wheels
Battery Li-ion 6S 4.4 Ah (~98 Wh), cutoff 20 → 18 V
Bluetooth HM-10 / TI CC2541 UART module advertising bolt-XXX, service FFE0, characteristic FFE1 (write + notify), 20-byte chunks, no PIN

The module forwards plain VESC packets:

  • Framing: 02 len payload crc16 03, CRC-16/XMODEM.
  • One connection at a time: close metr (or turn off its auto-connect) while using this app.

The request/response round trip takes 60120 ms, so telemetry is polled at 5 Hz.

VESC commands used (lib/vesc/):

ID Command Use
0 COMM_FW_VERSION identify controller on connect
14 COMM_GET_MCCONF limits, battery and wheel setup (layout parsed for FW 3.58 only, signature 0x1DFFE636)
47 COMM_GET_VALUES_SETUP telemetry (FW 3.48+): speed, distance, battery %, Wh left
4 COMM_GET_VALUES telemetry fallback for FW 3.073.47, speed/distance computed from the motor setup
49 COMM_SET_MCCONF_TEMP_SETUP speed profiles: current scales, speed limit, store = RAM only or flash

The unit tests use real replies captured from the board (test/fixtures/fw358.dart).

Requirements

  • App build: Flutter 3.47 (Dart 3.13) and Xcode 27.
  • Phone: iOS 18 or later. Relaunch after a swipe-quit needs iOS 26+.
  • Apple developer team: the project is set up for team MVV5Z7RX84, bundle id at.lbsfilm.bolt and iCloud container iCloud.at.lbsfilm.bolt. For another team, change all three: Xcode signing, ios/Runner/Runner.entitlements, NSUbiquitousContainers in Info.plist, and ICloudExporter.containerId in BoardLinkPlugin.swift.
  • Garmin watch app (optional): Connect IQ SDK 9.x, a Connect IQ developer key, and an Instinct 2.

Build and install

flutter pub get
flutter test
flutter build ios --release
xcrun devicectl device install app --device <device-udid> build/ios/iphoneos/Runner.app

Test background behaviour with a release (or profile) build: iOS can't launch Flutter debug builds without the debugger attached, so background relaunches won't work.

After installing, the onboarding asks for location ("Always" is needed for GPS on rides that start in the background) and notifications, then pairs the board.

  • Pairing sheet: pairing uses the iOS accessory sheet (AccessorySetupKit), so there is no Bluetooth permission prompt.
  • Managing the board: it then appears under Settings Privacy & Security Accessories.
  • Not found? If the sheet can't find the board, the app offers a second search without the name filter; hold the phone right next to the board.

Using it

  • Recording:
    • Ready: when the board connects, the app shows READY.
    • Start: a ride begins once the board moves (≥ 3 km/h for 1.5 s, or 15 m), keeping the 5 s before that.
    • End: a ride ends when the board stands still for 5 min, or 60 s after switching it off. Switching it back on within those 60 s continues the same ride.
    • Trimming: trailing standing time is cut, and rides under 100 m or 60 s are discarded.
    • Manual control: "Start now" and "Stop ride" on the Live tab.
    • All of these are in Settings.
  • Background: the app keeps a pending Bluetooth connection to the board with state restoration, so iOS relaunches it when the board powers on, including after a swipe-quit (iOS 26+). While READY it runs coarse location updates so iOS doesn't suspend it before you ride off.
  • Speed profiles (Board tab):
    • Until reboot: writes the limits to the controller's RAM; switching the board off restores them.
    • Save: writes them to flash.
    • When: only while the board stands still.
    • Stock: a snapshot of the board's own limits, taken on first connect.
  • Charge detection:
    • How: each connection logs the resting battery level (median voltage while no current flows) at connect and disconnect. A charge is counted when the level at connect is ≥ 15 points (configurable) above the last disconnect.
    • Corrections: override it per ride ("Charged before this ride?").
    • Starting odometer: in Settings, adds the km ridden before this app.

Data

Everything lives in the app's Documents folder, visible in Files On My iPhone Bolt:

Rides/<yyyy-MM-dd_HH-mm-ss>/
  Bolt_<yyyy-MM-dd_HHmm>.csv   one row per sample, written live (crash-safe)
  Bolt_<yyyy-MM-dd_HHmm>.gpx   GPX 1.1 track, written when the ride ends
  ride.json                    metadata, summary and track preview
sessions.jsonl                 one line per board connection (rest voltage / battery % at connect and disconnect)
charges.json                   manual charge overrides
profiles.json                  speed profiles and the stock snapshot
  • CSV columns: time_utc, elapsed_s, speed_kmh, gps_speed_kmh, lat, lon, gps_alt_m, gps_acc_m, voltage_v, battery_pct, battery_a, motor_a, power_w, duty_pct, erpm, trip_m, esc_temp_c, motor_temp_c, ah_used, ah_regen, wh_used, wh_regen, wh_left, fault. Distance and energy counters are per ride and survive a controller reboot.
  • GPX: one point per GPS fix, with gpxtpx:speed plus bolt: extensions (voltage, battery, current, power, duty, ESC temp).
  • iCloud Drive: finished rides are copied to iCloud Drive Bolt Rides (on a Mac: ~/Library/Mobile Documents/iCloud~at~lbsfilm~bolt/Documents/Rides).
  • Export everything (Settings) builds a zip with all rides, rides_summary.csv, charges.csv and sessions.jsonl.

Garmin watch

garmin/ is a Connect IQ watch app for the Instinct 2:

  • Screen: big speed, board battery, READY/REC, ride time and distance, and the time of day in the round subscreen.
  • Updates: the phone pushes a small frame once a second while the board is on, and every 10 s while it is off. The frame keys are documented at watchFrame() in lib/services/garmin_service.dart.
  • Opening and closing: the phone can ask the watch to open the app when a ride starts and close it when the ride ends.

Build and install:

MONKEYC="$HOME/Library/Application Support/Garmin/ConnectIQ/Sdks/connectiq-sdk-mac-9.2.0-2026-06-09-92a1605b2/bin/monkeyc"
"$MONKEYC" -f garmin/monkey.jungle -d instinct2 -o garmin/bin/Bolt.prg -y /path/to/developer_key.der -r
  • Release flag: without -r (debug build) the app plays demo ride data, for the Connect IQ simulator, which can't reach the iPhone.
  • Sideloading: copy garmin/bin/Bolt.prg to the watch's GARMIN/APPS folder over USB.
  • App id: the id in garmin/manifest.xml must match GarminLink.watchAppId in ios/Runner/GarminLink.swift.

Connect in the app via Settings Garmin watch:

  1. Turn the switch on. Garmin Connect opens so you can pick the watch.
  2. Back in Bolt, allow access in the iOS accessory sheet. Because the app uses AccessorySetupKit, CoreBluetooth (and the Connect IQ SDK) can only reach accessories approved there, so the already-paired watch is added as a migration item.

How it works

  • App startup (ios/Runner/AppDelegate.swift, SceneDelegate.swift):
    • AppDelegate starts an explicit Flutter engine and the Bluetooth manager at launch.
    • SceneDelegate attaches the UI to that engine.
    • As a result, main() and the ride recorder also run when iOS relaunches the app in the background with no UI.
  • Board Bluetooth (ios/Runner/BoardLink.swift, BoardLinkPlugin.swift; Dart side lib/ble/board_link.dart): AccessorySetupKit pairing, CoreBluetooth (state restoration, pending connect, chunked UART writes), the location "Always" upgrade, and the iCloud Drive copy.
  • Garmin bridge (ios/Runner/GarminLink.swift, GarminLinkPlugin.swift; Dart side lib/garmin/garmin_link.dart): Connect IQ SDK and watch authorization.
  • lib/vesc/: packet framing and streaming decoder, parsers (FW version, values, setup values, FW 3.58 motor configuration), the profile command, and VescClient (sequential request/response).
  • lib/services/board_service.dart: identifies the controller, polls telemetry, and logs sessions.
  • lib/services/ride_recorder.dart and lib/recording/:
    • recorder states idle → armed → recording → grace
    • motion trigger, live CSV, summary, GPX and ride storage
  • lib/services/location_service.dart: coarse GPS while armed, precise during rides.
  • lib/stats/ and lib/services/stats_service.dart: all-time totals and charge cycles.
  • Remaining services:
    • lib/services/garmin_service.dart: watch frames
    • icloud_sync.dart
    • export_service.dart
    • notifications.dart
  • lib/ui/: Live, Rides, Stats, Board and Settings tabs, onboarding, and the logo.

Tools

  • flutter test: protocol, recording, stats and watch-frame tests.
  • tools/vesc_probe.py: read-only VESC probe from a Mac over Bluetooth (pip install bleak), to compare the app's values with direct readings.
    • Options: --watch polls continuously, --raw dumps payloads for test fixtures.
    • Disconnect the phone first, since the module accepts one connection.
  • flutter test tools/render_logo_test.dart: renders assets/icon/icon.png from lib/ui/logo.dart; run dart run flutter_launcher_icons afterwards. With BOLT_LOGO_PHOTO=<photo.png> it also writes build/logo_overlay.png to check the traced shapes against a photo of the deck.
  • flutter test tools/render_watch_icon_test.dart: renders the Garmin launcher icon.
  • Checking on the device:
    • xcrun devicectl device capture screenshot --device <udid> --destination shot.png takes a screenshot.
    • Killing the app with xcrun devicectl device process signal --pid <pid> --signal SIGKILL simulates iOS terminating it, to test background relaunch.

Status

Verified on an iPhone (iOS 27) with the board:

  • live values and wheel-spin speed/distance
  • movement-triggered recording
  • AccessorySetupKit pairing
  • background relaunch after termination and after swipe-quit, with the board on or off at quit time

Not yet checked with the board or real rides:

  • speed profile writes
  • GPS track quality on a real ride
  • AirDrop/iCloud exports and charge statistics