Compare commits

..

25 Commits

Author SHA1 Message Date
073d825a3e dev: add some logging for dfu 2026-05-04 14:51:53 +02:00
bcccd03ecc feat: smarter firmware confirm via reconnect 2026-05-04 14:40:13 +02:00
16690dc216 feat: fw-update recovery flow 2026-05-04 13:54:19 +02:00
9b672a7503 feat: update optimizations 2026-05-04 13:18:03 +02:00
f5e5c3904f fix: fix disconnect when selecting firmware for dfu 2026-05-03 19:24:57 +02:00
3310387ec4 fix: show firmware update only after pairing 2026-05-01 15:11:51 +02:00
aa2d150300 feat: fullscreen OTA with back-block and warning 2026-05-01 15:06:46 +02:00
dc1f53b6e1 feat: recover bootloader OTA transfers 2026-04-29 19:59:11 +02:00
16365e1d04 fix: align bootloader image validation limits 2026-04-29 19:55:10 +02:00
09c686d542 docs: document bootloader OTA flow 2026-04-29 18:04:28 +02:00
06834a0cc0 feat: switch firmware updates to bootloader OTA 2026-04-29 18:02:48 +02:00
b673c9100d feat: add bootloader DFU protocol validation 2026-04-29 17:56:32 +02:00
eb26c759e8 refactor: remove direct trainer address assignment 2026-04-28 21:32:43 +02:00
5285c44173 feat: use shifter trainer scan flow 2026-04-28 21:31:52 +02:00
be1c39d5d7 feat: add shifter trainer scan service 2026-04-28 21:29:26 +02:00
7628947623 feat: add trainer scan protocol models 2026-04-28 21:28:40 +02:00
76b7195e5e fix: smooth scan RSSI readings 2026-04-28 20:38:33 +02:00
96416a2f73 fix(ios): show FTMS trainers advertised as 16-bit UUID 2026-04-28 20:25:30 +02:00
ac93c01cea feat: pairing ui 2026-04-28 20:22:15 +02:00
e3eba0bfc1 chore: refine ios pairing recovery copy 2026-04-28 20:13:18 +02:00
9922b90f49 fix(ios): open settings from pairing recovery 2026-04-28 20:06:10 +02:00
2e7c10f87d fix(pairing): pairing flow preempt status read fix 2026-04-28 19:56:54 +02:00
1f5ec5ebb2 fix(ios): ios bluetooth permission 2026-04-28 17:25:06 +02:00
84e026de52 Merge branch 'main' into ui-rework 2026-04-28 17:15:42 +02:00
2fa0447593 chore: ios dev files 2026-04-28 17:15:26 +02:00
43 changed files with 4501 additions and 1636 deletions

View File

@ -4,7 +4,7 @@ A new Flutter project.
## Operational Docs
- [DFU v1 Operator Guide](docs/dfu-v1-operator-guide.md)
- [Bootloader OTA Operator Guide](docs/bootloader-ota-operator-guide.md)
## Getting Started

View File

@ -1,17 +1,11 @@
package com.example.abawo_bt_app
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.reactivex.exceptions.UndeliverableException
import io.reactivex.plugins.RxJavaPlugins
class MainActivity: FlutterActivity() {
private val settingsChannel = "abawo/settings"
override fun onCreate(savedInstanceState: Bundle?) {
RxJavaPlugins.setErrorHandler { throwable ->
val error = if (throwable is UndeliverableException && throwable.cause != null) {
@ -29,27 +23,4 @@ class MainActivity: FlutterActivity() {
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, settingsChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"openBluetoothSettings" -> {
try {
startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS))
result.success(true)
} catch (_: Exception) {
try {
startActivity(Intent(Settings.ACTION_SETTINGS))
result.success(true)
} catch (_: Exception) {
result.success(false)
}
}
}
else -> result.notImplemented()
}
}
}
}

View File

@ -0,0 +1,62 @@
# Bootloader OTA Operator Guide
This guide explains the Universal Shifters single-slot bootloader update flow in `abawo_bt_app`.
## App-Side Flow
1. Connect to the target button and open **Device Details**.
2. In **Firmware Update**, select a local raw application `.bin` with **Select Firmware**.
3. The app validates size and vector table before enabling the update.
4. Review file metadata: size, session id, CRC32, app start, image version, and reset vector.
5. Tap **Start Update** and keep the phone close to the button.
6. The app sends `EnterDfu` to the running application, waits for reset, and connects to `US-DFU`.
7. The app sends bootloader `START`; this erases the active app slot.
8. The app transfers offset-based frames and tracks bootloader `expected_offset`.
9. The app sends `FINISH`, waits for final OK, then waits for the bootloader reset.
10. Success is shown only after the updated app reconnects and status verification passes.
## Image Requirements
- File extension must be `.bin`.
- Image must be at least 8 bytes and no larger than `0x3F000` bytes (252 KiB).
- Image bytes must start at application address `0x00030000`.
- Initial stack pointer must be aligned and within `0x20000000..=0x20010000`.
- Reset vector must have the Thumb bit set and point inside the image after the first two vector words.
- Flags are always `0`; encrypted/signed update flags are not supported by the current bootloader.
- Image version is currently sent as `0` unless a later packaging flow provides it.
## Operational Notes
- Single-slot update is destructive after bootloader `START`; the previous app is erased before image transfer.
- If transfer fails after `START`, recovery is through bootloader DFU or external reflash.
- Gear writes and **Connect Button to Bike** stay disabled while OTA is running.
- If BLE drops during transfer, retry promptly while the bootloader is still advertising `US-DFU`.
- Cancel after `START` sends bootloader `ABORT` and leaves the device in bootloader/recovery flow.
## Troubleshooting
| Symptom in app | Likely cause | Operator action |
| --- | --- | --- |
| Invalid stack pointer or reset vector | `.bin` is not a raw app image for `0x00030000` | Rebuild/export the application image from the correct linker layout. |
| Could not connect to bootloader DFU mode | Phone did not find `US-DFU` after app reset | Move closer, retry, and verify the device is advertising `US-DFU`. |
| Timed out waiting for bootloader DFU status | Status indication/read did not arrive | Reconnect to `US-DFU` and retry. |
| Bootloader status `bounds error` | Image length or app start rejected | Use a valid app image no larger than `0x3F000` bytes (252 KiB). |
| Bootloader status `CRC error` | Full-image CRC did not match flash contents | Re-export or re-download the `.bin`, then retry. |
| Bootloader status `vector table error` | Bootloader rejected the written vector table | Rebuild firmware for app start `0x00030000`. |
| Bootloader status `flash error` | Flash erase/write/read failed | Retry once; if repeated, service or externally reflash the device. |
| Bootloader status `boot metadata error` | Bootloader could not persist boot metadata | Treat as service risk; retry reflash, then return device if repeated. |
| Updated app did not reconnect | New app did not boot/confirm or reconnect window expired | Scan for `US-DFU`; if present, retry OTA with a known-good image. |
| Updated app reconnected but verification failed | Normal app status read failed | Reconnect manually and verify status; retry only if the device is still in bootloader or unusable. |
Escalate with app logs, device identifier, firmware filename/hash, and observed bootloader status when a known-good image repeatedly fails.
## Manual QA Checklist
- [ ] Happy path: select valid `.bin`, enter bootloader, transfer, finish, reboot, reconnect, completed.
- [ ] Image validation: invalid extension, empty file, too-small file, too-large file, invalid SP, invalid reset vector.
- [ ] UI state gating: gear ratio save and trainer assignment remain disabled during OTA.
- [ ] Queue-full/status recovery: app sends `GET_STATUS` and resumes from returned offset.
- [ ] Cancel path: cancel after `START` sends `[ABORT, session]` and shows canceled state.
- [ ] Bootloader status errors: CRC/vector/flash/metadata statuses show actionable messages.
- [ ] Reconnect timeout: no updated app reconnect produces a clear failure message.
- [ ] Regression check: after successful update, status and firmware telemetry still load normally.

View File

@ -1,62 +0,0 @@
# DFU v1 Operator Guide
This guide explains how to run and support firmware updates for Universal Shifters in `abawo_bt_app`.
## App-Side Flow (Operator)
1. Connect to the target button and open **Device Details**.
2. In **Firmware Update**, select a local `.bin` file with **Select Firmware**.
3. Confirm file metadata is shown (size, session id, CRC32), then tap **Start Update**.
4. Monitor progress:
- Phase text: `Sending START`, `Waiting for ACK`, `Transferring`, `Finalizing`
- Progress bar and bytes sent
- Last ACK sequence (`0x..`)
5. During `Finalizing`, expect a brief disconnect while the device reboots.
6. The app attempts reconnect + reachability verification automatically.
7. Success is only shown after reconnect verification passes.
Operational notes:
- Keep the phone near the button for the full transfer.
- Keep this screen open until completion.
- Gear writes and "Connect Button to Bike" are disabled during DFU by design.
## Troubleshooting Matrix
| Symptom in app | Likely cause | Operator action |
| --- | --- | --- |
| Preflight fails with MTU too low | Negotiated MTU below minimum required for 64-byte frames (`>=67`) | Reconnect BLE, retry update, and reduce RF interference/distance. |
| `Timed out waiting for initial DFU ACK after START` | ACK indications not enabled/received, or unstable link | Disconnect/reconnect button, retry update, keep device nearby. |
| `Upload stalled: no ACK progress ...` | Packet loss or weak BLE link; missing frame prevents cumulative ACK movement | Move closer, reduce interference, retry update; app will rewind and resend from last ACK while running. |
| `Received malformed ACK indication` | Corrupted/unexpected ACK payload from transport path | Reconnect and retry. If repeatable, capture logs and firmware version for investigation. |
| `Device did not perform the expected post-FINISH reset disconnect` | Device did not reset after FINISH, or disconnect event was missed | Retry update once. If repeatable, treat as firmware-side finalize/reset issue. |
| `Device did not reconnect after DFU reset` | Reboot happened but reconnect window expired | Manually reconnect in app and retry update with strong signal. |
| `post-update verification failed` or verification timeout | Device reconnected but status read failed in verification step | Reconnect and verify normal status manually; retry update only if needed. |
| Transfer reaches end but completion never succeeds; ACK does not advance after FINISH | Likely CRC mismatch (or device rejected FINISH completeness/integrity checks) | Re-export/re-download firmware `.bin`, reselect file, retry. Do not power cycle mid-transfer. |
Escalate with logs when the same firmware + device repeatedly fails after one clean retry.
## DFU v1 Limitations and Roadmap
Current v1 limitations:
- The app verifies reachability after reconnect, but **cannot strictly compare old/new firmware version** yet (no version characteristic exposed by device).
- `START.flags` supports encrypted/signed modes, but the app currently runs plain `.bin` updates and does **not** perform signed/encrypted payload validation.
Roadmap direction:
- Add device firmware version characteristic and enforce strict version progression checks in-app.
- Add signed update manifest verification before upload acceptance.
- Add encrypted payload transport mode and key management flow.
## Manual QA Checklist (Release Validation)
Run on at least one known-good button and firmware image.
- [ ] **Happy path**: Select valid `.bin` -> start -> transfer -> reboot/disconnect -> reconnect -> completed.
- [ ] **UI state gating**: During DFU, gear ratio save and "Connect Button to Bike" controls stay disabled.
- [ ] **Cancel path**: Start update, cancel mid-transfer, confirm terminal `canceled` state and safe recovery.
- [ ] **Preflight MTU failure**: Force low-MTU environment; confirm clear failure message and no transfer start.
- [ ] **Stalled ACK handling**: In degraded RF conditions, verify retries/rewind behavior and bounded failure messaging.
- [ ] **Reconnect timeout handling**: Simulate slow/no reconnect after FINISH; confirm explicit reconnect timeout error.
- [ ] **Bad file validation**: Confirm non-`.bin` and empty file selections are rejected with actionable messages.
- [ ] **Regression check**: After update attempt (success/failure), reconnect normally and verify status reads still work.
If a checklist item fails, attach app logs, device identifier, firmware filename/hash, and observed phase/error text.

View File

@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

163
ios/Podfile.lock Normal file
View File

