Compare commits

..

67 Commits
v ... v3.2.0+32

Author SHA1 Message Date
Jonas Bark
2eab9c581c update readme 2025-10-22 09:43:28 +02:00
Jonas Bark
1284499c25 MyWhoosh Link implementation #2 2025-10-22 09:35:41 +02:00
Jonas Bark
a74471b9f8 MyWhoosh Link implementation #1 2025-10-21 22:43:02 +02:00
Jonas Bark
81f61a5b87 Merge remote-tracking branch 'origin/main' 2025-10-21 10:45:39 +02:00
Jonas Bark
7b2446b6e0 CI cleanup 2025-10-21 10:45:30 +02:00
jonasbark
60898f7536 Update Windows Store version to 3.1.1 2025-10-21 10:25:26 +02:00
Jonas Bark
b2fa7870b6 CI cleanup 2025-10-21 10:21:44 +02:00
Jonas Bark
6ef2ff711a misc fixes 2025-10-21 10:18:12 +02:00
Jonas Bark
9f58dca10e restructure UI to make target selection easier to understand as well as how to get help 2025-10-21 10:10:40 +02:00
Jonas Bark
35e499720b CI patch update 2025-10-20 19:26:22 +02:00
Jonas Bark
7820a80241 Merge remote-tracking branch 'origin/main' 2025-10-20 19:11:08 +02:00
Jonas Bark
ffc6409488 CI patch update 2025-10-20 19:10:57 +02:00
jonasbark
8eaa411a80 Update CHANGELOG for version 3.1.0 enhancements 2025-10-20 19:09:40 +02:00
Jonas Bark
e4bbb8b279 windows store preparation 2025-10-20 12:19:25 +02:00
Jonas Bark
a13e2aa494 windows store preparation 2025-10-20 11:59:01 +02:00
Jonas Bark
b8383a2280 windows store preparation 2025-10-20 11:36:36 +02:00
Jonas Bark
2cb5ef03ce windows store preparation 2025-10-20 11:35:21 +02:00
Jonas Bark
5203c3a576 windows store preparation 2025-10-20 11:31:11 +02:00
Jonas Bark
36dfb2dc0b windows store preparation 2025-10-20 11:29:18 +02:00
jonasbark
3f6434b5a3 Revise download links and compatibility information
Updated download links for iPhone, macOS, and Windows in the README.
2025-10-20 11:28:15 +02:00
Jonas Bark
d9595a3485 windows store preparation 2025-10-20 11:25:20 +02:00
Jonas Bark
b3352d0c1c restart scanning when bluetooth turned on, cleanup when turned off 2025-10-19 12:55:13 +02:00
Jonas Bark
7e15df1f15 restart scanning when bluetooth turned on, cleanup when turned off
remove Sterzo from Readme until confirmed working
2025-10-19 12:44:44 +02:00
Jonas Bark
b7e086c326 resolve issue #123 2025-10-19 11:15:36 +02:00
Jonas Bark
659e7b0585 resolve #122 2025-10-19 11:09:11 +02:00
Jonas Bark
501ab48da5 Merge branch 'copilot/support-elite-sterzo-smart' 2025-10-19 09:56:50 +02:00
Jonas Bark
3b9ceea64b web CORS proxy workaround for fetching file 2025-10-19 09:56:39 +02:00
Jonas Bark
f3c7bbbcbf Revert "Use file storage instead of SharedPreferences for challenge codes"
This reverts commit a744242c70.
2025-10-19 09:48:51 +02:00
Jonas Bark
9b21a2775e Zwift devices: add firmware update available information 2025-10-19 09:11:51 +02:00
Jonas Bark
b669d4c5ea Android: fix touches for very old Android versions 2025-10-19 08:55:15 +02:00
copilot-swe-agent[bot]
a744242c70 Use file storage instead of SharedPreferences for challenge codes
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-19 06:36:11 +00:00
copilot-swe-agent[bot]
7f963f71f8 Load Elite Sterzo challenge codes from HTTP with caching
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-19 06:28:13 +00:00
Jonas Bark
9f0ab53e1f add troubleshooting entry for Redmi devices 2025-10-18 12:27:11 +02:00
Jonas Bark
5fc16e9fb7 version++ 2025-10-18 10:06:41 +02:00
Jonas Bark
4329afba1c version++ 2025-10-18 09:58:46 +02:00
Jonas Bark
01f87beef5 resolve issue #116 2025-10-18 09:56:04 +02:00
Jonas Bark
45fecfb4f6 more robust parsing of settings (issue #115) 2025-10-18 09:29:26 +02:00
copilot-swe-agent[bot]
9b020e09ae Fix Elite Sterzo Smart implementation with correct UUIDs and protocol
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-17 19:23:58 +00:00
Jonas Bark
3b1c05aba4 version++ 2025-10-17 20:51:17 +02:00
Jonas Bark
90a111944a fix issue #114 by adding missing entitlement 2025-10-17 20:42:26 +02:00
copilot-swe-agent[bot]
ceb029afb0 Add Elite Sterzo Smart support for virtual steering
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-17 17:40:12 +00:00
copilot-swe-agent[bot]
dc769ce6a0 Initial plan 2025-10-17 17:33:56 +00:00
Jonas Bark
66b7e74f84 CI change 2025-10-17 13:13:49 +02:00
Jonas Bark
29ef0dfaf4 CI change 2025-10-17 11:21:50 +02:00
Jonas Bark
90144948f4 CI change 2025-10-17 10:35:36 +02:00
Jonas Bark
bda384953e CI change 2025-10-17 10:12:39 +02:00
Jonas Bark
b0fd2a8413 fix logo 2025-10-17 10:10:13 +02:00
Jonas Bark
2403971063 update CI to exclude github build until App Store versions are available - avoid confusion 2025-10-17 10:09:34 +02:00
Jonas Bark
22a0379202 revise restart mechanism, fix Android 'Exit' notification button issue 2025-10-17 09:41:33 +02:00
Jonas Bark
c06a426490 allow macOS and Windows to act as remote 2025-10-17 09:26:31 +02:00
Jonas Bark
908e144e1b macos + ios icon changes 2025-10-17 09:19:21 +02:00
Jonas Bark
0189019e54 Merge branch 'icon_new' 2025-10-17 09:01:48 +02:00
Jonas Bark
5995835d03 more cleanup, refactoring 2025-10-17 08:59:01 +02:00
Jonas Bark
16e637b256 more cleanup, refactoring 2025-10-17 08:52:15 +02:00
Jonas Bark
ac2522e860 remove encryption as it's no longer used, cleanup 2025-10-17 08:15:49 +02:00
Jonas Bark
fdb3ad0efc missing files 2025-10-16 23:56:04 +02:00
Jonas Bark
f7a01f3c32 new icon, cleanup 2025-10-16 23:46:32 +02:00
Jonas Bark
94fd2c7eff Merge remote-tracking branch 'origin/main' 2025-10-16 20:52:46 +02:00
Jonas Bark
f917dfbbb2 elite square: more logging 2025-10-16 20:52:31 +02:00
jonasbark
40bfad6810 Add troubleshooting section for SwiftControl crashes
Added troubleshooting tip for SwiftControl crashes on Windows.
2025-10-16 12:31:09 +02:00
Jonas Bark
fefde66b7b Merge remote-tracking branch 'origin/main' 2025-10-16 12:19:57 +02:00
Jonas Bark
6869adcc09 fix macOS code signing 2025-10-16 12:19:50 +02:00
jonasbark
f5abaec551 Update compatibility matrix in README.md 2025-10-16 12:10:34 +02:00
Jonas Bark
52fbf693b5 fix restart behavior 2025-10-16 11:13:40 +02:00
Jonas Bark
bf3995496e CI 2025-10-15 19:20:51 +02:00
Jonas Bark
f7470a032a CI 2025-10-15 18:56:46 +02:00
Jonas Bark
64c9fe5f03 CI 2025-10-15 18:56:33 +02:00
178 changed files with 2440 additions and 1710 deletions

View File

@@ -6,23 +6,33 @@ on:
build_mac:
description: 'Build for macOS'
required: false
default: 'true'
default: true
type: boolean
build_github:
description: 'Build for GitHub'
required: false
default: true
type: boolean
build_windows:
description: 'Build for Windows'
required: false
default: 'true'
default: true
type: boolean
build_android:
description: 'Build for Android'
required: false
default: 'true'
default: true
type: boolean
build_ios:
description: 'Build for iOS'
required: false
default: 'true'
default: true
type: boolean
build_web:
description: 'Build for Web'
required: false
default: 'true'
default: true
type: boolean
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
@@ -44,7 +54,7 @@ jobs:
uses: actions/checkout@v3
- name: Install certificates
if: github.event.inputs.build_mac == 'true' || github.event.inputs.build_ios == 'true'
if: inputs.build_mac || inputs.build_ios
env:
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
DEVELOPER_ID_INSTALLER_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_INSTALLER_P12_BASE64_MAC }}
@@ -87,25 +97,26 @@ jobs:
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
- name: 🐦 Setup Shorebird
if: inputs.build_mac || inputs.build_android || inputs.build_ios || inputs.build_web
uses: shorebirdtech/setup-shorebird@v1
with:
cache: true
- name: 🚀 Shorebird Release macOS
if: github.event.inputs.build_mac == 'true'
if: inputs.build_mac
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
- name: Decode Keystore
if: github.event.inputs.build_android == 'true'
if: inputs.build_android
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: 🚀 Shorebird Release Android
if: github.event.inputs.build_android == 'true'
if: inputs.build_android
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -113,25 +124,25 @@ jobs:
args: "--artifact=apk"
- name: Set Up Flutter
if: github.event.inputs.build_web == 'true'
if: inputs.build_web
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build Web
if: github.event.inputs.build_web == 'true'
if: inputs.build_web
run: flutter build web --release --base-href "/swiftcontrol/"
- name: Upload static files as artifact
if: github.event.inputs.build_web == 'true'
if: inputs.build_web
id: deployment
uses: actions/upload-pages-artifact@v3
with:
path: build/web
- name: Web Deploy
if: github.event.inputs.build_web == 'true'
if: inputs.build_web
uses: actions/deploy-pages@v4
- name: Extract latest changelog
@@ -142,7 +153,7 @@ jobs:
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
- name: 🚀 Shorebird Release iOS
if: github.event.inputs.build_ios == 'true'
if: inputs.build_ios
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -150,7 +161,7 @@ jobs:
args: "--export-options-plist ios/ExportOptions.plist"
- name: Prepare App Store authentication key
if: github.event.inputs.build_ios == 'true' || github.event.inputs.build_mac == 'true'
if: inputs.build_ios || inputs.build_mac
env:
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
@@ -159,7 +170,7 @@ jobs:
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
- name: Upload to Play Store
if: github.event.inputs.build_android == 'true'
if: inputs.build_android
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
@@ -169,7 +180,7 @@ jobs:
whatsNewDirectory: whatsnew
- name: Upload to macOS App Store
if: github.event.inputs.build_mac == 'true'
if: inputs.build_mac
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
@@ -178,7 +189,7 @@ jobs:
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
- name: Upload to iOS App Store
if: github.event.inputs.build_ios == 'true'
if: inputs.build_ios
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
@@ -186,78 +197,64 @@ jobs:
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
- name: Handle Android archives
if: github.event.inputs.build_android == 'true'
if: inputs.build_android && inputs.build_github
run: |
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
- name: Code Signing of macOS app
if: github.event.inputs.build_mac == 'true'
if: inputs.build_mac && inputs.build_github
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
working-directory: build/macos/Build/Products/Release
env:
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
- name: Handle macOS archives
if: github.event.inputs.build_mac == 'true'
if: inputs.build_mac && inputs.build_github
run: |
cd build/macos/Build/Products/Release/
zip -r SwiftControl.macos.zip SwiftControl.app/
- name: Upload Android Artifacts
if: github.event.inputs.build_android == 'true'
if: inputs.build_android && inputs.build_github
uses: actions/upload-artifact@v4
with:
overwrite: true
name: Releases
path: |
build/app/outputs/flutter-apk/SwiftControl.android.apk
- name: Upload macOS Artifacts
if: github.event.inputs.build_mac == 'true'
if: inputs.build_mac && inputs.build_github
uses: actions/upload-artifact@v4
with:
overwrite: true
name: Releases
path: |
build/macos/Build/Products/Release/SwiftControl.macos.zip
#10 Extract Version
- name: Extract version from pubspec.yaml
if: inputs.build_github
id: extract_version
run: |
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
echo "VERSION=$version" >> $GITHUB_ENV
#11 Check if Tag Exists
- name: Check if Tag Exists
id: check_tag
run: |
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
echo "TAG_EXISTS=true" >> $GITHUB_ENV
else
echo "TAG_EXISTS=false" >> $GITHUB_ENV
fi
#12 Modify Tag if it Exists
- name: Modify Tag
if: env.TAG_EXISTS == 'true'
id: modify_tag
run: |
new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
echo "VERSION=$new_version" >> $GITHUB_ENV
#13 Create Release
- name: Create Release
if: inputs.build_github
uses: ncipollo/release-action@v1
with:
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
allowUpdates: true
prerelease: ${{ endsWith(env.VERSION, '1337') }}
prerelease: true
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
windows:
needs: build
if: github.event.inputs.build_windows == 'true'
if: inputs.build_windows
name: Build & Release on Windows
runs-on: windows-latest
@@ -266,13 +263,6 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v3
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: 'oracle'
java-version: '17'
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
@@ -308,7 +298,31 @@ jobs:
}
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
#9 Upload Artifacts
- uses: microsoft/setup-msstore-cli@v1
if: false
- name: Configure the Microsoft Store CLI
if: false
run: msstore reconfigure --tenantId $ --clientId $ --clientSecret $ --sellerId $
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Create MSIX package
run: dart run msix:create
- name: Publish MSIX to the Microsoft Store
if: false
run: msstore publish -v "build/windows/x64/runner/Release/"
- name: Rename swift_control.msix to SwiftControl.windows.msix
shell: pwsh
run: |
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "SwiftControl.windows.msix"
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
@@ -316,8 +330,8 @@ jobs:
name: Releases
path: |
build/windows/x64/runner/Release/SwiftControl.windows.zip
build/windows/x64/runner/Release/SwiftControl.windows.msix
#10 Extract Version
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |
@@ -326,13 +340,13 @@ jobs:
}
echo "VERSION=$version" >> $env:GITHUB_ENV
# add artifact to release
- name: Create Release
- name: Update Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
bodyFile: scripts/RELEASE_NOTES.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

