feat(dfu): add firmware update controls to device page

This commit is contained in:
2026-03-04 18:07:12 +01:00
parent 32f258a492
commit 1dbbf191e6

View File

@ -1,6 +1,9 @@
import 'dart:async';
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_file_selection_service.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
@ -64,9 +67,46 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
List<double> _gearRatios = const [];
int _defaultGearIndex = 0;
late final FirmwareFileSelectionService _firmwareFileSelectionService;
FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
DfuV1PreparedFirmware? _selectedFirmware;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
lastAckedSequence: 0xFF,
sessionId: 0,
flags: DfuUpdateFlags(),
);
bool _isSelectingFirmware = false;
bool _isStartingFirmwareUpdate = false;
String? _firmwareUserMessage;
bool get _isFirmwareUpdateBusy {
if (_isStartingFirmwareUpdate) {
return true;
}
switch (_dfuProgress.state) {
case DfuUpdateState.starting:
case DfuUpdateState.waitingForAck:
case DfuUpdateState.transferring:
case DfuUpdateState.finishing:
return true;
case DfuUpdateState.idle:
case DfuUpdateState.completed:
case DfuUpdateState.aborted:
case DfuUpdateState.failed:
return false;
}
}
@override
void initState() {
super.initState();
_firmwareFileSelectionService = FirmwareFileSelectionService(
filePicker: LocalFirmwareFilePicker(),
);
_connectionStatusSubscription =
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
connectionStatusProvider,
@ -88,6 +128,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
_shifterService?.dispose();
_firmwareProgressSubscription?.cancel();
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
super.dispose();
}
@ -100,6 +142,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_isExitingPage = true;
_reconnectTimeoutTimer?.cancel();
await _firmwareUpdateService?.cancelUpdate();
await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value;
await bluetooth?.disconnect();
await _stopStatusStreaming();
@ -127,11 +172,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (_wasConnectedToCurrentDevice &&
!_isReconnecting &&
status == ConnectionStatus.disconnected) {
status == ConnectionStatus.disconnected &&
!_isFirmwareUpdateBusy) {
_startReconnect();
}
if (!isCurrentDevice || status == ConnectionStatus.disconnected) {
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
!_isFirmwareUpdateBusy) {
_stopStatusStreaming();
}
}
@ -161,6 +208,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _startStatusStreamingIfNeeded() async {
if (_shifterService != null) {
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
if (!mounted) {
return;
}
_recordStatus(status);
});
_shifterService!.startStatusNotifications();
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
unawaited(_loadGearRatios());
}
@ -216,13 +270,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<void> _stopStatusStreaming() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
if (_isFirmwareUpdateBusy) {
return;
}
await _disposeFirmwareUpdateService();
await _shifterService?.dispose();
_shifterService = null;
}
Future<void> _disposeFirmwareUpdateService() async {
await _firmwareProgressSubscription?.cancel();
_firmwareProgressSubscription = null;
await _firmwareUpdateService?.dispose();
_firmwareUpdateService = null;
}
Future<void> _loadGearRatios() async {
final shifter = _shifterService;
if (shifter == null || _isGearRatiosLoading) {
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
return;
}
@ -257,6 +324,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
Future<String?> _saveGearRatios(
List<double> ratios, int defaultGearIndex) async {
if (_isFirmwareUpdateBusy) {
return 'Gear ratio changes are disabled during firmware update.';
}
final shifter = _shifterService;
if (shifter == null) {
return 'Status channel is not ready yet.';
@ -289,6 +360,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
Future<void> _connectButtonToBike() async {
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to bike is disabled during firmware updates.'),
),
);
return;
}
final selectedBike = await BikeScanDialog.show(
context,
excludedDeviceId: widget.deviceAddress,
@ -328,6 +408,177 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
);
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
final shifter = _shifterService;
if (shifter == null) {
return null;
}
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final bluetooth = asyncBluetooth.valueOrNull;
if (bluetooth == null) {
return null;
}
final service = FirmwareUpdateService(
transport: ShifterFirmwareUpdateTransport(
shifterService: shifter,
bluetoothController: bluetooth,
buttonDeviceId: widget.deviceAddress,
),
);
_firmwareProgressSubscription = service.progressStream.listen((progress) {
if (!mounted) {
return;
}
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
}
if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The button rebooted and reconnected.';
}
if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
}
});
});
_firmwareUpdateService = service;
return service;
}
Future<void> _selectFirmwareFile() async {
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
return;
}
setState(() {
_isSelectingFirmware = true;
_firmwareUserMessage = null;
});
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
if (!mounted) {
return;
}
setState(() {
_isSelectingFirmware = false;
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_firmwareUserMessage =
'Selected ${result.firmware!.fileName}. Ready to start update.';
} else if (!result.isCanceled) {
_firmwareUserMessage = result.failure?.message;
}
});
}
Future<void> _startFirmwareUpdate() async {
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
return;
}
final firmware = _selectedFirmware;
if (firmware == null) {
setState(() {
_firmwareUserMessage =
'Select a firmware .bin file before starting the update.';
});
return;
}
await _startStatusStreamingIfNeeded();
final updater = await _ensureFirmwareUpdateService();
if (!mounted) {
return;
}
if (updater == null) {
setState(() {
_firmwareUserMessage =
'Firmware updater is not ready. Ensure the button is connected.';
});
return;
}
setState(() {
_isStartingFirmwareUpdate = true;
_firmwareUserMessage =
'Starting update. Keep this screen open and stay near the button.';
});
final result = await updater.startUpdate(
imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
);
if (!mounted) {
return;
}
setState(() {
_isStartingFirmwareUpdate = false;
if (result.isErr()) {
_firmwareUserMessage = result.unwrapErr().toString();
}
});
}
Future<void> _cancelFirmwareUpdate() async {
final updater = _firmwareUpdateService;
if (updater == null || !_isFirmwareUpdateBusy) {
return;
}
setState(() {
_firmwareUserMessage = 'Canceling firmware update...';
});
await updater.cancelUpdate();
}
String _dfuPhaseText(DfuUpdateState state) {
switch (state) {
case DfuUpdateState.idle:
return 'Idle';
case DfuUpdateState.starting:
return 'Sending START command';
case DfuUpdateState.waitingForAck:
return 'Waiting for ACK from button';
case DfuUpdateState.transferring:
return 'Transferring firmware frames';
case DfuUpdateState.finishing:
return 'Finalizing update and waiting for reboot/reconnect';
case DfuUpdateState.completed:
return 'Update completed';
case DfuUpdateState.aborted:
return 'Update canceled';
case DfuUpdateState.failed:
return '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';
}
String _hexByte(int value) {
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
}
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
await _disconnectOnClose();
@ -465,6 +716,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final isCurrentConnected = connectionData != null &&
connectionData.$1 == ConnectionStatus.connected &&
connectionData.$2 == widget.deviceAddress;
final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected &&
!_isSelectingFirmware &&
!_isFirmwareUpdateBusy &&
_selectedFirmware != null;
final canCancelFirmware = _isFirmwareUpdateBusy;
return PopScope(
canPop: false,
@ -508,18 +766,42 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _connectButtonToBike,
onPressed:
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
icon: const Icon(Icons.link),
label: const Text('Connect Button to Bike'),
),
),
const SizedBox(height: 16),
GearRatioEditorCard(
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
canCancel: canCancelFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
onCancelUpdate: _cancelFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
),
const SizedBox(height: 16),
Opacity(
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
child: AbsorbPointer(
absorbing: _isFirmwareUpdateBusy,
child: GearRatioEditorCard(
ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError,
onRetry: _loadGearRatios,
onRetry:
_isFirmwareUpdateBusy ? null : _loadGearRatios,
onSave: _saveGearRatios,
presets: const [
GearRatioPreset(
@ -530,6 +812,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
),
],
),
),
),
],
],
),
@ -583,6 +867,159 @@ class _StatusHistoryEntry {
final CentralStatus status;
}
class _FirmwareUpdateCard extends StatelessWidget {
const _FirmwareUpdateCard({
required this.selectedFirmware,
required this.progress,
required this.isSelecting,
required this.isStarting,
required this.canSelect,
required this.canStart,
required this.canCancel,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.ackSequenceHex,
required this.onSelectFirmware,
required this.onStartUpdate,
required this.onCancelUpdate,
});
final DfuV1PreparedFirmware? selectedFirmware;
final DfuUpdateProgress progress;
final bool isSelecting;
final bool isStarting;
final bool canSelect;
final bool canStart;
final bool canCancel;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String ackSequenceHex;
final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate;
final Future<void> Function() onCancelUpdate;
bool get _showProgress {
return progress.totalBytes > 0 ||
progress.sentBytes > 0 ||
progress.state != DfuUpdateState.idle;
}
bool get _showRebootExpectation {
return progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.completed;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Firmware Update',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: canSelect ? onSelectFirmware : null,
icon: isSelecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: const Text('Select Firmware'),
),
FilledButton.icon(
onPressed: canStart ? onStartUpdate : null,
icon: isStarting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
label: const Text('Start Update'),
),
TextButton.icon(
onPressed: canCancel ? onCancelUpdate : null,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Cancel Update'),
),
],
),
const SizedBox(height: 10),
Text(
selectedFirmware == null
? 'Selected file: none'
: 'Selected file: ${selectedFirmware!.fileName}',
style: theme.textTheme.bodyMedium,
),
if (selectedFirmware != null) ...[
const SizedBox(height: 4),
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: 10),
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
if (_showProgress) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
),
const SizedBox(height: 6),
Text(
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
style: theme.textTheme.bodySmall,
),
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
if (statusText != null && statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
statusText!,
style: theme.textTheme.bodySmall?.copyWith(
color: progress.state == DfuUpdateState.failed
? colorScheme.error
: theme.textTheme.bodySmall?.color,
),
),
],
],
),
);
}
}
class _StatusBanner extends StatelessWidget {
const _StatusBanner({
required this.status,