feat: add trainer scan protocol models
This commit is contained in:
@ -8,6 +8,8 @@ const String universalShifterStatusCharacteristicUuid =
|
|||||||
'0993826f-0ee4-4b37-9614-d13ecba40000';
|
'0993826f-0ee4-4b37-9614-d13ecba40000';
|
||||||
const String universalShifterConnectToAddrCharacteristicUuid =
|
const String universalShifterConnectToAddrCharacteristicUuid =
|
||||||
'0993826f-0ee4-4b37-9614-d13ecba40001';
|
'0993826f-0ee4-4b37-9614-d13ecba40001';
|
||||||
|
const String universalShifterScanResultCharacteristicUuid =
|
||||||
|
'0993826f-0ee4-4b37-9614-d13ecba40004';
|
||||||
const String universalShifterCommandCharacteristicUuid =
|
const String universalShifterCommandCharacteristicUuid =
|
||||||
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
||||||
const String universalShifterGearRatiosCharacteristicUuid =
|
const String universalShifterGearRatiosCharacteristicUuid =
|
||||||
@ -52,6 +54,13 @@ const int errorPairingAuth = 3;
|
|||||||
const int errorPairingEncrypt = 4;
|
const int errorPairingEncrypt = 4;
|
||||||
const int errorFtmsRequiredCharMissing = 5;
|
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 {
|
enum DfuUpdateState {
|
||||||
idle,
|
idle,
|
||||||
starting,
|
starting,
|
||||||
@ -250,6 +259,144 @@ enum UniversalShifterCommand {
|
|||||||
final int value;
|
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 {
|
class ShifterDeviceTelemetry {
|
||||||
const ShifterDeviceTelemetry({
|
const ShifterDeviceTelemetry({
|
||||||
this.batteryPercent,
|
this.batteryPercent,
|
||||||
@ -522,6 +669,23 @@ List<int> parseMacToLittleEndianBytes(String macAddress) {
|
|||||||
return bytes.reversed.toList(growable: false);
|
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) {
|
String formatMacAddressFromLittleEndian(List<int> bytes) {
|
||||||
if (bytes.length != 6) {
|
if (bytes.length != 6) {
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
|
|||||||
@ -108,6 +108,126 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('TrainerScanEvent.fromBytes', () {
|
||||||
|
test('parses scan lifecycle events', () {
|
||||||
|
expect(
|
||||||
|
TrainerScanEvent.fromBytes(const [1, 0, 7]).kind,
|
||||||
|
TrainerScanEventKind.scanStarted,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
TrainerScanEvent.fromBytes(const [1, 2, 8]).kind,
|
||||||
|
TrainerScanEventKind.scanFinished,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
TrainerScanEvent.fromBytes(const [1, 3, 9]).kind,
|
||||||
|
TrainerScanEventKind.scanCancelled,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses device event with signed RSSI and flags', () {
|
||||||
|
final event = TrainerScanEvent.fromBytes([
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
42,
|
||||||
|
0xc1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
0xd6,
|
||||||
|
trainerScanDeviceFlagFtmsDetected |
|
||||||
|
trainerScanDeviceFlagNameComplete |
|
||||||
|
trainerScanDeviceFlagConnectable,
|
||||||
|
5,
|
||||||
|
...'Kickr'.codeUnits,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(event.kind, TrainerScanEventKind.device);
|
||||||
|
expect(event.sequence, 42);
|
||||||
|
expect(event.result, isNotNull);
|
||||||
|
expect(event.result!.address.flags, 0xc1);
|
||||||
|
expect(event.result!.address.bytes, [1, 2, 3, 4, 5, 6]);
|
||||||
|
expect(event.result!.rssi, -42);
|
||||||
|
expect(event.result!.name, 'Kickr');
|
||||||
|
expect(event.result!.ftmsDetected, isTrue);
|
||||||
|
expect(event.result!.nameComplete, isTrue);
|
||||||
|
expect(event.result!.scanResponseSeen, isFalse);
|
||||||
|
expect(event.result!.connectable, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid scan payloads', () {
|
||||||
|
expect(
|
||||||
|
() => TrainerScanEvent.fromBytes(const []),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => TrainerScanEvent.fromBytes(const [2, 0, 1]),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => TrainerScanEvent.fromBytes(const [1, 9, 1]),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => TrainerScanEvent.fromBytes(const [1, 1, 1]),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => TrainerScanEvent.fromBytes(const [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
65,
|
||||||
|
]),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('encodeTrainerAddress', () {
|
||||||
|
test('encodes flags and address bytes', () {
|
||||||
|
expect(
|
||||||
|
encodeTrainerAddress(
|
||||||
|
const TrainerAddress(flags: 0xc1, bytes: [1, 2, 3, 4, 5, 6]),
|
||||||
|
),
|
||||||
|
[0xc1, 1, 2, 3, 4, 5, 6],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid address values', () {
|
||||||
|
expect(
|
||||||
|
() => encodeTrainerAddress(
|
||||||
|
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5]),
|
||||||
|
),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => encodeTrainerAddress(
|
||||||
|
const TrainerAddress(flags: 256, bytes: [1, 2, 3, 4, 5, 6]),
|
||||||
|
),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => encodeTrainerAddress(
|
||||||
|
const TrainerAddress(flags: 0, bytes: [1, 2, 3, 4, 5, 256]),
|
||||||
|
),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('standard GATT telemetry parsing', () {
|
group('standard GATT telemetry parsing', () {
|
||||||
test('decodes battery level percentage', () {
|
test('decodes battery level percentage', () {
|
||||||
expect(parseBatteryLevelPercent([0]), 0);
|
expect(parseBatteryLevelPercent([0]), 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user