mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Merge pull request #151 from jonasbark/copilot/add-cycplus-bc2-support
Add CYCPLUS BC2 virtual shifter support via Nordic UART Service
This commit is contained in:
@@ -45,6 +45,7 @@ Check the compatibility matrix below!
|
||||
- Zwift Ride
|
||||
- Zwift Play
|
||||
- Wahoo Kickr Bike Shift
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Gamepads (beta)
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
|
||||
@@ -37,6 +38,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
@@ -58,6 +60,10 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
|
||||
if (scanResult.name != null && (scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2'))) {
|
||||
device = CycplusBc2(scanResult);
|
||||
}
|
||||
} else {
|
||||
device = switch (scanResult.name) {
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
@@ -72,6 +78,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
device = EliteSterzo(scanResult);
|
||||
} else if (scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else if (scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2')) {
|
||||
device = CycplusBc2(scanResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +115,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
return EliteSquare(scanResult);
|
||||
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
|
||||
return EliteSterzo(scanResult);
|
||||
} else if (scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase())) {
|
||||
return CycplusBc2(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
96
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
96
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
// Process CYCPLUS BC2 data
|
||||
// The BC2 typically sends button press data as simple byte values
|
||||
// Common patterns for virtual shifters:
|
||||
// - 0x01 or similar for shift up
|
||||
// - 0x02 or similar for shift down
|
||||
// - 0x00 for button release
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
final buttonCode = bytes[0];
|
||||
|
||||
switch (buttonCode) {
|
||||
case 0x01:
|
||||
// Shift up button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
|
||||
break;
|
||||
case 0x02:
|
||||
// Shift down button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
|
||||
break;
|
||||
case 0x00:
|
||||
// Button released
|
||||
handleButtonsClicked([]);
|
||||
break;
|
||||
default:
|
||||
// Unknown button code - log for debugging
|
||||
print('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
|
||||
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// TX Characteristic - device sends data to app
|
||||
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// RX Characteristic - app sends data to device (not used for button reading)
|
||||
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
}
|
||||
|
||||
class CycplusBc2Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
color: Colors.green,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.red,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_sterzo.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
@@ -75,5 +76,6 @@ class ControllerButton {
|
||||
...ZwiftButtons.values,
|
||||
...EliteSquareButtons.values,
|
||||
...WahooKickrShiftButtons.values,
|
||||
...CycplusBc2Buttons.values,
|
||||
].distinct().toList();
|
||||
}
|
||||
|
||||
106
test/cycplus_bc2_test.dart
Normal file
106
test/cycplus_bc2_test.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('CYCPLUS BC2 Virtual Shifter Tests', () {
|
||||
test('Should recognize shift up button code', () {
|
||||
// Test button code recognition
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
const releaseCode = 0x00;
|
||||
|
||||
expect(shiftUpCode, equals(0x01));
|
||||
expect(shiftDownCode, equals(0x02));
|
||||
expect(releaseCode, equals(0x00));
|
||||
});
|
||||
|
||||
test('Should handle button press and release cycle', () {
|
||||
// Test button state transitions
|
||||
final states = [0x01, 0x00, 0x02, 0x00];
|
||||
|
||||
expect(states[0], equals(0x01)); // Shift up pressed
|
||||
expect(states[1], equals(0x00)); // Button released
|
||||
expect(states[2], equals(0x02)); // Shift down pressed
|
||||
expect(states[3], equals(0x00)); // Button released
|
||||
});
|
||||
|
||||
test('Should validate UART service UUID format', () {
|
||||
// Nordic UART Service UUID
|
||||
const serviceUuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(serviceUuid.length, equals(36));
|
||||
expect(serviceUuid.contains('-'), isTrue);
|
||||
expect(serviceUuid.toLowerCase(), equals(serviceUuid));
|
||||
});
|
||||
|
||||
test('Should validate TX characteristic UUID format', () {
|
||||
// TX Characteristic UUID (device to app)
|
||||
const txCharUuid = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(txCharUuid.length, equals(36));
|
||||
expect(txCharUuid.contains('-'), isTrue);
|
||||
expect(txCharUuid.toLowerCase(), equals(txCharUuid));
|
||||
});
|
||||
|
||||
test('Should validate RX characteristic UUID format', () {
|
||||
// RX Characteristic UUID (app to device)
|
||||
const rxCharUuid = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(rxCharUuid.length, equals(36));
|
||||
expect(rxCharUuid.contains('-'), isTrue);
|
||||
expect(rxCharUuid.toLowerCase(), equals(rxCharUuid));
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Button Code Tests', () {
|
||||
test('Should differentiate between shift up and shift down', () {
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(shiftUpCode != shiftDownCode, isTrue);
|
||||
expect(shiftUpCode < shiftDownCode, isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize release code as different from press codes', () {
|
||||
const releaseCode = 0x00;
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(releaseCode != shiftUpCode, isTrue);
|
||||
expect(releaseCode != shiftDownCode, isTrue);
|
||||
expect(releaseCode < shiftUpCode, isTrue);
|
||||
expect(releaseCode < shiftDownCode, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Device Name Recognition Tests', () {
|
||||
test('Should recognize CYCPLUS device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'Cycplus BC2';
|
||||
const deviceName3 = 'CYCPLUS';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize BC2 in device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'BC2 Shifter';
|
||||
const deviceName3 = 'Virtual BC2';
|
||||
|
||||
expect(deviceName1.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName2.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName3.toUpperCase().contains('BC2'), isTrue);
|
||||
});
|
||||
|
||||
test('Should not match non-CYCPLUS devices', () {
|
||||
const deviceName1 = 'Zwift Click';
|
||||
const deviceName2 = 'Elite Sterzo';
|
||||
const deviceName3 = 'Wahoo KICKR';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user