diff --git a/README.md b/README.md index 6c5dc85..2cf8d10 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bootloader-ota-operator-guide.md b/docs/bootloader-ota-operator-guide.md new file mode 100644 index 0000000..b6027c3 --- /dev/null +++ b/docs/bootloader-ota-operator-guide.md @@ -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. diff --git a/docs/dfu-v1-operator-guide.md b/docs/dfu-v1-operator-guide.md deleted file mode 100644 index 01a14ba..0000000 --- a/docs/dfu-v1-operator-guide.md +++ /dev/null @@ -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. diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 816c76a..3f4db98 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -531,7 +531,7 @@ class _DeviceDetailsPageState extends ConsumerState { } 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 { 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 { 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 { '${_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 Function() onSelectFirmware; final Future 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,