Compare commits
22 Commits
76b7195e5e
...
faster-upd
| Author | SHA1 | Date | |
|---|---|---|---|
| 30b25784c1 | |||
| 0da0905697 | |||
| 4fceb0c690 | |||
| 230a6838e0 | |||
| 512c31d356 | |||
| f1491749d5 | |||
| 073d825a3e | |||
| bcccd03ecc | |||
| 16690dc216 | |||
| 9b672a7503 | |||
| f5e5c3904f | |||
| 3310387ec4 | |||
| aa2d150300 | |||
| dc1f53b6e1 | |||
| 16365e1d04 | |||
| 09c686d542 | |||
| 06834a0cc0 | |||
| b673c9100d | |||
| eb26c759e8 | |||
| 5285c44173 | |||
| be1c39d5d7 | |||
| 7628947623 |
@ -4,7 +4,7 @@ A new Flutter project.
|
||||
|
||||
## Operational Docs
|
||||
|
||||
- [DFU v1 Operator Guide](docs/dfu-v1-operator-guide.md)
|
||||
- [Bootloader OTA Operator Guide](docs/bootloader-ota-operator-guide.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
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.
|
||||
@ -14,6 +14,13 @@ part 'bluetooth.g.dart';
|
||||
|
||||
final log = Logger('BluetoothController');
|
||||
|
||||
final backgroundBluetoothDisconnectSuppressionCountProvider =
|
||||
StateProvider<int>((ref) => 0);
|
||||
|
||||
final backgroundBluetoothDisconnectSuppressedProvider = Provider<bool>((ref) {
|
||||
return ref.watch(backgroundBluetoothDisconnectSuppressionCountProvider) > 0;
|
||||
});
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
FlutterReactiveBle reactiveBle(Ref ref) {
|
||||
ref.keepAlive();
|
||||
@ -356,6 +363,27 @@ class BluetoothController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<void>> requestHighPerformanceConnection(
|
||||
String deviceId,
|
||||
) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await _ble.requestConnectionPriority(
|
||||
deviceId: deviceId,
|
||||
priority: ConnectionPriority.highPerformance,
|
||||
);
|
||||
log.info('High-performance BLE connection requested for $deviceId');
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
return bail(
|
||||
'Error requesting high-performance BLE connection for $deviceId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<void>> _requestInitialMtu(String deviceId) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
return Ok(null);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
|
||||
import 'package:abawo_bt_app/pages/devices_page.dart';
|
||||
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
||||
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
||||
@ -57,6 +58,9 @@ class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.hidden ||
|
||||
state == AppLifecycleState.paused) {
|
||||
if (ref.read(backgroundBluetoothDisconnectSuppressedProvider)) {
|
||||
return;
|
||||
}
|
||||
unawaited(_disconnectBluetoothForBackground());
|
||||
}
|
||||
}
|
||||
@ -122,6 +126,18 @@ final _router = GoRouter(
|
||||
return DeviceDetailsPage(deviceAddress: deviceAddress);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/bootloader_recovery_update',
|
||||
builder: (context, state) {
|
||||
final args = state.extra;
|
||||
if (args is! BootloaderRecoveryUpdateArgs) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Text('Missing bootloader recovery data.')),
|
||||
);
|
||||
}
|
||||
return BootloaderRecoveryUpdatePage(args: args);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class DfuV1FirmwareMetadata {
|
||||
const DfuV1FirmwareMetadata({
|
||||
class BootloaderDfuFirmwareMetadata {
|
||||
const BootloaderDfuFirmwareMetadata({
|
||||
required this.totalLength,
|
||||
required this.crc32,
|
||||
required this.appStart,
|
||||
required this.imageVersion,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
required this.vectorStackPointer,
|
||||
required this.vectorReset,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int crc32;
|
||||
final int appStart;
|
||||
final int imageVersion;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
final int vectorStackPointer;
|
||||
final int vectorReset;
|
||||
}
|
||||
|
||||
class DfuV1PreparedFirmware {
|
||||
const DfuV1PreparedFirmware({
|
||||
class BootloaderDfuPreparedFirmware {
|
||||
const BootloaderDfuPreparedFirmware({
|
||||
required this.fileName,
|
||||
required this.fileBytes,
|
||||
required this.metadata,
|
||||
@ -25,7 +33,7 @@ class DfuV1PreparedFirmware {
|
||||
final String fileName;
|
||||
final String? filePath;
|
||||
final Uint8List fileBytes;
|
||||
final DfuV1FirmwareMetadata metadata;
|
||||
final BootloaderDfuFirmwareMetadata metadata;
|
||||
}
|
||||
|
||||
enum FirmwareSelectionFailureReason {
|
||||
@ -33,6 +41,9 @@ enum FirmwareSelectionFailureReason {
|
||||
malformedSelection,
|
||||
unsupportedExtension,
|
||||
emptyFile,
|
||||
imageTooSmall,
|
||||
imageTooLarge,
|
||||
invalidVectorTable,
|
||||
readFailed,
|
||||
}
|
||||
|
||||
@ -52,7 +63,7 @@ class FirmwareFileSelectionResult {
|
||||
this.failure,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? firmware;
|
||||
final BootloaderDfuPreparedFirmware? firmware;
|
||||
final FirmwareSelectionFailure? failure;
|
||||
|
||||
bool get isSuccess => firmware != null;
|
||||
@ -60,7 +71,8 @@ class FirmwareFileSelectionResult {
|
||||
bool get isCanceled =>
|
||||
failure?.reason == FirmwareSelectionFailureReason.canceled;
|
||||
|
||||
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) {
|
||||
static FirmwareFileSelectionResult success(
|
||||
BootloaderDfuPreparedFirmware firmware) {
|
||||
return FirmwareFileSelectionResult._(firmware: firmware);
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ const String universalShifterStatusCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40000';
|
||||
const String universalShifterConnectToAddrCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40001';
|
||||
const String universalShifterScanResultCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40004';
|
||||
const String universalShifterCommandCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
||||
const String universalShifterGearRatiosCharacteristicUuid =
|
||||
@ -16,7 +18,7 @@ const String universalShifterDfuControlCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40008';
|
||||
const String universalShifterDfuDataCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40009';
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
const String universalShifterDfuStatusCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
|
||||
@ -34,30 +36,54 @@ bool isFtmsUuid(Uuid uuid) {
|
||||
const int universalShifterDfuOpcodeStart = 0x01;
|
||||
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||
const int universalShifterDfuOpcodeAbort = 0x03;
|
||||
const int universalShifterDfuOpcodeGetStatus = 0x04;
|
||||
|
||||
const int universalShifterDfuFrameSizeBytes = 64;
|
||||
const int universalShifterDfuFramePayloadSizeBytes = 63;
|
||||
const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9;
|
||||
const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
|
||||
universalShifterDfuFrameSizeBytes -
|
||||
universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||
const int universalShifterBootloaderDfuStatusSizeBytes = 6;
|
||||
const int universalShifterAttWriteOverheadBytes = 3;
|
||||
const int universalShifterDfuMinimumMtu =
|
||||
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
||||
const int universalShifterDfuPreferredMtu = 128;
|
||||
const int universalShifterDfuPreferredMtu = 131;
|
||||
|
||||
const int universalShifterDfuAppStart = 0x00030000;
|
||||
const int universalShifterDfuAppSlotSizeBytes = 0x0003F000;
|
||||
const int universalShifterDfuMinimumImageLengthBytes = 8;
|
||||
const int universalShifterDfuRamStart = 0x20000000;
|
||||
const int universalShifterDfuRamEnd = 0x20010000;
|
||||
|
||||
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||
const int universalShifterDfuFlagSigned = 0x02;
|
||||
const int universalShifterDfuFlagNone = 0x00;
|
||||
|
||||
const String universalShifterBootMetadataWarningMessage =
|
||||
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support';
|
||||
|
||||
const int errorSequence = 1;
|
||||
const int errorFtmsMissing = 2;
|
||||
const int errorPairingAuth = 3;
|
||||
const int errorPairingEncrypt = 4;
|
||||
const int errorFtmsRequiredCharMissing = 5;
|
||||
|
||||
const int trainerScanProtocolVersion = 1;
|
||||
|
||||
const int trainerScanDeviceFlagFtmsDetected = 0x01;
|
||||
const int trainerScanDeviceFlagNameComplete = 0x02;
|
||||
const int trainerScanDeviceFlagScanResponseSeen = 0x04;
|
||||
const int trainerScanDeviceFlagConnectable = 0x08;
|
||||
|
||||
enum DfuUpdateState {
|
||||
idle,
|
||||
starting,
|
||||
waitingForAck,
|
||||
enteringBootloader,
|
||||
connectingBootloader,
|
||||
waitingForStatus,
|
||||
erasing,
|
||||
transferring,
|
||||
finishing,
|
||||
rebooting,
|
||||
verifying,
|
||||
completed,
|
||||
aborted,
|
||||
failed,
|
||||
@ -96,18 +122,20 @@ class DfuUpdateProgress {
|
||||
required this.state,
|
||||
required this.totalBytes,
|
||||
required this.sentBytes,
|
||||
required this.lastAckedSequence,
|
||||
required this.expectedOffset,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
this.bootloaderStatus,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final DfuUpdateState state;
|
||||
final int totalBytes;
|
||||
final int sentBytes;
|
||||
final int lastAckedSequence;
|
||||
final int expectedOffset;
|
||||
final int sessionId;
|
||||
final DfuUpdateFlags flags;
|
||||
final DfuBootloaderStatus? bootloaderStatus;
|
||||
final String? errorMessage;
|
||||
|
||||
double get fractionComplete {
|
||||
@ -125,59 +153,47 @@ class DfuUpdateProgress {
|
||||
state == DfuUpdateState.failed;
|
||||
}
|
||||
|
||||
enum DfuPreflightFailureReason {
|
||||
deviceNotConnected,
|
||||
wrongConnectedDevice,
|
||||
mtuRequestFailed,
|
||||
mtuTooLow,
|
||||
enum DfuBootloaderStatusCode {
|
||||
ok(0x00),
|
||||
parseError(0x01),
|
||||
stateError(0x02),
|
||||
boundsError(0x03),
|
||||
crcError(0x04),
|
||||
flashError(0x05),
|
||||
unsupportedError(0x06),
|
||||
vectorError(0x07),
|
||||
queueFull(0x08),
|
||||
bootMetadataError(0x09),
|
||||
unknown(-1);
|
||||
|
||||
const DfuBootloaderStatusCode(this.value);
|
||||
|
||||
final int value;
|
||||
|
||||
static DfuBootloaderStatusCode fromRaw(int value) {
|
||||
for (final code in values) {
|
||||
if (code.value == value) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return DfuBootloaderStatusCode.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
class DfuPreflightResult {
|
||||
const DfuPreflightResult._({
|
||||
required this.requestedMtu,
|
||||
required this.requiredMtu,
|
||||
required this.negotiatedMtu,
|
||||
required this.failureReason,
|
||||
required this.message,
|
||||
class DfuBootloaderStatus {
|
||||
const DfuBootloaderStatus({
|
||||
required this.code,
|
||||
required this.rawCode,
|
||||
required this.sessionId,
|
||||
required this.expectedOffset,
|
||||
});
|
||||
|
||||
final int requestedMtu;
|
||||
final int requiredMtu;
|
||||
final int? negotiatedMtu;
|
||||
final DfuPreflightFailureReason? failureReason;
|
||||
final String? message;
|
||||
final DfuBootloaderStatusCode code;
|
||||
final int rawCode;
|
||||
final int sessionId;
|
||||
final int expectedOffset;
|
||||
|
||||
bool get canStart => failureReason == null;
|
||||
|
||||
static DfuPreflightResult ready({
|
||||
required int requestedMtu,
|
||||
required int negotiatedMtu,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: null,
|
||||
message: null,
|
||||
);
|
||||
}
|
||||
|
||||
static DfuPreflightResult failed({
|
||||
required int requestedMtu,
|
||||
required DfuPreflightFailureReason failureReason,
|
||||
required String message,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
int? negotiatedMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: failureReason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
bool get isOk => code == DfuBootloaderStatusCode.ok;
|
||||
}
|
||||
|
||||
class ShifterErrorInfo {
|
||||
@ -244,12 +260,151 @@ enum UniversalShifterCommand {
|
||||
stopScan(0x02),
|
||||
connectToDevice(0x03),
|
||||
disconnect(0x04),
|
||||
turnOff(0x05);
|
||||
turnOff(0x05),
|
||||
enterDfu(0x06);
|
||||
|
||||
const UniversalShifterCommand(this.value);
|
||||
final int value;
|
||||
}
|
||||
|
||||
enum TrainerScanEventKind {
|
||||
scanStarted(0),
|
||||
device(1),
|
||||
scanFinished(2),
|
||||
scanCancelled(3);
|
||||
|
||||
const TrainerScanEventKind(this.value);
|
||||
final int value;
|
||||
|
||||
static TrainerScanEventKind fromRaw(int value) {
|
||||
for (final kind in values) {
|
||||
if (kind.value == value) {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
throw FormatException('Unknown trainer scan event kind: $value');
|
||||
}
|
||||
}
|
||||
|
||||
class TrainerAddress {
|
||||
const TrainerAddress({
|
||||
required this.flags,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final int flags;
|
||||
final List<int> bytes;
|
||||
|
||||
String get key => '${flags.toRadixString(16).padLeft(2, '0')}:'
|
||||
'${bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other is! TrainerAddress ||
|
||||
other.flags != flags ||
|
||||
other.bytes.length != bytes.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
if (other.bytes[i] != bytes[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(flags, Object.hashAll(bytes));
|
||||
}
|
||||
|
||||
class TrainerScanResult {
|
||||
const TrainerScanResult({
|
||||
required this.sequence,
|
||||
required this.address,
|
||||
required this.rssi,
|
||||
required this.flags,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int sequence;
|
||||
final TrainerAddress address;
|
||||
final int rssi;
|
||||
final int flags;
|
||||
final String name;
|
||||
|
||||
bool get ftmsDetected => (flags & trainerScanDeviceFlagFtmsDetected) != 0;
|
||||
bool get nameComplete => (flags & trainerScanDeviceFlagNameComplete) != 0;
|
||||
bool get scanResponseSeen =>
|
||||
(flags & trainerScanDeviceFlagScanResponseSeen) != 0;
|
||||
bool get connectable => (flags & trainerScanDeviceFlagConnectable) != 0;
|
||||
}
|
||||
|
||||
class TrainerScanEvent {
|
||||
const TrainerScanEvent({
|
||||
required this.kind,
|
||||
required this.sequence,
|
||||
this.result,
|
||||
});
|
||||
|
||||
final TrainerScanEventKind kind;
|
||||
final int sequence;
|
||||
final TrainerScanResult? result;
|
||||
|
||||
static TrainerScanEvent fromBytes(List<int> bytes) {
|
||||
if (bytes.length < 3) {
|
||||
throw FormatException(
|
||||
'Trainer scan event payload too short: ${bytes.length}',
|
||||
);
|
||||
}
|
||||
if (bytes[0] != trainerScanProtocolVersion) {
|
||||
throw FormatException(
|
||||
'Unsupported trainer scan protocol version: ${bytes[0]}',
|
||||
);
|
||||
}
|
||||
|
||||
final kind = TrainerScanEventKind.fromRaw(bytes[1]);
|
||||
final sequence = bytes[2];
|
||||
if (kind != TrainerScanEventKind.device) {
|
||||
return TrainerScanEvent(kind: kind, sequence: sequence);
|
||||
}
|
||||
|
||||
if (bytes.length < 13) {
|
||||
throw FormatException(
|
||||
'Trainer scan device payload too short: ${bytes.length}',
|
||||
);
|
||||
}
|
||||
final nameLength = bytes[12];
|
||||
if (bytes.length < 13 + nameLength) {
|
||||
throw FormatException(
|
||||
'Trainer scan device name length $nameLength exceeds payload length '
|
||||
'${bytes.length}',
|
||||
);
|
||||
}
|
||||
|
||||
final rssiRaw = bytes[10];
|
||||
final rssi = rssiRaw > 127 ? rssiRaw - 256 : rssiRaw;
|
||||
final result = TrainerScanResult(
|
||||
sequence: sequence,
|
||||
address: TrainerAddress(
|
||||
flags: bytes[3],
|
||||
bytes: bytes.sublist(4, 10).toList(growable: false),
|
||||
),
|
||||
rssi: rssi,
|
||||
flags: bytes[11],
|
||||
name: utf8.decode(bytes.sublist(13, 13 + nameLength)),
|
||||
);
|
||||
|
||||
return TrainerScanEvent(
|
||||
kind: kind,
|
||||
sequence: sequence,
|
||||
result: result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShifterDeviceTelemetry {
|
||||
const ShifterDeviceTelemetry({
|
||||
this.batteryPercent,
|
||||
@ -510,16 +665,21 @@ class CentralStatus {
|
||||
}
|
||||
}
|
||||
|
||||
List<int> parseMacToLittleEndianBytes(String macAddress) {
|
||||
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
|
||||
if (compact.length != 12) {
|
||||
throw FormatException('Invalid MAC address format: $macAddress');
|
||||
List<int> encodeTrainerAddress(TrainerAddress address) {
|
||||
if (address.flags < 0 || address.flags > 0xff) {
|
||||
throw FormatException('Invalid trainer address flags: ${address.flags}');
|
||||
}
|
||||
final bytes = <int>[];
|
||||
for (int i = 0; i < compact.length; i += 2) {
|
||||
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
|
||||
if (address.bytes.length != 6) {
|
||||
throw FormatException(
|
||||
'Invalid trainer address length: ${address.bytes.length}',
|
||||
);
|
||||
}
|
||||
return bytes.reversed.toList(growable: false);
|
||||
for (final byte in address.bytes) {
|
||||
if (byte < 0 || byte > 0xff) {
|
||||
throw FormatException('Invalid trainer address byte: $byte');
|
||||
}
|
||||
}
|
||||
return [address.flags, ...address.bytes];
|
||||
}
|
||||
|
||||
String formatMacAddressFromLittleEndian(List<int> bytes) {
|
||||
|
||||
251
lib/pages/bootloader_recovery_update_page.dart
Normal file
251
lib/pages/bootloader_recovery_update_page.dart
Normal file
@ -0,0 +1,251 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BootloaderRecoveryUpdateArgs {
|
||||
const BootloaderRecoveryUpdateArgs({
|
||||
required this.bootloaderDeviceId,
|
||||
required this.firmware,
|
||||
});
|
||||
|
||||
final String bootloaderDeviceId;
|
||||
final BootloaderDfuPreparedFirmware firmware;
|
||||
}
|
||||
|
||||
class BootloaderRecoveryUpdatePage extends ConsumerStatefulWidget {
|
||||
const BootloaderRecoveryUpdatePage({
|
||||
required this.args,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BootloaderRecoveryUpdateArgs args;
|
||||
|
||||
@override
|
||||
ConsumerState<BootloaderRecoveryUpdatePage> createState() =>
|
||||
_BootloaderRecoveryUpdatePageState();
|
||||
}
|
||||
|
||||
class _BootloaderRecoveryUpdatePageState
|
||||
extends ConsumerState<BootloaderRecoveryUpdatePage> {
|
||||
FirmwareUpdateService? _firmwareUpdateService;
|
||||
BluetoothController? _bluetooth;
|
||||
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
||||
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
expectedOffset: 0,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
String? _firmwareUserMessage = 'Preparing US-DFU recovery update...';
|
||||
bool _hasStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
unawaited(_startUpdate());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final service = _firmwareUpdateService;
|
||||
final bluetooth = _bluetooth;
|
||||
_firmwareUpdateService = null;
|
||||
unawaited(_firmwareProgressSubscription?.cancel());
|
||||
unawaited(() async {
|
||||
await service?.dispose();
|
||||
await _disconnectBootloaderIfStillConnected(
|
||||
bluetooth: bluetooth,
|
||||
allowRefRead: false,
|
||||
);
|
||||
}());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _disconnectBootloaderIfStillConnected({
|
||||
BluetoothController? bluetooth,
|
||||
bool allowRefRead = true,
|
||||
}) async {
|
||||
if (bluetooth == null && allowRefRead) {
|
||||
bluetooth = ref.read(bluetoothProvider).valueOrNull;
|
||||
}
|
||||
if (bluetooth == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentState = bluetooth.currentConnectionState;
|
||||
if (currentState.$2 != widget.args.bootloaderDeviceId ||
|
||||
(currentState.$1 != ConnectionStatus.connected &&
|
||||
currentState.$1 != ConnectionStatus.connecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bluetooth.disconnect();
|
||||
}
|
||||
|
||||
Future<void> _dismissToDevices() async {
|
||||
await _disconnectBootloaderIfStillConnected();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go('/devices');
|
||||
}
|
||||
|
||||
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||
if (_firmwareUpdateService != null) {
|
||||
return _firmwareUpdateService;
|
||||
}
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
|
||||
if (bluetooth == null) {
|
||||
return null;
|
||||
}
|
||||
_bluetooth = bluetooth;
|
||||
|
||||
final service = FirmwareUpdateService(
|
||||
verifyAfterFinish: false,
|
||||
transport: ShifterFirmwareUpdateTransport(
|
||||
shifterService: null,
|
||||
bluetoothController: bluetooth,
|
||||
buttonDeviceId: widget.args.bootloaderDeviceId,
|
||||
),
|
||||
);
|
||||
_firmwareProgressSubscription = service.progressStream.listen((progress) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dfuProgress = progress;
|
||||
if (progress.state == DfuUpdateState.failed &&
|
||||
progress.errorMessage != null) {
|
||||
_firmwareUserMessage = progress.errorMessage;
|
||||
} else if (progress.state == DfuUpdateState.completed) {
|
||||
_firmwareUserMessage =
|
||||
'Firmware update completed. The bootloader accepted FINISH and reset; reconnect to the device when it starts advertising again.';
|
||||
} else if (progress.state == DfuUpdateState.aborted) {
|
||||
_firmwareUserMessage = 'Firmware update canceled.';
|
||||
} else if (progress.errorMessage != null) {
|
||||
_firmwareUserMessage = progress.errorMessage;
|
||||
}
|
||||
});
|
||||
});
|
||||
_firmwareUpdateService = service;
|
||||
return service;
|
||||
}
|
||||
|
||||
Future<void> _startUpdate() async {
|
||||
if (_hasStarted) {
|
||||
return;
|
||||
}
|
||||
_hasStarted = true;
|
||||
|
||||
final updater = await _ensureFirmwareUpdateService();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (updater == null) {
|
||||
setState(() {
|
||||
_dfuProgress = DfuUpdateProgress(
|
||||
state: DfuUpdateState.failed,
|
||||
totalBytes: widget.args.firmware.fileBytes.length,
|
||||
sentBytes: 0,
|
||||
expectedOffset: 0,
|
||||
sessionId: widget.args.firmware.metadata.sessionId,
|
||||
flags: DfuUpdateFlags.fromRaw(widget.args.firmware.metadata.flags),
|
||||
errorMessage:
|
||||
'Firmware updater is not ready. Reconnect to US-DFU and retry.',
|
||||
);
|
||||
_firmwareUserMessage = _dfuProgress.errorMessage;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final firmware = widget.args.firmware;
|
||||
final result = await updater.startUpdate(
|
||||
imageBytes: firmware.fileBytes,
|
||||
sessionId: firmware.metadata.sessionId,
|
||||
appStart: firmware.metadata.appStart,
|
||||
imageVersion: firmware.metadata.imageVersion,
|
||||
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
|
||||
);
|
||||
|
||||
if (!mounted || result.isOk()) {
|
||||
return;
|
||||
}
|
||||
await _disconnectBootloaderIfStillConnected();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final errorMessage = result.unwrapErr().toString();
|
||||
setState(() {
|
||||
_firmwareUserMessage = errorMessage;
|
||||
});
|
||||
|
||||
if (errorMessage.startsWith(universalShifterBootMetadataWarningMessage)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _dfuPhaseText(DfuUpdateState state) {
|
||||
return switch (state) {
|
||||
DfuUpdateState.idle => 'Preparing recovery update',
|
||||
DfuUpdateState.starting => 'Preparing update',
|
||||
DfuUpdateState.enteringBootloader => 'Checking bootloader mode',
|
||||
DfuUpdateState.connectingBootloader => 'Connecting to bootloader',
|
||||
DfuUpdateState.waitingForStatus => 'Waiting for bootloader status',
|
||||
DfuUpdateState.erasing => 'Starting destructive bootloader update',
|
||||
DfuUpdateState.transferring => 'Transferring firmware image',
|
||||
DfuUpdateState.finishing => 'Finalizing bootloader update',
|
||||
DfuUpdateState.rebooting => 'Waiting for bootloader reset',
|
||||
DfuUpdateState.verifying => 'Verifying updated app',
|
||||
DfuUpdateState.completed => 'Update completed',
|
||||
DfuUpdateState.aborted => 'Update canceled',
|
||||
DfuUpdateState.failed => 'Update failed',
|
||||
};
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) {
|
||||
return '$bytes B';
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FirmwareUpdateFullscreen(
|
||||
progress: _dfuProgress,
|
||||
selectedFirmware: widget.args.firmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
expectedOffsetHex:
|
||||
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
doneLabel: 'Back to devices',
|
||||
failedLabel: 'Back to devices',
|
||||
onDismiss: () => unawaited(_dismissToDevices()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,17 +8,19 @@ import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
||||
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
|
||||
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||
show DiscoveredDevice;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nb_utils/nb_utils.dart';
|
||||
|
||||
import '../controller/bluetooth.dart';
|
||||
import '../database/database.dart';
|
||||
|
||||
final _log = Logger('DeviceDetailsPage');
|
||||
|
||||
class DeviceDetailsPage extends ConsumerStatefulWidget {
|
||||
const DeviceDetailsPage({
|
||||
required this.deviceAddress,
|
||||
@ -64,6 +66,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
bool _isPairingCheckRunning = false;
|
||||
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
||||
_connectionStatusSubscription;
|
||||
BluetoothController? _bluetooth;
|
||||
|
||||
ShifterService? _shifterService;
|
||||
StreamSubscription<CentralStatus>? _statusSubscription;
|
||||
@ -82,12 +85,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
||||
FirmwareUpdateService? _firmwareUpdateService;
|
||||
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
||||
DfuV1PreparedFirmware? _selectedFirmware;
|
||||
BootloaderDfuPreparedFirmware? _selectedFirmware;
|
||||
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: 0xFF,
|
||||
expectedOffset: 0,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
@ -101,9 +104,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
switch (_dfuProgress.state) {
|
||||
case DfuUpdateState.starting:
|
||||
case DfuUpdateState.waitingForAck:
|
||||
case DfuUpdateState.enteringBootloader:
|
||||
case DfuUpdateState.connectingBootloader:
|
||||
case DfuUpdateState.waitingForStatus:
|
||||
case DfuUpdateState.erasing:
|
||||
case DfuUpdateState.transferring:
|
||||
case DfuUpdateState.finishing:
|
||||
case DfuUpdateState.rebooting:
|
||||
case DfuUpdateState.verifying:
|
||||
return true;
|
||||
case DfuUpdateState.idle:
|
||||
case DfuUpdateState.completed:
|
||||
@ -135,7 +143,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_disconnectOnClose());
|
||||
_log.info(
|
||||
'Disposing device details page for ${widget.deviceAddress}; '
|
||||
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
|
||||
);
|
||||
final bluetooth = _bluetooth;
|
||||
unawaited(
|
||||
_disconnectOnClose(bluetooth: bluetooth, allowRefRead: false),
|
||||
);
|
||||
_connectionStatusSubscription?.close();
|
||||
_statusSubscription?.cancel();
|
||||
_shifterService?.dispose();
|
||||
@ -144,22 +159,33 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _disconnectOnClose() async {
|
||||
Future<void> _disconnectOnClose({
|
||||
BluetoothController? bluetooth,
|
||||
bool allowRefRead = true,
|
||||
}) async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
_log.info('Skipping disconnect on close because firmware update is busy');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasRequestedDisconnect) {
|
||||
_log.fine('Disconnect on close already requested');
|
||||
return;
|
||||
}
|
||||
|
||||
_hasRequestedDisconnect = true;
|
||||
_isExitingPage = true;
|
||||
|
||||
final bluetoothController = bluetooth ??
|
||||
_bluetooth ??
|
||||
(allowRefRead ? ref.read(bluetoothProvider).value : null);
|
||||
if (bluetoothController != null) {
|
||||
_bluetooth = bluetoothController;
|
||||
}
|
||||
|
||||
await _disposeFirmwareUpdateService();
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
await bluetooth?.disconnect();
|
||||
await bluetoothController?.disconnect();
|
||||
await _stopStatusStreaming();
|
||||
}
|
||||
|
||||
@ -170,6 +196,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
final (status, connectedDeviceId) = data;
|
||||
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
||||
if (_isFirmwareUpdateBusy || _dfuProgress.state != DfuUpdateState.idle) {
|
||||
_log.info(
|
||||
'Connection update during firmware flow: status=$status, '
|
||||
'connectedDevice=$connectedDeviceId, expected=${widget.deviceAddress}, '
|
||||
'isCurrentDevice=$isCurrentDevice, dfuState=${_dfuProgress.state}',
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
||||
_startStatusStreamingIfNeeded();
|
||||
@ -191,6 +224,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
if (_shifterService != null) {
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
if (bluetooth != null) {
|
||||
_bluetooth = bluetooth;
|
||||
}
|
||||
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
@ -219,6 +255,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
} else {
|
||||
bluetooth = await ref.read(bluetoothProvider.future);
|
||||
}
|
||||
_bluetooth = bluetooth;
|
||||
if (!isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
@ -244,6 +281,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
if (bluetooth != null) {
|
||||
_bluetooth = bluetooth;
|
||||
}
|
||||
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
||||
break;
|
||||
}
|
||||
@ -438,20 +478,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
_isAssignTrainerDialogOpen = true;
|
||||
final DiscoveredDevice? selectedBike;
|
||||
try {
|
||||
selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
);
|
||||
} finally {
|
||||
_isAssignTrainerDialogOpen = false;
|
||||
}
|
||||
if (selectedBike == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _startStatusStreamingIfNeeded();
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
@ -463,8 +489,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await shifter.connectButtonToBike(selectedBike.id);
|
||||
_isAssignTrainerDialogOpen = true;
|
||||
final TrainerScanResult? selectedTrainer;
|
||||
try {
|
||||
selectedTrainer = await BikeScanDialog.show(
|
||||
context,
|
||||
shifter: shifter,
|
||||
);
|
||||
} finally {
|
||||
_isAssignTrainerDialogOpen = false;
|
||||
}
|
||||
if (selectedTrainer == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result =
|
||||
await shifter.connectButtonToTrainer(selectedTrainer.address);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@ -479,15 +523,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
selectedTrainer.name.isEmpty
|
||||
? 'Sent connect request for trainer.'
|
||||
: 'Sent connect request for ${selectedTrainer.name}.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
return null;
|
||||
}
|
||||
if (_firmwareUpdateService != null) {
|
||||
return _firmwareUpdateService;
|
||||
}
|
||||
@ -497,10 +543,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
if (bluetooth == null) {
|
||||
return null;
|
||||
}
|
||||
_bluetooth = bluetooth;
|
||||
|
||||
final service = FirmwareUpdateService(
|
||||
transport: ShifterFirmwareUpdateTransport(
|
||||
shifterService: shifter,
|
||||
shifterService: _shifterService,
|
||||
bluetoothController: bluetooth,
|
||||
buttonDeviceId: widget.deviceAddress,
|
||||
),
|
||||
@ -510,6 +557,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_log.info(
|
||||
'Firmware progress: state=${progress.state}, '
|
||||
'sent=${progress.sentBytes}/${progress.totalBytes}, '
|
||||
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
|
||||
);
|
||||
setState(() {
|
||||
_dfuProgress = progress;
|
||||
if (progress.state == DfuUpdateState.failed &&
|
||||
@ -518,7 +570,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
if (progress.state == DfuUpdateState.completed) {
|
||||
_firmwareUserMessage =
|
||||
'Firmware update completed. The button rebooted and reconnected.';
|
||||
'Firmware update completed. The bootloader rebooted into the updated app and verification passed.';
|
||||
}
|
||||
if (progress.state == DfuUpdateState.aborted) {
|
||||
_firmwareUserMessage = 'Firmware update canceled.';
|
||||
@ -540,7 +592,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_firmwareUserMessage = null;
|
||||
});
|
||||
|
||||
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
|
||||
final suppressionCount = ref.read(
|
||||
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
|
||||
);
|
||||
suppressionCount.state += 1;
|
||||
|
||||
final FirmwareFileSelectionResult result;
|
||||
try {
|
||||
result =
|
||||
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
|
||||
} finally {
|
||||
suppressionCount.state =
|
||||
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@ -550,7 +614,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
if (result.isSuccess) {
|
||||
_selectedFirmware = result.firmware;
|
||||
_firmwareUserMessage =
|
||||
'Selected ${result.firmware!.fileName}. Ready to start update.';
|
||||
'Validated ${result.firmware!.fileName}. Ready for bootloader update.';
|
||||
} else if (!result.isCanceled) {
|
||||
_firmwareUserMessage = result.failure?.message;
|
||||
}
|
||||
@ -571,7 +635,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _startStatusStreamingIfNeeded();
|
||||
final updater = await _ensureFirmwareUpdateService();
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -587,12 +650,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
setState(() {
|
||||
_isStartingFirmwareUpdate = true;
|
||||
_firmwareUserMessage =
|
||||
'Starting update. Keep this screen open and stay near the button.';
|
||||
'Requesting bootloader mode. Keep this screen open and stay near the button.';
|
||||
});
|
||||
|
||||
final result = await updater.startUpdate(
|
||||
imageBytes: firmware.fileBytes,
|
||||
sessionId: firmware.metadata.sessionId,
|
||||
appStart: firmware.metadata.appStart,
|
||||
imageVersion: firmware.metadata.imageVersion,
|
||||
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
|
||||
);
|
||||
|
||||
@ -607,6 +672,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
});
|
||||
|
||||
if (result.isErr() &&
|
||||
result.unwrapErr().toString().startsWith(
|
||||
universalShifterBootMetadataWarningMessage,
|
||||
)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.isOk()) {
|
||||
_hasLoadedDeviceTelemetry = false;
|
||||
unawaited(_loadDeviceTelemetry(force: true));
|
||||
@ -618,13 +696,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
case DfuUpdateState.idle:
|
||||
return 'Idle';
|
||||
case DfuUpdateState.starting:
|
||||
return 'Sending START command';
|
||||
case DfuUpdateState.waitingForAck:
|
||||
return 'Waiting for ACK from button';
|
||||
return 'Preparing update';
|
||||
case DfuUpdateState.enteringBootloader:
|
||||
return 'Requesting bootloader mode';
|
||||
case DfuUpdateState.connectingBootloader:
|
||||
return 'Connecting to bootloader';
|
||||
case DfuUpdateState.waitingForStatus:
|
||||
return 'Waiting for bootloader status';
|
||||
case DfuUpdateState.erasing:
|
||||
return 'Starting destructive bootloader update';
|
||||
case DfuUpdateState.transferring:
|
||||
return 'Transferring firmware frames';
|
||||
return 'Transferring firmware image';
|
||||
case DfuUpdateState.finishing:
|
||||
return 'Finalizing update and waiting for reboot/reconnect';
|
||||
return 'Finalizing bootloader update';
|
||||
case DfuUpdateState.rebooting:
|
||||
return 'Waiting for updated app reboot';
|
||||
case DfuUpdateState.verifying:
|
||||
return 'Verifying updated app';
|
||||
case DfuUpdateState.completed:
|
||||
return 'Update completed';
|
||||
case DfuUpdateState.aborted:
|
||||
@ -644,10 +732,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
String _hexByte(int value) {
|
||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> _manualReconnect() async {
|
||||
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
||||
return;
|
||||
@ -659,6 +743,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
try {
|
||||
final bluetooth = await ref.read(bluetoothProvider.future);
|
||||
_bluetooth = bluetooth;
|
||||
final result = await bluetooth.connectById(
|
||||
widget.deviceAddress,
|
||||
timeout: const Duration(seconds: 10),
|
||||
@ -669,6 +754,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
if (result.isErr()) {
|
||||
if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -715,15 +805,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
Future<void> _exitPage() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
_log.warning('Blocked page exit while firmware update is busy');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Firmware update is running. Keep this screen open until it completes.'),
|
||||
'Firmware update is running. Do not close this screen or the app until it completes.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info('Exiting device details page to /devices');
|
||||
await _disconnectOnClose();
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -731,6 +823,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
context.go('/devices');
|
||||
}
|
||||
|
||||
void _dismissFirmwareFullscreen() {
|
||||
_log.info(
|
||||
'Dismissing firmware fullscreen from state ${_dfuProgress.state}');
|
||||
setState(() {
|
||||
_dfuProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
expectedOffset: 0,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
_firmwareUserMessage = null;
|
||||
_selectedFirmware = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showStatusHistory() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@ -850,12 +959,30 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
currentConnectionStatus == ConnectionStatus.connected;
|
||||
final hasDeviceAccess =
|
||||
isCurrentConnected && _shifterService != null && _latestStatus != null;
|
||||
final canUseFirmwareUpdate = hasDeviceAccess;
|
||||
final canSelectFirmware =
|
||||
hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = hasDeviceAccess &&
|
||||
canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = canUseFirmwareUpdate &&
|
||||
!_isSelectingFirmware &&
|
||||
!_isFirmwareUpdateBusy &&
|
||||
_selectedFirmware != null;
|
||||
if (_isFirmwareUpdateBusy ||
|
||||
(_dfuProgress.state != DfuUpdateState.idle &&
|
||||
_dfuProgress.state != DfuUpdateState.completed &&
|
||||
_dfuProgress.state != DfuUpdateState.failed)) {
|
||||
return FirmwareUpdateFullscreen(
|
||||
progress: _dfuProgress,
|
||||
selectedFirmware: _selectedFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
expectedOffsetHex:
|
||||
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
onDismiss: _dismissFirmwareFullscreen,
|
||||
);
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (bool didPop, bool? result) {
|
||||
@ -885,8 +1012,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
status: _latestStatus,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (hasDeviceAccess) ...[
|
||||
if (canUseFirmwareUpdate) ...[
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
expectedOffsetHex:
|
||||
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (hasDeviceAccess) ...[
|
||||
_StatusBanner(
|
||||
status: _latestStatus,
|
||||
onTap: _showStatusHistory,
|
||||
@ -902,22 +1047,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
const SizedBox(height: 16),
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign:
|
||||
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onAssign: _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Opacity(
|
||||
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
|
||||
child: AbsorbPointer(
|
||||
absorbing: _isFirmwareUpdateBusy,
|
||||
child: GearRatioEditorCard(
|
||||
GearRatioEditorCard(
|
||||
ratios: _gearRatios,
|
||||
defaultGearIndex: _defaultGearIndex,
|
||||
isLoading: _isGearRatiosLoading,
|
||||
errorText: _gearRatiosError,
|
||||
onRetry:
|
||||
_isFirmwareUpdateBusy ? null : _loadGearRatios,
|
||||
onRetry: _loadGearRatios,
|
||||
onSave: _saveGearRatios,
|
||||
presets: const [
|
||||
GearRatioPreset(
|
||||
@ -985,29 +1124,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
),
|
||||
] else if (isCurrentConnected) ...[
|
||||
_PairingRequiredCard(
|
||||
isChecking: _isPairingCheckRunning,
|
||||
errorText: _pairingError,
|
||||
onRetry: _isFirmwareUpdateBusy ? null : _retryPairing,
|
||||
onRetry: _retryPairing,
|
||||
onOpenBluetoothSettings: _openPairingSettings,
|
||||
),
|
||||
] else ...[
|
||||
@ -1048,12 +1169,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
required this.phaseText,
|
||||
required this.statusText,
|
||||
required this.formattedProgressBytes,
|
||||
required this.ackSequenceHex,
|
||||
required this.expectedOffsetHex,
|
||||
required this.onSelectFirmware,
|
||||
required this.onStartUpdate,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? selectedFirmware;
|
||||
final BootloaderDfuPreparedFirmware? selectedFirmware;
|
||||
final DfuUpdateProgress progress;
|
||||
final bool isSelecting;
|
||||
final bool isStarting;
|
||||
@ -1062,7 +1183,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
final String phaseText;
|
||||
final String? statusText;
|
||||
final String formattedProgressBytes;
|
||||
final String ackSequenceHex;
|
||||
final String expectedOffsetHex;
|
||||
final Future<void> Function() onSelectFirmware;
|
||||
final Future<void> Function() onStartUpdate;
|
||||
|
||||
@ -1074,9 +1195,33 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
|
||||
bool get _showRebootExpectation {
|
||||
return progress.state == DfuUpdateState.finishing ||
|
||||
progress.state == DfuUpdateState.rebooting ||
|
||||
progress.state == DfuUpdateState.verifying ||
|
||||
progress.state == DfuUpdateState.completed;
|
||||
}
|
||||
|
||||
String? get _bootloaderStatusText {
|
||||
final status = progress.bootloaderStatus;
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
final codeLabel = switch (status.code) {
|
||||
DfuBootloaderStatusCode.ok => 'OK',
|
||||
DfuBootloaderStatusCode.parseError => 'parse error',
|
||||
DfuBootloaderStatusCode.stateError => 'state error',
|
||||
DfuBootloaderStatusCode.boundsError => 'bounds error',
|
||||
DfuBootloaderStatusCode.crcError => 'CRC error',
|
||||
DfuBootloaderStatusCode.flashError => 'flash error',
|
||||
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
|
||||
DfuBootloaderStatusCode.vectorError => 'vector table error',
|
||||
DfuBootloaderStatusCode.queueFull => 'queue full',
|
||||
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
|
||||
DfuBootloaderStatusCode.unknown =>
|
||||
'unknown 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
};
|
||||
return 'Bootloader status: $codeLabel, session ${status.sessionId}, expected offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@ -1101,7 +1246,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a firmware image, review the transfer state, and start the update when ready.',
|
||||
'Select a raw app image for the single-slot bootloader. Once START is accepted, the active app slot is erased.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
@ -1160,6 +1305,11 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'App start: 0x${selectedFirmware!.metadata.appStart.toRadixString(16).padLeft(8, '0').toUpperCase()} | Image version: ${selectedFirmware!.metadata.imageVersion} | Reset: 0x${selectedFirmware!.metadata.vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -1175,14 +1325,21 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Expected offset $expectedOffsetHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (_bootloaderStatusText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_bootloaderStatusText!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
'Expected behavior: after FINISH, the bootloader verifies the image, resets, and the updated app confirms itself before reconnecting.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@ -116,12 +116,11 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
}
|
||||
break;
|
||||
case Err(:final v):
|
||||
final error = v.toString();
|
||||
if (error.toLowerCase().contains('disconnected')) {
|
||||
if (isBluetoothPairingRecoveryError(v)) {
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Connection unsuccessful:\n$error')),
|
||||
SnackBar(content: Text('Connection unsuccessful:\n$v')),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -1,18 +1,176 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||
show DiscoveredDevice, ScanMode, Uuid;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DevicesTabPage extends ConsumerWidget {
|
||||
class DevicesTabPage extends ConsumerStatefulWidget {
|
||||
const DevicesTabPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DevicesTabPage> createState() => _DevicesTabPageState();
|
||||
}
|
||||
|
||||
class _DevicesTabPageState extends ConsumerState<DevicesTabPage> {
|
||||
static const Duration _bootloaderScanTimeout = Duration(seconds: 10);
|
||||
|
||||
StreamSubscription<List<DiscoveredDevice>>? _scanSubscription;
|
||||
BluetoothController? _bluetooth;
|
||||
DiscoveredDevice? _dfuDevice;
|
||||
bool _isBootloaderScanStarting = false;
|
||||
bool _isDisposed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_startBootloaderBackgroundScan());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
final bluetooth = _bluetooth;
|
||||
unawaited(_stopBootloaderScan(bluetooth));
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startBootloaderBackgroundScan() async {
|
||||
if (_isBootloaderScanStarting || _scanSubscription != null) {
|
||||
return;
|
||||
}
|
||||
_isBootloaderScanStarting = true;
|
||||
_clearBootloaderDevice();
|
||||
|
||||
try {
|
||||
final bluetooth = await ref.read(bluetoothProvider.future);
|
||||
if (!mounted || _isDisposed) {
|
||||
return;
|
||||
}
|
||||
_bluetooth = bluetooth;
|
||||
|
||||
final scanResult = await bluetooth.startScan(
|
||||
timeout: _bootloaderScanTimeout,
|
||||
scanMode: ScanMode.lowLatency,
|
||||
);
|
||||
if (!mounted || _isDisposed) {
|
||||
await bluetooth.stopScan();
|
||||
return;
|
||||
}
|
||||
if (scanResult.isErr()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_updateBootloaderDevice(bluetooth.scanResults);
|
||||
_scanSubscription = bluetooth.scanResultsStream.listen(
|
||||
_updateBootloaderDevice,
|
||||
);
|
||||
} finally {
|
||||
_isBootloaderScanStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
|
||||
final subscription = _scanSubscription;
|
||||
_scanSubscription = null;
|
||||
await subscription?.cancel();
|
||||
|
||||
await bluetooth?.stopScan();
|
||||
}
|
||||
|
||||
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
|
||||
if (_isDisposed || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
|
||||
(device) => device != null && _isBootloaderAdvertisement(device),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (dfuDevice == null) {
|
||||
_clearBootloaderDevice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dfuDevice.id == _dfuDevice?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_dfuDevice = dfuDevice;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearBootloaderDevice() {
|
||||
if (_isDisposed || !mounted || _dfuDevice == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_dfuDevice = null;
|
||||
});
|
||||
}
|
||||
|
||||
bool _isBootloaderAdvertisement(DiscoveredDevice device) {
|
||||
final name = device.name.trim();
|
||||
if (name == 'US-DFU' || name == 'UniversalShifters DFU') {
|
||||
return true;
|
||||
}
|
||||
return name.toLowerCase().contains('dfu') &&
|
||||
device.serviceUuids.any(
|
||||
(uuid) =>
|
||||
uuid.expanded ==
|
||||
Uuid.parse(universalShifterControlServiceUuid).expanded,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openBootloaderRecovery() async {
|
||||
final device = _dfuDevice;
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final firmware =
|
||||
await Navigator.of(context).push<BootloaderDfuPreparedFirmware>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _BootloaderRecoverySetupPage(device: device),
|
||||
),
|
||||
);
|
||||
if (!mounted || _isDisposed || firmware == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _stopBootloaderScan();
|
||||
if (!mounted || _isDisposed) {
|
||||
return;
|
||||
}
|
||||
_clearBootloaderDevice();
|
||||
context.push(
|
||||
'/bootloader_recovery_update',
|
||||
extra: BootloaderRecoveryUpdateArgs(
|
||||
bootloaderDeviceId: device.id,
|
||||
firmware: firmware,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicesAsync = ref.watch(nConnectedDevicesProvider);
|
||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||
final dfuDevice = _dfuDevice;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
@ -50,6 +208,13 @@ class DevicesTabPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (dfuDevice != null) ...[
|
||||
_BootloaderRecoveryCard(
|
||||
device: dfuDevice,
|
||||
onStartRecovery: _openBootloaderRecovery,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
devicesAsync.when(
|
||||
loading: () => const _LoadingCard(),
|
||||
error: (error, _) => _MessageCard(
|
||||
@ -86,6 +251,259 @@ class DevicesTabPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _BootloaderRecoveryCard extends StatelessWidget {
|
||||
const _BootloaderRecoveryCard({
|
||||
required this.device,
|
||||
required this.onStartRecovery,
|
||||
});
|
||||
|
||||
final DiscoveredDevice device;
|
||||
final VoidCallback onStartRecovery;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.45),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.system_update_alt, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'US-DFU Device Detected',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
device.name.isEmpty ? device.id : '${device.name} - ${device.id}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onStartRecovery,
|
||||
icon: const Icon(Icons.build_circle_outlined),
|
||||
label: const Text('Start Recovery'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget {
|
||||
const _BootloaderRecoverySetupPage({required this.device});
|
||||
|
||||
final DiscoveredDevice device;
|
||||
|
||||
@override
|
||||
ConsumerState<_BootloaderRecoverySetupPage> createState() =>
|
||||
_BootloaderRecoverySetupPageState();
|
||||
}
|
||||
|
||||
class _BootloaderRecoverySetupPageState
|
||||
extends ConsumerState<_BootloaderRecoverySetupPage> {
|
||||
final FirmwareFileSelectionService _firmwareFileSelectionService =
|
||||
FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker());
|
||||
|
||||
BootloaderDfuPreparedFirmware? _selectedFirmware;
|
||||
bool _isSelectingFirmware = false;
|
||||
String? _message;
|
||||
|
||||
Future<void> _selectFirmwareFile() async {
|
||||
if (_isSelectingFirmware) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSelectingFirmware = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
final suppressionCount = ref.read(
|
||||
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
|
||||
);
|
||||
suppressionCount.state += 1;
|
||||
|
||||
final FirmwareFileSelectionResult result;
|
||||
try {
|
||||
result =
|
||||
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
|
||||
} finally {
|
||||
suppressionCount.state =
|
||||
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSelectingFirmware = false;
|
||||
if (result.isSuccess) {
|
||||
_selectedFirmware = result.firmware;
|
||||
_message =
|
||||
'Validated ${result.firmware!.fileName}. Ready to start recovery.';
|
||||
} else if (!result.isCanceled) {
|
||||
_message = result.failure?.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startRecovery() {
|
||||
final firmware = _selectedFirmware;
|
||||
if (firmware == null) {
|
||||
setState(() {
|
||||
_message = 'Select a firmware .bin file before starting recovery.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(firmware);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final selectedFirmware = _selectedFirmware;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('US-DFU Recovery'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.system_update_alt_rounded,
|
||||
color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Recover Firmware Update',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
widget.device.name.isEmpty
|
||||
? widget.device.id
|
||||
: '${widget.device.name} - ${widget.device.id}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware.fileName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Size: ${selectedFirmware.fileBytes.length} bytes | Session: ${selectedFirmware.metadata.sessionId} | CRC32: 0x${selectedFirmware.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
_isSelectingFirmware ? null : _selectFirmwareFile,
|
||||
icon: _isSelectingFirmware
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed:
|
||||
selectedFirmware == null ? null : _startRecovery,
|
||||
icon: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_message != null && _message!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_message!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedDevicesList extends ConsumerStatefulWidget {
|
||||
const _SavedDevicesList();
|
||||
|
||||
@ -178,6 +596,8 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
|
||||
}
|
||||
|
||||
context.push('/device/${device.deviceAddress}');
|
||||
} else if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
||||
@ -30,7 +30,6 @@ class HomePage extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Devices Section
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Column(
|
||||
@ -84,7 +83,7 @@ class DevicesList extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
String? _connectingDeviceId; // ID of device currently being connected
|
||||
String? _connectingDeviceId;
|
||||
|
||||
Future<void> _removeDevice(ConnectedDevice device) async {
|
||||
final shouldRemove = await showDialog<bool>(
|
||||
@ -197,10 +196,10 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
context.go('/device/${device.deviceAddress}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Connection failed. Is the device turned on and in range?'),
|
||||
duration: const Duration(seconds: 3),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,112 +2,170 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
|
||||
const int _startPayloadLength = 11;
|
||||
const int _startPayloadLength = 19;
|
||||
|
||||
class DfuStartPayload {
|
||||
const DfuStartPayload({
|
||||
class BootloaderDfuStartPayload {
|
||||
const BootloaderDfuStartPayload({
|
||||
required this.totalLength,
|
||||
required this.imageCrc32,
|
||||
this.appStart = universalShifterDfuAppStart,
|
||||
this.imageVersion = 0,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int imageCrc32;
|
||||
final int appStart;
|
||||
final int imageVersion;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuDataFrame {
|
||||
const DfuDataFrame({
|
||||
required this.sequence,
|
||||
class BootloaderDfuDataFrame {
|
||||
const BootloaderDfuDataFrame({
|
||||
required this.sessionId,
|
||||
required this.offset,
|
||||
required this.payloadLength,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final int sequence;
|
||||
final int sessionId;
|
||||
final int offset;
|
||||
final int payloadLength;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
class DfuProtocol {
|
||||
const DfuProtocol._();
|
||||
class BootloaderDfuProtocol {
|
||||
const BootloaderDfuProtocol._();
|
||||
|
||||
static Uint8List encodeStartPayload(DfuStartPayload payload) {
|
||||
static const int crc32Initial = 0xFFFFFFFF;
|
||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||
|
||||
static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) {
|
||||
final data = ByteData(_startPayloadLength);
|
||||
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||
data.setUint32(1, payload.totalLength, Endian.little);
|
||||
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||
data.setUint8(9, payload.sessionId);
|
||||
data.setUint8(10, payload.flags);
|
||||
data.setUint32(9, payload.appStart, Endian.little);
|
||||
data.setUint32(13, payload.imageVersion, Endian.little);
|
||||
data.setUint8(17, payload.sessionId & 0xFF);
|
||||
data.setUint8(18, payload.flags & 0xFF);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List encodeFinishPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
|
||||
static Uint8List encodeFinishPayload(int sessionId) {
|
||||
return Uint8List.fromList([
|
||||
universalShifterDfuOpcodeFinish,
|
||||
sessionId & 0xFF,
|
||||
]);
|
||||
}
|
||||
|
||||
static Uint8List encodeAbortPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
|
||||
static Uint8List encodeAbortPayload(int sessionId) {
|
||||
return Uint8List.fromList([
|
||||
universalShifterDfuOpcodeAbort,
|
||||
sessionId & 0xFF,
|
||||
]);
|
||||
}
|
||||
|
||||
static List<DfuDataFrame> buildDataFrames(
|
||||
List<int> imageBytes, {
|
||||
int startSequence = 0,
|
||||
static Uint8List encodeGetStatusPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeGetStatus]);
|
||||
}
|
||||
|
||||
static BootloaderDfuDataFrame buildDataFrame({
|
||||
required List<int> imageBytes,
|
||||
required int sessionId,
|
||||
required int offset,
|
||||
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
}) {
|
||||
final frames = <DfuDataFrame>[];
|
||||
var seq = _asU8(startSequence);
|
||||
var offset = 0;
|
||||
while (offset < imageBytes.length) {
|
||||
if (offset < 0 || offset >= imageBytes.length) {
|
||||
throw RangeError.range(offset, 0, imageBytes.length - 1, 'offset');
|
||||
}
|
||||
if (payloadSize <= 0 ||
|
||||
payloadSize > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
|
||||
throw RangeError.range(
|
||||
payloadSize,
|
||||
1,
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
'payloadSize',
|
||||
);
|
||||
}
|
||||
|
||||
final remaining = imageBytes.length - offset;
|
||||
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
||||
? remaining
|
||||
: universalShifterDfuFramePayloadSizeBytes;
|
||||
|
||||
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
||||
frame[0] = seq;
|
||||
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
||||
|
||||
frames.add(
|
||||
DfuDataFrame(
|
||||
sequence: seq,
|
||||
offset: offset,
|
||||
payloadLength: chunkLength,
|
||||
bytes: frame,
|
||||
),
|
||||
final payloadLength = remaining < payloadSize ? remaining : payloadSize;
|
||||
final payloadEnd = offset + payloadLength;
|
||||
final payload = imageBytes.sublist(offset, payloadEnd);
|
||||
final frame = Uint8List(
|
||||
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
|
||||
);
|
||||
|
||||
offset += chunkLength;
|
||||
seq = nextSequence(seq);
|
||||
frame[0] = sessionId & 0xFF;
|
||||
final data = ByteData.view(frame.buffer);
|
||||
data.setUint32(1, offset, Endian.little);
|
||||
data.setUint32(5, crc32(payload), Endian.little);
|
||||
frame.setRange(
|
||||
universalShifterBootloaderDfuDataHeaderSizeBytes,
|
||||
universalShifterBootloaderDfuDataHeaderSizeBytes + payloadLength,
|
||||
payload,
|
||||
);
|
||||
|
||||
return BootloaderDfuDataFrame(
|
||||
sessionId: sessionId & 0xFF,
|
||||
offset: offset,
|
||||
payloadLength: payloadLength,
|
||||
bytes: frame,
|
||||
);
|
||||
}
|
||||
|
||||
static List<BootloaderDfuDataFrame> buildDataFrames({
|
||||
required List<int> imageBytes,
|
||||
required int sessionId,
|
||||
int payloadSize = universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
}) {
|
||||
final frames = <BootloaderDfuDataFrame>[];
|
||||
var offset = 0;
|
||||
while (offset < imageBytes.length) {
|
||||
final frame = buildDataFrame(
|
||||
imageBytes: imageBytes,
|
||||
sessionId: sessionId,
|
||||
offset: offset,
|
||||
payloadSize: payloadSize,
|
||||
);
|
||||
frames.add(frame);
|
||||
offset += frame.payloadLength;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
static int nextSequence(int sequence) {
|
||||
return _asU8(sequence + 1);
|
||||
static int maxPayloadSizeForMtu(int negotiatedMtu) {
|
||||
final writePayloadBytes =
|
||||
negotiatedMtu - universalShifterAttWriteOverheadBytes;
|
||||
final availablePayload =
|
||||
writePayloadBytes - universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||
if (availablePayload <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (availablePayload > universalShifterBootloaderDfuMaxPayloadSizeBytes) {
|
||||
return universalShifterBootloaderDfuMaxPayloadSizeBytes;
|
||||
}
|
||||
return availablePayload;
|
||||
}
|
||||
|
||||
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
||||
return nextSequence(acknowledgedSequence);
|
||||
static DfuBootloaderStatus parseStatusPayload(List<int> payload) {
|
||||
if (payload.length != universalShifterBootloaderDfuStatusSizeBytes) {
|
||||
throw const FormatException(
|
||||
'DFU status payload must be exactly 6 bytes.');
|
||||
}
|
||||
|
||||
static int sequenceDistance(int from, int to) {
|
||||
return _asU8(to - from);
|
||||
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 parseAckPayload(List<int> payload) {
|
||||
if (payload.length != 1) {
|
||||
throw const FormatException('ACK payload must be exactly 1 byte.');
|
||||
}
|
||||
return _asU8(payload.first);
|
||||
}
|
||||
|
||||
static const int crc32Initial = 0xFFFFFFFF;
|
||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||
|
||||
static int crc32Update(int crc, List<int> bytes) {
|
||||
var next = crc & 0xFFFFFFFF;
|
||||
for (final byte in bytes) {
|
||||
@ -130,8 +188,4 @@ class DfuProtocol {
|
||||
static int crc32(List<int> bytes) {
|
||||
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||
}
|
||||
|
||||
static int _asU8(int value) {
|
||||
return value & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ class FirmwareFileSelectionService {
|
||||
final FirmwareFilePicker _filePicker;
|
||||
final SessionIdGenerator _sessionIdGenerator;
|
||||
|
||||
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
|
||||
Future<FirmwareFileSelectionResult> selectAndPrepareBootloaderDfu() async {
|
||||
final FirmwarePickerSelection? selection;
|
||||
try {
|
||||
selection = await _filePicker.pickFirmwareFile();
|
||||
@ -127,15 +127,32 @@ class FirmwareFileSelectionService {
|
||||
);
|
||||
}
|
||||
|
||||
final metadata = DfuV1FirmwareMetadata(
|
||||
final imageValidationFailure = _validateBootloaderImage(
|
||||
selection.fileBytes,
|
||||
fileName,
|
||||
);
|
||||
if (imageValidationFailure != null) {
|
||||
return FirmwareFileSelectionResult.failed(imageValidationFailure);
|
||||
}
|
||||
|
||||
final vectorStackPointer = _readLeU32(selection.fileBytes, 0);
|
||||
final vectorReset = _readLeU32(selection.fileBytes, 4);
|
||||
|
||||
final sessionId = _normalizeSessionId(_sessionIdGenerator());
|
||||
|
||||
final metadata = BootloaderDfuFirmwareMetadata(
|
||||
totalLength: selection.fileBytes.length,
|
||||
crc32: DfuProtocol.crc32(selection.fileBytes),
|
||||
sessionId: _sessionIdGenerator() & 0xFF,
|
||||
crc32: BootloaderDfuProtocol.crc32(selection.fileBytes),
|
||||
appStart: universalShifterDfuAppStart,
|
||||
imageVersion: 0,
|
||||
sessionId: sessionId,
|
||||
flags: universalShifterDfuFlagNone,
|
||||
vectorStackPointer: vectorStackPointer,
|
||||
vectorReset: vectorReset,
|
||||
);
|
||||
|
||||
return FirmwareFileSelectionResult.success(
|
||||
DfuV1PreparedFirmware(
|
||||
BootloaderDfuPreparedFirmware(
|
||||
fileName: fileName,
|
||||
filePath: selection.filePath,
|
||||
fileBytes: selection.fileBytes,
|
||||
@ -148,7 +165,64 @@ class FirmwareFileSelectionService {
|
||||
return fileName.toLowerCase().endsWith('.bin');
|
||||
}
|
||||
|
||||
FirmwareSelectionFailure? _validateBootloaderImage(
|
||||
Uint8List imageBytes,
|
||||
String fileName,
|
||||
) {
|
||||
if (imageBytes.length < universalShifterDfuMinimumImageLengthBytes) {
|
||||
return FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.imageTooSmall,
|
||||
message:
|
||||
'Selected firmware file "$fileName" is too small for a bootloader application image. Need at least $universalShifterDfuMinimumImageLengthBytes bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (imageBytes.length > universalShifterDfuAppSlotSizeBytes) {
|
||||
return FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.imageTooLarge,
|
||||
message:
|
||||
'Selected firmware file "$fileName" is ${imageBytes.length} bytes, which exceeds the $universalShifterDfuAppSlotSizeBytes byte application slot.',
|
||||
);
|
||||
}
|
||||
|
||||
final vectorStackPointer = _readLeU32(imageBytes, 0);
|
||||
final vectorReset = _readLeU32(imageBytes, 4);
|
||||
final resetAddress = vectorReset & ~0x1;
|
||||
final imageEnd = universalShifterDfuAppStart + imageBytes.length;
|
||||
|
||||
if (vectorStackPointer < universalShifterDfuRamStart ||
|
||||
vectorStackPointer > universalShifterDfuRamEnd ||
|
||||
(vectorStackPointer & 0x3) != 0) {
|
||||
return FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.invalidVectorTable,
|
||||
message:
|
||||
'Selected firmware file "$fileName" has an invalid initial stack pointer (0x${vectorStackPointer.toRadixString(16).padLeft(8, '0').toUpperCase()}).',
|
||||
);
|
||||
}
|
||||
|
||||
if ((vectorReset & 0x1) == 0 ||
|
||||
resetAddress < universalShifterDfuAppStart + 8 ||
|
||||
resetAddress >= imageEnd) {
|
||||
return FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.invalidVectorTable,
|
||||
message:
|
||||
'Selected firmware file "$fileName" has an invalid reset vector (0x${vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}). Ensure the image starts at application address 0x${universalShifterDfuAppStart.toRadixString(16).padLeft(8, '0').toUpperCase()}.',
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int _readLeU32(Uint8List bytes, int offset) {
|
||||
return ByteData.sublistView(bytes).getUint32(offset, Endian.little);
|
||||
}
|
||||
|
||||
static int _normalizeSessionId(int sessionId) {
|
||||
final normalized = sessionId & 0xFF;
|
||||
return normalized == 0 ? 1 : normalized;
|
||||
}
|
||||
|
||||
static int _randomSessionId() {
|
||||
return Random.secure().nextInt(256);
|
||||
return Random.secure().nextInt(255) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,29 +10,15 @@ final _log = Logger('ShifterService');
|
||||
|
||||
class ShifterService {
|
||||
ShifterService({
|
||||
BluetoothController? bluetooth,
|
||||
required BluetoothController bluetooth,
|
||||
required this.buttonDeviceId,
|
||||
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
|
||||
}) : _bluetooth = bluetooth,
|
||||
_dfuPreflightBluetooth =
|
||||
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
|
||||
if (bluetooth == null && dfuPreflightBluetooth == null) {
|
||||
throw ArgumentError(
|
||||
'Either bluetooth or dfuPreflightBluetooth must be provided.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}) : _bluetooth = bluetooth;
|
||||
|
||||
final BluetoothController? _bluetooth;
|
||||
final BluetoothController _bluetooth;
|
||||
final String buttonDeviceId;
|
||||
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
|
||||
|
||||
BluetoothController get _requireBluetooth {
|
||||
final bluetooth = _bluetooth;
|
||||
if (bluetooth == null) {
|
||||
throw StateError('Bluetooth controller is not available.');
|
||||
}
|
||||
return bluetooth;
|
||||
return _bluetooth;
|
||||
}
|
||||
|
||||
final StreamController<CentralStatus> _statusController =
|
||||
@ -46,9 +32,11 @@ class ShifterService {
|
||||
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
|
||||
static const int _gearRatioWriteMtu = 64;
|
||||
|
||||
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
|
||||
Future<Result<void>> writeConnectToTrainerAddress(
|
||||
TrainerAddress trainerAddress,
|
||||
) async {
|
||||
try {
|
||||
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
|
||||
final payload = encodeTrainerAddress(trainerAddress);
|
||||
return _requireBluetooth.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
@ -56,12 +44,30 @@ class ShifterService {
|
||||
payload,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
return bail('Could not parse bike address "$bikeDeviceId": $e');
|
||||
return bail('Could not encode trainer address: $e');
|
||||
} catch (e) {
|
||||
return bail('Failed writing connect address: $e');
|
||||
return bail('Failed writing trainer address: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<TrainerScanEvent> subscribeToTrainerScanResults() {
|
||||
return _requireBluetooth
|
||||
.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterScanResultCharacteristicUuid,
|
||||
)
|
||||
.map(TrainerScanEvent.fromBytes);
|
||||
}
|
||||
|
||||
Future<Result<void>> startTrainerScan() {
|
||||
return writeCommand(UniversalShifterCommand.startScan);
|
||||
}
|
||||
|
||||
Future<Result<void>> stopTrainerScan() {
|
||||
return writeCommand(UniversalShifterCommand.stopScan);
|
||||
}
|
||||
|
||||
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
|
||||
return _requireBluetooth.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
@ -71,8 +77,10 @@ class ShifterService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async {
|
||||
final addrRes = await writeConnectToAddress(bikeDeviceId);
|
||||
Future<Result<void>> connectButtonToTrainer(
|
||||
TrainerAddress trainerAddress,
|
||||
) async {
|
||||
final addrRes = await writeConnectToTrainerAddress(trainerAddress);
|
||||
if (addrRes.isErr()) {
|
||||
return addrRes;
|
||||
}
|
||||
@ -221,72 +229,6 @@ class ShifterService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<DfuPreflightResult>> runDfuPreflight({
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
}) async {
|
||||
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
|
||||
final connectionStatus = currentConnection.$1;
|
||||
final connectedDeviceId = currentConnection.$2;
|
||||
|
||||
if (connectionStatus != ConnectionStatus.connected ||
|
||||
connectedDeviceId == null) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.deviceNotConnected,
|
||||
message:
|
||||
'No button connection is active. Connect the target button, then retry the firmware update.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (connectedDeviceId != buttonDeviceId) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
|
||||
message:
|
||||
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
|
||||
buttonDeviceId,
|
||||
mtu: requestedMtu,
|
||||
);
|
||||
if (mtuResult.isErr()) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
|
||||
message:
|
||||
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final negotiatedMtu = mtuResult.unwrap();
|
||||
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: DfuPreflightFailureReason.mtuTooLow,
|
||||
message:
|
||||
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void startStatusNotifications() {
|
||||
if (_statusSubscription != null) {
|
||||
return;
|
||||
@ -347,32 +289,6 @@ class ShifterService {
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class DfuPreflightBluetoothAdapter {
|
||||
(ConnectionStatus, String?) get currentConnectionState;
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
});
|
||||
}
|
||||
|
||||
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
|
||||
const _BluetoothDfuPreflightAdapter(this._bluetooth);
|
||||
|
||||
final BluetoothController _bluetooth;
|
||||
|
||||
@override
|
||||
(ConnectionStatus, String?) get currentConnectionState =>
|
||||
_bluetooth.currentConnectionState;
|
||||
|
||||
@override
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
}) {
|
||||
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||
}
|
||||
}
|
||||
|
||||
class GearRatiosData {
|
||||
const GearRatiosData({
|
||||
required this.ratios,
|
||||
|
||||
@ -3,6 +3,10 @@ import 'dart:io';
|
||||
import 'package:app_settings/app_settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
bool isBluetoothPairingRecoveryError(Object error) {
|
||||
return error.toString().toLowerCase().contains('disconnected');
|
||||
}
|
||||
|
||||
Future<bool> openBluetoothSettings() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
@ -23,7 +27,8 @@ Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
|
||||
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';
|
||||
final settingsButtonLabel =
|
||||
isIOS ? 'Open Settings' : 'Open Bluetooth settings';
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BikeScanDialog extends ConsumerStatefulWidget {
|
||||
class BikeScanDialog extends StatefulWidget {
|
||||
const BikeScanDialog({
|
||||
required this.excludedDeviceId,
|
||||
required this.shifter,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String excludedDeviceId;
|
||||
final ShifterService shifter;
|
||||
|
||||
static Future<DiscoveredDevice?> show(
|
||||
static Future<TrainerScanResult?> show(
|
||||
BuildContext context, {
|
||||
required String excludedDeviceId,
|
||||
required ShifterService shifter,
|
||||
}) {
|
||||
return showDialog<DiscoveredDevice>(
|
||||
return showDialog<TrainerScanResult>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
|
||||
builder: (_) => BikeScanDialog(shifter: shifter),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
|
||||
State<BikeScanDialog> createState() => _BikeScanDialogState();
|
||||
}
|
||||
|
||||
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
bool _showAll = false;
|
||||
class _BikeScanDialogState extends State<BikeScanDialog> {
|
||||
bool _showOnlyFtms = true;
|
||||
bool _isStartingScan = true;
|
||||
bool _isScanning = false;
|
||||
String? _scanError;
|
||||
BluetoothController? _controller;
|
||||
final Map<String, TrainerScanResult> _resultsByAddress = {};
|
||||
StreamSubscription<TrainerScanEvent>? _scanSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -42,16 +42,39 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
await _scanSubscription?.cancel();
|
||||
if (_isScanning) {
|
||||
await widget.shifter.stopTrainerScan();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isStartingScan = true;
|
||||
_isScanning = false;
|
||||
_scanError = null;
|
||||
_resultsByAddress.clear();
|
||||
});
|
||||
|
||||
try {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
_scanSubscription = widget.shifter.subscribeToTrainerScanResults().listen(
|
||||
_handleScanEvent,
|
||||
onError: (Object error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_scanError = error.toString();
|
||||
_isStartingScan = false;
|
||||
_isScanning = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
final startResult = await widget.shifter.startTrainerScan();
|
||||
if (startResult.isErr()) {
|
||||
_scanError = startResult.unwrapErr().toString();
|
||||
} else {
|
||||
_isScanning = true;
|
||||
}
|
||||
} catch (error) {
|
||||
_scanError = error.toString();
|
||||
} finally {
|
||||
@ -63,15 +86,43 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleScanEvent(TrainerScanEvent event) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isStartingScan = false;
|
||||
switch (event.kind) {
|
||||
case TrainerScanEventKind.scanStarted:
|
||||
_isScanning = true;
|
||||
_scanError = null;
|
||||
break;
|
||||
case TrainerScanEventKind.device:
|
||||
final result = event.result;
|
||||
if (result != null) {
|
||||
_resultsByAddress[result.address.key] = result;
|
||||
}
|
||||
break;
|
||||
case TrainerScanEventKind.scanFinished:
|
||||
case TrainerScanEventKind.scanCancelled:
|
||||
_isScanning = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.stopScan();
|
||||
_scanSubscription?.cancel();
|
||||
if (_isScanning) {
|
||||
unawaited(widget.shifter.stopTrainerScan());
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btAsync = ref.watch(bluetoothProvider);
|
||||
final size = MediaQuery.of(context).size;
|
||||
final dialogWidth = size.width < 640 ? size.width - 24 : 560.0;
|
||||
final dialogHeight = size.height < 780 ? size.height * 0.78 : 560.0;
|
||||
@ -83,216 +134,85 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
child: SizedBox(
|
||||
width: dialogWidth,
|
||||
height: dialogHeight,
|
||||
child: btAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
|
||||
data: (controller) {
|
||||
_controller ??= controller;
|
||||
return Column(
|
||||
child: Column(
|
||||
children: [
|
||||
_DialogHeader(
|
||||
showAll: _showAll,
|
||||
isScanning: _isStartingScan,
|
||||
showOnlyFtms: _showOnlyFtms,
|
||||
isScanning: _isStartingScan || _isScanning,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showAll = value;
|
||||
_showOnlyFtms = value;
|
||||
});
|
||||
},
|
||||
onRescan: _startScan,
|
||||
),
|
||||
Expanded(
|
||||
child: _scanError != null
|
||||
? _ScanMessage(
|
||||
message: 'Could not start trainer scan: $_scanError',
|
||||
Expanded(child: _buildBody(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (_scanError != null) {
|
||||
return _ScanMessage(
|
||||
message: 'Could not start shifter trainer scan: $_scanError',
|
||||
action: TextButton.icon(
|
||||
onPressed: _startScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
)
|
||||
: 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 (_isStartingScan && _resultsByAddress.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final devices = _filteredDevices();
|
||||
if (devices.isEmpty) {
|
||||
return const _ScanMessage(
|
||||
message:
|
||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||
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) {
|
||||
final device = devices[index];
|
||||
final isFtms = _advertisesFtms(device);
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) => _TrainerScanResultTile(
|
||||
result: devices[index],
|
||||
onTap: () => Navigator.of(context).pop(devices[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
|
||||
return devices.where((device) {
|
||||
if (device.id == widget.excludedDeviceId) {
|
||||
return false;
|
||||
}
|
||||
if (_showAll) {
|
||||
return true;
|
||||
}
|
||||
return _advertisesFtms(device);
|
||||
List<TrainerScanResult> _filteredDevices() {
|
||||
final devices = _resultsByAddress.values.where((device) {
|
||||
return !_showOnlyFtms || device.ftmsDetected;
|
||||
}).toList(growable: false);
|
||||
devices.sort((a, b) {
|
||||
final ftmsCompare = (b.ftmsDetected ? 1 : 0) - (a.ftmsDetected ? 1 : 0);
|
||||
if (ftmsCompare != 0) {
|
||||
return ftmsCompare;
|
||||
}
|
||||
|
||||
bool _advertisesFtms(DiscoveredDevice device) {
|
||||
return device.serviceUuids.any(isFtmsUuid) ||
|
||||
device.serviceData.keys.any(isFtmsUuid);
|
||||
return b.rssi.compareTo(a.rssi);
|
||||
});
|
||||
return devices;
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogHeader extends StatelessWidget {
|
||||
const _DialogHeader({
|
||||
required this.showAll,
|
||||
required this.showOnlyFtms,
|
||||
required this.isScanning,
|
||||
required this.onChanged,
|
||||
required this.onRescan,
|
||||
});
|
||||
|
||||
final bool showAll;
|
||||
final bool showOnlyFtms;
|
||||
final bool isScanning;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final VoidCallback onRescan;
|
||||
@ -319,7 +239,7 @@ class _DialogHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Tap a nearby trainer to assign it to the connected shifter.',
|
||||
'The shifter scans nearby trainers. Tap one to assign it.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
@ -344,13 +264,13 @@ class _DialogHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Show All',
|
||||
'FTMS only',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(value: showAll, onChanged: onChanged),
|
||||
Switch(value: showOnlyFtms, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -379,6 +299,109 @@ class _DialogHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TrainerScanResultTile extends StatelessWidget {
|
||||
const _TrainerScanResultTile({
|
||||
required this.result,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final TrainerScanResult result;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final name = result.name.isEmpty ? 'Unknown Trainer' : result.name;
|
||||
final typeLabel = result.ftmsDetected ? 'FTMS trainer' : 'Nearby device';
|
||||
final addressText = _formatTrainerAddress(result.address);
|
||||
|
||||
return Material(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pedal_bike_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
typeLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
addressText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RssiBadge(rssi: result.rssi),
|
||||
const SizedBox(height: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTrainerAddress(TrainerAddress address) {
|
||||
final flags = address.flags.toRadixString(16).padLeft(2, '0');
|
||||
return '${formatMacAddressFromLittleEndian(address.bytes)} · flags 0x$flags';
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanMessage extends StatelessWidget {
|
||||
const _ScanMessage({
|
||||
required this.message,
|
||||
|
||||
247
lib/widgets/firmware_update_fullscreen.dart
Normal file
247
lib/widgets/firmware_update_fullscreen.dart
Normal file
@ -0,0 +1,247 @@
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FirmwareUpdateFullscreen extends StatelessWidget {
|
||||
const FirmwareUpdateFullscreen({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.selectedFirmware,
|
||||
required this.phaseText,
|
||||
required this.statusText,
|
||||
required this.formattedProgressBytes,
|
||||
required this.expectedOffsetHex,
|
||||
required this.onDismiss,
|
||||
this.doneLabel = 'Done',
|
||||
this.failedLabel = 'Back to device',
|
||||
});
|
||||
|
||||
final DfuUpdateProgress progress;
|
||||
final BootloaderDfuPreparedFirmware? selectedFirmware;
|
||||
final String phaseText;
|
||||
final String? statusText;
|
||||
final String formattedProgressBytes;
|
||||
final String expectedOffsetHex;
|
||||
final VoidCallback onDismiss;
|
||||
final String doneLabel;
|
||||
final String failedLabel;
|
||||
|
||||
bool get _isTerminal =>
|
||||
progress.state == DfuUpdateState.completed ||
|
||||
progress.state == DfuUpdateState.failed;
|
||||
|
||||
bool get _isRunning => !_isTerminal && progress.state != DfuUpdateState.idle;
|
||||
|
||||
String? get _bootloaderStatusText {
|
||||
final status = progress.bootloaderStatus;
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
final codeLabel = switch (status.code) {
|
||||
DfuBootloaderStatusCode.ok => 'OK',
|
||||
DfuBootloaderStatusCode.parseError => 'parse error',
|
||||
DfuBootloaderStatusCode.stateError => 'state error',
|
||||
DfuBootloaderStatusCode.boundsError => 'bounds error',
|
||||
DfuBootloaderStatusCode.crcError => 'CRC error',
|
||||
DfuBootloaderStatusCode.flashError => 'flash error',
|
||||
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
|
||||
DfuBootloaderStatusCode.vectorError => 'vector table error',
|
||||
DfuBootloaderStatusCode.queueFull => 'queue full',
|
||||
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
|
||||
DfuBootloaderStatusCode.unknown =>
|
||||
'unknown 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
};
|
||||
return '$codeLabel • session ${status.sessionId} • offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isFailed = progress.state == DfuUpdateState.failed;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_isRunning)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
color: colorScheme.errorContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: colorScheme.onErrorContainer, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Do not close the app, lock the phone, or move away from the button.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_isTerminal
|
||||
? (isFailed
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.check_circle_outline_rounded)
|
||||
: Icons.system_update_alt_rounded,
|
||||
size: 56,
|
||||
color: _isTerminal
|
||||
? (isFailed
|
||||
? colorScheme.error
|
||||
: colorScheme.primary)
|
||||
: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_isTerminal
|
||||
? (isFailed ? 'Update failed' : 'Update completed')
|
||||
: 'Updating firmware',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
phaseText,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${selectedFirmware!.fileName} • ${_formatBytes(selectedFirmware!.fileBytes.length)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (_isRunning) ...[
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0
|
||||
? progress.fractionComplete
|
||||
: null,
|
||||
minHeight: 12,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (progress.state == DfuUpdateState.finishing ||
|
||||
progress.state == DfuUpdateState.rebooting ||
|
||||
progress.state == DfuUpdateState.verifying) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.36),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Bootloader is verifying, resetting, and booting the new app. Keep the screen open.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_bootloaderStatusText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_bootloaderStatusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.56),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null &&
|
||||
statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isFailed
|
||||
? colorScheme.onErrorContainer
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_isTerminal) ...[
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onDismiss,
|
||||
icon: Icon(isFailed
|
||||
? Icons.arrow_back_rounded
|
||||
: Icons.check_rounded),
|
||||
label: Text(isFailed ? failedLabel : doneLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
}
|
||||
@ -108,6 +108,126 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('TrainerScanEvent.fromBytes', () {
|
||||
test('parses scan lifecycle events', () {
|
||||
expect(
|
||||
TrainerScanEvent.fromBytes(const [1, 0, 7]).kind,
|
||||
TrainerScanEventKind.scanStarted,
|
||||
);
|
||||
expect(
|
||||
TrainerScanEvent.fromBytes(const [1, 2, 8]).kind,
|
||||
TrainerScanEventKind.scanFinished,
|
||||
);
|
||||
expect(
|
||||
TrainerScanEvent.fromBytes(const [1, 3, 9]).kind,
|
||||
TrainerScanEventKind.scanCancelled,
|
||||
);
|
||||
});
|
||||
|
||||
test('parses device event with signed RSSI and flags', () {
|
||||
final event = TrainerScanEvent.fromBytes([
|
||||
1,
|
||||
1,
|
||||
42,
|
||||
0xc1,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
0xd6,
|
||||
trainerScanDeviceFlagFtmsDetected |
|
||||
trainerScanDeviceFlagNameComplete |
|
||||
trainerScanDeviceFlagConnectable,
|
||||
5,
|
||||
...'Kickr'.codeUnits,
|
||||
]);
|
||||
|
||||
expect(event.kind, TrainerScanEventKind.device);
|
||||
expect(event.sequence, 42);
|
||||
expect(event.result, isNotNull);
|
||||
expect(event.result!.address.flags, 0xc1);
|
||||
expect(event.result!.address.bytes, [1, 2, 3, 4, 5, 6]);
|
||||
expect(event.result!.rssi, -42);
|
||||
expect(event.result!.name, 'Kickr');
|
||||
expect(event.result!.ftmsDetected, isTrue);
|
||||
expect(event.result!.nameComplete, isTrue);
|
||||
expect(event.result!.scanResponseSeen, isFalse);
|
||||
expect(event.result!.connectable, isTrue);
|
||||
});
|
||||
|
||||
test('rejects invalid scan payloads', () {
|
||||
expect(
|
||||
() => TrainerScanEvent.fromBytes(const []),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => TrainerScanEvent.fromBytes(const [2, 0, 1]),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => TrainerScanEvent.fromBytes(const [1, 9, 1]),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => TrainerScanEvent.fromBytes(const [1, 1, 1]),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => TrainerScanEvent.fromBytes(const [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
65,
|
||||
]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('encodeTrainerAddress', () {
|
||||
test('encodes flags and address bytes', () {
|
||||
expect(
|
||||
encodeTrainerAddress(
|
||||
const TrainerAddress(flags: 0xc1, bytes: [1, 2, 3, 4, 5, 6]),
|
||||
),
|
||||
[0xc1, 1, 2, 3, 4, 5, 6],
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid address values', () {
|
||||
expect(
|
||||
() => encodeTrainerAddress(
|
||||
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5]),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => encodeTrainerAddress(
|
||||
const TrainerAddress(flags: 256, bytes: [1, 2, 3, 4, 5, 6]),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => encodeTrainerAddress(
|
||||
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5, 256]),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('standard GATT telemetry parsing', () {
|
||||
test('decodes battery level percentage', () {
|
||||
expect(parseBatteryLevelPercent([0]), 0);
|
||||
|
||||
@ -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,28 +3,28 @@ import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('DfuProtocol CRC32', () {
|
||||
group('BootloaderDfuProtocol CRC32', () {
|
||||
test('matches known vector', () {
|
||||
final crc = DfuProtocol.crc32('123456789'.codeUnits);
|
||||
final crc = BootloaderDfuProtocol.crc32('123456789'.codeUnits);
|
||||
expect(crc, 0xCBF43926);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol control payload encoding', () {
|
||||
test('encodes START payload with exact 11-byte LE layout', () {
|
||||
final payload = DfuProtocol.encodeStartPayload(
|
||||
const DfuStartPayload(
|
||||
group('BootloaderDfuProtocol control payload encoding', () {
|
||||
test('encodes START payload with exact 19-byte LE layout', () {
|
||||
final payload = BootloaderDfuProtocol.encodeStartPayload(
|
||||
const BootloaderDfuStartPayload(
|
||||
totalLength: 0x1234,
|
||||
imageCrc32: 0x89ABCDEF,
|
||||
appStart: universalShifterDfuAppStart,
|
||||
imageVersion: 0x10203040,
|
||||
sessionId: 0x22,
|
||||
flags: universalShifterDfuFlagEncrypted,
|
||||
flags: universalShifterDfuFlagNone,
|
||||
),
|
||||
);
|
||||
|
||||
expect(payload.length, 11);
|
||||
expect(
|
||||
payload,
|
||||
[
|
||||
expect(payload.length, 19);
|
||||
expect(payload, [
|
||||
universalShifterDfuOpcodeStart,
|
||||
0x34,
|
||||
0x12,
|
||||
@ -34,66 +34,127 @@ void main() {
|
||||
0xCD,
|
||||
0xAB,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x03,
|
||||
0x00,
|
||||
0x40,
|
||||
0x30,
|
||||
0x20,
|
||||
0x10,
|
||||
0x22,
|
||||
universalShifterDfuFlagEncrypted,
|
||||
],
|
||||
universalShifterDfuFlagNone,
|
||||
]);
|
||||
});
|
||||
|
||||
test('encodes FINISH, ABORT, and GET_STATUS payloads', () {
|
||||
expect(
|
||||
BootloaderDfuProtocol.encodeFinishPayload(0x12),
|
||||
[universalShifterDfuOpcodeFinish, 0x12],
|
||||
);
|
||||
expect(
|
||||
BootloaderDfuProtocol.encodeAbortPayload(0x34),
|
||||
[universalShifterDfuOpcodeAbort, 0x34],
|
||||
);
|
||||
expect(
|
||||
BootloaderDfuProtocol.encodeGetStatusPayload(),
|
||||
[universalShifterDfuOpcodeGetStatus],
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes FINISH and ABORT payloads as one byte', () {
|
||||
expect(
|
||||
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
|
||||
expect(
|
||||
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol data frame building', () {
|
||||
test('builds 64-byte frames and handles final partial payload', () {
|
||||
final image = List<int>.generate(80, (index) => index);
|
||||
final frames = DfuProtocol.buildDataFrames(image);
|
||||
group('BootloaderDfuProtocol data frame building', () {
|
||||
test('builds offset frames with payload CRC and variable final length', () {
|
||||
final image = List<int>.generate(60, (index) => index);
|
||||
final frames = BootloaderDfuProtocol.buildDataFrames(
|
||||
imageBytes: image,
|
||||
sessionId: 0x7A,
|
||||
);
|
||||
|
||||
expect(frames.length, 2);
|
||||
|
||||
expect(frames[0].sequence, 0);
|
||||
expect(frames[0].sessionId, 0x7A);
|
||||
expect(frames[0].offset, 0);
|
||||
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
|
||||
expect(frames[0].payloadLength,
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes);
|
||||
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
|
||||
expect(frames[0].bytes[0], 0x7A);
|
||||
expect(frames[0].bytes.sublist(1, 5), [0, 0, 0, 0]);
|
||||
expect(
|
||||
frames[0].bytes.sublist(5, 9),
|
||||
_leU32Bytes(BootloaderDfuProtocol.crc32(image.sublist(0, 55))),
|
||||
);
|
||||
expect(frames[0].bytes.sublist(9), image.sublist(0, 55));
|
||||
|
||||
expect(frames[1].sequence, 1);
|
||||
expect(frames[1].offset, 63);
|
||||
expect(frames[1].payloadLength, 17);
|
||||
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
|
||||
expect(frames[1].offset, 55);
|
||||
expect(frames[1].payloadLength, 5);
|
||||
expect(frames[1].bytes.length, 14);
|
||||
expect(frames[1].bytes.sublist(1, 5), [55, 0, 0, 0]);
|
||||
expect(frames[1].bytes.sublist(9), image.sublist(55));
|
||||
});
|
||||
|
||||
test('uses deterministic wrapping sequence numbers from custom start', () {
|
||||
final image = List<int>.generate(
|
||||
3 * universalShifterDfuFramePayloadSizeBytes,
|
||||
(index) => index & 0xFF);
|
||||
test('uses caller supplied payload size for low-MTU links', () {
|
||||
final image = List<int>.generate(15, (index) => index);
|
||||
final frames = BootloaderDfuProtocol.buildDataFrames(
|
||||
imageBytes: image,
|
||||
sessionId: 0x01,
|
||||
payloadSize: 4,
|
||||
);
|
||||
|
||||
final frames = DfuProtocol.buildDataFrames(image, startSequence: 0xFE);
|
||||
expect(frames.map((frame) => frame.payloadLength), [4, 4, 4, 3]);
|
||||
expect(frames.map((frame) => frame.offset), [0, 4, 8, 12]);
|
||||
});
|
||||
|
||||
expect(frames.length, 3);
|
||||
expect(frames[0].sequence, 0xFE);
|
||||
expect(frames[1].sequence, 0xFF);
|
||||
expect(frames[2].sequence, 0x00);
|
||||
test('calculates safe payload size from negotiated MTU', () {
|
||||
expect(
|
||||
BootloaderDfuProtocol.maxPayloadSizeForMtu(64),
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes - 3,
|
||||
);
|
||||
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(23), 11);
|
||||
expect(BootloaderDfuProtocol.maxPayloadSizeForMtu(12), 0);
|
||||
expect(
|
||||
BootloaderDfuProtocol.maxPayloadSizeForMtu(128),
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol sequence and ACK helpers', () {
|
||||
test('wraps sequence values and computes ack+1 rewind', () {
|
||||
expect(DfuProtocol.nextSequence(0x00), 0x01);
|
||||
expect(DfuProtocol.nextSequence(0xFF), 0x00);
|
||||
group('BootloaderDfuProtocol status parsing', () {
|
||||
test('parses bootloader status payload', () {
|
||||
final status = BootloaderDfuProtocol.parseStatusPayload(
|
||||
[0x00, 0x22, 0x78, 0x56, 0x34, 0x12],
|
||||
);
|
||||
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
|
||||
expect(status.code, DfuBootloaderStatusCode.ok);
|
||||
expect(status.rawCode, 0x00);
|
||||
expect(status.sessionId, 0x22);
|
||||
expect(status.expectedOffset, 0x12345678);
|
||||
expect(status.isOk, isTrue);
|
||||
});
|
||||
|
||||
test('computes wrapping sequence distance', () {
|
||||
expect(DfuProtocol.sequenceDistance(250, 2), 8);
|
||||
expect(DfuProtocol.sequenceDistance(1, 1), 0);
|
||||
test('preserves unknown status codes', () {
|
||||
final status = BootloaderDfuProtocol.parseStatusPayload(
|
||||
[0xFE, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
);
|
||||
|
||||
expect(status.code, DfuBootloaderStatusCode.unknown);
|
||||
expect(status.rawCode, 0xFE);
|
||||
});
|
||||
|
||||
test('rejects malformed status payloads', () {
|
||||
expect(
|
||||
() => BootloaderDfuProtocol.parseStatusPayload(const [0, 1]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
List<int> _leU32Bytes(int value) {
|
||||
return [
|
||||
value & 0xFF,
|
||||
(value >> 8) & 0xFF,
|
||||
(value >> 16) & 0xFF,
|
||||
(value >> 24) & 0xFF,
|
||||
];
|
||||
}
|
||||
|
||||
@ -2,34 +2,40 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareFileSelectionService', () {
|
||||
test('prepares v1 metadata for selected .bin firmware', () async {
|
||||
test('prepares bootloader metadata for selected .bin firmware', () async {
|
||||
final image = _validBootloaderImage();
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.BIN',
|
||||
filePath: '/tmp/firmware.BIN',
|
||||
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
|
||||
fileBytes: image,
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => 0x1AB,
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
expect(result.isSuccess, isTrue);
|
||||
|
||||
final firmware = result.firmware!;
|
||||
expect(firmware.fileName, 'firmware.BIN');
|
||||
expect(firmware.filePath, '/tmp/firmware.BIN');
|
||||
expect(firmware.fileBytes, <int>[1, 2, 3, 4]);
|
||||
expect(firmware.metadata.totalLength, 4);
|
||||
expect(firmware.metadata.crc32, 0xB63CFBCD);
|
||||
expect(firmware.fileBytes, image);
|
||||
expect(firmware.metadata.totalLength, image.length);
|
||||
expect(firmware.metadata.crc32, BootloaderDfuProtocol.crc32(image));
|
||||
expect(firmware.metadata.appStart, universalShifterDfuAppStart);
|
||||
expect(firmware.metadata.imageVersion, 0);
|
||||
expect(firmware.metadata.sessionId, 0xAB);
|
||||
expect(firmware.metadata.flags, universalShifterDfuFlagNone);
|
||||
expect(firmware.metadata.vectorStackPointer, 0x20001000);
|
||||
expect(firmware.metadata.vectorReset, 0x00030009);
|
||||
});
|
||||
|
||||
test('returns canceled result when user dismisses picker', () async {
|
||||
@ -37,7 +43,7 @@ void main() {
|
||||
filePicker: _FakeFirmwareFilePicker(selection: null),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.isCanceled, isTrue);
|
||||
@ -49,12 +55,12 @@ void main() {
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.hex',
|
||||
fileBytes: Uint8List.fromList(<int>[1]),
|
||||
fileBytes: _validBootloaderImage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason,
|
||||
@ -71,31 +77,124 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile);
|
||||
});
|
||||
|
||||
test('rejects images that are too small for a vector table', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(
|
||||
result.failure?.reason, FirmwareSelectionFailureReason.imageTooSmall);
|
||||
});
|
||||
|
||||
test('rejects images larger than the application slot', () async {
|
||||
final image = Uint8List(universalShifterDfuAppSlotSizeBytes + 1);
|
||||
_writeLeU32(image, 0, 0x20001000);
|
||||
_writeLeU32(image, 4, 0x00030009);
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: image,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(
|
||||
result.failure?.reason, FirmwareSelectionFailureReason.imageTooLarge);
|
||||
});
|
||||
|
||||
test('accepts image exactly at application slot size', () async {
|
||||
final image = Uint8List(universalShifterDfuAppSlotSizeBytes);
|
||||
_writeLeU32(image, 0, 0x20001000);
|
||||
_writeLeU32(image, 4, 0x00030009);
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: image,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(result.firmware?.metadata.totalLength,
|
||||
universalShifterDfuAppSlotSizeBytes);
|
||||
});
|
||||
|
||||
test('rejects images with invalid vector table', () async {
|
||||
final image = _validBootloaderImage();
|
||||
_writeLeU32(image, 0, 0x10001000);
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: image,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason,
|
||||
FirmwareSelectionFailureReason.invalidVectorTable);
|
||||
});
|
||||
|
||||
test('generates session id per run', () async {
|
||||
var nextSession = 9;
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List.fromList(<int>[10]),
|
||||
fileBytes: _validBootloaderImage(),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => nextSession++,
|
||||
);
|
||||
|
||||
final first = await service.selectAndPrepareDfuV1();
|
||||
final second = await service.selectAndPrepareDfuV1();
|
||||
final first = await service.selectAndPrepareBootloaderDfu();
|
||||
final second = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(first.firmware?.metadata.sessionId, 9);
|
||||
expect(second.firmware?.metadata.sessionId, 10);
|
||||
});
|
||||
|
||||
test('normalizes generated zero session id to one', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: _validBootloaderImage(),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => 0,
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
expect(result.firmware?.metadata.sessionId, 1);
|
||||
});
|
||||
|
||||
test('maps picker read failure to explicit validation error', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
@ -104,7 +203,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
final result = await service.selectAndPrepareBootloaderDfu();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed);
|
||||
@ -113,6 +212,18 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
Uint8List _validBootloaderImage() {
|
||||
final image = Uint8List(16);
|
||||
_writeLeU32(image, 0, 0x20001000);
|
||||
_writeLeU32(image, 4, 0x00030009);
|
||||
return image;
|
||||
}
|
||||
|
||||
void _writeLeU32(Uint8List bytes, int offset, int value) {
|
||||
final data = ByteData.sublistView(bytes);
|
||||
data.setUint32(offset, value, Endian.little);
|
||||
}
|
||||
|
||||
class _FakeFirmwareFilePicker implements FirmwareFilePicker {
|
||||
_FakeFirmwareFilePicker({
|
||||
required this.selection,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
@ -6,241 +7,180 @@ import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareUpdateService', () {
|
||||
test('completes happy path with START, data frames, and FINISH', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport();
|
||||
group('FirmwareUpdateService bootloader flow', () {
|
||||
test('completes happy path with START, offset data, FINISH, and verify',
|
||||
() async {
|
||||
final image = _validImage(130);
|
||||
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 7,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.controlWrites.length, 2);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
expect(transport.steps, [
|
||||
'isConnectedToBootloader',
|
||||
'enterBootloader',
|
||||
'waitForAppDisconnect',
|
||||
'connectToBootloader',
|
||||
'optimizeBootloaderConnection',
|
||||
'negotiateMtu',
|
||||
'readStatus',
|
||||
'waitForBootloaderDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
]);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(
|
||||
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
|
||||
expect(transport.dataWrites, isNotEmpty);
|
||||
expect(transport.dataWrites.first[0], 7);
|
||||
expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
expect(service.currentProgress.expectedOffset, image.length);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
||||
test('starts directly when already connected to bootloader', () async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
alreadyInBootloader: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 3,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
maxNoProgressRetries: 4,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 8,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.steps, [
|
||||
'isConnectedToBootloader',
|
||||
'optimizeBootloaderConnection',
|
||||
'negotiateMtu',
|
||||
'readStatus',
|
||||
'waitForBootloaderDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
]);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('tolerates enter bootloader write error when app disconnects',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
failEnterBootloader: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 12,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.steps, contains('waitForAppDisconnect'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('backs off on queue-full status and resumes from GET_STATUS',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
queueFullOnFirstData: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(190, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 9,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.dataWrites.length, greaterThan(4));
|
||||
expect(transport.sequenceWriteCount(1), greaterThan(1));
|
||||
expect(
|
||||
transport.controlWrites
|
||||
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails after bounded retries when ACK progress times out', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 40),
|
||||
maxNoProgressRetries: 2,
|
||||
);
|
||||
|
||||
final image = List<int>.generate(90, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 10,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('Upload stalled'));
|
||||
expect(result.unwrapErr().toString(), contains('after 3 retries'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(transport.sequenceWriteCount(0), 3);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel sends ABORT and reports aborted state', () async {
|
||||
final firstFrameSent = Completer<void>();
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
onDataWrite: (frame) {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
suppressDataAcks: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
||||
sessionId: 11,
|
||||
);
|
||||
|
||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||
await service.cancelUpdate();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when reconnect does not succeed after expected reset',
|
||||
test('completes when FINISH status is lost but bootloader disconnects',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
reconnectError: 'simulated reconnect timeout',
|
||||
totalBytes: image.length,
|
||||
suppressFinishStatus: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
defaultStatusTimeout: const Duration(milliseconds: 20),
|
||||
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 13,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('did not reconnect'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when expected reset disconnect is not observed', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
resetDisconnectError: 'simulated missing disconnect',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 15,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('expected post-FINISH reset disconnect'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
['waitForExpectedResetDisconnect'],
|
||||
);
|
||||
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
|
||||
expect(transport.steps, contains('reconnectForVerification'));
|
||||
expect(transport.steps, contains('verifyDeviceReachable'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when post-update status verification read fails', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
verificationError: 'simulated status read failure',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 14,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('post-update verification failed'),
|
||||
);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('does not expose a version characteristic'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('handles deterministic ACK sequence wrap-around across 0xFF->0x00',
|
||||
test('fails when FINISH status is lost and bootloader stays connected',
|
||||
() async {
|
||||
const frameCount = 260;
|
||||
final transport = _FakeFirmwareUpdateTransport();
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
suppressFinishStatus: true,
|
||||
disconnectAfterFinish: false,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 16,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(
|
||||
frameCount * universalShifterDfuFramePayloadSizeBytes,
|
||||
(index) => index & 0xFF,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 10),
|
||||
defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
@ -248,171 +188,465 @@ void main() {
|
||||
sessionId: 16,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('post-FINISH reset'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(transport.steps, isNot(contains('reconnectForVerification')));
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when FINISH returns explicit bootloader error', () async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
finishStatusCode: DfuBootloaderStatusCode.flashError,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 20),
|
||||
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 17,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('flash error'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(transport.steps, isNot(contains('reconnectForVerification')));
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('reconnects and resumes from status after transient data failure',
|
||||
() async {
|
||||
final image = _validImage(130);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
failDataWriteAtOffsetOnce:
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 13,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
|
||||
var ffToZeroTransitions = 0;
|
||||
for (var i = 1; i < transport.ackNotifications.length; i++) {
|
||||
if (transport.ackNotifications[i - 1] == 0xFF &&
|
||||
transport.ackNotifications[i] == 0x00) {
|
||||
ffToZeroTransitions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(ffToZeroTransitions, greaterThanOrEqualTo(2));
|
||||
expect(service.currentProgress.lastAckedSequence, 0x03);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
expect(
|
||||
transport.steps.where((step) => step == 'connectToBootloader').length,
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
transport.steps
|
||||
.where((step) => step == 'optimizeBootloaderConnection')
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
transport.controlWrites
|
||||
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
transport.dataWriteOffsets
|
||||
.where(
|
||||
(offset) =>
|
||||
offset == universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
)
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('restarts START when reconnect status has no active session',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
failDataWriteAtOffsetOnce:
|
||||
universalShifterBootloaderDfuMaxPayloadSizeBytes,
|
||||
resetSessionOnRecoveryStatus: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 14,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(
|
||||
transport.controlWrites
|
||||
.where((write) => write.first == universalShifterDfuOpcodeStart)
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('ignores stale previous-session status while waiting for START',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
staleStartStatusSessionId: 20,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 21,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.dataWrites.first[0], 21);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails with bootloader status error on rejected START', () async {
|
||||
final image = _validImage(40);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
startStatusCode: DfuBootloaderStatusCode.vectorError,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 10,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('vector table error'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails early on boot metadata error before START', () async {
|
||||
final image = _validImage(40);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
initialStatusCode: DfuBootloaderStatusCode.bootMetadataError,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 18,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(),
|
||||
startsWith(universalShifterBootMetadataWarningMessage));
|
||||
expect(
|
||||
transport.controlWrites
|
||||
.where((write) => write.first == universalShifterDfuOpcodeStart),
|
||||
isEmpty);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel after START sends session-scoped ABORT', () async {
|
||||
final image = _validImage(80);
|
||||
final firstFrameSent = Completer<void>();
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
totalBytes: image.length,
|
||||
suppressFirstDataStatus: true,
|
||||
onDataWrite: () {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultStatusTimeout: const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 11,
|
||||
);
|
||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||
await service.cancelUpdate();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||
expect(
|
||||
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
this.dropFirstSequence,
|
||||
required this.totalBytes,
|
||||
this.initialStatusCode = DfuBootloaderStatusCode.ok,
|
||||
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
||||
this.alreadyInBootloader = false,
|
||||
this.failEnterBootloader = false,
|
||||
this.queueFullOnFirstData = false,
|
||||
this.suppressFirstDataStatus = false,
|
||||
this.failDataWriteAtOffsetOnce,
|
||||
this.resetSessionOnRecoveryStatus = false,
|
||||
this.staleStartStatusSessionId,
|
||||
this.suppressFinishStatus = false,
|
||||
this.disconnectAfterFinish = true,
|
||||
this.finishStatusCode = DfuBootloaderStatusCode.ok,
|
||||
this.onDataWrite,
|
||||
this.suppressDataAcks = false,
|
||||
this.resetDisconnectError,
|
||||
this.reconnectError,
|
||||
this.verificationError,
|
||||
});
|
||||
|
||||
final int? dropFirstSequence;
|
||||
final void Function(List<int> frame)? onDataWrite;
|
||||
final bool suppressDataAcks;
|
||||
final String? resetDisconnectError;
|
||||
final String? reconnectError;
|
||||
final String? verificationError;
|
||||
final int totalBytes;
|
||||
final DfuBootloaderStatusCode initialStatusCode;
|
||||
final DfuBootloaderStatusCode startStatusCode;
|
||||
final bool alreadyInBootloader;
|
||||
final bool failEnterBootloader;
|
||||
final bool queueFullOnFirstData;
|
||||
final bool suppressFirstDataStatus;
|
||||
final int? failDataWriteAtOffsetOnce;
|
||||
final bool resetSessionOnRecoveryStatus;
|
||||
final int? staleStartStatusSessionId;
|
||||
final bool suppressFinishStatus;
|
||||
final bool disconnectAfterFinish;
|
||||
final DfuBootloaderStatusCode finishStatusCode;
|
||||
final void Function()? onDataWrite;
|
||||
|
||||
final StreamController<List<int>> _ackController =
|
||||
final StreamController<List<int>> _statusController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
|
||||
final List<String> steps = <String>[];
|
||||
final List<List<int>> controlWrites = <List<int>>[];
|
||||
final List<List<int>> dataWrites = <List<int>>[];
|
||||
final List<int> ackNotifications = <int>[];
|
||||
final List<String> postFinishSteps = <String>[];
|
||||
final Set<int> _droppedOnce = <int>{};
|
||||
int _lastAck = 0xFF;
|
||||
int _expectedSequence = 0;
|
||||
final List<int> dataWriteOffsets = <int>[];
|
||||
|
||||
int _sessionId = 0;
|
||||
int _expectedOffset = 0;
|
||||
int _connectCount = 0;
|
||||
bool _sentDataFailure = false;
|
||||
bool _sentQueueFull = false;
|
||||
bool _suppressedDataStatus = false;
|
||||
bool _finishDisconnectAvailable = false;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) async {
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: 128,
|
||||
),
|
||||
);
|
||||
Future<Result<bool>> isConnectedToBootloader() async {
|
||||
steps.add('isConnectedToBootloader');
|
||||
return Ok(alreadyInBootloader);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
||||
Future<Result<void>> enterBootloader() async {
|
||||
steps.add('enterBootloader');
|
||||
if (failEnterBootloader) {
|
||||
return bail('app disconnected before write response');
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForAppDisconnect({required Duration timeout}) async {
|
||||
steps.add('waitForAppDisconnect');
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
||||
steps.add('connectToBootloader');
|
||||
_connectCount += 1;
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> optimizeBootloaderConnection() async {
|
||||
steps.add('optimizeBootloaderConnection');
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
|
||||
steps.add('negotiateMtu');
|
||||
expect(requestedMtu, universalShifterDfuPreferredMtu);
|
||||
return Ok(128);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToStatus() => _statusController.stream;
|
||||
|
||||
@override
|
||||
Future<Result<List<int>>> readStatus() async {
|
||||
steps.add('readStatus');
|
||||
return Ok(_status(initialStatusCode, 0, 0));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) async {
|
||||
controlWrites.add(List<int>.from(payload, growable: false));
|
||||
|
||||
final opcode = payload.isEmpty ? -1 : payload.first;
|
||||
final opcode = payload.first;
|
||||
if (opcode == universalShifterDfuOpcodeStart) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
_scheduleAck(0xFF);
|
||||
_sessionId = payload[17];
|
||||
_expectedOffset = 0;
|
||||
final staleSessionId = staleStartStatusSessionId;
|
||||
if (staleSessionId != null) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, staleSessionId, 0);
|
||||
}
|
||||
|
||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
_scheduleStatus(startStatusCode, _sessionId, 0);
|
||||
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
||||
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
|
||||
_sessionId = 0;
|
||||
_expectedOffset = 0;
|
||||
}
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
||||
if (suppressFinishStatus) {
|
||||
_finishDisconnectAvailable = disconnectAfterFinish;
|
||||
} else {
|
||||
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
|
||||
}
|
||||
} else if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
||||
dataWrites.add(List<int>.from(frame, growable: false));
|
||||
onDataWrite?.call(frame);
|
||||
onDataWrite?.call();
|
||||
|
||||
if (suppressDataAcks) {
|
||||
final offset = _readLeU32(frame, 1);
|
||||
dataWriteOffsets.add(offset);
|
||||
|
||||
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
|
||||
_sentDataFailure = true;
|
||||
return bail('simulated BLE write failure');
|
||||
}
|
||||
|
||||
if (queueFullOnFirstData && !_sentQueueFull) {
|
||||
_sentQueueFull = true;
|
||||
_scheduleStatus(
|
||||
DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset);
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
final sequence = frame.first;
|
||||
final shouldDrop = dropFirstSequence != null &&
|
||||
sequence == dropFirstSequence &&
|
||||
!_droppedOnce.contains(sequence);
|
||||
|
||||
if (shouldDrop) {
|
||||
_droppedOnce.add(sequence);
|
||||
_scheduleAck(_lastAck);
|
||||
if (suppressFirstDataStatus && !_suppressedDataStatus) {
|
||||
_suppressedDataStatus = true;
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
if (sequence == _expectedSequence) {
|
||||
_lastAck = sequence;
|
||||
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
||||
}
|
||||
|
||||
_scheduleAck(_lastAck);
|
||||
|
||||
final payloadLength =
|
||||
frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||
_expectedOffset = offset + payloadLength;
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
void _scheduleAck(int sequence) {
|
||||
final ack = sequence & 0xFF;
|
||||
ackNotifications.add(ack);
|
||||
@override
|
||||
Future<Result<void>> waitForBootloaderDisconnect(
|
||||
{required Duration timeout}) async {
|
||||
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
|
||||
return bail('still connected');
|
||||
}
|
||||
steps.add('waitForBootloaderDisconnect');
|
||||
_finishDisconnectAvailable = true;
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification(
|
||||
{required Duration timeout}) async {
|
||||
steps.add('reconnectForVerification');
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable(
|
||||
{required Duration timeout}) async {
|
||||
steps.add('verifyDeviceReachable');
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
void _scheduleStatus(
|
||||
DfuBootloaderStatusCode code, int sessionId, int offset) {
|
||||
final status = _status(code, sessionId, offset);
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([ack]);
|
||||
_statusController.add(status);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('waitForExpectedResetDisconnect');
|
||||
if (resetDisconnectError != null) {
|
||||
return bail(resetDisconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
|
||||
return [
|
||||
code.value,
|
||||
sessionId & 0xFF,
|
||||
offset & 0xFF,
|
||||
(offset >> 8) & 0xFF,
|
||||
(offset >> 16) & 0xFF,
|
||||
(offset >> 24) & 0xFF,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('reconnectForVerification');
|
||||
if (reconnectError != null) {
|
||||
return bail(reconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('verifyDeviceReachable');
|
||||
if (verificationError != null) {
|
||||
return bail(verificationError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
int sequenceWriteCount(int sequence) {
|
||||
var count = 0;
|
||||
for (final frame in dataWrites) {
|
||||
if (frame.first == sequence) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
int _readLeU32(List<int> bytes, int offset) {
|
||||
final data = ByteData.sublistView(Uint8List.fromList(bytes));
|
||||
return data.getUint32(offset, Endian.little);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _ackController.close();
|
||||
await _statusController.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _validImage(int length) {
|
||||
final image = Uint8List(length);
|
||||
final data = ByteData.sublistView(image);
|
||||
data.setUint32(0, 0x20001000, Endian.little);
|
||||
data.setUint32(4, 0x00030009, Endian.little);
|
||||
for (var index = 8; index < image.length; index++) {
|
||||
image[index] = index & 0xFF;
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
23
test/util/bluetooth_settings_test.dart
Normal file
23
test/util/bluetooth_settings_test.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('isBluetoothPairingRecoveryError', () {
|
||||
test('detects immediate disconnect connection failures', () {
|
||||
expect(
|
||||
isBluetoothPairingRecoveryError(
|
||||
'Failed to connect to device-id: disconnected',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not classify generic connection failures as pairing recovery',
|
||||
() {
|
||||
expect(
|
||||
isBluetoothPairingRecoveryError('Timed out connecting to device-id'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user