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({ required BluetoothController bluetooth, required this.buttonDeviceId, }) : _bluetooth = bluetooth; final BluetoothController _bluetooth; final String buttonDeviceId; 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 _bluetooth.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 _bluetooth.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 _bluetooth.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 _bluetooth.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 _bluetooth.writeCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, universalShifterGearRatiosCharacteristicUuid, payload, ); } Future> readStatus() async { final readRes = await _bluetooth.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'); } } void startStatusNotifications() { if (_statusSubscription != null) { return; } _statusSubscription = _bluetooth .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; } } class GearRatiosData { const GearRatiosData({ required this.ratios, required this.defaultGearIndex, }); final List ratios; final int defaultGearIndex; }