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

@ -1,3 +1,6 @@
import 'package:abawo_bt_app/util/constants.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' show DeviceIdentifier;
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'bluetooth_device_model.freezed.dart';
@ -12,6 +15,24 @@ enum DeviceType {
other,
}
DeviceType deviceTypeFromUuids(List<Uuid> uuids) {
if (uuids.any((uuid) => isAbawoUniversalShiftersDeviceGuid(uuid))) {
return DeviceType.universalShifters;
}
return DeviceType.other;
}
DeviceType deviceTypeFromString(String type) {
return DeviceType.values.firstWhere(
(e) => e.toString().split('.').last == type,
orElse: () => DeviceType.other,
);
}
String deviceTypeToString(DeviceType type) {
return type.toString().split('.').last;
}
/// Model representing a Bluetooth device
@freezed
abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
@ -25,23 +46,28 @@ abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
/// MAC address of the device
required String address,
/// Signal strength indicator (RSSI)
int? rssi,
/// Type of the device
@Default(DeviceType.other) DeviceType type,
/// Whether the device is currently connected
@Default(false) bool isConnected,
/// Additional device information
Map<String, dynamic>? manufacturerData,
/// Service UUIDs advertised by the device
List<String>? serviceUuids,
/// Identifier of the device
@DeviceIdentJsonConverter() required DeviceIdentifier deviceIdent,
}) = _BluetoothDeviceModel;
/// Create a BluetoothDeviceModel from JSON
factory BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
}
class DeviceIdentJsonConverter
implements JsonConverter<DeviceIdentifier, String> {
const DeviceIdentJsonConverter();
@override
DeviceIdentifier fromJson(String json) => DeviceIdentifier(json);
@override
String toJson(DeviceIdentifier object) => object.str;
}

View File

