4 Commits

Author SHA1 Message Date
073d825a3e dev: add some logging for dfu 2026-05-04 14:51:53 +02:00
bcccd03ecc feat: smarter firmware confirm via reconnect 2026-05-04 14:40:13 +02:00
16690dc216 feat: fw-update recovery flow 2026-05-04 13:54:19 +02:00
9b672a7503 feat: update optimizations 2026-05-04 13:18:03 +02:00
10 changed files with 1303 additions and 265 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;

View File

@ -0,0 +1,196 @@
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;
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() {
unawaited(_firmwareProgressSubscription?.cancel());
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
super.dispose();
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
if (bluetooth == null) {
return null;
}
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;
}
setState(() {
_firmwareUserMessage = result.unwrapErr().toString();
});
}
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: () => context.go('/devices'),
);
}
}

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 &&
@ -743,6 +765,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 +775,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return;
}
_log.info('Exiting device details page to /devices');
await _disconnectOnClose();
if (!mounted) {
return;
@ -760,6 +784,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 +930,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 +1982,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

@ -1,18 +1,145 @@
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: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;
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 || dfuDevice == null || dfuDevice.id == _dfuDevice?.id) {
return;
}
setState(() {
_dfuDevice = dfuDevice;
});
}
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;
}
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 +177,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 +220,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();

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,30 @@ 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());
_log.info('Initial bootloader DFU status read succeeded');
}
Future<void> _transferImage({
@ -389,9 +434,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 +496,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,6 +506,19 @@ 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(
@ -481,6 +547,7 @@ class FirmwareUpdateService {
errorMessage: failure.message,
);
await _connectToBootloader(timeout: bootloaderConnectTimeout);
await _optimizeBootloaderConnection();
await _subscribeToStatus();
_emitProgress(state: DfuUpdateState.waitingForStatus);
return _requestStatus(timeout: timeout);
@ -492,8 +559,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 +586,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,6 +617,90 @@ 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,
@ -546,11 +714,20 @@ class FirmwareUpdateService {
_throwIfStatusStreamErrored(recoverable: recoverable);
if (_statusEventCount > observedEvents && _latestStatus != null) {
_log.fine(
'Received DFU status for wait: events=$_statusEventCount, '
'session=${_latestStatus!.sessionId}, offset=${_latestStatus!.expectedOffset}, '
'code=${_statusLabel(_latestStatus!)}',
);
return _latestStatus!;
}
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 +779,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 +792,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 +928,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 +963,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 +978,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 +1006,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 +1073,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 +1087,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 +1101,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 +1119,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 +1140,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 +1167,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 +1184,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 +1196,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 +1219,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 +1231,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 +1248,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 +1263,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 +1281,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

@ -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)
@ -289,6 +380,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
this.suppressFirstDataStatus = false,
this.failDataWriteAtOffsetOnce,
this.resetSessionOnRecoveryStatus = false,
this.suppressFinishStatus = false,
this.disconnectAfterFinish = true,
this.finishStatusCode = DfuBootloaderStatusCode.ok,
this.onDataWrite,
});
@ -300,6 +394,9 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final bool suppressFirstDataStatus;
final int? failDataWriteAtOffsetOnce;
final bool resetSessionOnRecoveryStatus;
final bool suppressFinishStatus;
final bool disconnectAfterFinish;
final DfuBootloaderStatusCode finishStatusCode;
final void Function()? onDataWrite;
final StreamController<List<int>> _statusController =
@ -315,6 +412,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 +442,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);
}
@ -374,7 +479,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 +525,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);
}