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({ required BluetoothController bluetooth, required this.buttonDeviceId, }) : _bluetooth = bluetooth; final BluetoothController _bluetooth; final String buttonDeviceId; BluetoothController get _requireBluetooth { 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> 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> 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, ), ); } 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(' '); } } class GearRatiosData { const GearRatiosData({ required this.ratios, required this.defaultGearIndex, }); final List ratios; final int defaultGearIndex; }