Files
abawo-bt-app/lib/service/shifter_service.dart

301 lines
8.3 KiB
Dart

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<CentralStatus> _statusController =
StreamController<CentralStatus>.broadcast();
StreamSubscription<List<int>>? _statusSubscription;
Stream<CentralStatus> 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<Result<void>> 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<TrainerScanEvent> subscribeToTrainerScanResults() {
return _requireBluetooth
.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterScanResultCharacteristicUuid,
)
.map(TrainerScanEvent.fromBytes);
}
Future<Result<void>> startTrainerScan() {
return writeCommand(UniversalShifterCommand.startScan);
}
Future<Result<void>> stopTrainerScan() {
return writeCommand(UniversalShifterCommand.stopScan);
}
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid,
[command.value],
);
}
Future<Result<void>> connectButtonToTrainer(
TrainerAddress trainerAddress,
) async {
final addrRes = await writeConnectToTrainerAddress(trainerAddress);
if (addrRes.isErr()) {
return addrRes;
}
return writeCommand(UniversalShifterCommand.connectToDevice);
}
Future<Result<GearRatiosData>> 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<int>.filled(
_gearRatioPayloadBytes,
0,
growable: false,
);
for (var i = 0; i < raw.length; i++) {
normalizedRaw[i] = raw[i];
}
final ratios = <double>[];
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<Result<void>> 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<int>.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<Result<CentralStatus>> 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<Result<ShifterDeviceTelemetry>> readDeviceTelemetry() async {
int? batteryPercent;
String? firmwareRevision;
final errors = <String>[];
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<void> stopStatusNotifications() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
}
Future<void> dispose() async {
await stopStatusNotifications();
await _statusController.close();
}
int _encodeGearRatio(double value) {
return encodeGearRatioByte(value);
}
double _decodeGearRatio(int raw) {
return decodeGearRatioByte(raw);
}
String _formatBytes(List<int> bytes) {
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(' ');
}
}
class GearRatiosData {
const GearRatiosData({
required this.ratios,
required this.defaultGearIndex,
});
final List<double> ratios;
final int defaultGearIndex;
}