Compare commits

...

210 Commits

Author SHA1 Message Date
Jonas Bark
57961aec5d bump version 2025-11-04 17:36:01 +01:00
Jonas Bark
1675d7f2d0 bump version 2025-11-04 17:35:32 +01:00
Jonas Bark
baec8d24c3 fix detection of Sterzo devices https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3486678629 2025-11-04 17:34:25 +01:00
Jonas Bark
a968723277 update gitignore 2025-11-04 08:58:54 +01:00
Jonas Bark
8668957738 fix name 2025-11-03 20:30:05 +01:00
Jonas Bark
ac550fad5b do not offer MyWhoosh Link when not compatible 2025-11-03 18:58:41 +01:00
Jonas Bark
c511ac32b6 fix 'Exit' behavior on Android notification tap 2025-11-03 10:02:37 +01:00
Jonas Bark
ee48ce0f4e Merge remote-tracking branch 'origin/main' 2025-11-03 09:30:45 +01:00
Jonas Bark
8a3d64491b version++ 2025-11-03 09:30:38 +01:00
jonasbark
b72cc803f0 Clarify iOS, macOS, and Windows requirements
Updated iOS, macOS, and Windows requirements for Bluetooth buttons.
2025-11-03 09:12:00 +01:00
Jonas Bark
ea17b2e142 Merge remote-tracking branch 'origin/main' 2025-11-03 08:45:50 +01:00
Jonas Bark
da62fc4dc6 fix resetting keymap, show all touches by default 2025-11-03 08:45:39 +01:00
jonasbark
239630f681 Fix formatting and grammar issues in README.md
Corrected formatting and grammar in the README file.
2025-11-02 17:54:51 +01:00
Jonas Bark
d95d0cf8cf exit app on Android to apply patch 2025-11-02 17:48:34 +01:00
Jonas Bark
2b25ba942c Merge remote-tracking branch 'origin/main' 2025-11-02 16:22:14 +01:00
Jonas Bark
c65369a746 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:22:06 +01:00
Jonas Bark
fa7d5e7853 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:20:32 +01:00
Jonas Bark
8ac47cbd4d make Link / emulation UI clearer 2025-11-02 16:02:07 +01:00
Jonas Bark
eb85844503 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-02 13:56:12 +01:00
Jonas Bark
010d0ed331 UI adjustments 2025-11-02 13:55:56 +01:00
Jonas Bark
1f8f7765a3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:24:19 +01:00
Jonas Bark
68f416dda3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:10:35 +01:00
Jonas Bark
49e45faec0 fix SwiftControl Web 2025-11-02 10:50:44 +01:00
Jonas Bark
c81516350a initial support for Shimano Di2 D-Fly channel buttons 2025-11-02 10:45:51 +01:00
jonasbark
890f393fd6 Merge pull request #151 from jonasbark/copilot/add-cycplus-bc2-support
Add CYCPLUS BC2 virtual shifter support via Nordic UART Service
2025-11-02 08:26:42 +01:00
copilot-swe-agent[bot]
e46969c5c4 Add CYCPLUS BC2 virtual shifter support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-02 06:45:59 +00:00
copilot-swe-agent[bot]
1ec9b55645 Initial plan 2025-11-02 06:39:40 +00:00
Jonas Bark
b0caf7c13b UI adjustments 2025-11-01 19:43:30 +01:00
Jonas Bark
302fc15dd7 don't reset after a minute 2025-11-01 19:40:23 +01:00
jonasbark
6a2cf1a1c9 Merge pull request #147 from jonasbark/copilot/fix-miui-service-issue
Add MIUI device detection and battery optimization warning with dismissal
2025-11-01 19:36:16 +01:00
jonasbark
8ea73bc54a Update Windows Store version to 3.3.0 2025-11-01 19:27:07 +01:00
copilot-swe-agent[bot]
7cbab3925f Use AndroidActions type check instead of negated RemoteActions
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 10:15:16 +00:00
copilot-swe-agent[bot]
246a1bd2be Add dismiss button to MIUI warning with persistent state
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 09:54:20 +00:00
copilot-swe-agent[bot]
f7e2a89ed6 Move MIUI warning from requirements to DevicePage widget
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:48:58 +00:00
copilot-swe-agent[bot]
f94252edb9 Address code review feedback - improve comments and efficiency
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:37:58 +00:00
copilot-swe-agent[bot]
b7b6b9803f Add MIUI device detection and warning for accessibility service
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:31:30 +00:00
copilot-swe-agent[bot]
807d868b74 Initial plan 2025-10-31 18:25:43 +00:00
jonasbark
c3e8c4666c Merge pull request #145 from jonasbark/zwift
Zwift Support
2025-10-31 13:16:07 +01:00
Jonas Bark
926651ebb3 zwift emulation rouvy support 2025-10-31 13:15:19 +01:00
Jonas Bark
a7d5624582 update mywhoosh profile 2025-10-31 12:53:04 +01:00
Jonas Bark
03209740ec ux adjustments 2025-10-31 12:18:15 +01:00
jonasbark
af6ae3433e Update lib/bluetooth/devices/zwift/zwift_emulator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 11:28:11 +01:00
Jonas Bark
41f4dd1d57 update changelog 2025-10-31 11:24:08 +01:00
Jonas Bark
d5c1b67675 clarify bluetooth buttons 2025-10-31 11:20:54 +01:00
Jonas Bark
0b18d74ac9 fix already paired gamepad detection 2025-10-31 09:41:34 +01:00
Jonas Bark
fb3fe5f8c0 add rouvy keymap and enable zwift controller emulation 2025-10-31 09:41:19 +01:00
Jonas Bark
796c973fd4 clarify Zwift usage, make controller emulation optional 2025-10-30 09:17:35 +01:00
Jonas Bark
7c6335c4d1 implement remaining Zwift actions 2025-10-29 09:13:17 +01:00
Jonas Bark
af2267c486 allow action unassignment 2025-10-28 18:06:07 +01:00
Jonas Bark
56d9e62610 integration #1 2025-10-28 17:58:44 +01:00
Jonas Bark
7e18a169d4 cleanup 2025-10-28 16:08:59 +01:00
Jonas Bark
74280eda34 zwift click emulation #4 (command works) 2025-10-28 16:05:31 +01:00
Jonas Bark
e1309d4d95 zwift click emulation #3 (connection works) 2025-10-28 14:57:54 +01:00
Jonas Bark
14aa6f7454 zwift click emulation #2 (connection works) 2025-10-28 12:55:39 +01:00
Jonas Bark
1368d7d24e zwift click emulation #1 2025-10-28 12:25:51 +01:00
Jonas Bark
8c09b170c3 some bugfixes, UI adjustments 2025-10-28 09:19:22 +01:00
Jonas Bark
080409b984 add trainer app selection during app start 2025-10-27 21:45:50 +01:00
Jonas Bark
f0ec276547 improve UI for buttons clicked without a keypair 2025-10-27 17:32:08 +01:00
Jonas Bark
23aafcd7bc improve UI for MyWhoosh Link 2025-10-27 17:14:38 +01:00
Jonas Bark
3718a126ac fix keymap explanation not noticing when MyWhoosh Link is connected 2025-10-27 16:48:23 +01:00
Jonas Bark
846dd07bf4 fix UI, fix first button click not handled 2025-10-27 16:44:37 +01:00
Jonas Bark
ba60062a24 Bluetooth HID tests 2025-10-27 16:28:41 +01:00
Jonas Bark
ed4f928fde Click V2 adjustments 2025-10-27 14:40:04 +01:00
Jonas Bark
2a09d550e5 Click V2 adjustments 2025-10-27 14:05:09 +01:00
Jonas Bark
bb1ae4e616 cleanup, fixes 2025-10-27 13:43:18 +01:00
Jonas Bark
828aa70a56 implement MyWhoosh link in the editor 2025-10-27 12:14:41 +01:00
Jonas Bark
4021f3131d refactor connection with remote and controllers 2025-10-27 11:12:23 +01:00
Jonas Bark
80ef81ca64 allow disconnection of bluetooth device 2025-10-27 09:54:01 +01:00
Jonas Bark
fec13d012b show custom keymap hint for gamepads 2025-10-27 09:38:19 +01:00
Jonas Bark
e8ca3fc287 use clipboard again when sharing 2025-10-27 08:27:08 +01:00
Jonas Bark
d5260d801c fix issues in #135 2025-10-26 21:23:57 +01:00
Jonas Bark
916b1ec1fc cleanup 2025-10-26 20:58:52 +01:00
Jonas Bark
7380bb5001 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-10-26 20:55:20 +01:00
Jonas Bark
2e95fb556a don't reset in debug mode 2025-10-26 20:54:53 +01:00
jonasbark
90591cbfa2 Merge pull request #137 from michidk/rssi
Displaying the RSSI of connected devices
2025-10-26 20:54:05 +01:00
Michael Lohr
929409db71 implement rssi 2025-10-26 20:32:43 +01:00
Jonas Bark
4263375fb2 fix scanning issue on web https://github.com/jonasbark/swiftcontrol/issues/134#issuecomment-3448689314 2025-10-26 20:28:25 +01:00
jonasbark
bb5d149ba4 Merge pull request #136 from jonasbark/gamepads
Gamepads
2025-10-26 20:17:15 +01:00
Jonas Bark
1a322dc0d3 resolve issue #111 2025-10-26 20:13:54 +01:00
Jonas Bark
d10da94f20 adjust changelog and supported devices 2025-10-26 20:11:17 +01:00
Jonas Bark
7eb28881cb Merge branch 'main' into gamepads 2025-10-26 20:10:34 +01:00
Jonas Bark
823e04d189 resolve issue #133 partially 2025-10-26 19:55:51 +01:00
Jonas Bark
ca5d4aeadb resolve issue #134 2025-10-26 19:53:46 +01:00
Jonas Bark
a4d937c4f3 resolve issue #135 2025-10-26 19:48:17 +01:00
Jonas Bark
fa4add6797 store background image during touch editing, don't show auto rotation warning when not needed 2025-10-26 19:45:11 +01:00
Jonas Bark
ec2ed4e6c5 refactoring #5 2025-10-26 19:27:35 +01:00
Jonas Bark
6bd41d9a54 refactoring #4 2025-10-26 18:53:18 +01:00
Jonas Bark
1ff2a205bc refactoring #3 2025-10-26 17:21:00 +01:00
Jonas Bark
dd73c3249b refactoring #1 2025-10-26 16:26:56 +01:00
Jonas Bark
75eef49317 initial support for gamepads 2025-10-26 10:21:59 +01:00
Jonas Bark
e8858e0c7d fix macOS new version check 2025-10-26 10:06:32 +01:00
Jonas Bark
df9142a6bf update CI 2025-10-25 09:35:26 +02:00
Jonas Bark
36f312403b update changelog 2025-10-25 09:35:06 +02:00
Jonas Bark
d8983889ae fix latest firmware on Click v2 2025-10-25 09:29:35 +02:00
Jonas Bark
bfaf2f2d29 Merge remote-tracking branch 'origin/main' 2025-10-24 18:43:31 +02:00
Jonas Bark
2ba9c284ba scan for all devices, another attempt at #42 2025-10-24 18:43:25 +02:00
jonasbark
ef2b4af28a Update WINDOWS_STORE_VERSION.txt 2025-10-24 12:25:57 +02:00
Jonas Bark
ba042cd07d more work on issue #42 2025-10-24 10:37:28 +02:00
Jonas Bark
f8cb4cff4f update training peaks keymap according to #126 2025-10-24 09:53:55 +02:00
Jonas Bark
92010b787b cleanup 2025-10-24 09:47:37 +02:00
Jonas Bark
e142a8c587 resolve issue #42 2025-10-24 09:47:02 +02:00
Jonas Bark
759dcaa8b8 Merge branch 'fix-38' 2025-10-24 09:44:30 +02:00
Jonas Bark
05939dcf1e implement fix for issue #38 2025-10-24 09:44:06 +02:00
jonasbark
34494819f5 Add instructions for MyWhoosh Link method
Added detailed instructions for using the MyWhoosh Link method, including steps and a video link.
2025-10-23 15:29:11 +02:00
Jonas Bark
a9491b7fa5 Merge branch 'main' into fix-38 2025-10-23 10:49:13 +02:00
Jonas Bark
311a676aea implement fix for issue #38 2025-10-23 10:48:21 +02:00
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
copilot-swe-agent[bot]
f08714f25a Refine PWM keypress behavior to prevent overlaps
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-20 11:47:28 +00:00
copilot-swe-agent[bot]
1f3352ff80 Implement Elite Sterzo Smart improvements for issue #111
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-20 11:44:24 +00:00
copilot-swe-agent[bot]
2601844970 Initial plan 2025-10-20 11:37:42 +00: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
Jonas Bark
febfbc3cc8 - improve iOS reactivity by delaying button events for a few milliseconds
- use correct icon for shift down buttons
- add more troubleshooting for iOS devices
2025-10-15 16:32:13 +02:00
Jonas Bark
5ea848b62e work on #104 2025-10-15 09:25:52 +02:00
Jonas Bark
96118a98b1 conditional builds, throw error for failed in app updates 2025-10-14 13:25:58 +02:00
Jonas Bark
d25f3a2d4e more work on #42 2025-10-14 09:18:11 +02:00
Jonas Bark
c0600746b6 fix issue #101 2025-10-14 09:15:37 +02:00
Jonas Bark
24cb34408b fix CI 2025-10-13 16:34:23 +02:00
Jonas Bark
f90ae87017 fix CI 2025-10-13 16:33:49 +02:00
Jonas Bark
273a71e759 fix CI 2025-10-13 15:51:13 +02:00
Jonas Bark
d5c6a8f7f1 fix CI 2025-10-13 15:32:08 +02:00
Jonas Bark
b6bb2c37a1 adjust touch placements on Android and macOS 2025-10-13 15:26:59 +02:00
Jonas Bark
3ea1184bab prevent wrong changelog dialog 2025-10-13 14:51:08 +02:00
Jonas Bark
a45e5c4874 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-10-13 14:48:33 +02:00
jonasbark
d5926f1d5c Merge pull request #98 from jmoro/main
Fix drag-and-drop in keymap editor
2025-10-13 14:48:15 +02:00
Javier Moro Sotelo
c08ac5468a Fix drag-and-drop in keymap editor
Replaced velocity-based drag detection with distance-based threshold to ensure button positions update correctly even when dragging over other buttons, which can cause velocity to drop to zero.