@ -0,0 +1,163 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- fluttertoast (0.0.2):
- Flutter
- integration_test (0.0.1):
- Flutter
- nb_utils (0.0.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- Protobuf (3.29.6)
- reactive_ble_mobile (0.0.1):
- Flutter
- FlutterMacOS
- Protobuf (~> 3.5)
- SwiftProtobuf (~> 1.0)
- rust_lib_abawo_bt_app (0.0.1):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (3.49.2):
- sqlite3/common (= 3.49.2)
- sqlite3/common (3.49.2)
- sqlite3/dbstatvtab (3.49.2):
- sqlite3/common
- sqlite3/fts5 (3.49.2):
- sqlite3/common
- sqlite3/math (3.49.2):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.2):
- sqlite3/common
- sqlite3/rtree (3.49.2):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftProtobuf (1.37.0)
- SwiftyGif (5.4.5)
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- nb_utils (from `.symlinks/plugins/nb_utils/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- reactive_ble_mobile (from `.symlinks/plugins/reactive_ble_mobile/darwin`)
- rust_lib_abawo_bt_app (from `.symlinks/plugins/rust_lib_abawo_bt_app/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Protobuf
- SDWebImage
- sqlite3
- SwiftProtobuf
- SwiftyGif
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
nb_utils:
:path: ".symlinks/plugins/nb_utils/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
reactive_ble_mobile:
:path: ".symlinks/plugins/reactive_ble_mobile/darwin"
rust_lib_abawo_bt_app:
:path: ".symlinks/plugins/rust_lib_abawo_bt_app/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqlite3_flutter_libs:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
nb_utils: f4e2d76a9018f7ac0ed7823d8efd6eca639c01a5
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
Protobuf: 8d5596f7468be5400861a6bbfb2dfe9d0d1cde34
reactive_ble_mobile: ea97709e18fa6f0b0592909725d7ae7e1c595b64
rust_lib_abawo_bt_app: f36a2083dacd44f0b0ca5996bab34f55afd0316d
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
COCOAPODS: 1.16.2

View File

@ -14,6 +14,8 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B2511BBF20B78CC491AE9073 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A52089F854AA1F01FDBBA547 /* Pods_RunnerTests.framework */; };
FCF666D593496D04750CA50C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D5F03233E49EA19C83DCB638 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -40,11 +42,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
031FF26870C63B7EF4600406 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
61EA32A3FB1020B4BBC510EF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@ -55,6 +59,12 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A52089F854AA1F01FDBBA547 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AFDB6F93B60B56379902DDBB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
B72A7C54943C56D4277555D8 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
D5F03233E49EA19C83DCB638 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E8B40E3805F9A529E1ABE2C2 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
EB8109EAAA9A3AFF7160B947 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -62,6 +72,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FCF666D593496D04750CA50C /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
ED8B912F26EFF1D76D0F5A4D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B2511BBF20B78CC491AE9073 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -76,6 +95,15 @@
path = RunnerTests;
sourceTree = "<group>";
};
833620FCD9C8C6F1A1243070 /* Frameworks */ = {
isa = PBXGroup;
children = (
D5F03233E49EA19C83DCB638 /* Pods_Runner.framework */,
A52089F854AA1F01FDBBA547 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@ -94,6 +122,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
A70FEE79345AAF2DEE362C8C /* Pods */,
833620FCD9C8C6F1A1243070 /* Frameworks */,
);
sourceTree = "<group>";
};
@ -121,6 +151,19 @@
path = Runner;
sourceTree = "<group>";
};
A70FEE79345AAF2DEE362C8C /* Pods */ = {
isa = PBXGroup;
children = (
031FF26870C63B7EF4600406 /* Pods-Runner.debug.xcconfig */,
EB8109EAAA9A3AFF7160B947 /* Pods-Runner.release.xcconfig */,
AFDB6F93B60B56379902DDBB /* Pods-Runner.profile.xcconfig */,
B72A7C54943C56D4277555D8 /* Pods-RunnerTests.debug.xcconfig */,
61EA32A3FB1020B4BBC510EF /* Pods-RunnerTests.release.xcconfig */,
E8B40E3805F9A529E1ABE2C2 /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -128,8 +171,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
4D3E903FDA1944703D91310F /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
ED8B912F26EFF1D76D0F5A4D /* Frameworks */,
);
buildRules = (
);
@ -145,12 +190,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
0C46584F62EE9C26EF2B0063 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
5643EB9D69B18B7E0260A79A /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -222,6 +269,28 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0C46584F62EE9C26EF2B0063 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@ -238,6 +307,45 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
4D3E903FDA1944703D91310F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
5643EB9D69B18B7E0260A79A /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@ -346,7 +454,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -362,13 +470,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp;
PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -378,6 +487,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B72A7C54943C56D4277555D8 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@ -395,6 +505,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 61EA32A3FB1020B4BBC510EF /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@ -410,6 +521,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = E8B40E3805F9A529E1ABE2C2 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@ -472,7 +584,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -523,7 +635,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -541,13 +653,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp;
PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -563,13 +676,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp;
PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@ -26,6 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@ -54,11 +55,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24765" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24743"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
@ -14,13 +16,14 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="132" y="-48"/>
</scene>
</scenes>
</document>

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -24,6 +26,33 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to find and connect to nearby abawo devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to find and connect to nearby abawo devices.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@ -41,9 +70,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -14,6 +14,13 @@ part 'bluetooth.g.dart';
final log = Logger('BluetoothController');
final backgroundBluetoothDisconnectSuppressionCountProvider =
StateProvider<int>((ref) => 0);
final backgroundBluetoothDisconnectSuppressedProvider = Provider<bool>((ref) {
return ref.watch(backgroundBluetoothDisconnectSuppressionCountProvider) > 0;
});
@Riverpod(keepAlive: true)
FlutterReactiveBle reactiveBle(Ref ref) {
ref.keepAlive();
@ -45,6 +52,7 @@ class BluetoothController {
BluetoothController(this._ble);
static const int defaultMtu = 64;
static const Duration _rssiAverageWindow = Duration(milliseconds: 500);
final FlutterReactiveBle _ble;
@ -52,6 +60,7 @@ class BluetoothController {
StreamSubscription<DiscoveredDevice>? _scanResultsSubscription;
Timer? _scanTimeout;
final Map<String, DiscoveredDevice> _scanResultsById = {};
final RssiAverager _rssiAverager = RssiAverager(window: _rssiAverageWindow);
final _scanResultsSubject =
BehaviorSubject<List<DiscoveredDevice>>.seeded(const []);
final _isScanningSubject = BehaviorSubject<bool>.seeded(false);
@ -102,6 +111,7 @@ class BluetoothController {
_scanTimeout?.cancel();
_scanResultsById.clear();
_rssiAverager.clear();
_scanResultsSubject.add(const []);
_isScanningSubject.add(true);
@ -112,7 +122,12 @@ class BluetoothController {
requireLocationServicesEnabled: requireLocationServicesEnabled,
)
.listen((device) {
_scanResultsById[device.id] = device;
final smoothedRssi = _rssiAverager.addSample(
device.id,
device.rssi,
DateTime.now(),
);
_scanResultsById[device.id] = device.copyWith(rssi: smoothedRssi);
_scanResultsSubject
.add(_scanResultsById.values.toList(growable: false));
}, onError: (Object error, StackTrace st) {
@ -348,6 +363,27 @@ class BluetoothController {
}
}
Future<Result<void>> requestHighPerformanceConnection(
String deviceId,
) async {
if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null);
}
try {
await _ble.requestConnectionPriority(
deviceId: deviceId,
priority: ConnectionPriority.highPerformance,
);
log.info('High-performance BLE connection requested for $deviceId');
return Ok(null);
} catch (e) {
return bail(
'Error requesting high-performance BLE connection for $deviceId: $e',
);
}
}
Future<Result<void>> _requestInitialMtu(String deviceId) async {
if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null);
@ -394,3 +430,26 @@ class BluetoothController {
return Ok(null);
}
}
class RssiAverager {
RssiAverager({required this.window});
final Duration window;
final Map<String, List<(DateTime, int)>> _samplesByDeviceId = {};
int addSample(String deviceId, int rssi, DateTime timestamp) {
final cutoff = timestamp.subtract(window);
final samples = _samplesByDeviceId.putIfAbsent(deviceId, () => []);
samples
..removeWhere((sample) => sample.$1.isBefore(cutoff))
..add((timestamp, rssi));
final total = samples.fold<int>(0, (sum, sample) => sum + sample.$2);
return (total / samples.length).round();
}
void clear() {
_samplesByDeviceId.clear();
}
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
@ -57,6 +58,9 @@ class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) {
if (ref.read(backgroundBluetoothDisconnectSuppressedProvider)) {
return;
}
unawaited(_disconnectBluetoothForBackground());
}
}
@ -122,6 +126,18 @@ final _router = GoRouter(
return DeviceDetailsPage(deviceAddress: deviceAddress);
},
),
GoRoute(
path: '/bootloader_recovery_update',
builder: (context, state) {
final args = state.extra;
if (args is! BootloaderRecoveryUpdateArgs) {
return const Scaffold(
body: Center(child: Text('Missing bootloader recovery data.')),
);
}
return BootloaderRecoveryUpdatePage(args: args);
},
),
],
);

View File

