feat: switch firmware updates to bootloader OTA
This commit is contained in:
@ -18,8 +18,6 @@ const String universalShifterDfuControlCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40008';
|
||||
const String universalShifterDfuDataCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40009';
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String universalShifterDfuStatusCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
@ -41,15 +39,12 @@ const int universalShifterDfuOpcodeAbort = 0x03;
|
||||
const int universalShifterDfuOpcodeGetStatus = 0x04;
|
||||
|
||||
const int universalShifterDfuFrameSizeBytes = 64;
|
||||
const int universalShifterDfuFramePayloadSizeBytes = 63;
|
||||
const int universalShifterBootloaderDfuDataHeaderSizeBytes = 9;
|
||||
const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
|
||||
universalShifterDfuFrameSizeBytes -
|
||||
universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||
const int universalShifterBootloaderDfuStatusSizeBytes = 6;
|
||||
const int universalShifterAttWriteOverheadBytes = 3;
|
||||
const int universalShifterDfuMinimumMtu =
|
||||
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
||||
const int universalShifterDfuPreferredMtu = 128;
|
||||
|
||||
const int universalShifterDfuAppStart = 0x00030000;
|
||||
@ -78,9 +73,14 @@ const int trainerScanDeviceFlagConnectable = 0x08;
|
||||
enum DfuUpdateState {
|
||||
idle,
|
||||
starting,
|
||||
waitingForAck,
|
||||
enteringBootloader,
|
||||
connectingBootloader,
|
||||
waitingForStatus,
|
||||
erasing,
|
||||
transferring,
|
||||
finishing,
|
||||
rebooting,
|
||||
verifying,
|
||||
completed,
|
||||
aborted,
|
||||
failed,
|
||||
@ -119,18 +119,20 @@ class DfuUpdateProgress {
|
||||
required this.state,
|
||||
required this.totalBytes,
|
||||
required this.sentBytes,
|
||||
required this.lastAckedSequence,
|
||||
required this.expectedOffset,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
this.bootloaderStatus,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final DfuUpdateState state;
|
||||
final int totalBytes;
|
||||
final int sentBytes;
|
||||
final int lastAckedSequence;
|
||||
final int expectedOffset;
|
||||
final int sessionId;
|
||||
final DfuUpdateFlags flags;
|
||||
final DfuBootloaderStatus? bootloaderStatus;
|
||||
final String? errorMessage;
|
||||
|
||||
double get fractionComplete {
|
||||
@ -191,61 +193,6 @@ class DfuBootloaderStatus {
|
||||
bool get isOk => code == DfuBootloaderStatusCode.ok;
|
||||
}
|
||||
|
||||
enum DfuPreflightFailureReason {
|
||||
deviceNotConnected,
|
||||
wrongConnectedDevice,
|
||||
mtuRequestFailed,
|
||||
mtuTooLow,
|
||||
}
|
||||
|
||||
class DfuPreflightResult {
|
||||
const DfuPreflightResult._({
|
||||
required this.requestedMtu,
|
||||
required this.requiredMtu,
|
||||
required this.negotiatedMtu,
|
||||
required this.failureReason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final int requestedMtu;
|
||||
final int requiredMtu;
|
||||
final int? negotiatedMtu;
|
||||
final DfuPreflightFailureReason? failureReason;
|
||||
final String? message;
|
||||
|
||||
bool get canStart => failureReason == null;
|
||||
|
||||
static DfuPreflightResult ready({
|
||||
required int requestedMtu,
|
||||
required int negotiatedMtu,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: null,
|
||||
message: null,
|
||||
);
|
||||
}
|
||||
|
||||
static DfuPreflightResult failed({
|
||||
required int requestedMtu,
|
||||
required DfuPreflightFailureReason failureReason,
|
||||
required String message,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
int? negotiatedMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: failureReason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShifterErrorInfo {
|
||||
const ShifterErrorInfo({
|
||||
required this.code,
|
||||
|
||||
@ -85,7 +85,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: 0xFF,
|
||||
expectedOffset: 0,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
@ -99,9 +99,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
switch (_dfuProgress.state) {
|
||||
case DfuUpdateState.starting:
|
||||
case DfuUpdateState.waitingForAck:
|
||||
case DfuUpdateState.enteringBootloader:
|
||||
case DfuUpdateState.connectingBootloader:
|
||||
case DfuUpdateState.waitingForStatus:
|
||||
case DfuUpdateState.erasing:
|
||||
case DfuUpdateState.transferring:
|
||||
case DfuUpdateState.finishing:
|
||||
case DfuUpdateState.rebooting:
|
||||
case DfuUpdateState.verifying:
|
||||
return true;
|
||||
case DfuUpdateState.idle:
|
||||
case DfuUpdateState.completed:
|
||||
@ -627,13 +632,23 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
case DfuUpdateState.idle:
|
||||
return 'Idle';
|
||||
case DfuUpdateState.starting:
|
||||
return 'Sending START command';
|
||||
case DfuUpdateState.waitingForAck:
|
||||
return 'Waiting for ACK from button';
|
||||
return 'Preparing update';
|
||||
case DfuUpdateState.enteringBootloader:
|
||||
return 'Requesting bootloader mode';
|
||||
case DfuUpdateState.connectingBootloader:
|
||||
return 'Connecting to bootloader';
|
||||
case DfuUpdateState.waitingForStatus:
|
||||
return 'Waiting for bootloader status';
|
||||
case DfuUpdateState.erasing:
|
||||
return 'Starting destructive bootloader update';
|
||||
case DfuUpdateState.transferring:
|
||||
return 'Transferring firmware frames';
|
||||
return 'Transferring firmware image';
|
||||
case DfuUpdateState.finishing:
|
||||
return 'Finalizing update and waiting for reboot/reconnect';
|
||||
return 'Finalizing bootloader update';
|
||||
case DfuUpdateState.rebooting:
|
||||
return 'Waiting for updated app reboot';
|
||||
case DfuUpdateState.verifying:
|
||||
return 'Verifying updated app';
|
||||
case DfuUpdateState.completed:
|
||||
return 'Update completed';
|
||||
case DfuUpdateState.aborted:
|
||||
@ -653,10 +668,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
String _hexByte(int value) {
|
||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> _manualReconnect() async {
|
||||
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
||||
return;
|
||||
@ -1010,7 +1021,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
ackSequenceHex:
|
||||
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
),
|
||||
] else if (isCurrentConnected) ...[
|
||||
_PairingRequiredCard(
|
||||
|
||||
@ -2,8 +2,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
|
||||
const int _startPayloadLength = 11;
|
||||
const int _bootloaderStartPayloadLength = 19;
|
||||
const int _startPayloadLength = 19;
|
||||
|
||||
class BootloaderDfuStartPayload {
|
||||
const BootloaderDfuStartPayload({
|
||||
@ -37,143 +36,14 @@ class BootloaderDfuDataFrame {
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
class DfuStartPayload {
|
||||
const DfuStartPayload({
|
||||
required this.totalLength,
|
||||
required this.imageCrc32,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int imageCrc32;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuDataFrame {
|
||||
const DfuDataFrame({
|
||||
required this.sequence,
|
||||
required this.offset,
|
||||
required this.payloadLength,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final int sequence;
|
||||
final int offset;
|
||||
final int payloadLength;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
class DfuProtocol {
|
||||
const DfuProtocol._();
|
||||
|
||||
static Uint8List encodeStartPayload(DfuStartPayload payload) {
|
||||
final data = ByteData(_startPayloadLength);
|
||||
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||
data.setUint32(1, payload.totalLength, Endian.little);
|
||||
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||
data.setUint8(9, payload.sessionId);
|
||||
data.setUint8(10, payload.flags);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List encodeFinishPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
|
||||
}
|
||||
|
||||
static Uint8List encodeAbortPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
|
||||
}
|
||||
|
||||
static List<DfuDataFrame> buildDataFrames(
|
||||
List<int> imageBytes, {
|
||||
int startSequence = 0,
|
||||
}) {
|
||||
final frames = <DfuDataFrame>[];
|
||||
var seq = _asU8(startSequence);
|
||||
var offset = 0;
|
||||
while (offset < imageBytes.length) {
|
||||
final remaining = imageBytes.length - offset;
|
||||
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
||||
? remaining
|
||||
: universalShifterDfuFramePayloadSizeBytes;
|
||||
|
||||
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
||||
frame[0] = seq;
|
||||
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
||||
|
||||
frames.add(
|
||||
DfuDataFrame(
|
||||
sequence: seq,
|
||||
offset: offset,
|
||||
payloadLength: chunkLength,
|
||||
bytes: frame,
|
||||
),
|
||||
);
|
||||
|
||||
offset += chunkLength;
|
||||
seq = nextSequence(seq);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
static int nextSequence(int sequence) {
|
||||
return _asU8(sequence + 1);
|
||||
}
|
||||
|
||||
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
||||
return nextSequence(acknowledgedSequence);
|
||||
}
|
||||
|
||||
static int sequenceDistance(int from, int to) {
|
||||
return _asU8(to - from);
|
||||
}
|
||||
|
||||
static int parseAckPayload(List<int> payload) {
|
||||
if (payload.length != 1) {
|
||||
throw const FormatException('ACK payload must be exactly 1 byte.');
|
||||
}
|
||||
return _asU8(payload.first);
|
||||
}
|
||||
class BootloaderDfuProtocol {
|
||||
const BootloaderDfuProtocol._();
|
||||
|
||||
static const int crc32Initial = 0xFFFFFFFF;
|
||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||
|
||||
static int crc32Update(int crc, List<int> bytes) {
|
||||
var next = crc & 0xFFFFFFFF;
|
||||
for (final byte in bytes) {
|
||||
next ^= byte;
|
||||
for (var bit = 0; bit < 8; bit++) {
|
||||
if ((next & 0x1) != 0) {
|
||||
next = (next >> 1) ^ _crc32PolynomialReflected;
|
||||
} else {
|
||||
next >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32Finalize(int crc) {
|
||||
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32(List<int> bytes) {
|
||||
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||
}
|
||||
|
||||
static int _asU8(int value) {
|
||||
return value & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
class BootloaderDfuProtocol {
|
||||
const BootloaderDfuProtocol._();
|
||||
|
||||
static Uint8List encodeStartPayload(BootloaderDfuStartPayload payload) {
|
||||
final data = ByteData(_bootloaderStartPayloadLength);
|
||||
final data = ByteData(_startPayloadLength);
|
||||
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||
data.setUint32(1, payload.totalLength, Endian.little);
|
||||
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||
@ -296,17 +166,26 @@ class BootloaderDfuProtocol {
|
||||
);
|
||||
}
|
||||
|
||||
static const int crc32Initial = DfuProtocol.crc32Initial;
|
||||
|
||||
static int crc32Update(int crc, List<int> bytes) {
|
||||
return DfuProtocol.crc32Update(crc, bytes);
|
||||
var next = crc & 0xFFFFFFFF;
|
||||
for (final byte in bytes) {
|
||||
next ^= byte;
|
||||
for (var bit = 0; bit < 8; bit++) {
|
||||
if ((next & 0x1) != 0) {
|
||||
next = (next >> 1) ^ _crc32PolynomialReflected;
|
||||
} else {
|
||||
next >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32Finalize(int crc) {
|
||||
return DfuProtocol.crc32Finalize(crc);
|
||||
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32(List<int> bytes) {
|
||||
return DfuProtocol.crc32(bytes);
|
||||
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,29 +10,15 @@ final _log = Logger('ShifterService');
|
||||
|
||||
class ShifterService {
|
||||
ShifterService({
|
||||
BluetoothController? bluetooth,
|
||||
required BluetoothController bluetooth,
|
||||
required this.buttonDeviceId,
|
||||
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
|
||||
}) : _bluetooth = bluetooth,
|
||||
_dfuPreflightBluetooth =
|
||||
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
|
||||
if (bluetooth == null && dfuPreflightBluetooth == null) {
|
||||
throw ArgumentError(
|
||||
'Either bluetooth or dfuPreflightBluetooth must be provided.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}) : _bluetooth = bluetooth;
|
||||
|
||||
final BluetoothController? _bluetooth;
|
||||
final BluetoothController _bluetooth;
|
||||
final String buttonDeviceId;
|
||||
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
|
||||
|
||||
BluetoothController get _requireBluetooth {
|
||||
final bluetooth = _bluetooth;
|
||||
if (bluetooth == null) {
|
||||
throw StateError('Bluetooth controller is not available.');
|
||||
}
|
||||
return bluetooth;
|
||||
return _bluetooth;
|
||||
}
|
||||
|
||||
final StreamController<CentralStatus> _statusController =
|
||||
@ -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() {
|
||||
if (_statusSubscription != null) {
|
||||
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 {
|
||||
const GearRatiosData({
|
||||
required this.ratios,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
@ -6,413 +7,304 @@ import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareUpdateService', () {
|
||||
test('completes happy path with START, data frames, and FINISH', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport();
|
||||
group('FirmwareUpdateService bootloader flow', () {
|
||||
test('completes happy path with START, offset data, FINISH, and verify',
|
||||
() async {
|
||||
final image = _validImage(130);
|
||||
final transport = _FakeFirmwareUpdateTransport(totalBytes: image.length);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 7,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.controlWrites.length, 2);
|
||||
expect(transport.steps, [
|
||||
'enterBootloader',
|
||||
'waitForAppDisconnect',
|
||||
'connectToBootloader',
|
||||
'negotiateMtu',
|
||||
'readStatus',
|
||||
'waitForBootloaderDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
]);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 7]);
|
||||
expect(transport.dataWrites, isNotEmpty);
|
||||
expect(transport.dataWrites.first[0], 7);
|
||||
expect(transport.dataWrites.first.sublist(1, 5), [0, 0, 0, 0]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
expect(service.currentProgress.expectedOffset, image.length);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
||||
test('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,
|
||||
defaultWindowSize: 3,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
maxNoProgressRetries: 4,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(190, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 9,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.dataWrites.length, greaterThan(4));
|
||||
expect(transport.sequenceWriteCount(1), greaterThan(1));
|
||||
expect(
|
||||
transport.controlWrites
|
||||
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
transport.dataWriteOffsets.where((offset) => offset == 0).length, 2);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails after bounded retries when ACK progress times out', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
|
||||
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,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 40),
|
||||
maxNoProgressRetries: 2,
|
||||
defaultStatusTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(90, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 10,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('Upload stalled'));
|
||||
expect(result.unwrapErr().toString(), contains('after 3 retries'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(transport.sequenceWriteCount(0), 3);
|
||||
expect(result.unwrapErr().toString(), contains('vector table error'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.controlWrites.last.first, universalShifterDfuOpcodeStart);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel 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 transport = _FakeFirmwareUpdateTransport(
|
||||
onDataWrite: (frame) {
|
||||
totalBytes: image.length,
|
||||
suppressFirstDataStatus: true,
|
||||
onDataWrite: () {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
suppressDataAcks: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
||||
defaultStatusTimeout: const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
||||
imageBytes: image,
|
||||
sessionId: 11,
|
||||
);
|
||||
|
||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||
await service.cancelUpdate();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(
|
||||
transport.controlWrites.last, [universalShifterDfuOpcodeAbort, 11]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.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 {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
this.dropFirstSequence,
|
||||
required this.totalBytes,
|
||||
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
||||
this.queueFullOnFirstData = false,
|
||||
this.suppressFirstDataStatus = false,
|
||||
this.onDataWrite,
|
||||
this.suppressDataAcks = false,
|
||||
this.resetDisconnectError,
|
||||
this.reconnectError,
|
||||
this.verificationError,
|
||||
});
|
||||
|
||||
final int? dropFirstSequence;
|
||||
final void Function(List<int> frame)? onDataWrite;
|
||||
final bool suppressDataAcks;
|
||||
final String? resetDisconnectError;
|
||||
final String? reconnectError;
|
||||
final String? verificationError;
|
||||
final int totalBytes;
|
||||
final DfuBootloaderStatusCode startStatusCode;
|
||||
final bool queueFullOnFirstData;
|
||||
final bool suppressFirstDataStatus;
|
||||
final void Function()? onDataWrite;
|
||||
|
||||
final StreamController<List<int>> _ackController =
|
||||
final StreamController<List<int>> _statusController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
|
||||
final List<String> steps = <String>[];
|
||||
final List<List<int>> controlWrites = <List<int>>[];
|
||||
final List<List<int>> dataWrites = <List<int>>[];
|
||||
final List<int> ackNotifications = <int>[];
|
||||
final List<String> postFinishSteps = <String>[];
|
||||
final Set<int> _droppedOnce = <int>{};
|
||||
int _lastAck = 0xFF;
|
||||
int _expectedSequence = 0;
|
||||
final List<int> dataWriteOffsets = <int>[];
|
||||
|
||||
int _sessionId = 0;
|
||||
int _expectedOffset = 0;
|
||||
bool _sentQueueFull = false;
|
||||
bool _suppressedDataStatus = false;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) async {
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: 128,
|
||||
),
|
||||
);
|
||||
Future<Result<void>> enterBootloader() async {
|
||||
steps.add('enterBootloader');
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
||||
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');
|
||||
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
|
||||
Future<Result<void>> writeControl(List<int> payload) async {
|
||||
controlWrites.add(List<int>.from(payload, growable: false));
|
||||
|
||||
final opcode = payload.isEmpty ? -1 : payload.first;
|
||||
final opcode = payload.first;
|
||||
if (opcode == universalShifterDfuOpcodeStart) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
_scheduleAck(0xFF);
|
||||
_sessionId = payload[17];
|
||||
_expectedOffset = 0;
|
||||
_scheduleStatus(startStatusCode, _sessionId, 0);
|
||||
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
|
||||
} else if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
|
||||
}
|
||||
|
||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
||||
dataWrites.add(List<int>.from(frame, growable: false));
|
||||
onDataWrite?.call(frame);
|
||||
onDataWrite?.call();
|
||||
|
||||
if (suppressDataAcks) {
|
||||
final offset = _readLeU32(frame, 1);
|
||||
dataWriteOffsets.add(offset);
|
||||
|
||||
if (queueFullOnFirstData && !_sentQueueFull) {
|
||||
_sentQueueFull = true;
|
||||
_scheduleStatus(
|
||||
DfuBootloaderStatusCode.queueFull, _sessionId, _expectedOffset);
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
final sequence = frame.first;
|
||||
final shouldDrop = dropFirstSequence != null &&
|
||||
sequence == dropFirstSequence &&
|
||||
!_droppedOnce.contains(sequence);
|
||||
|
||||
if (shouldDrop) {
|
||||
_droppedOnce.add(sequence);
|
||||
_scheduleAck(_lastAck);
|
||||
if (suppressFirstDataStatus && !_suppressedDataStatus) {
|
||||
_suppressedDataStatus = true;
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
if (sequence == _expectedSequence) {
|
||||
_lastAck = sequence;
|
||||
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
||||
}
|
||||
|
||||
_scheduleAck(_lastAck);
|
||||
|
||||
final payloadLength =
|
||||
frame.length - universalShifterBootloaderDfuDataHeaderSizeBytes;
|
||||
_expectedOffset = offset + payloadLength;
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
void _scheduleAck(int sequence) {
|
||||
final ack = sequence & 0xFF;
|
||||
ackNotifications.add(ack);
|
||||
@override
|
||||
Future<Result<void>> waitForBootloaderDisconnect(
|
||||
{required Duration timeout}) async {
|
||||
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(() {
|
||||
_ackController.add([ack]);
|
||||
_statusController.add(status);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('waitForExpectedResetDisconnect');
|
||||
if (resetDisconnectError != null) {
|
||||
return bail(resetDisconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
List<int> _status(DfuBootloaderStatusCode code, int sessionId, int offset) {
|
||||
return [
|
||||
code.value,
|
||||
sessionId & 0xFF,
|
||||
offset & 0xFF,
|
||||
(offset >> 8) & 0xFF,
|
||||
(offset >> 16) & 0xFF,
|
||||
(offset >> 24) & 0xFF,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('reconnectForVerification');
|
||||
if (reconnectError != null) {
|
||||
return bail(reconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('verifyDeviceReachable');
|
||||
if (verificationError != null) {
|
||||
return bail(verificationError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
int sequenceWriteCount(int sequence) {
|
||||
var count = 0;
|
||||
for (final frame in dataWrites) {
|
||||
if (frame.first == sequence) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
int _readLeU32(List<int> bytes, int offset) {
|
||||
final data = ByteData.sublistView(Uint8List.fromList(bytes));
|
||||
return data.getUint32(offset, Endian.little);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _ackController.close();
|
||||
await _statusController.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _validImage(int length) {
|
||||
final image = Uint8List(length);
|
||||
final data = ByteData.sublistView(image);
|
||||
data.setUint32(0, 0x20001000, Endian.little);
|
||||
data.setUint32(4, 0x00030009, Endian.little);
|
||||
for (var index = 8; index < image.length; index++) {
|
||||
image[index] = index & 0xFF;
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user