Compare commits

..

10 Commits

13 changed files with 1570 additions and 278 deletions

View File

@ -363,6 +363,27 @@ class BluetoothController {
} }
} }
Future<Result<void>> requestHighPerformanceConnection(
String deviceId,
) async {
if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null);
}
try {
await _ble.requestConnectionPriority(
deviceId: deviceId,
priority: ConnectionPriority.highPerformance,
);
log.info('High-performance BLE connection requested for $deviceId');
return Ok(null);
} catch (e) {
return bail(
'Error requesting high-performance BLE connection for $deviceId: $e',
);
}
}
Future<Result<void>> _requestInitialMtu(String deviceId) async { Future<Result<void>> _requestInitialMtu(String deviceId) async {
if (defaultTargetPlatform != TargetPlatform.android) { if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null); return Ok(null);

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
import 'package:abawo_bt_app/pages/devices_page.dart'; import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/pages/devices_tab_page.dart'; import 'package:abawo_bt_app/pages/devices_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart';
@ -125,6 +126,18 @@ final _router = GoRouter(
return DeviceDetailsPage(deviceAddress: deviceAddress); return DeviceDetailsPage(deviceAddress: deviceAddress);
}, },
), ),
GoRoute(
path: '/bootloader_recovery_update',
builder: (context, state) {
final args = state.extra;
if (args is! BootloaderRecoveryUpdateArgs) {
return const Scaffold(
body: Center(child: Text('Missing bootloader recovery data.')),
);
}
return BootloaderRecoveryUpdatePage(args: args);
},
),
], ],
); );

View File

@ -45,7 +45,7 @@ const int universalShifterBootloaderDfuMaxPayloadSizeBytes =
universalShifterBootloaderDfuDataHeaderSizeBytes; universalShifterBootloaderDfuDataHeaderSizeBytes;
const int universalShifterBootloaderDfuStatusSizeBytes = 6; const int universalShifterBootloaderDfuStatusSizeBytes = 6;
const int universalShifterAttWriteOverheadBytes = 3; const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuPreferredMtu = 131;
const int universalShifterDfuAppStart = 0x00030000; const int universalShifterDfuAppStart = 0x00030000;
const int universalShifterDfuAppSlotSizeBytes = 0x0003F000; const int universalShifterDfuAppSlotSizeBytes = 0x0003F000;
@ -57,6 +57,9 @@ const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagSigned = 0x02;
const int universalShifterDfuFlagNone = 0x00; const int universalShifterDfuFlagNone = 0x00;
const String universalShifterBootMetadataWarningMessage =
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support';
const int errorSequence = 1; const int errorSequence = 1;
const int errorFtmsMissing = 2; const int errorFtmsMissing = 2;
const int errorPairingAuth = 3; const int errorPairingAuth = 3;

View File

@ -0,0 +1,251 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class BootloaderRecoveryUpdateArgs {
const BootloaderRecoveryUpdateArgs({
required this.bootloaderDeviceId,
required this.firmware,
});
final String bootloaderDeviceId;
final BootloaderDfuPreparedFirmware firmware;
}
class BootloaderRecoveryUpdatePage extends ConsumerStatefulWidget {
const BootloaderRecoveryUpdatePage({
required this.args,
super.key,
});
final BootloaderRecoveryUpdateArgs args;
@override
ConsumerState<BootloaderRecoveryUpdatePage> createState() =>
_BootloaderRecoveryUpdatePageState();
}
class _BootloaderRecoveryUpdatePageState
extends ConsumerState<BootloaderRecoveryUpdatePage> {
FirmwareUpdateService? _firmwareUpdateService;
BluetoothController? _bluetooth;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
String? _firmwareUserMessage = 'Preparing US-DFU recovery update...';
bool _hasStarted = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_startUpdate());
}
});
}
@override
void dispose() {
final service = _firmwareUpdateService;
final bluetooth = _bluetooth;
_firmwareUpdateService = null;
unawaited(_firmwareProgressSubscription?.cancel());
unawaited(() async {
await service?.dispose();
await _disconnectBootloaderIfStillConnected(
bluetooth: bluetooth,
allowRefRead: false,
);
}());
super.dispose();
}
Future<void> _disconnectBootloaderIfStillConnected({
BluetoothController? bluetooth,
bool allowRefRead = true,
}) async {
if (bluetooth == null && allowRefRead) {
bluetooth = ref.read(bluetoothProvider).valueOrNull;
}
if (bluetooth == null) {
return;
}
final currentState = bluetooth.currentConnectionState;
if (currentState.$2 != widget.args.bootloaderDeviceId ||
(currentState.$1 != ConnectionStatus.connected &&
currentState.$1 != ConnectionStatus.connecting)) {
return;
}
await bluetooth.disconnect();
}
Future<void> _dismissToDevices() async {
await _disconnectBootloaderIfStillConnected();
if (!mounted) {
return;
}
context.go('/devices');
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
if (bluetooth == null) {
return null;
}
_bluetooth = bluetooth;
final service = FirmwareUpdateService(
verifyAfterFinish: false,
transport: ShifterFirmwareUpdateTransport(
shifterService: null,
bluetoothController: bluetooth,
buttonDeviceId: widget.args.bootloaderDeviceId,
),
);
_firmwareProgressSubscription = service.progressStream.listen((progress) {
if (!mounted) {
return;
}
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
} else if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The bootloader accepted FINISH and reset; reconnect to the device when it starts advertising again.';
} else if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
} else if (progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
}
});
});
_firmwareUpdateService = service;
return service;
}
Future<void> _startUpdate() async {
if (_hasStarted) {
return;
}
_hasStarted = true;
final updater = await _ensureFirmwareUpdateService();
if (!mounted) {
return;
}
if (updater == null) {
setState(() {
_dfuProgress = DfuUpdateProgress(
state: DfuUpdateState.failed,
totalBytes: widget.args.firmware.fileBytes.length,
sentBytes: 0,
expectedOffset: 0,
sessionId: widget.args.firmware.metadata.sessionId,
flags: DfuUpdateFlags.fromRaw(widget.args.firmware.metadata.flags),
errorMessage:
'Firmware updater is not ready. Reconnect to US-DFU and retry.',
);
_firmwareUserMessage = _dfuProgress.errorMessage;
});
return;
}
final firmware = widget.args.firmware;
final result = await updater.startUpdate(
imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId,
appStart: firmware.metadata.appStart,
imageVersion: firmware.metadata.imageVersion,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
);
if (!mounted || result.isOk()) {
return;
}
await _disconnectBootloaderIfStillConnected();
if (!mounted) {
return;
}
final errorMessage = result.unwrapErr().toString();
setState(() {
_firmwareUserMessage = errorMessage;
});
if (errorMessage.startsWith(universalShifterBootMetadataWarningMessage)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
),
),
);
}
}
String _dfuPhaseText(DfuUpdateState state) {
return switch (state) {
DfuUpdateState.idle => 'Preparing recovery update',
DfuUpdateState.starting => 'Preparing update',
DfuUpdateState.enteringBootloader => 'Checking bootloader mode',
DfuUpdateState.connectingBootloader => 'Connecting to bootloader',
DfuUpdateState.waitingForStatus => 'Waiting for bootloader status',
DfuUpdateState.erasing => 'Starting destructive bootloader update',
DfuUpdateState.transferring => 'Transferring firmware image',
DfuUpdateState.finishing => 'Finalizing bootloader update',
DfuUpdateState.rebooting => 'Waiting for bootloader reset',
DfuUpdateState.verifying => 'Verifying updated app',
DfuUpdateState.completed => 'Update completed',
DfuUpdateState.aborted => 'Update canceled',
DfuUpdateState.failed => 'Update failed',
};
}
String _formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
}
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
@override
Widget build(BuildContext context) {
return FirmwareUpdateFullscreen(
progress: _dfuProgress,
selectedFirmware: widget.args.firmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
doneLabel: 'Back to devices',
failedLabel: 'Back to devices',
onDismiss: () => unawaited(_dismissToDevices()),
);
}
}

View File

