Files
abawo-bt-app/lib/service/firmware_update_service.dart

1060 lines
33 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;
class FirmwareUpdateService {
FirmwareUpdateService({
required FirmwareUpdateTransport transport,
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 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;
_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();
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()}',
);
}
_emitProgress(state: DfuUpdateState.connectingBootloader);
await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout);
}
final mtuResult =
await _transport.negotiateMtu(requestedMtu: requestedMtu);
if (mtuResult.isErr()) {
throw _DfuFailure(
'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}',
);
}
final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu(
mtuResult.unwrap(),
);
if (payloadSize <= 0) {
throw _DfuFailure(
'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.',
);
}
await _subscribeToStatus();
_emitProgress(state: DfuUpdateState.waitingForStatus);
await _readInitialStatus();
_emitProgress(state: DfuUpdateState.erasing);
final startStatus = await _sendStartAndWaitForStatus(
startPayload,
timeout: effectiveStatusTimeout,
);
_requireOkStatus(
startStatus,
sessionId: normalizedSessionId,
expectedOffset: 0,
operation: 'START',
);
startAccepted = true;
_emitProgress(state: DfuUpdateState.transferring);
await _transferImage(
imageBytes: imageBytes,
sessionId: normalizedSessionId,
payloadSize: payloadSize,
startPayload: startPayload,
statusTimeout: effectiveStatusTimeout,
bootloaderConnectTimeout: effectiveBootloaderConnectTimeout,
);
_emitProgress(state: DfuUpdateState.finishing);
final finishStatus = await _writeControlAndWaitForStatus(
BootloaderDfuProtocol.encodeFinishPayload(normalizedSessionId),
timeout: effectiveStatusTimeout,
);
_requireOkStatus(
finishStatus,
sessionId: normalizedSessionId,
expectedOffset: imageBytes.length,
operation: 'FINISH',
);
await _statusSubscription?.cancel();
_statusSubscription = null;
_emitProgress(
state: DfuUpdateState.rebooting, sentBytes: imageBytes.length);
final resetDisconnectResult =
await _transport.waitForBootloaderDisconnect(
timeout: effectivePostFinishResetTimeout,
);
if (resetDisconnectResult.isErr()) {
throw _DfuFailure(
'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
);
}
_emitProgress(state: DfuUpdateState.verifying);
final reconnectResult = await _transport.reconnectForVerification(
timeout: effectiveReconnectTimeout,
);
if (reconnectResult.isErr()) {
throw _DfuFailure(
'Updated app did not reconnect after bootloader reset: ${reconnectResult.unwrapErr()}',
);
}
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,
);
return Ok(null);
} on _DfuCancelled {
if (startAccepted) {
await _sendAbortForCancel(normalizedSessionId);
}
_emitProgress(state: DfuUpdateState.aborted);
return bail('Firmware update canceled by user.');
} on _DfuFailure catch (failure) {
_emitProgress(
state: DfuUpdateState.failed, errorMessage: failure.message);
return bail(failure.message);
} catch (error) {
final message =
'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.';
_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;
_statusSubscription = _transport.subscribeToStatus().listen(
_handleStatusPayload,
onError: (Object error) {
_statusStreamError =
'Bootloader status indication stream failed: $error. Reconnect and retry the update.';
_signalStatusWaiters();
},
);
}
Future<void> _readInitialStatus() async {
final statusResult = await _transport.readStatus();
if (statusResult.isErr()) {
throw _DfuFailure(
'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}',
);
}
_handleStatusPayload(statusResult.unwrap());
}
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) {
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;
}
_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 {
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()}',
recoverable: recoverable,
);
}
return _waitForStatus(
afterEventCount: eventCount,
timeout: timeout,
recoverable: recoverable,
);
}
Future<DfuBootloaderStatus> _writeDataFrameAndWaitForStatus(
BootloaderDfuDataFrame frame, {
required Duration timeout,
}) async {
final eventCount = _statusEventCount;
final result = await _transport.writeDataFrame(frame.bytes);
if (result.isErr()) {
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<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(recoverable: recoverable);
if (_statusEventCount > observedEvents && _latestStatus != null) {
return _latestStatus!;
}
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
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;
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.';
} 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<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;
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() {
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;
if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == _bootloaderDeviceId &&
_bootloaderDeviceId != null) {
return Ok(null);
}
final scanResult = await _scanForBootloader(timeout: timeout);
if (scanResult.isErr()) {
return Err(scanResult.unwrapErr());
}
final bootloaderDevice = scanResult.unwrap();
_bootloaderDeviceId = bootloaderDevice.id;
return bluetoothController.connectById(
bootloaderDevice.id,
timeout: timeout,
);
}
@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();
return bluetoothController.subscribeToCharacteristic(
deviceId,
universalShifterControlServiceUuid,
universalShifterDfuStatusCharacteristicUuid,
);
}
@override
Future<Result<List<int>>> readStatus() {
final deviceId = _requireBootloaderDeviceId();
if (deviceId.isErr()) {
return Future.value(Err(deviceId.unwrapErr()));
}
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()));
}
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()));
}
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 {
final connectResult =
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
if (connectResult.isErr()) {
return Err(connectResult.unwrapErr());
}
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == buttonDeviceId) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere(
(state) =>
state.$1 == ConnectionStatus.connected &&
state.$2 == buttonDeviceId,
)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for updated app reconnect.',
);
} catch (error) {
return bail('Updated app reconnect wait failed: $error');
}
}
@override
Future<Result<void>> verifyDeviceReachable(
{required Duration timeout}) async {
try {
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(
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
);
} catch (error) {
return bail('Post-update verification failed: $error');
}
}
Future<Result<void>> _waitForDisconnect({
required Duration timeout,
required String label,
}) async {
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.disconnected) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for $label disconnect.',
);
} catch (error) {
return bail('Failed while waiting for $label disconnect: $error');
}
}
Future<Result<DiscoveredDevice>> _scanForBootloader({
required Duration timeout,
}) async {
final serviceUuid = Uuid.parse(universalShifterControlServiceUuid);
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) {
if (_isBootloaderAdvertisement(device)) {
immediate = device;
break;
}
}
if (immediate != null) {
return Ok(immediate);
}
final device = await bluetoothController.scanResultsStream
.expand((devices) => devices)
.firstWhere(_isBootloaderAdvertisement)
.timeout(timeout);
return Ok(device);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.',
);
} catch (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();
}