@ -1,21 +1,29 @@
import 'dart:typed_data';
class DfuV1FirmwareMetadata {
const DfuV1FirmwareMetadata({
class BootloaderDfuFirmwareMetadata {
const BootloaderDfuFirmwareMetadata({
required this.totalLength,
required this.crc32,
required this.appStart,
required this.imageVersion,
required this.sessionId,
required this.flags,
required this.vectorStackPointer,
required this.vectorReset,
});
final int totalLength;
final int crc32;
final int appStart;
final int imageVersion;
final int sessionId;
final int flags;
final int vectorStackPointer;
final int vectorReset;
}
class DfuV1PreparedFirmware {
const DfuV1PreparedFirmware({
class BootloaderDfuPreparedFirmware {
const BootloaderDfuPreparedFirmware({
required this.fileName,
required this.fileBytes,
required this.metadata,
@ -25,7 +33,7 @@ class DfuV1PreparedFirmware {
final String fileName;
final String? filePath;
final Uint8List fileBytes;
final DfuV1FirmwareMetadata metadata;
final BootloaderDfuFirmwareMetadata metadata;
}
enum FirmwareSelectionFailureReason {
@ -33,6 +41,9 @@ enum FirmwareSelectionFailureReason {
malformedSelection,
unsupportedExtension,
emptyFile,
imageTooSmall,
imageTooLarge,
invalidVectorTable,
readFailed,
}
@ -52,7 +63,7 @@ class FirmwareFileSelectionResult {
this.failure,
});
final DfuV1PreparedFirmware? firmware;
final BootloaderDfuPreparedFirmware? firmware;
final FirmwareSelectionFailure? failure;
bool get isSuccess => firmware != null;
@ -60,7 +71,8 @@ class FirmwareFileSelectionResult {
bool get isCanceled =>
failure?.reason == FirmwareSelectionFailureReason.canceled;
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) {
static FirmwareFileSelectionResult success(
BootloaderDfuPreparedFirmware firmware) {
return FirmwareFileSelectionResult._(firmware: firmware);
}

View File

@ -1,11 +1,15 @@
import 'dart:convert';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
const String universalShifterControlServiceUuid =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterScanResultCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40004';
const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid =
@ -14,7 +18,7 @@ const String universalShifterDfuControlCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40008';
const String universalShifterDfuDataCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40009';
const String universalShifterDfuAckCharacteristicUuid =
const String universalShifterDfuStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
@ -25,16 +29,29 @@ const String deviceInformationServiceUuid =
const String firmwareRevisionCharacteristicUuid =
'00002a26-0000-1000-8000-00805f9b34fb';
bool isFtmsUuid(Uuid uuid) {
return uuid.expanded == Uuid.parse(ftmsServiceUuid).expanded;
}
const int universalShifterDfuOpcodeStart = 0x01;
const int universalShifterDfuOpcodeFinish = 0x02;
const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuOpcodeGetStatus = 0x04;
const int universalShifterDfuFrameSizeBytes = 64;
const int universalShifterDfuFramePayloadSizeBytes = 63;
const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9;
const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
universalShifterDfuFrameSizeBytes -
universalShifterBootloaderDfuDataHeaderSizeBytes;
const int universalShifterBootloaderDfuStatusSizeBytes = 6;
const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuPreferredMtu = 131;
const int universalShifterDfuAppStart = 0x00030000;
const int universalShifterDfuAppSlotSizeBytes = 0x0003F000;
const int universalShifterDfuMinimumImageLengthBytes = 8;
const int universalShifterDfuRamStart = 0x20000000;
const int universalShifterDfuRamEnd = 0x20010000;
const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02;
@ -46,12 +63,24 @@ const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5;
const int trainerScanProtocolVersion = 1;
const int trainerScanDeviceFlagFtmsDetected = 0x01;
const int trainerScanDeviceFlagNameComplete = 0x02;
const int trainerScanDeviceFlagScanResponseSeen = 0x04;
const int trainerScanDeviceFlagConnectable = 0x08;
enum DfuUpdateState {
idle,
starting,
waitingForAck,
enteringBootloader,
connectingBootloader,
waitingForStatus,
erasing,
transferring,
finishing,
rebooting,
verifying,
completed,
aborted,
failed,
@ -90,18 +119,20 @@ class DfuUpdateProgress {
required this.state,
required this.totalBytes,
required this.sentBytes,
required this.lastAckedSequence,
required this.expectedOffset,
required this.sessionId,
required this.flags,
this.bootloaderStatus,
this.errorMessage,
});
final DfuUpdateState state;
final int totalBytes;
final int sentBytes;
final int lastAckedSequence;
final int expectedOffset;
final int sessionId;
final DfuUpdateFlags flags;
final DfuBootloaderStatus? bootloaderStatus;
final String? errorMessage;
double get fractionComplete {
@ -119,59 +150,47 @@ class DfuUpdateProgress {
state == DfuUpdateState.failed;
}
enum DfuPreflightFailureReason {
deviceNotConnected,
wrongConnectedDevice,
mtuRequestFailed,
mtuTooLow,
enum DfuBootloaderStatusCode {
ok(0x00),
parseError(0x01),
stateError(0x02),
boundsError(0x03),
crcError(0x04),
flashError(0x05),
unsupportedError(0x06),
vectorError(0x07),
queueFull(0x08),
bootMetadataError(0x09),
unknown(-1);
const DfuBootloaderStatusCode(this.value);
final int value;
static DfuBootloaderStatusCode fromRaw(int value) {
for (final code in values) {
if (code.value == value) {
return code;
}
}
return DfuBootloaderStatusCode.unknown;
}
}
class DfuPreflightResult {
const DfuPreflightResult._({
required this.requestedMtu,
required this.requiredMtu,
required this.negotiatedMtu,
required this.failureReason,
required this.message,
class DfuBootloaderStatus {
const DfuBootloaderStatus({
required this.code,
required this.rawCode,
required this.sessionId,
required this.expectedOffset,
});
final int requestedMtu;
final int requiredMtu;
final int? negotiatedMtu;
final DfuPreflightFailureReason? failureReason;
final String? message;
final DfuBootloaderStatusCode code;
final int rawCode;
final int sessionId;
final int expectedOffset;
bool get canStart => failureReason == null;
static DfuPreflightResult ready({
required int requestedMtu,
required int negotiatedMtu,
int requiredMtu = universalShifterDfuMinimumMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: null,
message: null,
);
}
static DfuPreflightResult failed({
required int requestedMtu,
required DfuPreflightFailureReason failureReason,
required String message,
int requiredMtu = universalShifterDfuMinimumMtu,
int? negotiatedMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: failureReason,
message: message,
);
}
bool get isOk => code == DfuBootloaderStatusCode.ok;
}
class ShifterErrorInfo {
@ -238,12 +257,151 @@ enum UniversalShifterCommand {
stopScan(0x02),
connectToDevice(0x03),
disconnect(0x04),
turnOff(0x05);
turnOff(0x05),
enterDfu(0x06);
const UniversalShifterCommand(this.value);
final int value;
}
enum TrainerScanEventKind {
scanStarted(0),
device(1),
scanFinished(2),
scanCancelled(3);
const TrainerScanEventKind(this.value);
final int value;
static TrainerScanEventKind fromRaw(int value) {
for (final kind in values) {
if (kind.value == value) {
return kind;
}
}
throw FormatException('Unknown trainer scan event kind: $value');
}
}
class TrainerAddress {
const TrainerAddress({
required this.flags,
required this.bytes,
});
final int flags;
final List<int> bytes;
String get key => '${flags.toRadixString(16).padLeft(2, '0')}:'
'${bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! TrainerAddress ||
other.flags != flags ||
other.bytes.length != bytes.length) {
return false;
}
for (var i = 0; i < bytes.length; i++) {
if (other.bytes[i] != bytes[i]) {
return false;
}
}
return true;
}
@override
int get hashCode => Object.hash(flags, Object.hashAll(bytes));
}
class TrainerScanResult {
const TrainerScanResult({
required this.sequence,
required this.address,
required this.rssi,
required this.flags,
required this.name,
});
final int sequence;
final TrainerAddress address;
final int rssi;
final int flags;
final String name;
bool get ftmsDetected => (flags & trainerScanDeviceFlagFtmsDetected) != 0;
bool get nameComplete => (flags & trainerScanDeviceFlagNameComplete) != 0;
bool get scanResponseSeen =>
(flags & trainerScanDeviceFlagScanResponseSeen) != 0;
bool get connectable => (flags & trainerScanDeviceFlagConnectable) != 0;
}
class TrainerScanEvent {
const TrainerScanEvent({
required this.kind,
required this.sequence,
this.result,
});
final TrainerScanEventKind kind;
final int sequence;
final TrainerScanResult? result;
static TrainerScanEvent fromBytes(List<int> bytes) {
if (bytes.length < 3) {
throw FormatException(
'Trainer scan event payload too short: ${bytes.length}',
);
}
if (bytes[0] != trainerScanProtocolVersion) {
throw FormatException(
'Unsupported trainer scan protocol version: ${bytes[0]}',
);
}
final kind = TrainerScanEventKind.fromRaw(bytes[1]);
final sequence = bytes[2];
if (kind != TrainerScanEventKind.device) {
return TrainerScanEvent(kind: kind, sequence: sequence);
}
if (bytes.length < 13) {
throw FormatException(
'Trainer scan device payload too short: ${bytes.length}',
);
}
final nameLength = bytes[12];
if (bytes.length < 13 + nameLength) {
throw FormatException(
'Trainer scan device name length $nameLength exceeds payload length '
'${bytes.length}',
);
}
final rssiRaw = bytes[10];
final rssi = rssiRaw > 127 ? rssiRaw - 256 : rssiRaw;
final result = TrainerScanResult(
sequence: sequence,
address: TrainerAddress(
flags: bytes[3],
bytes: bytes.sublist(4, 10).toList(growable: false),
),
rssi: rssi,
flags: bytes[11],
name: utf8.decode(bytes.sublist(13, 13 + nameLength)),
);
return TrainerScanEvent(
kind: kind,
sequence: sequence,
result: result,
);
}
}
class ShifterDeviceTelemetry {
const ShifterDeviceTelemetry({
this.batteryPercent,
@ -504,16 +662,21 @@ class CentralStatus {
}
}
List<int> parseMacToLittleEndianBytes(String macAddress) {
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
if (compact.length != 12) {
throw FormatException('Invalid MAC address format: $macAddress');
List<int> encodeTrainerAddress(TrainerAddress address) {
if (address.flags < 0 || address.flags > 0xff) {
throw FormatException('Invalid trainer address flags: ${address.flags}');
}
final bytes = <int>[];
for (int i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
if (address.bytes.length != 6) {
throw FormatException(
'Invalid trainer address length: ${address.bytes.length}',
);
}
return bytes.reversed.toList(growable: false);
for (final byte in address.bytes) {
if (byte < 0 || byte > 0xff) {
throw FormatException('Invalid trainer address byte: $byte');
}
}
return [address.flags, ...address.bytes];
}
String formatMacAddressFromLittleEndian(List<int> bytes) {

View File

@ -0,0 +1,196 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class BootloaderRecoveryUpdateArgs {
const BootloaderRecoveryUpdateArgs({
required this.bootloaderDeviceId,
required this.firmware,
});
final String bootloaderDeviceId;
final BootloaderDfuPreparedFirmware firmware;
}
class BootloaderRecoveryUpdatePage extends ConsumerStatefulWidget {
const BootloaderRecoveryUpdatePage({
required this.args,
super.key,
});
final BootloaderRecoveryUpdateArgs args;
@override
ConsumerState<BootloaderRecoveryUpdatePage> createState() =>
_BootloaderRecoveryUpdatePageState();
}
class _BootloaderRecoveryUpdatePageState
extends ConsumerState<BootloaderRecoveryUpdatePage> {
FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
String? _firmwareUserMessage = 'Preparing US-DFU recovery update...';
bool _hasStarted = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_startUpdate());
}
});
}
@override
void dispose() {
unawaited(_firmwareProgressSubscription?.cancel());
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
super.dispose();
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
if (bluetooth == null) {
return null;
}
final service = FirmwareUpdateService(
verifyAfterFinish: false,
transport: ShifterFirmwareUpdateTransport(
shifterService: null,
bluetoothController: bluetooth,
buttonDeviceId: widget.args.bootloaderDeviceId,
),
);
_firmwareProgressSubscription = service.progressStream.listen((progress) {
if (!mounted) {
return;
}
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
} else if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The bootloader accepted FINISH and reset; reconnect to the device when it starts advertising again.';
} else if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
} else if (progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
}
});
});
_firmwareUpdateService = service;
return service;
}
Future<void> _startUpdate() async {
if (_hasStarted) {
return;
}
_hasStarted = true;
final updater = await _ensureFirmwareUpdateService();
if (!mounted) {
return;
}
if (updater == null) {
setState(() {
_dfuProgress = DfuUpdateProgress(
state: DfuUpdateState.failed,
totalBytes: widget.args.firmware.fileBytes.length,
sentBytes: 0,
expectedOffset: 0,
sessionId: widget.args.firmware.metadata.sessionId,
flags: DfuUpdateFlags.fromRaw(widget.args.firmware.metadata.flags),
errorMessage:
'Firmware updater is not ready. Reconnect to US-DFU and retry.',
);
_firmwareUserMessage = _dfuProgress.errorMessage;
});
return;
}
final firmware = widget.args.firmware;
final result = await updater.startUpdate(
imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId,
appStart: firmware.metadata.appStart,
imageVersion: firmware.metadata.imageVersion,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
);
if (!mounted || result.isOk()) {
return;
}
setState(() {
_firmwareUserMessage = result.unwrapErr().toString();
});
}
String _dfuPhaseText(DfuUpdateState state) {
return switch (state) {
DfuUpdateState.idle => 'Preparing recovery update',
DfuUpdateState.starting => 'Preparing update',
DfuUpdateState.enteringBootloader => 'Checking bootloader mode',
DfuUpdateState.connectingBootloader => 'Connecting to bootloader',
DfuUpdateState.waitingForStatus => 'Waiting for bootloader status',
DfuUpdateState.erasing => 'Starting destructive bootloader update',
DfuUpdateState.transferring => 'Transferring firmware image',
DfuUpdateState.finishing => 'Finalizing bootloader update',
DfuUpdateState.rebooting => 'Waiting for bootloader reset',
DfuUpdateState.verifying => 'Verifying updated app',
DfuUpdateState.completed => 'Update completed',
DfuUpdateState.aborted => 'Update canceled',
DfuUpdateState.failed => 'Update failed',
};
}
String _formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
}
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
@override
Widget build(BuildContext context) {
return FirmwareUpdateFullscreen(
progress: _dfuProgress,
selectedFirmware: widget.args.firmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
doneLabel: 'Back to devices',
failedLabel: 'Back to devices',
onDismiss: () => context.go('/devices'),
);
}
}

View File

@ -8,17 +8,19 @@ import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart';
import '../database/database.dart';
final _log = Logger('DeviceDetailsPage');
class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({
required this.deviceAddress,
@ -51,18 +53,24 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
2.77,
3.27,
];
static const List<Duration> _initialStatusRetryDelays = [
Duration(milliseconds: 500),
Duration(milliseconds: 1500),
Duration(seconds: 3),
];
bool _isExitingPage = false;
bool _hasRequestedDisconnect = false;
bool _hasShownPairingRecoveryDialog = false;
bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false;
bool _isPairingCheckRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus;
String? _pairingError;
final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false;
@ -76,12 +84,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
late final FirmwareFileSelectionService _firmwareFileSelectionService;
FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuV1PreparedFirmware? _selectedFirmware;
BootloaderDfuPreparedFirmware? _selectedFirmware;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
lastAckedSequence: 0xFF,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
@ -95,9 +103,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
switch (_dfuProgress.state) {
case DfuUpdateState.starting:
case DfuUpdateState.waitingForAck:
case DfuUpdateState.enteringBootloader:
case DfuUpdateState.connectingBootloader:
case DfuUpdateState.waitingForStatus:
case DfuUpdateState.erasing:
case DfuUpdateState.transferring:
case DfuUpdateState.finishing:
case DfuUpdateState.rebooting:
case DfuUpdateState.verifying:
return true;
case DfuUpdateState.idle:
case DfuUpdateState.completed:
@ -129,6 +142,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override
void dispose() {
_log.info(
'Disposing device details page for ${widget.deviceAddress}; '
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
);
unawaited(_disconnectOnClose());
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
@ -140,10 +157,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _disconnectOnClose() async {
if (_isFirmwareUpdateBusy) {
_log.info('Skipping disconnect on close because firmware update is busy');
return;
}
if (_hasRequestedDisconnect) {
_log.fine('Disconnect on close already requested');
return;
}
@ -164,6 +183,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final (status, connectedDeviceId) = data;
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
if (_isFirmwareUpdateBusy || _dfuProgress.state != DfuUpdateState.idle) {
_log.info(
'Connection update during firmware flow: status=$status, '
'connectedDevice=$connectedDeviceId, expected=${widget.deviceAddress}, '
'isCurrentDevice=$isCurrentDevice, dfuState=${_dfuProgress.state}',
);
}
if (isCurrentDevice && status == ConnectionStatus.connected) {
_startStatusStreamingIfNeeded();
@ -203,6 +229,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
return;
}
if (_isPairingCheckRunning) {
return;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final BluetoothController bluetooth;
if (asyncBluetooth.hasValue) {
@ -218,15 +247,42 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
buttonDeviceId: widget.deviceAddress,
);
final initialStatusResult = await service.readStatus();
setState(() {
_isPairingCheckRunning = true;
_pairingError = null;
});
var initialStatusResult = await service.readStatus();
for (final delay in _initialStatusRetryDelays) {
if (initialStatusResult.isOk() || !mounted) {
break;
}
await Future<void>.delayed(delay);
if (!mounted) {
break;
}
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
break;
}
initialStatusResult = await service.readStatus();
}
if (!mounted) {
await service.dispose();
return;
}
if (initialStatusResult.isErr()) {
final error = initialStatusResult.unwrapErr();
await service.dispose();
await _showPairingRecoveryDialog();
setState(() {
_isPairingCheckRunning = false;
_pairingError = error.toString();
_latestStatus = null;
});
return;
}
@ -242,20 +298,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
service.startStatusNotifications();
setState(() {
_shifterService = service;
_isPairingCheckRunning = false;
_pairingError = null;
});
unawaited(_loadGearRatios());
unawaited(_loadDeviceTelemetry());
}
Future<void> _showPairingRecoveryDialog() async {
if (!mounted || _hasShownPairingRecoveryDialog) {
return;
}
_hasShownPairingRecoveryDialog = true;
await showBluetoothPairingRecoveryDialog(context);
}
void _recordStatus(CentralStatus status) {
setState(() {
_latestStatus = status;
@ -283,6 +332,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
await _disposeFirmwareUpdateService();
await _shifterService?.dispose();
_shifterService = null;
_isPairingCheckRunning = false;
_isDeviceTelemetryLoading = false;
_hasLoadedDeviceTelemetry = false;
}
@ -408,20 +458,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return;
}
_isAssignTrainerDialogOpen = true;
final DiscoveredDevice? selectedBike;
try {
selectedBike = await BikeScanDialog.show(
context,
excludedDeviceId: widget.deviceAddress,
);
} finally {
_isAssignTrainerDialogOpen = false;
}
if (selectedBike == null || !mounted) {
return;
}
await _startStatusStreamingIfNeeded();
final shifter = _shifterService;
if (shifter == null) {
@ -433,8 +469,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
);
return;
}
if (!mounted) {
return;
}
final result = await shifter.connectButtonToBike(selectedBike.id);
_isAssignTrainerDialogOpen = true;
final TrainerScanResult? selectedTrainer;
try {
selectedTrainer = await BikeScanDialog.show(
context,
shifter: shifter,
);
} finally {
_isAssignTrainerDialogOpen = false;
}
if (selectedTrainer == null || !mounted) {
return;
}
final result =
await shifter.connectButtonToTrainer(selectedTrainer.address);
if (!mounted) {
return;
}
@ -449,15 +503,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')),
SnackBar(
content: Text(
selectedTrainer.name.isEmpty
? 'Sent connect request for trainer.'
: 'Sent connect request for ${selectedTrainer.name}.',
),
),
);
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
final shifter = _shifterService;
if (shifter == null) {
return null;
}
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
@ -470,7 +526,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final service = FirmwareUpdateService(
transport: ShifterFirmwareUpdateTransport(
shifterService: shifter,
shifterService: _shifterService,
bluetoothController: bluetooth,
buttonDeviceId: widget.deviceAddress,
),
@ -480,6 +536,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (!mounted) {
return;
}
_log.info(
'Firmware progress: state=${progress.state}, '
'sent=${progress.sentBytes}/${progress.totalBytes}, '
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
);
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
@ -488,7 +549,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The button rebooted and reconnected.';
'Firmware update completed. The bootloader rebooted into the updated app and verification passed.';
}
if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
@ -510,7 +571,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_firmwareUserMessage = null;
});
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
final suppressionCount = ref.read(
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
);
suppressionCount.state += 1;
final FirmwareFileSelectionResult result;
try {
result =
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
} finally {
suppressionCount.state =
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
}
if (!mounted) {
return;
}
@ -520,7 +593,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_firmwareUserMessage =
'Selected ${result.firmware!.fileName}. Ready to start update.';
'Validated ${result.firmware!.fileName}. Ready for bootloader update.';
} else if (!result.isCanceled) {
_firmwareUserMessage = result.failure?.message;
}
@ -541,7 +614,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return;
}
await _startStatusStreamingIfNeeded();
final updater = await _ensureFirmwareUpdateService();
if (!mounted) {
return;
@ -557,12 +629,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
setState(() {
_isStartingFirmwareUpdate = true;
_firmwareUserMessage =
'Starting update. Keep this screen open and stay near the button.';
'Requesting bootloader mode. Keep this screen open and stay near the button.';
});
final result = await updater.startUpdate(
imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId,
appStart: firmware.metadata.appStart,
imageVersion: firmware.metadata.imageVersion,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
);
@ -588,13 +662,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
case DfuUpdateState.idle:
return 'Idle';
case DfuUpdateState.starting:
return 'Sending START command';
case DfuUpdateState.waitingForAck:
return 'Waiting for ACK from button';
return 'Preparing update';
case DfuUpdateState.enteringBootloader:
return 'Requesting bootloader mode';
case DfuUpdateState.connectingBootloader:
return 'Connecting to bootloader';
case DfuUpdateState.waitingForStatus:
return 'Waiting for bootloader status';
case DfuUpdateState.erasing:
return 'Starting destructive bootloader update';
case DfuUpdateState.transferring:
return 'Transferring firmware frames';
return 'Transferring firmware image';
case DfuUpdateState.finishing:
return 'Finalizing update and waiting for reboot/reconnect';
return 'Finalizing bootloader update';
case DfuUpdateState.rebooting:
return 'Waiting for updated app reboot';
case DfuUpdateState.verifying:
return 'Verifying updated app';
case DfuUpdateState.completed:
return 'Update completed';
case DfuUpdateState.aborted:
@ -614,10 +698,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
String _hexByte(int value) {
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
}
Future<void> _manualReconnect() async {
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
return;
@ -664,17 +744,38 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
}
Future<void> _retryPairing() async {
if (_isPairingCheckRunning || _isFirmwareUpdateBusy) {
return;
}
await _startStatusStreamingIfNeeded();
}
Future<void> _openPairingSettings() async {
final opened = await openBluetoothSettings();
if (!mounted || opened) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open Bluetooth settings.')),
);
}
Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
_log.warning('Blocked page exit while firmware update is busy');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Firmware update is running. Keep this screen open until it completes.'),
'Firmware update is running. Do not close this screen or the app until it completes.'),
),
);
return;
}
_log.info('Exiting device details page to /devices');
await _disconnectOnClose();
if (!mounted) {
return;
@ -682,6 +783,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
context.go('/devices');
}
void _dismissFirmwareFullscreen() {
_log.info(
'Dismissing firmware fullscreen from state ${_dfuProgress.state}');
setState(() {
_dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
_firmwareUserMessage = null;
_selectedFirmware = null;
});
}
void _showStatusHistory() {
showModalBottomSheet<void>(
context: context,
@ -799,12 +917,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
: ConnectionStatus.disconnected;
final isCurrentConnected =
currentConnectionStatus == ConnectionStatus.connected;
final hasDeviceAccess =
isCurrentConnected && _shifterService != null && _latestStatus != null;
final canUseFirmwareUpdate = hasDeviceAccess;
final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected &&
canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = canUseFirmwareUpdate &&
!_isSelectingFirmware &&
!_isFirmwareUpdateBusy &&
_selectedFirmware != null;
if (_isFirmwareUpdateBusy ||
(_dfuProgress.state != DfuUpdateState.idle &&
_dfuProgress.state != DfuUpdateState.completed &&
_dfuProgress.state != DfuUpdateState.failed)) {
return FirmwareUpdateFullscreen(
progress: _dfuProgress,
selectedFirmware: _selectedFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
onDismiss: _dismissFirmwareFullscreen,
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) {
@ -834,8 +972,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
status: _latestStatus,
),
const SizedBox(height: 20),
if (isCurrentConnected) ...[
if (canUseFirmwareUpdate) ...[
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
),
const SizedBox(height: 16),
],
if (hasDeviceAccess) ...[
_StatusBanner(
status: _latestStatus,
onTap: _showStatusHistory,
@ -851,106 +1007,89 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
const SizedBox(height: 16),
_TrainerConnectionCard(
status: _latestStatus,
onAssign:
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
onAssign: _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16),
Opacity(
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
child: AbsorbPointer(
absorbing: _isFirmwareUpdateBusy,
child: GearRatioEditorCard(
ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError,
onRetry:
_isFirmwareUpdateBusy ? null : _loadGearRatios,
onSave: _saveGearRatios,
presets: const [
GearRatioPreset(
name: 'Road',
description:
'Balanced 12-speed road gearing for steady cadence steps.',
ratios: [
0.50,
0.58,
0.67,
0.76,
0.86,
0.97,
1.09,
1.22,
1.36,
1.51,
1.67,
1.84,
],
),
GearRatioPreset(
name: 'Gravel',
description:
'Slightly lower gearing with smooth jumps for mixed terrain rides.',
ratios: [
0.46,
0.54,
0.62,
0.70,
0.79,
0.89,
1.00,
1.12,
1.25,
1.40,
1.57,
1.76,
],
),
GearRatioPreset(
name: 'MTB',
description:
'Lower climbing gears with wider top-end spacing for steep trails.',
ratios: [
0.42,
0.49,
0.57,
0.66,
0.76,
0.87,
1.00,
1.15,
1.32,
1.52,
1.75,
2.02,
],
),
GearRatioPreset(
name: 'KeAnt Classic',
description:
'17-step baseline from KeAnt cross app gearing.',
ratios: _keAntRatios,
),
GearRatioEditorCard(
ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError,
onRetry: _loadGearRatios,
onSave: _saveGearRatios,
presets: const [
GearRatioPreset(
name: 'Road',
description:
'Balanced 12-speed road gearing for steady cadence steps.',
ratios: [
0.50,
0.58,
0.67,
0.76,
0.86,
0.97,
1.09,
1.22,
1.36,
1.51,
1.67,
1.84,
],
),
),
GearRatioPreset(
name: 'Gravel',
description:
'Slightly lower gearing with smooth jumps for mixed terrain rides.',
ratios: [
0.46,
0.54,
0.62,
0.70,
0.79,
0.89,
1.00,
1.12,
1.25,
1.40,
1.57,
1.76,
],
),
GearRatioPreset(
name: 'MTB',
description:
'Lower climbing gears with wider top-end spacing for steep trails.',
ratios: [
0.42,
0.49,
0.57,
0.66,
0.76,
0.87,
1.00,
1.15,
1.32,
1.52,
1.75,
2.02,
],
),
GearRatioPreset(
name: 'KeAnt Classic',
description:
'17-step baseline from KeAnt cross app gearing.',
ratios: _keAntRatios,
),
],
),
const SizedBox(height: 16),
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
] else if (isCurrentConnected) ...[
_PairingRequiredCard(
isChecking: _isPairingCheckRunning,
errorText: _pairingError,
onRetry: _retryPairing,
onOpenBluetoothSettings: _openPairingSettings,
),
] else ...[
_DisconnectedDetailCard(
@ -990,12 +1129,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.ackSequenceHex,
required this.expectedOffsetHex,
required this.onSelectFirmware,
required this.onStartUpdate,
});
final DfuV1PreparedFirmware? selectedFirmware;
final BootloaderDfuPreparedFirmware? selectedFirmware;
final DfuUpdateProgress progress;
final bool isSelecting;
final bool isStarting;
@ -1004,7 +1143,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String ackSequenceHex;
final String expectedOffsetHex;
final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate;
@ -1016,9 +1155,33 @@ class _FirmwareUpdateCard extends StatelessWidget {
bool get _showRebootExpectation {
return progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying ||
progress.state == DfuUpdateState.completed;
}
String? get _bootloaderStatusText {
final status = progress.bootloaderStatus;
if (status == null) {
return null;
}
final codeLabel = switch (status.code) {
DfuBootloaderStatusCode.ok => 'OK',
DfuBootloaderStatusCode.parseError => 'parse error',
DfuBootloaderStatusCode.stateError => 'state error',
DfuBootloaderStatusCode.boundsError => 'bounds error',
DfuBootloaderStatusCode.crcError => 'CRC error',
DfuBootloaderStatusCode.flashError => 'flash error',
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
DfuBootloaderStatusCode.vectorError => 'vector table error',
DfuBootloaderStatusCode.queueFull => 'queue full',
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
DfuBootloaderStatusCode.unknown =>
'unknown 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
};
return 'Bootloader status: $codeLabel, session ${status.sessionId}, expected offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -1043,7 +1206,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Select a firmware image, review the transfer state, and start the update when ready.',
'Select a raw app image for the single-slot bootloader. Once START is accepted, the active app slot is erased.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
@ -1102,6 +1265,11 @@ class _FirmwareUpdateCard extends StatelessWidget {
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 2),
Text(
'App start: 0x${selectedFirmware!.metadata.appStart.toRadixString(16).padLeft(8, '0').toUpperCase()} | Image version: ${selectedFirmware!.metadata.imageVersion} | Reset: 0x${selectedFirmware!.metadata.vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
],
),
@ -1117,14 +1285,21 @@ class _FirmwareUpdateCard extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'${progress.percentComplete}% • $formattedProgressBytesLast ACK $ackSequenceHex',
'${progress.percentComplete}% • $formattedProgressBytesExpected offset $expectedOffsetHex',
style: theme.textTheme.bodySmall,
),
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 4),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall,
),
],
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
'Expected behavior: after FINISH, the bootloader verifies the image, resets, and the updated app confirms itself before reconnecting.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
@ -1517,6 +1692,120 @@ class _TrainerConnectionCard extends StatelessWidget {
}
}
class _PairingRequiredCard extends StatelessWidget {
const _PairingRequiredCard({
required this.isChecking,
required this.errorText,
required this.onRetry,
required this.onOpenBluetoothSettings,
});
final bool isChecking;
final String? errorText;
final VoidCallback? onRetry;
final VoidCallback onOpenBluetoothSettings;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final trimmedError = errorText?.trim();
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12),
),
child: Icon(
Icons.bluetooth_searching_rounded,
color: colorScheme.primary,
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isChecking ? 'Checking pairing...' : 'Pair this device',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Device controls need Bluetooth pairing before they can be used. Follow any system pairing prompts, keep the button nearby, then retry.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
),
),
],
),
if (trimmedError != null && trimmedError.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Text(
'Last attempt failed: $trimmedError',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isChecking ? null : onRetry,
icon: isChecking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
label:
Text(isChecking ? 'Checking Pairing...' : 'Retry Pairing'),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isChecking ? null : onOpenBluetoothSettings,
icon: const Icon(Icons.settings_bluetooth_rounded),
label: const Text('Open Bluetooth Settings'),
),
),
],
),
),
);
}
}
class _DisconnectedDetailCard extends StatelessWidget {
const _DisconnectedDetailCard({
required this.isReconnecting,

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/database/database.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
@ -92,14 +90,6 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
switch (res) {
case Ok():
if (!Platform.isAndroid) {
controller.readCharacteristic(
device.id,
'0993826f-0ee4-4b37-9614-d13ecba4ffc2',
'0993826f-0ee4-4b37-9614-d13ecba40000',
);
}
final notifier = ref.read(nConnectedDevicesProvider.notifier);
final name = device.name.isNotEmpty ? device.name : 'Unknown Device';
final deviceCompanion = ConnectedDevicesCompanion(

View File

@ -1,18 +1,145 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
import 'package:abawo_bt_app/database/database.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice, ScanMode, Uuid;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class DevicesTabPage extends ConsumerWidget {
class DevicesTabPage extends ConsumerStatefulWidget {
const DevicesTabPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DevicesTabPage> createState() => _DevicesTabPageState();
}
class _DevicesTabPageState extends ConsumerState<DevicesTabPage> {
static const Duration _bootloaderScanTimeout = Duration(seconds: 10);
StreamSubscription<List<DiscoveredDevice>>? _scanSubscription;
DiscoveredDevice? _dfuDevice;
bool _isBootloaderScanStarting = false;
@override
void initState() {
super.initState();
unawaited(_startBootloaderBackgroundScan());
}
@override
void dispose() {
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
unawaited(_scanSubscription?.cancel());
unawaited(_stopBootloaderScan(bluetooth));
super.dispose();
}
Future<void> _startBootloaderBackgroundScan() async {
if (_isBootloaderScanStarting || _scanSubscription != null) {
return;
}
_isBootloaderScanStarting = true;
try {
final bluetooth = await ref.read(bluetoothProvider.future);
if (!mounted) {
return;
}
final scanResult = await bluetooth.startScan(
timeout: _bootloaderScanTimeout,
scanMode: ScanMode.lowLatency,
);
if (scanResult.isErr()) {
return;
}
_updateBootloaderDevice(bluetooth.scanResults);
_scanSubscription = bluetooth.scanResultsStream.listen(
_updateBootloaderDevice,
);
} finally {
_isBootloaderScanStarting = false;
}
}
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
await _scanSubscription?.cancel();
_scanSubscription = null;
await bluetooth?.stopScan();
}
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
(device) => device != null && _isBootloaderAdvertisement(device),
orElse: () => null,
);
if (!mounted || dfuDevice == null || dfuDevice.id == _dfuDevice?.id) {
return;
}
setState(() {
_dfuDevice = dfuDevice;
});
}
bool _isBootloaderAdvertisement(DiscoveredDevice device) {
final name = device.name.trim();
if (name == 'US-DFU' || name == 'UniversalShifters DFU') {
return true;
}
return name.toLowerCase().contains('dfu') &&
device.serviceUuids.any(
(uuid) =>
uuid.expanded ==
Uuid.parse(universalShifterControlServiceUuid).expanded,
);
}
Future<void> _openBootloaderRecovery() async {
final device = _dfuDevice;
if (device == null) {
return;
}
final firmware =
await Navigator.of(context).push<BootloaderDfuPreparedFirmware>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _BootloaderRecoverySetupPage(device: device),
),
);
if (!mounted || firmware == null) {
return;
}
await _stopBootloaderScan();
if (!mounted) {
return;
}
context.push(
'/bootloader_recovery_update',
extra: BootloaderRecoveryUpdateArgs(
bootloaderDeviceId: device.id,
firmware: firmware,
),
);
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(nConnectedDevicesProvider);
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final dfuDevice = _dfuDevice;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
@ -50,6 +177,13 @@ class DevicesTabPage extends ConsumerWidget {
],
),
const SizedBox(height: 20),
if (dfuDevice != null) ...[
_BootloaderRecoveryCard(
device: dfuDevice,
onStartRecovery: _openBootloaderRecovery,
),
const SizedBox(height: 20),
],
devicesAsync.when(
loading: () => const _LoadingCard(),
error: (error, _) => _MessageCard(
@ -86,6 +220,259 @@ class DevicesTabPage extends ConsumerWidget {
}
}
class _BootloaderRecoveryCard extends StatelessWidget {
const _BootloaderRecoveryCard({
required this.device,
required this.onStartRecovery,
});
final DiscoveredDevice device;
final VoidCallback onStartRecovery;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.errorContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.system_update_alt, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'US-DFU Device Detected',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
const Text(
'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?',
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
device.name.isEmpty ? device.id : '${device.name} - ${device.id}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onStartRecovery,
icon: const Icon(Icons.build_circle_outlined),
label: const Text('Start Recovery'),
),
],
),
),
);
}
}
class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget {
const _BootloaderRecoverySetupPage({required this.device});
final DiscoveredDevice device;
@override
ConsumerState<_BootloaderRecoverySetupPage> createState() =>
_BootloaderRecoverySetupPageState();
}
class _BootloaderRecoverySetupPageState
extends ConsumerState<_BootloaderRecoverySetupPage> {
final FirmwareFileSelectionService _firmwareFileSelectionService =
FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker());
BootloaderDfuPreparedFirmware? _selectedFirmware;
bool _isSelectingFirmware = false;
String? _message;
Future<void> _selectFirmwareFile() async {
if (_isSelectingFirmware) {
return;
}
setState(() {
_isSelectingFirmware = true;
_message = null;
});
final suppressionCount = ref.read(
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
);
suppressionCount.state += 1;
final FirmwareFileSelectionResult result;
try {
result =
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
} finally {
suppressionCount.state =
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
}
if (!mounted) {
return;
}
setState(() {
_isSelectingFirmware = false;
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_message =
'Validated ${result.firmware!.fileName}. Ready to start recovery.';
} else if (!result.isCanceled) {
_message = result.failure?.message;
}
});
}
void _startRecovery() {
final firmware = _selectedFirmware;
if (firmware == null) {
setState(() {
_message = 'Select a firmware .bin file before starting recovery.';
});
return;
}
Navigator.of(context).pop(firmware);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final selectedFirmware = _selectedFirmware;
return Scaffold(
appBar: AppBar(
title: const Text('US-DFU Recovery'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.system_update_alt_rounded,
color: colorScheme.primary),
const SizedBox(width: 10),
Text(
'Recover Firmware Update',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
Text(
'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 14),
Text(
widget.device.name.isEmpty
? widget.device.id
: '${widget.device.name} - ${widget.device.id}',
style: theme.textTheme.bodySmall,
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedFirmware == null
? 'Selected file: none'
: 'Selected file: ${selectedFirmware.fileName}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 6),
Text(
'Size: ${selectedFirmware.fileBytes.length} bytes | Session: ${selectedFirmware.metadata.sessionId} | CRC32: 0x${selectedFirmware.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed:
_isSelectingFirmware ? null : _selectFirmwareFile,
icon: _isSelectingFirmware
? const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: const Text('Select Firmware'),
),
FilledButton.icon(
onPressed:
selectedFirmware == null ? null : _startRecovery,
icon: const Icon(Icons.system_update_alt),
label: const Text('Start Update'),
),
],
),
if (_message != null && _message!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(_message!),
],
],
),
),
),
],
),
),
);
}
}
class _SavedDevicesList extends ConsumerStatefulWidget {
const _SavedDevicesList();

View File

@ -30,7 +30,6 @@ class HomePage extends StatelessWidget {
style: TextStyle(fontSize: 20),
),
const SizedBox(height: 20),
// Devices Section
Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Column(
@ -84,7 +83,7 @@ class DevicesList extends ConsumerStatefulWidget {
}
class _DevicesListState extends ConsumerState<DevicesList> {
String? _connectingDeviceId; // ID of device currently being connected
String? _connectingDeviceId;
Future<void> _removeDevice(ConnectedDevice device) async {
final shouldRemove = await showDialog<bool>(
@ -197,10 +196,10 @@ class _DevicesListState extends ConsumerState<DevicesList> {
context.go('/device/${device.deviceAddress}');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text(
'Connection failed. Is the device turned on and in range?'),
duration: const Duration(seconds: 3),
duration: Duration(seconds: 3),
),
);
}

View File

@ -2,111 +2,169 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart';
const int _startPayloadLength = 11;
const int _startPayloadLength = 19;
class DfuStartPayload {
const DfuStartPayload({
class BootloaderDfuStartPayload {
const BootloaderDfuStartPayload({
required this.totalLength,
required this.imageCrc32,
this.appStart = universalShifterDfuAppStart,
this.imageVersion = 0,
required this.sessionId,
required this.flags,
});
final int totalLength;
final int imageCrc32;
final int appStart;
final int imageVersion;
final int sessionId;
final int flags;
}
class DfuDataFrame {
const DfuDataFrame({
required this.sequence,
class BootloaderDfuDataFrame {
const BootloaderDfuDataFrame({
required this.sessionId,
required this.offset,
required this.payloadLength,
required this.bytes,
});
final int sequence;
final int sessionId;
final int offset;
final int payloadLength;
final Uint8List bytes;
}
class DfuProtocol {
const DfuProtocol._();
class BootloaderDfuProtocol {
const BootloaderDfuProtocol._();
static Uint8List encodeStartPayload(DfuStartPayload payload) {
static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320;
static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) {
final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, Endian.little);
data.setUint8(9, payload.sessionId);
data.setUint8(10, payload.flags);
data.setUint32(9, payload.appStart, Endian.little);
data.setUint32(13, payload.imageVersion, Endian.little);
data.setUint8(17, payload.sessionId & 0xFF);
data.setUint8(18, payload.flags & 0xFF);
return data.buffer.asUint8List();
}
static Uint8List encodeFinishPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
static Uint8List encodeFinishPayload(int sessionId) {
return Uint8List.fromList([
universalShifterDfuOpcodeFinish,
sessionId & 0xFF,
]);
}
static Uint8List encodeAbortPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
static Uint8List encodeAbortPayload(int sessionId) {
return Uint8List.fromList([
universalShifterDfuOpcodeAbort,
sessionId & 0xFF,
]);
}
static List<DfuDataFrame> buildDataFrames(
List<int> imageBytes, {
int startSequence = 0,
static Uint8List encodeGetStatusPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeGetStatus]);
}
static BootloaderDfuDataFrame buildDataFrame({
required List<int> imageBytes,
required int sessionId,
required int offset,
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
}) {
final frames = <DfuDataFrame>[];
var seq = _asU8(startSequence);
var offset = 0;
while (offset < imageBytes.length) {
final remaining = imageBytes.length - offset;
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
? remaining
: universalShifterDfuFramePayloadSizeBytes;
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
frame[0] = seq;
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
frames.add(
DfuDataFrame(
sequence: seq,
offset: offset,
payloadLength: chunkLength,
bytes: frame,
),
if (offset < 0 || offset >= imageBytes.length) {
throw RangeError.range(offset, 0, imageBytes.length - 1, 'offset');
}
if (payloadSize <= 0 ||
payloadSize > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
throw RangeError.range(
payloadSize,
1,
universalShifterBootloaderDfuMaxPayloadSizeBytes,
'payloadSize',
);
offset += chunkLength;
seq = nextSequence(seq);
}
final remaining = imageBytes.length - offset;
final payloadLength = remaining < payloadSize ? remaining : payloadSize;
final payloadEnd = offset + payloadLength;
final payload = imageBytes.sublist(offset, payloadEnd);
final frame = Uint8List(
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
);
frame[0] = sessionId & 0xFF;
final data = ByteData.view(frame.buffer);
data.setUint32(1, offset, Endian.little);
data.setUint32(5, crc32(payload), Endian.little);
frame.setRange(
universalShifterBootloaderDfuDataHeaderSizeBytes,
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
payload,
);
return BootloaderDfuDataFrame(
sessionId: sessionId & 0xFF,
offset: offset,
payloadLength: payloadLength,
bytes: frame,
);
}
static List<BootloaderDfuDataFrame> buildDataFrames({
required List<int> imageBytes,
required int sessionId,
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
}) {
final frames = <BootloaderDfuDataFrame>[];
var offset = 0;
while (offset < imageBytes.length) {
final frame = buildDataFrame(
imageBytes: imageBytes,
sessionId: sessionId,
offset: offset,
payloadSize: payloadSize,
);
frames.add(frame);
offset += frame.payloadLength;
}
return frames;
}
static int nextSequence(int sequence) {
return _asU8(sequence + 1);
}
static int rewindSequenceFromAck(int acknowledgedSequence) {
return nextSequence(acknowledgedSequence);
}
static int sequenceDistance(int from, int to) {
return _asU8(to - from);
}
static int parseAckPayload(List<int> payload) {
if (payload.length != 1) {
throw const FormatException('ACK payload must be exactly 1 byte.');
static int maxPayloadSizeForMtu(int negotiatedMtu) {
final writePayloadBytes =
negotiatedMtu - universalShifterAttWriteOverheadBytes;
final availablePayload =
writePayloadBytes - universalShifterBootloaderDfuDataHeaderSizeBytes;
if (availablePayload <= 0) {
return 0;
}
return _asU8(payload.first);
if (availablePayload > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
return universalShifterBootloaderDfuMaxPayloadSizeBytes;
}
return availablePayload;
}
static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320;
static DfuBootloaderStatus parseStatusPayload(List<int> payload) {
if (payload.length != universalShifterBootloaderDfuStatusSizeBytes) {
throw const FormatException(
'DFU status payload must be exactly 6 bytes.');
}
final data = ByteData.sublistView(Uint8List.fromList(payload));
final rawCode = data.getUint8(0);
return DfuBootloaderStatus(
code: DfuBootloaderStatusCode.fromRaw(rawCode),
rawCode: rawCode,
sessionId: data.getUint8(1),
expectedOffset: data.getUint32(2, Endian.little),
);
}
static int crc32Update(int crc, List<int> bytes) {
var next = crc & 0xFFFFFFFF;
@ -130,8 +188,4 @@ class DfuProtocol {
static int crc32(List<int> bytes) {
return crc32Finalize(crc32Update(crc32Initial, bytes));
}
static int _asU8(int value) {
return value & 0xFF;
}
}

View File

@ -75,7 +75,7 @@ class FirmwareFileSelectionService {
final FirmwareFilePicker _filePicker;
final SessionIdGenerator _sessionIdGenerator;
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
Future<FirmwareFileSelectionResult> selectAndPrepareBootloaderDfu() async {
final FirmwarePickerSelection? selection;
try {
selection = await _filePicker.pickFirmwareFile();
@ -127,15 +127,32 @@ class FirmwareFileSelectionService {
);
}
final metadata = DfuV1FirmwareMetadata(
final imageValidationFailure = _validateBootloaderImage(
selection.fileBytes,
fileName,
);
if (imageValidationFailure != null) {
return FirmwareFileSelectionResult.failed(imageValidationFailure);
}
final vectorStackPointer = _readLeU32(selection.fileBytes, 0);
final vectorReset = _readLeU32(selection.fileBytes, 4);
final sessionId = _normalizeSessionId(_sessionIdGenerator());
final metadata = BootloaderDfuFirmwareMetadata(
totalLength: selection.fileBytes.length,
crc32: DfuProtocol.crc32(selection.fileBytes),
sessionId: _sessionIdGenerator() & 0xFF,
crc32: BootloaderDfuProtocol.crc32(selection.fileBytes),
appStart: universalShifterDfuAppStart,
imageVersion: 0,
sessionId: sessionId,
flags: universalShifterDfuFlagNone,
vectorStackPointer: vectorStackPointer,
vectorReset: vectorReset,
);
return FirmwareFileSelectionResult.success(
DfuV1PreparedFirmware(
BootloaderDfuPreparedFirmware(
fileName: fileName,
filePath: selection.filePath,
fileBytes: selection.fileBytes,
@ -148,7 +165,64 @@ class FirmwareFileSelectionService {
return fileName.toLowerCase().endsWith('.bin');
}
FirmwareSelectionFailure? _validateBootloaderImage(
Uint8List imageBytes,
String fileName,
) {
if (imageBytes.length < universalShifterDfuMinimumImageLengthBytes) {
return FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.imageTooSmall,
message:
'Selected firmware file "$fileName" is too small for a bootloader application image. Need at least $universalShifterDfuMinimumImageLengthBytes bytes.',
);
}
if (imageBytes.length > universalShifterDfuAppSlotSizeBytes) {
return FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.imageTooLarge,
message:
'Selected firmware file "$fileName" is ${imageBytes.length} bytes, which exceeds the $universalShifterDfuAppSlotSizeBytes byte application slot.',
);
}
final vectorStackPointer = _readLeU32(imageBytes, 0);
final vectorReset = _readLeU32(imageBytes, 4);
final resetAddress = vectorReset & ~0x1;
final imageEnd = universalShifterDfuAppStart + imageBytes.length;
if (vectorStackPointer < universalShifterDfuRamStart ||
vectorStackPointer > universalShifterDfuRamEnd ||
(vectorStackPointer & 0x3) != 0) {
return FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.invalidVectorTable,
message:
'Selected firmware file "$fileName" has an invalid initial stack pointer (0x${vectorStackPointer.toRadixString(16).padLeft(8, '0').toUpperCase()}).',
);
}
if ((vectorReset & 0x1) == 0 ||
resetAddress < universalShifterDfuAppStart + 8 ||
resetAddress >= imageEnd) {
return FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.invalidVectorTable,
message:
'Selected firmware file "$fileName" has an invalid reset vector (0x${vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}). Ensure the image starts at application address 0x${universalShifterDfuAppStart.toRadixString(16).padLeft(8, '0').toUpperCase()}.',
);
}
return null;
}
int _readLeU32(Uint8List bytes, int offset) {
return ByteData.sublistView(bytes).getUint32(offset, Endian.little);
}
static int _normalizeSessionId(int sessionId) {
final normalized = sessionId & 0xFF;
return normalized == 0 ? 1 : normalized;
}
static int _randomSessionId() {
return Random.secure().nextInt(256);
return Random.secure().nextInt(255) + 1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,29 +10,15 @@ final _log = Logger('ShifterService');
class ShifterService {
ShifterService({
BluetoothController? bluetooth,
required BluetoothController bluetooth,
required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
}) : _bluetooth = bluetooth;
final BluetoothController? _bluetooth;
final BluetoothController _bluetooth;
final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
return _bluetooth;
}
final StreamController<CentralStatus> _statusController =
@ -46,9 +32,11 @@ class ShifterService {
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
static const int _gearRatioWriteMtu = 64;
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
Future<Result<void>> writeConnectToTrainerAddress(
TrainerAddress trainerAddress,
) async {
try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
final payload = encodeTrainerAddress(trainerAddress);
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
@ -56,12 +44,30 @@ class ShifterService {
payload,
);
} on FormatException catch (e) {
return bail('Could not parse bike address "$bikeDeviceId": $e');
return bail('Could not encode trainer address: $e');
} catch (e) {
return bail('Failed writing connect address: $e');
return bail('Failed writing trainer address: $e');
}
}
Stream<TrainerScanEvent> subscribeToTrainerScanResults() {
return _requireBluetooth
.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterScanResultCharacteristicUuid,
)
.map(TrainerScanEvent.fromBytes);
}
Future<Result<void>> startTrainerScan() {
return writeCommand(UniversalShifterCommand.startScan);
}
Future<Result<void>> stopTrainerScan() {
return writeCommand(UniversalShifterCommand.stopScan);
}
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
@ -71,8 +77,10 @@ class ShifterService {
);
}
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async {
final addrRes = await writeConnectToAddress(bikeDeviceId);
Future<Result<void>> connectButtonToTrainer(
TrainerAddress trainerAddress,
) async {
final addrRes = await writeConnectToTrainerAddress(trainerAddress);
if (addrRes.isErr()) {
return addrRes;
}
@ -221,72 +229,6 @@ class ShifterService {
);
}
Future<Result<DfuPreflightResult>> runDfuPreflight({
int requestedMtu = universalShifterDfuPreferredMtu,
}) async {
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
final connectionStatus = currentConnection.$1;
final connectedDeviceId = currentConnection.$2;
if (connectionStatus != ConnectionStatus.connected ||
connectedDeviceId == null) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.deviceNotConnected,
message:
'No button connection is active. Connect the target button, then retry the firmware update.',
),
);
}
if (connectedDeviceId != buttonDeviceId) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
message:
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
),
);
}
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
buttonDeviceId,
mtu: requestedMtu,
);
if (mtuResult.isErr()) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
message:
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
),
);
}
final negotiatedMtu = mtuResult.unwrap();
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
failureReason: DfuPreflightFailureReason.mtuTooLow,
message:
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
),
);
}
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
),
);
}
void startStatusNotifications() {
if (_statusSubscription != null) {
return;
@ -347,32 +289,6 @@ class ShifterService {
}
}
abstract interface class DfuPreflightBluetoothAdapter {
(ConnectionStatus, String?) get currentConnectionState;
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
});
}
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
const _BluetoothDfuPreflightAdapter(this._bluetooth);
final BluetoothController _bluetooth;
@override
(ConnectionStatus, String?) get currentConnectionState =>
_bluetooth.currentConnectionState;
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) {
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
}
}
class GearRatiosData {
const GearRatiosData({
required this.ratios,

View File

@ -1,31 +1,35 @@
import 'dart:io';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const MethodChannel _settingsChannel = MethodChannel('abawo/settings');
Future<bool> openBluetoothSettings() async {
if (!Platform.isAndroid) {
return false;
}
try {
return await _settingsChannel.invokeMethod<bool>('openBluetoothSettings') ??
false;
} on PlatformException {
if (Platform.isAndroid) {
await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
} else if (Platform.isIOS) {
await AppSettings.openAppSettings();
} else {
return false;
}
return true;
} catch (_) {
return false;
}
}
Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
final isIOS = Platform.isIOS;
final content = isIOS
? 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nGo to Settings, then Bluetooth, then forget this device. After that, come back and connect again.\n\nOr press Open Settings below. From the app settings page, press Back twice to reach Bluetooth settings, then forget this device.'
: 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.';
final settingsButtonLabel = isIOS ? 'Open Settings' : 'Open Bluetooth settings';
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bluetooth pairing may be broken'),
content: const Text(
'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.',
),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
@ -36,7 +40,7 @@ Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
Navigator.of(context).pop();
await openBluetoothSettings();
},
child: const Text('Open Bluetooth settings'),
child: Text(settingsButtonLabel),
),
],
),

