zwift click emulation #1

This commit is contained in:
Jonas Bark
2025-10-28 12:25:51 +01:00
parent 8c09b170c3
commit 1368d7d24e
6 changed files with 430 additions and 21 deletions

View File

@@ -1,8 +1,7 @@
class BleUuid {
static final DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb".toLowerCase();
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
.toLowerCase();
static const DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb";
static const DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb";
static final DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb".toLowerCase();
static final DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb".toLowerCase();
static const DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb";
static const DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002A19-0000-1000-8000-00805F9B34FB";
}

View File

@@ -79,8 +79,8 @@ abstract class BluetoothDevice extends BaseDevice {
if (device != null) {
return device;
} else if (scanResult.services.containsAny([
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
])) {
// otherwise use the manufacturer data to identify the device
final manufacturerData = scanResult.manufacturerDataList;

View File

@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
class ZwiftConstants {
static final ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb".toLowerCase();
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb";
static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A

View File

@@ -24,7 +24,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> handleServices(List<BleService> services) async {
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId.toLowerCase());
if (customService == null) {
throw Exception(
@@ -33,10 +33,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
@@ -56,13 +56,13 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
);
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(),
);
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(),
);
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
@@ -87,9 +87,9 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (kDebugMode && false) {
if (kDebugMode) {
print(
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
"${DateTime.now().toString().split(" ").last} Received data on $characteristic:\n${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
);
}
if (bytes.isEmpty) {

View File

@@ -7,6 +7,7 @@ import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/remote.dart';
import 'package:swift_control/utils/requirements/zwift.dart';
import 'package:universal_ble/universal_ble.dart';
abstract class PlatformRequirement {
@@ -83,7 +84,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
],
switch (connectionType) {
ConnectionType.local => AccessibilityRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.remote => ZwiftRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];

View File

@@ -0,0 +1,409 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../../pages/markdown.dart';
final peripheralManager = PeripheralManager();
bool _isAdvertising = false;
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Connect to your target device',
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
switch (settings.getLastTarget()) {
Target.iPad =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.',
},
);
}
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
_isServiceAdded = false;
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
if (!kIsWeb && Platform.isAndroid) {
if (Platform.isAndroid) {
peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
//peripheralManager.stopAdvertising();
onUpdate();*/
} else if (state.state == ConnectionState.disconnected) {
(actionHandler as RemoteActions).setConnectedCentral(null, null);
onUpdate();
}
});
}
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
_isAdvertising = false;
onUpdate();
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
print('Waiting for peripheral manager to be powered on...');
if (settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final syncTxCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.indicate,
],
permissions: [
GATTCharacteristicPermission.read,
],
);
final asyncCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID:
print('Handling read request for SYNC TX characteristic');
break;
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
break;
default:
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
}
final central = eventArgs.central;
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final offset = request.offset;
final trimmedValue = Uint8List.fromList([]);
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
final central = eventArgs.central;
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final offset = request.offset;
final value = request.value;
print(
'Write request for characteristic: ${characteristic.uuid}, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}',
);
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID:
print('Handling write request for SYNC RX characteristic');
if (value.startsWith(ZwiftConstants.RIDE_ON)) {
await peripheralManager.notifyCharacteristic(
central,
syncTxCharacteristic,
value: ZwiftConstants.RIDE_ON,
);
await peripheralManager.notifyCharacteristic(
central,
asyncCharacteristic,
value: Uint8List.fromList([0x19, 0x10, 0x03]),
);
}
break;
default:
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
}
await peripheralManager.respondWriteRequest(request);
});
}
// Device Information
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
characteristics: [
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A29'),
value: Uint8List.fromList('Zwift Inc'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A25'),
value: Uint8List.fromList('09-B48123283828FFD82'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A27'),
value: Uint8List.fromList('A.0'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A26'),
value: Uint8List.fromList('1.1.0'.codeUnits),
descriptors: [],
),
],
includedServices: [],
),
);
// Battery Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
characteristics: [
GATTCharacteristic.mutable(
uuid: UUID.fromString('2A19'),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.notify,
],
permissions: [
GATTCharacteristicPermission.read,
],
),
],
includedServices: [],
),
);
// Unknown Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID),
isPrimary: true,
characteristics: [
asyncCharacteristic,
GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.writeWithoutResponse,
],
permissions: [],
),
syncTxCharacteristic,
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
),
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.indicate,
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.writeWithoutResponse,
GATTCharacteristicProperty.write,
],
permissions: [
GATTCharacteristicPermission.read,
GATTCharacteristicPermission.write,
],
),
],
includedServices: [],
),
);
_isServiceAdded = true;
}
final advertisement = Advertisement(
name: 'SwiftControl',
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID)],
manufacturerSpecificData: [
ManufacturerSpecificData(id: 0x094A, data: Uint8List.fromList([0x09, 0xFD, 0x82])),
],
);
print('Starting advertising with HID service...');
await peripheralManager.startAdvertising(advertisement);
_isAdvertising = true;
onUpdate();
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return _PairWidget(onUpdate: onUpdate, requirement: this);
}
@override
Future<void> getStatus() async {
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
}
}
class _PairWidget extends StatefulWidget {
final ZwiftRequirement requirement;
final VoidCallback onUpdate;
const _PairWidget({super.key, required this.onUpdate, required this.requirement});
@override
State<_PairWidget> createState() => _PairWidgetState();
}
class _PairWidgetState extends State<_PairWidget> {
@override
void initState() {
super.initState();
// after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
toggle().catchError((e) {
print('Error starting advertising: $e');
});
});
}
@override
Widget build(BuildContext context) {
return Column(
spacing: 10,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 10,
children: [
ElevatedButton(
onPressed: () async {
try {
await toggle();
} catch (e) {
print('Error toggling advertising: $e');
}
},
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
],
),
if (settings.getTrainerApp() is MyWhoosh)
ElevatedButton(
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => DevicePage(),
settings: RouteSettings(name: '/device'),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Use MyWhoosh Link only'),
Text(
'No pairing required, connect directly via MyWhoosh Link.',
style: TextStyle(fontSize: 10, color: Colors.black87),
),
],
),
),
),
if (_isAdvertising) ...[
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
},
child: Text('Check the troubleshooting guide'),
),
],
],
);
}
Future<void> toggle() async {
if (_isAdvertising) {
await peripheralManager.stopAdvertising();
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
widget.onUpdate();
_isLoading = false;
setState(() {});
} else {
_isLoading = true;
setState(() {});
await widget.requirement.startAdvertising(widget.onUpdate);
_isLoading = false;
if (mounted) setState(() {});
}
}
}