From 1dbbf191e69019686e3774188f0383e02d551870 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Wed, 4 Mar 2026 18:07:12 +0100 Subject: [PATCH] feat(dfu): add firmware update controls to device page --- lib/pages/device_details_page.dart | 473 +++++++++++++++++++++++++++-- 1 file changed, 455 insertions(+), 18 deletions(-) diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index bf00fda..b1e315b 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -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 { List _gearRatios = const []; int _defaultGearIndex = 0; + late final FirmwareFileSelectionService _firmwareFileSelectionService; + FirmwareUpdateService? _firmwareUpdateService; + StreamSubscription? _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>( connectionStatusProvider, @@ -88,6 +128,8 @@ class _DeviceDetailsPageState extends ConsumerState { _connectionStatusSubscription?.close(); _statusSubscription?.cancel(); _shifterService?.dispose(); + _firmwareProgressSubscription?.cancel(); + unawaited(_firmwareUpdateService?.dispose() ?? Future.value()); super.dispose(); } @@ -100,6 +142,9 @@ class _DeviceDetailsPageState extends ConsumerState { _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 { 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 { Future _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 { Future _stopStatusStreaming() async { await _statusSubscription?.cancel(); _statusSubscription = null; + + if (_isFirmwareUpdateBusy) { + return; + } + + await _disposeFirmwareUpdateService(); await _shifterService?.dispose(); _shifterService = null; } + Future _disposeFirmwareUpdateService() async { + await _firmwareProgressSubscription?.cancel(); + _firmwareProgressSubscription = null; + await _firmwareUpdateService?.dispose(); + _firmwareUpdateService = null; + } + Future _loadGearRatios() async { final shifter = _shifterService; - if (shifter == null || _isGearRatiosLoading) { + if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) { return; } @@ -257,6 +324,10 @@ class _DeviceDetailsPageState extends ConsumerState { Future _saveGearRatios( List 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 { } Future _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 { ); } + Future _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 _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 _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 _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 _terminateConnectionAndGoHome(String toastMessage) async { await _disconnectOnClose(); @@ -465,6 +716,13 @@ class _DeviceDetailsPageState extends ConsumerState { 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,27 +766,53 @@ class _DeviceDetailsPageState extends ConsumerState { 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( - ratios: _gearRatios, - defaultGearIndex: _defaultGearIndex, - isLoading: _isGearRatiosLoading, - errorText: _gearRatiosError, - onRetry: _loadGearRatios, - onSave: _saveGearRatios, - presets: const [ - GearRatioPreset( - name: 'KeAnt Classic', - description: - '17-step baseline from KeAnt cross app gearing.', - ratios: _keAntRatios, + _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: + _isFirmwareUpdateBusy ? null : _loadGearRatios, + onSave: _saveGearRatios, + presets: const [ + GearRatioPreset( + name: 'KeAnt Classic', + description: + '17-step baseline from KeAnt cross app gearing.', + ratios: _keAntRatios, + ), + ], ), - ], + ), ), ], ], @@ -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 Function() onSelectFirmware; + final Future Function() onStartUpdate; + final Future 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,