feat: working connection, conn setting, and gear ratio setting for universal shifters

This commit is contained in:
2026-02-22 23:05:12 +01:00
parent f92d6d04f5
commit dcb1e6596e
93 changed files with 10538 additions and 668 deletions

View File

@ -0,0 +1,179 @@
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<CentralStatus> _statusController =
StreamController<CentralStatus>.broadcast();
StreamSubscription<List<int>>? _statusSubscription;
Stream<CentralStatus> get statusStream => _statusController.stream;
static const int _gearRatioSlots = 32;
static const double _maxGearRatio = 255 / 64;
static const int _gearRatioWriteMtu = 64;
Future<Result<void>> 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<Result<void>> writeCommand(UniversalShifterCommand command) {
return _bluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid,
[command.value],
);
}
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async {
final addrRes = await writeConnectToAddress(bikeDeviceId);
if (addrRes.isErr()) {
return addrRes;
}
return writeCommand(UniversalShifterCommand.connectToDevice);
}
Future<Result<List<double>>> readGearRatios() async {
final readRes = await _bluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
}
final raw = readRes.unwrap();
if (raw.length > _gearRatioSlots) {
return bail(
'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}',
);
}
final normalizedRaw = List<int>.filled(_gearRatioSlots, 0, growable: false);
for (var i = 0; i < raw.length; i++) {
normalizedRaw[i] = raw[i];
}
final ratios = normalizedRaw
.where((v) => v > 0)
.map((v) => _decodeGearRatio(v))
.toList(growable: false);
return Ok(ratios);
}
Future<Result<void>> writeGearRatios(List<double> ratios) 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<int>.filled(_gearRatioSlots, 0, growable: false);
final limit =
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
for (var i = 0; i < limit; i++) {
payload[i] = _encodeGearRatio(ratios[i]);
}
return _bluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
payload,
);
}
Future<Result<CentralStatus>> 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<void> stopStatusNotifications() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
}
Future<void> 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;
}
}