301 lines
8.3 KiB
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;
|
|
}
|