Compare commits

...

3 Commits

6 changed files with 168 additions and 10 deletions

View File

@ -2,6 +2,10 @@
A new Flutter project. A new Flutter project.
## Operational Docs
- [DFU v1 Operator Guide](docs/dfu-v1-operator-guide.md)
## Getting Started ## Getting Started
This project is a starting point for a Flutter application. This project is a starting point for a Flutter application.

View File

@ -0,0 +1,62 @@
# 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.

View File

@ -153,7 +153,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
final isAlreadyConnected = final isAlreadyConnected =
connectedDeviceAddresses.contains(device.id); connectedDeviceAddresses.contains(device.id);
final abawoDevice = final abawoDevice =
device.serviceUuids.any(isAbawoDeviceGuid); // device.serviceUuids.any(isAbawoDeviceGuid);
isAbawoDeviceIdent(device.manufacturerData);
final connectable = device.serviceUuids final connectable = device.serviceUuids
.any(isConnectableAbawoDeviceGuid); .any(isConnectableAbawoDeviceGuid);
final deviceName = device.name.isEmpty final deviceName = device.name.isEmpty

View File

@ -4,6 +4,8 @@ const abawoServiceBtUUIDPrefix = '0993826f-0ee4-4b37-9614';
const abawoUniversalShiftersServiceBtUUID = const abawoUniversalShiftersServiceBtUUID =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2'; '0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const abawoManuIdentData = [0x41, 0x28, 0x18, 0xA9];
bool isAbawoDeviceGuid(Uuid guid) { bool isAbawoDeviceGuid(Uuid guid) {
return guid return guid
.toString() .toString()
@ -19,3 +21,11 @@ bool isAbawoUniversalShiftersDeviceGuid(Uuid guid) {
bool isConnectableAbawoDeviceGuid(Uuid guid) { bool isConnectableAbawoDeviceGuid(Uuid guid) {
return isAbawoUniversalShiftersDeviceGuid(guid); return isAbawoUniversalShiftersDeviceGuid(guid);
} }
bool isAbawoDeviceIdent(List<int> manuData) {
if (manuData.length < abawoManuIdentData.length) return false;
for (int i = 0; i < abawoManuIdentData.length; i++) {
if (manuData[i] != abawoManuIdentData[i]) return false;
}
return true;
}

View File

@ -67,6 +67,19 @@ void main() {
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes); expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80)); expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
}); });
test('uses deterministic wrapping sequence numbers from custom start', () {
final image = List<int>.generate(
3 * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF);
final frames = DfuProtocol.buildDataFrames(image, startSequence: 0xFE);
expect(frames.length, 3);
expect(frames[0].sequence, 0xFE);
expect(frames[1].sequence, 0xFF);
expect(frames[2].sequence, 0x00);
});
}); });
group('DfuProtocol sequence and ACK helpers', () { group('DfuProtocol sequence and ACK helpers', () {

View File

@ -66,6 +66,32 @@ void main() {
await transport.dispose(); await transport.dispose();
}); });
test('fails after bounded retries when ACK progress times out', () async {
final transport = _FakeFirmwareUpdateTransport(suppressDataAcks: true);
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 1,
defaultAckTimeout: const Duration(milliseconds: 40),
maxNoProgressRetries: 2,
);
final image = List<int>.generate(90, (index) => index & 0xFF);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 10,
);
expect(result.isErr(), isTrue);
expect(result.unwrapErr().toString(), contains('Upload stalled'));
expect(result.unwrapErr().toString(), contains('after 3 retries'));
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
expect(transport.sequenceWriteCount(0), 3);
expect(service.currentProgress.state, DfuUpdateState.failed);
await service.dispose();
await transport.dispose();
});
test('cancel sends ABORT and reports aborted state', () async { test('cancel sends ABORT and reports aborted state', () async {
final firstFrameSent = Completer<void>(); final firstFrameSent = Completer<void>();
final transport = _FakeFirmwareUpdateTransport( final transport = _FakeFirmwareUpdateTransport(
@ -201,6 +227,45 @@ void main() {
await service.dispose(); await service.dispose();
await transport.dispose(); await transport.dispose();
}); });
test('handles deterministic ACK sequence wrap-around across 0xFF->0x00',
() async {
const frameCount = 260;
final transport = _FakeFirmwareUpdateTransport();
final service = FirmwareUpdateService(
transport: transport,
defaultWindowSize: 16,
defaultAckTimeout: const Duration(milliseconds: 100),
);
final image = List<int>.generate(
frameCount * universalShifterDfuFramePayloadSizeBytes,
(index) => index & 0xFF,
);
final result = await service.startUpdate(
imageBytes: image,
sessionId: 16,
);
expect(result.isOk(), isTrue);
var ffToZeroTransitions = 0;
for (var i = 1; i < transport.ackNotifications.length; i++) {
if (transport.ackNotifications[i - 1] == 0xFF &&
transport.ackNotifications[i] == 0x00) {
ffToZeroTransitions += 1;
}
}
expect(ffToZeroTransitions, greaterThanOrEqualTo(2));
expect(service.currentProgress.lastAckedSequence, 0x03);
expect(service.currentProgress.sentBytes, image.length);
expect(service.currentProgress.state, DfuUpdateState.completed);
await service.dispose();
await transport.dispose();
});
}); });
} }
@ -226,6 +291,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
final List<List<int>> controlWrites = <List<int>>[]; final List<List<int>> controlWrites = <List<int>>[];
final List<List<int>> dataWrites = <List<int>>[]; final List<List<int>> dataWrites = <List<int>>[];
final List<int> ackNotifications = <int>[];
final List<String> postFinishSteps = <String>[]; final List<String> postFinishSteps = <String>[];
final Set<int> _droppedOnce = <int>{}; final Set<int> _droppedOnce = <int>{};
int _lastAck = 0xFF; int _lastAck = 0xFF;
@ -254,9 +320,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (opcode == universalShifterDfuOpcodeStart) { if (opcode == universalShifterDfuOpcodeStart) {
_lastAck = 0xFF; _lastAck = 0xFF;
_expectedSequence = 0; _expectedSequence = 0;
scheduleMicrotask(() { _scheduleAck(0xFF);
_ackController.add([0xFF]);
});
} }
if (opcode == universalShifterDfuOpcodeAbort) { if (opcode == universalShifterDfuOpcodeAbort) {
@ -283,9 +347,7 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
if (shouldDrop) { if (shouldDrop) {
_droppedOnce.add(sequence); _droppedOnce.add(sequence);
scheduleMicrotask(() { _scheduleAck(_lastAck);
_ackController.add([_lastAck]);
});
return Ok(null); return Ok(null);
} }
@ -294,13 +356,19 @@ class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
_expectedSequence = (_expectedSequence + 1) & 0xFF; _expectedSequence = (_expectedSequence + 1) & 0xFF;
} }
scheduleMicrotask(() { _scheduleAck(_lastAck);
_ackController.add([_lastAck]);
});
return Ok(null); return Ok(null);
} }
void _scheduleAck(int sequence) {
final ack = sequence & 0xFF;
ackNotifications.add(ack);
scheduleMicrotask(() {
_ackController.add([ack]);
});
}
@override @override
Future<Result<void>> waitForExpectedResetDisconnect({ Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout, required Duration timeout,