mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb0e5616a | ||
|
|
7e98f595ee | ||
|
|
a9fdc4b16e | ||
|
|
c06819b502 | ||
|
|
969faca658 | ||
|
|
61fbb099e2 | ||
|
|
fbd6356be0 | ||
|
|
1c40455bf3 | ||
|
|
15129634a6 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,2 @@
|
||||
github: [jonasbark]
|
||||
open_collective: jonas-bark1
|
||||
custom: ["https://paypal.me/boni"]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
### 2.2.0 (2025-01-03)
|
||||
### 2.3.0 (2025-09-11)
|
||||
- Add support for latest Zwift Click v2
|
||||
|
||||
### 2.2.0 (2025-09-08)
|
||||
- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61)
|
||||
- Windows: adjust key sending method to improve compatibility with more apps (fixes #62)
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering / turning
|
||||
- adjust workout intensity
|
||||
@@ -35,23 +35,22 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
|
||||
## Supported Platforms
|
||||
- Android
|
||||
- App is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
|
||||
- macOS
|
||||
- Windows
|
||||
- make sure you have installed the "[Microsoft Visual C++ Runtime libraries](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)"
|
||||
- Windows may flag the app as virus. I think it does so because the app does control the mouse and keyboard.
|
||||
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
|
||||
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
|
||||
|
||||
## Troubleshooting
|
||||
- Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
|
||||
- The **Android** app is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
|
||||
- **Windows** bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Zwift device automatically.
|
||||
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
|
||||
|
||||
- When using Android a "click" on a certain part of the screen is simulated to trigger the action.
|
||||
- When using Android a touch on a certain part of the screen is simulated to trigger the action.
|
||||
- When using macOS or Windows a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh and indieVelo / Training Peaks
|
||||
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- you can also create your own Keymaps for any other app
|
||||
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class BleUuid {
|
||||
}
|
||||
|
||||
class Constants {
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
|
||||
|
||||
// Zwift Play = RC1
|
||||
static const RC1_LEFT_SIDE = 0x03;
|
||||
@@ -22,6 +22,11 @@ class Constants {
|
||||
// Zwift Click = BC1
|
||||
static const BC1 = 0x09;
|
||||
|
||||
// Zwift Click v2 Right (unconfirmed)
|
||||
static const CLICK_V2_RIGHT_SIDE = 0x0A;
|
||||
// Zwift Click v2 Right (unconfirmed)
|
||||
static const CLICK_V2_LEFT_SIDE = 0x0B;
|
||||
|
||||
static final RIDE_ON = Uint8List.fromList([0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e]);
|
||||
static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]);
|
||||
|
||||
@@ -46,6 +51,8 @@ class Constants {
|
||||
|
||||
enum DeviceType {
|
||||
click,
|
||||
clickV2Right,
|
||||
clickV2Left,
|
||||
playLeft,
|
||||
playRight,
|
||||
rideRight,
|
||||
@@ -61,6 +68,10 @@ enum DeviceType {
|
||||
switch (data) {
|
||||
case Constants.BC1:
|
||||
return DeviceType.click;
|
||||
case Constants.CLICK_V2_RIGHT_SIDE:
|
||||
return DeviceType.clickV2Right;
|
||||
case Constants.CLICK_V2_LEFT_SIDE:
|
||||
return DeviceType.clickV2Left;
|
||||
case Constants.RC1_LEFT_SIDE:
|
||||
return DeviceType.playLeft;
|
||||
case Constants.RC1_RIGHT_SIDE:
|
||||
|
||||
@@ -34,11 +34,15 @@ class Connection {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
_lastScanResult.add(result);
|
||||
final scanResult = BaseDevice.fromScanResult(result);
|
||||
_actionStreams.add(
|
||||
LogNotification('Found new device: ${result.name ?? scanResult?.runtimeType ?? result.deviceId}'),
|
||||
);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data =
|
||||
manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data?.firstOrNull}'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
@@ -41,7 +42,7 @@ abstract class BaseDevice {
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClick(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -61,8 +62,10 @@ abstract class BaseDevice {
|
||||
DeviceType.click => ZwiftClick(scanResult),
|
||||
DeviceType.playRight => ZwiftPlay(scanResult),
|
||||
DeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
DeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
DeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -125,25 +128,15 @@ abstract class BaseDevice {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
asyncCharacteristic.uuid,
|
||||
BleInputProperty.notification,
|
||||
);
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
syncTxCharacteristic.uuid,
|
||||
BleInputProperty.indication,
|
||||
);
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await _setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> _setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.writeValue(
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
@@ -152,15 +145,15 @@ abstract class BaseDevice {
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
BleOutputProperty.withoutResponse,
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.writeValue(
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
BleOutputProperty.withoutResponse,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
5
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
5
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult);
|
||||
}
|
||||
@@ -6,12 +6,26 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text('PayPal'),
|
||||
onTap: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Other'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/sponsors/jonasbark?frequency=one-time');
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
child: Text('Donate ♥'),
|
||||
icon: Text('Donate ♥', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const MenuButton(),
|
||||
SizedBox(width: 8),
|
||||
];
|
||||
|
||||
@@ -91,7 +91,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
Text('SwiftControl'),
|
||||
if (_packageInfoValue != null)
|
||||
Text(
|
||||
'v${_packageInfoValue!.version}',
|
||||
'v${_packageInfoValue!.version}+${_packageInfoValue!.buildNumber}',
|
||||
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
|
||||
12
pubspec.lock
12
pubspec.lock
@@ -249,10 +249,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: d59eeafd6df92174b1d5f68fc9d66634c97ce2e7cfe2293476236547bb19bbbd
|
||||
sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "19.0.0"
|
||||
version: "19.4.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,18 +265,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c"
|
||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "9.1.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e
|
||||
sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.2"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 2.2.0
|
||||
version: 2.3.0+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
@@ -11,7 +11,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
url_launcher: ^6.3.1
|
||||
flutter_local_notifications: ^19.0.0
|
||||
flutter_local_notifications: ^19.4.1
|
||||
universal_ble: ^0.21.1
|
||||
protobuf: ^3.1.0
|
||||
permission_handler: ^11.4.0
|
||||
|
||||
Reference in New Issue
Block a user