View File

@ -1,39 +1,39 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BikeScanDialog extends ConsumerStatefulWidget {
class BikeScanDialog extends StatefulWidget {
const BikeScanDialog({
required this.excludedDeviceId,
required this.shifter,
super.key,
});
final String excludedDeviceId;
final ShifterService shifter;
static Future<DiscoveredDevice?> show(
static Future<TrainerScanResult?> show(
BuildContext context, {
required String excludedDeviceId,
required ShifterService shifter,
}) {
return showDialog<DiscoveredDevice>(
return showDialog<TrainerScanResult>(
context: context,
barrierDismissible: true,
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
builder: (_) => BikeScanDialog(shifter: shifter),
);
}
@override
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
State<BikeScanDialog> createState() => _BikeScanDialogState();
}
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
bool _showAll = false;
class _BikeScanDialogState extends State<BikeScanDialog> {
bool _showOnlyFtms = true;
bool _isStartingScan = true;
bool _isScanning = false;
String? _scanError;
BluetoothController? _controller;
final Map<String, TrainerScanResult> _resultsByAddress = {};
StreamSubscription<TrainerScanEvent>? _scanSubscription;
@override
void initState() {
@ -42,16 +42,39 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
}
Future<void> _startScan() async {
await _scanSubscription?.cancel();
if (_isScanning) {
await widget.shifter.stopTrainerScan();
}
setState(() {
_isStartingScan = true;
_isScanning = false;
_scanError = null;
_resultsByAddress.clear();
});
try {
final controller = await ref.read(bluetoothProvider.future);
_controller = controller;
await controller.stopScan();
await controller.startScan();
_scanSubscription = widget.shifter.subscribeToTrainerScanResults().listen(
_handleScanEvent,
onError: (Object error) {
if (!mounted) {
return;
}
setState(() {
_scanError = error.toString();
_isStartingScan = false;
_isScanning = false;
});
},
);
final startResult = await widget.shifter.startTrainerScan();
if (startResult.isErr()) {
_scanError = startResult.unwrapErr().toString();
} else {
_isScanning = true;
}
} catch (error) {
_scanError = error.toString();
} finally {
@ -63,15 +86,43 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
}
}
void _handleScanEvent(TrainerScanEvent event) {
if (!mounted) {
return;
}
setState(() {
_isStartingScan = false;
switch (event.kind) {
case TrainerScanEventKind.scanStarted:
_isScanning = true;
_scanError = null;
break;
case TrainerScanEventKind.device:
final result = event.result;
if (result != null) {
_resultsByAddress[result.address.key] = result;
}
break;
case TrainerScanEventKind.scanFinished:
case TrainerScanEventKind.scanCancelled:
_isScanning = false;
break;
}
});
}
@override
void dispose() {
_controller?.stopScan();
_scanSubscription?.cancel();
if (_isScanning) {
unawaited(widget.shifter.stopTrainerScan());
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final btAsync = ref.watch(bluetoothProvider);
final size = MediaQuery.of(context).size;
final dialogWidth = size.width < 640 ? size.width - 24 : 560.0;
final dialogHeight = size.height < 780 ? size.height * 0.78 : 560.0;
@ -83,213 +134,85 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
child: SizedBox(
width: dialogWidth,
height: dialogHeight,
child: btAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
data: (controller) {
_controller ??= controller;
return Column(
children: [
_DialogHeader(
showAll: _showAll,
isScanning: _isStartingScan,
onChanged: (value) {
setState(() {
_showAll = value;
});
},
onRescan: _startScan,
),
Expanded(
child: _scanError != null
? _ScanMessage(
message: 'Could not start trainer scan: $_scanError',
action: TextButton.icon(
onPressed: _startScan,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
)
: StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: controller.scanResults,
builder: (context, snapshot) {
if (_isStartingScan &&
(snapshot.data == null ||
snapshot.data!.isEmpty)) {
return const Center(
child: CircularProgressIndicator());
}
final devices =
_filteredDevices(snapshot.data ?? const []);
if (devices.isEmpty) {
return const _ScanMessage(
message:
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final device = devices[index];
final isFtms = device.serviceUuids
.contains(Uuid.parse(ftmsServiceUuid));
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () =>
Navigator.of(context).pop(device),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.55),
),
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.12),
),
child: Icon(
Icons.pedal_bike_rounded,
color: Theme.of(context)
.colorScheme
.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
device.name.isEmpty
? 'Unknown Device'
: device.name,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
isFtms
? 'FTMS'
: 'Nearby trainer',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
device.id,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(
alpha: 0.62),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
_RssiBadge(rssi: device.rssi),
const SizedBox(height: 12),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.55),
),
],
),
],
),
),
),
);
},
);
},
),
),
],
);
},
child: Column(
children: [
_DialogHeader(
showOnlyFtms: _showOnlyFtms,
isScanning: _isStartingScan || _isScanning,
onChanged: (value) {
setState(() {
_showOnlyFtms = value;
});
},
onRescan: _startScan,
),
Expanded(child: _buildBody(context)),
],
),
),
);
}
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
final ftmsUuid = Uuid.parse(ftmsServiceUuid);
return devices.where((device) {
if (device.id == widget.excludedDeviceId) {
return false;
}
if (_showAll) {
return true;
}
return device.serviceUuids.contains(ftmsUuid);
Widget _buildBody(BuildContext context) {
if (_scanError != null) {
return _ScanMessage(
message: 'Could not start shifter trainer scan: $_scanError',
action: TextButton.icon(
onPressed: _startScan,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
);
}
if (_isStartingScan && _resultsByAddress.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final devices = _filteredDevices();
if (devices.isEmpty) {
return _ScanMessage(
message: _isScanning
? 'The shifter is scanning. Nearby trainers will appear here as soon as the shifter reports them.'
: 'No matching trainers were reported by the shifter. Rescan with the trainer nearby and awake.',
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) => _TrainerScanResultTile(
result: devices[index],
onTap: () => Navigator.of(context).pop(devices[index]),
),
);
}
List<TrainerScanResult> _filteredDevices() {
final devices = _resultsByAddress.values.where((device) {
return !_showOnlyFtms || device.ftmsDetected;
}).toList(growable: false);
devices.sort((a, b) {
final ftmsCompare = (b.ftmsDetected ? 1 : 0) - (a.ftmsDetected ? 1 : 0);
if (ftmsCompare != 0) {
return ftmsCompare;
}
return b.rssi.compareTo(a.rssi);
});
return devices;
}
}
class _DialogHeader extends StatelessWidget {
const _DialogHeader({
required this.showAll,
required this.showOnlyFtms,
required this.isScanning,
required this.onChanged,
required this.onRescan,
});
final bool showAll;
final bool showOnlyFtms;
final bool isScanning;
final ValueChanged<bool> onChanged;
final VoidCallback onRescan;
@ -316,7 +239,7 @@ class _DialogHeader extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'Tap a nearby trainer to assign it to the connected shifter.',
'The shifter scans nearby trainers. Tap one to assign it.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
@ -341,13 +264,13 @@ class _DialogHeader extends StatelessWidget {
child: Row(
children: [
Text(
'Show All',
'FTMS only',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Switch(value: showAll, onChanged: onChanged),
Switch(value: showOnlyFtms, onChanged: onChanged),
],
),
),
@ -376,6 +299,109 @@ class _DialogHeader extends StatelessWidget {
}
}
class _TrainerScanResultTile extends StatelessWidget {
const _TrainerScanResultTile({
required this.result,
required this.onTap,
});
final TrainerScanResult result;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final name = result.name.isEmpty ? 'Unknown Trainer' : result.name;
final typeLabel = result.ftmsDetected ? 'FTMS trainer' : 'Nearby device';
final addressText = _formatTrainerAddress(result.address);
return Material(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
),
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12),
),
child: Icon(
Icons.pedal_bike_rounded,
color: colorScheme.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
typeLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
addressText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.62),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_RssiBadge(rssi: result.rssi),
const SizedBox(height: 12),
Icon(
Icons.chevron_right_rounded,
color: colorScheme.onSurface.withValues(alpha: 0.55),
),
],
),
],
),
),
),
);
}
String _formatTrainerAddress(TrainerAddress address) {
final flags = address.flags.toRadixString(16).padLeft(2, '0');
return '${formatMacAddressFromLittleEndian(address.bytes)} · flags 0x$flags';
}
}
class _ScanMessage extends StatelessWidget {
const _ScanMessage({
required this.message,

View File

@ -0,0 +1,247 @@
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter/material.dart';
class FirmwareUpdateFullscreen extends StatelessWidget {
const FirmwareUpdateFullscreen({
super.key,
required this.progress,
required this.selectedFirmware,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.expectedOffsetHex,
required this.onDismiss,
this.doneLabel = 'Done',
this.failedLabel = 'Back to device',
});
final DfuUpdateProgress progress;
final BootloaderDfuPreparedFirmware? selectedFirmware;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String expectedOffsetHex;
final VoidCallback onDismiss;
final String doneLabel;
final String failedLabel;
bool get _isTerminal =>
progress.state == DfuUpdateState.completed ||
progress.state == DfuUpdateState.failed;
bool get _isRunning => !_isTerminal && progress.state != DfuUpdateState.idle;
String? get _bootloaderStatusText {
final status = progress.bootloaderStatus;
if (status == null) {
return null;
}
final codeLabel = switch (status.code) {
DfuBootloaderStatusCode.ok => 'OK',
DfuBootloaderStatusCode.parseError => 'parse error',
DfuBootloaderStatusCode.stateError => 'state error',
DfuBootloaderStatusCode.boundsError => 'bounds error',
DfuBootloaderStatusCode.crcError => 'CRC error',
DfuBootloaderStatusCode.flashError => 'flash error',
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
DfuBootloaderStatusCode.vectorError => 'vector table error',
DfuBootloaderStatusCode.queueFull => 'queue full',
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
DfuBootloaderStatusCode.unknown =>
'unknown 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
};
return '$codeLabel • session ${status.sessionId} • offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFailed = progress.state == DfuUpdateState.failed;
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
if (_isRunning)
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
color: colorScheme.errorContainer,
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
color: colorScheme.onErrorContainer, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Do not close the app, lock the phone, or move away from the button.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
_isTerminal
? (isFailed
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded)
: Icons.system_update_alt_rounded,
size: 56,
color: _isTerminal
? (isFailed
? colorScheme.error
: colorScheme.primary)
: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
_isTerminal
? (isFailed ? 'Update failed' : 'Update completed')
: 'Updating firmware',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
phaseText,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 12),
Text(
'${selectedFirmware!.fileName}${_formatBytes(selectedFirmware!.fileBytes.length)}',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
if (_isRunning) ...[
LinearProgressIndicator(
value: progress.totalBytes > 0
? progress.fractionComplete
: null,
minHeight: 12,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 12),
Text(
'${progress.percentComplete}% • $formattedProgressBytes',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
if (progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withValues(alpha: 0.36),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bootloader is verifying, resetting, and booting the new app. Keep the screen open.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 12),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.56),
),
),
],
if (statusText != null &&
statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Text(
statusText!,
style: theme.textTheme.bodyMedium?.copyWith(
color: isFailed
? colorScheme.onErrorContainer
: null,
),
),
),
],
if (_isTerminal) ...[
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onDismiss,
icon: Icon(isFailed
? Icons.arrow_back_rounded
: Icons.check_rounded),
label: Text(isFailed ? failedLabel : doneLabel),
),
),
],
],
),
),
),
],
),
),
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
}

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import app_settings
import connectivity_plus
import file_picker
import flutter_blue_plus_darwin
@ -15,6 +16,7 @@ import shared_preferences_foundation
import sqlite3_flutter_libs
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))

