9 Commits

13 changed files with 1529 additions and 274 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 {
if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null);

View File

@ -1,6 +1,7 @@
import 'dart:async';
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_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
@ -125,6 +126,18 @@ final _router = GoRouter(
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;
const int universalShifterBootloaderDfuStatusSizeBytes = 6;
const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuPreferredMtu = 131;
const int universalShifterDfuAppStart = 0x00030000;
const int universalShifterDfuAppSlotSizeBytes = 0x0003F000;
@ -57,6 +57,9 @@ const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02;
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 errorFtmsMissing = 2;
const int errorPairingAuth = 3;

View File

@ -0,0 +1,245 @@
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);
}());
super.dispose();
}
Future<void> _disconnectBootloaderIfStillConnected({
BluetoothController? bluetooth,
}) async {
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/util/bluetooth_settings.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:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart';
import '../database/database.dart';
final _log = Logger('DeviceDetailsPage');
class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({
required this.deviceAddress,
@ -138,6 +142,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override
void dispose() {
_log.info(
'Disposing device details page for ${widget.deviceAddress}; '
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
);
unawaited(_disconnectOnClose());
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
@ -149,10 +157,12 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _disconnectOnClose() async {
if (_isFirmwareUpdateBusy) {
_log.info('Skipping disconnect on close because firmware update is busy');
return;
}
if (_hasRequestedDisconnect) {
_log.fine('Disconnect on close already requested');
return;
}
@ -173,6 +183,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final (status, connectedDeviceId) = data;
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) {
_startStatusStreamingIfNeeded();
@ -519,6 +536,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (!mounted) {
return;
}
_log.info(
'Firmware progress: state=${progress.state}, '
'sent=${progress.sentBytes}/${progress.totalBytes}, '
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
);
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
@ -629,6 +651,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()) {
_hasLoadedDeviceTelemetry = false;
unawaited(_loadDeviceTelemetry(force: true));
@ -697,6 +732,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
if (result.isErr()) {
if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@ -743,6 +783,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
_log.warning('Blocked page exit while firmware update is busy');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -752,6 +793,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return;
}
_log.info('Exiting device details page to /devices');
await _disconnectOnClose();
if (!mounted) {
return;
@ -760,6 +802,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
void _dismissFirmwareFullscreen() {
_log.info(
'Dismissing firmware fullscreen from state ${_dfuProgress.state}');
setState(() {
_dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
@ -904,7 +948,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
(_dfuProgress.state != DfuUpdateState.idle &&
_dfuProgress.state != DfuUpdateState.completed &&
_dfuProgress.state != DfuUpdateState.failed)) {
return _FirmwareUpdateFullscreen(
return FirmwareUpdateFullscreen(
progress: _dfuProgress,
selectedFirmware: _selectedFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
@ -1956,244 +2000,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;
case Err(:final v):
final error = v.toString();
if (error.toLowerCase().contains('disconnected')) {
if (isBluetoothPairingRecoveryError(v)) {
await showBluetoothPairingRecoveryDialog(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection unsuccessful:\n$error')),
SnackBar(content: Text('Connection unsuccessful:\n$v')),
);
}
break;

View File

@ -1,18 +1,167 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/controller/shifter_device_telemetry.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/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_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice, ScanMode, Uuid;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class DevicesTabPage extends ConsumerWidget {
class DevicesTabPage extends ConsumerStatefulWidget {
const DevicesTabPage({super.key});
@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;
DiscoveredDevice? _dfuDevice;
bool _isBootloaderScanStarting = false;
@override
void initState() {
super.initState();
unawaited(_startBootloaderBackgroundScan());
}
@override
void dispose() {
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
unawaited(_scanSubscription?.cancel());
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) {
return;
}
final scanResult = await bluetooth.startScan(
timeout: _bootloaderScanTimeout,
scanMode: ScanMode.lowLatency,
);
if (scanResult.isErr()) {
return;
}
_updateBootloaderDevice(bluetooth.scanResults);
_scanSubscription = bluetooth.scanResultsStream.listen(
_updateBootloaderDevice,
);
} finally {
_isBootloaderScanStarting = false;
}
}
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
await _scanSubscription?.cancel();
_scanSubscription = null;
await bluetooth?.stopScan();
}
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
(device) => device != null && _isBootloaderAdvertisement(device),
orElse: () => null,
);
if (!mounted) {
return;
}
if (dfuDevice == null) {
_clearBootloaderDevice();
return;
}
if (dfuDevice.id == _dfuDevice?.id) {
return;
}
setState(() {
_dfuDevice = dfuDevice;
});
}
void _clearBootloaderDevice() {
if (!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 || firmware == null) {
return;
}
await _stopBootloaderScan();
if (!mounted) {
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 connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final dfuDevice = _dfuDevice;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
@ -50,6 +199,13 @@ class DevicesTabPage extends ConsumerWidget {
],
),
const SizedBox(height: 20),
if (dfuDevice != null) ...[
_BootloaderRecoveryCard(
device: dfuDevice,
onStartRecovery: _openBootloaderRecovery,
),
const SizedBox(height: 20),
],
devicesAsync.when(
loading: () => const _LoadingCard(),
error: (error, _) => _MessageCard(
@ -86,6 +242,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 {
const _SavedDevicesList();
@ -178,6 +587,8 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
}
context.push('/device/${device.deviceAddress}');
} else if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(

View File

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

View File

@ -3,6 +3,10 @@ import 'dart:io';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
bool isBluetoothPairingRecoveryError(Object error) {
return error.toString().toLowerCase().contains('disconnected');
}
Future<bool> openBluetoothSettings() async {
try {
if (Platform.isAndroid) {
@ -23,7 +27,8 @@ Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
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\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>(
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',
'waitForAppDisconnect',
'connectToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
@ -68,6 +69,7 @@ void main() {
expect(result.isOk(), isTrue);
expect(transport.steps, [
'isConnectedToBootloader',
'optimizeBootloaderConnection',
'negotiateMtu',
'readStatus',
'waitForBootloaderDisconnect',
@ -138,6 +140,89 @@ void main() {
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',
() async {
final image = _validImage(130);
@ -162,6 +247,12 @@ void main() {
transport.steps.where((step) => step == 'connectToBootloader').length,
2,
);
expect(
transport.steps
.where((step) => step == 'optimizeBootloaderConnection')
.length,
2,
);
expect(
transport.controlWrites
.where((write) => write.first == universalShifterDfuOpcodeGetStatus)
@ -216,6 +307,33 @@ void main() {
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 {
final image = _validImage(40);
final transport = _FakeFirmwareUpdateTransport(
@ -242,6 +360,35 @@ void main() {
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 {
final image = _validImage(80);
final firstFrameSent = Completer<void>();
@ -282,6 +429,7 @@ void main() {
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_FakeFirmwareUpdateTransport({
required this.totalBytes,
this.initialStatusCode = DfuBootloaderStatusCode.ok,
this.startStatusCode = DfuBootloaderStatusCode.ok,
this.alreadyInBootloader = false,
this.failEnterBootloader = false,
@ -289,10 +437,15 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.staleStartStatusSessionId,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite,
});
final int totalBytes;
final DfuBootloaderStatusCode initialStatusCode;
final DfuBootloaderStatusCode startStatusCode;
final bool alreadyInBootloader;
final bool failEnterBootloader;
@ -300,6 +453,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final int? staleStartStatusSessionId;
final bool suppressFinishStatus;
final bool disconnectAfterFinish;
final DfuBootloaderStatusCode finishStatusCode;
final void Function()? onDataWrite;
final StreamController<List<int>> _statusController =
@ -315,6 +472,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
bool _sentDataFailure = false;
bool _sentQueueFull = false;
bool _suppressedDataStatus = false;
bool _finishDisconnectAvailable = false;
@override
Future<Result<bool>> isConnectedToBootloader() async {
@ -344,9 +502,16 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
return Ok(null);
}
@override
Future<Result<void>> optimizeBootloaderConnection() async {
steps.add('optimizeBootloaderConnection');
return Ok(null);
}
@override
Future<Result<int>> negotiateMtu({required int requestedMtu}) async {
steps.add('negotiateMtu');
expect(requestedMtu, universalShifterDfuPreferredMtu);
return Ok(128);
}
@ -356,7 +521,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override
Future<Result<List<int>>> readStatus() async {
steps.add('readStatus');
return Ok(_status(DfuBootloaderStatusCode.ok, 0, 0));
return Ok(_status(initialStatusCode, 0, 0));
}
@override
@ -366,6 +531,10 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (opcode == universalShifterDfuOpcodeStart) {
_sessionId = payload[17];
_expectedOffset = 0;
final staleSessionId = staleStartStatusSessionId;
if (staleSessionId != null) {
_scheduleStatus(DfuBootloaderStatusCode.ok, staleSessionId, 0);
}
_scheduleStatus(startStatusCode, _sessionId, 0);
} else if (opcode == universalShifterDfuOpcodeGetStatus) {
if (resetSessionOnRecoveryStatus && _connectCount > 1) {
@ -374,7 +543,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
}
_scheduleStatus(DfuBootloaderStatusCode.ok, _sessionId, _expectedOffset);
} else if (opcode == universalShifterDfuOpcodeFinish) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], totalBytes);
if (suppressFinishStatus) {
_finishDisconnectAvailable = disconnectAfterFinish;
} else {
_scheduleStatus(finishStatusCode, payload[1], totalBytes);
}
} else if (opcode == universalShifterDfuOpcodeAbort) {
_scheduleStatus(DfuBootloaderStatusCode.ok, payload[1], 0);
}
@ -416,7 +589,11 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
@override
Future<Result<void>> waitForBootloaderDisconnect(
{required Duration timeout}) async {
if (timeout == Duration.zero && !_finishDisconnectAvailable) {
return bail('still connected');
}
steps.add('waitForBootloaderDisconnect');
_finishDisconnectAvailable = true;
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,
);
});
});
}