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:
jonasbark
2025-11-02 08:26:42 +01:00
committed by GitHub
5 changed files with 215 additions and 0 deletions

View File

@@ -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)

View File

@@ -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;
}

View 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,
];
}

View File

@@ -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
View 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);
});
});
}