diff --git a/lib/service/dfu_protocol.dart b/lib/service/dfu_protocol.dart new file mode 100644 index 0000000..6a83147 --- /dev/null +++ b/lib/service/dfu_protocol.dart @@ -0,0 +1,137 @@ +import 'dart:typed_data'; + +import 'package:abawo_bt_app/model/shifter_types.dart'; + +const int _startPayloadLength = 11; + +class DfuStartPayload { + const DfuStartPayload({ + required this.totalLength, + required this.imageCrc32, + required this.sessionId, + required this.flags, + }); + + final int totalLength; + final int imageCrc32; + final int sessionId; + final int flags; +} + +class DfuDataFrame { + const DfuDataFrame({ + required this.sequence, + required this.offset, + required this.payloadLength, + required this.bytes, + }); + + final int sequence; + final int offset; + final int payloadLength; + final Uint8List bytes; +} + +class DfuProtocol { + const DfuProtocol._(); + + static Uint8List encodeStartPayload(DfuStartPayload payload) { + final data = ByteData(_startPayloadLength); + data.setUint8(0, universalShifterDfuOpcodeStart); + data.setUint32(1, payload.totalLength, Endian.little); + data.setUint32(5, payload.imageCrc32, Endian.little); + data.setUint8(9, payload.sessionId); + data.setUint8(10, payload.flags); + return data.buffer.asUint8List(); + } + + static Uint8List encodeFinishPayload() { + return Uint8List.fromList([universalShifterDfuOpcodeFinish]); + } + + static Uint8List encodeAbortPayload() { + return Uint8List.fromList([universalShifterDfuOpcodeAbort]); + } + + static List buildDataFrames( + List imageBytes, { + int startSequence = 0, + }) { + final frames = []; + var seq = _asU8(startSequence); + var offset = 0; + while (offset < imageBytes.length) { + final remaining = imageBytes.length - offset; + final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes + ? remaining + : universalShifterDfuFramePayloadSizeBytes; + + final frame = Uint8List(universalShifterDfuFrameSizeBytes); + frame[0] = seq; + frame.setRange(1, 1 + chunkLength, imageBytes, offset); + + frames.add( + DfuDataFrame( + sequence: seq, + offset: offset, + payloadLength: chunkLength, + bytes: frame, + ), + ); + + offset += chunkLength; + seq = nextSequence(seq); + } + + return frames; + } + + static int nextSequence(int sequence) { + return _asU8(sequence + 1); + } + + static int rewindSequenceFromAck(int acknowledgedSequence) { + return nextSequence(acknowledgedSequence); + } + + static int sequenceDistance(int from, int to) { + return _asU8(to - from); + } + + static int parseAckPayload(List payload) { + if (payload.length != 1) { + throw const FormatException('ACK payload must be exactly 1 byte.'); + } + return _asU8(payload.first); + } + + static const int crc32Initial = 0xFFFFFFFF; + static const int _crc32PolynomialReflected = 0xEDB88320; + + static int crc32Update(int crc, List bytes) { + var next = crc & 0xFFFFFFFF; + for (final byte in bytes) { + next ^= byte; + for (var bit = 0; bit < 8; bit++) { + if ((next & 0x1) != 0) { + next = (next >> 1) ^ _crc32PolynomialReflected; + } else { + next >>= 1; + } + } + } + return next & 0xFFFFFFFF; + } + + static int crc32Finalize(int crc) { + return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF; + } + + static int crc32(List bytes) { + return crc32Finalize(crc32Update(crc32Initial, bytes)); + } + + static int _asU8(int value) { + return value & 0xFF; + } +} diff --git a/test/service/dfu_protocol_test.dart b/test/service/dfu_protocol_test.dart new file mode 100644 index 0000000..1b5b74f --- /dev/null +++ b/test/service/dfu_protocol_test.dart @@ -0,0 +1,86 @@ +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/dfu_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DfuProtocol CRC32', () { + test('matches known vector', () { + final crc = DfuProtocol.crc32('123456789'.codeUnits); + expect(crc, 0xCBF43926); + }); + }); + + group('DfuProtocol control payload encoding', () { + test('encodes START payload with exact 11-byte LE layout', () { + final payload = DfuProtocol.encodeStartPayload( + const DfuStartPayload( + totalLength: 0x1234, + imageCrc32: 0x89ABCDEF, + sessionId: 0x22, + flags: universalShifterDfuFlagEncrypted, + ), + ); + + expect(payload.length, 11); + expect( + payload, + [ + universalShifterDfuOpcodeStart, + 0x34, + 0x12, + 0x00, + 0x00, + 0xEF, + 0xCD, + 0xAB, + 0x89, + 0x22, + universalShifterDfuFlagEncrypted, + ], + ); + }); + + test('encodes FINISH and ABORT payloads as one byte', () { + expect( + DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]); + expect( + DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]); + }); + }); + + group('DfuProtocol data frame building', () { + test('builds 64-byte frames and handles final partial payload', () { + final image = List.generate(80, (index) => index); + final frames = DfuProtocol.buildDataFrames(image); + + expect(frames.length, 2); + + expect(frames[0].sequence, 0); + expect(frames[0].offset, 0); + expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes); + expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes); + expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63)); + + expect(frames[1].sequence, 1); + expect(frames[1].offset, 63); + expect(frames[1].payloadLength, 17); + expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes); + expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80)); + }); + }); + + group('DfuProtocol sequence and ACK helpers', () { + test('wraps sequence values and computes ack+1 rewind', () { + expect(DfuProtocol.nextSequence(0x00), 0x01); + expect(DfuProtocol.nextSequence(0xFF), 0x00); + + expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06); + expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00); + }); + + test('computes wrapping sequence distance', () { + expect(DfuProtocol.sequenceDistance(250, 2), 8); + expect(DfuProtocol.sequenceDistance(1, 1), 0); + }); + }); +}