View File

@@ -79,21 +79,21 @@ jobs:
with:
platform: macos
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch Android
uses: shorebirdtech/shorebird-patch@v1
with:
platform: android
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch iOS
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: Set Up Flutter
uses: subosito/flutter-action@v2
@@ -103,15 +103,17 @@ jobs:
# shorebird struggles with the app from GitHub
- name: Build macOS
run: flutter build macos --release;
- name: Sign macOS build
env:
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
run: |
flutter build macos --release;
cd build/macos/Build/Products/Release/;
zip -r SwiftControl.macos.zip SwiftControl.app/;
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r');
echo "VERSION=$version" >> $GITHUB_ENV;
cd build/macos/Build/Products/Release/;
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v;
zip -r SwiftControl.macos.zip SwiftControl.app/;
#9 Upload Artifacts
- name: Upload Artifacts
@@ -158,4 +160,4 @@ jobs:
with:
platform: windows
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'

View File

@@ -1,4 +1,4 @@
name: "Build"
name: "Build Web"
on:
push:

View File

@@ -1,7 +1,18 @@
### 3.0.4 (not released yet)
### 3.2.0 (2025-10-22)
- a brand-new way of controlling MyWhoosh:
- device pairing no longer required as mouse emulation is no longer needed
- SwiftControl can now stay in the background
- more devices can be controlled
- do more, such as define Emotes, Camera angles and steering
### 3.1.0 (2025-10-17)
- new app icon
- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101)
- initial support for Wahook Kickr Bike Shift (thanks @MattW2)
- initial support for Elite Square Smart Frame
- support for Wahook Kickr Bike Shift (thanks @MattW2)
- initial support for Elite Square Smart Frame
- reconnects to your device automatically when connection is lost
- SwiftControl now warns you if your device firmware is outdated
- SwiftControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
### 3.0.3 (2025-10-12)
- SwiftControl now supports iOS!