Fixes #97

Signed-off-by: Javier Moro Sotelo <810976+jmoro@users.noreply.github.com>
2025-10-13 14:42:12 +02:00
Jonas Bark
32ad152079 update supported devices 2025-10-13 13:26:10 +02:00
jonasbark
94372918ac Merge pull request #96 from bin101/patch-1
fix: change key mappings from 'A' and 'D' to arrow keys
2025-10-13 12:24:11 +02:00
jonasbark
3ce364a5be Merge branch 'main' into patch-1 2025-10-13 12:23:57 +02:00
Jonas Bark
e4105ea248 Merge branch 'wahoo_kickr_bike_shift' 2025-10-13 12:22:46 +02:00
Jonas Bark
604a8b6bd6 CI changes 2025-10-13 12:22:35 +02:00
Jonas Bark
fc82a62af3 initial support for wahoo kickr bike shifting 2025-10-13 12:13:45 +02:00
Jens van Almsick
67aeb3e257 fix: change key mappings from 'A' and 'D' to arrow keys
see https://mywhooshinfo.com/blog/mywhoosh-keyboard-shortcuts
2025-10-13 12:04:41 +02:00
Jonas Bark
d371ec6d6e add initial support for Elite Square 2025-10-13 11:42:38 +02:00
Jonas Bark
01509eaae9 refactor device handling to support more devices #2 2025-10-13 11:09:18 +02:00
Jonas Bark
b0df25241a refactor device handling to support more devices #1 2025-10-13 10:59:12 +02:00
Jonas Bark
56447743b2 revise update mechanism 2025-10-13 10:09:58 +02:00
Jonas Bark
301dc39648 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-10-13 09:11:30 +02:00
jonasbark
3195568399 Clean up Windows section in README
Removed redundant text from the Windows section.
2025-10-13 09:08:54 +02:00
jonasbark
200b13c97f Add download links to README
Added download links for Google Play and App Store.
2025-10-13 09:02:29 +02:00
jonasbark
47173f6dbd Enhance README with compatibility matrix and links
Added compatibility matrix and download links for platforms.
2025-10-13 09:02:16 +02:00
Jonas Bark
83bf1fe047 cleanup 2025-10-13 00:14:51 +02:00
jonasbark
aa8310905d Update README with app support and web demo details 2025-10-13 00:12:44 +02:00
Jonas Bark
a67a82d638 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-10-13 00:10:28 +02:00
jonasbark
65b0807903 Revise README with app and platform updates
Updated supported apps and platforms, added details for iOS and Android usage, and improved troubleshooting section.
2025-10-13 00:10:01 +02:00
Jonas Bark
ca4702a684 github actions patch pipeline 2025-10-12 13:08:55 +02:00
224 changed files with 6913 additions and 2989 deletions

