Compare commits

31 Commits

Author SHA1 Message Date
30b25784c1 fix: lifecycle 2026-05-05 22:01:22 +02:00
0da0905697 fix(bluetooth_controller): cache for dispose 2026-05-05 21:26:58 +02:00
4fceb0c690 feat: better pairing broken notification 2026-05-05 21:06:11 +02:00
230a6838e0 fix: fix dfu id mismatch because stale notification 2026-05-05 20:44:31 +02:00
512c31d356 feat: snackbar for flash err + disconnect on dfurecovery end 2026-05-05 20:19:43 +02:00
f1491749d5 fix: bootloader device card persistence fix 2026-05-05 20:02:04 +02:00
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
44 changed files with 4761 additions and 1642 deletions

View File

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

View File

@ -1,17 +1,11 @@
package com.example.abawo_bt_app package com.example.abawo_bt_app
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity 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.exceptions.UndeliverableException
import io.reactivex.plugins.RxJavaPlugins import io.reactivex.plugins.RxJavaPlugins
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val settingsChannel = "abawo/settings"
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
RxJavaPlugins.setErrorHandler { throwable -> RxJavaPlugins.setErrorHandler { throwable ->
val error = if (throwable is UndeliverableException && throwable.cause != null) { val error = if (throwable is UndeliverableException && throwable.cause != null) {
@ -29,27 +23,4 @@ class MainActivity: FlutterActivity() {
} }
super.onCreate(savedInstanceState) 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> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict> </dict>
</plist> </plist>

View File

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

View File

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.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 */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -40,11 +42,13 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -62,6 +72,15 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FCF666D593496D04750CA50C /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
ED8B912F26EFF1D76D0F5A4D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B2511BBF20B78CC491AE9073 /* Pods_RunnerTests.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -76,6 +95,15 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
833620FCD9C8C6F1A1243070 /* Frameworks */ = {
isa = PBXGroup;
children = (
D5F03233E49EA19C83DCB638 /* Pods_Runner.framework */,
A52089F854AA1F01FDBBA547 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -94,6 +122,8 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
A70FEE79345AAF2DEE362C8C /* Pods */,
833620FCD9C8C6F1A1243070 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -121,6 +151,19 @@
path = Runner; path = Runner;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -128,8 +171,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
4D3E903FDA1944703D91310F /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
ED8B912F26EFF1D76D0F5A4D /* Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -145,12 +190,14 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
0C46584F62EE9C26EF2B0063 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
5643EB9D69B18B7E0260A79A /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -222,6 +269,28 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase 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 */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -238,6 +307,45 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 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 */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -346,7 +454,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@ -362,13 +470,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp; PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -378,6 +487,7 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = B72A7C54943C56D4277555D8 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -395,6 +505,7 @@
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 61EA32A3FB1020B4BBC510EF /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -410,6 +521,7 @@
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = E8B40E3805F9A529E1ABE2C2 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -472,7 +584,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -523,7 +635,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@ -541,13 +653,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp; PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -563,13 +676,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = C9QV2HUCQ4;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.abawoBtApp; PRODUCT_BUNDLE_IDENTIFIER = com.abawo.abawoBtApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

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

View File

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

View File

@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) 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"?> <?xml version="1.0" encoding="UTF-8"?>
<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"> <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> <dependencies>
<deployment identifier="iOS"/> <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> </dependencies>
<scenes> <scenes>
<!--Flutter View Controller--> <!--Flutter View Controller-->
@ -14,13 +16,14 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides> </layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> <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"/> <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> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="132" y="-48"/>
</scene> </scene>
</scenes> </scenes>
</document> </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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -24,6 +26,33 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@ -41,9 +70,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -14,6 +14,13 @@ part 'bluetooth.g.dart';
final log = Logger('BluetoothController'); final log = Logger('BluetoothController');
final backgroundBluetoothDisconnectSuppressionCountProvider =
StateProvider<int>((ref) => 0);
final backgroundBluetoothDisconnectSuppressedProvider = Provider<bool>((ref) {
return ref.watch(backgroundBluetoothDisconnectSuppressionCountProvider) > 0;
});
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
FlutterReactiveBle reactiveBle(Ref ref) { FlutterReactiveBle reactiveBle(Ref ref) {
ref.keepAlive(); ref.keepAlive();
@ -45,6 +52,7 @@ class BluetoothController {
BluetoothController(this._ble); BluetoothController(this._ble);
static const int defaultMtu = 64; static const int defaultMtu = 64;
static const Duration _rssiAverageWindow = Duration(milliseconds: 500);
final FlutterReactiveBle _ble; final FlutterReactiveBle _ble;
@ -52,6 +60,7 @@ class BluetoothController {
StreamSubscription<DiscoveredDevice>? _scanResultsSubscription; StreamSubscription<DiscoveredDevice>? _scanResultsSubscription;
Timer? _scanTimeout; Timer? _scanTimeout;
final Map<String, DiscoveredDevice> _scanResultsById = {}; final Map<String, DiscoveredDevice> _scanResultsById = {};
final RssiAverager _rssiAverager = RssiAverager(window: _rssiAverageWindow);
final _scanResultsSubject = final _scanResultsSubject =
BehaviorSubject<List<DiscoveredDevice>>.seeded(const []); BehaviorSubject<List<DiscoveredDevice>>.seeded(const []);
final _isScanningSubject = BehaviorSubject<bool>.seeded(false); final _isScanningSubject = BehaviorSubject<bool>.seeded(false);
@ -102,6 +111,7 @@ class BluetoothController {
_scanTimeout?.cancel(); _scanTimeout?.cancel();
_scanResultsById.clear(); _scanResultsById.clear();
_rssiAverager.clear();
_scanResultsSubject.add(const []); _scanResultsSubject.add(const []);
_isScanningSubject.add(true); _isScanningSubject.add(true);
@ -112,7 +122,12 @@ class BluetoothController {
requireLocationServicesEnabled: requireLocationServicesEnabled, requireLocationServicesEnabled: requireLocationServicesEnabled,
) )
.listen((device) { .listen((device) {
_scanResultsById[device.id] = device; final smoothedRssi = _rssiAverager.addSample(
device.id,
device.rssi,
DateTime.now(),
);
_scanResultsById[device.id] = device.copyWith(rssi: smoothedRssi);
_scanResultsSubject _scanResultsSubject
.add(_scanResultsById.values.toList(growable: false)); .add(_scanResultsById.values.toList(growable: false));
}, onError: (Object error, StackTrace st) { }, 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 { Future<Result<void>> _requestInitialMtu(String deviceId) async {
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null); return Ok(null);
@ -394,3 +430,26 @@ class BluetoothController {
return Ok(null); 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 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart'; 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_page.dart';
import 'package:abawo_bt_app/pages/devices_tab_page.dart'; import 'package:abawo_bt_app/pages/devices_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart';
@ -57,6 +58,9 @@ class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.hidden || if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) { state == AppLifecycleState.paused) {
if (ref.read(backgroundBluetoothDisconnectSuppressedProvider)) {
return;
}
unawaited(_disconnectBluetoothForBackground()); unawaited(_disconnectBluetoothForBackground());
} }
} }
@ -122,6 +126,18 @@ final _router = GoRouter(
return DeviceDetailsPage(deviceAddress: deviceAddress); 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'; import 'dart:typed_data';
class DfuV1FirmwareMetadata { class BootloaderDfuFirmwareMetadata {
const DfuV1FirmwareMetadata({ const BootloaderDfuFirmwareMetadata({
required this.totalLength, required this.totalLength,
required this.crc32, required this.crc32,
required this.appStart,
required this.imageVersion,
required this.sessionId, required this.sessionId,
required this.flags, required this.flags,
required this.vectorStackPointer,
required this.vectorReset,
}); });
final int totalLength; final int totalLength;
final int crc32; final int crc32;
final int appStart;
final int imageVersion;
final int sessionId; final int sessionId;
final int flags; final int flags;
final int vectorStackPointer;
final int vectorReset;
} }
class DfuV1PreparedFirmware { class BootloaderDfuPreparedFirmware {
const DfuV1PreparedFirmware({ const BootloaderDfuPreparedFirmware({
required this.fileName, required this.fileName,
required this.fileBytes, required this.fileBytes,
required this.metadata, required this.metadata,
@ -25,7 +33,7 @@ class DfuV1PreparedFirmware {
final String fileName; final String fileName;
final String? filePath; final String? filePath;
final Uint8List fileBytes; final Uint8List fileBytes;
final DfuV1FirmwareMetadata metadata; final BootloaderDfuFirmwareMetadata metadata;
} }
enum FirmwareSelectionFailureReason { enum FirmwareSelectionFailureReason {
@ -33,6 +41,9 @@ enum FirmwareSelectionFailureReason {
malformedSelection, malformedSelection,
unsupportedExtension, unsupportedExtension,
emptyFile, emptyFile,
imageTooSmall,
imageTooLarge,
invalidVectorTable,
readFailed, readFailed,
} }
@ -52,7 +63,7 @@ class FirmwareFileSelectionResult {
this.failure, this.failure,
}); });
final DfuV1PreparedFirmware? firmware; final BootloaderDfuPreparedFirmware? firmware;
final FirmwareSelectionFailure? failure; final FirmwareSelectionFailure? failure;
bool get isSuccess => firmware != null; bool get isSuccess => firmware != null;
@ -60,7 +71,8 @@ class FirmwareFileSelectionResult {
bool get isCanceled => bool get isCanceled =>
failure?.reason == FirmwareSelectionFailureReason.canceled; failure?.reason == FirmwareSelectionFailureReason.canceled;
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) { static FirmwareFileSelectionResult success(
BootloaderDfuPreparedFirmware firmware) {
return FirmwareFileSelectionResult._(firmware: firmware); return FirmwareFileSelectionResult._(firmware: firmware);
} }

View File

@ -1,11 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
const String universalShifterControlServiceUuid = const String universalShifterControlServiceUuid =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2'; '0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const String universalShifterStatusCharacteristicUuid = const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000'; '0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid = const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001'; '0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterScanResultCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40004';
const String universalShifterCommandCharacteristicUuid = const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005'; '0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid = const String universalShifterGearRatiosCharacteristicUuid =
@ -14,7 +18,7 @@ const String universalShifterDfuControlCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40008'; '0993826f-0ee4-4b37-9614-d13ecba40008';
const String universalShifterDfuDataCharacteristicUuid = const String universalShifterDfuDataCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40009'; '0993826f-0ee4-4b37-9614-d13ecba40009';
const String universalShifterDfuAckCharacteristicUuid = const String universalShifterDfuStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a'; '0993826f-0ee4-4b37-9614-d13ecba4000a';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb'; const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb'; const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
@ -25,33 +29,61 @@ const String deviceInformationServiceUuid =
const String firmwareRevisionCharacteristicUuid = const String firmwareRevisionCharacteristicUuid =
'00002a26-0000-1000-8000-00805f9b34fb'; '00002a26-0000-1000-8000-00805f9b34fb';
bool isFtmsUuid(Uuid uuid) {
return uuid.expanded == Uuid.parse(ftmsServiceUuid).expanded;
}
const int universalShifterDfuOpcodeStart = 0x01; const int universalShifterDfuOpcodeStart = 0x01;
const int universalShifterDfuOpcodeFinish = 0x02; const int universalShifterDfuOpcodeFinish = 0x02;
const int universalShifterDfuOpcodeAbort = 0x03; const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuOpcodeGetStatus = 0x04;
const int universalShifterDfuFrameSizeBytes = 64; 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 universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu = const int universalShifterDfuPreferredMtu = 131;
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128; 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 universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagSigned = 0x02;
const int universalShifterDfuFlagNone = 0x00; const int universalShifterDfuFlagNone = 0x00;
const String universalShifterBootMetadataWarningMessage =
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support';
const int errorSequence = 1; const int errorSequence = 1;
const int errorFtmsMissing = 2; const int errorFtmsMissing = 2;
const int errorPairingAuth = 3; const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4; const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5; 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 { enum DfuUpdateState {
idle, idle,
starting, starting,
waitingForAck, enteringBootloader,
connectingBootloader,
waitingForStatus,
erasing,
transferring, transferring,
finishing, finishing,
rebooting,
verifying,
completed, completed,
aborted, aborted,
failed, failed,
@ -90,18 +122,20 @@ class DfuUpdateProgress {
required this.state, required this.state,
required this.totalBytes, required this.totalBytes,
required this.sentBytes, required this.sentBytes,
required this.lastAckedSequence, required this.expectedOffset,
required this.sessionId, required this.sessionId,
required this.flags, required this.flags,
this.bootloaderStatus,
this.errorMessage, this.errorMessage,
}); });
final DfuUpdateState state; final DfuUpdateState state;
final int totalBytes; final int totalBytes;
final int sentBytes; final int sentBytes;
final int lastAckedSequence; final int expectedOffset;
final int sessionId; final int sessionId;
final DfuUpdateFlags flags; final DfuUpdateFlags flags;
final DfuBootloaderStatus? bootloaderStatus;
final String? errorMessage; final String? errorMessage;
double get fractionComplete { double get fractionComplete {
@ -119,59 +153,47 @@ class DfuUpdateProgress {
state == DfuUpdateState.failed; state == DfuUpdateState.failed;
} }
enum DfuPreflightFailureReason { enum DfuBootloaderStatusCode {
deviceNotConnected, ok(0x00),
wrongConnectedDevice, parseError(0x01),
mtuRequestFailed, stateError(0x02),
mtuTooLow, 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 { class DfuBootloaderStatus {
const DfuPreflightResult._({ const DfuBootloaderStatus({
required this.requestedMtu, required this.code,
required this.requiredMtu, required this.rawCode,
required this.negotiatedMtu, required this.sessionId,
required this.failureReason, required this.expectedOffset,
required this.message,
}); });
final int requestedMtu; final DfuBootloaderStatusCode code;
final int requiredMtu; final int rawCode;
final int? negotiatedMtu; final int sessionId;
final DfuPreflightFailureReason? failureReason; final int expectedOffset;
final String? message;
bool get canStart => failureReason == null; bool get isOk => code == DfuBootloaderStatusCode.ok;
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,
);
}
} }
class ShifterErrorInfo { class ShifterErrorInfo {
@ -238,12 +260,151 @@ enum UniversalShifterCommand {
stopScan(0x02), stopScan(0x02),
connectToDevice(0x03), connectToDevice(0x03),
disconnect(0x04), disconnect(0x04),
turnOff(0x05); turnOff(0x05),
enterDfu(0x06);
const UniversalShifterCommand(this.value); const UniversalShifterCommand(this.value);
final int 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 { class ShifterDeviceTelemetry {
const ShifterDeviceTelemetry({ const ShifterDeviceTelemetry({
this.batteryPercent, this.batteryPercent,
@ -504,16 +665,21 @@ class CentralStatus {
} }
} }
List<int> parseMacToLittleEndianBytes(String macAddress) { List<int> encodeTrainerAddress(TrainerAddress address) {
final compact = macAddress.replaceAll(':', '').replaceAll('-', ''); if (address.flags < 0 || address.flags > 0xff) {
if (compact.length != 12) { throw FormatException('Invalid trainer address flags: ${address.flags}');
throw FormatException('Invalid MAC address format: $macAddress');
} }
final bytes = <int>[]; if (address.bytes.length != 6) {
for (int i = 0; i < compact.length; i += 2) { throw FormatException(
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16)); '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) { String formatMacAddressFromLittleEndian(List<int> bytes) {

View File

@ -0,0 +1,251 @@
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;
BluetoothController? _bluetooth;
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() {
final service = _firmwareUpdateService;
final bluetooth = _bluetooth;
_firmwareUpdateService = null;
unawaited(_firmwareProgressSubscription?.cancel());
unawaited(() async {
await service?.dispose();
await _disconnectBootloaderIfStillConnected(
bluetooth: bluetooth,
allowRefRead: false,
);
}());
super.dispose();
}
Future<void> _disconnectBootloaderIfStillConnected({
BluetoothController? bluetooth,
bool allowRefRead = true,
}) async {
if (bluetooth == null && allowRefRead) {
bluetooth = ref.read(bluetoothProvider).valueOrNull;
}
if (bluetooth == null) {
return;
}
final currentState = bluetooth.currentConnectionState;
if (currentState.$2 != widget.args.bootloaderDeviceId ||
(currentState.$1 != ConnectionStatus.connected &&
currentState.$1 != ConnectionStatus.connecting)) {
return;
}
await bluetooth.disconnect();
}
Future<void> _dismissToDevices() async {
await _disconnectBootloaderIfStillConnected();
if (!mounted) {
return;
}
context.go('/devices');
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
if (bluetooth == null) {
return null;
}
_bluetooth = bluetooth;
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;
}
await _disconnectBootloaderIfStillConnected();
if (!mounted) {
return;
}
final errorMessage = result.unwrapErr().toString();
setState(() {
_firmwareUserMessage = errorMessage;
});
if (errorMessage.startsWith(universalShifterBootMetadataWarningMessage)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
),
),
);
}
}
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: () => unawaited(_dismissToDevices()),
);
}
}

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/service/shifter_service.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.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/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:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart'; import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart'; import '../controller/bluetooth.dart';
import '../database/database.dart'; import '../database/database.dart';
final _log = Logger('DeviceDetailsPage');
class DeviceDetailsPage extends ConsumerStatefulWidget { class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({ const DeviceDetailsPage({
required this.deviceAddress, required this.deviceAddress,
@ -51,18 +53,25 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
2.77, 2.77,
3.27, 3.27,
]; ];
static const List<Duration> _initialStatusRetryDelays = [
Duration(milliseconds: 500),
Duration(milliseconds: 1500),
Duration(seconds: 3),
];
bool _isExitingPage = false; bool _isExitingPage = false;
bool _hasRequestedDisconnect = false; bool _hasRequestedDisconnect = false;
bool _hasShownPairingRecoveryDialog = false;
bool _isAssignTrainerDialogOpen = false; bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false; bool _isManualReconnectRunning = false;
bool _isPairingCheckRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>? ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription; _connectionStatusSubscription;
BluetoothController? _bluetooth;
ShifterService? _shifterService; ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription; StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus; CentralStatus? _latestStatus;
String? _pairingError;
final List<_StatusHistoryEntry> _statusHistory = []; final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false; bool _isGearRatiosLoading = false;
@ -76,12 +85,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
late final FirmwareFileSelectionService _firmwareFileSelectionService; late final FirmwareFileSelectionService _firmwareFileSelectionService;
FirmwareUpdateService? _firmwareUpdateService; FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription; StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuV1PreparedFirmware? _selectedFirmware; BootloaderDfuPreparedFirmware? _selectedFirmware;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress( DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle, state: DfuUpdateState.idle,
totalBytes: 0, totalBytes: 0,
sentBytes: 0, sentBytes: 0,
lastAckedSequence: 0xFF, expectedOffset: 0,
sessionId: 0, sessionId: 0,
flags: DfuUpdateFlags(), flags: DfuUpdateFlags(),
); );
@ -95,9 +104,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
switch (_dfuProgress.state) { switch (_dfuProgress.state) {
case DfuUpdateState.starting: case DfuUpdateState.starting:
case DfuUpdateState.waitingForAck: case DfuUpdateState.enteringBootloader:
case DfuUpdateState.connectingBootloader:
case DfuUpdateState.waitingForStatus:
case DfuUpdateState.erasing:
case DfuUpdateState.transferring: case DfuUpdateState.transferring:
case DfuUpdateState.finishing: case DfuUpdateState.finishing:
case DfuUpdateState.rebooting:
case DfuUpdateState.verifying:
return true; return true;
case DfuUpdateState.idle: case DfuUpdateState.idle:
case DfuUpdateState.completed: case DfuUpdateState.completed:
@ -129,7 +143,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override @override
void dispose() { void dispose() {
unawaited(_disconnectOnClose()); _log.info(
'Disposing device details page for ${widget.deviceAddress}; '
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
);
final bluetooth = _bluetooth;
unawaited(
_disconnectOnClose(bluetooth: bluetooth, allowRefRead: false),
);
_connectionStatusSubscription?.close(); _connectionStatusSubscription?.close();
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_shifterService?.dispose(); _shifterService?.dispose();
@ -138,22 +159,33 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
super.dispose(); super.dispose();
} }
Future<void> _disconnectOnClose() async { Future<void> _disconnectOnClose({
BluetoothController? bluetooth,
bool allowRefRead = true,
}) async {
if (_isFirmwareUpdateBusy) { if (_isFirmwareUpdateBusy) {
_log.info('Skipping disconnect on close because firmware update is busy');
return; return;
} }
if (_hasRequestedDisconnect) { if (_hasRequestedDisconnect) {
_log.fine('Disconnect on close already requested');
return; return;
} }
_hasRequestedDisconnect = true; _hasRequestedDisconnect = true;
_isExitingPage = true; _isExitingPage = true;
final bluetoothController = bluetooth ??
_bluetooth ??
(allowRefRead ? ref.read(bluetoothProvider).value : null);
if (bluetoothController != null) {
_bluetooth = bluetoothController;
}
await _disposeFirmwareUpdateService(); await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value; await bluetoothController?.disconnect();
await bluetooth?.disconnect();
await _stopStatusStreaming(); await _stopStatusStreaming();
} }
@ -164,6 +196,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final (status, connectedDeviceId) = data; final (status, connectedDeviceId) = data;
final isCurrentDevice = connectedDeviceId == widget.deviceAddress; 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) { if (isCurrentDevice && status == ConnectionStatus.connected) {
_startStatusStreamingIfNeeded(); _startStatusStreamingIfNeeded();
@ -185,6 +224,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (_shifterService != null) { if (_shifterService != null) {
final bluetooth = ref.read(bluetoothProvider).value; final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth != null) {
_bluetooth = bluetooth;
}
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) { if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
return; return;
} }
@ -203,6 +245,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
return; return;
} }
if (_isPairingCheckRunning) {
return;
}
final asyncBluetooth = ref.read(bluetoothProvider); final asyncBluetooth = ref.read(bluetoothProvider);
final BluetoothController bluetooth; final BluetoothController bluetooth;
if (asyncBluetooth.hasValue) { if (asyncBluetooth.hasValue) {
@ -210,6 +255,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} else { } else {
bluetooth = await ref.read(bluetoothProvider.future); bluetooth = await ref.read(bluetoothProvider.future);
} }
_bluetooth = bluetooth;
if (!isCurrentDeviceConnected(bluetooth)) { if (!isCurrentDeviceConnected(bluetooth)) {
return; return;
} }
@ -218,15 +264,45 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
buttonDeviceId: widget.deviceAddress, 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) {
_bluetooth = bluetooth;
}
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
break;
}
initialStatusResult = await service.readStatus();
}
if (!mounted) { if (!mounted) {
await service.dispose(); await service.dispose();
return; return;
} }
if (initialStatusResult.isErr()) { if (initialStatusResult.isErr()) {
final error = initialStatusResult.unwrapErr();
await service.dispose(); await service.dispose();
await _showPairingRecoveryDialog(); setState(() {
_isPairingCheckRunning = false;
_pairingError = error.toString();
_latestStatus = null;
});
return; return;
} }
@ -242,20 +318,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
service.startStatusNotifications(); service.startStatusNotifications();
setState(() { setState(() {
_shifterService = service; _shifterService = service;
_isPairingCheckRunning = false;
_pairingError = null;
}); });
unawaited(_loadGearRatios()); unawaited(_loadGearRatios());
unawaited(_loadDeviceTelemetry()); unawaited(_loadDeviceTelemetry());
} }
Future<void> _showPairingRecoveryDialog() async {
if (!mounted || _hasShownPairingRecoveryDialog) {
return;
}
_hasShownPairingRecoveryDialog = true;
await showBluetoothPairingRecoveryDialog(context);
}
void _recordStatus(CentralStatus status) { void _recordStatus(CentralStatus status) {
setState(() { setState(() {
_latestStatus = status; _latestStatus = status;
@ -283,6 +352,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
await _disposeFirmwareUpdateService(); await _disposeFirmwareUpdateService();
await _shifterService?.dispose(); await _shifterService?.dispose();
_shifterService = null; _shifterService = null;
_isPairingCheckRunning = false;
_isDeviceTelemetryLoading = false; _isDeviceTelemetryLoading = false;
_hasLoadedDeviceTelemetry = false; _hasLoadedDeviceTelemetry = false;
} }
@ -408,20 +478,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; 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(); await _startStatusStreamingIfNeeded();
final shifter = _shifterService; final shifter = _shifterService;
if (shifter == null) { if (shifter == null) {
@ -433,8 +489,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
); );
return; 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) { if (!mounted) {
return; return;
} }
@ -449,15 +523,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
ScaffoldMessenger.of(context).showSnackBar( 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 { Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
final shifter = _shifterService;
if (shifter == null) {
return null;
}
if (_firmwareUpdateService != null) { if (_firmwareUpdateService != null) {
return _firmwareUpdateService; return _firmwareUpdateService;
} }
@ -467,10 +543,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (bluetooth == null) { if (bluetooth == null) {
return null; return null;
} }
_bluetooth = bluetooth;
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: ShifterFirmwareUpdateTransport( transport: ShifterFirmwareUpdateTransport(
shifterService: shifter, shifterService: _shifterService,
bluetoothController: bluetooth, bluetoothController: bluetooth,
buttonDeviceId: widget.deviceAddress, buttonDeviceId: widget.deviceAddress,
), ),
@ -480,6 +557,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
_log.info(
'Firmware progress: state=${progress.state}, '
'sent=${progress.sentBytes}/${progress.totalBytes}, '
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
);
setState(() { setState(() {
_dfuProgress = progress; _dfuProgress = progress;
if (progress.state == DfuUpdateState.failed && if (progress.state == DfuUpdateState.failed &&
@ -488,7 +570,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
if (progress.state == DfuUpdateState.completed) { if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage = _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) { if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.'; _firmwareUserMessage = 'Firmware update canceled.';
@ -510,7 +592,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_firmwareUserMessage = null; _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) { if (!mounted) {
return; return;
} }
@ -520,7 +614,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (result.isSuccess) { if (result.isSuccess) {
_selectedFirmware = result.firmware; _selectedFirmware = result.firmware;
_firmwareUserMessage = _firmwareUserMessage =
'Selected ${result.firmware!.fileName}. Ready to start update.'; 'Validated ${result.firmware!.fileName}. Ready for bootloader update.';
} else if (!result.isCanceled) { } else if (!result.isCanceled) {
_firmwareUserMessage = result.failure?.message; _firmwareUserMessage = result.failure?.message;
} }
@ -541,7 +635,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; return;
} }
await _startStatusStreamingIfNeeded();
final updater = await _ensureFirmwareUpdateService(); final updater = await _ensureFirmwareUpdateService();
if (!mounted) { if (!mounted) {
return; return;
@ -557,12 +650,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
setState(() { setState(() {
_isStartingFirmwareUpdate = true; _isStartingFirmwareUpdate = true;
_firmwareUserMessage = _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( final result = await updater.startUpdate(
imageBytes: firmware.fileBytes, imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId, sessionId: firmware.metadata.sessionId,
appStart: firmware.metadata.appStart,
imageVersion: firmware.metadata.imageVersion,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags), flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
); );
@ -577,6 +672,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
}); });
if (result.isErr() &&
result.unwrapErr().toString().startsWith(
universalShifterBootMetadataWarningMessage,
)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
),
),
);
}
if (result.isOk()) { if (result.isOk()) {
_hasLoadedDeviceTelemetry = false; _hasLoadedDeviceTelemetry = false;
unawaited(_loadDeviceTelemetry(force: true)); unawaited(_loadDeviceTelemetry(force: true));
@ -588,13 +696,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
case DfuUpdateState.idle: case DfuUpdateState.idle:
return 'Idle'; return 'Idle';
case DfuUpdateState.starting: case DfuUpdateState.starting:
return 'Sending START command'; return 'Preparing update';
case DfuUpdateState.waitingForAck: case DfuUpdateState.enteringBootloader:
return 'Waiting for ACK from button'; 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: case DfuUpdateState.transferring:
return 'Transferring firmware frames'; return 'Transferring firmware image';
case DfuUpdateState.finishing: 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: case DfuUpdateState.completed:
return 'Update completed'; return 'Update completed';
case DfuUpdateState.aborted: case DfuUpdateState.aborted:
@ -614,10 +732,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; 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 { Future<void> _manualReconnect() async {
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) { if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
return; return;
@ -629,6 +743,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
try { try {
final bluetooth = await ref.read(bluetoothProvider.future); final bluetooth = await ref.read(bluetoothProvider.future);
_bluetooth = bluetooth;
final result = await bluetooth.connectById( final result = await bluetooth.connectById(
widget.deviceAddress, widget.deviceAddress,
timeout: const Duration(seconds: 10), timeout: const Duration(seconds: 10),
@ -639,6 +754,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
if (result.isErr()) { if (result.isErr()) {
if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -664,17 +784,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 { Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) { if (_isFirmwareUpdateBusy) {
_log.warning('Blocked page exit while firmware update is busy');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( 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; return;
} }
_log.info('Exiting device details page to /devices');
await _disconnectOnClose(); await _disconnectOnClose();
if (!mounted) { if (!mounted) {
return; return;
@ -682,6 +823,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
context.go('/devices'); 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() { void _showStatusHistory() {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
@ -799,12 +957,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
: ConnectionStatus.disconnected; : ConnectionStatus.disconnected;
final isCurrentConnected = final isCurrentConnected =
currentConnectionStatus == ConnectionStatus.connected; currentConnectionStatus == ConnectionStatus.connected;
final hasDeviceAccess =
isCurrentConnected && _shifterService != null && _latestStatus != null;
final canUseFirmwareUpdate = hasDeviceAccess;
final canSelectFirmware = final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected && final canStartFirmware = canUseFirmwareUpdate &&
!_isSelectingFirmware && !_isSelectingFirmware &&
!_isFirmwareUpdateBusy && !_isFirmwareUpdateBusy &&
_selectedFirmware != null; _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( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) { onPopInvokedWithResult: (bool didPop, bool? result) {
@ -834,8 +1012,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
status: _latestStatus, status: _latestStatus,
), ),
const SizedBox(height: 20), 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), const SizedBox(height: 16),
],
if (hasDeviceAccess) ...[
_StatusBanner( _StatusBanner(
status: _latestStatus, status: _latestStatus,
onTap: _showStatusHistory, onTap: _showStatusHistory,
@ -851,22 +1047,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
_TrainerConnectionCard( _TrainerConnectionCard(
status: _latestStatus, status: _latestStatus,
onAssign: onAssign: _connectButtonToBike,
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
onShowStatusConsole: _showStatusHistory, onShowStatusConsole: _showStatusHistory,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Opacity( GearRatioEditorCard(
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
child: AbsorbPointer(
absorbing: _isFirmwareUpdateBusy,
child: GearRatioEditorCard(
ratios: _gearRatios, ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex, defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading, isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError, errorText: _gearRatiosError,
onRetry: onRetry: _loadGearRatios,
_isFirmwareUpdateBusy ? null : _loadGearRatios,
onSave: _saveGearRatios, onSave: _saveGearRatios,
presets: const [ presets: const [
GearRatioPreset( GearRatioPreset(
@ -934,23 +1124,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
), ),
], ],
), ),
), ] else if (isCurrentConnected) ...[
), _PairingRequiredCard(
const SizedBox(height: 16), isChecking: _isPairingCheckRunning,
_FirmwareUpdateCard( errorText: _pairingError,
selectedFirmware: _selectedFirmware, onRetry: _retryPairing,
progress: _dfuProgress, onOpenBluetoothSettings: _openPairingSettings,
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 ...[ ] else ...[
_DisconnectedDetailCard( _DisconnectedDetailCard(
@ -990,12 +1169,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
required this.phaseText, required this.phaseText,
required this.statusText, required this.statusText,
required this.formattedProgressBytes, required this.formattedProgressBytes,
required this.ackSequenceHex, required this.expectedOffsetHex,
required this.onSelectFirmware, required this.onSelectFirmware,
required this.onStartUpdate, required this.onStartUpdate,
}); });
final DfuV1PreparedFirmware? selectedFirmware; final BootloaderDfuPreparedFirmware? selectedFirmware;
final DfuUpdateProgress progress; final DfuUpdateProgress progress;
final bool isSelecting; final bool isSelecting;
final bool isStarting; final bool isStarting;
@ -1004,7 +1183,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
final String phaseText; final String phaseText;
final String? statusText; final String? statusText;
final String formattedProgressBytes; final String formattedProgressBytes;
final String ackSequenceHex; final String expectedOffsetHex;
final Future<void> Function() onSelectFirmware; final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate; final Future<void> Function() onStartUpdate;
@ -1016,9 +1195,33 @@ class _FirmwareUpdateCard extends StatelessWidget {
bool get _showRebootExpectation { bool get _showRebootExpectation {
return progress.state == DfuUpdateState.finishing || return progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying ||
progress.state == DfuUpdateState.completed; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -1043,7 +1246,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68), color: colorScheme.onSurface.withValues(alpha: 0.68),
), ),
@ -1102,6 +1305,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()}', 'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall, 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 +1325,21 @@ class _FirmwareUpdateCard extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'${progress.percentComplete}% • $formattedProgressBytesLast ACK $ackSequenceHex', '${progress.percentComplete}% • $formattedProgressBytesExpected offset $expectedOffsetHex',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 4),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall,
),
],
], ],
if (_showRebootExpectation) ...[ if (_showRebootExpectation) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -1517,6 +1732,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 { class _DisconnectedDetailCard extends StatelessWidget {
const _DisconnectedDetailCard({ const _DisconnectedDetailCard({
required this.isReconnecting, 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/controller/bluetooth.dart';
import 'package:abawo_bt_app/database/database.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/bluetooth_device_model.dart';
@ -92,14 +90,6 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
switch (res) { switch (res) {
case Ok(): 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 notifier = ref.read(nConnectedDevicesProvider.notifier);
final name = device.name.isNotEmpty ? device.name : 'Unknown Device'; final name = device.name.isNotEmpty ? device.name : 'Unknown Device';
final deviceCompanion = ConnectedDevicesCompanion( final deviceCompanion = ConnectedDevicesCompanion(
@ -126,12 +116,11 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
} }
break; break;
case Err(:final v): case Err(:final v):
final error = v.toString(); if (isBluetoothPairingRecoveryError(v)) {
if (error.toLowerCase().contains('disconnected')) {
await showBluetoothPairingRecoveryDialog(context); await showBluetoothPairingRecoveryDialog(context);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection unsuccessful:\n$error')), SnackBar(content: Text('Connection unsuccessful:\n$v')),
); );
} }
break; break;

View File

@ -1,18 +1,176 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
import 'package:abawo_bt_app/database/database.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/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:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:flutter/material.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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class DevicesTabPage extends ConsumerWidget { class DevicesTabPage extends ConsumerStatefulWidget {
const DevicesTabPage({super.key}); const DevicesTabPage({super.key});
@override @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;
BluetoothController? _bluetooth;
DiscoveredDevice? _dfuDevice;
bool _isBootloaderScanStarting = false;
bool _isDisposed = false;
@override
void initState() {
super.initState();
unawaited(_startBootloaderBackgroundScan());
}
@override
void dispose() {
_isDisposed = true;
final bluetooth = _bluetooth;
unawaited(_stopBootloaderScan(bluetooth));
super.dispose();
}
Future<void> _startBootloaderBackgroundScan() async {
if (_isBootloaderScanStarting || _scanSubscription != null) {
return;
}
_isBootloaderScanStarting = true;
_clearBootloaderDevice();
try {
final bluetooth = await ref.read(bluetoothProvider.future);
if (!mounted || _isDisposed) {
return;
}
_bluetooth = bluetooth;
final scanResult = await bluetooth.startScan(
timeout: _bootloaderScanTimeout,
scanMode: ScanMode.lowLatency,
);
if (!mounted || _isDisposed) {
await bluetooth.stopScan();
return;
}
if (scanResult.isErr()) {
return;
}
_updateBootloaderDevice(bluetooth.scanResults);
_scanSubscription = bluetooth.scanResultsStream.listen(
_updateBootloaderDevice,
);
} finally {
_isBootloaderScanStarting = false;
}
}
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
final subscription = _scanSubscription;
_scanSubscription = null;
await subscription?.cancel();
await bluetooth?.stopScan();
}
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
if (_isDisposed || !mounted) {
return;
}
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
(device) => device != null && _isBootloaderAdvertisement(device),
orElse: () => null,
);
if (dfuDevice == null) {
_clearBootloaderDevice();
return;
}
if (dfuDevice.id == _dfuDevice?.id) {
return;
}
setState(() {
_dfuDevice = dfuDevice;
});
}
void _clearBootloaderDevice() {
if (_isDisposed || !mounted || _dfuDevice == null) {
return;
}
setState(() {
_dfuDevice = null;
});
}
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 || _isDisposed || firmware == null) {
return;
}
await _stopBootloaderScan();
if (!mounted || _isDisposed) {
return;
}
_clearBootloaderDevice();
context.push(
'/bootloader_recovery_update',
extra: BootloaderRecoveryUpdateArgs(
bootloaderDeviceId: device.id,
firmware: firmware,
),
);
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(nConnectedDevicesProvider); final devicesAsync = ref.watch(nConnectedDevicesProvider);
final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final dfuDevice = _dfuDevice;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
@ -50,6 +208,13 @@ class DevicesTabPage extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (dfuDevice != null) ...[
_BootloaderRecoveryCard(
device: dfuDevice,
onStartRecovery: _openBootloaderRecovery,
),
const SizedBox(height: 20),
],
devicesAsync.when( devicesAsync.when(
loading: () => const _LoadingCard(), loading: () => const _LoadingCard(),
error: (error, _) => _MessageCard( error: (error, _) => _MessageCard(
@ -86,6 +251,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 { class _SavedDevicesList extends ConsumerStatefulWidget {
const _SavedDevicesList(); const _SavedDevicesList();
@ -178,6 +596,8 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
} }
context.push('/device/${device.deviceAddress}'); context.push('/device/${device.deviceAddress}');
} else if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

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

View File

@ -2,112 +2,170 @@ import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/model/shifter_types.dart';
const int _startPayloadLength = 11; const int _startPayloadLength = 19;
class DfuStartPayload { class BootloaderDfuStartPayload {
const DfuStartPayload({ const BootloaderDfuStartPayload({
required this.totalLength, required this.totalLength,
required this.imageCrc32, required this.imageCrc32,
this.appStart = universalShifterDfuAppStart,
this.imageVersion = 0,
required this.sessionId, required this.sessionId,
required this.flags, required this.flags,
}); });
final int totalLength; final int totalLength;
final int imageCrc32; final int imageCrc32;
final int appStart;
final int imageVersion;
final int sessionId; final int sessionId;
final int flags; final int flags;
} }
class DfuDataFrame { class BootloaderDfuDataFrame {
const DfuDataFrame({ const BootloaderDfuDataFrame({
required this.sequence, required this.sessionId,
required this.offset, required this.offset,
required this.payloadLength, required this.payloadLength,
required this.bytes, required this.bytes,
}); });
final int sequence; final int sessionId;
final int offset; final int offset;
final int payloadLength; final int payloadLength;
final Uint8List bytes; final Uint8List bytes;
} }
class DfuProtocol { class BootloaderDfuProtocol {
const DfuProtocol._(); 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); final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart); data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little); data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, Endian.little); data.setUint32(5, payload.imageCrc32, Endian.little);
data.setUint8(9, payload.sessionId); data.setUint32(9, payload.appStart, Endian.little);
data.setUint8(10, payload.flags); data.setUint32(13, payload.imageVersion, Endian.little);
data.setUint8(17, payload.sessionId & 0xFF);
data.setUint8(18, payload.flags & 0xFF);
return data.buffer.asUint8List(); return data.buffer.asUint8List();
} }
static Uint8List encodeFinishPayload() { static Uint8List encodeFinishPayload(int sessionId) {
return Uint8List.fromList([universalShifterDfuOpcodeFinish]); return Uint8List.fromList([
universalShifterDfuOpcodeFinish,
sessionId & 0xFF,
]);
} }
static Uint8List encodeAbortPayload() { static Uint8List encodeAbortPayload(int sessionId) {
return Uint8List.fromList([universalShifterDfuOpcodeAbort]); return Uint8List.fromList([
universalShifterDfuOpcodeAbort,
sessionId & 0xFF,
]);
} }
static List<DfuDataFrame> buildDataFrames( static Uint8List encodeGetStatusPayload() {
List<int> imageBytes, { return Uint8List.fromList([universalShifterDfuOpcodeGetStatus]);
int startSequence = 0, }
static BootloaderDfuDataFrame buildDataFrame({
required List<int> imageBytes,
required int sessionId,
required int offset,
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
}) { }) {
final frames = <DfuDataFrame>[]; if (offset < 0 || offset >= imageBytes.length) {
var seq = _asU8(startSequence); throw RangeError.range(offset, 0, imageBytes.length - 1, 'offset');
var offset = 0; }
while (offset < imageBytes.length) { if (payloadSize <= 0 ||
payloadSize > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
throw RangeError.range(
payloadSize,
1,
universalShifterBootloaderDfuMaxPayloadSizeBytes,
'payloadSize',
);
}
final remaining = imageBytes.length - offset; final remaining = imageBytes.length - offset;
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes final payloadLength = remaining < payloadSize ? remaining : payloadSize;
? remaining final payloadEnd = offset + payloadLength;
: universalShifterDfuFramePayloadSizeBytes; final payload = imageBytes.sublist(offset, payloadEnd);
final frame = Uint8List(
final frame = Uint8List(universalShifterDfuFrameSizeBytes); universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
frame[0] = seq;
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
frames.add(
DfuDataFrame(
sequence: seq,
offset: offset,
payloadLength: chunkLength,
bytes: frame,
),
); );
offset += chunkLength; frame[0] = sessionId & 0xFF;
seq = nextSequence(seq); 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; return frames;
} }
static int nextSequence(int sequence) { static int maxPayloadSizeForMtu(int negotiatedMtu) {
return _asU8(sequence + 1); final writePayloadBytes =
negotiatedMtu - universalShifterAttWriteOverheadBytes;
final availablePayload =
writePayloadBytes - universalShifterBootloaderDfuDataHeaderSizeBytes;
if (availablePayload <= 0) {
return 0;
}
if (availablePayload > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
return universalShifterBootloaderDfuMaxPayloadSizeBytes;
}
return availablePayload;
} }
static int rewindSequenceFromAck(int acknowledgedSequence) { static DfuBootloaderStatus parseStatusPayload(List<int> payload) {
return nextSequence(acknowledgedSequence); if (payload.length != universalShifterBootloaderDfuStatusSizeBytes) {
throw const FormatException(
'DFU status payload must be exactly 6 bytes.');
} }
final data = ByteData.sublistView(Uint8List.fromList(payload));
static int sequenceDistance(int from, int to) { final rawCode = data.getUint8(0);
return _asU8(to - from); return DfuBootloaderStatus(
code: DfuBootloaderStatusCode.fromRaw(rawCode),
rawCode: rawCode,
sessionId: data.getUint8(1),
expectedOffset: data.getUint32(2, Endian.little),
);
} }
static int parseAckPayload(List<int> payload) {
if (payload.length != 1) {
throw const FormatException('ACK payload must be exactly 1 byte.');
}
return _asU8(payload.first);
}
static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320;
static int crc32Update(int crc, List<int> bytes) { static int crc32Update(int crc, List<int> bytes) {
var next = crc & 0xFFFFFFFF; var next = crc & 0xFFFFFFFF;
for (final byte in bytes) { for (final byte in bytes) {
@ -130,8 +188,4 @@ class DfuProtocol {
static int crc32(List<int> bytes) { static int crc32(List<int> bytes) {
return crc32Finalize(crc32Update(crc32Initial, 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 FirmwareFilePicker _filePicker;
final SessionIdGenerator _sessionIdGenerator; final SessionIdGenerator _sessionIdGenerator;
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async { Future<FirmwareFileSelectionResult> selectAndPrepareBootloaderDfu() async {
final FirmwarePickerSelection? selection; final FirmwarePickerSelection? selection;
try { try {
selection = await _filePicker.pickFirmwareFile(); 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, totalLength: selection.fileBytes.length,
crc32: DfuProtocol.crc32(selection.fileBytes), crc32: BootloaderDfuProtocol.crc32(selection.fileBytes),
sessionId: _sessionIdGenerator() & 0xFF, appStart: universalShifterDfuAppStart,
imageVersion: 0,
sessionId: sessionId,
flags: universalShifterDfuFlagNone, flags: universalShifterDfuFlagNone,
vectorStackPointer: vectorStackPointer,
vectorReset: vectorReset,
); );
return FirmwareFileSelectionResult.success( return FirmwareFileSelectionResult.success(
DfuV1PreparedFirmware( BootloaderDfuPreparedFirmware(
fileName: fileName, fileName: fileName,
filePath: selection.filePath, filePath: selection.filePath,
fileBytes: selection.fileBytes, fileBytes: selection.fileBytes,
@ -148,7 +165,64 @@ class FirmwareFileSelectionService {
return fileName.toLowerCase().endsWith('.bin'); 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() { 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 { class ShifterService {
ShifterService({ ShifterService({
BluetoothController? bluetooth, required BluetoothController bluetooth,
required this.buttonDeviceId, required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth, }) : _bluetooth = bluetooth;
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
final BluetoothController? _bluetooth; final BluetoothController _bluetooth;
final String buttonDeviceId; final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth { BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth; return _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
} }
final StreamController<CentralStatus> _statusController = final StreamController<CentralStatus> _statusController =
@ -46,9 +32,11 @@ class ShifterService {
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1; static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
static const int _gearRatioWriteMtu = 64; static const int _gearRatioWriteMtu = 64;
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async { Future<Result<void>> writeConnectToTrainerAddress(
TrainerAddress trainerAddress,
) async {
try { try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId); final payload = encodeTrainerAddress(trainerAddress);
return _requireBluetooth.writeCharacteristic( return _requireBluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -56,12 +44,30 @@ class ShifterService {
payload, payload,
); );
} on FormatException catch (e) { } on FormatException catch (e) {
return bail('Could not parse bike address "$bikeDeviceId": $e'); return bail('Could not encode trainer address: $e');
} catch (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) { Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _requireBluetooth.writeCharacteristic( return _requireBluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
@ -71,8 +77,10 @@ class ShifterService {
); );
} }
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async { Future<Result<void>> connectButtonToTrainer(
final addrRes = await writeConnectToAddress(bikeDeviceId); TrainerAddress trainerAddress,
) async {
final addrRes = await writeConnectToTrainerAddress(trainerAddress);
if (addrRes.isErr()) { if (addrRes.isErr()) {
return addrRes; 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() { void startStatusNotifications() {
if (_statusSubscription != null) { if (_statusSubscription != null) {
return; 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 { class GearRatiosData {
const GearRatiosData({ const GearRatiosData({
required this.ratios, required this.ratios,

View File

@ -1,31 +1,40 @@
import 'dart:io'; import 'dart:io';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const MethodChannel _settingsChannel = MethodChannel('abawo/settings'); bool isBluetoothPairingRecoveryError(Object error) {
return error.toString().toLowerCase().contains('disconnected');
}
Future<bool> openBluetoothSettings() async { Future<bool> openBluetoothSettings() async {
if (!Platform.isAndroid) { try {
if (Platform.isAndroid) {
await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
} else if (Platform.isIOS) {
await AppSettings.openAppSettings();
} else {
return false; return false;
} }
return true;
try { } catch (_) {
return await _settingsChannel.invokeMethod<bool>('openBluetoothSettings') ??
false;
} on PlatformException {
return false; return false;
} }
} }
Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) { 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>( return showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Bluetooth pairing may be broken'), title: const Text('Bluetooth pairing may be broken'),
content: const Text( content: Text(content),
'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.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
@ -36,7 +45,7 @@ Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
Navigator.of(context).pop(); Navigator.of(context).pop();
await openBluetoothSettings(); await openBluetoothSettings();
}, },
child: const Text('Open Bluetooth settings'), child: Text(settingsButtonLabel),
), ),
], ],
), ),

View File

@ -1,39 +1,39 @@
import 'dart:async'; 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/model/shifter_types.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:flutter/material.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({ const BikeScanDialog({
required this.excludedDeviceId, required this.shifter,
super.key, super.key,
}); });
final String excludedDeviceId; final ShifterService shifter;
static Future<DiscoveredDevice?> show( static Future<TrainerScanResult?> show(
BuildContext context, { BuildContext context, {
required String excludedDeviceId, required ShifterService shifter,
}) { }) {
return showDialog<DiscoveredDevice>( return showDialog<TrainerScanResult>(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId), builder: (_) => BikeScanDialog(shifter: shifter),
); );
} }
@override @override
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState(); State<BikeScanDialog> createState() => _BikeScanDialogState();
} }
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> { class _BikeScanDialogState extends State<BikeScanDialog> {
bool _showAll = false; bool _showOnlyFtms = true;
bool _isStartingScan = true; bool _isStartingScan = true;
bool _isScanning = false;
String? _scanError; String? _scanError;
BluetoothController? _controller; final Map<String, TrainerScanResult> _resultsByAddress = {};
StreamSubscription<TrainerScanEvent>? _scanSubscription;
@override @override
void initState() { void initState() {
@ -42,16 +42,39 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
} }
Future<void> _startScan() async { Future<void> _startScan() async {
await _scanSubscription?.cancel();
if (_isScanning) {
await widget.shifter.stopTrainerScan();
}
setState(() { setState(() {
_isStartingScan = true; _isStartingScan = true;
_isScanning = false;
_scanError = null; _scanError = null;
_resultsByAddress.clear();
}); });
try { try {
final controller = await ref.read(bluetoothProvider.future); _scanSubscription = widget.shifter.subscribeToTrainerScanResults().listen(
_controller = controller; _handleScanEvent,
await controller.stopScan(); onError: (Object error) {
await controller.startScan(); 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) { } catch (error) {
_scanError = error.toString(); _scanError = error.toString();
} finally { } 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 @override
void dispose() { void dispose() {
_controller?.stopScan(); _scanSubscription?.cancel();
if (_isScanning) {
unawaited(widget.shifter.stopTrainerScan());
}
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final btAsync = ref.watch(bluetoothProvider);
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
final dialogWidth = size.width < 640 ? size.width - 24 : 560.0; final dialogWidth = size.width < 640 ? size.width - 24 : 560.0;
final dialogHeight = size.height < 780 ? size.height * 0.78 : 560.0; final dialogHeight = size.height < 780 ? size.height * 0.78 : 560.0;
@ -83,213 +134,85 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
child: SizedBox( child: SizedBox(
width: dialogWidth, width: dialogWidth,
height: dialogHeight, height: dialogHeight,
child: btAsync.when( child: Column(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
data: (controller) {
_controller ??= controller;
return Column(
children: [ children: [
_DialogHeader( _DialogHeader(
showAll: _showAll, showOnlyFtms: _showOnlyFtms,
isScanning: _isStartingScan, isScanning: _isStartingScan || _isScanning,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_showAll = value; _showOnlyFtms = value;
}); });
}, },
onRescan: _startScan, onRescan: _startScan,
), ),
Expanded( Expanded(child: _buildBody(context)),
child: _scanError != null ],
? _ScanMessage( ),
message: 'Could not start trainer scan: $_scanError', ),
);
}
Widget _buildBody(BuildContext context) {
if (_scanError != null) {
return _ScanMessage(
message: 'Could not start shifter trainer scan: $_scanError',
action: TextButton.icon( action: TextButton.icon(
onPressed: _startScan, onPressed: _startScan,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Retry'), 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 = if (_isStartingScan && _resultsByAddress.isEmpty) {
_filteredDevices(snapshot.data ?? const []); return const Center(child: CircularProgressIndicator());
}
final devices = _filteredDevices();
if (devices.isEmpty) { if (devices.isEmpty) {
return const _ScanMessage( return _ScanMessage(
message: message: _isScanning
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.', ? '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( return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length, itemCount: devices.length,
separatorBuilder: (_, __) => separatorBuilder: (_, __) => const SizedBox(height: 12),
const SizedBox(height: 12), itemBuilder: (context, index) => _TrainerScanResultTile(
itemBuilder: (context, index) { result: devices[index],
final device = devices[index]; onTap: () => Navigator.of(context).pop(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),
),
],
),
],
),
),
),
);
},
);
},
),
),
],
);
},
),
), ),
); );
} }
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) { List<TrainerScanResult> _filteredDevices() {
final ftmsUuid = Uuid.parse(ftmsServiceUuid); final devices = _resultsByAddress.values.where((device) {
return devices.where((device) { return !_showOnlyFtms || device.ftmsDetected;
if (device.id == widget.excludedDeviceId) {
return false;
}
if (_showAll) {
return true;
}
return device.serviceUuids.contains(ftmsUuid);
}).toList(growable: false); }).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 { class _DialogHeader extends StatelessWidget {
const _DialogHeader({ const _DialogHeader({
required this.showAll, required this.showOnlyFtms,
required this.isScanning, required this.isScanning,
required this.onChanged, required this.onChanged,
required this.onRescan, required this.onRescan,
}); });
final bool showAll; final bool showOnlyFtms;
final bool isScanning; final bool isScanning;
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
final VoidCallback onRescan; final VoidCallback onRescan;
@ -316,7 +239,7 @@ class _DialogHeader extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( 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( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@ -341,13 +264,13 @@ class _DialogHeader extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
'Show All', 'FTMS only',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 8), 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 { class _ScanMessage extends StatelessWidget {
const _ScanMessage({ const _ScanMessage({
required this.message, 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" #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" #include "ephemeral/Flutter-Generated.xcconfig"

View File

@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import app_settings
import connectivity_plus import connectivity_plus
import file_picker import file_picker
import flutter_blue_plus_darwin import flutter_blue_plus_darwin
@ -15,6 +16,7 @@ import shared_preferences_foundation
import sqlite3_flutter_libs import sqlite3_flutter_libs
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) 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" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" 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: args:
dependency: transitive dependency: transitive
description: description:

View File

@ -54,6 +54,7 @@ dependencies:
flutter_reactive_ble: ^5.4.0 flutter_reactive_ble: ^5.4.0
nb_utils: ^7.2.0 nb_utils: ^7.2.0
file_picker: ^8.1.7 file_picker: ^8.1.7
app_settings: ^7.0.0
dev_dependencies: dev_dependencies:
flutter_test: 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:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { 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', () { group('CentralStatus.fromBytes', () {
test('decodes status with FTMS ready', () { test('decodes status with FTMS ready', () {
final status = CentralStatus.fromBytes( 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', () { group('standard GATT telemetry parsing', () {
test('decodes battery level percentage', () { test('decodes battery level percentage', () {
expect(parseBatteryLevelPercent([0]), 0); 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,28 +3,28 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('DfuProtocol CRC32', () { group('BootloaderDfuProtocol CRC32', () {
test('matches known vector', () { test('matches known vector', () {
final crc = DfuProtocol.crc32('123456789'.codeUnits); final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits);
expect(crc, 0xCBF43926); expect(crc, 0xCBF43926);
}); });
}); });
group('DfuProtocol control payload encoding', () { group('BootloaderDfuProtocol control payload encoding', () {
test('encodes START payload with exact 11-byte LE layout', () { test('encodes START payload with exact 19-byte LE layout', () {
final payload = DfuProtocol.encodeStartPayload( final payload = BootloaderDfuProtocol.encodeStartPayload(
const DfuStartPayload( const BootloaderDfuStartPayload(
totalLength: 0x1234, totalLength: 0x1234,
imageCrc32: 0x89ABCDEF, imageCrc32: 0x89ABCDEF,
appStart: universalShifterDfuAppStart,
imageVersion: 0x10203040,
sessionId: 0x22, sessionId: 0x22,
flags: universalShifterDfuFlagEncrypted, flags: universalShifterDfuFlagNone,
), ),
); );
expect(payload.length, 11); expect(payload.length, 19);
expect( expect(payload, [
payload,
[
universalShifterDfuOpcodeStart, universalShifterDfuOpcodeStart,
0x34, 0x34,
0x12, 0x12,
@ -34,66 +34,127 @@ void main() {
0xCD, 0xCD,
0xAB, 0xAB,
0x89, 0x89,
0x00,
0x00,
0x03,
0x00,
0x40,
0x30,
0x20,
0x10,
0x22, 0x22,
universalShifterDfuFlagEncrypted, universalShifterDfuFlagNone,
], ]);
});
test('encodes FINISH, ABORT, and GET_STATUS payloads', () {
expect(
BootloaderDfuProtocol.encodeFinishPayload(0x12),
[universalShifterDfuOpcodeFinish, 0x12],
);
expect(
BootloaderDfuProtocol.encodeAbortPayload(0x34),
[universalShifterDfuOpcodeAbort, 0x34],
);
expect(
BootloaderDfuProtocol.encodeGetStatusPayload(),
[universalShifterDfuOpcodeGetStatus],
); );
}); });
test('encodes FINISH and ABORT payloads as one byte', () {
expect(
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
expect(
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
});
}); });
group('DfuProtocol data frame building', () { group('BootloaderDfuProtocol data frame building', () {
test('builds 64-byte frames and handles final partial payload', () { test('builds offset frames with payload CRC and variable final length', () {
final image = List<int>.generate(80, (index) => index); final image = List<int>.generate(60, (index) => index);
final frames = DfuProtocol.buildDataFrames(image); final frames = BootloaderDfuProtocol.buildDataFrames(
imageBytes: image,
sessionId: 0x7A,
);
expect(frames.length, 2); expect(frames.length, 2);
expect(frames[0].sequence, 0); expect(frames[0].sessionId, 0x7A);
expect(frames[0].offset, 0); 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.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, 55);
expect(frames[1].offset, 63); expect(frames[1].payloadLength, 5);
expect(frames[1].payloadLength, 17); expect(frames[1].bytes.length, 14);
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes); expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]);
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80)); expect(frames[1].bytes.sublist(9), image.sublist(55));
}); });
test('uses deterministic wrapping sequence numbers from custom start', () { test('uses caller supplied payload size for low-MTU links', () {
final image = List<int>.generate( final image = List<int>.generate(15, (index) => index);
3 * universalShifterDfuFramePayloadSizeBytes, final frames = BootloaderDfuProtocol.buildDataFrames(
(index) => index & 0xFF); 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); test('calculates safe payload size from negotiated MTU', () {
expect(frames[0].sequence, 0xFE); expect(
expect(frames[1].sequence, 0xFF); BootloaderDfuProtocol.maxPayloadSizeForMtu(64),
expect(frames[2].sequence, 0x00); universalShifterBootloaderDfuMaxPayloadSizeBytes - 3,
);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11);
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0);
expect(
BootloaderDfuProtocol.maxPayloadSizeForMtu(128),
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
}); });
}); });
group('DfuProtocol sequence and ACK helpers', () { group('BootloaderDfuProtocol status parsing', () {
test('wraps sequence values and computes ack+1 rewind', () { test('parses bootloader status payload', () {
expect(DfuProtocol.nextSequence(0x00), 0x01); final status = BootloaderDfuProtocol.parseStatusPayload(
expect(DfuProtocol.nextSequence(0xFF), 0x00); [0x00, 0x22, 0x78, 0x56, 0x34, 0x12],
);
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06); expect(status.code, DfuBootloaderStatusCode.ok);
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00); expect(status.rawCode, 0x00);
expect(status.sessionId, 0x22);
expect(status.expectedOffset, 0x12345678);
expect(status.isOk, isTrue);
}); });
test('computes wrapping sequence distance', () { test('preserves unknown status codes', () {
expect(DfuProtocol.sequenceDistance(250, 2), 8); final status = BootloaderDfuProtocol.parseStatusPayload(
expect(DfuProtocol.sequenceDistance(1, 1), 0); [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/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.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:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('FirmwareFileSelectionService', () { 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( final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.BIN', fileName: 'firmware.BIN',
filePath: '/tmp/firmware.BIN', filePath: '/tmp/firmware.BIN',
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]), fileBytes: image,
), ),
), ),
sessionIdGenerator: () => 0x1AB, sessionIdGenerator: () => 0x1AB,
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isTrue); expect(result.isSuccess, isTrue);
final firmware = result.firmware!; final firmware = result.firmware!;
expect(firmware.fileName, 'firmware.BIN'); expect(firmware.fileName, 'firmware.BIN');
expect(firmware.filePath, '/tmp/firmware.BIN'); expect(firmware.filePath, '/tmp/firmware.BIN');
expect(firmware.fileBytes, <int>[1, 2, 3, 4]); expect(firmware.fileBytes, image);
expect(firmware.metadata.totalLength, 4); expect(firmware.metadata.totalLength, image.length);
expect(firmware.metadata.crc32, 0xB63CFBCD); 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.sessionId, 0xAB);
expect(firmware.metadata.flags, universalShifterDfuFlagNone); expect(firmware.metadata.flags, universalShifterDfuFlagNone);
expect(firmware.metadata.vectorStackPointer, 0x20001000);
expect(firmware.metadata.vectorReset, 0x00030009);
}); });
test('returns canceled result when user dismisses picker', () async { test('returns canceled result when user dismisses picker', () async {
@ -37,7 +43,7 @@ void main() {
filePicker: _FakeFirmwareFilePicker(selection: null), filePicker: _FakeFirmwareFilePicker(selection: null),
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse); expect(result.isSuccess, isFalse);
expect(result.isCanceled, isTrue); expect(result.isCanceled, isTrue);
@ -49,12 +55,12 @@ void main() {
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.hex', 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.isSuccess, isFalse);
expect(result.failure?.reason, 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.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile); 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 { test('generates session id per run', () async {
var nextSession = 9; var nextSession = 9;
final service = FirmwareFileSelectionService( final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
selection: FirmwarePickerSelection( selection: FirmwarePickerSelection(
fileName: 'firmware.bin', fileName: 'firmware.bin',
fileBytes: Uint8List.fromList(<int>[10]), fileBytes: _validBootloaderImage(),
), ),
), ),
sessionIdGenerator: () => nextSession++, sessionIdGenerator: () => nextSession++,
); );
final first = await service.selectAndPrepareDfuV1(); final first = await service.selectAndPrepareBootloaderDfu();
final second = await service.selectAndPrepareDfuV1(); final second = await service.selectAndPrepareBootloaderDfu();
expect(first.firmware?.metadata.sessionId, 9); expect(first.firmware?.metadata.sessionId, 9);
expect(second.firmware?.metadata.sessionId, 10); 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 { test('maps picker read failure to explicit validation error', () async {
final service = FirmwareFileSelectionService( final service = FirmwareFileSelectionService(
filePicker: _FakeFirmwareFilePicker( filePicker: _FakeFirmwareFilePicker(
@ -104,7 +203,7 @@ void main() {
), ),
); );
final result = await service.selectAndPrepareDfuV1(); final result = await service.selectAndPrepareBootloaderDfu();
expect(result.isSuccess, isFalse); expect(result.isSuccess, isFalse);
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed); 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 { class _FakeFirmwareFilePicker implements FirmwareFilePicker {
_FakeFirmwareFilePicker({ _FakeFirmwareFilePicker({
required this.selection, required this.selection,

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.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/service/firmware_update_service.dart';
@ -6,241 +7,180 @@ import 'package:anyhow/anyhow.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('FirmwareUpdateService', () { group('FirmwareUpdateService bootloader flow', () {
test('completes happy path with START, data frames, and FINISH', () async { test('completes happy path with START, offset data, FINISH, and verify',
final transport = _FakeFirmwareUpdateTransport(); () async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 4, defaultStatusTimeout: const Duration(milliseconds: 100),
defaultAckTimeout: const Duration(milliseconds: 100),
); );
final image = List<int>.generate(130, (index) => index & 0xFF);
final result = await service.startUpdate( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 7, sessionId: 7,
); );
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.controlWrites.length, 2); expect(transport.steps, [
expect( 'isConnectedToBootloader',
transport.controlWrites.first.first, universalShifterDfuOpcodeStart); 'enterBootloader',
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]); 'waitForAppDisconnect',
expect(transport.dataWrites.length, greaterThanOrEqualTo(3)); 'connectToBootloader',
expect( 'optimizeBootloaderConnection',
transport.postFinishSteps, 'negotiateMtu',
[ 'readStatus',
'waitForExpectedResetDisconnect', 'waitForBootloaderDisconnect',
'reconnectForVerification', 'reconnectForVerification',
'verifyDeviceReachable', 'verifyDeviceReachable',
], ]);
); expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(
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.state, DfuUpdateState.completed);
expect(service.currentProgress.sentBytes, image.length); expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.expectedOffset, image.length);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('rewinds to ack+1 and retransmits after ACK stall', () async { test('starts directly when already connected to bootloader', () async {
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1); final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
alreadyInBootloader: true,
);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 3, defaultStatusTimeout: const Duration(milliseconds: 100),
defaultAckTimeout: const Duration(milliseconds: 100), );
maxNoProgressRetries: 4,
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( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 9, sessionId: 9,
); );
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.dataWrites.length, greaterThan(4)); expect(
expect(transport.sequenceWriteCount(1), greaterThan(1)); transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('fails after bounded retries when ACK progress times out', () async { test('completes when FINISH status is lost but bootloader disconnects',
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',
() async { () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport( final transport = _FakeFirmwareUpdateTransport(
reconnectError: 'simulated reconnect timeout', totalBytes: image.length,
suppressFinishStatus: true,
); );
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 4, defaultStatusTimeout: const Duration(milliseconds: 20),
defaultAckTimeout: const Duration(milliseconds: 100), 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( final result = await service.startUpdate(
imageBytes: image, imageBytes: image,
sessionId: 15, sessionId: 15,
); );
expect(result.isErr(), isTrue); expect(result.isOk(), isTrue);
expect( expect(
result.unwrapErr().toString(), transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
contains('expected post-FINISH reset disconnect'), expect(transport.steps, contains('reconnectForVerification'));
); expect(transport.steps, contains('verifyDeviceReachable'));
expect(service.currentProgress.state, DfuUpdateState.failed); expect(service.currentProgress.state, DfuUpdateState.completed);
expect(
transport.postFinishSteps,
['waitForExpectedResetDisconnect'],
);
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('fails when post-update status verification read fails', () async { test('fails when FINISH status is lost and bootloader stays connected',
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',
() async { () async {
const frameCount = 260; final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(); final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
disconnectAfterFinish: false,
);
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: transport, transport: transport,
defaultWindowSize: 16, defaultStatusTimeout: const Duration(milliseconds: 10),
defaultAckTimeout: const Duration(milliseconds: 100), defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
);
final image = List<int>.generate(
frameCount * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF,
); );
final result = await service.startUpdate( final result = await service.startUpdate(
@ -248,171 +188,465 @@ void main() {
sessionId: 16, 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); expect(result.isOk(), isTrue);
expect(
var ffToZeroTransitions = 0; transport.steps.where((step) => step == 'connectToBootloader').length,
for (var i = 1; i < transport.ackNotifications.length; i++) { 2,
if (transport.ackNotifications[i - 1] == 0xFF && );
transport.ackNotifications[i] == 0x00) { expect(
ffToZeroTransitions += 1; transport.steps
} .where((step) => step == 'optimizeBootloaderConnection')
} .length,
2,
expect(ffToZeroTransitions, greaterThanOrEqualTo(2)); );
expect(service.currentProgress.lastAckedSequence, 0x03); expect(
expect(service.currentProgress.sentBytes, image.length); transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
.length,
1,
);
expect(
transport.dataWriteOffsets
.where(
(offset) =>
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
)
.length,
2,
);
expect(service.currentProgress.state, DfuUpdateState.completed); expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose(); await service.dispose();
await transport.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('ignores stale previous-session status while waiting for START',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
staleStartStatusSessionId: 20,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 21,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.dataWrites.first[0], 21);
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('fails early on boot metadata error before START', () async {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
initialStatusCode: DfuBootloaderStatusCode.bootMetadataError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 18,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(),
startsWith(universalShifterBootMetadataWarningMessage));
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeStart),
isEmpty);
expect(service.currentProgress.state, DfuUpdateState.failed);
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 { class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({ _FakeFirmwareUpdateTransport({
this.dropFirstSequence, required this.totalBytes,
this.initialStatusCode = DfuBootloaderStatusCode.ok,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false,
this.failEnterBootloader = false,
this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.staleStartStatusSessionId,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite, this.onDataWrite,
this.suppressDataAcks = false,
this.resetDisconnectError,
this.reconnectError,
this.verificationError,
}); });
final int? dropFirstSequence; final int totalBytes;
final void Function(List<int> frame)? onDataWrite; final DfuBootloaderStatusCode initialStatusCode;
final bool suppressDataAcks; final DfuBootloaderStatusCode startStatusCode;
final String? resetDisconnectError; final bool alreadyInBootloader;
final String? reconnectError; final bool failEnterBootloader;
final String? verificationError; final bool queueFullOnFirstData;
final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final int? staleStartStatusSessionId;
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(); StreamController<List<int>>.broadcast();
final List<String> steps = <String>[];
final List<List<int>> controlWrites = <List<int>>[]; final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[]; final List<List<int>> dataWrites = <List<int>>[];
final List<int> ackNotifications = <int>[]; final List<int> dataWriteOffsets = <int>[];
final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{}; int _sessionId = 0;
int _lastAck = 0xFF; int _expectedOffset = 0;
int _expectedSequence = 0; int _connectCount = 0;
bool _sentDataFailure = false;
bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
bool _finishDisconnectAvailable = false;
@override @override
Future<Result<DfuPreflightResult>> runPreflight({ Future<Result<bool>> isConnectedToBootloader() async {
required int requestedMtu, steps.add('isConnectedToBootloader');
}) async { return Ok(alreadyInBootloader);
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: 128,
),
);
} }
@override @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(initialStatusCode, 0, 0));
}
@override @override
Future<Result<void>> writeControl(List<int> payload) async { Future<Result<void>> writeControl(List<int> payload) async {
controlWrites.add(List<int>.from(payload, growable: false)); controlWrites.add(List<int>.from(payload, growable: false));
final opcode = payload.first;
final opcode = payload.isEmpty ? -1 : payload.first;
if (opcode == universalShifterDfuOpcodeStart) { if (opcode == universalShifterDfuOpcodeStart) {
_lastAck = 0xFF; _sessionId = payload[17];
_expectedSequence = 0; _expectedOffset = 0;
_scheduleAck(0xFF); final staleSessionId = staleStartStatusSessionId;
if (staleSessionId != null) {
_scheduleStatus(DfuBootloaderStatusCode.ok, staleSessionId, 0);
} }
_scheduleStatus(startStatusCode, _sessionId, 0);
if (opcode == universalShifterDfuOpcodeAbort) { } else if (opcode == universalShifterDfuOpcodeGetStatus) {
_lastAck = 0xFF; if (resetSessionOnRecoveryStatus && _connectCount > 1) {
_expectedSequence = 0; _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);
} }
return Ok(null); return Ok(null);
} }
@override @override
Future<Result<void>> writeDataFrame(List<int> frame) async { Future<Result<void>> writeDataFrame(List<int> frame) async {
dataWrites.add(List<int>.from(frame, growable: false)); 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); return Ok(null);
} }
final sequence = frame.first; if (suppressFirstDataStatus && !_suppressedDataStatus) {
final shouldDrop = dropFirstSequence != null && _suppressedDataStatus = true;
sequence == dropFirstSequence &&
!_droppedOnce.contains(sequence);
if (shouldDrop) {
_droppedOnce.add(sequence);
_scheduleAck(_lastAck);
return Ok(null); return Ok(null);
} }
if (sequence == _expectedSequence) { final payloadLength =
_lastAck = sequence; frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
_expectedSequence = (_expectedSequence + 1) & 0xFF; _expectedOffset = offset + payloadLength;
} _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
_scheduleAck(_lastAck);
return Ok(null); return Ok(null);
} }
void _scheduleAck(int sequence) { @override
final ack = sequence & 0xFF; Future<Result<void>> waitForBootloaderDisconnect(
ackNotifications.add(ack); {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(() { scheduleMicrotask(() {
_ackController.add([ack]); _statusController.add(status);
}); });
} }
@override List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
Future<Result<void>> waitForExpectedResetDisconnect({ return [
required Duration timeout, code.value,
}) async { sessionId & 0xFF,
postFinishSteps.add('waitForExpectedResetDisconnect'); offset & 0xFF,
if (resetDisconnectError != null) { (offset >> 8) & 0xFF,
return bail(resetDisconnectError!); (offset >> 16) & 0xFF,
} (offset >> 24) & 0xFF,
return Ok(null); ];
} }
@override int _readLeU32(List<int> bytes, int offset) {
Future<Result<void>> reconnectForVerification({ final data = ByteData.sublistView(Uint8List.fromList(bytes));
required Duration timeout, return data.getUint32(offset, Endian.little);
}) 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;
} }
Future<void> dispose() async { 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;
}

View File

@ -0,0 +1,23 @@
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isBluetoothPairingRecoveryError', () {
test('detects immediate disconnect connection failures', () {
expect(
isBluetoothPairingRecoveryError(
'Failed to connect to device-id: disconnected',
),
isTrue,
);
});
test('does not classify generic connection failures as pairing recovery',
() {
expect(
isBluetoothPairingRecoveryError('Timed out connecting to device-id'),
isFalse,
);
});
});
}