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