feat: working connection, conn setting, and gear ratio setting for universal shifters
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
299
lib/model/shifter_types.dart
Normal file
299
lib/model/shifter_types.dart
Normal 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(':');
|
||||
}
|
||||
Reference in New Issue
Block a user