diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index 8ab3c51..e7ab40e 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -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 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 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 parseMacToLittleEndianBytes(String macAddress) { return bytes.reversed.toList(growable: false); } +List 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 bytes) { if (bytes.length != 6) { return 'Unknown'; diff --git a/test/model/shifter_types_test.dart b/test/model/shifter_types_test.dart index b27237f..813db95 100644 --- a/test/model/shifter_types_test.dart +++ b/test/model/shifter_types_test.dart @@ -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', () { test('decodes battery level percentage', () { expect(parseBatteryLevelPercent([0]), 0);