View File

@@ -1,16 +1,38 @@
name: "Build"
on:
push:
branches:
- main
- ios
paths:
- '.github/workflows/**'
- 'lib/**'
- 'accessibility/**'
- 'keypress_simulator/**'
- 'pubspec.yaml'
workflow_dispatch:
inputs:
build_mac:
description: 'Build for macOS'
required: false
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
type: boolean
build_android:
description: 'Build for Android'
required: false
default: true
type: boolean
build_ios:
description: 'Build for iOS'
required: false
default: true
type: boolean
build_web:
description: 'Build for Web'
required: false
default: false
type: boolean
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
@@ -32,6 +54,7 @@ jobs:
uses: actions/checkout@v3
- name: Install certificates
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 }}
@@ -74,28 +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: inputs.build_mac
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
- name: Code Signing
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: Decode Keystore
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: inputs.build_android
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -103,80 +124,25 @@ jobs:
args: "--artifact=apk"
- name: Set Up Flutter
if: inputs.build_web
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build Web
if: github.ref == 'refs/heads/main'
if: inputs.build_web
run: flutter build web --release --base-href "/swiftcontrol/"
- name: Handle archives
if: github.ref == 'refs/heads/main'
run: |
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
cd build/macos/Build/Products/Release/
zip -r SwiftControl.macos.zip SwiftControl.app/
#9 Upload Artifacts
- name: Upload Artifacts
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: Releases
path: |
build/app/outputs/flutter-apk/SwiftControl.android.apk
build/macos/Build/Products/Release/SwiftControl.macos.zip
#10 Extract Version
- name: Extract version from pubspec.yaml
if: github.ref == 'refs/heads/main'
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
if: github.ref == 'refs/heads/main'
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' && github.ref == 'refs/heads/main'
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: github.ref == 'refs/heads/main'
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') }}
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
- name: Upload static files as artifact
if: github.ref == 'refs/heads/main'
if: inputs.build_web
id: deployment
uses: actions/upload-pages-artifact@v3
with:
path: build/web
- name: Web Deploy
if: github.ref == 'refs/heads/main'
if: inputs.build_web
uses: actions/deploy-pages@v4
- name: Extract latest changelog
@@ -187,6 +153,7 @@ jobs:
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
- name: 🚀 Shorebird Release iOS
if: inputs.build_ios
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
@@ -194,6 +161,7 @@ jobs:
args: "--export-options-plist ios/ExportOptions.plist"
- name: Prepare App Store authentication key
if: inputs.build_ios || inputs.build_mac
env:
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
@@ -202,8 +170,7 @@ jobs:
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
- name: Upload to Play Store
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
if: "!endsWith(env.VERSION, '1337')"
if: inputs.build_android
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
@@ -213,6 +180,7 @@ jobs:
whatsNewDirectory: whatsnew
- name: Upload to macOS App Store
if: inputs.build_mac
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
@@ -221,16 +189,72 @@ 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: inputs.build_ios
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
run: |
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: 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: 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: 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: 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: 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
#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: true
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
windows:
needs: build
if: github.ref == 'refs/heads/main'
if: inputs.build_windows
name: Build & Release on Windows
runs-on: windows-latest
@@ -239,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:
@@ -281,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:
@@ -289,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: |
@@ -299,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 }}

