180 lines
5.0 KiB
Dart
180 lines
5.0 KiB
Dart
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;
|
|
}
|
|
}
|