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

@ -497,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;
} }
@ -513,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,
), ),
@ -585,7 +581,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; return;
} }
await _startStatusStreamingIfNeeded();
final updater = await _ensureFirmwareUpdateService(); final updater = await _ensureFirmwareUpdateService();
if (!mounted) { if (!mounted) {
return; return;
@ -872,9 +867,10 @@ 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 = isCurrentConnected;
final canSelectFirmware = final canSelectFirmware =
hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy; canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = hasDeviceAccess && final canStartFirmware = canUseFirmwareUpdate &&
!_isSelectingFirmware && !_isSelectingFirmware &&
!_isFirmwareUpdateBusy && !_isFirmwareUpdateBusy &&
_selectedFirmware != null; _selectedFirmware != null;
@ -907,8 +903,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,
@ -1009,23 +1023,6 @@ 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,
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
),
] else if (isCurrentConnected) ...[ ] else if (isCurrentConnected) ...[
_PairingRequiredCard( _PairingRequiredCard(
isChecking: _isPairingCheckRunning, isChecking: _isPairingCheckRunning,

View File

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

View File

@ -24,6 +24,7 @@ void main() {
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.steps, [ expect(transport.steps, [
'isConnectedToBootloader',
'enterBootloader', 'enterBootloader',
'waitForAppDisconnect', 'waitForAppDisconnect',
'connectToBootloader', 'connectToBootloader',
@ -48,6 +49,63 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('starts directly when already connected to bootloader', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
alreadyInBootloader: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
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', test('backs off on queue-full status and resumes from GET_STATUS',
() async { () async {
final image = _validImage(80); final image = _validImage(80);
@ -80,6 +138,84 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('reconnects and resumes from status after transient data failure',
() async {
final image = _validImage(130);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
failDataWriteAtOffsetOnce:
universalShifterBootloaderDfuMaxPayloadSizeBytes,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
defaultBootloaderConnectTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 13,
);
expect(result.isOk(), isTrue);
expect(
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 { test('fails with bootloader status error on rejected START', () async {
final image = _validImage(40); final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport( final transport = _FakeFirmwareUpdateTransport(
@ -147,15 +283,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({ _FakeFirmwareUpdateTransport({
required this.totalBytes, required this.totalBytes,
this.startStatusCode = DfuBootloaderStatusCode.ok, this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false,
this.failEnterBootloader = false,
this.queueFullOnFirstData = false, this.queueFullOnFirstData = false,
this.suppressFirstDataStatus = false, this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.onDataWrite, this.onDataWrite,
}); });
final int totalBytes; final int totalBytes;
final DfuBootloaderStatusCode startStatusCode; final DfuBootloaderStatusCode startStatusCode;
final bool alreadyInBootloader;
final bool failEnterBootloader;
final bool queueFullOnFirstData; final bool queueFullOnFirstData;
final bool suppressFirstDataStatus; final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final void Function()? onDataWrite; final void Function()? onDataWrite;
final StreamController<List<int>> _statusController = final StreamController<List<int>> _statusController =
@ -167,12 +311,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
int _sessionId = 0; int _sessionId = 0;
int _expectedOffset = 0; int _expectedOffset = 0;
int _connectCount = 0;
bool _sentDataFailure = false;
bool _sentQueueFull = false; bool _sentQueueFull = false;
bool _suppressedDataStatus = false; bool _suppressedDataStatus = false;
@override
Future<Result<bool>> isConnectedToBootloader() async {
steps.add('isConnectedToBootloader');
return Ok(alreadyInBootloader);
}
@override @override
Future<Result<void>> enterBootloader() async { Future<Result<void>> enterBootloader() async {
steps.add('enterBootloader'); steps.add('enterBootloader');
if (failEnterBootloader) {
return bail('app disconnected before write response');
}
return Ok(null); return Ok(null);
} }
@ -185,6 +340,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<void>> connectToBootloader({required Duration timeout}) async { Future<Result<void>> connectToBootloader({required Duration timeout}) async {
steps.add('connectToBootloader'); steps.add('connectToBootloader');
_connectCount += 1;
return Ok(null); return Ok(null);
} }
@ -212,6 +368,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_expectedOffset = 0; _expectedOffset = 0;
_scheduleStatus(startStatusCode, _sessionId, 0); _scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) { } else if (opcode == universalShifterDfuOpcodeGetStatus) {
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
_sessionId = 0;
_expectedOffset = 0;
}
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) { } else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
@ -229,6 +389,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final offset = _readLeU32(frame, 1); final offset = _readLeU32(frame, 1);
dataWriteOffsets.add(offset); dataWriteOffsets.add(offset);
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
_sentDataFailure = true;
return bail('simulated BLE write failure');
}
if (queueFullOnFirstData && !_sentQueueFull) { if (queueFullOnFirstData && !_sentQueueFull) {
_sentQueueFull = true; _sentQueueFull = true;
_scheduleStatus( _scheduleStatus(