164
.github/workflows/patch.yml vendored Normal file
View File

@@ -0,0 +1,164 @@
name: "Patch"
on:
workflow_dispatch:
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
FLUTTER_VERSION: 3.35.5
jobs:
build:
name: Patch iOS, Android & macOS
runs-on: macos-latest
permissions:
id-token: write
pages: write
contents: write
steps:
#1 Checkout Repository
- name: Checkout Repository
uses: actions/checkout@v3
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
cache: true
- name: Install certificates
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 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPSTORE_PROFILE_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_IOS_BASE64 }}
APPSTORE_PROFILE_MACOS_BASE64: ${{ secrets.APPSTORE_PROFILE_MACOS_BASE64 }}
APPSTORE_PROFILE_DEV_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_DEV_IOS_BASE64 }}
run: |
# create variables
DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_installer_certificate.p12
PP_PATH_IOS=$RUNNER_TEMP/build_pp_ios.mobileprovision
PP_PATH_IOS_DEV=$RUNNER_TEMP/build_pp_ios_dev.mobileprovision
PP_PATH_MACOS=$RUNNER_TEMP/build_pp_macos.provisionprofile
KEYCHAIN_PATH=$RUNNER_TEMP/pg-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$DEVELOPER_ID_APPLICATION_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH
echo -n "$DEVELOPER_ID_INSTALLER_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH
echo -n "$APPSTORE_PROFILE_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS
echo -n "$APPSTORE_PROFILE_DEV_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS_DEV
echo -n "$APPSTORE_PROFILE_MACOS_BASE64" | base64 --decode -o $PP_PATH_MACOS
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# security default-keychain -s $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: 🚀 Shorebird Patch macOS
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
uses: shorebirdtech/shorebird-patch@v1
with:
platform: macos
release-version: latest
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 --allow-native-diffs'
- name: 🚀 Shorebird Patch iOS
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
# 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: |
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
uses: actions/upload-artifact@v4
with:
overwrite: true
name: Releases
path: |
build/macos/Build/Products/Release/SwiftControl.macos.zip
# add artifact to release
- name: Create Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
windows:
name: Patch Windows
runs-on: windows-latest
steps:
#1 Checkout Repository
- 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:
cache: true
- name: 🚀 Shorebird Patch Windows
uses: shorebirdtech/shorebird-patch@v1
with:
platform: windows
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'

View File

@@ -1,11 +1,13 @@
name: "Build"
name: "Build Web"
on:
push:
branches:
- web
- wahoo_kickr_bike_shift
- main
paths:
- '.github/workflows/**'
- '.github/workflows/web.yml'
- 'lib/**'
- 'accessibility/**'
- 'keypress_simulator/**'
@@ -13,7 +15,7 @@ on:
jobs:
build:
name: Build & Release
name: Build Web
runs-on: macos-latest
permissions:

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
.history
.svn/
.swiftpm/
debug/
migrate_working_dir/
android/keystore.properties

View File

@@ -1,3 +1,43 @@
### 3.3.1 (unreleased)
**New Features:**
- Support for Shimano Di2
**Fixes:**
- fix detection of Elite Square Sterzo devices
### 3.3.0 (31-10-2025)
**New Features:**
- Support for Elite Sterzo (thanks @michidk)
- Support for Gamepads
- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))
- you can now customize the Keymap right from the Customize section
- show signal strength of connected devices (thanks @michidk)
- Android and Windows only: simulate bluetooth controllers
- enables gamepad and bluetooth remotes support for Zwift, Rouvy and Biketerra
**Fixes:**
- fix firmware version display for Zwift Click V2 devices
- fix touch position on some Android devices
- Wahoo Kickr Bike Shift can now be connected
- update default keymap for TrainingPeaks
### 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)
- 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!
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:

1
INSTRUCTIONS_ANDROID.md Normal file
View File

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

10
INSTRUCTIONS_IOS.md Normal file
View File