@ -8,15 +8,19 @@ import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.dart'; import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart'; import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart'; import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart'; import '../controller/bluetooth.dart';
import '../database/database.dart'; import '../database/database.dart';
final _log = Logger('DeviceDetailsPage');
class DeviceDetailsPage extends ConsumerStatefulWidget { class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({ const DeviceDetailsPage({
required this.deviceAddress, required this.deviceAddress,
@ -62,6 +66,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
bool _isPairingCheckRunning = false; bool _isPairingCheckRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>? ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription; _connectionStatusSubscription;
BluetoothController? _bluetooth;
ShifterService? _shifterService; ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription; StreamSubscription<CentralStatus>? _statusSubscription;
@ -138,7 +143,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override @override
void dispose() { void dispose() {
unawaited(_disconnectOnClose()); _log.info(
'Disposing device details page for ${widget.deviceAddress}; '
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
);
final bluetooth = _bluetooth;
unawaited(
_disconnectOnClose(bluetooth: bluetooth, allowRefRead: false),
);
_connectionStatusSubscription?.close(); _connectionStatusSubscription?.close();
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_shifterService?.dispose(); _shifterService?.dispose();
@ -147,22 +159,33 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
super.dispose(); super.dispose();
} }
Future<void> _disconnectOnClose() async { Future<void> _disconnectOnClose({
BluetoothController? bluetooth,
bool allowRefRead = true,
}) async {
if (_isFirmwareUpdateBusy) { if (_isFirmwareUpdateBusy) {
_log.info('Skipping disconnect on close because firmware update is busy');
return; return;
} }
if (_hasRequestedDisconnect) { if (_hasRequestedDisconnect) {
_log.fine('Disconnect on close already requested');
return; return;
} }
_hasRequestedDisconnect = true; _hasRequestedDisconnect = true;
_isExitingPage = true; _isExitingPage = true;
final bluetoothController = bluetooth ??
_bluetooth ??
(allowRefRead ? ref.read(bluetoothProvider).value : null);
if (bluetoothController != null) {
_bluetooth = bluetoothController;
}
await _disposeFirmwareUpdateService(); await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value; await bluetoothController?.disconnect();
await bluetooth?.disconnect();
await _stopStatusStreaming(); await _stopStatusStreaming();
} }
@ -173,6 +196,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final (status, connectedDeviceId) = data; final (status, connectedDeviceId) = data;
final isCurrentDevice = connectedDeviceId == widget.deviceAddress; final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
if (_isFirmwareUpdateBusy || _dfuProgress.state != DfuUpdateState.idle) {
_log.info(
'Connection update during firmware flow: status=$status, '
'connectedDevice=$connectedDeviceId, expected=${widget.deviceAddress}, '
'isCurrentDevice=$isCurrentDevice, dfuState=${_dfuProgress.state}',
);
}
if (isCurrentDevice && status == ConnectionStatus.connected) { if (isCurrentDevice && status == ConnectionStatus.connected) {
_startStatusStreamingIfNeeded(); _startStatusStreamingIfNeeded();
@ -194,6 +224,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (_shifterService != null) { if (_shifterService != null) {
final bluetooth = ref.read(bluetoothProvider).value; final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth != null) {
_bluetooth = bluetooth;
}
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) { if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
return; return;
} }
@ -222,6 +255,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} else { } else {
bluetooth = await ref.read(bluetoothProvider.future); bluetooth = await ref.read(bluetoothProvider.future);
} }
_bluetooth = bluetooth;
if (!isCurrentDeviceConnected(bluetooth)) { if (!isCurrentDeviceConnected(bluetooth)) {
return; return;
} }
@ -247,6 +281,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
final bluetooth = ref.read(bluetoothProvider).value; final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth != null) {
_bluetooth = bluetooth;
}
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) { if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
break; break;
} }
@ -506,6 +543,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (bluetooth == null) { if (bluetooth == null) {
return null; return null;
} }
_bluetooth = bluetooth;
final service = FirmwareUpdateService( final service = FirmwareUpdateService(
transport: ShifterFirmwareUpdateTransport( transport: ShifterFirmwareUpdateTransport(
@ -519,6 +557,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
_log.info(
'Firmware progress: state=${progress.state}, '
'sent=${progress.sentBytes}/${progress.totalBytes}, '
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
);
setState(() { setState(() {
_dfuProgress = progress; _dfuProgress = progress;
if (progress.state == DfuUpdateState.failed && if (progress.state == DfuUpdateState.failed &&
@ -629,6 +672,19 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
}); });
if (result.isErr() &&
result.unwrapErr().toString().startsWith(
universalShifterBootMetadataWarningMessage,
)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
),
),
);
}
if (result.isOk()) { if (result.isOk()) {
_hasLoadedDeviceTelemetry = false; _hasLoadedDeviceTelemetry = false;
unawaited(_loadDeviceTelemetry(force: true)); unawaited(_loadDeviceTelemetry(force: true));
@ -687,6 +743,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
try { try {
final bluetooth = await ref.read(bluetoothProvider.future); final bluetooth = await ref.read(bluetoothProvider.future);
_bluetooth = bluetooth;
final result = await bluetooth.connectById( final result = await bluetooth.connectById(
widget.deviceAddress, widget.deviceAddress,
timeout: const Duration(seconds: 10), timeout: const Duration(seconds: 10),
@ -697,6 +754,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
if (result.isErr()) { if (result.isErr()) {
if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -743,6 +805,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _exitPage() async { Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) { if (_isFirmwareUpdateBusy) {
_log.warning('Blocked page exit while firmware update is busy');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
@ -752,6 +815,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; return;
} }
_log.info('Exiting device details page to /devices');
await _disconnectOnClose(); await _disconnectOnClose();
if (!mounted) { if (!mounted) {
return; return;
@ -760,6 +824,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
void _dismissFirmwareFullscreen() { void _dismissFirmwareFullscreen() {
_log.info(
'Dismissing firmware fullscreen from state ${_dfuProgress.state}');
setState(() { setState(() {
_dfuProgress = const DfuUpdateProgress( _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle, state: DfuUpdateState.idle,
@ -904,7 +970,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
(_dfuProgress.state != DfuUpdateState.idle && (_dfuProgress.state != DfuUpdateState.idle &&
_dfuProgress.state != DfuUpdateState.completed && _dfuProgress.state != DfuUpdateState.completed &&
_dfuProgress.state != DfuUpdateState.failed)) { _dfuProgress.state != DfuUpdateState.failed)) {
return _FirmwareUpdateFullscreen( return FirmwareUpdateFullscreen(
progress: _dfuProgress, progress: _dfuProgress,
selectedFirmware: _selectedFirmware, selectedFirmware: _selectedFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state), phaseText: _dfuPhaseText(_dfuProgress.state),
@ -1956,244 +2022,3 @@ class _OverviewMetricTile extends StatelessWidget {
); );
} }
} }
class _FirmwareUpdateFullscreen extends StatelessWidget {
const _FirmwareUpdateFullscreen({
required this.progress,
required this.selectedFirmware,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.expectedOffsetHex,
required this.onDismiss,
});
final DfuUpdateProgress progress;
final BootloaderDfuPreparedFirmware? selectedFirmware;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String expectedOffsetHex;
final VoidCallback onDismiss;
bool get _isTerminal =>
progress.state == DfuUpdateState.completed ||
progress.state == DfuUpdateState.failed;
bool get _isRunning => !_isTerminal && progress.state != DfuUpdateState.idle;
String? get _bootloaderStatusText {
final status = progress.bootloaderStatus;
if (status == null) {
return null;
}
final codeLabel = 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 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
};
return '$codeLabel • session ${status.sessionId} • offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFailed = progress.state == DfuUpdateState.failed;
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
if (_isRunning)
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
color: colorScheme.errorContainer,
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
color: colorScheme.onErrorContainer, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Do not close the app, lock the phone, or move away from the button.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
_isTerminal
? (isFailed
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded)
: Icons.system_update_alt_rounded,
size: 56,
color: _isTerminal
? (isFailed
? colorScheme.error
: colorScheme.primary)
: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
_isTerminal
? (isFailed ? 'Update failed' : 'Update completed')
: 'Updating firmware',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
phaseText,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 12),
Text(
'${selectedFirmware!.fileName}${_formatBytes(selectedFirmware!.fileBytes.length)}',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
if (_isRunning) ...[
LinearProgressIndicator(
value: progress.totalBytes > 0
? progress.fractionComplete
: null,
minHeight: 12,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 12),
Text(
'${progress.percentComplete}% • $formattedProgressBytes',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
if (progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withValues(alpha: 0.36),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bootloader is verifying, resetting, and booting the new app. Keep the screen open.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 12),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.56),
),
),
],
if (statusText != null &&
statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Text(
statusText!,
style: theme.textTheme.bodyMedium?.copyWith(
color: isFailed
? colorScheme.onErrorContainer
: null,
),
),
),
],
if (_isTerminal) ...[
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onDismiss,
icon: Icon(isFailed
? Icons.arrow_back_rounded
: Icons.check_rounded),
label: Text(
isFailed ? 'Back to device' : 'Done',
),
),
),
],
],
),
),
),
],
),
),
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
}

View File

@ -116,12 +116,11 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
} }
break; break;
case Err(:final v): case Err(:final v):
final error = v.toString(); if (isBluetoothPairingRecoveryError(v)) {
if (error.toLowerCase().contains('disconnected')) {
await showBluetoothPairingRecoveryDialog(context); await showBluetoothPairingRecoveryDialog(context);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection unsuccessful:\n$error')), SnackBar(content: Text('Connection unsuccessful:\n$v')),
); );
} }
break; break;

View File

@ -1,18 +1,176 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/database/database.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice, ScanMode, Uuid;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class DevicesTabPage extends ConsumerWidget { class DevicesTabPage extends ConsumerStatefulWidget {
const DevicesTabPage({super.key}); const DevicesTabPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DevicesTabPage> createState() => _DevicesTabPageState();
}
class _DevicesTabPageState extends ConsumerState<DevicesTabPage> {
static const Duration _bootloaderScanTimeout = Duration(seconds: 10);
StreamSubscription<List<DiscoveredDevice>>? _scanSubscription;
BluetoothController? _bluetooth;
DiscoveredDevice? _dfuDevice;
bool _isBootloaderScanStarting = false;
bool _isDisposed = false;
@override
void initState() {
super.initState();
unawaited(_startBootloaderBackgroundScan());
}
@override
void dispose() {
_isDisposed = true;
final bluetooth = _bluetooth;
unawaited(_stopBootloaderScan(bluetooth));
super.dispose();
}
Future<void> _startBootloaderBackgroundScan() async {
if (_isBootloaderScanStarting || _scanSubscription != null) {
return;
}
_isBootloaderScanStarting = true;
_clearBootloaderDevice();
try {
final bluetooth = await ref.read(bluetoothProvider.future);
if (!mounted || _isDisposed) {
return;
}
_bluetooth = bluetooth;
final scanResult = await bluetooth.startScan(
timeout: _bootloaderScanTimeout,
scanMode: ScanMode.lowLatency,
);
if (!mounted || _isDisposed) {
await bluetooth.stopScan();
return;
}
if (scanResult.isErr()) {
return;
}
_updateBootloaderDevice(bluetooth.scanResults);
_scanSubscription = bluetooth.scanResultsStream.listen(
_updateBootloaderDevice,
);
} finally {
_isBootloaderScanStarting = false;
}
}
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
final subscription = _scanSubscription;
_scanSubscription = null;
await subscription?.cancel();
await bluetooth?.stopScan();
}
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
if (_isDisposed || !mounted) {
return;
}
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
(device) => device != null && _isBootloaderAdvertisement(device),
orElse: () => null,
);
if (dfuDevice == null) {
_clearBootloaderDevice();
return;
}
if (dfuDevice.id == _dfuDevice?.id) {
return;
}
setState(() {
_dfuDevice = dfuDevice;
});
}
void _clearBootloaderDevice() {
if (_isDisposed || !mounted || _dfuDevice == null) {
return;
}
setState(() {
_dfuDevice = null;
});
}
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,
);
}
Future<void> _openBootloaderRecovery() async {
final device = _dfuDevice;
if (device == null) {
return;
}
final firmware =
await Navigator.of(context).push<BootloaderDfuPreparedFirmware>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _BootloaderRecoverySetupPage(device: device),
),
);
if (!mounted || _isDisposed || firmware == null) {
return;
}
await _stopBootloaderScan();
if (!mounted || _isDisposed) {
return;
}
_clearBootloaderDevice();
context.push(
'/bootloader_recovery_update',
extra: BootloaderRecoveryUpdateArgs(
bootloaderDeviceId: device.id,
firmware: firmware,
),
);
}
@override
Widget build(BuildContext context) {
final devicesAsync = ref.watch(nConnectedDevicesProvider); final devicesAsync = ref.watch(nConnectedDevicesProvider);
final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final dfuDevice = _dfuDevice;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
@ -50,6 +208,13 @@ class DevicesTabPage extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (dfuDevice != null) ...[
_BootloaderRecoveryCard(
device: dfuDevice,
onStartRecovery: _openBootloaderRecovery,
),
const SizedBox(height: 20),
],
devicesAsync.when( devicesAsync.when(
loading: () => const _LoadingCard(), loading: () => const _LoadingCard(),
error: (error, _) => _MessageCard( error: (error, _) => _MessageCard(
@ -86,6 +251,259 @@ class DevicesTabPage extends ConsumerWidget {
} }
} }
class _BootloaderRecoveryCard extends StatelessWidget {
const _BootloaderRecoveryCard({
required this.device,
required this.onStartRecovery,
});
final DiscoveredDevice device;
final VoidCallback onStartRecovery;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.errorContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.system_update_alt, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'US-DFU Device Detected',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
const Text(
'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?',
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
device.name.isEmpty ? device.id : '${device.name} - ${device.id}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onStartRecovery,
icon: const Icon(Icons.build_circle_outlined),
label: const Text('Start Recovery'),
),
],
),
),
);
}
}
class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget {
const _BootloaderRecoverySetupPage({required this.device});
final DiscoveredDevice device;
@override
ConsumerState<_BootloaderRecoverySetupPage> createState() =>
_BootloaderRecoverySetupPageState();
}
class _BootloaderRecoverySetupPageState
extends ConsumerState<_BootloaderRecoverySetupPage> {
final FirmwareFileSelectionService _firmwareFileSelectionService =
FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker());
BootloaderDfuPreparedFirmware? _selectedFirmware;
bool _isSelectingFirmware = false;
String? _message;
Future<void> _selectFirmwareFile() async {
if (_isSelectingFirmware) {
return;
}
setState(() {
_isSelectingFirmware = true;
_message = null;
});
final suppressionCount = ref.read(
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
);
suppressionCount.state += 1;
final FirmwareFileSelectionResult result;
try {
result =
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
} finally {
suppressionCount.state =
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
}
if (!mounted) {
return;
}
setState(() {
_isSelectingFirmware = false;
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_message =
'Validated ${result.firmware!.fileName}. Ready to start recovery.';
} else if (!result.isCanceled) {
_message = result.failure?.message;
}
});
}
void _startRecovery() {
final firmware = _selectedFirmware;
if (firmware == null) {
setState(() {
_message = 'Select a firmware .bin file before starting recovery.';
});
return;
}
Navigator.of(context).pop(firmware);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final selectedFirmware = _selectedFirmware;
return Scaffold(
appBar: AppBar(
title: const Text('US-DFU Recovery'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.system_update_alt_rounded,
color: colorScheme.primary),
const SizedBox(width: 10),
Text(
'Recover Firmware Update',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
Text(
'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 14),
Text(
widget.device.name.isEmpty
? widget.device.id
: '${widget.device.name} - ${widget.device.id}',
style: theme.textTheme.bodySmall,
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedFirmware == null
? 'Selected file: none'
: 'Selected file: ${selectedFirmware.fileName}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 6),
Text(
'Size: ${selectedFirmware.fileBytes.length} bytes | Session: ${selectedFirmware.metadata.sessionId} | CRC32: 0x${selectedFirmware.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed:
_isSelectingFirmware ? null : _selectFirmwareFile,
icon: _isSelectingFirmware
? const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: const Text('Select Firmware'),
),
FilledButton.icon(
onPressed:
selectedFirmware == null ? null : _startRecovery,
icon: const Icon(Icons.system_update_alt),
label: const Text('Start Update'),
),
],
),
if (_message != null && _message!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(_message!),
],
],
),
),
),
],
),
),
);
}
}
class _SavedDevicesList extends ConsumerStatefulWidget { class _SavedDevicesList extends ConsumerStatefulWidget {
const _SavedDevicesList(); const _SavedDevicesList();
@ -178,6 +596,8 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
} }
context.push('/device/${device.deviceAddress}'); context.push('/device/${device.deviceAddress}');
} else if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@ -30,7 +30,6 @@ class HomePage extends StatelessWidget {
style: TextStyle(fontSize: 20), style: TextStyle(fontSize: 20),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Devices Section
Container( Container(
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.only(left: 16, right: 16),
child: Column( child: Column(
@ -84,7 +83,7 @@ class DevicesList extends ConsumerStatefulWidget {
} }
class _DevicesListState extends ConsumerState<DevicesList> { class _DevicesListState extends ConsumerState<DevicesList> {
String? _connectingDeviceId; // ID of device currently being connected String? _connectingDeviceId;
Future<void> _removeDevice(ConnectedDevice device) async { Future<void> _removeDevice(ConnectedDevice device) async {
final shouldRemove = await showDialog<bool>( final shouldRemove = await showDialog<bool>(
@ -197,10 +196,10 @@ class _DevicesListState extends ConsumerState<DevicesList> {
context.go('/device/${device.deviceAddress}'); context.go('/device/${device.deviceAddress}');
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text(
'Connection failed. Is the device turned on and in range?'), 'Connection failed. Is the device turned on and in range?'),
duration: const Duration(seconds: 3), duration: Duration(seconds: 3),
), ),
); );
} }

View File

@ -7,10 +7,14 @@ import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:anyhow/anyhow.dart'; import 'package:anyhow/anyhow.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice, Uuid; show DiscoveredDevice, Uuid;
import 'package:logging/logging.dart';
final _log = Logger('FirmwareUpdateService');
class FirmwareUpdateService { class FirmwareUpdateService {
FirmwareUpdateService({ FirmwareUpdateService({
required FirmwareUpdateTransport transport, required FirmwareUpdateTransport transport,
this.verifyAfterFinish = true,
this.defaultStatusTimeout = const Duration(seconds: 2), this.defaultStatusTimeout = const Duration(seconds: 2),
this.defaultBootloaderConnectTimeout = const Duration(seconds: 60), this.defaultBootloaderConnectTimeout = const Duration(seconds: 60),
this.defaultPostFinishResetTimeout = const Duration(seconds: 8), this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
@ -22,6 +26,7 @@ class FirmwareUpdateService {
}) : _transport = transport; }) : _transport = transport;
final FirmwareUpdateTransport _transport; final FirmwareUpdateTransport _transport;
final bool verifyAfterFinish;
final Duration defaultStatusTimeout; final Duration defaultStatusTimeout;
final Duration defaultBootloaderConnectTimeout; final Duration defaultBootloaderConnectTimeout;
final Duration defaultPostFinishResetTimeout; final Duration defaultPostFinishResetTimeout;
@ -114,6 +119,14 @@ class FirmwareUpdateService {
var startAccepted = false; 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( _emitProgress(
state: DfuUpdateState.starting, state: DfuUpdateState.starting,
totalBytes: imageBytes.length, totalBytes: imageBytes.length,
@ -128,8 +141,17 @@ class FirmwareUpdateService {
_throwIfCancelled(); _throwIfCancelled();
_emitProgress(state: DfuUpdateState.enteringBootloader); _emitProgress(state: DfuUpdateState.enteringBootloader);
final alreadyInBootloader = await _isConnectedToBootloader(); final alreadyInBootloader = await _isConnectedToBootloader();
_log.info(
'Bootloader connection check: alreadyInBootloader=$alreadyInBootloader');
if (!alreadyInBootloader) { if (!alreadyInBootloader) {
_log.info('Requesting app to enter bootloader mode');
final enterResult = await _transport.enterBootloader(); 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( final appDisconnectResult = await _transport.waitForAppDisconnect(
timeout: effectiveBootloaderConnectTimeout, timeout: effectiveBootloaderConnectTimeout,
); );
@ -144,10 +166,15 @@ class FirmwareUpdateService {
); );
} }
_log.info('App disconnected for bootloader mode');
_emitProgress(state: DfuUpdateState.connectingBootloader); _emitProgress(state: DfuUpdateState.connectingBootloader);
await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout); await _connectToBootloader(timeout: effectiveBootloaderConnectTimeout);
} }
await _optimizeBootloaderConnection();
_log.info('Negotiating bootloader MTU: requested=$requestedMtu');
final mtuResult = final mtuResult =
await _transport.negotiateMtu(requestedMtu: requestedMtu); await _transport.negotiateMtu(requestedMtu: requestedMtu);
if (mtuResult.isErr()) { if (mtuResult.isErr()) {
@ -155,6 +182,7 @@ class FirmwareUpdateService {
'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}', 'Could not negotiate bootloader DFU MTU: ${mtuResult.unwrapErr()}',
); );
} }
_log.info('Bootloader MTU negotiated: ${mtuResult.unwrap()}');
final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu( final payloadSize = BootloaderDfuProtocol.maxPayloadSizeForMtu(
mtuResult.unwrap(), mtuResult.unwrap(),
); );
@ -163,12 +191,14 @@ class FirmwareUpdateService {
'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.', 'Negotiated MTU ${mtuResult.unwrap()} is too small for bootloader DFU frames.',
); );
} }
_log.info('Bootloader DFU payload size selected: $payloadSize bytes');
await _subscribeToStatus(); await _subscribeToStatus();
_emitProgress(state: DfuUpdateState.waitingForStatus); _emitProgress(state: DfuUpdateState.waitingForStatus);
await _readInitialStatus(); await _readInitialStatus();
_emitProgress(state: DfuUpdateState.erasing); _emitProgress(state: DfuUpdateState.erasing);
_log.info('Sending START command');
final startStatus = await _sendStartAndWaitForStatus( final startStatus = await _sendStartAndWaitForStatus(
startPayload, startPayload,
timeout: effectiveStatusTimeout, timeout: effectiveStatusTimeout,
@ -180,8 +210,13 @@ class FirmwareUpdateService {
operation: 'START', operation: 'START',
); );
startAccepted = true; startAccepted = true;
_log.info(
'START accepted: session=${startStatus.sessionId}, '
'expectedOffset=${startStatus.expectedOffset}',
);
_emitProgress(state: DfuUpdateState.transferring); _emitProgress(state: DfuUpdateState.transferring);
_log.info('Starting firmware transfer');
await _transferImage( await _transferImage(
imageBytes: imageBytes, imageBytes: imageBytes,
sessionId: normalizedSessionId, sessionId: normalizedSessionId,
@ -190,17 +225,14 @@ class FirmwareUpdateService {
statusTimeout: effectiveStatusTimeout, statusTimeout: effectiveStatusTimeout,
bootloaderConnectTimeout: effectiveBootloaderConnectTimeout, bootloaderConnectTimeout: effectiveBootloaderConnectTimeout,
); );
_log.info('Firmware transfer completed; sending FINISH');
_emitProgress(state: DfuUpdateState.finishing); _emitProgress(state: DfuUpdateState.finishing);
final finishStatus = await _writeControlAndWaitForStatus( await _writeFinishAndWaitForReset(
BootloaderDfuProtocol.encodeFinishPayload(normalizedSessionId),
timeout: effectiveStatusTimeout,
);
_requireOkStatus(
finishStatus,
sessionId: normalizedSessionId, sessionId: normalizedSessionId,
expectedOffset: imageBytes.length, expectedOffset: imageBytes.length,
operation: 'FINISH', statusTimeout: effectiveStatusTimeout,
resetTimeout: effectivePostFinishResetTimeout,
); );
await _statusSubscription?.cancel(); await _statusSubscription?.cancel();
@ -208,17 +240,19 @@ class FirmwareUpdateService {
_emitProgress( _emitProgress(
state: DfuUpdateState.rebooting, sentBytes: imageBytes.length); state: DfuUpdateState.rebooting, sentBytes: imageBytes.length);
final resetDisconnectResult = _log.info('Bootloader reset observed after FINISH');
await _transport.waitForBootloaderDisconnect(
timeout: effectivePostFinishResetTimeout, if (!verifyAfterFinish) {
); _emitProgress(
if (resetDisconnectResult.isErr()) { state: DfuUpdateState.completed,
throw _DfuFailure( sentBytes: imageBytes.length,
'Bootloader did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}', expectedOffset: imageBytes.length,
); );
return Ok(null);
} }
_emitProgress(state: DfuUpdateState.verifying); _emitProgress(state: DfuUpdateState.verifying);
_log.info('Reconnecting to updated app for verification');
final reconnectResult = await _transport.reconnectForVerification( final reconnectResult = await _transport.reconnectForVerification(
timeout: effectiveReconnectTimeout, timeout: effectiveReconnectTimeout,
); );
@ -228,6 +262,7 @@ class FirmwareUpdateService {
); );
} }
_log.info('Updated app reconnected; verifying status characteristic');
final verificationResult = await _transport.verifyDeviceReachable( final verificationResult = await _transport.verifyDeviceReachable(
timeout: effectiveVerificationTimeout, timeout: effectiveVerificationTimeout,
); );
@ -242,20 +277,24 @@ class FirmwareUpdateService {
sentBytes: imageBytes.length, sentBytes: imageBytes.length,
expectedOffset: imageBytes.length, expectedOffset: imageBytes.length,
); );
_log.info('Firmware update completed successfully');
return Ok(null); return Ok(null);
} on _DfuCancelled { } on _DfuCancelled {
_log.warning('Firmware update canceled by user');
if (startAccepted) { if (startAccepted) {
await _sendAbortForCancel(normalizedSessionId); await _sendAbortForCancel(normalizedSessionId);
} }
_emitProgress(state: DfuUpdateState.aborted); _emitProgress(state: DfuUpdateState.aborted);
return bail('Firmware update canceled by user.'); return bail('Firmware update canceled by user.');
} on _DfuFailure catch (failure) { } on _DfuFailure catch (failure) {
_log.severe('Firmware update failed: ${failure.message}');
_emitProgress( _emitProgress(
state: DfuUpdateState.failed, errorMessage: failure.message); state: DfuUpdateState.failed, errorMessage: failure.message);
return bail(failure.message); return bail(failure.message);
} catch (error) { } catch (error, stackTrace) {
final message = final message =
'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.'; 'Firmware update failed unexpectedly: $error. Reconnect to the button or bootloader and retry.';
_log.severe(message, error, stackTrace);
_emitProgress(state: DfuUpdateState.failed, errorMessage: message); _emitProgress(state: DfuUpdateState.failed, errorMessage: message);
return bail(message); return bail(message);
} finally { } finally {
@ -291,24 +330,33 @@ class FirmwareUpdateService {
Future<void> _subscribeToStatus() async { Future<void> _subscribeToStatus() async {
await _statusSubscription?.cancel(); await _statusSubscription?.cancel();
_statusStreamError = null; _statusStreamError = null;
_log.info('Subscribing to bootloader DFU status indications');
_statusSubscription = _transport.subscribeToStatus().listen( _statusSubscription = _transport.subscribeToStatus().listen(
_handleStatusPayload, _handleStatusPayload,
onError: (Object error) { onError: (Object error) {
_statusStreamError = _statusStreamError =
'Bootloader status indication stream failed: $error. Reconnect and retry the update.'; 'Bootloader status indication stream failed: $error. Reconnect and retry the update.';
_log.severe(_statusStreamError);
_signalStatusWaiters(); _signalStatusWaiters();
}, },
); );
} }
Future<void> _readInitialStatus() async { Future<void> _readInitialStatus() async {
_log.info('Reading initial bootloader DFU status');
final statusResult = await _transport.readStatus(); final statusResult = await _transport.readStatus();
if (statusResult.isErr()) { if (statusResult.isErr()) {
_log.warning(
'Initial bootloader DFU status read failed: ${statusResult.unwrapErr()}');
throw _DfuFailure( throw _DfuFailure(
'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}', 'Could not read initial bootloader DFU status: ${statusResult.unwrapErr()}',
); );
} }
_handleStatusPayload(statusResult.unwrap()); _handleStatusPayload(statusResult.unwrap());
if (_latestStatus?.code == DfuBootloaderStatusCode.bootMetadataError) {
throw _DfuFailure(universalShifterBootMetadataWarningMessage);
}
_log.info('Initial bootloader DFU status read succeeded');
} }
Future<void> _transferImage({ Future<void> _transferImage({
@ -389,9 +437,15 @@ class FirmwareUpdateService {
} on _DfuFailure catch (failure) { } on _DfuFailure catch (failure) {
if (!failure.recoverable || if (!failure.recoverable ||
reconnectResumeAttempts >= maxReconnectResumeAttempts) { reconnectResumeAttempts >= maxReconnectResumeAttempts) {
_log.warning(
'Transfer failure is not recoverable: ${failure.message}');
rethrow; rethrow;
} }
reconnectResumeAttempts += 1; reconnectResumeAttempts += 1;
_log.warning(
'Recoverable transfer failure, reconnect attempt '
'$reconnectResumeAttempts/$maxReconnectResumeAttempts: ${failure.message}',
);
final recoveredStatus = await _recoverTransferStatus( final recoveredStatus = await _recoverTransferStatus(
timeout: statusTimeout, timeout: statusTimeout,
bootloaderConnectTimeout: bootloaderConnectTimeout, bootloaderConnectTimeout: bootloaderConnectTimeout,
@ -445,6 +499,8 @@ class FirmwareUpdateService {
} }
Future<void> _connectToBootloader({required Duration timeout}) async { Future<void> _connectToBootloader({required Duration timeout}) async {
_log.info(
'Connecting to bootloader with timeout ${timeout.inMilliseconds}ms');
final bootloaderConnectResult = await _transport.connectToBootloader( final bootloaderConnectResult = await _transport.connectToBootloader(
timeout: timeout, timeout: timeout,
); );
@ -453,15 +509,51 @@ class FirmwareUpdateService {
'Could not connect to bootloader DFU mode: ${bootloaderConnectResult.unwrapErr()}', '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( Future<DfuBootloaderStatus> _sendStartAndWaitForStatus(
BootloaderDfuStartPayload payload, { BootloaderDfuStartPayload payload, {
required Duration timeout, required Duration timeout,
}) { }) async {
return _writeControlAndWaitForStatus( final eventCount = _statusEventCount;
BootloaderDfuProtocol.encodeStartPayload(payload), 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, 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;
},
); );
} }
@ -481,6 +573,7 @@ class FirmwareUpdateService {
errorMessage: failure.message, errorMessage: failure.message,
); );
await _connectToBootloader(timeout: bootloaderConnectTimeout); await _connectToBootloader(timeout: bootloaderConnectTimeout);
await _optimizeBootloaderConnection();
await _subscribeToStatus(); await _subscribeToStatus();
_emitProgress(state: DfuUpdateState.waitingForStatus); _emitProgress(state: DfuUpdateState.waitingForStatus);
return _requestStatus(timeout: timeout); return _requestStatus(timeout: timeout);
@ -492,8 +585,16 @@ class FirmwareUpdateService {
bool recoverable = false, bool recoverable = false,
}) async { }) async {
final eventCount = _statusEventCount; 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); final result = await _transport.writeControl(payload);
if (result.isErr()) { if (result.isErr()) {
_log.warning(
'DFU control write failed for opcode '
'0x${payload.first.toRadixString(16).padLeft(2, '0')}: ${result.unwrapErr()}',
);
throw _DfuFailure( throw _DfuFailure(
'Failed to write bootloader control command: ${result.unwrapErr()}', 'Failed to write bootloader control command: ${result.unwrapErr()}',
recoverable: recoverable, recoverable: recoverable,
@ -511,8 +612,17 @@ class FirmwareUpdateService {
required Duration timeout, required Duration timeout,
}) async { }) async {
final eventCount = _statusEventCount; 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); final result = await _transport.writeDataFrame(frame.bytes);
if (result.isErr()) { if (result.isErr()) {
_log.warning(
'DFU data write failed at offset ${frame.offset}: ${result.unwrapErr()}',
);
throw _DfuFailure( throw _DfuFailure(
'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}', 'Failed sending DFU data at offset ${frame.offset}: ${result.unwrapErr()}',
recoverable: true, recoverable: true,
@ -533,10 +643,95 @@ class FirmwareUpdateService {
); );
} }
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({ Future<DfuBootloaderStatus> _waitForStatus({
required int afterEventCount, required int afterEventCount,
required Duration timeout, required Duration timeout,
bool recoverable = false, bool recoverable = false,
bool Function(DfuBootloaderStatus status)? acceptStatus,
}) async { }) async {
final deadline = DateTime.now().add(timeout); final deadline = DateTime.now().add(timeout);
var observedEvents = afterEventCount; var observedEvents = afterEventCount;
@ -546,11 +741,25 @@ class FirmwareUpdateService {
_throwIfStatusStreamErrored(recoverable: recoverable); _throwIfStatusStreamErrored(recoverable: recoverable);
if (_statusEventCount > observedEvents && _latestStatus != null) { if (_statusEventCount > observedEvents && _latestStatus != null) {
return _latestStatus!; _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()); final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) { if (remaining <= Duration.zero) {
_log.warning(
'Timed out waiting for DFU status afterEventCount=$afterEventCount, '
'currentEventCount=$_statusEventCount',
);
throw _DfuFailure( throw _DfuFailure(
'Timed out waiting for bootloader DFU status. Reconnect and retry the update.', 'Timed out waiting for bootloader DFU status. Reconnect and retry the update.',
recoverable: recoverable, recoverable: recoverable,
@ -602,6 +811,10 @@ class FirmwareUpdateService {
try { try {
final status = BootloaderDfuProtocol.parseStatusPayload(payload); final status = BootloaderDfuProtocol.parseStatusPayload(payload);
_latestStatus = status; _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(); final sentBytes = status.expectedOffset.clamp(0, _totalBytes).toInt();
_emitProgress( _emitProgress(
bootloaderStatus: status, bootloaderStatus: status,
@ -611,6 +824,7 @@ class FirmwareUpdateService {
} on FormatException catch (error) { } on FormatException catch (error) {
_statusStreamError = _statusStreamError =
'Received malformed bootloader DFU status: $error. Reconnect and retry.'; 'Received malformed bootloader DFU status: $error. Reconnect and retry.';
_log.severe('Malformed bootloader DFU status payload: $payload', error);
} finally { } finally {
_statusEventCount += 1; _statusEventCount += 1;
_signalStatusWaiters(); _signalStatusWaiters();
@ -746,6 +960,8 @@ abstract interface class FirmwareUpdateTransport {
Future<Result<void>> connectToBootloader({required Duration timeout}); Future<Result<void>> connectToBootloader({required Duration timeout});
Future<Result<void>> optimizeBootloaderConnection();
Future<Result<int>> negotiateMtu({required int requestedMtu}); Future<Result<int>> negotiateMtu({required int requestedMtu});
Stream<List<int>> subscribeToStatus(); Stream<List<int>> subscribeToStatus();
@ -779,6 +995,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<bool>> isConnectedToBootloader() async { Future<Result<bool>> isConnectedToBootloader() async {
final currentState = bluetoothController.currentConnectionState; final currentState = bluetoothController.currentConnectionState;
_log.info(
'Checking current connection for bootloader service: '
'state=${currentState.$1}, device=${currentState.$2}',
);
if (currentState.$1 != ConnectionStatus.connected || if (currentState.$1 != ConnectionStatus.connected ||
currentState.$2 == null) { currentState.$2 == null) {
return Ok(false); return Ok(false);
@ -790,9 +1010,14 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
universalShifterDfuStatusCharacteristicUuid, universalShifterDfuStatusCharacteristicUuid,
); );
if (statusResult.isErr()) { if (statusResult.isErr()) {
_log.info(
'Connected device does not expose bootloader status characteristic: '
'${statusResult.unwrapErr()}',
);
return Ok(false); return Ok(false);
} }
_bootloaderDeviceId = currentState.$2; _bootloaderDeviceId = currentState.$2;
_log.info('Current connection is bootloader: $_bootloaderDeviceId');
return Ok(true); return Ok(true);
} }
@ -813,25 +1038,58 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<void>> connectToBootloader({required Duration timeout}) async { Future<Result<void>> connectToBootloader({required Duration timeout}) async {
final currentState = bluetoothController.currentConnectionState; final currentState = bluetoothController.currentConnectionState;
_log.info(
'Transport connectToBootloader: state=${currentState.$1}, '
'currentDevice=${currentState.$2}, cachedBootloader=$_bootloaderDeviceId, '
'buttonDevice=$buttonDeviceId',
);
if (currentState.$1 == ConnectionStatus.connected && if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == _bootloaderDeviceId && currentState.$2 == _bootloaderDeviceId &&
_bootloaderDeviceId != null) { _bootloaderDeviceId != null) {
_log.info('Already connected to cached bootloader $_bootloaderDeviceId');
return Ok(null); 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); final scanResult = await _scanForBootloader(timeout: timeout);
if (scanResult.isErr()) { if (scanResult.isErr()) {
_log.warning('Bootloader scan failed: ${scanResult.unwrapErr()}');
return Err(scanResult.unwrapErr()); return Err(scanResult.unwrapErr());
} }
final bootloaderDevice = scanResult.unwrap(); final bootloaderDevice = scanResult.unwrap();
_bootloaderDeviceId = bootloaderDevice.id; _bootloaderDeviceId = bootloaderDevice.id;
_log.info(
'Bootloader advertisement selected: id=${bootloaderDevice.id}, '
'name=${bootloaderDevice.name}, rssi=${bootloaderDevice.rssi}',
);
return bluetoothController.connectById( return bluetoothController.connectById(
bootloaderDevice.id, bootloaderDevice.id,
timeout: timeout, 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 @override
Future<Result<int>> negotiateMtu({required int requestedMtu}) { Future<Result<int>> negotiateMtu({required int requestedMtu}) {
final deviceId = _requireBootloaderDeviceId(); final deviceId = _requireBootloaderDeviceId();
@ -847,6 +1105,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Stream<List<int>> subscribeToStatus() { Stream<List<int>> subscribeToStatus() {
final deviceId = _requireBootloaderDeviceId().unwrap(); final deviceId = _requireBootloaderDeviceId().unwrap();
_log.info('Transport subscribeToStatus on bootloader $deviceId');
return bluetoothController.subscribeToCharacteristic( return bluetoothController.subscribeToCharacteristic(
deviceId, deviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -860,6 +1119,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (deviceId.isErr()) { if (deviceId.isErr()) {
return Future.value(Err(deviceId.unwrapErr())); return Future.value(Err(deviceId.unwrapErr()));
} }
_log.info('Transport readStatus from bootloader ${deviceId.unwrap()}');
return bluetoothController.readCharacteristic( return bluetoothController.readCharacteristic(
deviceId.unwrap(), deviceId.unwrap(),
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -873,6 +1133,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (deviceId.isErr()) { if (deviceId.isErr()) {
return Future.value(Err(deviceId.unwrapErr())); 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( return bluetoothController.writeCharacteristic(
deviceId.unwrap(), deviceId.unwrap(),
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -887,6 +1151,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (deviceId.isErr()) { if (deviceId.isErr()) {
return Future.value(Err(deviceId.unwrapErr())); return Future.value(Err(deviceId.unwrapErr()));
} }
if (frame.length >= universalShifterBootloaderDfuDataHeaderSizeBytes) {
_log.fine(
'Transport writeDataFrame to ${deviceId.unwrap()}: len=${frame.length}');
}
return bluetoothController.writeCharacteristic( return bluetoothController.writeCharacteristic(
deviceId.unwrap(), deviceId.unwrap(),
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -904,15 +1172,22 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<void>> reconnectForVerification( Future<Result<void>> reconnectForVerification(
{required Duration timeout}) async { {required Duration timeout}) async {
_log.info(
'Transport reconnectForVerification to app id $buttonDeviceId '
'with timeout ${timeout.inMilliseconds}ms',
);
final connectResult = final connectResult =
await bluetoothController.connectById(buttonDeviceId, timeout: timeout); await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
if (connectResult.isErr()) { if (connectResult.isErr()) {
_log.warning(
'Updated app reconnect connectById failed: ${connectResult.unwrapErr()}');
return Err(connectResult.unwrapErr()); return Err(connectResult.unwrapErr());
} }
final currentState = bluetoothController.currentConnectionState; final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.connected && if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == buttonDeviceId) { currentState.$2 == buttonDeviceId) {
_log.info('Updated app reconnect completed immediately');
return Ok(null); return Ok(null);
} }
@ -924,12 +1199,15 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
state.$2 == buttonDeviceId, state.$2 == buttonDeviceId,
) )
.timeout(timeout); .timeout(timeout);
_log.info('Updated app reconnect observed on connection stream');
return Ok(null); return Ok(null);
} on TimeoutException { } on TimeoutException {
_log.warning('Timed out waiting for updated app reconnect stream event');
return bail( return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for updated app reconnect.', 'Timed out after ${timeout.inMilliseconds}ms waiting for updated app reconnect.',
); );
} catch (error) { } catch (error) {
_log.warning('Updated app reconnect wait failed: $error');
return bail('Updated app reconnect wait failed: $error'); return bail('Updated app reconnect wait failed: $error');
} }
} }
@ -938,6 +1216,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
Future<Result<void>> verifyDeviceReachable( Future<Result<void>> verifyDeviceReachable(
{required Duration timeout}) async { {required Duration timeout}) async {
try { try {
_log.info(
'Reading updated app status characteristic for verification '
'(timeout ${timeout.inMilliseconds}ms)',
);
final statusResult = await bluetoothController final statusResult = await bluetoothController
.readCharacteristic( .readCharacteristic(
buttonDeviceId, buttonDeviceId,
@ -946,15 +1228,20 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
) )
.timeout(timeout); .timeout(timeout);
if (statusResult.isErr()) { if (statusResult.isErr()) {
_log.warning(
'Updated app status verification read failed: ${statusResult.unwrapErr()}');
return Err(statusResult.unwrapErr()); return Err(statusResult.unwrapErr());
} }
CentralStatus.fromBytes(statusResult.unwrap()); CentralStatus.fromBytes(statusResult.unwrap());
_log.info('Updated app status verification succeeded');
return Ok(null); return Ok(null);
} on TimeoutException { } on TimeoutException {
_log.warning('Timed out reading updated app status for verification');
return bail( return bail(
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.', 'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
); );
} catch (error) { } catch (error) {
_log.warning('Post-update verification failed: $error');
return bail('Post-update verification failed: $error'); return bail('Post-update verification failed: $error');
} }
} }
@ -964,6 +1251,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
required String label, required String label,
}) async { }) async {
final currentState = bluetoothController.currentConnectionState; 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) { if (currentState.$1 == ConnectionStatus.disconnected) {
return Ok(null); return Ok(null);
} }
@ -972,12 +1263,15 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
await bluetoothController.connectionStateStream await bluetoothController.connectionStateStream
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected) .firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
.timeout(timeout); .timeout(timeout);
_log.info('$label disconnect observed');
return Ok(null); return Ok(null);
} on TimeoutException { } on TimeoutException {
_log.warning('Timed out waiting for $label disconnect');
return bail( return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for $label disconnect.', 'Timed out after ${timeout.inMilliseconds}ms waiting for $label disconnect.',
); );
} catch (error) { } catch (error) {
_log.warning('Failed while waiting for $label disconnect: $error');
return bail('Failed while waiting for $label disconnect: $error'); return bail('Failed while waiting for $label disconnect: $error');
} }
} }
@ -986,6 +1280,10 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
required Duration timeout, required Duration timeout,
}) async { }) async {
final serviceUuid = Uuid.parse(universalShifterControlServiceUuid); final serviceUuid = Uuid.parse(universalShifterControlServiceUuid);
_log.info(
'Starting bootloader scan: service=$universalShifterControlServiceUuid, '
'timeout=${timeout.inMilliseconds}ms',
);
final scanResult = await bluetoothController.startScan( final scanResult = await bluetoothController.startScan(
withServices: [serviceUuid], withServices: [serviceUuid],
timeout: timeout, timeout: timeout,
@ -997,12 +1295,17 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
try { try {
DiscoveredDevice? immediate; DiscoveredDevice? immediate;
for (final device in bluetoothController.scanResults) { 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)) { if (_isBootloaderAdvertisement(device)) {
immediate = device; immediate = device;
break; break;
} }
} }
if (immediate != null) { if (immediate != null) {
_log.info('Bootloader found in cached scan results: ${immediate.id}');
return Ok(immediate); return Ok(immediate);
} }
@ -1010,12 +1313,18 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
.expand((devices) => devices) .expand((devices) => devices)
.firstWhere(_isBootloaderAdvertisement) .firstWhere(_isBootloaderAdvertisement)
.timeout(timeout); .timeout(timeout);
_log.info(
'Bootloader found from scan stream: id=${device.id}, name=${device.name}, '
'rssi=${device.rssi}',
);
return Ok(device); return Ok(device);
} on TimeoutException { } on TimeoutException {
_log.warning('Timed out scanning for bootloader advertisement');
return bail( return bail(
'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.', 'Timed out after ${timeout.inMilliseconds}ms scanning for US-DFU bootloader.',
); );
} catch (error) { } catch (error) {
_log.warning('Bootloader scan failed: $error');
return bail('Bootloader scan failed: $error'); return bail('Bootloader scan failed: $error');
} finally { } finally {
await bluetoothController.stopScan(); await bluetoothController.stopScan();

View File

@ -3,6 +3,10 @@ import 'dart:io';
import 'package:app_settings/app_settings.dart'; import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
bool isBluetoothPairingRecoveryError(Object error) {
return error.toString().toLowerCase().contains('disconnected');
}
Future<bool> openBluetoothSettings() async { Future<bool> openBluetoothSettings() async {
try { try {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@ -23,7 +27,8 @@ Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
final content = isIOS final content = isIOS
? 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nGo to Settings, then Bluetooth, then forget this device. After that, come back and connect again.\n\nOr press Open Settings below. From the app settings page, press Back twice to reach Bluetooth settings, then forget this device.' ? 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nGo to Settings, then Bluetooth, then forget this device. After that, come back and connect again.\n\nOr press Open Settings below. From the app settings page, press Back twice to reach Bluetooth settings, then forget this device.'
: 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.'; : 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.';
final settingsButtonLabel = isIOS ? 'Open Settings' : 'Open Bluetooth settings'; final settingsButtonLabel =
isIOS ? 'Open Settings' : 'Open Bluetooth settings';
return showDialog<void>( return showDialog<void>(
context: context, context: context,

View File

@ -0,0 +1,247 @@
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter/material.dart';
class FirmwareUpdateFullscreen extends StatelessWidget {
const FirmwareUpdateFullscreen({
super.key,
required this.progress,
required this.selectedFirmware,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.expectedOffsetHex,
required this.onDismiss,
this.doneLabel = 'Done',
this.failedLabel = 'Back to device',
});
final DfuUpdateProgress progress;
final BootloaderDfuPreparedFirmware? selectedFirmware;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String expectedOffsetHex;
final VoidCallback onDismiss;
final String doneLabel;
final String failedLabel;
bool get _isTerminal =>
progress.state == DfuUpdateState.completed ||
progress.state == DfuUpdateState.failed;
bool get _isRunning => !_isTerminal && progress.state != DfuUpdateState.idle;
String? get _bootloaderStatusText {
final status = progress.bootloaderStatus;
if (status == null) {
return null;
}
final codeLabel = 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 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
};
return '$codeLabel • session ${status.sessionId} • offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFailed = progress.state == DfuUpdateState.failed;
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
if (_isRunning)
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
color: colorScheme.errorContainer,
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
color: colorScheme.onErrorContainer, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Do not close the app, lock the phone, or move away from the button.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
_isTerminal
? (isFailed
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded)
: Icons.system_update_alt_rounded,
size: 56,
color: _isTerminal
? (isFailed
? colorScheme.error
: colorScheme.primary)
: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
_isTerminal
? (isFailed ? 'Update failed' : 'Update completed')
: 'Updating firmware',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
phaseText,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 12),
Text(
'${selectedFirmware!.fileName}${_formatBytes(selectedFirmware!.fileBytes.length)}',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 24),
if (_isRunning) ...[
LinearProgressIndicator(
value: progress.totalBytes > 0
? progress.fractionComplete
: null,
minHeight: 12,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 12),
Text(
'${progress.percentComplete}% • $formattedProgressBytes',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
if (progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withValues(alpha: 0.36),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: colorScheme.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Bootloader is verifying, resetting, and booting the new app. Keep the screen open.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 12),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.56),
),
),
],
if (statusText != null &&
statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Text(
statusText!,
style: theme.textTheme.bodyMedium?.copyWith(
color: isFailed
? colorScheme.onErrorContainer
: null,
),
),
),
],
if (_isTerminal) ...[
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onDismiss,
icon: Icon(isFailed
? Icons.arrow_back_rounded
: Icons.check_rounded),
label: Text(isFailed ? failedLabel : doneLabel),
),
),
],
],
),
),
),
],
),
),
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
}

View File

@ -28,6 +28,7 @@ void main() {
'enterBootloader', 'enterBootloader',
'waitForAppDisconnect', 'waitForAppDisconnect',
'connectToBootloader', 'connectToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu', 'negotiateMtu',
'readStatus', 'readStatus',
'waitForBootloaderDisconnect', 'waitForBootloaderDisconnect',
@ -68,6 +69,7 @@ void main() {
expect(result.isOk(), isTrue); expect(result.isOk(), isTrue);
expect(transport.steps, [ expect(transport.steps, [
'isConnectedToBootloader', 'isConnectedToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu', 'negotiateMtu',
'readStatus', 'readStatus',
'waitForBootloaderDisconnect', 'waitForBootloaderDisconnect',
@ -138,6 +140,89 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('completes when FINISH status is lost but bootloader disconnects',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 15,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites.last, [universalShifterDfuOpcodeFinish, 15]);
expect(transport.steps, contains('reconnectForVerification'));
expect(transport.steps, contains('verifyDeviceReachable'));
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails when FINISH status is lost and bootloader stays connected',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
suppressFinishStatus: true,
disconnectAfterFinish: false,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 10),
defaultPostFinishResetTimeout: const Duration(milliseconds: 30),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 16,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('post-FINISH reset'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('fails when FINISH returns explicit bootloader error', () async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
finishStatusCode: DfuBootloaderStatusCode.flashError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 20),
defaultPostFinishResetTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 17,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('flash error'));
expect(service.currentProgress.state, DfuUpdateState.failed);
expect(transport.steps, isNot(contains('reconnectForVerification')));
await service.dispose();
await transport.dispose();
});
test('reconnects and resumes from status after transient data failure', test('reconnects and resumes from status after transient data failure',
() async { () async {
final image = _validImage(130); final image = _validImage(130);
@ -162,6 +247,12 @@ void main() {
transport.steps.where((step) => step == 'connectToBootloader').length, transport.steps.where((step) => step == 'connectToBootloader').length,
2, 2,
); );
expect(
transport.steps
.where((step) => step == 'optimizeBootloaderConnection')
.length,
2,
);
expect( expect(
transport.controlWrites transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus) .where((write) => write.first == universalShifterDfuOpcodeGetStatus)
@ -216,6 +307,33 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('ignores stale previous-session status while waiting for START',
() async {
final image = _validImage(80);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
staleStartStatusSessionId: 20,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 21,
);
expect(result.isOk(), isTrue);
expect(
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
expect(transport.dataWrites.first[0], 21);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
test('fails with bootloader status error on rejected START', () async { test('fails with bootloader status error on rejected START', () async {
final image = _validImage(40); final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport( final transport = _FakeFirmwareUpdateTransport(
@ -242,6 +360,35 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('fails early on boot metadata error before START', () async {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
totalBytes: image.length,
initialStatusCode: DfuBootloaderStatusCode.bootMetadataError,
);
final service = FirmwareUpdateService(
transport: transport,
defaultStatusTimeout: const Duration(milliseconds: 100),
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 18,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(),
startsWith(universalShifterBootMetadataWarningMessage));
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeStart),
isEmpty);
expect(service.currentProgress.state, DfuUpdateState.failed);
await service.dispose();
await transport.dispose();
});
test('cancel after START sends session-scoped ABORT', () async { test('cancel after START sends session-scoped ABORT', () async {
final image = _validImage(80); final image = _validImage(80);
final firstFrameSent = Completer<void>(); final firstFrameSent = Completer<void>();
@ -282,6 +429,7 @@ void main() {
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport { class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({ _FakeFirmwareUpdateTransport({
required this.totalBytes, required this.totalBytes,
this.initialStatusCode = DfuBootloaderStatusCode.ok,
this.startStatusCode = DfuBootloaderStatusCode.ok, this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false, this.alreadyInBootloader = false,
this.failEnterBootloader = false, this.failEnterBootloader = false,
@ -289,10 +437,15 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
this.suppressFirstDataStatus = false, this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce, this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false, this.resetSessionOnRecoveryStatus = false,
this.staleStartStatusSessionId,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite, this.onDataWrite,
}); });
final int totalBytes; final int totalBytes;
final DfuBootloaderStatusCode initialStatusCode;
final DfuBootloaderStatusCode startStatusCode; final DfuBootloaderStatusCode startStatusCode;
final bool alreadyInBootloader; final bool alreadyInBootloader;
final bool failEnterBootloader; final bool failEnterBootloader;
@ -300,6 +453,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final bool suppressFirstDataStatus; final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce; final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus; final bool resetSessionOnRecoveryStatus;
final int? staleStartStatusSessionId;
final bool suppressFinishStatus;
final bool disconnectAfterFinish;
final DfuBootloaderStatusCode finishStatusCode;
final void Function()? onDataWrite; final void Function()? onDataWrite;
final StreamController<List<int>> _statusController = final StreamController<List<int>> _statusController =
@ -315,6 +472,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
bool _sentDataFailure = false; bool _sentDataFailure = false;
bool _sentQueueFull = false; bool _sentQueueFull = false;
bool _suppressedDataStatus = false; bool _suppressedDataStatus = false;
bool _finishDisconnectAvailable = false;
@override @override
Future<Result<bool>> isConnectedToBootloader() async { Future<Result<bool>> isConnectedToBootloader() async {
@ -344,9 +502,16 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
return Ok(null); return Ok(null);
} }
@override
Future<Result<void>> optimizeBootloaderConnection() async {
steps.add('optimizeBootloaderConnection');
return Ok(null);
}
@override @override
Future<Result<int>> negotiateMtu({required int requestedMtu}) async { Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
steps.add('negotiateMtu'); steps.add('negotiateMtu');
expect(requestedMtu, universalShifterDfuPreferredMtu);
return Ok(128); return Ok(128);
} }
@ -356,7 +521,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<List<int>>> readStatus() async { Future<Result<List<int>>> readStatus() async {
steps.add('readStatus'); steps.add('readStatus');
return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0)); return Ok(_status(initialStatusCode, 0, 0));
} }
@override @override
@ -366,6 +531,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (opcode == universalShifterDfuOpcodeStart) { if (opcode == universalShifterDfuOpcodeStart) {
_sessionId = payload[17]; _sessionId = payload[17];
_expectedOffset = 0; _expectedOffset = 0;
final staleSessionId = staleStartStatusSessionId;
if (staleSessionId != null) {
_scheduleStatus(DfuBootloaderStatusCode.ok, staleSessionId, 0);
}
_scheduleStatus(startStatusCode, _sessionId, 0); _scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) { } else if (opcode == universalShifterDfuOpcodeGetStatus) {
if (resetSessionOnRecoveryStatus && _connectCount > 1) { if (resetSessionOnRecoveryStatus && _connectCount > 1) {
@ -374,7 +543,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
} }
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset); _scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) { } else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes); if (suppressFinishStatus) {
_finishDisconnectAvailable = disconnectAfterFinish;
} else {
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
}
} else if (opcode == universalShifterDfuOpcodeAbort) { } else if (opcode == universalShifterDfuOpcodeAbort) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0); _scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
} }
@ -416,7 +589,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override @override
Future<Result<void>> waitForBootloaderDisconnect( Future<Result<void>> waitForBootloaderDisconnect(
{required Duration timeout}) async { {required Duration timeout}) async {
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
return bail('still connected');
}
steps.add('waitForBootloaderDisconnect'); steps.add('waitForBootloaderDisconnect');
_finishDisconnectAvailable = true;
return Ok(null); return Ok(null);
} }

View File

@ -0,0 +1,23 @@
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isBluetoothPairingRecoveryError', () {
test('detects immediate disconnect connection failures', () {
expect(
isBluetoothPairingRecoveryError(
'Failed to connect to device-id: disconnected',
),
isTrue,
);
});
test('does not classify generic connection failures as pairing recovery',
() {
expect(
isBluetoothPairingRecoveryError('Timed out connecting to device-id'),
isFalse,
);
});
});
}