Compare commits

8 Commits

16 changed files with 2118 additions and 1284 deletions

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -57,6 +57,9 @@ class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.hidden || if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) { state == AppLifecycleState.paused) {
if (ref.read(backgroundBluetoothDisconnectSuppressedProvider)) {
return;
}
unawaited(_disconnectBluetoothForBackground()); unawaited(_disconnectBluetoothForBackground());
} }
} }

View File

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

View File

@ -18,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';
@ -36,14 +36,23 @@ 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 =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuAppStart = 0x00030000;
const int universalShifterDfuAppSlotSizeBytes = 0x0003F000;
const int universalShifterDfuMinimumImageLengthBytes = 8;
const int universalShifterDfuRamStart = 0x20000000;
const int universalShifterDfuRamEnd = 0x20010000;
const int universalShifterDfuFlagEncrypted = 0x01; const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagSigned = 0x02;
const int universalShifterDfuFlagNone = 0x00; const int universalShifterDfuFlagNone = 0x00;
@ -64,9 +73,14 @@ 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,
@ -105,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 {
@ -134,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 {
@ -253,7 +257,8 @@ 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;

View File

@ -80,12 +80,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(),
); );
@ -99,9 +99,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:
@ -492,10 +497,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
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;
} }
@ -508,7 +509,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,
), ),
@ -526,7 +527,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.';
@ -548,7 +549,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;
} }
@ -558,7 +571,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;
} }
@ -579,7 +592,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; return;
} }
await _startStatusStreamingIfNeeded();
final updater = await _ensureFirmwareUpdateService(); final updater = await _ensureFirmwareUpdateService();
if (!mounted) { if (!mounted) {
return; return;
@ -595,12 +607,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),
); );
@ -626,13 +640,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:
@ -652,10 +676,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;
@ -726,7 +746,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
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;
@ -739,6 +759,21 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
context.go('/devices'); context.go('/devices');
} }
void _dismissFirmwareFullscreen() {
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,
@ -858,12 +893,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) {
@ -893,8 +946,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,
@ -910,22 +981,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(
@ -993,29 +1058,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 ...[
@ -1056,12 +1103,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;
@ -1070,7 +1117,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;
@ -1082,9 +1129,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);
@ -1109,7 +1180,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),
), ),
@ -1168,6 +1239,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,
),
], ],
], ],
), ),
@ -1183,14 +1259,21 @@ class _FirmwareUpdateCard extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'${progress.percentComplete}% • $formattedProgressBytesLast ACK $ackSequenceHex', '${progress.percentComplete}% • $formattedProgressBytesExpected offset $expectedOffsetHex',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 4),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall,
),
],
], ],
if (_showRebootExpectation) ...[ if (_showRebootExpectation) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.', 'Expected behavior: after FINISH, the bootloader verifies the image, resets, and the updated app confirms itself before reconnecting.',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -1873,3 +1956,244 @@ class _OverviewMetricTile extends StatelessWidget {
); );
} }
} }
class _FirmwareUpdateFullscreen extends StatelessWidget {
const _FirmwareUpdateFullscreen({
required this.progress,
required this.selectedFirmware,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.expectedOffsetHex,
required this.onDismiss,
});
final DfuUpdateProgress progress;
final BootloaderDfuPreparedFirmware? selectedFirmware;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String expectedOffsetHex;
final VoidCallback onDismiss;
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 ? 'Back to device' : 'Done',
),
),
),
],
],
),
),
),
],
),
),
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -10,29 +10,15 @@ final _log = Logger('ShifterService');
class ShifterService { class ShifterService {
ShifterService({ ShifterService({
BluetoothController? bluetooth, required BluetoothController bluetooth,
required this.buttonDeviceId, required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth, }) : _bluetooth = bluetooth;
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
final BluetoothController? _bluetooth; final BluetoothController _bluetooth;
final String buttonDeviceId; final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth { BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth; return _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
} }
final StreamController<CentralStatus> _statusController = final StreamController<CentralStatus> _statusController =
@ -243,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;
@ -369,32 +289,6 @@ class ShifterService {
} }
} }
abstract interface class DfuPreflightBluetoothAdapter {
(ConnectionStatus, String?) get currentConnectionState;
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
});
}
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
const _BluetoothDfuPreflightAdapter(this._bluetooth);
final BluetoothController _bluetooth;
@override
(ConnectionStatus, String?) get currentConnectionState =>
_bluetooth.currentConnectionState;
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) {
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
}
}
class GearRatiosData { class GearRatiosData {
const GearRatiosData({ const GearRatiosData({
required this.ratios, required this.ratios,

View File

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

View File

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

View File

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

View File

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