feat: fw-update recovery flow

This commit is contained in:
2026-05-04 13:54:19 +02:00
parent 9b672a7503
commit 16690dc216
7 changed files with 869 additions and 248 deletions

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();