@ -24,20 +24,15 @@ mixin _$BluetoothDeviceModel {
/// MAC address of the device
String get address;
/// Signal strength indicator (RSSI)
int? get rssi;
/// Type of the device
DeviceType get type;
/// Whether the device is currently connected
bool get isConnected;
/// Additional device information
Map<String, dynamic>? get manufacturerData;
/// Service UUIDs advertised by the device
List<String>? get serviceUuids;
/// Identifier of the device
@DeviceIdentJsonConverter()
DeviceIdentifier get deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -58,32 +53,21 @@ mixin _$BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other.manufacturerData, manufacturerData) &&
const DeepCollectionEquality()
.equals(other.serviceUuids, serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(manufacturerData),
const DeepCollectionEquality().hash(serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -97,11 +81,9 @@ abstract mixin class $BluetoothDeviceModelCopyWith<$Res> {
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -120,11 +102,9 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_self.copyWith(
id: null == id
@ -139,26 +119,18 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self.manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self.serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}
@ -170,13 +142,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
{required this.id,
this.name,
required this.address,
this.rssi,
this.type = DeviceType.other,
this.isConnected = false,
final Map<String, dynamic>? manufacturerData,
final List<String>? serviceUuids})
: _manufacturerData = manufacturerData,
_serviceUuids = serviceUuids;
@DeviceIdentJsonConverter() required this.deviceIdent})
: _manufacturerData = manufacturerData;
factory _BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
@ -192,20 +161,11 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
@override
final String address;
/// Signal strength indicator (RSSI)
@override
final int? rssi;
/// Type of the device
@override
@JsonKey()
final DeviceType type;
/// Whether the device is currently connected
@override
@JsonKey()
final bool isConnected;
/// Additional device information
final Map<String, dynamic>? _manufacturerData;
@ -219,18 +179,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
return EqualUnmodifiableMapView(value);
}
/// Service UUIDs advertised by the device
final List<String>? _serviceUuids;
/// Service UUIDs advertised by the device
/// Identifier of the device
@override
List<String>? get serviceUuids {
final value = _serviceUuids;
if (value == null) return null;
if (_serviceUuids is EqualUnmodifiableListView) return _serviceUuids;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@DeviceIdentJsonConverter()
final DeviceIdentifier deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -256,32 +208,21 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other._manufacturerData, _manufacturerData) &&
const DeepCollectionEquality()
.equals(other._serviceUuids, _serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(_manufacturerData),
const DeepCollectionEquality().hash(_serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(_manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -297,11 +238,9 @@ abstract mixin class _$BluetoothDeviceModelCopyWith<$Res>
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -320,11 +259,9 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_BluetoothDeviceModel(
id: null == id
@ -339,26 +276,18 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self._manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self._serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}

View File

@ -12,14 +12,11 @@ _BluetoothDeviceModel _$BluetoothDeviceModelFromJson(
id: json['id'] as String,
name: json['name'] as String?,
address: json['address'] as String,
rssi: (json['rssi'] as num?)?.toInt(),
type: $enumDecodeNullable(_$DeviceTypeEnumMap, json['type']) ??
DeviceType.other,
isConnected: json['isConnected'] as bool? ?? false,
manufacturerData: json['manufacturerData'] as Map<String, dynamic>?,
serviceUuids: (json['serviceUuids'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
deviceIdent: const DeviceIdentJsonConverter()
.fromJson(json['deviceIdent'] as String),
);
Map<String, dynamic> _$BluetoothDeviceModelToJson(
@ -28,11 +25,10 @@ Map<String, dynamic> _$BluetoothDeviceModelToJson(
'id': instance.id,
'name': instance.name,
'address': instance.address,
'rssi': instance.rssi,
'type': _$DeviceTypeEnumMap[instance.type]!,
'isConnected': instance.isConnected,
'manufacturerData': instance.manufacturerData,
'serviceUuids': instance.serviceUuids,
'deviceIdent':
const DeviceIdentJsonConverter().toJson(instance.deviceIdent),
};
const _$DeviceTypeEnumMap = {

View File

@ -0,0 +1,299 @@
import 'package:cbor/simple.dart';
const String universalShifterControlServiceUuid =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40006';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const int errorSequence = 1;
const int errorFtmsMissing = 2;
const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5;
class ShifterErrorInfo {
const ShifterErrorInfo({
required this.code,
required this.title,
required this.details,
});
final int code;
final String title;
final String details;
}
ShifterErrorInfo shifterErrorInfo(int code) {
switch (code) {
case errorSequence:
return const ShifterErrorInfo(
code: errorSequence,
title: 'Invalid command sequence',
details:
'The button received Connect without a fresh target address. The app must write connect_to_addr first, then the connect command.',
);
case errorFtmsMissing:
return const ShifterErrorInfo(
code: errorFtmsMissing,
title: 'FTMS service missing',
details:
'The selected bike does not expose the FTMS service (UUID 0x1826), so pairing cannot continue.',
);
case errorPairingAuth:
return const ShifterErrorInfo(
code: errorPairingAuth,
title: 'Pairing authentication failed',
details:
'Bonding authentication with the bike failed. Remove old bonds on both devices and try pairing again nearby.',
);
case errorPairingEncrypt:
return const ShifterErrorInfo(
code: errorPairingEncrypt,
title: 'Pairing/encryption failed',
details:
'The secure link to the bike could not be established. Retry close to the bike and ensure it is pairable.',
);
case errorFtmsRequiredCharMissing:
return const ShifterErrorInfo(
code: errorFtmsRequiredCharMissing,
title: 'Required FTMS characteristic missing',
details:
'The bike has FTMS but is missing required characteristics (for example Indoor Bike Data), so control cannot start.',
);
default:
return ShifterErrorInfo(
code: code,
title: 'Unknown error',
details: 'The button reported an unknown error code ($code).',
);
}
}
enum UniversalShifterCommand {
reset(0x00),
startScan(0x01),
stopScan(0x02),
connectToDevice(0x03),
disconnect(0x04),
turnOff(0x05);
const UniversalShifterCommand(this.value);
final int value;
}
enum ControlConnectionState {
disconnected,
connected;
static ControlConnectionState fromRaw(dynamic raw) {
if (raw is int) {
return raw == 1
? ControlConnectionState.connected
: ControlConnectionState.disconnected;
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connected')) {
return ControlConnectionState.connected;
}
}
return ControlConnectionState.disconnected;
}
}
enum TrainerConnectionState {
idle,
connecting,
pairing,
discoveringFtms,
ftmsReady,
error,
}
class TrainerStatus {
const TrainerStatus({required this.state, this.errorCode});
final TrainerConnectionState state;
final int? errorCode;
String get label {
switch (state) {
case TrainerConnectionState.idle:
return 'Idle';
case TrainerConnectionState.connecting:
return 'Connecting';
case TrainerConnectionState.pairing:
return 'Pairing';
case TrainerConnectionState.discoveringFtms:
return 'Discovering FTMS';
case TrainerConnectionState.ftmsReady:
return 'FTMS Ready';
case TrainerConnectionState.error:
return 'Error${errorCode != null ? ' ($errorCode)' : ''}';
}
}
static TrainerStatus fromRaw(dynamic raw) {
if (raw is int) {
switch (raw) {
case 1:
return const TrainerStatus(state: TrainerConnectionState.connecting);
case 2:
return const TrainerStatus(state: TrainerConnectionState.pairing);
case 3:
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
case 4:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
default:
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
if (raw is List && raw.isNotEmpty) {
final variant = raw.first;
final value = raw.length > 1 ? raw[1] : null;
if (variant is int && variant == 5) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
if (raw is Map) {
final entry = raw.entries.isNotEmpty ? raw.entries.first : null;
if (entry != null) {
final key = entry.key;
final value = entry.value;
if ((key is int && key == 5) ||
(key is String && key.toLowerCase().contains('error'))) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connecting')) {
return const TrainerStatus(state: TrainerConnectionState.connecting);
}
if (normalized.contains('pairing')) {
return const TrainerStatus(state: TrainerConnectionState.pairing);
}
if (normalized.contains('discover')) {
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
}
if (normalized.contains('ready')) {
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
}
if (normalized.contains('error')) {
return const TrainerStatus(state: TrainerConnectionState.error);
}
}
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
class CentralStatus {
const CentralStatus({
required this.control,
required this.trainer,
required this.hasSavedBond,
required this.connectedTrainerAddr,
required this.lastFailure,
required this.raw,
});
final ControlConnectionState control;
final TrainerStatus trainer;
final bool hasSavedBond;
final List<int>? connectedTrainerAddr;
final int? lastFailure;
final dynamic raw;
String get statusLine =>
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
static CentralStatus fromCborBytes(List<int> bytes) {
final decoded = cbor.decode(bytes);
if (decoded is! Map) {
return CentralStatus(
control: ControlConnectionState.disconnected,
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
hasSavedBond: false,
connectedTrainerAddr: null,
lastFailure: null,
raw: decoded,
);
}
final controlRaw = _readMapValue(decoded, [0, 'control']);
final trainerRaw = _readMapValue(decoded, [1, 'trainer']);
final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']);
final connectedTrainerAddrRaw =
_readMapValue(decoded, [3, 'connected_trainer_addr']);
final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']);
return CentralStatus(
control: ControlConnectionState.fromRaw(controlRaw),
trainer: TrainerStatus.fromRaw(trainerRaw),
hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false,
connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw),
lastFailure: lastFailureRaw is int ? lastFailureRaw : null,
raw: decoded,
);
}
}
dynamic _readMapValue(Map<dynamic, dynamic> map, List<dynamic> keys) {
for (final key in keys) {
if (map.containsKey(key)) {
return map[key];
}
}
return null;
}
List<int>? _toByteList(dynamic value) {
if (value == null) {
return null;
}
if (value is List) {
return value.whereType<int>().toList(growable: false);
}
return null;
}
List<int> parseMacToLittleEndianBytes(String macAddress) {
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
if (compact.length != 12) {
throw FormatException('Invalid MAC address format: $macAddress');
}
final bytes = <int>[];
for (int i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
return bytes.reversed.toList(growable: false);
}
String formatMacAddressFromLittleEndian(List<int> bytes) {
if (bytes.length != 6) {
return 'Unknown';
}
return bytes.reversed
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
}