docs: document bootloader OTA flow

This commit is contained in:
2026-04-29 18:04:28 +02:00
parent 06834a0cc0
commit 09c686d542
4 changed files with 110 additions and 72 deletions

View File

@ -531,7 +531,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The button rebooted and reconnected.';
'Firmware update completed. The bootloader rebooted into the updated app and verification passed.';
}
if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
@ -564,7 +564,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_firmwareUserMessage =
'Selected ${result.firmware!.fileName}. Ready to start update.';
'Validated ${result.firmware!.fileName}. Ready for bootloader update.';
} else if (!result.isCanceled) {
_firmwareUserMessage = result.failure?.message;
}
@ -601,12 +601,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
setState(() {
_isStartingFirmwareUpdate = true;
_firmwareUserMessage =
'Starting update. Keep this screen open and stay near the button.';
'Requesting bootloader mode. Keep this screen open and stay near the button.';
});
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),
);
@ -1021,7 +1023,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
ackSequenceHex:
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
),
] else if (isCurrentConnected) ...[
@ -1069,7 +1071,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.ackSequenceHex,
required this.expectedOffsetHex,
required this.onSelectFirmware,
required this.onStartUpdate,
});
@ -1083,7 +1085,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String ackSequenceHex;
final String expectedOffsetHex;
final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate;
@ -1095,9 +1097,33 @@ class _FirmwareUpdateCard extends StatelessWidget {
bool get _showRebootExpectation {
return progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying ||
progress.state == DfuUpdateState.completed;
}
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 'Bootloader status: $codeLabel, session ${status.sessionId}, expected offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -1122,7 +1148,7 @@ class _FirmwareUpdateCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Select a firmware image, review the transfer state, and start the update when ready.',
'Select a raw app image for the single-slot bootloader. Once START is accepted, the active app slot is erased.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
@ -1181,6 +1207,11 @@ class _FirmwareUpdateCard extends StatelessWidget {
'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: 2),
Text(
'App start: 0x${selectedFirmware!.metadata.appStart.toRadixString(16).padLeft(8, '0').toUpperCase()} | Image version: ${selectedFirmware!.metadata.imageVersion} | Reset: 0x${selectedFirmware!.metadata.vectorReset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
],
),
@ -1196,14 +1227,21 @@ class _FirmwareUpdateCard extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'${progress.percentComplete}% • $formattedProgressBytesLast ACK $ackSequenceHex',
'${progress.percentComplete}% • $formattedProgressBytesExpected offset $expectedOffsetHex',
style: theme.textTheme.bodySmall,
),
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 4),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall,
),
],
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
'Expected behavior: after FINISH, the bootloader verifies the image, resets, and the updated app confirms itself before reconnecting.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,