@@ -0,0 +1,10 @@
**Instructions for using the MyWhoosh Link method**
1) launch MyWhoosh on the device of your choice
2) launch MyWhoosh Link, check if the "Link" connection works
3) close MyWhoosh Link, open SwiftControl, follow on screen instructions
Step 2 is not necessary after that once step 3 works
And here's a video with a few explanations:
[![SwiftControl Instruction for iOS](https://img.youtube.com/vi/p8sgQhuufeI/0.jpg)](https://www.youtube.com/watch?v=p8sgQhuufeI)
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)

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,15 +1,15 @@
# SwiftControl
<img src="logo.jpg" alt="SwiftControl Logo"/>
<img src="logo.png" alt="SwiftControl Logo"/>
## 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, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
- Virtual Gear shifting
- Steering / turning
- Steering/turning
- adjust workout intensity
- control music on your device
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
- more? If you can do it via keyboard, mouse, or touch, you can do it with SwiftControl
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
@@ -18,52 +18,76 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
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
- indieVelo / Training Peaks
- TrainingPeaks Virtual / indieVelo
- Biketerra.com
- any other! Customize touch points or keyboard shortcuts to your liking
- Rouvy
- Zwift
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
- any other!
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
## Supported Devices
- Zwift Click
- Zwift Click v2 (mostly, see issue #68)
- Zwift Ride
- Zwift Play
- Shimano Di2
- Configure your levers to use D-Fly channels with Shimano E-Tube app
- Wahoo Kickr Bike Shift
- CYCPLUS BC2 Virtual Shifter
- Elite Sterzo Smart (for steering support)
- Elite Square Smart Frame (beta)
- Gamepads (beta)
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
- works on Android
- on iOS, macOS and Windows it would require SwiftControl to act as media player - let me know if that works for you
Support for other devices can be added; check the issues tab here on GitHub.
## Supported Platforms
- Android
- App is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
- macOS
- Windows
- 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).
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
- iOS (iPhone, iPad)
- 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.
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 | ✅ | <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 | (✅) | <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 | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
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.
## Troubleshooting
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
## How does it work?
The app connects to your Zwift device 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 a "remote control" for other devices, such as an iPad. Example scenario:
- 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)
- 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
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
- **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 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
- 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
## Alternatives
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.

View File

