feat: recover bootloader OTA transfers

This commit is contained in:
2026-04-29 19:59:11 +02:00
parent 16365e1d04
commit dc1f53b6e1
3 changed files with 445 additions and 111 deletions

View File

@ -12,11 +12,13 @@ class FirmwareUpdateService {
FirmwareUpdateService({
required FirmwareUpdateTransport transport,
this.defaultStatusTimeout = const Duration(seconds: 2),
this.defaultBootloaderConnectTimeout = const Duration(seconds: 20),
this.defaultBootloaderConnectTimeout = const Duration(seconds: 60),
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
this.defaultReconnectTimeout = const Duration(seconds: 12),
this.defaultVerificationTimeout = const Duration(seconds: 5),
this.queueFullBackoff = const Duration(milliseconds: 200),
this.maxNoProgressRetries = 5,
this.maxReconnectResumeAttempts = 3,
}) : _transport = transport;
final FirmwareUpdateTransport _transport;
@ -25,7 +27,9 @@ class FirmwareUpdateService {
final Duration defaultPostFinishResetTimeout;
final Duration defaultReconnectTimeout;
final Duration defaultVerificationTimeout;
final Duration queueFullBackoff;
final int maxNoProgressRetries;
final int maxReconnectResumeAttempts;
final StreamController<DfuUpdateProgress> _progressController =
StreamController<DfuUpdateProgress>.broadcast();
@ -87,8 +91,17 @@ class FirmwareUpdateService {
reconnectTimeout ?? defaultReconnectTimeout;
final effectiveVerificationTimeout =
verificationTimeout ?? defaultVerificationTimeout;
final normalizedSessionId = sessionId & 0xFF;
final rawSessionId = sessionId & 0xFF;
final normalizedSessionId = rawSessionId == 0 ? 1 : rawSessionId;
final imageCrc32 = BootloaderDfuProtocol.crc32(imageBytes);
final startPayload = BootloaderDfuStartPayload(
totalLength: imageBytes.length,
imageCrc32: imageCrc32,
appStart: appStart,
imageVersion: imageVersion,
sessionId: normalizedSessionId,
flags: flags.rawValue,
);
_isRunning = true;
_cancelRequested = false;
@ -114,30 +127,25 @@ class FirmwareUpdateService {
try {
_throwIfCancelled();
_emitProgress(state: DfuUpdateState.enteringBootloader);
final enterResult = await _transport.enterBootloader();
if (enterResult.isErr()) {
throw _DfuFailure(
'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}',
final alreadyInBootloader = await _isConnectedToBootloader();
if (!alreadyInBootloader) {
final enterResult = await _transport.enterBootloader();
final appDisconnectResult = await _transport.waitForAppDisconnect(
timeout: effectiveBootloaderConnectTimeout,
);
}
if (appDisconnectResult.isErr()) {
if (enterResult.isErr()) {
throw _DfuFailure(
'Failed to request bootloader DFU mode: ${enterResult.unwrapErr()}',
);
}
throw _DfuFailure(
'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}',
);
}
final appDisconnectResult = await _transport.waitForAppDisconnect(
timeout: effectiveBootloaderConnectTimeout,
);
if (appDisconnectResult.isErr()) {
throw _DfuFailure(
'Device did not disconnect into bootloader mode: ${appDisconnectResult.unwrapErr()}',
);
}
_emitProgress(state: DfuUpdateState.connectingBootloader);
final bootloaderConnectResult = await _transport.connectToBootloader(
timeout: effectiveBootloaderConnectTimeout,
);
if (bootloaderConnectResult.isErr()) {
throw _DfuFailure(
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}',
);
_emitProgress(state: DfuUpdateState.connectingBootloader);
await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout);
}
final mtuResult =
@ -161,17 +169,8 @@ class FirmwareUpdateService {
await _readInitialStatus();
_emitProgress(state: DfuUpdateState.erasing);
final startStatus = await _writeControlAndWaitForStatus(
BootloaderDfuProtocol.encodeStartPayload(
BootloaderDfuStartPayload(
totalLength: imageBytes.length,
imageCrc32: imageCrc32,
appStart: appStart,
imageVersion: imageVersion,
sessionId: normalizedSessionId,
flags: flags.rawValue,
),
),
final startStatus = await _sendStartAndWaitForStatus(
startPayload,
timeout: effectiveStatusTimeout,
);
_requireOkStatus(
@ -187,7 +186,9 @@ class FirmwareUpdateService {
imageBytes: imageBytes,
sessionId: normalizedSessionId,
payloadSize: payloadSize,
startPayload: startPayload,
statusTimeout: effectiveStatusTimeout,
bootloaderConnectTimeout: effectiveBootloaderConnectTimeout,
);
_emitProgress(state: DfuUpdateState.finishing);
@ -289,6 +290,7 @@ class FirmwareUpdateService {
Future<void> _subscribeToStatus() async {
await _statusSubscription?.cancel();
_statusStreamError = null;
_statusSubscription = _transport.subscribeToStatus().listen(
_handleStatusPayload,
onError: (Object error) {
@ -313,80 +315,195 @@ class FirmwareUpdateService {
required List<int> imageBytes,
required int sessionId,
required int payloadSize,
required BootloaderDfuStartPayload startPayload,
required Duration statusTimeout,
required Duration bootloaderConnectTimeout,
}) async {
var expectedOffset = _currentProgress.expectedOffset;
var retriesWithoutProgress = 0;
var reconnectResumeAttempts = 0;
while (expectedOffset < imageBytes.length) {
_throwIfCancelled();
_throwIfStatusStreamErrored();
_throwIfStatusStreamErrored(recoverable: true);
final frame = BootloaderDfuProtocol.buildDataFrame(
imageBytes: imageBytes,
sessionId: sessionId,
offset: expectedOffset,
payloadSize: payloadSize,
);
final status = await _writeDataFrameAndWaitForStatus(
frame,
timeout: statusTimeout,
);
if (status.code == DfuBootloaderStatusCode.queueFull ||
status.code == DfuBootloaderStatusCode.stateError) {
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
_requireOkStatus(
recoveredStatus,
try {
final frame = BootloaderDfuProtocol.buildDataFrame(
imageBytes: imageBytes,
sessionId: sessionId,
operation: 'GET_STATUS after ${_statusLabel(status)}',
offset: expectedOffset,
payloadSize: payloadSize,
);
final status = await _writeDataFrameAndWaitForStatus(
frame,
timeout: statusTimeout,
);
expectedOffset =
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
_emitTransferProgress(recoveredStatus, imageBytes.length);
continue;
}
_requireOkStatus(status, sessionId: sessionId, operation: 'DATA');
final nextOffset =
status.expectedOffset.clamp(0, imageBytes.length).toInt();
if (nextOffset <= expectedOffset) {
retriesWithoutProgress += 1;
if (retriesWithoutProgress > maxNoProgressRetries) {
throw _DfuFailure(
'Bootloader DFU stalled at offset $expectedOffset after $retriesWithoutProgress status checks without progress.',
if (status.code == DfuBootloaderStatusCode.queueFull ||
status.code == DfuBootloaderStatusCode.stateError) {
if (status.code == DfuBootloaderStatusCode.queueFull) {
await _delayWithCancel(queueFullBackoff);
}
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
_requireOkStatus(
recoveredStatus,
sessionId: sessionId,
operation: 'GET_STATUS after ${_statusLabel(status)}',
);
expectedOffset = recoveredStatus.expectedOffset
.clamp(0, imageBytes.length)
.toInt();
_emitTransferProgress(recoveredStatus, imageBytes.length);
reconnectResumeAttempts = 0;
continue;
}
_requireOkStatus(status, sessionId: sessionId, operation: 'DATA');
final nextOffset =
status.expectedOffset.clamp(0, imageBytes.length).toInt();
if (nextOffset <= expectedOffset) {
retriesWithoutProgress += 1;
if (retriesWithoutProgress > maxNoProgressRetries) {
throw _DfuFailure(
'Bootloader DFU stalled at offset $expectedOffset after $retriesWithoutProgress status checks without progress.',
);
}
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
_requireOkStatus(
recoveredStatus,
sessionId: sessionId,
operation: 'GET_STATUS after no progress',
);
expectedOffset = recoveredStatus.expectedOffset
.clamp(0, imageBytes.length)
.toInt();
_emitTransferProgress(recoveredStatus, imageBytes.length);
continue;
}
retriesWithoutProgress = 0;
reconnectResumeAttempts = 0;
expectedOffset = nextOffset;
_emitTransferProgress(status, imageBytes.length);
} on _DfuFailure catch (failure) {
if (!failure.recoverable ||
reconnectResumeAttempts >= maxReconnectResumeAttempts) {
rethrow;
}
reconnectResumeAttempts += 1;
final recoveredStatus = await _recoverTransferStatus(
timeout: statusTimeout,
bootloaderConnectTimeout: bootloaderConnectTimeout,
failure: failure,
);
if (recoveredStatus.isOk &&
recoveredStatus.sessionId == 0 &&
recoveredStatus.expectedOffset == 0) {
_emitProgress(
state: DfuUpdateState.erasing,
sentBytes: 0,
expectedOffset: 0,
);
final startStatus = await _sendStartAndWaitForStatus(
startPayload,
timeout: statusTimeout,
);
_requireOkStatus(
startStatus,
sessionId: sessionId,
expectedOffset: 0,
operation: 'START after reconnect',
);
expectedOffset = 0;
retriesWithoutProgress = 0;
_emitProgress(state: DfuUpdateState.transferring);
continue;
}
final recoveredStatus = await _requestStatus(timeout: statusTimeout);
_requireOkStatus(
recoveredStatus,
sessionId: sessionId,
operation: 'GET_STATUS after no progress',
operation: 'GET_STATUS after reconnect',
);
expectedOffset =
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
_emitTransferProgress(recoveredStatus, imageBytes.length);
_emitProgress(state: DfuUpdateState.transferring);
continue;
}
retriesWithoutProgress = 0;
expectedOffset = nextOffset;
_emitTransferProgress(status, imageBytes.length);
}
}
Future<bool> _isConnectedToBootloader() async {
final result = await _transport.isConnectedToBootloader();
if (result.isErr()) {
throw _DfuFailure(
'Could not check bootloader DFU connection: ${result.unwrapErr()}',
);
}
return result.unwrap();
}
Future<void> _connectToBootloader({required Duration timeout}) async {
final bootloaderConnectResult = await _transport.connectToBootloader(
timeout: timeout,
);
if (bootloaderConnectResult.isErr()) {
throw _DfuFailure(
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}',
);
}
}
Future<DfuBootloaderStatus> _sendStartAndWaitForStatus(
BootloaderDfuStartPayload payload, {
required Duration timeout,
}) {
return _writeControlAndWaitForStatus(
BootloaderDfuProtocol.encodeStartPayload(payload),
timeout: timeout,
);
}
Future<DfuBootloaderStatus> _recoverTransferStatus({
required Duration timeout,
required Duration bootloaderConnectTimeout,
required _DfuFailure failure,
}) async {
await _statusSubscription?.cancel();
_statusSubscription = null;
_statusSignal = null;
_latestStatus = null;
_statusStreamError = null;
_statusEventCount = 0;
_emitProgress(
state: DfuUpdateState.connectingBootloader,
errorMessage: failure.message,
);
await _connectToBootloader(timeout: bootloaderConnectTimeout);
await _subscribeToStatus();
_emitProgress(state: DfuUpdateState.waitingForStatus);
return _requestStatus(timeout: timeout);
}
Future<DfuBootloaderStatus> _writeControlAndWaitForStatus(
List<int> payload, {
required Duration timeout,
bool recoverable = false,
}) async {
final eventCount = _statusEventCount;
final result = await _transport.writeControl(payload);
if (result.isErr()) {
throw _DfuFailure(
'Failed to write bootloader control command: ${result.unwrapErr()}');
'Failed to write bootloader control command: ${result.unwrapErr()}',
recoverable: recoverable,
);
}
return _waitForStatus(afterEventCount: eventCount, timeout: timeout);
return _waitForStatus(
afterEventCount: eventCount,
timeout: timeout,
recoverable: recoverable,
);
}
Future<DfuBootloaderStatus> _writeDataFrameAndWaitForStatus(
@ -398,28 +515,35 @@ class FirmwareUpdateService {
if (result.isErr()) {
throw _DfuFailure(
'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}',
recoverable: true,
);
}
return _waitForStatus(afterEventCount: eventCount, timeout: timeout);
return _waitForStatus(
afterEventCount: eventCount,
timeout: timeout,
recoverable: true,
);
}
Future<DfuBootloaderStatus> _requestStatus({required Duration timeout}) {
return _writeControlAndWaitForStatus(
BootloaderDfuProtocol.encodeGetStatusPayload(),
timeout: timeout,
recoverable: true,
);
}
Future<DfuBootloaderStatus> _waitForStatus({
required int afterEventCount,
required Duration timeout,
bool recoverable = false,
}) async {
final deadline = DateTime.now().add(timeout);
var observedEvents = afterEventCount;
while (true) {
_throwIfCancelled();
_throwIfStatusStreamErrored();
_throwIfStatusStreamErrored(recoverable: recoverable);
if (_statusEventCount > observedEvents && _latestStatus != null) {
return _latestStatus!;
@ -427,14 +551,16 @@ class FirmwareUpdateService {
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
throw const _DfuFailure(
throw _DfuFailure(
'Timed out waiting for bootloader DFU status. Reconnect and retry the update.',
recoverable: recoverable,
);
}
final gotEvent = await _waitForNextStatusEvent(
afterEventCount: observedEvents,
timeout: remaining,
recoverable: recoverable,
);
if (gotEvent) {
observedEvents = _statusEventCount - 1;
@ -445,6 +571,7 @@ class FirmwareUpdateService {
Future<bool> _waitForNextStatusEvent({
required int afterEventCount,
required Duration timeout,
required bool recoverable,
}) async {
if (_statusEventCount > afterEventCount) {
return true;
@ -467,7 +594,7 @@ class FirmwareUpdateService {
}
_throwIfCancelled();
_throwIfStatusStreamErrored();
_throwIfStatusStreamErrored(recoverable: recoverable);
return _statusEventCount > afterEventCount;
}
@ -584,10 +711,21 @@ class FirmwareUpdateService {
}
}
void _throwIfStatusStreamErrored() {
Future<void> _delayWithCancel(Duration duration) async {
if (duration <= Duration.zero) {
return;
}
await Future.any<void>([
Future<void>.delayed(duration),
_cancelSignal?.future ?? Future<void>.value(),
]);
_throwIfCancelled();
}
void _throwIfStatusStreamErrored({bool recoverable = false}) {
final error = _statusStreamError;
if (error != null) {
throw _DfuFailure(error);
throw _DfuFailure(error, recoverable: recoverable);
}
}
@ -600,6 +738,8 @@ class FirmwareUpdateService {
}
abstract interface class FirmwareUpdateTransport {
Future<Result<bool>> isConnectedToBootloader();
Future<Result<void>> enterBootloader();
Future<Result<void>> waitForAppDisconnect({required Duration timeout});
@ -630,15 +770,39 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
required this.buttonDeviceId,
});
final ShifterService shifterService;
final ShifterService? shifterService;
final BluetoothController bluetoothController;
final String buttonDeviceId;
String? _bootloaderDeviceId;
@override
Future<Result<bool>> isConnectedToBootloader() async {
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 != ConnectionStatus.connected ||
currentState.$2 == null) {
return Ok(false);
}
final statusResult = await bluetoothController.readCharacteristic(
currentState.$2!,
universalShifterControlServiceUuid,
universalShifterDfuStatusCharacteristicUuid,
);
if (statusResult.isErr()) {
return Ok(false);
}
_bootloaderDeviceId = currentState.$2;
return Ok(true);
}
@override
Future<Result<void>> enterBootloader() {
return shifterService.writeCommand(UniversalShifterCommand.enterDfu);
final shifter = shifterService;
if (shifter == null) {
return Future.value(bail('Normal app control service is not available.'));
}
return shifter.writeCommand(UniversalShifterCommand.enterDfu);
}
@override
@ -774,10 +938,17 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
Future<Result<void>> verifyDeviceReachable(
{required Duration timeout}) async {
try {
final statusResult = await shifterService.readStatus().timeout(timeout);
final statusResult = await bluetoothController
.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
)
.timeout(timeout);
if (statusResult.isErr()) {
return Err(statusResult.unwrapErr());
}
CentralStatus.fromBytes(statusResult.unwrap());
return Ok(null);
} on TimeoutException {
return bail(
@ -874,9 +1045,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
}
class _DfuFailure implements Exception {
const _DfuFailure(this.message);
const _DfuFailure(this.message, {this.recoverable = false});
final String message;
final bool recoverable;
@override
String toString() => message;