1
INSTRUCTIONS_ANDROID.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_IOS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_MACOS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_WINDOWS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

View File

@@ -1,6 +1,6 @@
# SwiftControl
<img src="logo.jpg" alt="SwiftControl Logo"/>
<img src="logo.png" alt="SwiftControl Logo"/>
## Description
@@ -21,11 +21,12 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
Check the compatibility matrix below!
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>
Get the latest version for Windows here: https://github.com/jonasbark/swiftcontrol/releases
<a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a>
## Supported Apps
- MyWhoosh
@@ -39,18 +40,23 @@ Get the latest version for Windows here: https://github.com/jonasbark/swiftcontr
- Zwift Click v2 (mostly, see issue #68)
- Zwift Ride
- Zwift Play
- Wahoo Kickr Bike Shift
- Elite Square Smart Frame (beta)
- Wahoo Kickr Bike Shift (beta)
Support for other devices can be added - check the issues tab here on GithUb.
## Supported Platforms
| Platform you want to run your Trainer app, e.g. MyWhoosh on | Possible | Link | Information |
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
| iPad (and possibly Apple TV) | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
| Windows | ✅ | [Get it here](https://github.com/jonasbark/swiftcontrol/releases) | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
| iPhone | | | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you can use it to remotely control MyWhoosh and similar on e.g. an iPad. |
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
| Apple TV | (✅) | | Unconfirmed: only MyWhoosh using the Link method is supported |
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
@@ -59,13 +65,14 @@ For testing purposes you can also run it on [Web](https://jonasbark.github.io/sw
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
## How does it work?
The app connects to your Zwift devices automatically. It does not connect to your trainer itself.
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
- you can also create your own Keymaps for any other app

View File

@@ -15,6 +15,13 @@ If you don't do that SwiftControl will need to reconnect every minute.
3. Connect your Trainer, then connect the Click V2
4. Close the Zwift app again and connect again in SwiftControl
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
- especially for Redmi and other chinese Android devices please follow the instructions on https://dontkillmyapp.com/:
- disable battery optimization for SwiftControl
- enable auto start of SwiftControl
- grant accessibility permission for SwiftControl
- see https://github.com/jonasbark/swiftcontrol/issues/38 for more details
## Remote control is not working - nothing happens
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
- Try restarting the pairing process in SwiftControl
@@ -26,3 +33,18 @@ If you don't do that SwiftControl will need to reconnect every minute.
iOS seems to be buggy here - try this in the iOS settings:
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or SwiftControl iOS) > Button 1
switch the setting to None, then back to Single-Tap and it should work again
## SwiftControl crashes on Windows when searching for the device
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
Here are some instructions that can help:
https://mywhoosh.com/troubleshoot/
https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
In essence:
- your two devices (phone, tablet) need to be on the same WiFi network
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
- Limit IP Address Tracking may need to be disabled
- mesh networks may not work

View File

@@ -0,0 +1 @@
3.1.1

View File

@@ -5,6 +5,7 @@ import android.accessibilityservice.GestureDescription
import android.accessibilityservice.GestureDescription.StrokeDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
@@ -56,7 +57,12 @@ class AccessibilityService : AccessibilityService(), Listener {
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
val stroke = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
} else {
// API 2425: no “willContinue” support
StrokeDescription(path, 0L, ViewConfiguration.getTapTimeout().toLong())
}
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)

View File

@@ -4,9 +4,9 @@
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@@ -4,9 +4,9 @@
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -1,32 +0,0 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
image_path: "icon.png"
android: "ic_launcher"
# image_path_android: "assets/icon/icon.png"
min_sdk_android: 24 # android min sdk min:16, default 21
# adaptive_icon_background: "assets/icon/background.png"
# adaptive_icon_foreground: "assets/icon/foreground.png"
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
ios: true
# image_path_ios: "assets/icon/icon.png"
remove_alpha_ios: true
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
# desaturate_tinted_to_grayscale_ios: true
web:
generate: true
image_path: "icon.png"
background_color: "#ffffff"
theme_color: "#ffffff"
windows:
generate: true
image_path: "icon.png"
icon_size: 48 # min:48, max:256, default: 48
macos:
generate: true
image_path: "icon.png"

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 KiB

View File

@@ -13,8 +13,6 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- restart (1.0.0):
- Flutter
- restart_app (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -36,7 +34,6 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- restart (from `.symlinks/plugins/restart/ios`)
- restart_app (from `.symlinks/plugins/restart_app/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
@@ -58,8 +55,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
restart:
:path: ".symlinks/plugins/restart/ios"
restart_app:
:path: ".symlinks/plugins/restart_app/ios"
shared_preferences_foundation:
@@ -79,7 +74,6 @@ SPEC CHECKSUMS:
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
restart: b5fe16e6e038f0024b2f3af43768e9d2a1557554
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -1 +1,134 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
{
"images": [
{
"filename": "AppIcon@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "60x60"
},
{
"filename": "AppIcon@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{
"filename": "AppIcon~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "76x76"
},
{
"filename": "AppIcon@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{
"filename": "AppIcon-83.5@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{
"filename": "AppIcon-40@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"filename": "AppIcon-40@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{
"filename": "AppIcon-40~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "40x40"
},
{
"filename": "AppIcon-40@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "40x40"
},
{
"filename": "AppIcon-20@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"filename": "AppIcon-20@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{
"filename": "AppIcon-20~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "20x20"
},
{
"filename": "AppIcon-20@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "20x20"
},
{
"filename": "AppIcon-29.png",
"idiom": "iphone",
"scale": "1x",
"size": "29x29"
},
{
"filename": "AppIcon-29@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"filename": "AppIcon-29@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{
"filename": "AppIcon-29~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "29x29"
},
{
"filename": "AppIcon-29@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "29x29"
},
{
"filename": "AppIcon-60@2x~car.png",
"idiom": "car",
"scale": "2x",
"size": "60x60"
},
{
"filename": "AppIcon-60@3x~car.png",
"idiom": "car",
"scale": "3x",
"size": "60x60"
},
{
"filename": "AppIcon~ios-marketing.png",
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info": {
"author": "iconkitchen",
"version": 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -1,23 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-min 2.png",
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"filename" : "AppIcon-min 1.png",
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"filename" : "AppIcon-min.png",
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

View File

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
@@ -14,9 +16,11 @@
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
<rect key="frame" x="111.33333333333333" y="340.66666666666669" width="170.66666666666669" height="170.66666666666669"/>
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
@@ -28,10 +32,10 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
<point key="canvasLocation" x="80.916030534351137" y="264.08450704225356"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="170.66667175292969" height="170.66667175292969"/>
</resources>
</document>

View File

@@ -26,6 +26,8 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>This app connects to your trainer app on your local network.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>

View File

@@ -67,11 +67,11 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
let point = CGPoint(x: x, y: y)
// Move mouse to the point
/*let move = CGEvent(mouseEventSource: nil,
let move = CGEvent(mouseEventSource: nil,
mouseType: .mouseMoved,
mouseCursorPosition: point,
mouseButton: .left)
move?.post(tap: .cghidEventTap)*/
move?.post(tap: .cghidEventTap)
if (keyDown) {
// Mouse down

View File

@@ -1,5 +1,3 @@
import 'package:flutter/services.dart';
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"
@@ -7,120 +5,4 @@ class BleUuid {
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 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();
}
class Constants {
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
// Zwift Play = RC1
static const RC1_LEFT_SIDE = 0x03;
static const RC1_RIGHT_SIDE = 0x02;
// Zwift Ride
static const RIDE_RIGHT_SIDE = 0x07;
static const RIDE_LEFT_SIDE = 0x08;
// 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]);
// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2)
static final RESPONSE_START_CLICK = Uint8List.fromList([1, 3]); // from device
static final RESPONSE_START_PLAY = Uint8List.fromList([1, 4]); // from device
static final RESPONSE_START_CLICK_V2 = Uint8List.fromList([0x02, 0x03]); // from device
static final RESPONSE_STOPPED_CLICK_V2 = Uint8List.fromList([
0xff,
0x05,
0x00,
0xea,
0x05,
0x19,
0x0a,
0x0c,
0x35,
0x38,
0x44,
0x31,
0x35,
0x41,
0x42,
0x42,
0x34,
0x33,
0x36,
0x33,
0x10,
0x01,
0x18,
0x84,
0x07,
0x20,
0x08,
0x28,
0x09,
0x30,
]); // from device
// Message types received from device
static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07;
static const EMPTY_MESSAGE_TYPE = 21;
static const BATTERY_LEVEL_TYPE = 25;
static const UNKNOWN_CLICKV2_TYPE = 0x3C;
// not figured out the protobuf type this really is, the content is just two varints.
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
// see this if connected to Core then Zwift connects to it. just one byte
static const DISCONNECT_MESSAGE_TYPE = 0xFE;
}
enum DeviceType {
click,
clickV2Right,
clickV2Left,
playLeft,
playRight,
rideRight,
rideLeft;
@override
String toString() {
return super.toString().split('.').last;
}
// add constructor
static DeviceType? fromManufacturerData(int data) {
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:
return DeviceType.playRight;
case Constants.RIDE_RIGHT_SIDE:
return DeviceType.rideRight;
case Constants.RIDE_LEFT_SIDE:
return DeviceType.rideLeft;
}
return null;
}
}

View File

@@ -9,8 +9,8 @@ import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth/ble.dart';
import 'devices/base_device.dart';
import 'devices/zwift/constants.dart';
import 'messages/notification.dart';
class Connection {
@@ -33,6 +33,14 @@ class Connection {
final ValueNotifier<bool> isScanning = ValueNotifier(false);
void initialize() {
UniversalBle.onAvailabilityChange = (available) {
_actionStreams.add(LogNotification('Bluetooth availability changed: $available'));
if (available == AvailabilityState.poweredOn) {
performScanning();
} else if (available == AvailabilityState.poweredOff) {
reset();
}
};
UniversalBle.onScanResult = (result) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
_lastScanResult.add(result);
@@ -44,7 +52,7 @@ class Connection {
} else {
final manufacturerData = result.manufacturerDataList;
final data = manufacturerData
.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
?.payload;
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data?.firstOrNull}'));
}
@@ -64,6 +72,9 @@ class Connection {
}
Future<void> performScanning() async {
if (isScanning.value) {
return;
}
isScanning.value = true;
_actionStreams.add(LogNotification('Scanning for devices...'));
@@ -83,12 +94,6 @@ class Connection {
scanFilter: ScanFilter(withServices: BaseDevice.servicesToScan),
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BaseDevice.servicesToScan)),
);
Future.delayed(Duration(seconds: 30)).then((_) {
if (isScanning.value) {
UniversalBle.stopScan();
isScanning.value = false;
}
});
}
void _addDevices(List<BaseDevice> dev) {
@@ -147,9 +152,7 @@ class Connection {
_connectionSubscriptions.remove(bleDevice);
_lastScanResult.clear();
// try reconnect
if (!isScanning.value) {
performScanning();
}
performScanning();
}
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
@@ -181,6 +184,7 @@ class Connection {
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
signalChange(device);
}
_lastScanResult.clear();
hasDevices.value = false;

View File

@@ -2,8 +2,8 @@ import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
@@ -15,6 +15,7 @@ import 'package:universal_ble/universal_ble.dart';
import '../../utils/keymap/buttons.dart';
import '../messages/notification.dart';
import 'elite/elite_square.dart';
import 'elite/elite_sterzo.dart';
abstract class BaseDevice {
final BleDevice scanResult;
@@ -31,10 +32,11 @@ abstract class BaseDevice {
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
static List<String> servicesToScan = [
BleUuid.ZWIFT_CUSTOM_SERVICE_UUID,
BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
SquareConstants.SERVICE_UUID,
WahooKickrBikeShiftConstants.SERVICE_UUID,
SterzoConstants.SERVICE_UUID,
];
static BaseDevice? fromScanResult(BleDevice scanResult) {
@@ -52,6 +54,10 @@ abstract class BaseDevice {
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
device = WahooKickrBikeShift(scanResult);
}
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
device = EliteSterzo(scanResult);
}
} else {
device = switch (scanResult.name) {
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
@@ -60,35 +66,43 @@ abstract class BaseDevice {
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
_ => null,
};
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
device = EliteSterzo(scanResult);
}
}
if (device != null) {
return device;
} else if (scanResult.services.containsAny([
BleUuid.ZWIFT_CUSTOM_SERVICE_UUID,
BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
])) {
// otherwise use the manufacturer data to identify the device
final manufacturerData = scanResult.manufacturerDataList;
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
final data = manufacturerData
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
?.payload;
if (data == null || data.isEmpty) {
return null;
}
final type = DeviceType.fromManufacturerData(data.first);
final type = ZwiftDeviceType.fromManufacturerData(data.first);
return switch (type) {
DeviceType.click => ZwiftClick(scanResult),
DeviceType.playRight => ZwiftPlay(scanResult),
DeviceType.playLeft => ZwiftPlay(scanResult),
DeviceType.rideLeft => ZwiftRide(scanResult),
ZwiftDeviceType.click => ZwiftClick(scanResult),
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
DeviceType.clickV2Left => ZwiftClickV2(scanResult),
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_ => null,
};
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
return EliteSquare(scanResult);
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
return EliteSterzo(scanResult);
} else if (scanResult.services.contains(WahooKickrBikeShiftConstants.SERVICE_UUID)) {
if (scanResult.name != null && !scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT')) {
return WahooKickrBikeShift(scanResult);
@@ -156,6 +170,8 @@ abstract class BaseDevice {
}
_previouslyPressedButtons.clear();
} else {
actionStreamInternal.add(ButtonNotification(buttonsClicked: buttonsClicked));
// Handle release events for buttons that are no longer pressed
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
final wasLongPress =

View File

@@ -37,6 +37,7 @@ class EliteSquare extends BaseDevice {
if (characteristic == SquareConstants.CHARACTERISTIC_UUID) {
final fullValue = _bytesToHex(bytes);
final currentValue = _extractButtonCode(fullValue);
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
if (_lastValue != null) {
final currentRelevantPart = fullValue.length >= 19
@@ -48,9 +49,7 @@ class EliteSquare extends BaseDevice {
if (currentRelevantPart != lastRelevantPart) {
final buttonClicked = SquareConstants.BUTTON_MAPPING[currentValue];
if (buttonClicked != null) {
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
}
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
handleButtonsClicked([
if (buttonClicked != null) buttonClicked,
]);

View File

@@ -0,0 +1,291 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
import '../../messages/notification.dart';
class EliteSterzo extends BaseDevice {
EliteSterzo(super.scanResult)
: super(
availableButtons: [
ControllerButton.navigationLeft,
ControllerButton.navigationRight,
],
isBeta: true,
);
double _lastAngle = 0.0;
int? _latestChallenge;
String? _serviceUuid;
static Uint8List? _challengeCodesData;
static bool _isLoadingChallenges = false;
@override
Future<void> handleServices(List<BleService> services) async {
final service = services.firstOrNullWhere(
(e) => e.uuid.toLowerCase().startsWith('347b0'),
);
if (service == null) {
throw Exception('Elite Sterzo service not found');
}
_serviceUuid = service.uuid;
// Find characteristics
final challengeChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID,
);
final measurementChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID,
);
final controlChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
);
if (challengeChar == null || measurementChar == null || controlChar == null) {
throw Exception('Required Sterzo characteristics not found');
}
// Subscribe to challenge code indications
await UniversalBle.subscribeNotifications(
device.deviceId,
service.uuid,
challengeChar.uuid,
);
// Subscribe to measurement notifications
await UniversalBle.subscribeNotifications(
device.deviceId,
service.uuid,
measurementChar.uuid,
);
// Request to start challenge
await UniversalBle.write(
device.deviceId,
service.uuid,
controlChar.uuid,
Uint8List.fromList([0x03, 0x10]),
withoutResponse: false,
);
actionStreamInternal.add(LogNotification('Elite Sterzo: Initialization started'));
}
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (characteristic == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID) {
_handleChallengeCode(bytes);
} else if (characteristic == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID) {
_handleSteeringMeasurement(bytes);
}
}
Future<void> _handleChallengeCode(Uint8List bytes) async {
if (bytes.length >= 4) {
// Challenge is in bytes 2-3 (big-endian)
final challenge = (bytes[2] << 8) | bytes[3];
_latestChallenge = challenge;
actionStreamInternal.add(LogNotification('Elite Sterzo: Received challenge code: $challenge'));
// Respond to challenge
await _activateSteeringMeasurements();
}
}
Future<void> _activateSteeringMeasurements() async {
if (_latestChallenge == null || _serviceUuid == null) {
return;
}
// Ensure challenge codes are loaded
await _ensureChallengeCodesLoaded();
// Get response codes for the challenge
final challengeCodes = _getChallengeResponse(_latestChallenge!);
// Send challenge response
await UniversalBle.write(
device.deviceId,
_serviceUuid!,
SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
Uint8List.fromList([0x03, 0x11, challengeCodes[0], challengeCodes[1]]),
withoutResponse: false,
);
await Future.delayed(const Duration(seconds: 1));
// Activate measurements
await UniversalBle.write(
device.deviceId,
_serviceUuid!,
SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
Uint8List.fromList([0x02, 0x02]),
withoutResponse: false,
);
actionStreamInternal.add(LogNotification('Elite Sterzo: Steering measurements activated'));
}
static Future<void> _ensureChallengeCodesLoaded() async {
if (_challengeCodesData != null) {
return; // Already loaded
}
// Wait if already loading
while (_isLoadingChallenges) {
await Future.delayed(const Duration(milliseconds: 100));
}
// Check again after waiting
if (_challengeCodesData != null) {
return;
}
_isLoadingChallenges = true;
try {
if (kIsWeb) {
// On web, always fetch from HTTP
_challengeCodesData = await _fetchChallengeCodes();
} else {
// On native platforms, try to load from cache first
_challengeCodesData = await _loadCachedChallengeCodes();
if (_challengeCodesData == null) {
// Cache miss - fetch from HTTP and cache it
_challengeCodesData = await _fetchChallengeCodes();
if (_challengeCodesData != null) {
await _cacheChallengeCodes(_challengeCodesData!);
}
}
}
} finally {
_isLoadingChallenges = false;
}
}
static Future<Uint8List?> _fetchChallengeCodes() async {
final url = kIsWeb
? 'https://corsproxy.io/${SterzoConstants.CHALLENGE_CODES_URL}'
: SterzoConstants.CHALLENGE_CODES_URL;
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
return response.bodyBytes;
}
} catch (e) {
if (kDebugMode) {
print('Failed to fetch challenge codes for URL $url: $e');
}
}
return null;
}
static Future<Uint8List?> _loadCachedChallengeCodes() async {
try {
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(SterzoConstants.CACHE_KEY);
if (cached != null) {
// Decode from base64
return base64Decode(cached);
}
} catch (e) {
if (kDebugMode) {
print('Failed to load cached challenge codes: $e');
}
}
return null;
}
static Future<void> _cacheChallengeCodes(Uint8List data) async {
try {
final prefs = await SharedPreferences.getInstance();
// Encode to base64 for storage
await prefs.setString(SterzoConstants.CACHE_KEY, base64Encode(data));
} catch (e) {
if (kDebugMode) {
print('Failed to cache challenge codes: $e');
}
}
}
void _handleSteeringMeasurement(Uint8List bytes) {
if (bytes.length >= 4) {
// Steering angle is a 32-bit float (little-endian)
final angle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
actionStreamInternal.add(LogNotification('Steering angle: ${angle.toStringAsFixed(1)}°'));
// Determine steering direction based on angle threshold
final button = _getSteeringButton(angle);
if (button != null) {
handleButtonsClicked([button]);
} else if (_getSteeringButton(_lastAngle) != null) {
// Release button if we were steering but now we're centered
handleButtonsClicked([]);
}
_lastAngle = angle;
}
}
ControllerButton? _getSteeringButton(double angle) {
// Use a threshold to avoid jitter around center
if (angle < -SterzoConstants.STEERING_THRESHOLD) {
return ControllerButton.navigationLeft;
} else if (angle > SterzoConstants.STEERING_THRESHOLD) {
return ControllerButton.navigationRight;
}
return null;
}
List<int> _getChallengeResponse(int challenge) {
if (_challengeCodesData == null) {
// Fallback if data not loaded
return [0x96, 0x96];
}
final index = challenge * 2;
if (index >= 0 && index < _challengeCodesData!.length - 1) {
return [_challengeCodesData![index], _challengeCodesData![index + 1]];
}
// Fallback for out of range challenges
return [0x96, 0x96];
}
}
class SterzoConstants {
static const String DEVICE_NAME = "STERZO";
// Elite Sterzo Smart characteristic UUIDs
static const String MEASUREMENT_CHARACTERISTIC_UUID = "347b0030-7635-408b-8918-8ff3949ce592";
static const String CONTROL_POINT_CHARACTERISTIC_UUID = "347b0031-7635-408b-8918-8ff3949ce592";
static const String CHALLENGE_CODE_CHARACTERISTIC_UUID = "347b0032-7635-408b-8918-8ff3949ce592";
// Service UUID pattern (matches Elite devices)
static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592";
// Steering angle threshold in degrees to trigger steering action
static const double STEERING_THRESHOLD = 5.0;
static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts
// URL to fetch challenge codes
static const String CHALLENGE_CODES_URL =
'https://github.com/zacharyedwardbull/pycycling/raw/refs/heads/master/pycycling/data/sterzo-challenge-codes.dat';
// Cache key for SharedPreferences
static const String CACHE_KEY = 'elite_sterzo_challenge_codes';
}

View File

@@ -9,7 +9,6 @@ class WahooKickrBikeShift extends BaseDevice {
WahooKickrBikeShift(super.scanResult)
: super(
availableButtons: WahooKickrBikeShiftConstants.prefixToButton.values.toList(),
isBeta: true,
);
@override

View File

@@ -0,0 +1,118 @@
import 'dart:typed_data';
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_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
// Zwift Play = RC1
static const RC1_LEFT_SIDE = 0x03;
static const RC1_RIGHT_SIDE = 0x02;
// Zwift Ride
static const RIDE_RIGHT_SIDE = 0x07;
static const RIDE_LEFT_SIDE = 0x08;
// 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]);
// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2)
static final RESPONSE_START_CLICK = Uint8List.fromList([1, 3]); // from device
static final RESPONSE_START_PLAY = Uint8List.fromList([1, 4]); // from device
static final RESPONSE_START_CLICK_V2 = Uint8List.fromList([0x02, 0x03]); // from device
static final RESPONSE_STOPPED_CLICK_V2 = Uint8List.fromList([
0xff,
0x05,
0x00,
0xea,
0x05,
0x19,
0x0a,
0x0c,
0x35,
0x38,
0x44,
0x31,
0x35,
0x41,
0x42,
0x42,
0x34,
0x33,
0x36,
0x33,
0x10,
0x01,
0x18,
0x84,
0x07,
0x20,
0x08,
0x28,
0x09,
0x30,
]); // from device
// Message types received from device
static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07;
static const EMPTY_MESSAGE_TYPE = 21;
static const BATTERY_LEVEL_TYPE = 25;
static const UNKNOWN_CLICKV2_TYPE = 0x3C;
// not figured out the protobuf type this really is, the content is just two varints.
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
// see this if connected to Core then Zwift connects to it. just one byte
static const DISCONNECT_MESSAGE_TYPE = 0xFE;
}
enum ZwiftDeviceType {
click,
clickV2Right,
clickV2Left,
playLeft,
playRight,
rideRight,
rideLeft;
@override
String toString() {
return super.toString().split('.').last;
}
// add constructor
static ZwiftDeviceType? fromManufacturerData(int data) {
switch (data) {
case ZwiftConstants.BC1:
return ZwiftDeviceType.click;
case ZwiftConstants.CLICK_V2_RIGHT_SIDE:
return ZwiftDeviceType.clickV2Right;
case ZwiftConstants.CLICK_V2_LEFT_SIDE:
return ZwiftDeviceType.clickV2Left;
case ZwiftConstants.RC1_LEFT_SIDE:
return ZwiftDeviceType.playLeft;
case ZwiftConstants.RC1_RIGHT_SIDE:
return ZwiftDeviceType.playRight;
case ZwiftConstants.RIDE_RIGHT_SIDE:
return ZwiftDeviceType.rideRight;
case ZwiftConstants.RIDE_LEFT_SIDE:
return ZwiftDeviceType.rideLeft;
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More