201 lines
5.7 KiB
Dart
201 lines
5.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
|
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
|
import 'package:anyhow/anyhow.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
group('FirmwareUpdateService', () {
|
|
test('completes happy path with START, data frames, and FINISH', () async {
|
|
final transport = _FakeFirmwareUpdateTransport();
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultWindowSize: 4,
|
|
defaultAckTimeout: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
final image = List<int>.generate(130, (index) => index & 0xFF);
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 7,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(transport.controlWrites.length, 2);
|
|
expect(
|
|
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
|
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
|
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
expect(service.currentProgress.sentBytes, image.length);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
|
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultWindowSize: 3,
|
|
defaultAckTimeout: const Duration(milliseconds: 100),
|
|
maxNoProgressRetries: 4,
|
|
);
|
|
|
|
final image = List<int>.generate(190, (index) => index & 0xFF);
|
|
final result = await service.startUpdate(
|
|
imageBytes: image,
|
|
sessionId: 9,
|
|
);
|
|
|
|
expect(result.isOk(), isTrue);
|
|
expect(transport.dataWrites.length, greaterThan(4));
|
|
expect(transport.sequenceWriteCount(1), greaterThan(1));
|
|
expect(service.currentProgress.state, DfuUpdateState.completed);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
|
|
test('cancel sends ABORT and reports aborted state', () async {
|
|
final firstFrameSent = Completer<void>();
|
|
final transport = _FakeFirmwareUpdateTransport(
|
|
onDataWrite: (frame) {
|
|
if (!firstFrameSent.isCompleted) {
|
|
firstFrameSent.complete();
|
|
}
|
|
},
|
|
suppressDataAcks: true,
|
|
);
|
|
final service = FirmwareUpdateService(
|
|
transport: transport,
|
|
defaultWindowSize: 1,
|
|
defaultAckTimeout: const Duration(milliseconds: 500),
|
|
);
|
|
|
|
final future = service.startUpdate(
|
|
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
|
sessionId: 11,
|
|
);
|
|
|
|
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
|
await service.cancelUpdate();
|
|
final result = await future;
|
|
|
|
expect(result.isErr(), isTrue);
|
|
expect(result.unwrapErr().toString(), contains('canceled'));
|
|
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
|
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
|
|
|
await service.dispose();
|
|
await transport.dispose();
|
|
});
|
|
});
|
|
}
|
|
|
|
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|
_FakeFirmwareUpdateTransport({
|
|
this.dropFirstSequence,
|
|
this.onDataWrite,
|
|
this.suppressDataAcks = false,
|
|
});
|
|
|
|
final int? dropFirstSequence;
|
|
final void Function(List<int> frame)? onDataWrite;
|
|
final bool suppressDataAcks;
|
|
|
|
final StreamController<List<int>> _ackController =
|
|
StreamController<List<int>>.broadcast();
|
|
|
|
final List<List<int>> controlWrites = <List<int>>[];
|
|
final List<List<int>> dataWrites = <List<int>>[];
|
|
final Set<int> _droppedOnce = <int>{};
|
|
int _lastAck = 0xFF;
|
|
int _expectedSequence = 0;
|
|
|
|
@override
|
|
Future<Result<DfuPreflightResult>> runPreflight({
|
|
required int requestedMtu,
|
|
}) async {
|
|
return Ok(
|
|
DfuPreflightResult.ready(
|
|
requestedMtu: requestedMtu,
|
|
negotiatedMtu: 128,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
|
|
|
@override
|
|
Future<Result<void>> writeControl(List<int> payload) async {
|
|
controlWrites.add(List<int>.from(payload, growable: false));
|
|
|
|
final opcode = payload.isEmpty ? -1 : payload.first;
|
|
if (opcode == universalShifterDfuOpcodeStart) {
|
|
_lastAck = 0xFF;
|
|
_expectedSequence = 0;
|
|
scheduleMicrotask(() {
|
|
_ackController.add([0xFF]);
|
|
});
|
|
}
|
|
|
|
if (opcode == universalShifterDfuOpcodeAbort) {
|
|
_lastAck = 0xFF;
|
|
_expectedSequence = 0;
|
|
}
|
|
|
|
return Ok(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
|
dataWrites.add(List<int>.from(frame, growable: false));
|
|
onDataWrite?.call(frame);
|
|
|
|
if (suppressDataAcks) {
|
|
return Ok(null);
|
|
}
|
|
|
|
final sequence = frame.first;
|
|
final shouldDrop = dropFirstSequence != null &&
|
|
sequence == dropFirstSequence &&
|
|
!_droppedOnce.contains(sequence);
|
|
|
|
if (shouldDrop) {
|
|
_droppedOnce.add(sequence);
|
|
scheduleMicrotask(() {
|
|
_ackController.add([_lastAck]);
|
|
});
|
|
return Ok(null);
|
|
}
|
|
|
|
if (sequence == _expectedSequence) {
|
|
_lastAck = sequence;
|
|
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
|
}
|
|
|
|
scheduleMicrotask(() {
|
|
_ackController.add([_lastAck]);
|
|
});
|
|
|
|
return Ok(null);
|
|
}
|
|
|
|
int sequenceWriteCount(int sequence) {
|
|
var count = 0;
|
|
for (final frame in dataWrites) {
|
|
if (frame.first == sequence) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await _ackController.close();
|
|
}
|
|
}
|