import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/gear_ratio_codec.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:anyhow/anyhow.dart'; import 'package:logging/logging.dart'; final _log = Logger('ShifterService'); 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 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> writeConnectToTrainerAddress( TrainerAddress trainerAddress, ) async { try { final payload = encodeTrainerAddress(trainerAddress); return _requireBluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterConnectToAddrCharacteristicUuid, payload, ); } on FormatException catch (e) { return bail('Could not encode trainer address: $e'); } catch (e) { return bail('Failed writing trainer address: $e'); } } Stream subscribeToTrainerScanResults() { return _requireBluetooth .subscribeToCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterScanResultCharacteristicUuid, ) .map(TrainerScanEvent.fromBytes); } Future> startTrainerScan() { return writeCommand(UniversalShifterCommand.startScan); } Future> stopTrainerScan() { return writeCommand(UniversalShifterCommand.stopScan); } 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> connectButtonToTrainer( TrainerAddress trainerAddress, ) async { final addrRes = await writeConnectToTrainerAddress(trainerAddress); if (addrRes.isErr()) { return addrRes; } return writeCommand(UniversalShifterCommand.connectToDevice); } Future> readGearRatios() async { final readRes = await _requireBluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, ); if (readRes.isErr()) { return Err(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 Err(readRes.unwrapErr()); } try { return Ok(CentralStatus.fromBytes(readRes.unwrap())); } catch (e) { return bail('Failed to decode status payload: $e'); } } Future> readDeviceTelemetry() async { int? batteryPercent; String? firmwareRevision; final errors = []; final batteryResult = await _requireBluetooth.readCharacteristic( buttonDeviceId, batteryServiceUuid, batteryLevelCharacteristicUuid, ); if (batteryResult.isOk()) { try { batteryPercent = parseBatteryLevelPercent(batteryResult.unwrap()); } catch (error) { errors.add('battery parse failed: $error'); } } else { errors.add('battery read failed: ${batteryResult.unwrapErr()}'); } final firmwareResult = await _requireBluetooth.readCharacteristic( buttonDeviceId, deviceInformationServiceUuid, firmwareRevisionCharacteristicUuid, ); if (firmwareResult.isOk()) { try { firmwareRevision = parseGattUtf8String(firmwareResult.unwrap()); } catch (error) { errors.add('firmware parse failed: $error'); } } else { errors.add('firmware read failed: ${firmwareResult.unwrapErr()}'); } if (batteryPercent == null && firmwareRevision == null && errors.isNotEmpty) { return bail('Could not read battery or firmware: ${errors.join('; ')}'); } return Ok( ShifterDeviceTelemetry( batteryPercent: batteryPercent, firmwareRevision: firmwareRevision, ), ); } 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; } try { _statusSubscription = _requireBluetooth .subscribeToCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterStatusCharacteristicUuid, ) .listen( (data) { try { final status = CentralStatus.fromBytes(data); _statusController.add(status); } catch (error, st) { _log.warning( 'Failed to decode status notification from $buttonDeviceId: ' 'bytes=${_formatBytes(data)}', error, st, ); } }, onError: (Object error, StackTrace st) { _log.warning('Status notification stream failed', error, st); }, ); } catch (error, st) { _log.warning('Could not start status notifications', error, st); } } Future stopStatusNotifications() async { await _statusSubscription?.cancel(); _statusSubscription = null; } Future dispose() async { await stopStatusNotifications(); await _statusController.close(); } int _encodeGearRatio(double value) { return encodeGearRatioByte(value); } double _decodeGearRatio(int raw) { return decodeGearRatioByte(raw); } String _formatBytes(List bytes) { return bytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(' '); } } 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; }