import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:anyhow/anyhow.dart'; class ShifterService { ShifterService({ BluetoothController? bluetooth, required this.buttonDeviceId, 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 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(); StreamSubscription>? _statusSubscription; Stream get statusStream => _statusController.stream; static const int _gearRatioSlots = 32; static const int _defaultGearIndexOffset = _gearRatioSlots; static const int _gearRatioPayloadBytes = _gearRatioSlots + 1; static const double _maxGearRatio = 255 / 64; static const int _gearRatioWriteMtu = 64; Future> writeConnectToAddress(String bikeDeviceId) async { try { final payload = parseMacToLittleEndianBytes(bikeDeviceId); return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterConnectToAddrCharacteristicUuid, payload, ); } on FormatException catch (e) { return bail('Could not parse bike address "$bikeDeviceId": $e'); } catch (e) { return bail('Failed writing connect address: $e'); } } Future> writeCommand(UniversalShifterCommand command) { return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterCommandCharacteristicUuid, [command.value], ); } Future> connectButtonToBike(String bikeDeviceId) async { final addrRes = await writeConnectToAddress(bikeDeviceId); if (addrRes.isErr()) { return addrRes; } return writeCommand(UniversalShifterCommand.connectToDevice); } Future> readGearRatios() async { final readRes = await _requireBluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, ); if (readRes.isErr()) { return bail(readRes.unwrapErr()); } final raw = readRes.unwrap(); if (raw.length > _gearRatioPayloadBytes) { return bail( 'Invalid gear ratio payload length: expected at most $_gearRatioPayloadBytes, got ${raw.length}', ); } final normalizedRaw = List.filled( _gearRatioPayloadBytes, 0, growable: false, ); for (var i = 0; i < raw.length; i++) { normalizedRaw[i] = raw[i]; } final ratios = []; for (var i = 0; i < _gearRatioSlots; i++) { final value = normalizedRaw[i]; if (value == 0) { break; } ratios.add(_decodeGearRatio(value)); } final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset]; final defaultGearIndex = ratios.isEmpty ? 0 : defaultIndexRaw.clamp(0, ratios.length - 1).toInt(); return Ok( GearRatiosData( ratios: ratios, defaultGearIndex: defaultGearIndex, ), ); } Future> writeGearRatios(GearRatiosData data) async { final mtuResult = await _requireBluetooth.requestMtu( buttonDeviceId, mtu: _gearRatioWriteMtu, ); if (mtuResult.isErr()) { return bail( 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); } final payload = List.filled(_gearRatioPayloadBytes, 0, growable: false); final ratios = data.ratios; final limit = ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots; for (var i = 0; i < limit; i++) { payload[i] = _encodeGearRatio(ratios[i]); } payload[_defaultGearIndexOffset] = limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt(); return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, payload, ); } Future> readStatus() async { final readRes = await _requireBluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterStatusCharacteristicUuid, ); if (readRes.isErr()) { return bail(readRes.unwrapErr()); } try { return Ok(CentralStatus.fromCborBytes(readRes.unwrap())); } catch (e) { return bail('Failed to decode status payload: $e'); } } 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 = _requireBluetooth .subscribeToCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterStatusCharacteristicUuid, ) .listen( (data) { try { final status = CentralStatus.fromCborBytes(data); _statusController.add(status); } catch (_) { // Ignore malformed payloads but keep stream alive. } }, onError: (_) { // Keep UI running; reconnection logic is handled elsewhere. }, ); } Future stopStatusNotifications() async { await _statusSubscription?.cancel(); _statusSubscription = null; } Future dispose() async { await stopStatusNotifications(); await _statusController.close(); } int _encodeGearRatio(double value) { if (value <= 0) { return 0; } final clamped = value.clamp(0, _maxGearRatio); final scaled = (clamped * 64).round(); if (scaled <= 0) { return 1; } return scaled.clamp(1, 255); } double _decodeGearRatio(int raw) { return raw / 64.0; } } 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, required this.defaultGearIndex, }); final List ratios; final int defaultGearIndex; }