Compare commits
19 Commits
84e026de52
...
ui-rework
| Author | SHA1 | Date | |
|---|---|---|---|
| f5e5c3904f | |||
| 3310387ec4 | |||
| aa2d150300 | |||
| dc1f53b6e1 | |||
| 16365e1d04 | |||
| 09c686d542 | |||
| 06834a0cc0 | |||
| b673c9100d | |||
| eb26c759e8 | |||
| 5285c44173 | |||
| be1c39d5d7 | |||
| 7628947623 | |||
| 76b7195e5e | |||
| 96416a2f73 | |||
| ac93c01cea | |||
| e3eba0bfc1 | |||
| 9922b90f49 | |||
| 2e7c10f87d | |||
| 1f5ec5ebb2 |
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
docs/bootloader-ota-operator-guide.md
Normal file
62
docs/bootloader-ota-operator-guide.md
Normal 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.
|
||||||
@ -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.
|
|
||||||
@ -26,6 +26,10 @@
|
|||||||
<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>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@ -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) {
|
||||||
@ -394,3 +409,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,17 +29,30 @@ 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 =
|
|
||||||
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
|
||||||
const int universalShifterDfuPreferredMtu = 128;
|
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;
|
||||||
@ -46,12 +63,24 @@ 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 +119,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 +150,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 +257,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 +662,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) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||||
|
|||||||
@ -2,111 +2,169 @@ 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 ||
|
||||||
final remaining = imageBytes.length - offset;
|
payloadSize > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
|
||||||
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
throw RangeError.range(
|
||||||
? remaining
|
payloadSize,
|
||||||
: universalShifterDfuFramePayloadSizeBytes;
|
1,
|
||||||
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
'payloadSize',
|
||||||
frame[0] = seq;
|
|
||||||
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
|
||||||
|
|
||||||
frames.add(
|
|
||||||
DfuDataFrame(
|
|
||||||
sequence: seq,
|
|
||||||
offset: offset,
|
|
||||||
payloadLength: chunkLength,
|
|
||||||
bytes: frame,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
offset += chunkLength;
|
|
||||||
seq = nextSequence(seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final remaining = imageBytes.length - offset;
|
||||||
|
final payloadLength = remaining < payloadSize ? remaining : payloadSize;
|
||||||
|
final payloadEnd = offset + payloadLength;
|
||||||
|
final payload = imageBytes.sublist(offset, payloadEnd);
|
||||||
|
final frame = Uint8List(
|
||||||
|
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
frame[0] = sessionId & 0xFF;
|
||||||
|
final data = ByteData.view(frame.buffer);
|
||||||
|
data.setUint32(1, offset, Endian.little);
|
||||||
|
data.setUint32(5, crc32(payload), Endian.little);
|
||||||
|
frame.setRange(
|
||||||
|
universalShifterBootloaderDfuDataHeaderSizeBytes,
|
||||||
|
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return BootloaderDfuDataFrame(
|
||||||
|
sessionId: sessionId & 0xFF,
|
||||||
|
offset: offset,
|
||||||
|
payloadLength: payloadLength,
|
||||||
|
bytes: frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<BootloaderDfuDataFrame> buildDataFrames({
|
||||||
|
required List<int> imageBytes,
|
||||||
|
required int sessionId,
|
||||||
|
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
}) {
|
||||||
|
final frames = <BootloaderDfuDataFrame>[];
|
||||||
|
var offset = 0;
|
||||||
|
while (offset < imageBytes.length) {
|
||||||
|
final frame = buildDataFrame(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
sessionId: sessionId,
|
||||||
|
offset: offset,
|
||||||
|
payloadSize: payloadSize,
|
||||||
|
);
|
||||||
|
frames.add(frame);
|
||||||
|
offset += frame.payloadLength;
|
||||||
|
}
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int nextSequence(int sequence) {
|
static int maxPayloadSizeForMtu(int negotiatedMtu) {
|
||||||
return _asU8(sequence + 1);
|
final writePayloadBytes =
|
||||||
}
|
negotiatedMtu - universalShifterAttWriteOverheadBytes;
|
||||||
|
final availablePayload =
|
||||||
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
writePayloadBytes - universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||||
return nextSequence(acknowledgedSequence);
|
if (availablePayload <= 0) {
|
||||||
}
|
return 0;
|
||||||
|
|
||||||
static int sequenceDistance(int from, int to) {
|
|
||||||
return _asU8(to - from);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int parseAckPayload(List<int> payload) {
|
|
||||||
if (payload.length != 1) {
|
|
||||||
throw const FormatException('ACK payload must be exactly 1 byte.');
|
|
||||||
}
|
}
|
||||||
return _asU8(payload.first);
|
if (availablePayload > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
|
||||||
|
return universalShifterBootloaderDfuMaxPayloadSizeBytes;
|
||||||
|
}
|
||||||
|
return availablePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const int crc32Initial = 0xFFFFFFFF;
|
static DfuBootloaderStatus parseStatusPayload(List<int> payload) {
|
||||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
if (payload.length != universalShifterBootloaderDfuStatusSizeBytes) {
|
||||||
|
throw const FormatException(
|
||||||
|
'DFU status payload must be exactly 6 bytes.');
|
||||||
|
}
|
||||||
|
final data = ByteData.sublistView(Uint8List.fromList(payload));
|
||||||
|
final rawCode = data.getUint8(0);
|
||||||
|
return DfuBootloaderStatus(
|
||||||
|
code: DfuBootloaderStatusCode.fromRaw(rawCode),
|
||||||
|
rawCode: rawCode,
|
||||||
|
sessionId: data.getUint8(1),
|
||||||
|
expectedOffset: data.getUint32(2, Endian.little),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static int crc32Update(int crc, List<int> bytes) {
|
static int crc32Update(int crc, List<int> bytes) {
|
||||||
var next = crc & 0xFFFFFFFF;
|
var next = crc & 0xFFFFFFFF;
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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,
|
||||||
|
|||||||
@ -1,31 +1,35 @@
|
|||||||
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');
|
|
||||||
|
|
||||||
Future<bool> openBluetoothSettings() async {
|
Future<bool> openBluetoothSettings() async {
|
||||||
if (!Platform.isAndroid) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await _settingsChannel.invokeMethod<bool>('openBluetoothSettings') ??
|
if (Platform.isAndroid) {
|
||||||
false;
|
await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
|
||||||
} on PlatformException {
|
} else if (Platform.isIOS) {
|
||||||
|
await AppSettings.openAppSettings();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
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 +40,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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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()),
|
children: [
|
||||||
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
|
_DialogHeader(
|
||||||
data: (controller) {
|
showOnlyFtms: _showOnlyFtms,
|
||||||
_controller ??= controller;
|
isScanning: _isStartingScan || _isScanning,
|
||||||
return Column(
|
onChanged: (value) {
|
||||||
children: [
|
setState(() {
|
||||||
_DialogHeader(
|
_showOnlyFtms = value;
|
||||||
showAll: _showAll,
|
});
|
||||||
isScanning: _isStartingScan,
|
},
|
||||||
onChanged: (value) {
|
onRescan: _startScan,
|
||||||
setState(() {
|
),
|
||||||
_showAll = value;
|
Expanded(child: _buildBody(context)),
|
||||||
});
|
],
|
||||||
},
|
|
||||||
onRescan: _startScan,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _scanError != null
|
|
||||||
? _ScanMessage(
|
|
||||||
message: 'Could not start trainer scan: $_scanError',
|
|
||||||
action: TextButton.icon(
|
|
||||||
onPressed: _startScan,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: StreamBuilder<List<DiscoveredDevice>>(
|
|
||||||
stream: controller.scanResultsStream,
|
|
||||||
initialData: controller.scanResults,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (_isStartingScan &&
|
|
||||||
(snapshot.data == null ||
|
|
||||||
snapshot.data!.isEmpty)) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
final devices =
|
|
||||||
_filteredDevices(snapshot.data ?? const []);
|
|
||||||
if (devices.isEmpty) {
|
|
||||||
return const _ScanMessage(
|
|
||||||
message:
|
|
||||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
|
||||||
itemCount: devices.length,
|
|
||||||
separatorBuilder: (_, __) =>
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final device = devices[index];
|
|
||||||
final isFtms = device.serviceUuids
|
|
||||||
.contains(Uuid.parse(ftmsServiceUuid));
|
|
||||||
return Material(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
onTap: () =>
|
|
||||||
Navigator.of(context).pop(device),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.outlineVariant
|
|
||||||
.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary
|
|
||||||
.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.pedal_bike_rounded,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
device.name.isEmpty
|
|
||||||
? 'Unknown Device'
|
|
||||||
: device.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow:
|
|
||||||
TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.titleMedium
|
|
||||||
?.copyWith(
|
|
||||||
fontWeight:
|
|
||||||
FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
isFtms
|
|
||||||
? 'FTMS'
|
|
||||||
: 'Nearby trainer',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodyMedium
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary,
|
|
||||||
fontWeight:
|
|
||||||
FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
device.id,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow:
|
|
||||||
TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodySmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface
|
|
||||||
.withValues(
|
|
||||||
alpha: 0.62),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
_RssiBadge(rssi: device.rssi),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right_rounded,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurface
|
|
||||||
.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
|
Widget _buildBody(BuildContext context) {
|
||||||
final ftmsUuid = Uuid.parse(ftmsServiceUuid);
|
if (_scanError != null) {
|
||||||
return devices.where((device) {
|
return _ScanMessage(
|
||||||
if (device.id == widget.excludedDeviceId) {
|
message: 'Could not start shifter trainer scan: $_scanError',
|
||||||
return false;
|
action: TextButton.icon(
|
||||||
}
|
onPressed: _startScan,
|
||||||
if (_showAll) {
|
icon: const Icon(Icons.refresh),
|
||||||
return true;
|
label: const Text('Retry'),
|
||||||
}
|
),
|
||||||
return device.serviceUuids.contains(ftmsUuid);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isStartingScan && _resultsByAddress.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final devices = _filteredDevices();
|
||||||
|
if (devices.isEmpty) {
|
||||||
|
return _ScanMessage(
|
||||||
|
message: _isScanning
|
||||||
|
? 'The shifter is scanning. Nearby trainers will appear here as soon as the shifter reports them.'
|
||||||
|
: 'No matching trainers were reported by the shifter. Rescan with the trainer nearby and awake.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
|
itemCount: devices.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) => _TrainerScanResultTile(
|
||||||
|
result: devices[index],
|
||||||
|
onTap: () => Navigator.of(context).pop(devices[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TrainerScanResult> _filteredDevices() {
|
||||||
|
final devices = _resultsByAddress.values.where((device) {
|
||||||
|
return !_showOnlyFtms || device.ftmsDetected;
|
||||||
}).toList(growable: false);
|
}).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,
|
||||||
|
|||||||
@ -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"))
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
70
test/controller/bluetooth_test.dart
Normal file
70
test/controller/bluetooth_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,97 +3,158 @@ 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,
|
||||||
[
|
0x34,
|
||||||
universalShifterDfuOpcodeStart,
|
0x12,
|
||||||
0x34,
|
0x00,
|
||||||
0x12,
|
0x00,
|
||||||
0x00,
|
0xEF,
|
||||||
0x00,
|
0xCD,
|
||||||
0xEF,
|
0xAB,
|
||||||
0xCD,
|
0x89,
|
||||||
0xAB,
|
0x00,
|
||||||
0x89,
|
0x00,
|
||||||
0x22,
|
0x03,
|
||||||
universalShifterDfuFlagEncrypted,
|
0x00,
|
||||||
],
|
0x40,
|
||||||
);
|
0x30,
|
||||||
|
0x20,
|
||||||
|
0x10,
|
||||||
|
0x22,
|
||||||
|
universalShifterDfuFlagNone,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('encodes FINISH and ABORT payloads as one byte', () {
|
test('encodes FINISH, ABORT, and GET_STATUS payloads', () {
|
||||||
expect(
|
expect(
|
||||||
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
|
BootloaderDfuProtocol.encodeFinishPayload(0x12),
|
||||||
|
[universalShifterDfuOpcodeFinish, 0x12],
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
|
BootloaderDfuProtocol.encodeAbortPayload(0x34),
|
||||||
|
[universalShifterDfuOpcodeAbort, 0x34],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BootloaderDfuProtocol.encodeGetStatusPayload(),
|
||||||
|
[universalShifterDfuOpcodeGetStatus],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,413 +7,469 @@ 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, [
|
||||||
|
'isConnectedToBootloader',
|
||||||
|
'enterBootloader',
|
||||||
|
'waitForAppDisconnect',
|
||||||
|
'connectToBootloader',
|
||||||
|
'negotiateMtu',
|
||||||
|
'readStatus',
|
||||||
|
'waitForBootloaderDisconnect',
|
||||||
|
'reconnectForVerification',
|
||||||
|
'verifyDeviceReachable',
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
|
||||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
|
||||||
expect(
|
expect(
|
||||||
transport.postFinishSteps,
|
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
|
||||||
[
|
expect(transport.dataWrites, isNotEmpty);
|
||||||
'waitForExpectedResetDisconnect',
|
expect(transport.dataWrites.first[0], 7);
|
||||||
'reconnectForVerification',
|
expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
|
||||||
'verifyDeviceReachable',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
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',
|
||||||
|
'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('reconnects and resumes from status after transient data failure',
|
||||||
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
|
() async {
|
||||||
|
final image = _validImage(130);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
failDataWriteAtOffsetOnce:
|
||||||
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
);
|
||||||
final service = FirmwareUpdateService(
|
final service = FirmwareUpdateService(
|
||||||
transport: transport,
|
transport: transport,
|
||||||
defaultWindowSize: 1,
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
defaultAckTimeout: const Duration(milliseconds: 40),
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||||
maxNoProgressRetries: 2,
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 13,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(
|
||||||
|
transport.steps.where((step) => step == 'connectToBootloader').length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites
|
||||||
|
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
transport.dataWriteOffsets
|
||||||
|
.where(
|
||||||
|
(offset) =>
|
||||||
|
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restarts START when reconnect status has no active session',
|
||||||
|
() async {
|
||||||
|
final image = _validImage(80);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
failDataWriteAtOffsetOnce:
|
||||||
|
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||||
|
resetSessionOnRecoveryStatus: true,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
|
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.startUpdate(
|
||||||
|
imageBytes: image,
|
||||||
|
sessionId: 14,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites
|
||||||
|
.where((write) => write.first == universalShifterDfuOpcodeStart)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||||
|
|
||||||
|
await service.dispose();
|
||||||
|
await transport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails with bootloader status error on rejected START', () async {
|
||||||
|
final image = _validImage(40);
|
||||||
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
|
totalBytes: image.length,
|
||||||
|
startStatusCode: DfuBootloaderStatusCode.vectorError,
|
||||||
|
);
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: transport,
|
||||||
|
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||||
);
|
);
|
||||||
|
|
||||||
final image = List<int>.generate(90, (index) => index & 0xFF);
|
|
||||||
final result = await service.startUpdate(
|
final result = await service.startUpdate(
|
||||||
imageBytes: image,
|
imageBytes: image,
|
||||||
sessionId: 10,
|
sessionId: 10,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.isErr(), isTrue);
|
expect(result.isErr(), isTrue);
|
||||||
expect(result.unwrapErr().toString(), contains('Upload stalled'));
|
expect(result.unwrapErr().toString(), contains('vector table error'));
|
||||||
expect(result.unwrapErr().toString(), contains('after 3 retries'));
|
|
||||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
|
||||||
expect(transport.sequenceWriteCount(0), 3);
|
|
||||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||||
|
expect(
|
||||||
|
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
|
||||||
|
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
await transport.dispose();
|
await transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cancel sends ABORT and reports aborted state', () async {
|
test('cancel after START sends session-scoped ABORT', () async {
|
||||||
|
final image = _validImage(80);
|
||||||
final firstFrameSent = Completer<void>();
|
final firstFrameSent = Completer<void>();
|
||||||
final transport = _FakeFirmwareUpdateTransport(
|
final transport = _FakeFirmwareUpdateTransport(
|
||||||
onDataWrite: (frame) {
|
totalBytes: image.length,
|
||||||
|
suppressFirstDataStatus: true,
|
||||||
|
onDataWrite: () {
|
||||||
if (!firstFrameSent.isCompleted) {
|
if (!firstFrameSent.isCompleted) {
|
||||||
firstFrameSent.complete();
|
firstFrameSent.complete();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
suppressDataAcks: true,
|
|
||||||
);
|
);
|
||||||
final service = FirmwareUpdateService(
|
final service = FirmwareUpdateService(
|
||||||
transport: transport,
|
transport: transport,
|
||||||
defaultWindowSize: 1,
|
defaultStatusTimeout: const Duration(seconds: 1),
|
||||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final future = service.startUpdate(
|
final future = service.startUpdate(
|
||||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
imageBytes: image,
|
||||||
sessionId: 11,
|
sessionId: 11,
|
||||||
);
|
);
|
||||||
|
|
||||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||||
await service.cancelUpdate();
|
await service.cancelUpdate();
|
||||||
final result = await future;
|
final result = await future;
|
||||||
|
|
||||||
expect(result.isErr(), isTrue);
|
expect(result.isErr(), isTrue);
|
||||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
expect(
|
||||||
|
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
|
||||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||||
|
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
await transport.dispose();
|
await transport.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails when reconnect does not succeed after expected reset',
|
|
||||||
() async {
|
|
||||||
final transport = _FakeFirmwareUpdateTransport(
|
|
||||||
reconnectError: 'simulated reconnect timeout',
|
|
||||||
);
|
|
||||||
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: 13,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isErr(), isTrue);
|
|
||||||
expect(result.unwrapErr().toString(), contains('did not reconnect'));
|
|
||||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
||||||
expect(
|
|
||||||
transport.postFinishSteps,
|
|
||||||
[
|
|
||||||
'waitForExpectedResetDisconnect',
|
|
||||||
'reconnectForVerification',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.dispose();
|
|
||||||
await transport.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fails when expected reset disconnect is not observed', () async {
|
|
||||||
final transport = _FakeFirmwareUpdateTransport(
|
|
||||||
resetDisconnectError: 'simulated missing disconnect',
|
|
||||||
);
|
|
||||||
final service = FirmwareUpdateService(
|
|
||||||
transport: transport,
|
|
||||||
defaultWindowSize: 4,
|
|
||||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
|
||||||
final result = await service.startUpdate(
|
|
||||||
imageBytes: image,
|
|
||||||
sessionId: 15,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isErr(), isTrue);
|
|
||||||
expect(
|
|
||||||
result.unwrapErr().toString(),
|
|
||||||
contains('expected post-FINISH reset disconnect'),
|
|
||||||
);
|
|
||||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
||||||
expect(
|
|
||||||
transport.postFinishSteps,
|
|
||||||
['waitForExpectedResetDisconnect'],
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.dispose();
|
|
||||||
await transport.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fails when post-update status verification read fails', () async {
|
|
||||||
final transport = _FakeFirmwareUpdateTransport(
|
|
||||||
verificationError: 'simulated status read failure',
|
|
||||||
);
|
|
||||||
final service = FirmwareUpdateService(
|
|
||||||
transport: transport,
|
|
||||||
defaultWindowSize: 4,
|
|
||||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
|
||||||
final result = await service.startUpdate(
|
|
||||||
imageBytes: image,
|
|
||||||
sessionId: 14,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isErr(), isTrue);
|
|
||||||
expect(
|
|
||||||
result.unwrapErr().toString(),
|
|
||||||
contains('post-update verification failed'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
result.unwrapErr().toString(),
|
|
||||||
contains('does not expose a version characteristic'),
|
|
||||||
);
|
|
||||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
|
||||||
expect(
|
|
||||||
transport.postFinishSteps,
|
|
||||||
[
|
|
||||||
'waitForExpectedResetDisconnect',
|
|
||||||
'reconnectForVerification',
|
|
||||||
'verifyDeviceReachable',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.dispose();
|
|
||||||
await transport.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles deterministic ACK sequence wrap-around across 0xFF->0x00',
|
|
||||||
() async {
|
|
||||||
const frameCount = 260;
|
|
||||||
final transport = _FakeFirmwareUpdateTransport();
|
|
||||||
final service = FirmwareUpdateService(
|
|
||||||
transport: transport,
|
|
||||||
defaultWindowSize: 16,
|
|
||||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
|
||||||
);
|
|
||||||
|
|
||||||
final image = List<int>.generate(
|
|
||||||
frameCount * universalShifterDfuFramePayloadSizeBytes,
|
|
||||||
(index) => index & 0xFF,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await service.startUpdate(
|
|
||||||
imageBytes: image,
|
|
||||||
sessionId: 16,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isOk(), isTrue);
|
|
||||||
|
|
||||||
var ffToZeroTransitions = 0;
|
|
||||||
for (var i = 1; i < transport.ackNotifications.length; i++) {
|
|
||||||
if (transport.ackNotifications[i - 1] == 0xFF &&
|
|
||||||
transport.ackNotifications[i] == 0x00) {
|
|
||||||
ffToZeroTransitions += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ffToZeroTransitions, greaterThanOrEqualTo(2));
|
|
||||||
expect(service.currentProgress.lastAckedSequence, 0x03);
|
|
||||||
expect(service.currentProgress.sentBytes, image.length);
|
|
||||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
||||||
|
|
||||||
await service.dispose();
|
|
||||||
await transport.dispose();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||||
_FakeFirmwareUpdateTransport({
|
_FakeFirmwareUpdateTransport({
|
||||||
this.dropFirstSequence,
|
required this.totalBytes,
|
||||||
|
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
||||||
|
this.alreadyInBootloader = false,
|
||||||
|
this.failEnterBootloader = false,
|
||||||
|
this.queueFullOnFirstData = false,
|
||||||
|
this.suppressFirstDataStatus = false,
|
||||||
|
this.failDataWriteAtOffsetOnce,
|
||||||
|
this.resetSessionOnRecoveryStatus = false,
|
||||||
this.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 startStatusCode;
|
||||||
final bool suppressDataAcks;
|
final bool alreadyInBootloader;
|
||||||
final String? resetDisconnectError;
|
final bool failEnterBootloader;
|
||||||
final String? reconnectError;
|
final bool queueFullOnFirstData;
|
||||||
final String? verificationError;
|
final bool suppressFirstDataStatus;
|
||||||
|
final int? failDataWriteAtOffsetOnce;
|
||||||
|
final bool resetSessionOnRecoveryStatus;
|
||||||
|
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;
|
||||||
|
|
||||||
@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<int>> negotiateMtu({required int requestedMtu}) async {
|
||||||
|
steps.add('negotiateMtu');
|
||||||
|
return Ok(128);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> subscribeToStatus() => _statusController.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<List<int>>> readStatus() async {
|
||||||
|
steps.add('readStatus');
|
||||||
|
return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@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);
|
_scheduleStatus(startStatusCode, _sessionId, 0);
|
||||||
|
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
||||||
|
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
|
||||||
|
_sessionId = 0;
|
||||||
|
_expectedOffset = 0;
|
||||||
|
}
|
||||||
|
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||||
|
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
||||||
|
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
|
||||||
|
} else if (opcode == universalShifterDfuOpcodeAbort) {
|
||||||
|
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
|
||||||
_lastAck = 0xFF;
|
|
||||||
_expectedSequence = 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 {
|
||||||
|
steps.add('waitForBootloaderDisconnect');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user