42
macos/Podfile Normal file
View File

@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View File

@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
app_settings:
dependency: "direct main"
description:
name: app_settings
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
args:
dependency: transitive
description:

View File

@ -54,6 +54,7 @@ dependencies:
flutter_reactive_ble: ^5.4.0
nb_utils: ^7.2.0
file_picker: ^8.1.7
app_settings: ^7.0.0
dev_dependencies:
flutter_test:

View File

@ -0,0 +1,70 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('RssiAverager', () {
test('averages samples within the configured window', () {
final averager = RssiAverager(window: const Duration(milliseconds: 500));
final startedAt = DateTime(2026);
expect(averager.addSample('trainer', -80, startedAt), -80);
expect(
averager.addSample(
'trainer',
-70,
startedAt.add(const Duration(milliseconds: 100)),
),
-75,
);
expect(
averager.addSample(
'trainer',
-60,
startedAt.add(const Duration(milliseconds: 400)),
),
-70,
);
});
test('drops samples older than the configured window', () {
final averager = RssiAverager(window: const Duration(milliseconds: 500));
final startedAt = DateTime(2026);
averager.addSample('trainer', -80, startedAt);
averager.addSample(
'trainer',
-60,
startedAt.add(const Duration(milliseconds: 250)),
);
expect(
averager.addSample(
'trainer',
-40,
startedAt.add(const Duration(milliseconds: 501)),
),
-50,
);
});
test('tracks devices independently', () {
final averager = RssiAverager(window: const Duration(milliseconds: 500));
final startedAt = DateTime(2026);
averager.addSample('trainer-a', -80, startedAt);
averager.addSample('trainer-a', -60, startedAt);
expect(averager.addSample('trainer-b', -40, startedAt), -40);
});
test('clear removes previous samples', () {
final averager = RssiAverager(window: const Duration(milliseconds: 500));
final startedAt = DateTime(2026);
averager.addSample('trainer', -80, startedAt);
averager.clear();
expect(averager.addSample('trainer', -40, startedAt), -40);
});
});
}

