feat: recover bootloader OTA transfers
This commit is contained in:
@ -497,10 +497,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
return null;
|
||||
}
|
||||
if (_firmwareUpdateService != null) {
|
||||
return _firmwareUpdateService;
|
||||
}
|
||||
@ -513,7 +509,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
final service = FirmwareUpdateService(
|
||||
transport: ShifterFirmwareUpdateTransport(
|
||||
shifterService: shifter,
|
||||
shifterService: _shifterService,
|
||||
bluetoothController: bluetooth,
|
||||
buttonDeviceId: widget.deviceAddress,
|
||||
),
|
||||
@ -585,7 +581,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _startStatusStreamingIfNeeded();
|
||||
final updater = await _ensureFirmwareUpdateService();
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -872,9 +867,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
currentConnectionStatus == ConnectionStatus.connected;
|
||||
final hasDeviceAccess =
|
||||
isCurrentConnected && _shifterService != null && _latestStatus != null;
|
||||
final canUseFirmwareUpdate = isCurrentConnected;
|
||||
final canSelectFirmware =
|
||||
hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = hasDeviceAccess &&
|
||||
canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = canUseFirmwareUpdate &&
|
||||
!_isSelectingFirmware &&
|
||||
!_isFirmwareUpdateBusy &&
|
||||
_selectedFirmware != null;
|
||||
@ -907,8 +903,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
status: _latestStatus,
|
||||
),
|
||||
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),
|
||||
],
|
||||
if (hasDeviceAccess) ...[
|
||||
_StatusBanner(
|
||||
status: _latestStatus,
|
||||
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) ...[
|
||||
_PairingRequiredCard(
|
||||
isChecking: _isPairingCheckRunning,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -24,6 +24,7 @@ void main() {
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.steps, [
|
||||
'isConnectedToBootloader',
|
||||
'enterBootloader',
|
||||
'waitForAppDisconnect',
|
||||
'connectToBootloader',
|
||||
@ -48,6 +49,63 @@ void main() {
|
||||
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',
|
||||
() async {
|
||||
final image = _validImage(80);
|
||||
@ -80,6 +138,84 @@ void main() {
|
||||
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 {
|
||||
final image = _validImage(40);
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
@ -147,15 +283,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
required this.totalBytes,
|
||||
this.startStatusCode = DfuBootloaderStatusCode.ok,
|
||||
this.alreadyInBootloader = false,
|
||||
this.failEnterBootloader = false,
|
||||
this.queueFullOnFirstData = false,
|
||||
this.suppressFirstDataStatus = false,
|
||||
this.failDataWriteAtOffsetOnce,
|
||||
this.resetSessionOnRecoveryStatus = false,
|
||||
this.onDataWrite,
|
||||
});
|
||||
|
||||
final int totalBytes;
|
||||
final DfuBootloaderStatusCode startStatusCode;
|
||||
final bool alreadyInBootloader;
|
||||
final bool failEnterBootloader;
|
||||
final bool queueFullOnFirstData;
|
||||
final bool suppressFirstDataStatus;
|
||||
final int? failDataWriteAtOffsetOnce;
|
||||
final bool resetSessionOnRecoveryStatus;
|
||||
final void Function()? onDataWrite;
|
||||
|
||||
final StreamController<List<int>> _statusController =
|
||||
@ -167,12 +311,23 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
|
||||
int _sessionId = 0;
|
||||
int _expectedOffset = 0;
|
||||
int _connectCount = 0;
|
||||
bool _sentDataFailure = false;
|
||||
bool _sentQueueFull = false;
|
||||
bool _suppressedDataStatus = false;
|
||||
|
||||
@override
|
||||
Future<Result<bool>> isConnectedToBootloader() async {
|
||||
steps.add('isConnectedToBootloader');
|
||||
return Ok(alreadyInBootloader);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> enterBootloader() async {
|
||||
steps.add('enterBootloader');
|
||||
if (failEnterBootloader) {
|
||||
return bail('app disconnected before write response');
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@ -185,6 +340,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
@override
|
||||
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
||||
steps.add('connectToBootloader');
|
||||
_connectCount += 1;
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@ -212,6 +368,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_expectedOffset = 0;
|
||||
_scheduleStatus(startStatusCode, _sessionId, 0);
|
||||
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
|
||||
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
|
||||
_sessionId = 0;
|
||||
_expectedOffset = 0;
|
||||
}
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
|
||||
} else if (opcode == universalShifterDfuOpcodeFinish) {
|
||||
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
|
||||
@ -229,6 +389,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
final offset = _readLeU32(frame, 1);
|
||||
dataWriteOffsets.add(offset);
|
||||
|
||||
if (failDataWriteAtOffsetOnce == offset && !_sentDataFailure) {
|
||||
_sentDataFailure = true;
|
||||
return bail('simulated BLE write failure');
|
||||
}
|
||||
|
||||
if (queueFullOnFirstData && !_sentQueueFull) {
|
||||
_sentQueueFull = true;
|
||||
_scheduleStatus(
|
||||
|
||||
Reference in New Issue
Block a user