1369 lines
45 KiB
Dart
1369 lines
45 KiB
Dart
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';
|
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
|
show DiscoveredDevice, Uuid;
|
|
import 'package:logging/logging.dart';
|
|
|
|
final _log = Logger('FirmwareUpdateService');
|
|
|
|
class FirmwareUpdateService {
|
|
FirmwareUpdateService({
|
|
required FirmwareUpdateTransport transport,
|
|
this.verifyAfterFinish = true,
|
|
this.defaultStatusTimeout = const Duration(seconds: 2),
|
|
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;
|
|
final bool verifyAfterFinish;
|
|
final Duration defaultStatusTimeout;
|
|
final Duration defaultBootloaderConnectTimeout;
|
|
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();
|
|
|
|
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
|
|
state: DfuUpdateState.idle,
|
|
totalBytes: 0,
|
|
sentBytes: 0,
|
|
expectedOffset: 0,
|
|
sessionId: 0,
|
|
flags: DfuUpdateFlags(),
|
|
);
|
|
|
|
StreamSubscription<List<int>>? _statusSubscription;
|
|
Completer<void>? _statusSignal;
|
|
Completer<void>? _cancelSignal;
|
|
DfuBootloaderStatus? _latestStatus;
|
|
String? _statusStreamError;
|
|
int _statusEventCount = 0;
|
|
bool _isRunning = false;
|
|
bool _cancelRequested = false;
|
|
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,
|
|
int appStart = universalShifterDfuAppStart,
|
|
int imageVersion = 0,
|
|
DfuUpdateFlags flags = const DfuUpdateFlags(),
|
|
int requestedMtu = universalShifterDfuPreferredMtu,
|
|
Duration? statusTimeout,
|
|
Duration? bootloaderConnectTimeout,
|
|
Duration? postFinishResetTimeout,
|
|
Duration? reconnectTimeout,
|
|
Duration? verificationTimeout,
|
|
}) 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 effectiveStatusTimeout = statusTimeout ?? defaultStatusTimeout;
|
|
final effectiveBootloaderConnectTimeout =
|
|
bootloaderConnectTimeout ?? defaultBootloaderConnectTimeout;
|
|
final effectivePostFinishResetTimeout =
|
|
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
|
|
final effectiveReconnectTimeout =
|
|
reconnectTimeout ?? defaultReconnectTimeout;
|
|
final effectiveVerificationTimeout =
|
|
verificationTimeout ?? defaultVerificationTimeout;
|
|
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;
|
|
_cancelSignal = Completer<void>();
|
|
_statusSignal = null;
|
|
_latestStatus = null;
|
|
_statusStreamError = null;
|
|
_statusEventCount = 0;
|
|
_totalBytes = imageBytes.length;
|
|
|
|
var startAccepted = false;
|
|
|
|
_log.info(
|
|
'Starting firmware update: bytes=${imageBytes.length}, '
|
|
'session=$normalizedSessionId, appStart=0x${appStart.toRadixString(16)}, '
|
|
'imageVersion=$imageVersion, flags=0x${flags.rawValue.toRadixString(16)}, '
|
|
'crc32=0x${imageCrc32.toRadixString(16).padLeft(8, '0')}, '
|
|
'requestedMtu=$requestedMtu',
|
|
);
|
|
|
|
_emitProgress(
|
|
state: DfuUpdateState.starting,
|
|
totalBytes: imageBytes.length,
|
|
sentBytes: 0,
|
|
expectedOffset: 0,
|
|
sessionId: normalizedSessionId,
|
|
flags: flags,
|
|
bootloaderStatus: null,
|
|
);
|
|
|
|
try {
|
|
_throwIfCancelled();
|
|
_emitProgress(state: DfuUpdateState.enteringBootloader);
|
|
final alreadyInBootloader = await _isConnectedToBootloader();
|
|
_log.info(
|
|
'Bootloader connection check: alreadyInBootloader=$alreadyInBootloader');
|
|
if (!alreadyInBootloader) {
|
|
_log.info('Requesting app to enter bootloader mode');
|
|
final enterResult = await _transport.enterBootloader();
|
|
if (enterResult.isErr()) {
|
|
_log.warning(
|
|
'Enter bootloader command returned an error; waiting for disconnect anyway: '
|
|
'${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()}',
|
|
);
|
|
}
|
|
|
|
_log.info('App disconnected for bootloader mode');
|
|
|
|
_emitProgress(state: DfuUpdateState.connectingBootloader);
|
|
await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout);
|
|
}
|
|
|
|
await _optimizeBootloaderConnection();
|
|
|
|
_log.info('Negotiating bootloader MTU: requested=$requestedMtu');
|
|
final mtuResult =
|
|
await _transport.negotiateMtu(requestedMtu: requestedMtu);
|
|
if (mtuResult.isErr()) {
|
|
throw _DfuFailure(
|
|
'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}',
|
|
);
|
|
}
|
|
_log.info('Bootloader MTU negotiated: ${mtuResult.unwrap()}');
|
|
final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu(
|
|
mtuResult.unwrap(),
|
|
);
|
|
if (payloadSize <= 0) {
|
|
throw _DfuFailure(
|
|
'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.',
|
|
);
|
|
}
|
|
_log.info('Bootloader DFU payload size selected: $payloadSize bytes');
|
|
|
|
await _subscribeToStatus();
|
|
_emitProgress(state: DfuUpdateState.waitingForStatus);
|
|
await _readInitialStatus();
|
|
|
|
_emitProgress(state: DfuUpdateState.erasing);
|
|
_log.info('Sending START command');
|
|
final startStatus = await _sendStartAndWaitForStatus(
|
|
startPayload,
|
|
timeout: effectiveStatusTimeout,
|
|
);
|
|
_requireOkStatus(
|
|
startStatus,
|
|
sessionId: normalizedSessionId,
|
|
expectedOffset: 0,
|
|
operation: 'START',
|
|
);
|
|
startAccepted = true;
|
|
_log.info(
|
|
'START accepted: session=${startStatus.sessionId}, '
|
|
'expectedOffset=${startStatus.expectedOffset}',
|
|
);
|
|
|
|
_emitProgress(state: DfuUpdateState.transferring);
|
|
_log.info('Starting firmware transfer');
|
|
await _transferImage(
|
|
imageBytes: imageBytes,
|
|
sessionId: normalizedSessionId,
|
|
payloadSize: payloadSize,
|
|
startPayload: startPayload,
|
|
statusTimeout: effectiveStatusTimeout,
|
|
bootloaderConnectTimeout: effectiveBootloaderConnectTimeout,
|
|
);
|
|
_log.info('Firmware transfer completed; sending FINISH');
|
|
|
|
_emitProgress(state: DfuUpdateState.finishing);
|
|
await _writeFinishAndWaitForReset(
|
|
sessionId: normalizedSessionId,
|
|
expectedOffset: imageBytes.length,
|
|
statusTimeout: effectiveStatusTimeout,
|
|
resetTimeout: effectivePostFinishResetTimeout,
|
|
);
|
|
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription = null;
|
|
|
|
_emitProgress(
|
|
state: DfuUpdateState.rebooting, sentBytes: imageBytes.length);
|
|
_log.info('Bootloader reset observed after FINISH');
|
|
|
|
if (!verifyAfterFinish) {
|
|
_emitProgress(
|
|
state: DfuUpdateState.completed,
|
|
sentBytes: imageBytes.length,
|
|
expectedOffset: imageBytes.length,
|
|
);
|
|
return Ok(null);
|
|
}
|
|
|
|
_emitProgress(state: DfuUpdateState.verifying);
|
|
_log.info('Reconnecting to updated app for verification');
|
|
final reconnectResult = await _transport.reconnectForVerification(
|
|
timeout: effectiveReconnectTimeout,
|
|
);
|
|
if (reconnectResult.isErr()) {
|
|
throw _DfuFailure(
|
|
'Updated app did not reconnect after bootloader reset: ${reconnectResult.unwrapErr()}',
|
|
);
|
|
}
|
|
|
|
_log.info('Updated app reconnected; verifying status characteristic');
|
|
final verificationResult = await _transport.verifyDeviceReachable(
|
|
timeout: effectiveVerificationTimeout,
|
|
);
|
|
if (verificationResult.isErr()) {
|
|
throw _DfuFailure(
|
|
'Updated app reconnected but verification failed: ${verificationResult.unwrapErr()}',
|
|
);
|
|
}
|
|
|
|
_emitProgress(
|
|
state: DfuUpdateState.completed,
|
|
sentBytes: imageBytes.length,
|
|
expectedOffset: imageBytes.length,
|
|
);
|
|
_log.info('Firmware update completed successfully');
|
|
return Ok(null);
|
|
} on _DfuCancelled {
|
|
_log.warning('Firmware update canceled by user');
|
|
if (startAccepted) {
|
|
await _sendAbortForCancel(normalizedSessionId);
|
|
}
|
|
_emitProgress(state: DfuUpdateState.aborted);
|
|
return bail('Firmware update canceled by user.');
|
|
} on _DfuFailure catch (failure) {
|
|
_log.severe('Firmware update failed: ${failure.message}');
|
|
_emitProgress(
|
|
state: DfuUpdateState.failed, errorMessage: failure.message);
|
|
return bail(failure.message);
|
|
} catch (error, stackTrace) {
|
|
final message =
|
|
'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.';
|
|
_log.severe(message, error, stackTrace);
|
|
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
|
|
return bail(message);
|
|
} finally {
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription = null;
|
|
_isRunning = false;
|
|
_cancelRequested = false;
|
|
_cancelSignal = null;
|
|
_statusSignal = null;
|
|
_statusStreamError = null;
|
|
_latestStatus = null;
|
|
_statusEventCount = 0;
|
|
_totalBytes = 0;
|
|
}
|
|
}
|
|
|
|
Future<void> cancelUpdate() async {
|
|
if (!_isRunning || _cancelRequested) {
|
|
return;
|
|
}
|
|
_cancelRequested = true;
|
|
_cancelSignal?.complete();
|
|
_signalStatusWaiters();
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await cancelUpdate();
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription = null;
|
|
await _progressController.close();
|
|
}
|
|
|
|
Future<void> _subscribeToStatus() async {
|
|
await _statusSubscription?.cancel();
|
|
_statusStreamError = null;
|
|
_log.info('Subscribing to bootloader DFU status indications');
|
|
_statusSubscription = _transport.subscribeToStatus().listen(
|
|
_handleStatusPayload,
|
|
onError: (Object error) {
|
|
_statusStreamError =
|
|
'Bootloader status indication stream failed: $error. Reconnect and retry the update.';
|
|
_log.severe(_statusStreamError);
|
|
_signalStatusWaiters();
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _readInitialStatus() async {
|
|
_log.info('Reading initial bootloader DFU status');
|
|
final statusResult = await _transport.readStatus();
|
|
if (statusResult.isErr()) {
|
|
_log.warning(
|
|
'Initial bootloader DFU status read failed: ${statusResult.unwrapErr()}');
|
|
throw _DfuFailure(
|
|
'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}',
|
|
);
|
|
}
|
|
_handleStatusPayload(statusResult.unwrap());
|
|
if (_latestStatus?.code == DfuBootloaderStatusCode.bootMetadataError) {
|
|
throw _DfuFailure(universalShifterBootMetadataWarningMessage);
|
|
}
|
|
_log.info('Initial bootloader DFU status read succeeded');
|
|
}
|
|
|
|
Future<void> _transferImage({
|
|
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(recoverable: true);
|
|
|
|
try {
|
|
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) {
|
|
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) {
|
|
_log.warning(
|
|
'Transfer failure is not recoverable: ${failure.message}');
|
|
rethrow;
|
|
}
|
|
reconnectResumeAttempts += 1;
|
|
_log.warning(
|
|
'Recoverable transfer failure, reconnect attempt '
|
|
'$reconnectResumeAttempts/$maxReconnectResumeAttempts: ${failure.message}',
|
|
);
|
|
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;
|
|
}
|
|
_requireOkStatus(
|
|
recoveredStatus,
|
|
sessionId: sessionId,
|
|
operation: 'GET_STATUS after reconnect',
|
|
);
|
|
expectedOffset =
|
|
recoveredStatus.expectedOffset.clamp(0, imageBytes.length).toInt();
|
|
_emitTransferProgress(recoveredStatus, imageBytes.length);
|
|
_emitProgress(state: DfuUpdateState.transferring);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
_log.info(
|
|
'Connecting to bootloader with timeout ${timeout.inMilliseconds}ms');
|
|
final bootloaderConnectResult = await _transport.connectToBootloader(
|
|
timeout: timeout,
|
|
);
|
|
if (bootloaderConnectResult.isErr()) {
|
|
throw _DfuFailure(
|
|
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}',
|
|
);
|
|
}
|
|
_log.info('Connected to bootloader');
|
|
}
|
|
|
|
Future<void> _optimizeBootloaderConnection() async {
|
|
_log.info('Optimizing bootloader connection');
|
|
final result = await _transport.optimizeBootloaderConnection();
|
|
if (result.isErr()) {
|
|
_log.warning(
|
|
'Bootloader connection optimization failed: ${result.unwrapErr()}');
|
|
_emitProgress(errorMessage: result.unwrapErr().toString());
|
|
return;
|
|
}
|
|
_log.info('Bootloader connection optimization completed');
|
|
}
|
|
|
|
Future<DfuBootloaderStatus> _sendStartAndWaitForStatus(
|
|
BootloaderDfuStartPayload payload, {
|
|
required Duration timeout,
|
|
}) async {
|
|
final eventCount = _statusEventCount;
|
|
final encodedPayload = BootloaderDfuProtocol.encodeStartPayload(payload);
|
|
_log.fine(
|
|
'Writing DFU START command for session ${payload.sessionId} '
|
|
'(len=${encodedPayload.length})',
|
|
);
|
|
final result = await _transport.writeControl(encodedPayload);
|
|
if (result.isErr()) {
|
|
_log.warning('DFU START write failed: ${result.unwrapErr()}');
|
|
throw _DfuFailure(
|
|
'Failed to write bootloader control command: ${result.unwrapErr()}',
|
|
);
|
|
}
|
|
return _waitForStatus(
|
|
afterEventCount: eventCount,
|
|
timeout: timeout,
|
|
acceptStatus: (status) {
|
|
if (status.sessionId == payload.sessionId) {
|
|
return true;
|
|
}
|
|
_log.fine(
|
|
'Ignoring stale START status for session ${status.sessionId}; '
|
|
'waiting for session ${payload.sessionId}',
|
|
);
|
|
return false;
|
|
},
|
|
);
|
|
}
|
|
|
|
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 _optimizeBootloaderConnection();
|
|
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;
|
|
_log.fine(
|
|
'Writing DFU control opcode 0x${payload.first.toRadixString(16).padLeft(2, '0')} '
|
|
'(len=${payload.length}, recoverable=$recoverable)',
|
|
);
|
|
final result = await _transport.writeControl(payload);
|
|
if (result.isErr()) {
|
|
_log.warning(
|
|
'DFU control write failed for opcode '
|
|
'0x${payload.first.toRadixString(16).padLeft(2, '0')}: ${result.unwrapErr()}',
|
|
);
|
|
throw _DfuFailure(
|
|
'Failed to write bootloader control command: ${result.unwrapErr()}',
|
|
recoverable: recoverable,
|
|
);
|
|
}
|
|
return _waitForStatus(
|
|
afterEventCount: eventCount,
|
|
timeout: timeout,
|
|
recoverable: recoverable,
|
|
);
|
|
}
|
|
|
|
Future<DfuBootloaderStatus> _writeDataFrameAndWaitForStatus(
|
|
BootloaderDfuDataFrame frame, {
|
|
required Duration timeout,
|
|
}) async {
|
|
final eventCount = _statusEventCount;
|
|
if (frame.offset == 0 || frame.offset % 4096 == 0) {
|
|
_log.fine(
|
|
'Writing DFU data frame: offset=${frame.offset}, '
|
|
'payload=${frame.payloadLength}, frameLen=${frame.bytes.length}',
|
|
);
|
|
}
|
|
final result = await _transport.writeDataFrame(frame.bytes);
|
|
if (result.isErr()) {
|
|
_log.warning(
|
|
'DFU data write failed at offset ${frame.offset}: ${result.unwrapErr()}',
|
|
);
|
|
throw _DfuFailure(
|
|
'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}',
|
|
recoverable: true,
|
|
);
|
|
}
|
|
return _waitForStatus(
|
|
afterEventCount: eventCount,
|
|
timeout: timeout,
|
|
recoverable: true,
|
|
);
|
|
}
|
|
|
|
Future<DfuBootloaderStatus> _requestStatus({required Duration timeout}) {
|
|
return _writeControlAndWaitForStatus(
|
|
BootloaderDfuProtocol.encodeGetStatusPayload(),
|
|
timeout: timeout,
|
|
recoverable: true,
|
|
);
|
|
}
|
|
|
|
Future<void> _writeFinishAndWaitForReset({
|
|
required int sessionId,
|
|
required int expectedOffset,
|
|
required Duration statusTimeout,
|
|
required Duration resetTimeout,
|
|
}) async {
|
|
final eventCount = _statusEventCount;
|
|
_log.info(
|
|
'Writing FINISH command: session=$sessionId, expectedOffset=$expectedOffset, '
|
|
'resetTimeout=${resetTimeout.inMilliseconds}ms',
|
|
);
|
|
final result = await _transport.writeControl(
|
|
BootloaderDfuProtocol.encodeFinishPayload(sessionId),
|
|
);
|
|
if (result.isErr()) {
|
|
_log.warning('FINISH write failed: ${result.unwrapErr()}');
|
|
throw _DfuFailure(
|
|
'Failed to write bootloader control command: ${result.unwrapErr()}',
|
|
);
|
|
}
|
|
|
|
final deadline = DateTime.now().add(resetTimeout);
|
|
var observedEvents = eventCount;
|
|
while (true) {
|
|
_throwIfCancelled();
|
|
_throwIfStatusStreamErrored();
|
|
|
|
if (_statusEventCount > observedEvents && _latestStatus != null) {
|
|
final status = _latestStatus!;
|
|
_requireOkStatus(
|
|
status,
|
|
sessionId: sessionId,
|
|
expectedOffset: expectedOffset,
|
|
operation: 'FINISH',
|
|
);
|
|
final remaining = deadline.difference(DateTime.now());
|
|
final resetDisconnectResult =
|
|
await _transport.waitForBootloaderDisconnect(
|
|
timeout: remaining > Duration.zero ? remaining : Duration.zero,
|
|
);
|
|
if (resetDisconnectResult.isErr()) {
|
|
_log.warning(
|
|
'Bootloader reset disconnect wait failed after FINISH status: '
|
|
'${resetDisconnectResult.unwrapErr()}',
|
|
);
|
|
throw _DfuFailure(
|
|
'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
|
|
);
|
|
}
|
|
_log.info('Bootloader reset disconnect observed after FINISH status');
|
|
return;
|
|
}
|
|
|
|
final disconnectResult = await _transport.waitForBootloaderDisconnect(
|
|
timeout: Duration.zero,
|
|
);
|
|
if (disconnectResult.isOk()) {
|
|
_log.info(
|
|
'Bootloader reset disconnect observed before FINISH status indication');
|
|
return;
|
|
}
|
|
|
|
final remaining = deadline.difference(DateTime.now());
|
|
if (remaining <= Duration.zero) {
|
|
_log.warning(
|
|
'Timed out waiting for bootloader reset disconnect after FINISH');
|
|
throw _DfuFailure(
|
|
'Bootloader did not perform the expected post-FINISH reset disconnect within ${resetTimeout.inMilliseconds}ms.',
|
|
);
|
|
}
|
|
|
|
final waitDuration =
|
|
remaining < statusTimeout ? remaining : statusTimeout;
|
|
final gotEvent = await _waitForNextStatusEvent(
|
|
afterEventCount: observedEvents,
|
|
timeout: waitDuration,
|
|
recoverable: false,
|
|
);
|
|
if (gotEvent) {
|
|
observedEvents = _statusEventCount - 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<DfuBootloaderStatus> _waitForStatus({
|
|
required int afterEventCount,
|
|
required Duration timeout,
|
|
bool recoverable = false,
|
|
bool Function(DfuBootloaderStatus status)? acceptStatus,
|
|
}) async {
|
|
final deadline = DateTime.now().add(timeout);
|
|
var observedEvents = afterEventCount;
|
|
|
|
while (true) {
|
|
_throwIfCancelled();
|
|
_throwIfStatusStreamErrored(recoverable: recoverable);
|
|
|
|
if (_statusEventCount > observedEvents && _latestStatus != null) {
|
|
_log.fine(
|
|
'Received DFU status for wait: events=$_statusEventCount, '
|
|
'session=${_latestStatus!.sessionId}, offset=${_latestStatus!.expectedOffset}, '
|
|
'code=${_statusLabel(_latestStatus!)}',
|
|
);
|
|
final status = _latestStatus!;
|
|
if (acceptStatus == null || acceptStatus(status)) {
|
|
return status;
|
|
}
|
|
observedEvents = _statusEventCount;
|
|
continue;
|
|
}
|
|
|
|
final remaining = deadline.difference(DateTime.now());
|
|
if (remaining <= Duration.zero) {
|
|
_log.warning(
|
|
'Timed out waiting for DFU status afterEventCount=$afterEventCount, '
|
|
'currentEventCount=$_statusEventCount',
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _waitForNextStatusEvent({
|
|
required int afterEventCount,
|
|
required Duration timeout,
|
|
required bool recoverable,
|
|
}) async {
|
|
if (_statusEventCount > afterEventCount) {
|
|
return true;
|
|
}
|
|
|
|
_statusSignal ??= Completer<void>();
|
|
final signal = _statusSignal!;
|
|
|
|
try {
|
|
await Future.any<void>([
|
|
signal.future,
|
|
_cancelSignal?.future ?? Future<void>.value(),
|
|
]).timeout(timeout);
|
|
} on TimeoutException {
|
|
return false;
|
|
}
|
|
|
|
if (identical(_statusSignal, signal)) {
|
|
_statusSignal = null;
|
|
}
|
|
|
|
_throwIfCancelled();
|
|
_throwIfStatusStreamErrored(recoverable: recoverable);
|
|
return _statusEventCount > afterEventCount;
|
|
}
|
|
|
|
void _handleStatusPayload(List<int> payload) {
|
|
try {
|
|
final status = BootloaderDfuProtocol.parseStatusPayload(payload);
|
|
_latestStatus = status;
|
|
_log.fine(
|
|
'Bootloader status: code=${_statusLabel(status)}, session=${status.sessionId}, '
|
|
'expectedOffset=${status.expectedOffset}, rawLen=${payload.length}',
|
|
);
|
|
final sentBytes = status.expectedOffset.clamp(0, _totalBytes).toInt();
|
|
_emitProgress(
|
|
bootloaderStatus: status,
|
|
expectedOffset: status.expectedOffset,
|
|
sentBytes: sentBytes,
|
|
);
|
|
} on FormatException catch (error) {
|
|
_statusStreamError =
|
|
'Received malformed bootloader DFU status: $error. Reconnect and retry.';
|
|
_log.severe('Malformed bootloader DFU status payload: $payload', error);
|
|
} finally {
|
|
_statusEventCount += 1;
|
|
_signalStatusWaiters();
|
|
}
|
|
}
|
|
|
|
void _emitTransferProgress(DfuBootloaderStatus status, int totalBytes) {
|
|
final offset = status.expectedOffset.clamp(0, totalBytes).toInt();
|
|
_emitProgress(
|
|
bootloaderStatus: status,
|
|
expectedOffset: offset,
|
|
sentBytes: offset,
|
|
);
|
|
}
|
|
|
|
void _emitProgress({
|
|
DfuUpdateState? state,
|
|
int? totalBytes,
|
|
int? sentBytes,
|
|
int? expectedOffset,
|
|
int? sessionId,
|
|
DfuUpdateFlags? flags,
|
|
DfuBootloaderStatus? bootloaderStatus,
|
|
String? errorMessage,
|
|
}) {
|
|
final next = DfuUpdateProgress(
|
|
state: state ?? _currentProgress.state,
|
|
totalBytes: totalBytes ?? _currentProgress.totalBytes,
|
|
sentBytes: sentBytes ?? _currentProgress.sentBytes,
|
|
expectedOffset: expectedOffset ?? _currentProgress.expectedOffset,
|
|
sessionId: sessionId ?? _currentProgress.sessionId,
|
|
flags: flags ?? _currentProgress.flags,
|
|
bootloaderStatus: bootloaderStatus ?? _currentProgress.bootloaderStatus,
|
|
errorMessage: errorMessage,
|
|
);
|
|
_currentProgress = next;
|
|
_progressController.add(next);
|
|
}
|
|
|
|
void _requireOkStatus(
|
|
DfuBootloaderStatus status, {
|
|
required int sessionId,
|
|
int? expectedOffset,
|
|
required String operation,
|
|
}) {
|
|
if (!status.isOk) {
|
|
throw _DfuFailure(_statusFailureMessage(status, operation));
|
|
}
|
|
if (status.sessionId != sessionId) {
|
|
throw _DfuFailure(
|
|
'$operation returned status for session ${status.sessionId}, expected $sessionId.',
|
|
);
|
|
}
|
|
if (expectedOffset != null && status.expectedOffset != expectedOffset) {
|
|
throw _DfuFailure(
|
|
'$operation returned expected offset ${status.expectedOffset}, expected $expectedOffset.',
|
|
);
|
|
}
|
|
}
|
|
|
|
String _statusFailureMessage(DfuBootloaderStatus status, String operation) {
|
|
return '$operation failed with bootloader status ${_statusLabel(status)} '
|
|
'(session ${status.sessionId}, expected offset ${status.expectedOffset}).';
|
|
}
|
|
|
|
String _statusLabel(DfuBootloaderStatus status) {
|
|
return switch (status.code) {
|
|
DfuBootloaderStatusCode.ok => 'OK',
|
|
DfuBootloaderStatusCode.parseError => 'parse error',
|
|
DfuBootloaderStatusCode.stateError => 'state error',
|
|
DfuBootloaderStatusCode.boundsError => 'bounds error',
|
|
DfuBootloaderStatusCode.crcError => 'CRC error',
|
|
DfuBootloaderStatusCode.flashError => 'flash error',
|
|
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
|
|
DfuBootloaderStatusCode.vectorError => 'vector table error',
|
|
DfuBootloaderStatusCode.queueFull => 'queue full',
|
|
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
|
|
DfuBootloaderStatusCode.unknown =>
|
|
'unknown status 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
|
};
|
|
}
|
|
|
|
Future<void> _sendAbortForCancel(int sessionId) async {
|
|
final result = await _transport.writeControl(
|
|
BootloaderDfuProtocol.encodeAbortPayload(sessionId),
|
|
);
|
|
if (result.isErr()) {
|
|
_emitProgress(
|
|
errorMessage:
|
|
'Could not send bootloader DFU ABORT during cancel: ${result.unwrapErr()}',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _throwIfCancelled() {
|
|
if (_cancelRequested) {
|
|
throw const _DfuCancelled();
|
|
}
|
|
}
|
|
|
|
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, recoverable: recoverable);
|
|
}
|
|
}
|
|
|
|
void _signalStatusWaiters() {
|
|
final signal = _statusSignal;
|
|
if (signal != null && !signal.isCompleted) {
|
|
signal.complete();
|
|
}
|
|
}
|
|
}
|
|
|
|
abstract interface class FirmwareUpdateTransport {
|
|
Future<Result<bool>> isConnectedToBootloader();
|
|
|
|
Future<Result<void>> enterBootloader();
|
|
|
|
Future<Result<void>> waitForAppDisconnect({required Duration timeout});
|
|
|
|
Future<Result<void>> connectToBootloader({required Duration timeout});
|
|
|
|
Future<Result<void>> optimizeBootloaderConnection();
|
|
|
|
Future<Result<int>> negotiateMtu({required int requestedMtu});
|
|
|
|
Stream<List<int>> subscribeToStatus();
|
|
|
|
Future<Result<List<int>>> readStatus();
|
|
|
|
Future<Result<void>> writeControl(List<int> payload);
|
|
|
|
Future<Result<void>> writeDataFrame(List<int> frame);
|
|
|
|
Future<Result<void>> waitForBootloaderDisconnect({required Duration timeout});
|
|
|
|
Future<Result<void>> reconnectForVerification({required Duration timeout});
|
|
|
|
Future<Result<void>> verifyDeviceReachable({required Duration timeout});
|
|
}
|
|
|
|
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|
ShifterFirmwareUpdateTransport({
|
|
required this.shifterService,
|
|
required this.bluetoothController,
|
|
required this.buttonDeviceId,
|
|
});
|
|
|
|
final ShifterService? shifterService;
|
|
final BluetoothController bluetoothController;
|
|
final String buttonDeviceId;
|
|
|
|
String? _bootloaderDeviceId;
|
|
|
|
@override
|
|
Future<Result<bool>> isConnectedToBootloader() async {
|
|
final currentState = bluetoothController.currentConnectionState;
|
|
_log.info(
|
|
'Checking current connection for bootloader service: '
|
|
'state=${currentState.$1}, device=${currentState.$2}',
|
|
);
|
|
if (currentState.$1 != ConnectionStatus.connected ||
|
|
currentState.$2 == null) {
|
|
return Ok(false);
|
|
}
|
|
|
|
final statusResult = await bluetoothController.readCharacteristic(
|
|
currentState.$2!,
|
|
universalShifterControlServiceUuid,
|
|
universalShifterDfuStatusCharacteristicUuid,
|
|
);
|
|
if (statusResult.isErr()) {
|
|
_log.info(
|
|
'Connected device does not expose bootloader status characteristic: '
|
|
'${statusResult.unwrapErr()}',
|
|
);
|
|
return Ok(false);
|
|
}
|
|
_bootloaderDeviceId = currentState.$2;
|
|
_log.info('Current connection is bootloader: $_bootloaderDeviceId');
|
|
return Ok(true);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> enterBootloader() {
|
|
final shifter = shifterService;
|
|
if (shifter == null) {
|
|
return Future.value(bail('Normal app control service is not available.'));
|
|
}
|
|
return shifter.writeCommand(UniversalShifterCommand.enterDfu);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> waitForAppDisconnect({required Duration timeout}) {
|
|
return _waitForDisconnect(timeout: timeout, label: 'app reset');
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> connectToBootloader({required Duration timeout}) async {
|
|
final currentState = bluetoothController.currentConnectionState;
|
|
_log.info(
|
|
'Transport connectToBootloader: state=${currentState.$1}, '
|
|
'currentDevice=${currentState.$2}, cachedBootloader=$_bootloaderDeviceId, '
|
|
'buttonDevice=$buttonDeviceId',
|
|
);
|
|
if (currentState.$1 == ConnectionStatus.connected &&
|
|
currentState.$2 == _bootloaderDeviceId &&
|
|
_bootloaderDeviceId != null) {
|
|
_log.info('Already connected to cached bootloader $_bootloaderDeviceId');
|
|
return Ok(null);
|
|
}
|
|
|
|
if (shifterService == null) {
|
|
_bootloaderDeviceId = buttonDeviceId;
|
|
_log.info(
|
|
'Recovery mode: connecting directly to bootloader id $buttonDeviceId');
|
|
return bluetoothController.connectById(
|
|
buttonDeviceId,
|
|
timeout: timeout,
|
|
);
|
|
}
|
|
|
|
_log.info('Scanning for bootloader advertisement');
|
|
final scanResult = await _scanForBootloader(timeout: timeout);
|
|
if (scanResult.isErr()) {
|
|
_log.warning('Bootloader scan failed: ${scanResult.unwrapErr()}');
|
|
return Err(scanResult.unwrapErr());
|
|
}
|
|
|
|
final bootloaderDevice = scanResult.unwrap();
|
|
_bootloaderDeviceId = bootloaderDevice.id;
|
|
_log.info(
|
|
'Bootloader advertisement selected: id=${bootloaderDevice.id}, '
|
|
'name=${bootloaderDevice.name}, rssi=${bootloaderDevice.rssi}',
|
|
);
|
|
return bluetoothController.connectById(
|
|
bootloaderDevice.id,
|
|
timeout: timeout,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> optimizeBootloaderConnection() {
|
|
final deviceId = _requireBootloaderDeviceId();
|
|
if (deviceId.isErr()) {
|
|
return Future.value(Err(deviceId.unwrapErr()));
|
|
}
|
|
return bluetoothController.requestHighPerformanceConnection(
|
|
deviceId.unwrap(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<int>> negotiateMtu({required int requestedMtu}) {
|
|
final deviceId = _requireBootloaderDeviceId();
|
|
if (deviceId.isErr()) {
|
|
return Future.value(Err(deviceId.unwrapErr()));
|
|
}
|
|
return bluetoothController.requestMtuAndGetValue(
|
|
deviceId.unwrap(),
|
|
mtu: requestedMtu,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Stream<List<int>> subscribeToStatus() {
|
|
final deviceId = _requireBootloaderDeviceId().unwrap();
|
|
_log.info('Transport subscribeToStatus on bootloader $deviceId');
|
|
return bluetoothController.subscribeToCharacteristic(
|
|
deviceId,
|
|
universalShifterControlServiceUuid,
|
|
universalShifterDfuStatusCharacteristicUuid,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<List<int>>> readStatus() {
|
|
final deviceId = _requireBootloaderDeviceId();
|
|
if (deviceId.isErr()) {
|
|
return Future.value(Err(deviceId.unwrapErr()));
|
|
}
|
|
_log.info('Transport readStatus from bootloader ${deviceId.unwrap()}');
|
|
return bluetoothController.readCharacteristic(
|
|
deviceId.unwrap(),
|
|
universalShifterControlServiceUuid,
|
|
universalShifterDfuStatusCharacteristicUuid,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> writeControl(List<int> payload) {
|
|
final deviceId = _requireBootloaderDeviceId();
|
|
if (deviceId.isErr()) {
|
|
return Future.value(Err(deviceId.unwrapErr()));
|
|
}
|
|
_log.fine(
|
|
'Transport writeControl to ${deviceId.unwrap()}: '
|
|
'opcode=0x${payload.first.toRadixString(16).padLeft(2, '0')}, len=${payload.length}',
|
|
);
|
|
return bluetoothController.writeCharacteristic(
|
|
deviceId.unwrap(),
|
|
universalShifterControlServiceUuid,
|
|
universalShifterDfuControlCharacteristicUuid,
|
|
payload,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> writeDataFrame(List<int> frame) {
|
|
final deviceId = _requireBootloaderDeviceId();
|
|
if (deviceId.isErr()) {
|
|
return Future.value(Err(deviceId.unwrapErr()));
|
|
}
|
|
if (frame.length >= universalShifterBootloaderDfuDataHeaderSizeBytes) {
|
|
_log.fine(
|
|
'Transport writeDataFrame to ${deviceId.unwrap()}: len=${frame.length}');
|
|
}
|
|
return bluetoothController.writeCharacteristic(
|
|
deviceId.unwrap(),
|
|
universalShifterControlServiceUuid,
|
|
universalShifterDfuDataCharacteristicUuid,
|
|
frame,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> waitForBootloaderDisconnect(
|
|
{required Duration timeout}) {
|
|
return _waitForDisconnect(timeout: timeout, label: 'bootloader reset');
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> reconnectForVerification(
|
|
{required Duration timeout}) async {
|
|
_log.info(
|
|
'Transport reconnectForVerification to app id $buttonDeviceId '
|
|
'with timeout ${timeout.inMilliseconds}ms',
|
|
);
|
|
final connectResult =
|
|
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
|
|
if (connectResult.isErr()) {
|
|
_log.warning(
|
|
'Updated app reconnect connectById failed: ${connectResult.unwrapErr()}');
|
|
return Err(connectResult.unwrapErr());
|
|
}
|
|
|
|
final currentState = bluetoothController.currentConnectionState;
|
|
if (currentState.$1 == ConnectionStatus.connected &&
|
|
currentState.$2 == buttonDeviceId) {
|
|
_log.info('Updated app reconnect completed immediately');
|
|
return Ok(null);
|
|
}
|
|
|
|
try {
|
|
await bluetoothController.connectionStateStream
|
|
.firstWhere(
|
|
(state) =>
|
|
state.$1 == ConnectionStatus.connected &&
|
|
state.$2 == buttonDeviceId,
|
|
)
|
|
.timeout(timeout);
|
|
_log.info('Updated app reconnect observed on connection stream');
|
|
return Ok(null);
|
|
} on TimeoutException {
|
|
_log.warning('Timed out waiting for updated app reconnect stream event');
|
|
return bail(
|
|
'Timed out after ${timeout.inMilliseconds}ms waiting for updated app reconnect.',
|
|
);
|
|
} catch (error) {
|
|
_log.warning('Updated app reconnect wait failed: $error');
|
|
return bail('Updated app reconnect wait failed: $error');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> verifyDeviceReachable(
|
|
{required Duration timeout}) async {
|
|
try {
|
|
_log.info(
|
|
'Reading updated app status characteristic for verification '
|
|
'(timeout ${timeout.inMilliseconds}ms)',
|
|
);
|
|
final statusResult = await bluetoothController
|
|
.readCharacteristic(
|
|
buttonDeviceId,
|
|
universalShifterControlServiceUuid,
|
|
universalShifterStatusCharacteristicUuid,
|
|
)
|
|
.timeout(timeout);
|
|
if (statusResult.isErr()) {
|
|
_log.warning(
|
|
'Updated app status verification read failed: ${statusResult.unwrapErr()}');
|
|
return Err(statusResult.unwrapErr());
|
|
}
|
|
CentralStatus.fromBytes(statusResult.unwrap());
|
|
_log.info('Updated app status verification succeeded');
|
|
return Ok(null);
|
|
} on TimeoutException {
|
|
_log.warning('Timed out reading updated app status for verification');
|
|
return bail(
|
|
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
|
|
);
|
|
} catch (error) {
|
|
_log.warning('Post-update verification failed: $error');
|
|
return bail('Post-update verification failed: $error');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> _waitForDisconnect({
|
|
required Duration timeout,
|
|
required String label,
|
|
}) async {
|
|
final currentState = bluetoothController.currentConnectionState;
|
|
_log.fine(
|
|
'Waiting for $label disconnect: currentState=${currentState.$1}, '
|
|
'device=${currentState.$2}, timeout=${timeout.inMilliseconds}ms',
|
|
);
|
|
if (currentState.$1 == ConnectionStatus.disconnected) {
|
|
return Ok(null);
|
|
}
|
|
|
|
try {
|
|
await bluetoothController.connectionStateStream
|
|
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
|
|
.timeout(timeout);
|
|
_log.info('$label disconnect observed');
|
|
return Ok(null);
|
|
} on TimeoutException {
|
|
_log.warning('Timed out waiting for $label disconnect');
|
|
return bail(
|
|
'Timed out after ${timeout.inMilliseconds}ms waiting for $label disconnect.',
|
|
);
|
|
} catch (error) {
|
|
_log.warning('Failed while waiting for $label disconnect: $error');
|
|
return bail('Failed while waiting for $label disconnect: $error');
|
|
}
|
|
}
|
|
|
|
Future<Result<DiscoveredDevice>> _scanForBootloader({
|
|
required Duration timeout,
|
|
}) async {
|
|
final serviceUuid = Uuid.parse(universalShifterControlServiceUuid);
|
|
_log.info(
|
|
'Starting bootloader scan: service=$universalShifterControlServiceUuid, '
|
|
'timeout=${timeout.inMilliseconds}ms',
|
|
);
|
|
final scanResult = await bluetoothController.startScan(
|
|
withServices: [serviceUuid],
|
|
timeout: timeout,
|
|
);
|
|
if (scanResult.isErr()) {
|
|
return Err(scanResult.unwrapErr());
|
|
}
|
|
|
|
try {
|
|
DiscoveredDevice? immediate;
|
|
for (final device in bluetoothController.scanResults) {
|
|
_log.fine(
|
|
'Bootloader scan cached result: id=${device.id}, name=${device.name}, '
|
|
'rssi=${device.rssi}, services=${device.serviceUuids.length}',
|
|
);
|
|
if (_isBootloaderAdvertisement(device)) {
|
|
immediate = device;
|
|
break;
|
|
}
|
|
}
|
|
if (immediate != null) {
|
|
_log.info('Bootloader found in cached scan results: ${immediate.id}');
|
|
return Ok(immediate);
|
|
}
|
|
|
|
final device = await bluetoothController.scanResultsStream
|
|
.expand((devices) => devices)
|
|
.firstWhere(_isBootloaderAdvertisement)
|
|
.timeout(timeout);
|
|
_log.info(
|
|
'Bootloader found from scan stream: id=${device.id}, name=${device.name}, '
|
|
'rssi=${device.rssi}',
|
|
);
|
|
return Ok(device);
|
|
} on TimeoutException {
|
|
_log.warning('Timed out scanning for bootloader advertisement');
|
|
return bail(
|
|
'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.',
|
|
);
|
|
} catch (error) {
|
|
_log.warning('Bootloader scan failed: $error');
|
|
return bail('Bootloader scan failed: $error');
|
|
} finally {
|
|
await bluetoothController.stopScan();
|
|
}
|
|
}
|
|
|
|
bool _isBootloaderAdvertisement(DiscoveredDevice device) {
|
|
final name = device.name.trim();
|
|
if (name == 'US-DFU' || name == 'UniversalShifters DFU') {
|
|
return true;
|
|
}
|
|
return name.toLowerCase().contains('dfu') &&
|
|
device.serviceUuids.any(
|
|
(uuid) =>
|
|
uuid.expanded ==
|
|
Uuid.parse(universalShifterControlServiceUuid).expanded,
|
|
);
|
|
}
|
|
|
|
Result<String> _requireBootloaderDeviceId() {
|
|
final deviceId = _bootloaderDeviceId;
|
|
if (deviceId == null || deviceId.trim().isEmpty) {
|
|
return bail('Bootloader device is not connected yet.');
|
|
}
|
|
return Ok(deviceId);
|
|
}
|
|
}
|
|
|
|
class _DfuFailure implements Exception {
|
|
const _DfuFailure(this.message, {this.recoverable = false});
|
|
|
|
final String message;
|
|
final bool recoverable;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
class _DfuCancelled implements Exception {
|
|
const _DfuCancelled();
|
|
}
|