View File

@ -1,7 +1,16 @@
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isFtmsUuid', () {
test('matches 16-bit and expanded FTMS UUIDs', () {
expect(isFtmsUuid(Uuid.parse('1826')), isTrue);
expect(isFtmsUuid(Uuid.parse(ftmsServiceUuid)), isTrue);
expect(isFtmsUuid(Uuid.parse('180f')), isFalse);
});
});
group('CentralStatus.fromBytes', () {
test('decodes status with FTMS ready', () {
final status = CentralStatus.fromBytes(
@ -99,6 +108,126 @@ void main() {
});
});
group('TrainerScanEvent.fromBytes', () {
test('parses scan lifecycle events', () {
expect(
TrainerScanEvent.fromBytes(const [1, 0, 7]).kind,
TrainerScanEventKind.scanStarted,
);
expect(
TrainerScanEvent.fromBytes(const [1, 2, 8]).kind,
TrainerScanEventKind.scanFinished,
);
expect(
TrainerScanEvent.fromBytes(const [1, 3, 9]).kind,
TrainerScanEventKind.scanCancelled,
);
});
test('parses device event with signed RSSI and flags', () {
final event = TrainerScanEvent.fromBytes([
1,
1,
42,
0xc1,
1,
2,
3,
4,
5,
6,
0xd6,
trainerScanDeviceFlagFtmsDetected |
trainerScanDeviceFlagNameComplete |
trainerScanDeviceFlagConnectable,
5,
...'Kickr'.codeUnits,
]);
expect(event.kind, TrainerScanEventKind.device);
expect(event.sequence, 42);
expect(event.result, isNotNull);
expect(event.result!.address.flags, 0xc1);
expect(event.result!.address.bytes, [1, 2, 3, 4, 5, 6]);
expect(event.result!.rssi, -42);
expect(event.result!.name, 'Kickr');
expect(event.result!.ftmsDetected, isTrue);
expect(event.result!.nameComplete, isTrue);
expect(event.result!.scanResponseSeen, isFalse);
expect(event.result!.connectable, isTrue);
});
test('rejects invalid scan payloads', () {
expect(
() => TrainerScanEvent.fromBytes(const []),
throwsFormatException,
);
expect(
() => TrainerScanEvent.fromBytes(const [2, 0, 1]),
throwsFormatException,
);
expect(
() => TrainerScanEvent.fromBytes(const [1, 9, 1]),
throwsFormatException,
);
expect(
() => TrainerScanEvent.fromBytes(const [1, 1, 1]),
throwsFormatException,
);
expect(
() => TrainerScanEvent.fromBytes(const [
1,
1,
1,
0,
1,
2,
3,
4,
5,
6,
0,
0,
4,
65,
]),
throwsFormatException,
);
});
});
group('encodeTrainerAddress', () {
test('encodes flags and address bytes', () {
expect(
encodeTrainerAddress(
const TrainerAddress(flags: 0xc1, bytes: [1, 2, 3, 4, 5, 6]),
),
[0xc1, 1, 2, 3, 4, 5, 6],
);
});
test('rejects invalid address values', () {
expect(
() => encodeTrainerAddress(
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5]),
),
throwsFormatException,
);
expect(
() => encodeTrainerAddress(
const TrainerAddress(flags: 256, bytes: [1, 2, 3, 4, 5, 6]),
),
throwsFormatException,
);
expect(
() => encodeTrainerAddress(
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5, 256]),
),
throwsFormatException,
);
});
});
group('standard GATT telemetry parsing', () {
test('decodes battery level percentage', () {
expect(parseBatteryLevelPercent([0]), 0);

View File

@ -1,137 +0,0 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:anyhow/anyhow.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ShifterService.runDfuPreflight', () {
test('fails when no active button connection exists', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.disconnected, null),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason,
DfuPreflightFailureReason.deviceNotConnected);
expect(adapter.requestMtuCallCount, 0);
});
test('fails when connected to a different button', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'wrong-device'),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason,
DfuPreflightFailureReason.wrongConnectedDevice);
expect(adapter.requestMtuCallCount, 0);
});
test('fails when MTU negotiation fails', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: bail('adapter rejected mtu request'),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight(requestedMtu: 247);
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(
preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed);
expect(preflight.message, contains('adapter rejected mtu request'));
expect(adapter.requestedMtuValues, [247]);
});
test('fails when negotiated MTU is too low for 64-byte frame writes',
() async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: Ok(universalShifterDfuMinimumMtu - 1),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isFalse);
expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow);
expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1);
expect(preflight.requiredMtu, universalShifterDfuMinimumMtu);
});
test('passes when connected to target and MTU is sufficient', () async {
final adapter = _FakeDfuPreflightBluetoothAdapter(
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
mtuResult: Ok(128),
);
final service = ShifterService(
buttonDeviceId: 'target-device',
dfuPreflightBluetooth: adapter,
);
final result = await service.runDfuPreflight();
expect(result.isOk(), isTrue);
final preflight = result.unwrap();
expect(preflight.canStart, isTrue);
expect(preflight.failureReason, isNull);
expect(preflight.negotiatedMtu, 128);
expect(preflight.requestedMtu, universalShifterDfuPreferredMtu);
});
});
}
class _FakeDfuPreflightBluetoothAdapter
implements DfuPreflightBluetoothAdapter {
_FakeDfuPreflightBluetoothAdapter({
required this.currentConnectionState,
required Result<int> mtuResult,
}) : _mtuResult = mtuResult;
@override
final (ConnectionStatus, String?) currentConnectionState;
final Result<int> _mtuResult;
int requestMtuCallCount = 0;
final List<int> requestedMtuValues = <int>[];
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) async {
requestMtuCallCount += 1;
requestedMtuValues.add(mtu);
return _mtuResult;
}
}

