- Dart 83.1%
- Swift 12.7%
- Monkey C 2.6%
- Python 1.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| assets | ||
| garmin | ||
| ios | ||
| lib | ||
| test | ||
| tools | ||
| .gitignore | ||
| .metadata | ||
| analysis_options.yaml | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
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 60–120 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.07–3.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 idat.lbsfilm.boltand iCloud containeriCloud.at.lbsfilm.bolt. For another team, change all three: Xcode signing,ios/Runner/Runner.entitlements,NSUbiquitousContainersinInfo.plist, andICloudExporter.containerIdinBoardLinkPlugin.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:speedplusbolt: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.csvandsessions.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()inlib/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.prgto the watch'sGARMIN/APPSfolder over USB. - App id: the id in
garmin/manifest.xmlmust matchGarminLink.watchAppIdinios/Runner/GarminLink.swift.
Connect in the app via Settings › Garmin watch:
- Turn the switch on. Garmin Connect opens so you can pick the watch.
- 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):AppDelegatestarts an explicit Flutter engine and the Bluetooth manager at launch.SceneDelegateattaches 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 sidelib/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 sidelib/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, andVescClient(sequential request/response).lib/services/board_service.dart: identifies the controller, polls telemetry, and logs sessions.lib/services/ride_recorder.dartandlib/recording/:- recorder states
idle → armed → recording → grace - motion trigger, live CSV, summary, GPX and ride storage
- recorder states
lib/services/location_service.dart: coarse GPS while armed, precise during rides.lib/stats/andlib/services/stats_service.dart: all-time totals and charge cycles.- Remaining services:
lib/services/garmin_service.dart: watch framesicloud_sync.dartexport_service.dartnotifications.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:
--watchpolls continuously,--rawdumps payloads for test fixtures. - Disconnect the phone first, since the module accepts one connection.
- Options:
flutter test tools/render_logo_test.dart: rendersassets/icon/icon.pngfromlib/ui/logo.dart; rundart run flutter_launcher_iconsafterwards. WithBOLT_LOGO_PHOTO=<photo.png>it also writesbuild/logo_overlay.pngto 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.pngtakes a screenshot.- Killing the app with
xcrun devicectl device process signal --pid <pid> --signal SIGKILLsimulates 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