feat(dfu): add firmware transfer engine with ack retries

This commit is contained in:
2026-03-03 17:00:37 +01:00
parent dd2afa34ef
commit 8b24084f97
2 changed files with 751 additions and 0 deletions

View File

@ -0,0 +1,551 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:anyhow/anyhow.dart';
const int _initialAckSequence = 0xFF;
class FirmwareUpdateService {
FirmwareUpdateService({
required FirmwareUpdateTransport transport,
this.defaultWindowSize = 8,
this.maxNoProgressRetries = 5,
this.defaultAckTimeout = const Duration(milliseconds: 800),
}) : _transport = transport;
final FirmwareUpdateTransport _transport;
final int defaultWindowSize;
final int maxNoProgressRetries;
final Duration defaultAckTimeout;
final StreamController<DfuUpdateProgress> _progressController =
StreamController<DfuUpdateProgress>.broadcast();
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
lastAckedSequence: _initialAckSequence,
sessionId: 0,
flags: DfuUpdateFlags(),
);
StreamSubscription<List<int>>? _ackSubscription;
Completer<void>? _ackSignal;
Completer<void>? _cancelSignal;
int _ackEventCount = 0;
String? _ackStreamError;
bool _isRunning = false;
bool _cancelRequested = false;
int _latestAckSequence = _initialAckSequence;
int _ackedFrames = 0;
int _totalFrames = 0;
int _totalBytes = 0;
Stream<DfuUpdateProgress> get progressStream => _progressController.stream;
DfuUpdateProgress get currentProgress => _currentProgress;
bool get isUpdating => _isRunning;
Future<Result<void>> startUpdate({
required List<int> imageBytes,
required int sessionId,
DfuUpdateFlags flags = const DfuUpdateFlags(),
int requestedMtu = universalShifterDfuPreferredMtu,
int? windowSize,
Duration? ackTimeout,
int? noProgressRetries,
}) async {
if (_isRunning) {
return bail(
'Firmware update is already running. Cancel or wait for completion before starting a new upload.');
}
if (imageBytes.isEmpty) {
return bail(
'Firmware image is empty. Select a valid .bin file and retry.');
}
final effectiveWindowSize = windowSize ?? defaultWindowSize;
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
final effectiveNoProgressRetries =
noProgressRetries ?? maxNoProgressRetries;
if (effectiveWindowSize <= 0) {
return bail(
'DFU window size must be at least 1 frame. Got $effectiveWindowSize.');
}
if (effectiveNoProgressRetries < 0) {
return bail(
'No-progress retry limit cannot be negative. Got $effectiveNoProgressRetries.');
}
_isRunning = true;
_cancelRequested = false;
_cancelSignal = Completer<void>();
_ackSignal = null;
_ackEventCount = 0;
_ackStreamError = null;
_latestAckSequence = _initialAckSequence;
_ackedFrames = 0;
_totalFrames =
(imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/
universalShifterDfuFramePayloadSizeBytes;
_totalBytes = imageBytes.length;
final normalizedSessionId = sessionId & 0xFF;
final crc32 = DfuProtocol.crc32(imageBytes);
final frames = DfuProtocol.buildDataFrames(imageBytes);
var shouldAbortForCleanup = false;
_emitProgress(
state: DfuUpdateState.starting,
totalBytes: imageBytes.length,
sentBytes: 0,
lastAckedSequence: _initialAckSequence,
sessionId: normalizedSessionId,
flags: flags,
);
try {
final preflightResult = await _transport.runPreflight(
requestedMtu: requestedMtu,
);
if (preflightResult.isErr()) {
throw _DfuFailure(
'DFU preflight check failed due to transport error: ${preflightResult.unwrapErr()}',
);
}
final preflight = preflightResult.unwrap();
if (!preflight.canStart) {
throw _DfuFailure(
preflight.message ??
'DFU preflight failed. Ensure button connection and MTU are ready, then retry.',
);
}
await _ackSubscription?.cancel();
_ackSubscription = _transport.subscribeToAck().listen(
_handleAckPayload,
onError: (Object error) {
_ackStreamError =
'ACK indication stream failed: $error. Reconnect and retry the update.';
_signalAckWaiters();
},
);
_emitProgress(state: DfuUpdateState.waitingForAck);
final startEventCount = _ackEventCount;
final startWriteResult = await _transport.writeControl(
DfuProtocol.encodeStartPayload(
DfuStartPayload(
totalLength: imageBytes.length,
imageCrc32: crc32,
sessionId: normalizedSessionId,
flags: flags.rawValue,
),
),
);
if (startWriteResult.isErr()) {
throw _DfuFailure(
'Failed to send DFU START command: ${startWriteResult.unwrapErr()}',
);
}
shouldAbortForCleanup = true;
final initialAck = await _waitForInitialAck(
afterEventCount: startEventCount,
timeout: effectiveAckTimeout,
);
if (initialAck != _initialAckSequence) {
throw _DfuFailure(
'Device did not acknowledge START correctly (expected ACK 0xFF, got 0x${initialAck.toRadixString(16).padLeft(2, '0').toUpperCase()}). Send ABORT, reconnect if needed, and retry.',
);
}
_emitProgress(state: DfuUpdateState.transferring);
var nextFrameIndex = 0;
var retriesWithoutProgress = 0;
while (_ackedFrames < _totalFrames) {
_throwIfCancelled();
_throwIfAckStreamErrored();
final ackedBeforeWindow = _ackedFrames;
final endExclusive =
(nextFrameIndex + effectiveWindowSize).clamp(0, frames.length);
for (var frameIndex = nextFrameIndex;
frameIndex < endExclusive;
frameIndex++) {
_throwIfCancelled();
final writeResult =
await _transport.writeDataFrame(frames[frameIndex].bytes);
if (writeResult.isErr()) {
throw _DfuFailure(
'Failed sending DFU data frame #$frameIndex (seq 0x${frames[frameIndex].sequence.toRadixString(16).padLeft(2, '0').toUpperCase()}): ${writeResult.unwrapErr()}',
);
}
}
nextFrameIndex = endExclusive;
if (_ackedFrames > ackedBeforeWindow) {
retriesWithoutProgress = 0;
nextFrameIndex = _ackedFrames;
continue;
}
final gotProgress = await _waitForAckProgress(
ackedFramesBeforeWait: ackedBeforeWindow,
timeout: effectiveAckTimeout,
);
if (gotProgress) {
retriesWithoutProgress = 0;
nextFrameIndex = _ackedFrames;
continue;
}
retriesWithoutProgress += 1;
if (retriesWithoutProgress > effectiveNoProgressRetries) {
throw _DfuFailure(
'Upload stalled: no ACK progress after $retriesWithoutProgress retries (last ACK 0x${_latestAckSequence.toRadixString(16).padLeft(2, '0').toUpperCase()}). Check BLE signal quality and retry.',
);
}
nextFrameIndex = _ackedFrames;
}
_emitProgress(
state: DfuUpdateState.finishing, sentBytes: imageBytes.length);
final finishResult =
await _transport.writeControl(DfuProtocol.encodeFinishPayload());
if (finishResult.isErr()) {
throw _DfuFailure(
'Failed to send DFU FINISH command: ${finishResult.unwrapErr()}',
);
}
shouldAbortForCleanup = false;
_emitProgress(
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
return Ok(null);
} on _DfuCancelled {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
_emitProgress(state: DfuUpdateState.aborted);
return bail('Firmware update canceled by user.');
} on _DfuFailure catch (failure) {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
_emitProgress(
state: DfuUpdateState.failed, errorMessage: failure.message);
return bail(failure.message);
} catch (error) {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
final message =
'Firmware update failed unexpectedly: $error. Reconnect to the button and retry.';
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
return bail(message);
} finally {
await _ackSubscription?.cancel();
_ackSubscription = null;
_isRunning = false;
_cancelRequested = false;
_cancelSignal = null;
_ackSignal = null;
_ackEventCount = 0;
_ackStreamError = null;
_latestAckSequence = _currentProgress.lastAckedSequence;
_ackedFrames = 0;
_totalFrames = 0;
_totalBytes = 0;
}
}
Future<void> cancelUpdate() async {
if (!_isRunning || _cancelRequested) {
return;
}
_cancelRequested = true;
_cancelSignal?.complete();
_signalAckWaiters();
}
Future<void> dispose() async {
await cancelUpdate();
await _ackSubscription?.cancel();
_ackSubscription = null;
await _progressController.close();
}
void _handleAckPayload(List<int> payload) {
try {
final sequence = DfuProtocol.parseAckPayload(payload);
final previousAck = _latestAckSequence;
_latestAckSequence = sequence;
if (_totalFrames > 0 &&
_currentProgress.state == DfuUpdateState.transferring) {
final delta = DfuProtocol.sequenceDistance(previousAck, sequence);
if (delta > 0) {
_ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames);
}
_emitProgress(
lastAckedSequence: sequence,
sentBytes:
_ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes),
);
} else {
_emitProgress(lastAckedSequence: sequence);
}
} on FormatException catch (error) {
_ackStreamError =
'Received malformed ACK indication: $error. Reconnect and retry.';
} finally {
_ackEventCount += 1;
_signalAckWaiters();
}
}
void _emitProgress({
DfuUpdateState? state,
int? totalBytes,
int? sentBytes,
int? lastAckedSequence,
int? sessionId,
DfuUpdateFlags? flags,
String? errorMessage,
}) {
final next = DfuUpdateProgress(
state: state ?? _currentProgress.state,
totalBytes: totalBytes ?? _currentProgress.totalBytes,
sentBytes: sentBytes ?? _currentProgress.sentBytes,
lastAckedSequence:
lastAckedSequence ?? _currentProgress.lastAckedSequence,
sessionId: sessionId ?? _currentProgress.sessionId,
flags: flags ?? _currentProgress.flags,
errorMessage: errorMessage,
);
_currentProgress = next;
_progressController.add(next);
}
Future<int> _waitForInitialAck({
required int afterEventCount,
required Duration timeout,
}) async {
final deadline = DateTime.now().add(timeout);
var observedEvents = afterEventCount;
while (true) {
_throwIfCancelled();
_throwIfAckStreamErrored();
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
throw _DfuFailure(
'Timed out waiting for initial DFU ACK after START. Ensure indications are enabled and retry.',
);
}
final gotEvent = await _waitForNextAckEvent(
afterEventCount: observedEvents,
timeout: remaining,
);
if (!gotEvent) {
continue;
}
observedEvents = _ackEventCount;
return _latestAckSequence;
}
}
Future<bool> _waitForAckProgress({
required int ackedFramesBeforeWait,
required Duration timeout,
}) async {
final deadline = DateTime.now().add(timeout);
var observedEvents = _ackEventCount;
while (true) {
_throwIfCancelled();
_throwIfAckStreamErrored();
if (_ackedFrames > ackedFramesBeforeWait) {
return true;
}
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
return false;
}
final gotEvent = await _waitForNextAckEvent(
afterEventCount: observedEvents,
timeout: remaining,
);
if (!gotEvent) {
continue;
}
observedEvents = _ackEventCount;
}
}
Future<bool> _waitForNextAckEvent({
required int afterEventCount,
required Duration timeout,
}) async {
if (_ackEventCount > afterEventCount) {
return true;
}
_ackSignal ??= Completer<void>();
final signal = _ackSignal!;
try {
await Future.any<void>([
signal.future,
_cancelSignal?.future ?? Future<void>.value(),
]).timeout(timeout);
} on TimeoutException {
return false;
}
if (identical(_ackSignal, signal)) {
_ackSignal = null;
}
_throwIfCancelled();
_throwIfAckStreamErrored();
return _ackEventCount > afterEventCount;
}
void _throwIfCancelled() {
if (_cancelRequested) {
throw const _DfuCancelled();
}
}
void _throwIfAckStreamErrored() {
final error = _ackStreamError;
if (error != null) {
throw _DfuFailure(error);
}
}
Future<void> _sendAbortForCleanup() async {
final result =
await _transport.writeControl(DfuProtocol.encodeAbortPayload());
if (result.isErr()) {
final cleanupMessage =
'Could not send DFU ABORT during cleanup: ${result.unwrapErr()}';
if (_currentProgress.state == DfuUpdateState.failed &&
_currentProgress.errorMessage != null) {
_emitProgress(
errorMessage: '${_currentProgress.errorMessage} $cleanupMessage',
);
}
}
}
void _signalAckWaiters() {
final signal = _ackSignal;
if (signal != null && !signal.isCompleted) {
signal.complete();
}
}
int _ackedBytesFromFrames(int ackedFrames, int totalFrames, int totalBytes) {
if (totalFrames == 0 || ackedFrames <= 0) {
return 0;
}
if (ackedFrames >= totalFrames) {
return totalBytes;
}
return ackedFrames * universalShifterDfuFramePayloadSizeBytes;
}
}
abstract interface class FirmwareUpdateTransport {
Future<Result<DfuPreflightResult>> runPreflight({required int requestedMtu});
Stream<List<int>> subscribeToAck();
Future<Result<void>> writeControl(List<int> payload);
Future<Result<void>> writeDataFrame(List<int> frame);
}
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
ShifterFirmwareUpdateTransport({
required this.shifterService,
required this.bluetoothController,
required this.buttonDeviceId,
});
final ShifterService shifterService;
final BluetoothController bluetoothController;
final String buttonDeviceId;
@override
Future<Result<DfuPreflightResult>> runPreflight({
required int requestedMtu,
}) {
return shifterService.runDfuPreflight(requestedMtu: requestedMtu);
}
@override
Stream<List<int>> subscribeToAck() {
return bluetoothController.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuAckCharacteristicUuid,
);
}
@override
Future<Result<void>> writeControl(List<int> payload) {
return bluetoothController.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuControlCharacteristicUuid,
payload,
);
}
@override
Future<Result<void>> writeDataFrame(List<int> frame) {
return bluetoothController.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuDataCharacteristicUuid,
frame,
withResponse: false,
);
}
}
class _DfuFailure implements Exception {
const _DfuFailure(this.message);
final String message;
@override
String toString() => message;
}
class _DfuCancelled implements Exception {
const _DfuCancelled();
}