View File

@ -3,97 +3,158 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('DfuProtocol CRC32', () {
group('BootloaderDfuProtocol CRC32', () {
test('matches known vector', () {
final crc = DfuProtocol.crc32('123456789'.codeUnits);
final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits);
expect(crc, 0xCBF43926);
});
});
group('DfuProtocol control payload encoding', () {
test('encodes START payload with exact 11-byte LE layout', () {
final payload = DfuProtocol.encodeStartPayload(
const DfuStartPayload(
group('BootloaderDfuProtocol control payload encoding', () {
test('encodes START payload with exact 19-byte LE layout', () {
final payload = BootloaderDfuProtocol.encodeStartPayload(
const BootloaderDfuStartPayload(
totalLength: 0x1234,
imageCrc32: 0x89ABCDEF,
appStart: universalShifterDfuAppStart,
imageVersion: 0x10203040,
sessionId: 0x22,
flags: universalShifterDfuFlagEncrypted,
flags: universalShifterDfuFlagNone,
),
);
expect(payload.length, 11);
expect(
payload,
[
universalShifterDfuOpcodeStart,
0x34,
0x12,
0x00,
0x00,
0xEF,
0xCD,
0xAB,
0x89,
0x22,
universalShifterDfuFlagEncrypted,
],
);
expect(payload.length, 19);
expect(payload, [
universalShifterDfuOpcodeStart,
0x34,
0x12,
0x00,
0x00,
0xEF,
0xCD,
0xAB,
0x89,
0x00,
0x00,
0x03,
0x00,
0x40,
0x30,
0x20,
0x10,
0x22,
universalShifterDfuFlagNone,
]);
});
test('encodes FINISH and ABORT payloads as one byte', () {
test('encodes FINISH, ABORT, and GET_STATUS payloads', () {
expect(
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
BootloaderDfuProtocol.encodeFinishPayload(0x12),
[universalShifterDfuOpcodeFinish, 0x12],
);
expect(
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
BootloaderDfuProtocol.encodeAbortPayload(0x34),
[universalShifterDfuOpcodeAbort, 0x34],
);
expect(
BootloaderDfuProtocol.encodeGetStatusPayload(),
[universalShifterDfuOpcodeGetStatus],
);
});
});
group('DfuProtocol data frame building', () {
test('builds 64-byte frames and handles final partial payload', () {
final image = List<int>.generate(80, (index) => index);
final frames = DfuProtocol.buildDataFrames(image);
group('BootloaderDfuProtocol data frame building', () {
test('builds offset frames with payload CRC and variable final length', () {
final image = List<int>.generate(60, (index) => index);
final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x7A,
);
expect(frames.length, 2);
expect(frames[0].sequence, 0);
expect(frames[0].sessionId, 0x7A);
expect(frames[0].offset, 0);
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
expect(frames[0].payloadLength,
universalShifterBootloaderDfuMaxPayloadSizeBytes);
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
expect(frames[0].bytes[0], 0x7A);
expect(frames[0].bytes.sublist(1, 5), [0, 0, 0, 0]);
expect(
frames[0].bytes.sublist(5, 9),
_leU32Bytes(BootloaderDfuProtocol.crc32(image.sublist(0, 55))),
);
expect(frames[0].bytes.sublist(9), image.sublist(0, 55));
expect(frames[1].sequence, 1);
expect(frames[1].offset, 63);
expect(frames[1].payloadLength, 17);
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
expect(frames[1].offset, 55);
expect(frames[1].payloadLength, 5);
expect(frames[1].bytes.length, 14);
expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]);
expect(frames[1].bytes.sublist(9), image.sublist(55));
});
test('uses deterministic wrapping sequence numbers from custom start', () {
final image = List<int>.generate(
3 * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF);
test('uses caller supplied payload size for low-MTU links', () {
final image = List<int>.generate(15, (index) => index);
final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x01,
payloadSize: 4,
);
final frames = DfuProtocol.buildDataFrames(image, startSequence: 0xFE);
expect(frames.map((frame) => frame.payloadLength), [4, 4, 4, 3]);
expect(frames.map((frame) => frame.offset), [0, 4, 8, 12]);
});
expect(frames.length, 3);
expect(frames[0].sequence, 0xFE);
expect(frames[1].sequence, 0xFF);
expect(frames[2].sequence, 0x00);
test('calculates safe payload size from negotiated MTU', () {
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(64),
universalShifterBootloaderDfuMaxPayloadSizeBytes - 3,
);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0);
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(128),
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
});
});
group('DfuProtocol sequence and ACK helpers', () {
test('wraps sequence values and computes ack+1 rewind', () {
expect(DfuProtocol.nextSequence(0x00), 0x01);
expect(DfuProtocol.nextSequence(0xFF), 0x00);
group('BootloaderDfuProtocol status parsing', () {
test('parses bootloader status payload', () {
final status = BootloaderDfuProtocol.parseStatusPayload(
[0x00, 0x22, 0x78, 0x56, 0x34, 0x12],
);
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
expect(status.code, DfuBootloaderStatusCode.ok);
expect(status.rawCode, 0x00);
expect(status.sessionId, 0x22);
expect(status.expectedOffset, 0x12345678);
expect(status.isOk, isTrue);
});
test('computes wrapping sequence distance', () {
expect(DfuProtocol.sequenceDistance(250, 2), 8);
expect(DfuProtocol.sequenceDistance(1, 1), 0);
test('preserves unknown status codes', () {
final status = BootloaderDfuProtocol.parseStatusPayload(
[0xFE, 0x00, 0x00, 0x00, 0x00, 0x00],
);
expect(status.code, DfuBootloaderStatusCode.unknown);
expect(status.rawCode, 0xFE);
});
test('rejects malformed status payloads', () {
expect(
() => BootloaderDfuProtocol.parseStatusPayload(const [0, 1]),
throwsFormatException,
);
});
});
}
List<int> _leU32Bytes(int value) {
return [
value & 0xFF,
(value >> 8) & 0xFF,
(value >> 16) & 0xFF,
(value >> 24) & 0xFF,
];
}

View File

@ -2,34 +2,40 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FirmwareFileSelectionService', () {
test('prepares v1 metadata for selected .bin firmware', () async {
test('prepares bootloader metadata for selected .bin firmware', () async {
final image = _validBootloaderImage();
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.BIN',
filePath: '/tmp/firmware.BIN',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
fileBytes: image,
),
),
sessionIdGenerator: () => 0x1AB,
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue);
final firmware = result.firmware!;
expect(firmware.fileName, 'firmware.BIN');
expect(firmware.filePath, '/tmp/firmware.BIN');
expect(firmware.fileBytes, <int>[1, 2, 3, 4]);
expect(firmware.metadata.totalLength, 4);
expect(firmware.metadata.crc32, 0xB63CFBCD);
expect(firmware.fileBytes, image);
expect(firmware.metadata.totalLength, image.length);
expect(firmware.metadata.crc32, BootloaderDfuProtocol.crc32(image));
expect(firmware.metadata.appStart, universalShifterDfuAppStart);
expect(firmware.metadata.imageVersion, 0);
expect(firmware.metadata.sessionId, 0xAB);
expect(firmware.metadata.flags, universalShifterDfuFlagNone);
expect(firmware.metadata.vectorStackPointer, 0x20001000);
expect(firmware.metadata.vectorReset, 0x00030009);
});
test('returns canceled result when user dismisses picker', () async {
@ -37,7 +43,7 @@ void main() {
filePicker: _FakeFirmwareFilePicker(selection: null),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.isCanceled, isTrue);
@ -49,12 +55,12 @@ void main() {
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.hex',
fileBytes: Uint8List.fromList(<int>[1]),
fileBytes: _validBootloaderImage(),
),
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason,
@ -71,31 +77,124 @@ void main() {
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile);
});
test('rejects images that are too small for a vector table', () async {
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(
result.failure?.reason, FirmwareSelectionFailureReason.imageTooSmall);
});
test('rejects images larger than the application slot', () async {
final image = Uint8List(universalShifterDfuAppSlotSizeBytes + 1);
_writeLeU32(image, 0, 0x20001000);
_writeLeU32(image, 4, 0x00030009);
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: image,
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(
result.failure?.reason, FirmwareSelectionFailureReason.imageTooLarge);
});
test('accepts image exactly at application slot size', () async {
final image = Uint8List(universalShifterDfuAppSlotSizeBytes);
_writeLeU32(image, 0, 0x20001000);
_writeLeU32(image, 4, 0x00030009);
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: image,
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue);
expect(result.firmware?.metadata.totalLength,
universalShifterDfuAppSlotSizeBytes);
});
test('rejects images with invalid vector table', () async {
final image = _validBootloaderImage();
_writeLeU32(image, 0, 0x10001000);
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: image,
),
),
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason,
FirmwareSelectionFailureReason.invalidVectorTable);
});
test('generates session id per run', () async {
var nextSession = 9;
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[10]),
fileBytes: _validBootloaderImage(),
),
),
sessionIdGenerator: () => nextSession++,
);
final first = await service.selectAndPrepareDfuV1();
final second = await service.selectAndPrepareDfuV1();
final first = await service.selectAndPrepareBootloaderDfu();
final second = await service.selectAndPrepareBootloaderDfu();
expect(first.firmware?.metadata.sessionId, 9);
expect(second.firmware?.metadata.sessionId, 10);
});
test('normalizes generated zero session id to one', () async {
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection(
fileName: 'firmware.bin',
fileBytes: _validBootloaderImage(),
),
),
sessionIdGenerator: () => 0,
);
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue);
expect(result.firmware?.metadata.sessionId, 1);
});
test('maps picker read failure to explicit validation error', () async {
final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker(
@ -104,7 +203,7 @@ void main() {
),
);
final result = await service.selectAndPrepareDfuV1();
final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed);
@ -113,6 +212,18 @@ void main() {
});
}
Uint8List _validBootloaderImage() {
final image = Uint8List(16);
_writeLeU32(image, 0, 0x20001000);
_writeLeU32(image, 4, 0x00030009);
return image;
}
void _writeLeU32(Uint8List bytes, int offset, int value) {
final data = ByteData.sublistView(bytes);
data.setUint32(offset, value, Endian.little);
}
class _FakeFirmwareFilePicker implements FirmwareFilePicker {
_FakeFirmwareFilePicker({
required this.selection,

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart';
@ -6,241 +7,180 @@ import 'package:anyhow/anyhow.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FirmwareUpdateService', () {
test('completes happy path with START, data frames, and FINISH', () async {
final transport = _FakeFirmwareUpdateTransport();
group('FirmwareUpdateService bootloader flow', () {
test('completes happy path with START, offset data, FINISH, and verify',
() async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 7,
);
expect(result.isOk(), isTrue);
expect(transport.controlWrites.length, 2);
expect(transport.steps, [
'isConnectedToBootloader',
'enterBootloader',
'waitForAppDisconnect',
'connectToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
]);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
expect(transport.dataWrites, isNotEmpty);
expect(transport.dataWrites.first[0], 7);
expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
expect(service.currentProgress.state, DfuUpdateState.completed);
expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.expectedOffset, image.length);
await service.dispose();
await transport.dispose();
});
test('rewinds to ack+1 and retransmits after ACK stall', () async {
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
test('starts directly when already connected to bootloader', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
alreadyInBootloader: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 3,
defaultAckTimeout: const Duration(milliseconds: 100),
maxNoProgressRetries: 4,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 8,
);
expect(result.isOk(), isTrue);
expect(transport.steps, [
'isConnectedToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
]);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
await service.dispose();
await transport.dispose();
});
test('tolerates enter bootloader write error when app disconnects',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failEnterBootloader: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 12,
);
expect(result.isOk(), isTrue);
expect(transport.steps, contains('waitForAppDisconnect'));
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('backs off on queue-full status and resumes from GET_STATUS',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
queueFullOnFirstData: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(190, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 9,
);
expect(result.isOk(), isTrue);
expect(transport.dataWrites.length, greaterThan(4));
expect(transport.sequenceWriteCount(1), greaterThan(1));
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails after bounded retries when ACK progress times out', () async {
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 1,
defaultAckTimeout: const Duration(milliseconds: 40),
maxNoProgressRetries: 2,
);
final image = List<int>.generate(90, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 10,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('Upload stalled'));
expect(result.unwrapErr().toString(), contains('after 3 retries'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(transport.sequenceWriteCount(0), 3);
expect(service.currentProgress.state, DfuUpdateState.failed);
await service.dispose();
await transport.dispose();
});
test('cancel sends ABORT and reports aborted state', () async {
final firstFrameSent = Completer<void>();
final transport = _FakeFirmwareUpdateTransport(
onDataWrite: (frame) {
if (!firstFrameSent.isCompleted) {
firstFrameSent.complete();
}
},
suppressDataAcks: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 1,
defaultAckTimeout: const Duration(milliseconds: 500),
);
final future = service.startUpdate(
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
sessionId: 11,
);
await firstFrameSent.future.timeout(const Duration(seconds: 1));
await service.cancelUpdate();
final result = await future;
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('canceled'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(service.currentProgress.state, DfuUpdateState.aborted);
await service.dispose();
await transport.dispose();
});
test('fails when reconnect does not succeed after expected reset',
test('completes when FINISH status is lost but bootloader disconnects',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
reconnectError: 'simulated reconnect timeout',
totalBytes: image.length,
suppressFinishStatus: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('did not reconnect'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
],
);
await service.dispose();
await transport.dispose();
});
test('fails when expected reset disconnect is not observed', () async {
final transport = _FakeFirmwareUpdateTransport(
resetDisconnectError: 'simulated missing disconnect',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 15,
);
expect(result.isErr(), isTrue);
expect(result.isOk(), isTrue);
expect(
result.unwrapErr().toString(),
contains('expected post-FINISH reset disconnect'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
['waitForExpectedResetDisconnect'],
);
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
expect(transport.steps, contains('reconnectForVerification'));
expect(transport.steps, contains('verifyDeviceReachable'));
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails when post-update status verification read fails', () async {
final transport = _FakeFirmwareUpdateTransport(
verificationError: 'simulated status read failure',
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 4,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 14,
);
expect(result.isErr(), isTrue);
expect(
result.unwrapErr().toString(),
contains('post-update verification failed'),
);
expect(
result.unwrapErr().toString(),
contains('does not expose a version characteristic'),
);
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.postFinishSteps,
[
'waitForExpectedResetDisconnect',
'reconnectForVerification',
'verifyDeviceReachable',
],
);
await service.dispose();
await transport.dispose();
});
test('handles deterministic ACK sequence wrap-around across 0xFF->0x00',
test('fails when FINISH status is lost and bootloader stays connected',
() async {
const frameCount = 260;
final transport = _FakeFirmwareUpdateTransport();
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
disconnectAfterFinish: false,
);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 16,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(
frameCount * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF,
defaultStatusTimeout: const Duration(milliseconds: 10),
defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
);
final result = await service.startUpdate(
@ -248,171 +188,401 @@ void main() {
sessionId: 16,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('post-FINISH reset'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('fails when FINISH returns explicit bootloader error', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
finishStatusCode: DfuBootloaderStatusCode.flashError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 17,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('flash error'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('reconnects and resumes from status after transient data failure',
() async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failDataWriteAtOffsetOnce:
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isOk(), isTrue);
var ffToZeroTransitions = 0;
for (var i = 1; i < transport.ackNotifications.length; i++) {
if (transport.ackNotifications[i - 1] == 0xFF &&
transport.ackNotifications[i] == 0x00) {
ffToZeroTransitions += 1;
}
}
expect(ffToZeroTransitions, greaterThanOrEqualTo(2));
expect(service.currentProgress.lastAckedSequence, 0x03);
expect(service.currentProgress.sentBytes, image.length);
expect(
transport.steps.where((step) => step == 'connectToBootloader').length,
2,
);
expect(
transport.steps
.where((step) => step == 'optimizeBootloaderConnection')
.length,
2,
);
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets
.where(
(offset) =>
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
)
.length,
2,
);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('restarts START when reconnect status has no active session',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failDataWriteAtOffsetOnce:
universalShifterBootloaderDfuMaxPayloadSizeBytes,
resetSessionOnRecoveryStatus: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 14,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeStart)
.length,
2,
);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails with bootloader status error on rejected START', () async {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
startStatusCode: DfuBootloaderStatusCode.vectorError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 10,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('vector table error'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
await service.dispose();
await transport.dispose();
});
test('cancel after START sends session-scoped ABORT', () async {
final image = _validImage(80);
final firstFrameSent = Completer<void>();
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFirstDataStatus: true,
onDataWrite: () {
if (!firstFrameSent.isCompleted) {
firstFrameSent.complete();
}
},
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(seconds: 1),
);
final future = service.startUpdate(
imageBytes: image,
sessionId: 11,
);
await firstFrameSent.future.timeout(const Duration(seconds: 1));
await service.cancelUpdate();
final result = await future;
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('canceled'));
expect(
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
expect(service.currentProgress.state, DfuUpdateState.aborted);
await service.dispose();
await transport.dispose();
});
});
}
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({
this.dropFirstSequence,
required this.totalBytes,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false,
this.failEnterBootloader = false,
this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite,
this.suppressDataAcks = false,
this.resetDisconnectError,
this.reconnectError,
this.verificationError,
});
final int? dropFirstSequence;
final void Function(List<int> frame)? onDataWrite;
final bool suppressDataAcks;
final String? resetDisconnectError;
final String? reconnectError;
final String? verificationError;
final int totalBytes;
final DfuBootloaderStatusCode startStatusCode;
final bool alreadyInBootloader;
final bool failEnterBootloader;
final bool queueFullOnFirstData;
final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final bool suppressFinishStatus;
final bool disconnectAfterFinish;
final DfuBootloaderStatusCode finishStatusCode;
final void Function()? onDataWrite;
final StreamController<List<int>> _ackController =
final StreamController<List<int>> _statusController =
StreamController<List<int>>.broadcast();
final List<String> steps = <String>[];
final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[];
final List<int> ackNotifications = <int>[];
final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{};
int _lastAck = 0xFF;
int _expectedSequence = 0;
final List<int> dataWriteOffsets = <int>[];
int _sessionId = 0;
int _expectedOffset = 0;
int _connectCount = 0;
bool _sentDataFailure = false;
bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
bool _finishDisconnectAvailable = false;
@override
Future<Result<DfuPreflightResult>> runPreflight({
required int requestedMtu,
}) async {
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: 128,
),
);
Future<Result<bool>> isConnectedToBootloader() async {
steps.add('isConnectedToBootloader');
return Ok(alreadyInBootloader);
}
@override
Stream<List<int>> subscribeToAck() => _ackController.stream;
Future<Result<void>> enterBootloader() async {
steps.add('enterBootloader');
if (failEnterBootloader) {
return bail('app disconnected before write response');
}
return Ok(null);
}
@override
Future<Result<void>> waitForAppDisconnect({required Duration timeout}) async {
steps.add('waitForAppDisconnect');
return Ok(null);
}
@override
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
steps.add('connectToBootloader');
_connectCount += 1;
return Ok(null);
}
@override
Future<Result<void>> optimizeBootloaderConnection() async {
steps.add('optimizeBootloaderConnection');
return Ok(null);
}
@override
Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
steps.add('negotiateMtu');
expect(requestedMtu, universalShifterDfuPreferredMtu);
return Ok(128);
}
@override
Stream<List<int>> subscribeToStatus() => _statusController.stream;
@override
Future<Result<List<int>>> readStatus() async {
steps.add('readStatus');
return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0));
}
@override
Future<Result<void>> writeControl(List<int> payload) async {
controlWrites.add(List<int>.from(payload, growable: false));
final opcode = payload.isEmpty ? -1 : payload.first;
final opcode = payload.first;
if (opcode == universalShifterDfuOpcodeStart) {
_lastAck = 0xFF;
_expectedSequence = 0;
_scheduleAck(0xFF);
_sessionId = payload[17];
_expectedOffset = 0;
_scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
_sessionId = 0;
_expectedOffset = 0;
}
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) {
if (suppressFinishStatus) {
_finishDisconnectAvailable = disconnectAfterFinish;
} else {
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
}
} else if (opcode == universalShifterDfuOpcodeAbort) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
}
if (opcode == universalShifterDfuOpcodeAbort) {
_lastAck = 0xFF;
_expectedSequence = 0;
}
return Ok(null);
}
@override
Future<Result<void>> writeDataFrame(List<int> frame) async {
dataWrites.add(List<int>.from(frame, growable: false));
onDataWrite?.call(frame);
onDataWrite?.call();
if (suppressDataAcks) {
final offset = _readLeU32(frame, 1);
dataWriteOffsets.add(offset);
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
_sentDataFailure = true;
return bail('simulated BLE write failure');
}
if (queueFullOnFirstData && !_sentQueueFull) {
_sentQueueFull = true;
_scheduleStatus(
DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset);
return Ok(null);
}
final sequence = frame.first;
final shouldDrop = dropFirstSequence != null &&
sequence == dropFirstSequence &&
!_droppedOnce.contains(sequence);
if (shouldDrop) {
_droppedOnce.add(sequence);
_scheduleAck(_lastAck);
if (suppressFirstDataStatus && !_suppressedDataStatus) {
_suppressedDataStatus = true;
return Ok(null);
}
if (sequence == _expectedSequence) {
_lastAck = sequence;
_expectedSequence = (_expectedSequence + 1) & 0xFF;
}
_scheduleAck(_lastAck);
final payloadLength =
frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
_expectedOffset = offset + payloadLength;
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
return Ok(null);
}
void _scheduleAck(int sequence) {
final ack = sequence & 0xFF;
ackNotifications.add(ack);
@override
Future<Result<void>> waitForBootloaderDisconnect(
{required Duration timeout}) async {
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
return bail('still connected');
}
steps.add('waitForBootloaderDisconnect');
_finishDisconnectAvailable = true;
return Ok(null);
}
@override
Future<Result<void>> reconnectForVerification(
{required Duration timeout}) async {
steps.add('reconnectForVerification');
return Ok(null);
}
@override
Future<Result<void>> verifyDeviceReachable(
{required Duration timeout}) async {
steps.add('verifyDeviceReachable');
return Ok(null);
}
void _scheduleStatus(
DfuBootloaderStatusCode code, int sessionId, int offset) {
final status = _status(code, sessionId, offset);
scheduleMicrotask(() {
_ackController.add([ack]);
_statusController.add(status);
});
}
@override
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
}) async {
postFinishSteps.add('waitForExpectedResetDisconnect');
if (resetDisconnectError != null) {
return bail(resetDisconnectError!);
}
return Ok(null);
List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
return [
code.value,
sessionId & 0xFF,
offset & 0xFF,
(offset >> 8) & 0xFF,
(offset >> 16) & 0xFF,
(offset >> 24) & 0xFF,
];
}
@override
Future<Result<void>> reconnectForVerification({
required Duration timeout,
}) async {
postFinishSteps.add('reconnectForVerification');
if (reconnectError != null) {
return bail(reconnectError!);
}
return Ok(null);
}
@override
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
}) async {
postFinishSteps.add('verifyDeviceReachable');
if (verificationError != null) {
return bail(verificationError!);
}
return Ok(null);
}
int sequenceWriteCount(int sequence) {
var count = 0;
for (final frame in dataWrites) {
if (frame.first == sequence) {
count += 1;
}
}
return count;
int _readLeU32(List<int> bytes, int offset) {
final data = ByteData.sublistView(Uint8List.fromList(bytes));
return data.getUint32(offset, Endian.little);
}
Future<void> dispose() async {
await _ackController.close();
await _statusController.close();
}
}
List<int> _validImage(int length) {
final image = Uint8List(length);
final data = ByteData.sublistView(image);
data.setUint32(0, 0x20001000, Endian.little);
data.setUint32(4, 0x00030009, Endian.little);
for (var index = 8; index < image.length; index++) {
image[index] = index & 0xFF;
}
return image;
}