Compare commits
3 Commits
2ac68e09ab
...
65e295f16d
| Author | SHA1 | Date | |
|---|---|---|---|
| 65e295f16d | |||
| b76503b144 | |||
| bdcd200a62 |
@ -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.
|
||||||
|
|||||||
62
docs/dfu-v1-operator-guide.md
Normal file
62
docs/dfu-v1-operator-guide.md
Normal 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.
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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', () {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user