feat(dfu): add connection and MTU preflight checks

This commit is contained in:
2026-03-03 16:54:56 +01:00
parent 7a33e71410
commit fb85565854
4 changed files with 329 additions and 12 deletions

View File

@ -299,11 +299,20 @@ class BluetoothController {
Future<Result<void>> requestMtu(String deviceId, Future<Result<void>> requestMtu(String deviceId,
{int mtu = defaultMtu}) async { {int mtu = defaultMtu}) async {
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
if (result.isErr()) {
return bail(result.unwrapErr());
}
return Ok(null);
}
Future<Result<int>> requestMtuAndGetValue(String deviceId,
{int mtu = defaultMtu}) async {
try { try {
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu); final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
log.info( log.info(
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu'); 'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
return Ok(null); return Ok(negotiatedMtu);
} catch (e) { } catch (e) {
return bail('Error requesting MTU $mtu for $deviceId: $e'); return bail('Error requesting MTU $mtu for $deviceId: $e');
} }

View File

@ -24,6 +24,10 @@ const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuFrameSizeBytes = 64; const int universalShifterDfuFrameSizeBytes = 64;
const int universalShifterDfuFramePayloadSizeBytes = 63; const int universalShifterDfuFramePayloadSizeBytes = 63;
const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuFlagEncrypted = 0x01; const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02; const int universalShifterDfuFlagSigned = 0x02;
@ -107,6 +111,61 @@ class DfuUpdateProgress {
state == DfuUpdateState.failed; 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 { class ShifterErrorInfo {
const ShifterErrorInfo({ const ShifterErrorInfo({
required this.code, required this.code,

View File

@ -6,12 +6,30 @@ import 'package:anyhow/anyhow.dart';
class ShifterService { class ShifterService {
ShifterService({ ShifterService({
required BluetoothController bluetooth, BluetoothController? bluetooth,
required this.buttonDeviceId, 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 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<CentralStatus> _statusController = final StreamController<CentralStatus> _statusController =
StreamController<CentralStatus>.broadcast(); StreamController<CentralStatus>.broadcast();
@ -28,7 +46,7 @@ class ShifterService {
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async { Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
try { try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId); final payload = parseMacToLittleEndianBytes(bikeDeviceId);
return _bluetooth.writeCharacteristic( return _requireBluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
universalShifterConnectToAddrCharacteristicUuid, universalShifterConnectToAddrCharacteristicUuid,
@ -42,7 +60,7 @@ class ShifterService {
} }
Future<Result<void>> writeCommand(UniversalShifterCommand command) { Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _bluetooth.writeCharacteristic( return _requireBluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid, universalShifterCommandCharacteristicUuid,
@ -59,7 +77,7 @@ class ShifterService {
} }
Future<Result<GearRatiosData>> readGearRatios() async { Future<Result<GearRatiosData>> readGearRatios() async {
final readRes = await _bluetooth.readCharacteristic( final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid, universalShifterGearRatiosCharacteristicUuid,
@ -106,8 +124,10 @@ class ShifterService {
} }
Future<Result<void>> writeGearRatios(GearRatiosData data) async { Future<Result<void>> writeGearRatios(GearRatiosData data) async {
final mtuResult = final mtuResult = await _requireBluetooth.requestMtu(
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu); buttonDeviceId,
mtu: _gearRatioWriteMtu,
);
if (mtuResult.isErr()) { if (mtuResult.isErr()) {
return bail( return bail(
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
@ -124,7 +144,7 @@ class ShifterService {
payload[_defaultGearIndexOffset] = payload[_defaultGearIndexOffset] =
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt(); limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
return _bluetooth.writeCharacteristic( return _requireBluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid, universalShifterGearRatiosCharacteristicUuid,
@ -133,7 +153,7 @@ class ShifterService {
} }
Future<Result<CentralStatus>> readStatus() async { Future<Result<CentralStatus>> readStatus() async {
final readRes = await _bluetooth.readCharacteristic( final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid, universalShifterStatusCharacteristicUuid,
@ -149,12 +169,78 @@ class ShifterService {
} }
} }
Future<Result<DfuPreflightResult>> 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() { void startStatusNotifications() {
if (_statusSubscription != null) { if (_statusSubscription != null) {
return; return;
} }
_statusSubscription = _bluetooth _statusSubscription = _requireBluetooth
.subscribeToCharacteristic( .subscribeToCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -202,6 +288,32 @@ class ShifterService {
} }
} }
abstract interface class DfuPreflightBluetoothAdapter {
(ConnectionStatus, String?) get currentConnectionState;
Future<Result<int>> 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<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) {
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
}
}
class GearRatiosData { class GearRatiosData {
const GearRatiosData({ const GearRatiosData({
required this.ratios, required this.ratios,

View File

@ -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<int> mtuResult,
}) : _mtuResult = mtuResult;
@override
final (ConnectionStatus, String?) currentConnectionState;
final Result<int> _mtuResult;
int requestMtuCallCount = 0;
final List<int> requestedMtuValues = <int>[];
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) async {
requestMtuCallCount += 1;
requestedMtuValues.add(mtu);
return _mtuResult;
}
}