feat(dfu): add firmware update controls to device page
This commit is contained in:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user