@@ -15,8 +15,38 @@ 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/](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](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
- try restarting Bluetooth on your phone and on the device you want to control
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in SwiftControl / restart SwiftControl.
## Remote control only clicks on a single coordinate on my iPad
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://mywhoosh.com/troubleshoot/)
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](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.3.0

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

@@ -1,5 +1,46 @@
package de.jonasbark.swiftcontrol
import android.hardware.input.InputManager
import android.os.Handler
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import io.flutter.embedding.android.FlutterActivity
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
class MainActivity : FlutterActivity()
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
var keyListener: ((KeyEvent) -> Boolean)? = null
var motionListener: ((MotionEvent) -> Boolean)? = null
override fun isGamepadsInputDevice(device: InputDevice): Boolean {
return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|| device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
// Some bluetooth keyboards are identified as GamePad. Check if it is ALPHABETIC keyboard.
// && device.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC
}
override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean {
return motionListener?.invoke(motionEvent) ?: false
}
override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean {
if (keyListener?.invoke(keyEvent) == true) {
return true
}
return super.dispatchKeyEvent(keyEvent)
}
override fun registerInputDeviceListener(
listener: InputManager.InputDeviceListener, handler: Handler?) {
val inputManager = getSystemService(INPUT_SERVICE) as InputManager
inputManager.registerInputDeviceListener(listener, null)
}
override fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) {
keyListener = handler
}
override fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) {
motionListener = handler
}
}

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

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -7,13 +7,16 @@ PODS:
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- gamepads_ios (0.1.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
- path_provider_foundation (0.0.1):
- Flutter
- restart (1.0.0):
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- restart_app (0.0.1):
- Flutter
@@ -33,10 +36,11 @@ DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- 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`)
@@ -52,14 +56,16 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
gamepads_ios:
:path: ".symlinks/plugins/gamepads_ios/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
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:
@@ -76,10 +82,11 @@ SPEC CHECKSUMS:
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
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,126 +1,7 @@
import 'dart:typed_data';
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 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;
}
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

@@ -1,20 +1,33 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:gamepads/gamepads.dart';
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
import 'package:swift_control/bluetooth/devices/gamepad/gamepad_device.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth/ble.dart';
import '../utils/keymap/apps/my_whoosh.dart';
import 'devices/base_device.dart';
import 'devices/link/link_device.dart';
import 'devices/zwift/constants.dart';
import 'messages/notification.dart';
class Connection {
final devices = <BaseDevice>[];
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices];
List<BaseDevice> get remoteDevices => devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice).toList();
var _androidNotificationsSetup = false;
final _connectionQueue = <BaseDevice>[];
@@ -32,26 +45,53 @@ class Connection {
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
final ValueNotifier<bool> isScanning = ValueNotifier(false);
Timer? _gamePadSearchTimer;
void initialize() {
UniversalBle.onAvailabilityChange = (available) {
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
if (available == AvailabilityState.poweredOn && !kIsWeb) {
performScanning();
} else if (available == AvailabilityState.poweredOff) {
reset();
}
};
UniversalBle.onScanResult = (result) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
// Update RSSI for already connected devices
final existingDevice = bluetoothDevices.firstOrNullWhere(
(e) => e.device.deviceId == result.deviceId,
);
if (existingDevice != null && existingDevice.rssi != result.rssi) {
existingDevice.rssi = result.rssi;
_connectionStreams.add(existingDevice); // Notify UI of update
}
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
_lastScanResult.add(result);
final scanResult = BaseDevice.fromScanResult(result);
if (kDebugMode) {
print('Scan result: ${result.name} - ${result.deviceId}');
}
final scanResult = BluetoothDevice.fromScanResult(result);
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}'));
final data = manufacturerData
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
?.payload;
if (data != null && kDebugMode) {
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data.firstOrNull}'));
}
}
}
};
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
final device = devices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device == null) {
_actionStreams.add(LogNotification('Device not found: $deviceId'));
UniversalBle.disconnect(deviceId);
@@ -60,18 +100,29 @@ class Connection {
device.processCharacteristic(characteristicUuid, value);
}
};
UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) {
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device != null && !isConnected) {
// allow reconnection
_lastScanResult.removeWhere((d) => d.deviceId == deviceId);
}
};
}
Future<void> performScanning() async {
if (isScanning.value) {
return;
}
isScanning.value = true;
_actionStreams.add(LogNotification('Scanning for devices...'));
// does not work on web, may not work on Windows
if (!kIsWeb && !Platform.isWindows) {
UniversalBle.getSystemDevices(
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
withServices: BluetoothDevice.servicesToScan,
).then((devices) async {
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList();
if (baseDevices.isNotEmpty) {
_addDevices(baseDevices);
}
@@ -79,22 +130,68 @@ class Connection {
}
await UniversalBle.startScan(
scanFilter: ScanFilter(withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]),
platformConfig: PlatformConfig(web: WebOptions(optionalServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID])),
// allow all to enable Wahoo Kickr Bike Shift detection
//scanFilter: kIsWeb ? ScanFilter(withServices: BluetoothDevice.servicesToScan) : null,
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
);
if (!kIsWeb) {
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
_addDevices(pads);
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
for (var device in removedDevices) {
devices.remove(device);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
signalChange(device);
}
});
});
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
_addDevices(pads);
});
}
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
startMyWhooshServer();
}
}
Future<void> startMyWhooshServer() {
return whooshLink.startServer(
onConnected: (socket) {
final existing = remoteDevices.firstOrNullWhere(
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
);
if (existing != null) {
existing.isConnected = true;
signalChange(existing);
}
},
onDisconnected: (socket) {
final device = devices.firstOrNullWhere(
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
);
if (device != null) {
devices.remove(device);
signalChange(device);
}
},
);
Future.delayed(Duration(seconds: 30)).then((_) {
if (isScanning.value) {
UniversalBle.stopScan();
isScanning.value = false;
}
});
}
void _addDevices(List<BaseDevice> dev) {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
devices.addAll(newDevices);
_connectionQueue.addAll(newDevices);
_handleConnectionQueue();
hasDevices.value = devices.isNotEmpty;
@@ -111,18 +208,20 @@ class Connection {
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue) {
_handlingConnectionQueue = true;
final device = _connectionQueue.removeAt(0);
_actionStreams.add(LogNotification('Connecting to: ${device.device.name ?? device.runtimeType}'));
_actionStreams.add(LogNotification('Connecting to: ${device.name}'));
_connect(device)
.then((_) {
_handlingConnectionQueue = false;
_actionStreams.add(LogNotification('Connection finished: ${device.device.name ?? device.runtimeType}'));
_actionStreams.add(LogNotification('Connection finished: ${device.name}'));
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
})
.catchError((e) {
_handlingConnectionQueue = false;
_actionStreams.add(LogNotification('Connection failed: ${device.device.name ?? device.runtimeType} - $e'));
_actionStreams.add(
LogNotification('Connection failed: ${device.name} - $e'),
);
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
@@ -130,32 +229,43 @@ class Connection {
}
}
Future<void> _connect(BaseDevice bleDevice) async {
Future<void> _connect(BaseDevice device) async {
try {
final actionSubscription = bleDevice.actionStream.listen((data) {
final actionSubscription = device.actionStream.listen((data) {
_actionStreams.add(data);
});
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
bleDevice.isConnected = state;
_connectionStreams.add(bleDevice);
if (!bleDevice.isConnected) {
devices.remove(bleDevice);
_streamSubscriptions[bleDevice]?.cancel();
_streamSubscriptions.remove(bleDevice);
_connectionSubscriptions[bleDevice]?.cancel();
_connectionSubscriptions.remove(bleDevice);
_lastScanResult.clear();
// try reconnect
if (!isScanning.value) {
if (device is BluetoothDevice) {
final connectionStateSubscription = UniversalBle.connectionStream(device.device.deviceId).listen((state) {
device.isConnected = state;
_connectionStreams.add(device);
if (!device.isConnected) {
disconnect(device, forget: true);
// try reconnect
performScanning();
}
}
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
});
_connectionSubscriptions[device] = connectionStateSubscription;
}
await bleDevice.connect();
await device.connect();
signalChange(device);
_streamSubscriptions[bleDevice] = actionSubscription;
final newButtons = device.availableButtons.filter(
(button) => actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
);
for (final button in newButtons) {
actionHandler.supportedApp?.keymap.addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [button],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
}
_streamSubscriptions[device] = actionSubscription;
} catch (e, backtrace) {
_actionStreams.add(LogNotification("$e\n$backtrace"));
if (kDebugMode) {
@@ -166,21 +276,26 @@ class Connection {
}
}
void reset() {
Future<void> reset() async {
_actionStreams.add(LogNotification('Disconnecting all devices'));
if (actionHandler is AndroidActions) {
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
_androidNotificationsSetup = false;
}
UniversalBle.stopScan();
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
if (isBtEnabled) {
UniversalBle.stopScan();
}
isScanning.value = false;
for (var device in devices) {
for (var device in bluetoothDevices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
signalChange(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
devices.clear();
@@ -193,4 +308,22 @@ class Connection {
void signalChange(BaseDevice baseDevice) {
_connectionStreams.add(baseDevice);
}
Future<void> disconnect(BaseDevice device, {required bool forget}) async {
if (device.isConnected) {
await device.disconnect();
}
if (device is! LinkDevice) {
// keep it in the list to allow reconnect
devices.remove(device);
}
if (!forget && device is BluetoothDevice) {
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
}
signalChange(device);
}
}

View File

@@ -1,276 +1,47 @@
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/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:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/crypto/local_key_provider.dart';
import 'package:swift_control/utils/crypto/zap_crypto.dart';
import 'package:swift_control/utils/single_line_exception.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import '../../utils/crypto/encryption_utils.dart';
import '../../utils/keymap/buttons.dart';
import '../messages/notification.dart';
abstract class BaseDevice {
final BleDevice scanResult;
final List<ZwiftButton> availableButtons;
final String name;
final bool isBeta;
final List<ControllerButton> availableButtons;
BaseDevice(this.scanResult, {required this.availableButtons});
final zapEncryption = ZapCrypto(LocalKeyProvider());
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false});
bool isConnected = false;
int? batteryLevel;
String? firmwareVersion;
bool supportsEncryption = false;
BleCharacteristic? syncRxCharacteristic;
Timer? _longPressTimer;
Set<ZwiftButton> _previouslyPressedButtons = <ZwiftButton>{};
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
static BaseDevice? fromScanResult(BleDevice scanResult) {
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
final device =
kIsWeb
? switch (scanResult.name) {
'Zwift Ride' => ZwiftRide(scanResult),
'Zwift Play' => ZwiftPlay(scanResult),
'Zwift Click' => ZwiftClickV2(scanResult),
_ => null,
}
: switch (scanResult.name) {
//'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), special case for Zwift Click v2: we must only connect to the left controller
_ => null,
};
if (device != null) {
return device;
} else {
// 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;
if (data == null || data.isEmpty) {
return null;
}
final type = DeviceType.fromManufacturerData(data.first);
return switch (type) {
DeviceType.click => ZwiftClick(scanResult),
DeviceType.playRight => ZwiftPlay(scanResult),
DeviceType.playLeft => ZwiftPlay(scanResult),
DeviceType.rideLeft => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
DeviceType.clickV2Left => ZwiftClickV2(scanResult),
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_ => null,
};
}
}
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
@override
int get hashCode => scanResult.hashCode;
int get hashCode => name.hashCode;
@override
String toString() {
return runtimeType.toString();
}
BleDevice get device => scanResult;
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
Future<void> connect() async {
actionStream.listen((message) {
print("Received message: $message");
});
Future<void> connect();
await UniversalBle.connect(device.deviceId);
if (!kIsWeb) {
await UniversalBle.requestMtu(device.deviceId, 517);
}
final services = await UniversalBle.discoverServices(device.deviceId);
await _handleServices(services);
}
Future<void> _handleServices(List<BleService> services) async {
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
if (customService == null) {
throw Exception(
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
);
}
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
device.deviceId,
deviceInformationService!.uuid,
firmwareCharacteristic.uuid,
);
firmwareVersion = String.fromCharCodes(firmwareData);
connection.signalChange(this);
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
);
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
);
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
);
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
throw Exception('Characteristics not found');
}
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.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
Uint8List.fromList([
...Constants.RIDE_ON,
...Constants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
]),
withoutResponse: true,
);
} else {
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
Constants.RIDE_ON,
withoutResponse: true,
);
}
}
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (kDebugMode && false) {
print(
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
);
}
if (bytes.isEmpty) {
return;
}
try {
if (bytes.startsWith(startCommand)) {
_processDevicePublicKeyResponse(bytes);
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
processData(bytes);
}
} catch (e, stackTrace) {
print("Error processing data: $e");
print("Stack Trace: $stackTrace");
if (e is SingleLineException) {
actionStreamInternal.add(LogNotification(e.message));
} else {
actionStreamInternal.add(LogNotification("$e\n$stackTrace"));
}
}
}
void _processDevicePublicKeyResponse(Uint8List bytes) {
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
if (kDebugMode) {
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
zapEncryption.initialise(devicePublicKeyBytes);
}
Future<void> processData(Uint8List bytes) async {
int type;
Uint8List message;
if (supportsEncryption) {
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
final payload = bytes.sublist(4);
if (zapEncryption.encryptionKeyBytes == null) {
actionStreamInternal.add(
LogNotification(
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
),
);
return;
}
final data = zapEncryption.decrypt(counter, payload);
type = data[0];
message = data.sublist(1);
} else {
type = bytes[0];
message = bytes.sublist(1);
}
switch (type) {
case Constants.EMPTY_MESSAGE_TYPE:
//print("Empty Message"); // expected when nothing happening
break;
case Constants.BATTERY_LEVEL_TYPE:
if (batteryLevel != message[1]) {
batteryLevel = message[1];
connection.signalChange(this);
}
break;
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE:
processClickNotification(message)
.then((buttonsClicked) async {
return handleButtonsClicked(buttonsClicked);
})
.catchError((e) {
actionStreamInternal.add(LogNotification(e.toString()));
});
break;
}
}
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
Future<void> handleButtonsClicked(List<ZwiftButton>? buttonsClicked) async {
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
if (buttonsClicked == null) {
// ignore, no changes
} else if (buttonsClicked.isEmpty) {
@@ -283,17 +54,19 @@ abstract class BaseDevice {
buttonsReleased.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
if (buttonsReleased.isNotEmpty && isLongPress) {
await _performRelease(buttonsReleased);
await performRelease(buttonsReleased);
}
_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 =
buttonsReleased.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
if (buttonsReleased.isNotEmpty && wasLongPress) {
await _performRelease(buttonsReleased);
await performRelease(buttonsReleased);
}
final isLongPress =
@@ -301,30 +74,26 @@ abstract class BaseDevice {
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
if (!isLongPress &&
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
buttonsClicked.singleOrNull == ZwiftButtons.onOffRight)) {
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
_longPressTimer?.cancel();
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
_performClick(buttonsClicked);
performClick(buttonsClicked);
});
}
// Update currently pressed buttons
_previouslyPressedButtons = buttonsClicked.toSet();
if (isLongPress) {
return _performDown(buttonsClicked);
return performDown(buttonsClicked);
} else {
return _performClick(buttonsClicked);
return performClick(buttonsClicked);
}
}
}
Future<void> _performDown(List<ZwiftButton> buttonsClicked) async {
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
settings.getVibrationEnabled()) {
await _vibrate();
}
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
for (final action in buttonsClicked) {
// For repeated actions, don't trigger key down/up events (useful for long press)
actionStreamInternal.add(
@@ -333,11 +102,7 @@ abstract class BaseDevice {
}
}
Future<void> _performClick(List<ZwiftButton> buttonsClicked) async {
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
settings.getVibrationEnabled()) {
await _vibrate();
}
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
for (final action in buttonsClicked) {
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
@@ -345,7 +110,7 @@ abstract class BaseDevice {
}
}
Future<void> _performRelease(List<ZwiftButton> buttonsReleased) async {
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
for (final action in buttonsReleased) {
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
@@ -353,17 +118,6 @@ abstract class BaseDevice {
}
}
Future<void> _vibrate() async {
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
withoutResponse: true,
);
}
Future<void> disconnect() async {
_longPressTimer?.cancel();
// Release any held keys in long press mode
@@ -371,7 +125,31 @@ abstract class BaseDevice {
await (actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
}
_previouslyPressedButtons.clear();
await UniversalBle.disconnect(device.deviceId);
isConnected = false;
}
Widget showInformation(BuildContext context);
ControllerButton? getOrAddButton(String name, ControllerButton Function() button) {
if (actionHandler.supportedApp is CustomApp) {
final allButtons = actionHandler.supportedApp!.keymap.keyPairs.expand((kp) => kp.buttons).toSet().toList();
if (allButtons.none((b) => b.name == name)) {
final newButton = button();
availableButtons.add(newButton);
actionHandler.supportedApp!.keymap.addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [newButton],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
return newButton;
} else {
return allButtons.firstWhere((b) => b.name == name);
}
}
return null;
}
}

View File

@@ -0,0 +1,251 @@
import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.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_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:universal_ble/universal_ble.dart';
import 'cycplus/cycplus_bc2.dart';
import 'elite/elite_square.dart';
import 'elite/elite_sterzo.dart';
abstract class BluetoothDevice extends BaseDevice {
final BleDevice scanResult;
BluetoothDevice(this.scanResult, {required super.availableButtons, super.isBeta = false})
: super(scanResult.name ?? 'Unknown Device') {
rssi = scanResult.rssi;
}
int? batteryLevel;
String? firmwareVersion;
int? rssi;
static List<String> servicesToScan = [
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
SquareConstants.SERVICE_UUID,
WahooKickrBikeShiftConstants.SERVICE_UUID,
SterzoConstants.SERVICE_UUID,
CycplusBc2Constants.SERVICE_UUID,
ShimanoDi2Constants.SERVICE_UUID,
];
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
BluetoothDevice? device;
if (kIsWeb) {
device = switch (scanResult.name) {
'Zwift Ride' => ZwiftRide(scanResult),
'Zwift Play' => ZwiftPlay(scanResult),
'Zwift Click' => ZwiftClickV2(scanResult),
'SQUARE' => EliteSquare(scanResult),
null => null,
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
CycplusBc2(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
_ => null,
};
} else {
device = switch (scanResult.name) {
null => null,
//'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), special case for Zwift Click v2: we must only connect to the left controller
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
CycplusBc2(scanResult),
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
// otherwise the service UUIDs will be used
_ => null,
};
}
if (device != null) {
return device;
} else if (scanResult.services.containsAny([
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;
final data = manufacturerData
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
?.payload;
if (data == null || data.isEmpty) {
return null;
}
final type = ZwiftDeviceType.fromManufacturerData(data.first);
return switch (type) {
ZwiftDeviceType.click => ZwiftClick(scanResult),
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_ => null,
};
} else {
return null;
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
@override
int get hashCode => scanResult.hashCode;
@override
String toString() {
return runtimeType.toString();
}
BleDevice get device => scanResult;
@override
Future<void> connect() async {
actionStream.listen((message) {
print("Received message: $message");
});
await UniversalBle.connect(device.deviceId);
if (!kIsWeb) {
await UniversalBle.requestMtu(device.deviceId, 517);
}
final services = await UniversalBle.discoverServices(device.deviceId);
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
device.deviceId,
deviceInformationService!.uuid,
firmwareCharacteristic.uuid,
);
firmwareVersion = String.fromCharCodes(firmwareData);
connection.signalChange(this);
}
final batteryService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_BATTERY_SERVICE_UUID.toLowerCase(),
);
final batteryCharacteristic = batteryService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL.toLowerCase(),
);
if (batteryCharacteristic != null) {
final batteryData = await UniversalBle.read(
device.deviceId,
batteryService!.uuid,
batteryCharacteristic.uuid,
);
if (batteryData.isNotEmpty) {
batteryLevel = batteryData.first;
connection.signalChange(this);
}
}
await handleServices(services);
}
Future<void> handleServices(List<BleService> services);
Future<void> processCharacteristic(String characteristic, Uint8List bytes);
@override
Future<void> disconnect() async {
await UniversalBle.disconnect(device.deviceId);
super.disconnect();
}
@override
Widget showInformation(BuildContext context) {
return Row(
children: [
Text(
device.name?.screenshot ?? device.runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
if (batteryLevel != null) ...[
Icon(switch (batteryLevel!) {
>= 80 => Icons.battery_full,
>= 60 => Icons.battery_6_bar,
>= 50 => Icons.battery_5_bar,
>= 25 => Icons.battery_4_bar,
>= 10 => Icons.battery_2_bar,
_ => Icons.battery_alert,
}),
Text('$batteryLevel%'),
],
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
if (firmwareVersion != null &&
this is ZwiftDevice &&
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[
SizedBox(width: 8),
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
Text(
' (latest: ${(this as ZwiftDevice).latestFirmwareVersion})',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (rssi != null)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Tooltip(
message: 'Signal Strength: $rssi dBm',
child: Icon(
switch (rssi!) {
>= -50 => Icons.signal_cellular_4_bar,
>= -60 => Icons.signal_cellular_alt_2_bar,
>= -70 => Icons.signal_cellular_alt_1_bar,
_ => Icons.signal_cellular_alt,
},
size: 18,
),
),
),
Expanded(child: SizedBox()),
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(
child: Text('Disconnect and Forget'),
onTap: () {
connection.disconnect(this, forget: true);
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,97 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth_device.dart';
class CycplusBc2 extends BluetoothDevice {
CycplusBc2(super.scanResult)
: super(
availableButtons: CycplusBc2Buttons.values,
);
@override
Future<void> handleServices(List<BleService> services) async {
final service = services.firstWhere(
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
);
final characteristic = service.characteristics.firstWhere(
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
);
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
}
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
// Process CYCPLUS BC2 data
// The BC2 typically sends button press data as simple byte values
// Common patterns for virtual shifters:
// - 0x01 or similar for shift up
// - 0x02 or similar for shift down
// - 0x00 for button release
if (bytes.isNotEmpty) {
final buttonCode = bytes[0];
switch (buttonCode) {
case 0x01:
// Shift up button pressed
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
break;
case 0x02:
// Shift down button pressed
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
break;
case 0x00:
// Button released
handleButtonsClicked([]);
break;
default:
// Unknown button code - log for debugging
actionStreamInternal.add(
LogNotification('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}'),
);
break;
}
}
}
return Future.value();
}
}
class CycplusBc2Constants {
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
// TX Characteristic - device sends data to app
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
// RX Characteristic - app sends data to device (not used for button reading)
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
}
class CycplusBc2Buttons {
static const ControllerButton shiftUp = ControllerButton(
'shiftUp',
action: InGameAction.shiftUp,
icon: Icons.add,
);
static const ControllerButton shiftDown = ControllerButton(
'shiftDown',
action: InGameAction.shiftDown,
icon: Icons.remove,
);
static const List<ControllerButton> values = [
shiftUp,
shiftDown,
];
}

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