docs: document bootloader OTA flow
This commit is contained in:
@ -4,7 +4,7 @@ A new Flutter project.
|
||||
|
||||
## Operational Docs
|
||||
|
||||
- [DFU v1 Operator Guide](docs/dfu-v1-operator-guide.md)
|
||||
- [Bootloader OTA Operator Guide](docs/bootloader-ota-operator-guide.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
62
docs/bootloader-ota-operator-guide.md
Normal file
62
docs/bootloader-ota-operator-guide.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Bootloader OTA Operator Guide
|
||||
|
||||
This guide explains the Universal Shifters single-slot bootloader update flow in `abawo_bt_app`.
|
||||
|
||||
## App-Side Flow
|
||||
|
||||
1. Connect to the target button and open **Device Details**.
|
||||
2. In **Firmware Update**, select a local raw application `.bin` with **Select Firmware**.
|
||||
3. The app validates size and vector table before enabling the update.
|
||||
4. Review file metadata: size, session id, CRC32, app start, image version, and reset vector.
|
||||
5. Tap **Start Update** and keep the phone close to the button.
|
||||
6. The app sends `EnterDfu` to the running application, waits for reset, and connects to `US-DFU`.
|
||||
7. The app sends bootloader `START`; this erases the active app slot.
|
||||
8. The app transfers offset-based frames and tracks bootloader `expected_offset`.
|
||||
9. The app sends `FINISH`, waits for final OK, then waits for the bootloader reset.
|
||||
10. Success is shown only after the updated app reconnects and status verification passes.
|
||||
|
||||
## Image Requirements
|
||||
|
||||
- File extension must be `.bin`.
|
||||
- Image must be at least 8 bytes and no larger than `0x42000` bytes.
|
||||
- Image bytes must start at application address `0x00030000`.
|
||||
- Initial stack pointer must be aligned and within `0x20000000..=0x20010000`.
|
||||
- Reset vector must have the Thumb bit set and point inside the image after the first two vector words.
|
||||
- Flags are always `0`; encrypted/signed update flags are not supported by the current bootloader.
|
||||
- Image version is currently sent as `0` unless a later packaging flow provides it.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Single-slot update is destructive after bootloader `START`; the previous app is erased before image transfer.
|
||||
- If transfer fails after `START`, recovery is through bootloader DFU or external reflash.
|
||||
- Gear writes and **Connect Button to Bike** stay disabled while OTA is running.
|
||||
- If BLE drops during transfer, retry promptly while the bootloader is still advertising `US-DFU`.
|
||||
- Cancel after `START` sends bootloader `ABORT` and leaves the device in bootloader/recovery flow.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom in app | Likely cause | Operator action |
|
||||
| --- | --- | --- |
|
||||
| Invalid stack pointer or reset vector | `.bin` is not a raw app image for `0x00030000` | Rebuild/export the application image from the correct linker layout. |
|
||||
| Could not connect to bootloader DFU mode | Phone did not find `US-DFU` after app reset | Move closer, retry, and verify the device is advertising `US-DFU`. |
|
||||
| Timed out waiting for bootloader DFU status | Status indication/read did not arrive | Reconnect to `US-DFU` and retry. |
|
||||
| Bootloader status `bounds error` | Image length or app start rejected | Use a valid app image no larger than `0x42000` bytes. |
|
||||
| Bootloader status `CRC error` | Full-image CRC did not match flash contents | Re-export or re-download the `.bin`, then retry. |
|
||||
| Bootloader status `vector table error` | Bootloader rejected the written vector table | Rebuild firmware for app start `0x00030000`. |
|
||||
| Bootloader status `flash error` | Flash erase/write/read failed | Retry once; if repeated, service or externally reflash the device. |
|
||||
| Bootloader status `boot metadata error` | Bootloader could not persist boot metadata | Treat as service risk; retry reflash, then return device if repeated. |
|
||||
| Updated app did not reconnect | New app did not boot/confirm or reconnect window expired | Scan for `US-DFU`; if present, retry OTA with a known-good image. |
|
||||
| Updated app reconnected but verification failed | Normal app status read failed | Reconnect manually and verify status; retry only if the device is still in bootloader or unusable. |
|
||||
|
||||
Escalate with app logs, device identifier, firmware filename/hash, and observed bootloader status when a known-good image repeatedly fails.
|
||||
|
||||
## Manual QA Checklist
|
||||
|
||||
- [ ] Happy path: select valid `.bin`, enter bootloader, transfer, finish, reboot, reconnect, completed.
|
||||
- [ ] Image validation: invalid extension, empty file, too-small file, too-large file, invalid SP, invalid reset vector.
|
||||
- [ ] UI state gating: gear ratio save and trainer assignment remain disabled during OTA.
|
||||
- [ ] Queue-full/status recovery: app sends `GET_STATUS` and resumes from returned offset.
|
||||
- [ ] Cancel path: cancel after `START` sends `[ABORT, session]` and shows canceled state.
|
||||
- [ ] Bootloader status errors: CRC/vector/flash/metadata statuses show actionable messages.
|
||||
- [ ] Reconnect timeout: no updated app reconnect produces a clear failure message.
|
||||
- [ ] Regression check: after successful update, status and firmware telemetry still load normally.
|
||||
@ -1,62 +0,0 @@
|
||||
# DFU v1 Operator Guide
|
||||
|
||||
This guide explains how to run and support firmware updates for Universal Shifters in `abawo_bt_app`.
|
||||
|
||||
## App-Side Flow (Operator)
|
||||
|
||||
1. Connect to the target button and open **Device Details**.
|
||||
2. In **Firmware Update**, select a local `.bin` file with **Select Firmware**.
|
||||
3. Confirm file metadata is shown (size, session id, CRC32), then tap **Start Update**.
|
||||
4. Monitor progress:
|
||||
- Phase text: `Sending START`, `Waiting for ACK`, `Transferring`, `Finalizing`
|
||||
- Progress bar and bytes sent
|
||||
- Last ACK sequence (`0x..`)
|
||||
5. During `Finalizing`, expect a brief disconnect while the device reboots.
|
||||
6. The app attempts reconnect + reachability verification automatically.
|
||||
7. Success is only shown after reconnect verification passes.
|
||||
|
||||
Operational notes:
|
||||
- Keep the phone near the button for the full transfer.
|
||||
- Keep this screen open until completion.
|
||||
- Gear writes and "Connect Button to Bike" are disabled during DFU by design.
|
||||
|
||||
## Troubleshooting Matrix
|
||||
|
||||
| Symptom in app | Likely cause | Operator action |
|
||||
| --- | --- | --- |
|
||||
| Preflight fails with MTU too low | Negotiated MTU below minimum required for 64-byte frames (`>=67`) | Reconnect BLE, retry update, and reduce RF interference/distance. |
|
||||
| `Timed out waiting for initial DFU ACK after START` | ACK indications not enabled/received, or unstable link | Disconnect/reconnect button, retry update, keep device nearby. |
|
||||
| `Upload stalled: no ACK progress ...` | Packet loss or weak BLE link; missing frame prevents cumulative ACK movement | Move closer, reduce interference, retry update; app will rewind and resend from last ACK while running. |
|
||||
| `Received malformed ACK indication` | Corrupted/unexpected ACK payload from transport path | Reconnect and retry. If repeatable, capture logs and firmware version for investigation. |
|
||||
| `Device did not perform the expected post-FINISH reset disconnect` | Device did not reset after FINISH, or disconnect event was missed | Retry update once. If repeatable, treat as firmware-side finalize/reset issue. |
|
||||
| `Device did not reconnect after DFU reset` | Reboot happened but reconnect window expired | Manually reconnect in app and retry update with strong signal. |
|
||||
| `post-update verification failed` or verification timeout | Device reconnected but status read failed in verification step | Reconnect and verify normal status manually; retry update only if needed. |
|
||||
| Transfer reaches end but completion never succeeds; ACK does not advance after FINISH | Likely CRC mismatch (or device rejected FINISH completeness/integrity checks) | Re-export/re-download firmware `.bin`, reselect file, retry. Do not power cycle mid-transfer. |
|
||||
|
||||
Escalate with logs when the same firmware + device repeatedly fails after one clean retry.
|
||||
|
||||
## DFU v1 Limitations and Roadmap
|
||||
|
||||
Current v1 limitations:
|
||||
- The app verifies reachability after reconnect, but **cannot strictly compare old/new firmware version** yet (no version characteristic exposed by device).
|
||||
- `START.flags` supports encrypted/signed modes, but the app currently runs plain `.bin` updates and does **not** perform signed/encrypted payload validation.
|
||||
|
||||
Roadmap direction:
|
||||
- Add device firmware version characteristic and enforce strict version progression checks in-app.
|
||||
- Add signed update manifest verification before upload acceptance.
|
||||
- Add encrypted payload transport mode and key management flow.
|
||||
|
||||
## Manual QA Checklist (Release Validation)
|
||||
|
||||
Run on at least one known-good button and firmware image.
|
||||
|
||||
- [ ] **Happy path**: Select valid `.bin` -> start -> transfer -> reboot/disconnect -> reconnect -> completed.
|
||||
- [ ] **UI state gating**: During DFU, gear ratio save and "Connect Button to Bike" controls stay disabled.
|
||||
- [ ] **Cancel path**: Start update, cancel mid-transfer, confirm terminal `canceled` state and safe recovery.
|
||||
- [ ] **Preflight MTU failure**: Force low-MTU environment; confirm clear failure message and no transfer start.
|
||||
- [ ] **Stalled ACK handling**: In degraded RF conditions, verify retries/rewind behavior and bounded failure messaging.
|
||||
- [ ] **Reconnect timeout handling**: Simulate slow/no reconnect after FINISH; confirm explicit reconnect timeout error.
|
||||
- [ ] **Bad file validation**: Confirm non-`.bin` and empty file selections are rejected with actionable messages.
|
||||
- [ ] **Regression check**: After update attempt (success/failure), reconnect normally and verify status reads still work.
|
||||
|
||||
If a checklist item fails, attach app logs, device identifier, firmware filename/hash, and observed phase/error text.
|
||||
@ -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}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Expected 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,
|
||||
|
||||
Reference in New Issue
Block a user