From fb855658540bab34341118f421288ea6d6c6ab15 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 3 Mar 2026 16:54:56 +0100 Subject: [PATCH] feat(dfu): add connection and MTU preflight checks --- lib/controller/bluetooth.dart | 11 ++- lib/model/shifter_types.dart | 59 ++++++++++++ lib/service/shifter_service.dart | 134 +++++++++++++++++++++++--- test/service/dfu_preflight_test.dart | 137 +++++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 test/service/dfu_preflight_test.dart diff --git a/lib/controller/bluetooth.dart b/lib/controller/bluetooth.dart index 05bf056..c63b091 100644 --- a/lib/controller/bluetooth.dart +++ b/lib/controller/bluetooth.dart @@ -299,11 +299,20 @@ class BluetoothController { Future> requestMtu(String deviceId, {int mtu = defaultMtu}) async { + final result = await requestMtuAndGetValue(deviceId, mtu: mtu); + if (result.isErr()) { + return bail(result.unwrapErr()); + } + return Ok(null); + } + + Future> requestMtuAndGetValue(String deviceId, + {int mtu = defaultMtu}) async { try { final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu); log.info( 'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu'); - return Ok(null); + return Ok(negotiatedMtu); } catch (e) { return bail('Error requesting MTU $mtu for $deviceId: $e'); } diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index 55fc959..e51fbce 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -24,6 +24,10 @@ const int universalShifterDfuOpcodeAbort = 0x03; const int universalShifterDfuFrameSizeBytes = 64; const int universalShifterDfuFramePayloadSizeBytes = 63; +const int universalShifterAttWriteOverheadBytes = 3; +const int universalShifterDfuMinimumMtu = + universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes; +const int universalShifterDfuPreferredMtu = 128; const int universalShifterDfuFlagEncrypted = 0x01; const int universalShifterDfuFlagSigned = 0x02; @@ -107,6 +111,61 @@ class DfuUpdateProgress { state == DfuUpdateState.failed; } +enum DfuPreflightFailureReason { + deviceNotConnected, + wrongConnectedDevice, + mtuRequestFailed, + mtuTooLow, +} + +class DfuPreflightResult { + const DfuPreflightResult._({ + required this.requestedMtu, + required this.requiredMtu, + required this.negotiatedMtu, + required this.failureReason, + required this.message, + }); + + final int requestedMtu; + final int requiredMtu; + final int? negotiatedMtu; + final DfuPreflightFailureReason? failureReason; + final String? message; + + bool get canStart => failureReason == null; + + static DfuPreflightResult ready({ + required int requestedMtu, + required int negotiatedMtu, + int requiredMtu = universalShifterDfuMinimumMtu, + }) { + return DfuPreflightResult._( + requestedMtu: requestedMtu, + requiredMtu: requiredMtu, + negotiatedMtu: negotiatedMtu, + failureReason: null, + message: null, + ); + } + + static DfuPreflightResult failed({ + required int requestedMtu, + required DfuPreflightFailureReason failureReason, + required String message, + int requiredMtu = universalShifterDfuMinimumMtu, + int? negotiatedMtu, + }) { + return DfuPreflightResult._( + requestedMtu: requestedMtu, + requiredMtu: requiredMtu, + negotiatedMtu: negotiatedMtu, + failureReason: failureReason, + message: message, + ); + } +} + class ShifterErrorInfo { const ShifterErrorInfo({ required this.code, diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart index 0892067..26eeb95 100644 --- a/lib/service/shifter_service.dart +++ b/lib/service/shifter_service.dart @@ -6,12 +6,30 @@ import 'package:anyhow/anyhow.dart'; class ShifterService { ShifterService({ - required BluetoothController bluetooth, + BluetoothController? bluetooth, required this.buttonDeviceId, - }) : _bluetooth = bluetooth; + DfuPreflightBluetoothAdapter? dfuPreflightBluetooth, + }) : _bluetooth = bluetooth, + _dfuPreflightBluetooth = + dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) { + if (bluetooth == null && dfuPreflightBluetooth == null) { + throw ArgumentError( + 'Either bluetooth or dfuPreflightBluetooth must be provided.', + ); + } + } - final BluetoothController _bluetooth; + final BluetoothController? _bluetooth; final String buttonDeviceId; + final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth; + + BluetoothController get _requireBluetooth { + final bluetooth = _bluetooth; + if (bluetooth == null) { + throw StateError('Bluetooth controller is not available.'); + } + return bluetooth; + } final StreamController _statusController = StreamController.broadcast(); @@ -28,7 +46,7 @@ class ShifterService { Future> writeConnectToAddress(String bikeDeviceId) async { try { final payload = parseMacToLittleEndianBytes(bikeDeviceId); - return _bluetooth.writeCharacteristic( + return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterConnectToAddrCharacteristicUuid, @@ -42,7 +60,7 @@ class ShifterService { } Future> writeCommand(UniversalShifterCommand command) { - return _bluetooth.writeCharacteristic( + return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterCommandCharacteristicUuid, @@ -59,7 +77,7 @@ class ShifterService { } Future> readGearRatios() async { - final readRes = await _bluetooth.readCharacteristic( + final readRes = await _requireBluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, @@ -106,8 +124,10 @@ class ShifterService { } Future> writeGearRatios(GearRatiosData data) async { - final mtuResult = - await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu); + final mtuResult = await _requireBluetooth.requestMtu( + buttonDeviceId, + mtu: _gearRatioWriteMtu, + ); if (mtuResult.isErr()) { return bail( 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); @@ -124,7 +144,7 @@ class ShifterService { payload[_defaultGearIndexOffset] = limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt(); - return _bluetooth.writeCharacteristic( + return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, @@ -133,7 +153,7 @@ class ShifterService { } Future> readStatus() async { - final readRes = await _bluetooth.readCharacteristic( + final readRes = await _requireBluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterStatusCharacteristicUuid, @@ -149,12 +169,78 @@ class ShifterService { } } + Future> runDfuPreflight({ + int requestedMtu = universalShifterDfuPreferredMtu, + }) async { + final currentConnection = _dfuPreflightBluetooth.currentConnectionState; + final connectionStatus = currentConnection.$1; + final connectedDeviceId = currentConnection.$2; + + if (connectionStatus != ConnectionStatus.connected || + connectedDeviceId == null) { + return Ok( + DfuPreflightResult.failed( + requestedMtu: requestedMtu, + failureReason: DfuPreflightFailureReason.deviceNotConnected, + message: + 'No button connection is active. Connect the target button, then retry the firmware update.', + ), + ); + } + + if (connectedDeviceId != buttonDeviceId) { + return Ok( + DfuPreflightResult.failed( + requestedMtu: requestedMtu, + failureReason: DfuPreflightFailureReason.wrongConnectedDevice, + message: + 'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.', + ), + ); + } + + final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue( + buttonDeviceId, + mtu: requestedMtu, + ); + if (mtuResult.isErr()) { + return Ok( + DfuPreflightResult.failed( + requestedMtu: requestedMtu, + failureReason: DfuPreflightFailureReason.mtuRequestFailed, + message: + 'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}', + ), + ); + } + + final negotiatedMtu = mtuResult.unwrap(); + if (negotiatedMtu < universalShifterDfuMinimumMtu) { + return Ok( + DfuPreflightResult.failed( + requestedMtu: requestedMtu, + negotiatedMtu: negotiatedMtu, + failureReason: DfuPreflightFailureReason.mtuTooLow, + message: + 'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.', + ), + ); + } + + return Ok( + DfuPreflightResult.ready( + requestedMtu: requestedMtu, + negotiatedMtu: negotiatedMtu, + ), + ); + } + void startStatusNotifications() { if (_statusSubscription != null) { return; } - _statusSubscription = _bluetooth + _statusSubscription = _requireBluetooth .subscribeToCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, @@ -202,6 +288,32 @@ class ShifterService { } } +abstract interface class DfuPreflightBluetoothAdapter { + (ConnectionStatus, String?) get currentConnectionState; + Future> requestMtuAndGetValue( + String deviceId, { + required int mtu, + }); +} + +class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter { + const _BluetoothDfuPreflightAdapter(this._bluetooth); + + final BluetoothController _bluetooth; + + @override + (ConnectionStatus, String?) get currentConnectionState => + _bluetooth.currentConnectionState; + + @override + Future> requestMtuAndGetValue( + String deviceId, { + required int mtu, + }) { + return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu); + } +} + class GearRatiosData { const GearRatiosData({ required this.ratios, diff --git a/test/service/dfu_preflight_test.dart b/test/service/dfu_preflight_test.dart new file mode 100644 index 0000000..005814a --- /dev/null +++ b/test/service/dfu_preflight_test.dart @@ -0,0 +1,137 @@ +import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/shifter_service.dart'; +import 'package:anyhow/anyhow.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ShifterService.runDfuPreflight', () { + test('fails when no active button connection exists', () async { + final adapter = _FakeDfuPreflightBluetoothAdapter( + currentConnectionState: (ConnectionStatus.disconnected, null), + mtuResult: Ok(128), + ); + final service = ShifterService( + buttonDeviceId: 'target-device', + dfuPreflightBluetooth: adapter, + ); + + final result = await service.runDfuPreflight(); + + expect(result.isOk(), isTrue); + final preflight = result.unwrap(); + expect(preflight.canStart, isFalse); + expect(preflight.failureReason, + DfuPreflightFailureReason.deviceNotConnected); + expect(adapter.requestMtuCallCount, 0); + }); + + test('fails when connected to a different button', () async { + final adapter = _FakeDfuPreflightBluetoothAdapter( + currentConnectionState: (ConnectionStatus.connected, 'wrong-device'), + mtuResult: Ok(128), + ); + final service = ShifterService( + buttonDeviceId: 'target-device', + dfuPreflightBluetooth: adapter, + ); + + final result = await service.runDfuPreflight(); + + expect(result.isOk(), isTrue); + final preflight = result.unwrap(); + expect(preflight.canStart, isFalse); + expect(preflight.failureReason, + DfuPreflightFailureReason.wrongConnectedDevice); + expect(adapter.requestMtuCallCount, 0); + }); + + test('fails when MTU negotiation fails', () async { + final adapter = _FakeDfuPreflightBluetoothAdapter( + currentConnectionState: (ConnectionStatus.connected, 'target-device'), + mtuResult: bail('adapter rejected mtu request'), + ); + final service = ShifterService( + buttonDeviceId: 'target-device', + dfuPreflightBluetooth: adapter, + ); + + final result = await service.runDfuPreflight(requestedMtu: 247); + + expect(result.isOk(), isTrue); + final preflight = result.unwrap(); + expect(preflight.canStart, isFalse); + expect( + preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed); + expect(preflight.message, contains('adapter rejected mtu request')); + expect(adapter.requestedMtuValues, [247]); + }); + + test('fails when negotiated MTU is too low for 64-byte frame writes', + () async { + final adapter = _FakeDfuPreflightBluetoothAdapter( + currentConnectionState: (ConnectionStatus.connected, 'target-device'), + mtuResult: Ok(universalShifterDfuMinimumMtu - 1), + ); + final service = ShifterService( + buttonDeviceId: 'target-device', + dfuPreflightBluetooth: adapter, + ); + + final result = await service.runDfuPreflight(); + + expect(result.isOk(), isTrue); + final preflight = result.unwrap(); + expect(preflight.canStart, isFalse); + expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow); + expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1); + expect(preflight.requiredMtu, universalShifterDfuMinimumMtu); + }); + + test('passes when connected to target and MTU is sufficient', () async { + final adapter = _FakeDfuPreflightBluetoothAdapter( + currentConnectionState: (ConnectionStatus.connected, 'target-device'), + mtuResult: Ok(128), + ); + final service = ShifterService( + buttonDeviceId: 'target-device', + dfuPreflightBluetooth: adapter, + ); + + final result = await service.runDfuPreflight(); + + expect(result.isOk(), isTrue); + final preflight = result.unwrap(); + expect(preflight.canStart, isTrue); + expect(preflight.failureReason, isNull); + expect(preflight.negotiatedMtu, 128); + expect(preflight.requestedMtu, universalShifterDfuPreferredMtu); + }); + }); +} + +class _FakeDfuPreflightBluetoothAdapter + implements DfuPreflightBluetoothAdapter { + _FakeDfuPreflightBluetoothAdapter({ + required this.currentConnectionState, + required Result mtuResult, + }) : _mtuResult = mtuResult; + + @override + final (ConnectionStatus, String?) currentConnectionState; + + final Result _mtuResult; + + int requestMtuCallCount = 0; + final List requestedMtuValues = []; + + @override + Future> requestMtuAndGetValue( + String deviceId, { + required int mtu, + }) async { + requestMtuCallCount += 1; + requestedMtuValues.add(mtu); + return _mtuResult; + } +}