feat: add trainer scan protocol models

This commit is contained in:
2026-04-28 21:28:40 +02:00
parent 76b7195e5e
commit 7628947623
2 changed files with 284 additions and 0 deletions

View File

@ -8,6 +8,8 @@ const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterScanResultCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40004';
const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid =
@ -52,6 +54,13 @@ const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5;
const int trainerScanProtocolVersion = 1;
const int trainerScanDeviceFlagFtmsDetected = 0x01;
const int trainerScanDeviceFlagNameComplete = 0x02;
const int trainerScanDeviceFlagScanResponseSeen = 0x04;
const int trainerScanDeviceFlagConnectable = 0x08;
enum DfuUpdateState {
idle,
starting,
@ -250,6 +259,144 @@ enum UniversalShifterCommand {
final int value;
}
enum TrainerScanEventKind {
scanStarted(0),
device(1),
scanFinished(2),
scanCancelled(3);
const TrainerScanEventKind(this.value);
final int value;
static TrainerScanEventKind fromRaw(int value) {
for (final kind in values) {
if (kind.value == value) {
return kind;
}
}
throw FormatException('Unknown trainer scan event kind: $value');
}
}
class TrainerAddress {
const TrainerAddress({
required this.flags,
required this.bytes,
});
final int flags;
final List<int> bytes;
String get key => '${flags.toRadixString(16).padLeft(2, '0')}:'
'${bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! TrainerAddress ||
other.flags != flags ||
other.bytes.length != bytes.length) {
return false;
}
for (var i = 0; i < bytes.length; i++) {
if (other.bytes[i] != bytes[i]) {
return false;
}
}
return true;
}
@override
int get hashCode => Object.hash(flags, Object.hashAll(bytes));
}
class TrainerScanResult {
const TrainerScanResult({
required this.sequence,
required this.address,
required this.rssi,
required this.flags,
required this.name,
});
final int sequence;
final TrainerAddress address;
final int rssi;
final int flags;
final String name;
bool get ftmsDetected => (flags & trainerScanDeviceFlagFtmsDetected) != 0;
bool get nameComplete => (flags & trainerScanDeviceFlagNameComplete) != 0;
bool get scanResponseSeen =>
(flags & trainerScanDeviceFlagScanResponseSeen) != 0;
bool get connectable => (flags & trainerScanDeviceFlagConnectable) != 0;
}
class TrainerScanEvent {
const TrainerScanEvent({
required this.kind,
required this.sequence,
this.result,
});
final TrainerScanEventKind kind;
final int sequence;
final TrainerScanResult? result;
static TrainerScanEvent fromBytes(List<int> bytes) {
if (bytes.length < 3) {
throw FormatException(
'Trainer scan event payload too short: ${bytes.length}',
);
}
if (bytes[0] != trainerScanProtocolVersion) {
throw FormatException(
'Unsupported trainer scan protocol version: ${bytes[0]}',
);
}
final kind = TrainerScanEventKind.fromRaw(bytes[1]);
final sequence = bytes[2];
if (kind != TrainerScanEventKind.device) {
return TrainerScanEvent(kind: kind, sequence: sequence);
}
if (bytes.length < 13) {
throw FormatException(
'Trainer scan device payload too short: ${bytes.length}',
);
}
final nameLength = bytes[12];
if (bytes.length < 13 + nameLength) {
throw FormatException(
'Trainer scan device name length $nameLength exceeds payload length '
'${bytes.length}',
);
}
final rssiRaw = bytes[10];
final rssi = rssiRaw > 127 ? rssiRaw - 256 : rssiRaw;
final result = TrainerScanResult(
sequence: sequence,
address: TrainerAddress(
flags: bytes[3],
bytes: bytes.sublist(4, 10).toList(growable: false),
),
rssi: rssi,
flags: bytes[11],
name: utf8.decode(bytes.sublist(13, 13 + nameLength)),
);
return TrainerScanEvent(
kind: kind,
sequence: sequence,
result: result,
);
}
}
class ShifterDeviceTelemetry {
const ShifterDeviceTelemetry({
this.batteryPercent,
@ -522,6 +669,23 @@ List<int> parseMacToLittleEndianBytes(String macAddress) {
return bytes.reversed.toList(growable: false);
}
List<int> encodeTrainerAddress(TrainerAddress address) {
if (address.flags < 0 || address.flags > 0xff) {
throw FormatException('Invalid trainer address flags: ${address.flags}');
}
if (address.bytes.length != 6) {
throw FormatException(
'Invalid trainer address length: ${address.bytes.length}',
);
}
for (final byte in address.bytes) {
if (byte < 0 || byte > 0xff) {
throw FormatException('Invalid trainer address byte: $byte');
}
}
return [address.flags, ...address.bytes];
}
String formatMacAddressFromLittleEndian(List<int> bytes) {
if (bytes.length != 6) {
return 'Unknown';