mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
399 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8089556a | ||
|
|
f572a5c300 | ||
|
|
dcd11e6c4b | ||
|
|
3f1a920888 | ||
|
|
ceeca9dd02 | ||
|
|
ab379cf74b | ||
|
|
ad7bd646f3 | ||
|
|
7ed1ba4397 | ||
|
|
a9cb929b01 | ||
|
|
8d056b526e | ||
|
|
7a77acaf94 | ||
|
|
216dc97517 | ||
|
|
746a680449 | ||
|
|
b1385e70cc | ||
|
|
aec24bba61 | ||
|
|
226824c14a | ||
|
|
8c4816ffd0 | ||
|
|
07423fc0f6 | ||
|
|
812a4efe13 | ||
|
|
8dadc07e07 | ||
|
|
15dc34b2ea | ||
|
|
d8a528017d | ||
|
|
d2a41fc2fa | ||
|
|
7b2f16772d | ||
|
|
edf19e3ffa | ||
|
|
ec4e4fc375 | ||
|
|
59141c81af | ||
|
|
732bb4a150 | ||
|
|
84d9d1e312 | ||
|
|
3b6f9f6f29 | ||
|
|
21f7636cee | ||
|
|
3eda3b590a | ||
|
|
f844681f4c | ||
|
|
5c22851d66 | ||
|
|
fb2068a08a | ||
|
|
f7a0b8dca8 | ||
|
|
76c59537c1 | ||
|
|
81f14f16fd | ||
|
|
c4a8d1ef9c | ||
|
|
a1cfe43ef9 | ||
|
|
14f5486ab6 | ||
|
|
a7e2b5bc26 | ||
|
|
8bea3b36cc | ||
|
|
2802ead254 | ||
|
|
5c2ae38951 | ||
|
|
0dc6ea7fd4 | ||
|
|
288fbed819 | ||
|
|
497528c75b | ||
|
|
d8ceea9c63 | ||
|
|
bed3dac98e | ||
|
|
9eaa9c53f9 | ||
|
|
ccd1d46128 | ||
|
|
79edebc8f9 | ||
|
|
dbf148c41f | ||
|
|
fe898cefda | ||
|
|
f662d0a36a | ||
|
|
5c8a5934f2 | ||
|
|
c7e845086a | ||
|
|
67a4144ab0 | ||
|
|
bfeb72a775 | ||
|
|
799234c323 | ||
|
|
ab49da1cea | ||
|
|
0f41c85590 | ||
|
|
175ff75637 | ||
|
|
6b60046fd5 | ||
|
|
8ecac5d619 | ||
|
|
1a463f74be | ||
|
|
6fd319ffb2 | ||
|
|
551ed28310 | ||
|
|
6606586cef | ||
|
|
5157f74714 | ||
|
|
8b30b9bc29 | ||
|
|
9a74a14804 | ||
|
|
4d16eab3a7 | ||
|
|
76bcf2af68 | ||
|
|
269fb23c94 | ||
|
|
459597aca8 | ||
|
|
2c153f6c22 | ||
|
|
b66285bc83 | ||
|
|
e1bc66a1b6 | ||
|
|
fd4e5f5ce8 | ||
|
|
40a0eae187 | ||
|
|
d26cf5eb7b | ||
|
|
2404f0fdf5 | ||
|
|
50199ef077 | ||
|
|
11095cf052 | ||
|
|
69afc698dc | ||
|
|
babe564f3a | ||
|
|
c10666be80 | ||
|
|
67ad3fb8c5 | ||
|
|
586b17c2d2 | ||
|
|
4909a1a47f | ||
|
|
99e603413c | ||
|
|
875f5cb656 | ||
|
|
7ad65ba5dc | ||
|
|
7e969b1a94 | ||
|
|
5689980c87 | ||
|
|
b14a6451ed | ||
|
|
7a52828bd1 | ||
|
|
6926f5d3d5 | ||
|
|
cb6283364a | ||
|
|
1e220799be | ||
|
|
f0ad53e9d4 | ||
|
|
b0bf0bd802 | ||
|
|
e3fc35211f | ||
|
|
8c77bcea2a | ||
|
|
1693605305 | ||
|
|
93ad9d3a30 | ||
|
|
15bc4ab2af | ||
|
|
6c0942acad | ||
|
|
351e702238 | ||
|
|
8119d69c1a | ||
|
|
13d11dd927 | ||
|
|
f4c47071fb | ||
|
|
7043d16108 | ||
|
|
eb587c4341 | ||
|
|
80bd4725d1 | ||
|
|
25ff46d527 | ||
|
|
7acd86fc94 | ||
|
|
26047da35c | ||
|
|
d3ab4f8804 | ||
|
|
29940d45ba | ||
|
|
c6296b009a | ||
|
|
130a638e2b | ||
|
|
12a5d9e52b | ||
|
|
2e69cdb593 | ||
|
|
6d6427f8cd | ||
|
|
2d73d8577e | ||
|
|
19e72af527 | ||
|
|
1692c584f4 | ||
|
|
ab8e1b6afe | ||
|
|
9a7c4bb13a | ||
|
|
b97298099c | ||
|
|
a7a91e00a2 | ||
|
|
ad7d8276a2 | ||
|
|
6d652ad70b | ||
|
|
94d5b4ad92 | ||
|
|
792d2733ff | ||
|
|
07e4b5e89f | ||
|
|
fe271038cb | ||
|
|
51cb4859e3 | ||
|
|
b4667ca894 | ||
|
|
f79cfb6319 | ||
|
|
97279b7c16 | ||
|
|
5b8a64c356 | ||
|
|
29777d86e0 | ||
|
|
58440148b3 | ||
|
|
bfcf43d428 | ||
|
|
b8448d57e5 | ||
|
|
000104365c | ||
|
|
8141310f7b | ||
|
|
f432d9d187 | ||
|
|
5ab8ccc8c8 | ||
|
|
fd9818707e | ||
|
|
7422ff8624 | ||
|
|
58cf7b5bf3 | ||
|
|
87d71e1213 | ||
|
|
a0cde5352f | ||
|
|
b6ed1c047d | ||
|
|
8d8cb7381a | ||
|
|
f3bbf5e06c | ||
|
|
a5f9b42e6f | ||
|
|
d053101c14 | ||
|
|
3022b15d3a | ||
|
|
6b541e1d14 | ||
|
|
586b148ed3 | ||
|
|
cf47a758ee | ||
|
|
89230815a2 | ||
|
|
60a176bba2 | ||
|
|
44b5c010ba | ||
|
|
425c65528b | ||
|
|
8ac6f58d8e | ||
|
|
f6ac724c60 | ||
|
|
9010346c0b | ||
|
|
41a3a8f14d | ||
|
|
a883abcd1c | ||
|
|
ab37de8f40 | ||
|
|
ac0e15eaa7 | ||
|
|
a6a7e7f0c2 | ||
|
|
3cacdf9a3a | ||
|
|
3ebbda3690 | ||
|
|
74abb13acf | ||
|
|
86addc00fd | ||
|
|
9cebea225c | ||
|
|
59bdb30321 | ||
|
|
d51fb7dfa2 | ||
|
|
b955c51a91 | ||
|
|
86ecd1ad20 | ||
|
|
c089b3bdbd | ||
|
|
9612b213aa | ||
|
|
83c9b52708 | ||
|
|
a7bde7c08a | ||
|
|
c8613b5975 | ||
|
|
87bb728601 | ||
|
|
e1f9d4fb08 | ||
|
|
14e6c1186c | ||
|
|
abeb142f0b | ||
|
|
d416756614 | ||
|
|
823eb9e9a4 | ||
|
|
6579092f4a | ||
|
|
c242c09025 | ||
|
|
89c9ed598c | ||
|
|
a3a592bd16 | ||
|
|
a161829913 | ||
|
|
b4473ad067 | ||
|
|
4752f99fcf | ||
|
|
6e757cf15c | ||
|
|
a87810db88 | ||
|
|
0ddb3e8081 | ||
|
|
e29aed8bcf | ||
|
|
d99a3257af | ||
|
|
a772b210cd | ||
|
|
860700ab91 | ||
|
|
ff5d90d468 | ||
|
|
43773310d5 | ||
|
|
2da65645b0 | ||
|
|
0c62d64987 | ||
|
|
546f6c2f8f | ||
|
|
a6ee15e3ba | ||
|
|
51793847cf | ||
|
|
f308aa3847 | ||
|
|
695b994577 | ||
|
|
2301d04c61 | ||
|
|
e46ec5172c | ||
|
|
691e108c82 | ||
|
|
166146a8f8 | ||
|
|
2a42cfc80f | ||
|
|
aff1f20ebe | ||
|
|
804fed799d | ||
|
|
b6450ba47c | ||
|
|
14a4234583 | ||
|
|
72cd165992 | ||
|
|
239aec2083 | ||
|
|
a43ab60dcb | ||
|
|
484b7f74e6 | ||
|
|
8c1ddcb019 | ||
|
|
306a5badef | ||
|
|
e82003cd57 | ||
|
|
6a6aafe0a9 | ||
|
|
4bd440e167 | ||
|
|
80d198787f | ||
|
|
7f7bae477b | ||
|
|
2917814ecc | ||
|
|
e268a86f20 | ||
|
|
9a13831e91 | ||
|
|
9f6e57b9ff | ||
|
|
86cf7491c3 | ||
|
|
681ec710a1 | ||
|
|
dc40d61766 | ||
|
|
326ff4ce24 | ||
|
|
ca81642c5c | ||
|
|
32e4b9762b | ||
|
|
0c94852246 | ||
|
|
d2f67e402b | ||
|
|
145e55fe68 | ||
|
|
fd6358c233 | ||
|
|
1d047ce9ef | ||
|
|
a27a0b8872 | ||
|
|
0e98a9d500 | ||
|
|
728b2e0b53 | ||
|
|
220ffa49c6 | ||
|
|
74d1ec58de | ||
|
|
b2d19f7e70 | ||
|
|
7db8dfec62 | ||
|
|
b0c0767dc7 | ||
|
|
2e880869f3 | ||
|
|
1edf949a7a | ||
|
|
04c85668fe | ||
|
|
fcefd863cd | ||
|
|
64bac7e50f | ||
|
|
f94c1b1851 | ||
|
|
3a013db311 | ||
|
|
0fd141316e | ||
|
|
2ed4f389c9 | ||
|
|
9a1a25ed17 | ||
|
|
173fae7472 | ||
|
|
82c9df2214 | ||
|
|
a49b6b81ae | ||
|
|
dffa7003bc | ||
|
|
818ff4909a | ||
|
|
d1e054d5c5 | ||
|
|
7689da9acd | ||
|
|
0bf336643d | ||
|
|
70dc5d19e7 | ||
|
|
4da91b0fa3 | ||
|
|
cb219c57c4 | ||
|
|
0be5500d78 | ||
|
|
94dcccdce3 | ||
|
|
91b6ffdbe2 | ||
|
|
baa3a73984 | ||
|
|
dd5c231c47 | ||
|
|
3a0d4e1cbc | ||
|
|
631f031daa | ||
|
|
a487539e6a | ||
|
|
ad7454236a | ||
|
|
e182fea4d1 | ||
|
|
6615061658 | ||
|
|
5c1d423806 | ||
|
|
171f97645f | ||
|
|
9511c233a2 | ||
|
|
adfc2bd8cf | ||
|
|
2adba27dca | ||
|
|
b18c85fc8e | ||
|
|
2e79a43827 | ||
|
|
0fec34bb56 | ||
|
|
db3e133199 | ||
|
|
971cb91615 | ||
|
|
e5e04f3d59 | ||
|
|
fd9f7388e8 | ||
|
|
6ae2297246 | ||
|
|
c6fb2e68b5 | ||
|
|
c84c685a8f | ||
|
|
102f4a8818 | ||
|
|
661d72fa8c | ||
|
|
38df962e43 | ||
|
|
e02563733f | ||
|
|
c246d2d1fe | ||
|
|
8dbed9a8b5 | ||
|
|
6ea4fa82a7 | ||
|
|
0d78ca6352 | ||
|
|
b82ad80d1c | ||
|
|
eece2ce7dc | ||
|
|
3bfd7dd7ab | ||
|
|
b0f3dffc3c | ||
|
|
f5234d0c11 | ||
|
|
8a12ccb01e | ||
|
|
fac2e86240 | ||
|
|
39b49bb9de | ||
|
|
406ccfa2ce | ||
|
|
16e6b96cc7 | ||
|
|
1e37c8a742 | ||
|
|
5f0389b36f | ||
|
|
8762d85d57 | ||
|
|
018bbd43f1 | ||
|
|
756e3fc556 | ||
|
|
7ac5f5dbcb | ||
|
|
65c613fd24 | ||
|
|
e0ad9007a4 | ||
|
|
c3cda5f547 | ||
|
|
12f379c03d | ||
|
|
e760c34ede | ||
|
|
187a15a55d | ||
|
|
3e4751a6b5 | ||
|
|
b05d87196a | ||
|
|
b8297848f6 | ||
|
|
de0f004a48 | ||
|
|
0bc9c1d4d2 | ||
|
|
3fccb59544 | ||
|
|
ea76aabf91 | ||
|
|
dc1c900bd2 | ||
|
|
b1e59d8f2a | ||
|
|
006148dbd0 | ||
|
|
974ba258f5 | ||
|
|
29d833e9d1 | ||
|
|
eea72405bb | ||
|
|
e500f1ed0b | ||
|
|
6c3bd2e6a1 | ||
|
|
e54d8968e8 | ||
|
|
9dca2e989a | ||
|
|
610c5d6ef5 | ||
|
|
7797b34852 | ||
|
|
99e9f326f7 | ||
|
|
0f2d73239b | ||
|
|
497b489ea9 | ||
|
|
51581f106a | ||
|
|
43e9aa02e0 | ||
|
|
089a41cc2b | ||
|
|
279ab101cc | ||
|
|
c0f278652e | ||
|
|
ac02cc78bc | ||
|
|
d42ac3af6b | ||
|
|
20cfe76091 | ||
|
|
b68170b489 | ||
|
|
21273fa9ca | ||
|
|
700afb1050 | ||
|
|
d943544e56 | ||
|
|
e994eb01dd | ||
|
|
59d953bbc4 | ||
|
|
12ecbf80e1 | ||
|
|
a5b76b43bf | ||
|
|
fe9dd29964 | ||
|
|
eece2bcc0f | ||
|
|
f19c6b8dd0 | ||
|
|
44599b2d33 | ||
|
|
613f75fd25 | ||
|
|
6f68e6cb62 | ||
|
|
43ac412efd | ||
|
|
7149c98564 | ||
|
|
0f4e46a758 | ||
|
|
23fb927cd6 | ||
|
|
d055a260ab | ||
|
|
d55fa5f7c0 | ||
|
|
f4fd658c36 | ||
|
|
0e80d5612c | ||
|
|
9302ebc667 | ||
|
|
2265866f58 | ||
|
|
8ec6ee5ef0 | ||
|
|
a03d250bdb | ||
|
|
a0ebac41ea |
120
.github/workflows/build.yml
vendored
120
.github/workflows/build.yml
vendored
@@ -28,15 +28,10 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_web:
|
||||
description: 'Build for Web'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.38.4
|
||||
FLUTTER_VERSION: 3.41.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -52,6 +47,17 @@ jobs:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
|
||||
run: |
|
||||
if [ -f pubspec_overrides_ci.yaml ]; then
|
||||
mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
|
||||
else
|
||||
echo "No pubspec_overrides_ci.yaml found, skipping rename."
|
||||
fi
|
||||
|
||||
- name: Install certificates
|
||||
if: inputs.build_mac || inputs.build_ios
|
||||
@@ -97,12 +103,13 @@ 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
|
||||
#if: inputs.build_mac || inputs.build_android || inputs.build_ios
|
||||
if: inputs.build_android || inputs.build_ios
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
- name: Set Up Flutter maCOS
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
@@ -114,11 +121,17 @@ jobs:
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Release macOS
|
||||
if: inputs.build_mac
|
||||
if: inputs.build_mac && false
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
args: "-- --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
|
||||
|
||||
- name: Flutter Release macOS
|
||||
if: inputs.build_mac
|
||||
run:
|
||||
flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}
|
||||
|
||||
- name: Decode Keystore
|
||||
if: inputs.build_android
|
||||
@@ -132,22 +145,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: android
|
||||
args: "--artifact=apk"
|
||||
|
||||
- name: Build Web
|
||||
if: inputs.build_web
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
|
||||
- name: Upload static files as artifact
|
||||
if: inputs.build_web
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/web
|
||||
|
||||
- name: Web Deploy
|
||||
if: inputs.build_web
|
||||
uses: actions/deploy-pages@v4
|
||||
args: "-- --obfuscate --split-debug-info=symbols --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
|
||||
|
||||
- name: Extract latest changelog
|
||||
id: changelog
|
||||
@@ -168,7 +166,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: ios
|
||||
args: "--export-options-plist ios/ExportOptions.plist"
|
||||
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
|
||||
|
||||
- name: Prepare App Store authentication key
|
||||
if: inputs.build_ios || inputs.build_mac
|
||||
@@ -186,7 +184,7 @@ jobs:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
packageName: de.jonasbark.swiftcontrol
|
||||
releaseFiles: build/app/outputs/bundle/release/app-release.aab
|
||||
track: production
|
||||
track: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }}
|
||||
whatsNewDirectory: whatsnew
|
||||
|
||||
- name: Upload to macOS App Store
|
||||
@@ -206,41 +204,6 @@ jobs:
|
||||
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 && false
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.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 && false
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r BikeControl.macos.zip BikeControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: inputs.build_android && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
@@ -250,6 +213,13 @@ jobs:
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload Symbols
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Symbols
|
||||
path: symbols/
|
||||
|
||||
#13 Create Release
|
||||
- name: Create Release
|
||||
if: inputs.build_github
|
||||
@@ -271,6 +241,18 @@ jobs:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path pubspec_overrides_ci.yaml) {
|
||||
Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
|
||||
} else {
|
||||
Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
|
||||
}
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
@@ -301,6 +283,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: windows
|
||||
args: "-- --obfuscate --split-debug-info=symbols-win"
|
||||
|
||||
- name: Zip directory (Windows)
|
||||
shell: pwsh
|
||||
@@ -346,14 +329,5 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/bike_control.windows.zip
|
||||
build/windows/x64/runner/Release/bike_control.windows.msix
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/bike_control.windows.msix"
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
build/windows/x64/runner/Release/bike_control.msix
|
||||
symbols-win/
|
||||
|
||||
77
.github/workflows/patch.yml
vendored
77
.github/workflows/patch.yml
vendored
@@ -21,6 +21,17 @@ jobs:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
|
||||
run: |
|
||||
if [ -f pubspec_overrides_ci.yaml ]; then
|
||||
mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
|
||||
else
|
||||
echo "No pubspec_overrides_ci.yaml found, skipping rename."
|
||||
fi
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
@@ -86,70 +97,26 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: 🚀 Shorebird Patch macOS
|
||||
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
|
||||
if: false # patch doesn't work: https://github.com/OpenBikeControl/bikecontrol/issues/143
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: macos
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
|
||||
|
||||
- name: 🚀 Shorebird Patch Android
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: android
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --obfuscate --split-debug-info=symbols --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}'
|
||||
|
||||
- name: 🚀 Shorebird Patch iOS
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: ios
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
|
||||
# shorebird struggles with the app from GitHub
|
||||
- name: Build macOS
|
||||
if: false
|
||||
run: flutter build macos --release;
|
||||
|
||||
- name: Sign macOS build
|
||||
if: false
|
||||
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 BikeControl.app -v;
|
||||
zip -r BikeControl.macos.zip BikeControl.app/;
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
if: false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
- name: Generate release body
|
||||
if: false
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
if: false
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
|
||||
|
||||
windows:
|
||||
name: Patch Windows
|
||||
@@ -159,6 +126,18 @@ jobs:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path pubspec_overrides_ci.yaml) {
|
||||
Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
|
||||
} else {
|
||||
Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
|
||||
}
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
@@ -181,4 +160,4 @@ jobs:
|
||||
with:
|
||||
platform: windows
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --obfuscate --split-debug-info=symbols'
|
||||
|
||||
3
.github/workflows/web.yml
vendored
3
.github/workflows/web.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- web
|
||||
- wahoo_kickr_bike_shift
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/web.yml'
|
||||
@@ -45,7 +44,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Web
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
run: flutter build web --release --base-href "/bikecontrol/"
|
||||
|
||||
- name: Upload static files as artifact
|
||||
id: deployment
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ lib/gen/
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
lib/generated
|
||||
pubspec_overrides.yaml
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "prop"]
|
||||
path = prop
|
||||
url = git@github.com:OpenBikeControl/prop.git
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,3 +1,74 @@
|
||||
### 4.8.0 (15-02-2026)
|
||||
|
||||
**Features**:
|
||||
- Bluetooth media buttons are now supported on iOS
|
||||
- Shimano Di2: long press and double clicks are now supported:
|
||||
- perform steering using long presses
|
||||
- gear changes are now reflected properly without losing any button presses
|
||||
|
||||
### 4.7.0 (04-02-2026)
|
||||
|
||||
**Features**:
|
||||
- new connection method: act as Bluetooth Keyboard:
|
||||
Your device can now act as Bluetooth keyboard, allowing you to send keyboard shortcuts (e.g. for virtual shifting) directly to your connected device. Especially useful for tablets / iPads.
|
||||
- added new keyboard shortcuts for Rouvy (Kudos, Pause workout)
|
||||
|
||||
**Fixes**:
|
||||
- you can now finally buy the full version on Android :)
|
||||
- save "Enable Media Key detection" setting across app restarts
|
||||
- UI adjustments and fixes in the controller configuration screen
|
||||
- iOS: Remote pairing now works again
|
||||
|
||||
### 4.6.0 (28-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Improve Zwift Click V2 connection and handling
|
||||
- Buttons in Configuration are now grouped by device
|
||||
|
||||
### 4.5.0 (22-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Android: simulate additional actions for local connection method (Left, Down, Right, Up, Select, Back, Home, Recent Apps)
|
||||
- control your phone with your controller
|
||||
- control UI within the trainer app (if supported)
|
||||
- BikeControl now supports individual mapping when you use more than one Cycplus BC2 and ThinkRider VS200 controller
|
||||
- Windows & macOS: allow configuration of volume keys on Bluetooth HID devices
|
||||
|
||||
### 4.4.0 (16-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Support for Thinkrider VS200
|
||||
|
||||
**Fixes**:
|
||||
- Android: Local connection method allows passing keyboard events to the trainer app
|
||||
- macOS: Compatibility with macOS Tahoe
|
||||
- Windows: send keyboard events to the correct window when using multiple monitors or when another app is focused
|
||||
- Windows: fix media key detection
|
||||
|
||||
### 4.3.0 (07-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Onboarding for new users
|
||||
- support controlling music & volume for Windows, macOS and Android
|
||||
- App is now available in Italian (thanks to Connect_Thanks2613)
|
||||
|
||||
**Fixes**:
|
||||
- Vibration setting now available for Zwift Ride devices
|
||||
|
||||
### 4.2.0 (20-12-2025)
|
||||
|
||||
BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!
|
||||
|
||||
**Features**:
|
||||
- support for SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- use your phone/tablet for steering by attaching your device on your handlebar!
|
||||
- App is now available in Polish (thanks to Wandrocek)
|
||||
|
||||
**Fixes**:
|
||||
- You will now be notified when a connection to your controller is lost
|
||||
- improved UI of the Keymap customization screen
|
||||
|
||||
### 4.1.0 (16-12-2025)
|
||||
|
||||
**Features**:
|
||||
|
||||
17
INSTRUCTIONS_LOCAL.md
Normal file
17
INSTRUCTIONS_LOCAL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## What is the Local connection method?
|
||||
*
|
||||
The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app.
|
||||
|
||||
There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app.
|
||||
|
||||
## When to use the Local connection method?
|
||||
*
|
||||
The Local connection method is ideal for users who:
|
||||
- Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet).
|
||||
- Do not want to deal with network configurations or potential connectivity issues.
|
||||
|
||||
## Limitations of the Local connection method
|
||||
*
|
||||
While the Local connection method is easy to set up and use, it has some limitations:
|
||||
- It may not work well with trainer apps that have complex user interfaces or require precise timing.
|
||||
- It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios.
|
||||
@@ -1,31 +1,39 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) **close MyWhoosh Link** - very important!
|
||||
4) open BikeControl, follow on screen instructions
|
||||
1) Launch MyWhoosh on the device of your choice
|
||||
2) Only needed once: open the "MyWhoosh Link" app on the same device where you want to use BikeControl. Make sure the Link app is able to connect to MyWhoosh. If it does, close MyWhoosh Link.
|
||||
3) Make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
|
||||
4) Open BikeControl, enable the Link connection method, and follow the on-screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
Here's a video with a few explanations. Note that it uses an older version, but the idea is the same.
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
## The MyWhoosh Link app itself works fine, but BikeControl doesn't connect
|
||||
*
|
||||
The MyWhoosh Link app must not run simultaneously with BikeControl. Make sure the MyWhoosh Link app is fully closed, then reopen BikeControl and try connecting again.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if a connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
Checklist:
|
||||
- Use the MyWhoosh Link app to confirm if "Link" works in general
|
||||
- Use MyWhoosh Link app and connect, then close it, then open up BikeControl - this is key for some users
|
||||
- Both devices (if you use BikeControl on another device than MyWhoosh) are on the **same Wi‑Fi SSID**
|
||||
- Avoid “Guest” networks
|
||||
- Avoid “extenders/mesh guest mode” and networks with device isolation
|
||||
- If your router has it, disable:
|
||||
- “AP isolation / client isolation”
|
||||
- Try moving both devices to the same band:
|
||||
- Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering)
|
||||
- Temporarily disable:
|
||||
- VPNs
|
||||
- iCloud Private Relay (if enabled)
|
||||
- “Limit IP Address Tracking” (iOS Wi‑Fi option)
|
||||
- iOS Wi‑Fi settings for that network:
|
||||
- Turn off **Private Wi‑Fi Address**
|
||||
- Turn off **Limit IP Address Tracking**
|
||||
- Mesh networks: may work, but if it doesn’t, test with a simple router or phone hotspot.
|
||||
|
||||
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
|
||||
Official MyWhoosh troubleshooting links:
|
||||
- https://mywhoosh.com/troubleshoot/
|
||||
- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
## Local Connection method
|
||||
*
|
||||
The local connection method (avalable on Android, Windows and macOS) allows BikeControl to directly control Rouvy either using touch or keyboard keys. This way you don't need to select any "Controllers" at all in Rouvy.
|
||||
Make sure the "Virtual Shifting Controls" are enabled: https://support.rouvy.com/hc/en-us/articles/32452137189393-Virtual-Shifting#h_01K9SWGWYMAVQV108SQ9KWQAKC
|
||||
|
||||
771
LICENSE
771
LICENSE
@@ -1,674 +1,97 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
License notice:
|
||||
Versions of this project released prior to repository tag gpl3 were licensed
|
||||
under GPL-3.0. Those versions remain available under their original
|
||||
license. Versions released after that point are licensed under the
|
||||
Non-Commercial License.
|
||||
|
||||
NON-COMMERCIAL SOFTWARE LICENSE AGREEMENT
|
||||
|
||||
Version 1.0
|
||||
|
||||
Copyright (c) 2026 OpenBikeControl UG (haftungsbeschränkt).
|
||||
All rights reserved.
|
||||
|
||||
⸻
|
||||
|
||||
1. Definitions
|
||||
|
||||
“Software” means the source code, object code, binaries, and associated documentation made available by the Licensor under this License.
|
||||
|
||||
“Commercial Use” means any use of the Software, directly or indirectly, that is intended for or results in:
|
||||
• monetary compensation,
|
||||
• sale, licensing, or subscription fees,
|
||||
• advertising or sponsorship revenue,
|
||||
• inclusion in a product or service that is sold or monetized,
|
||||
• distribution through paid applications or application marketplaces.
|
||||
|
||||
“Licensor” means the copyright holder.
|
||||
|
||||
⸻
|
||||
|
||||
2. Grant of License
|
||||
|
||||
Subject to the terms of this License, the Licensor grants you a non-exclusive, non-transferable, revocable license to:
|
||||
• use the Software for personal, educational, or internal evaluation purposes only;
|
||||
• modify the Software for non-commercial purposes;
|
||||
• redistribute the Software only in source form, free of charge, and only under this same License.
|
||||
|
||||
⸻
|
||||
|
||||
3. Restrictions
|
||||
|
||||
You may not, without prior written permission from the Licensor:
|
||||
• use the Software for any Commercial Use;
|
||||
• distribute the Software as part of a paid or monetized product or service;
|
||||
• distribute the Software via application marketplaces (including but not limited to Apple App Store or Google Play) where the application itself or related services are monetized;
|
||||
• sublicense, sell, rent, or lease the Software.
|
||||
|
||||
⸻
|
||||
|
||||
4. Attribution
|
||||
|
||||
All copies and derivative works must retain:
|
||||
• this License text;
|
||||
• all existing copyright notices.
|
||||
|
||||
⸻
|
||||
|
||||
5. No Patent License
|
||||
|
||||
This License does not grant any patent rights.
|
||||
|
||||
⸻
|
||||
|
||||
6. Termination
|
||||
|
||||
Any violation of this License automatically terminates your rights under this License.
|
||||
|
||||
Upon termination, you must cease all use and distribution of the Software.
|
||||
|
||||
⸻
|
||||
|
||||
7. Disclaimer of Warranty
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND.
|
||||
|
||||
⸻
|
||||
|
||||
8. Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DAMAGES ARISING FROM THE USE OF THE SOFTWARE.
|
||||
|
||||
⸻
|
||||
|
||||
9. Governing Law
|
||||
|
||||
This License shall be governed by the laws of [YOUR COUNTRY], excluding conflict-of-law rules.
|
||||
|
||||
⸻
|
||||
|
||||
10. Commercial Licensing
|
||||
|
||||
Commercial use is available under separate commercial license terms.
|
||||
Contact: jonas@openbikecontrol.org
|
||||
|
||||
⸻
|
||||
|
||||
End of License
|
||||
|
||||
34
README.md
34
README.md
@@ -6,19 +6,16 @@
|
||||
|
||||
With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering/turning
|
||||
- Steering / navigation
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
[](https://youtu.be/0r3LO5lFlyc)
|
||||
|
||||
|
||||
|
||||
|
||||
## Downloads
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl.
|
||||
## Download
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl. A testing period is available, allowing you to try out the full functionality of BikeControl:
|
||||
|
||||
<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>
|
||||
|
||||
@@ -31,7 +28,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- Zwift
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- TrainingPeaks Virtual
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
|
||||
@@ -45,13 +42,21 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Zwift Play
|
||||
- Shimano Di2
|
||||
- Configure your levers to use D-Fly channels with Shimano E-Tube app
|
||||
- SRAM AXS/eTap
|
||||
- Configure your levers not to do any action in the "SRAM AXS" app
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Thinkrider VS200 Virtual Shifter (beta)
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Your Phone!
|
||||
- Mount your phone on the handlebar to detect e.g. steering
|
||||
- Available on Android and iOS
|
||||
- Gamepads
|
||||
- Keyboard input
|
||||
- like a Companion App
|
||||
- some trainers do not support keyboard input for all functions - now they do!
|
||||
- useful when remapping keys from other devices using e.g. AutoHotkey
|
||||
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
|
||||
@@ -70,7 +75,7 @@ Support for other devices can be added; check the issues tab here on GitHub.
|
||||
## Supported Platforms
|
||||
|
||||
Follow the "Get Started" button over at [bikecontrol.app](https://bikecontrol.app) to understand on which platform you want to run BikeControl.
|
||||
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
You can even try it out in your [Browser](https://openbikecontrol.github.io/bikecontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
|
||||
## Help
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
@@ -78,19 +83,16 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
## How does it work?
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. BikeControl uses different methods of connecting to the trainer app, depending on the trainer app and operating system:
|
||||
- Connect to the trainer app on the same device or on another device using Network
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
- supported by e.g. MyWhoosh, Rouvy and Zwift
|
||||
- Connect to the trainer app on another device by simulating a Bluetooth device
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
- supported by e.g. Rouvy and Zwift
|
||||
- Directly control the trainer app via Accessibility features (simulating touch and keyboard input)
|
||||
- available on Android, macOS, Windows
|
||||
- supported by all trainer apps
|
||||
- Connect to supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, macOS, Windows
|
||||
|
||||
## 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.
|
||||
- Connect to the supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
This means BikeControl does NOT see the device via Bluetooth.
|
||||
- Put the controller into pairing mode (LED should blink)
|
||||
- Ensure the controller is NOT connected to another app/device (e.g. Zwift)
|
||||
- Update controller firmware in Zwift Companion, if available
|
||||
- Reboot Bluetooth / reboot phone/PC
|
||||
|
||||
## Click / Ride device does not send any data
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
## My Click v2 disconnects after a minute or buttons do not work
|
||||
*
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
To make your Click V2 work best you should connect it in the Zwift app once before a workout session.
|
||||
If you don't do that BikeControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Optional: some users report that keeping the Click connected for more than a few seconds is more reliable.
|
||||
5. Close the Zwift app again and connect again in BikeControl
|
||||
2. Log in (subscription not required) → device connection screen
|
||||
3. Connect trainer, then connect Click v2
|
||||
4. Keep it connected for ~10–30 seconds
|
||||
5. Close Zwift completely, then connect in BikeControl
|
||||
|
||||
Details/updates: https://github.com/OpenBikeControl/bikecontrol/issues/68
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
*
|
||||
@@ -25,11 +30,7 @@ If you don't do that BikeControl will need to reconnect every minute.
|
||||
- disable battery optimization for BikeControl
|
||||
- enable auto start of BikeControl
|
||||
- grant accessibility permission for BikeControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
*
|
||||
You're probably running into [this](https://github.com/OpenBikeControl/bikecontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
- see [https://github.com/OpenBikeControl/bikecontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.0.0
|
||||
4.7.2
|
||||
|
||||
@@ -90,6 +90,23 @@ enum class MediaAction(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class GlobalAction(val raw: Int) {
|
||||
BACK(0),
|
||||
DPAD_CENTER(1),
|
||||
DOWN(2),
|
||||
RIGHT(3),
|
||||
UP(4),
|
||||
LEFT(5),
|
||||
HOME(6),
|
||||
RECENTS(7);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): GlobalAction? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class WindowEvent (
|
||||
val packageName: String,
|
||||
@@ -175,11 +192,16 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
GlobalAction.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
WindowEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AKeyEvent.fromList(it)
|
||||
}
|
||||
@@ -193,12 +215,16 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is WindowEvent -> {
|
||||
is GlobalAction -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is WindowEvent -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is AKeyEvent -> {
|
||||
stream.write(131)
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -213,9 +239,11 @@ interface Accessibility {
|
||||
fun hasPermission(): Boolean
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun performGlobalAction(action: GlobalAction)
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun isRunning(): Boolean
|
||||
fun ignoreHidDevices()
|
||||
fun setHandledKeys(keys: List<String>)
|
||||
|
||||
companion object {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -278,6 +306,24 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val actionArg = args[0] as GlobalAction
|
||||
val wrapped: List<Any?> = try {
|
||||
api.performGlobalAction(actionArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.controlMedia$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
@@ -327,6 +373,24 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val keysArg = args[0] as List<String>
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setHandledKeys(keysArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jonasbark.accessibility
|
||||
|
||||
import AKeyEvent
|
||||
import Accessibility
|
||||
import GlobalAction
|
||||
import HidKeyPressedStreamHandler
|
||||
import MediaAction
|
||||
import PigeonEventSink
|
||||
@@ -66,6 +67,10 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running")
|
||||
}
|
||||
|
||||
override fun performGlobalAction(action: GlobalAction) {
|
||||
Observable.toService?.performGlobalAction(action) ?: error("Service not running")
|
||||
}
|
||||
|
||||
override fun controlMedia(action: MediaAction) {
|
||||
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
|
||||
when (action) {
|
||||
@@ -90,6 +95,11 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
Observable.ignoreHidDevices = true
|
||||
}
|
||||
|
||||
override fun setHandledKeys(keys: List<String>) {
|
||||
// Clear and update the concurrent set
|
||||
Observable.handledKeys = keys.toSet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class WindowEventListener : StreamEventsStreamHandler(), Receiver {
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
|
||||
import GlobalAction
|
||||
|
||||
|
||||
class AccessibilityService : AccessibilityService(), Listener {
|
||||
@@ -70,8 +71,10 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
// if currently active app is BikeControl => handle it, so keymap can be created
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow?.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) {
|
||||
// Handle keys that have a keymap defined
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
|
||||
@@ -95,6 +98,20 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun performGlobalAction(action: GlobalAction) {
|
||||
val mappedAction = when (action) {
|
||||
GlobalAction.BACK -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK
|
||||
GlobalAction.DPAD_CENTER -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_CENTER
|
||||
GlobalAction.DOWN -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_DOWN
|
||||
GlobalAction.RIGHT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_RIGHT
|
||||
GlobalAction.UP -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_UP
|
||||
GlobalAction.LEFT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_LEFT
|
||||
GlobalAction.HOME -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME
|
||||
GlobalAction.RECENTS -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS
|
||||
}
|
||||
performGlobalAction(mappedAction)
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
|
||||
@@ -2,16 +2,21 @@ package de.jonasbark.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.KeyEvent
|
||||
import GlobalAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object Observable {
|
||||
var toService: Listener? = null
|
||||
var fromServiceWindow: Receiver? = null
|
||||
var fromServiceKeys: Receiver? = null
|
||||
var ignoreHidDevices: Boolean = false
|
||||
// Use concurrent set for thread-safe access from AccessibilityService and plugin
|
||||
var handledKeys: Set<String> = ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun performGlobalAction(action: GlobalAction)
|
||||
}
|
||||
|
||||
interface Receiver {
|
||||
|
||||
@@ -8,15 +8,30 @@ abstract class Accessibility {
|
||||
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void performGlobalAction(GlobalAction action);
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
|
||||
void setHandledKeys(List<String> keys);
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
|
||||
enum GlobalAction {
|
||||
back,
|
||||
dpadCenter,
|
||||
down,
|
||||
right,
|
||||
up,
|
||||
left,
|
||||
home,
|
||||
recents,
|
||||
}
|
||||
|
||||
class WindowEvent {
|
||||
final String packageName;
|
||||
final int top;
|
||||
|
||||
@@ -36,6 +36,17 @@ enum MediaAction {
|
||||
volumeDown,
|
||||
}
|
||||
|
||||
enum GlobalAction {
|
||||
back,
|
||||
dpadCenter,
|
||||
down,
|
||||
right,
|
||||
up,
|
||||
left,
|
||||
home,
|
||||
recents,
|
||||
}
|
||||
|
||||
class WindowEvent {
|
||||
WindowEvent({
|
||||
required this.packageName,
|
||||
@@ -164,11 +175,14 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is MediaAction) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is WindowEvent) {
|
||||
} else if (value is GlobalAction) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is WindowEvent) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is AKeyEvent) {
|
||||
buffer.putUint8(131);
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -182,8 +196,11 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : MediaAction.values[value];
|
||||
case 130:
|
||||
return WindowEvent.decode(readValue(buffer)!);
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : GlobalAction.values[value];
|
||||
case 131:
|
||||
return WindowEvent.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return AKeyEvent.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
@@ -280,6 +297,29 @@ class Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performGlobalAction(GlobalAction action) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[action]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> controlMedia(MediaAction action) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.controlMedia$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
@@ -353,6 +393,29 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHandledKeys(List<String> keys) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[keys]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
|
||||
@@ -16,7 +16,7 @@ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
android {
|
||||
namespace = "de.jonasbark.swiftcontrol"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
// Flag to enable support for the new language APIs
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<!-- New Bluetooth permissions in Android 12
|
||||
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
|
||||
@@ -16,6 +17,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
|
||||
<uses-permission android:name="android.permission.BILLING"/>
|
||||
|
||||
<!-- legacy for Android 9 or lower -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />
|
||||
@@ -49,6 +51,13 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
|
||||
<data android:scheme="bikecontrol" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
|
||||
@@ -5,10 +5,10 @@ import android.os.Handler
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
|
||||
|
||||
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
|
||||
class MainActivity: FlutterFragmentActivity(), GamepadsCompatibleActivity {
|
||||
var keyListener: ((KeyEvent) -> Boolean)? = null
|
||||
var motionListener: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 785 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 476 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
3
android/app/src/main/res/raw/keep.xml
Normal file
3
android/app/src/main/res/raw/keep.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*" />
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
|
||||
BIN
assets/silence.mp3
Normal file
BIN
assets/silence.mp3
Normal file
Binary file not shown.
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
platform :ios, '15.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
163
ios/Podfile.lock
163
ios/Podfile.lock
@@ -1,4 +1,18 @@
|
||||
PODS:
|
||||
- app_links (6.4.1):
|
||||
- Flutter
|
||||
- AppAuth (2.0.0):
|
||||
- AppAuth/Core (= 2.0.0)
|
||||
- AppAuth/ExternalUserAgent (= 2.0.0)
|
||||
- AppAuth/Core (2.0.0)
|
||||
- AppAuth/ExternalUserAgent (2.0.0):
|
||||
- AppAuth/Core
|
||||
- AppCheckCore (11.2.0):
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- bluetooth_low_energy_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -7,30 +21,86 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- gamepads_ios (0.1.1):
|
||||
- Flutter
|
||||
- google_sign_in_ios (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- GoogleSignIn (~> 9.0)
|
||||
- GTMSessionFetcher (>= 3.4.0)
|
||||
- GoogleSignIn (9.1.0):
|
||||
- AppAuth (~> 2.0)
|
||||
- AppCheckCore (~> 11.0)
|
||||
- GTMAppAuth (~> 5.0)
|
||||
- GTMSessionFetcher/Core (~> 3.3)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMAppAuth (5.0.0):
|
||||
- AppAuth/Core (~> 2.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
|
||||
- GTMSessionFetcher (3.5.0):
|
||||
- GTMSessionFetcher/Full (= 3.5.0)
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- GTMSessionFetcher/Full (3.5.0):
|
||||
- GTMSessionFetcher/Core
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- in_app_purchase_storekit (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- ios_receipt (0.0.1):
|
||||
- Flutter
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- media_key_detector_ios (0.0.1):
|
||||
- Flutter
|
||||
- nsd_ios (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- purchases_flutter (9.10.6):
|
||||
- Flutter
|
||||
- PurchasesHybridCommon (= 17.27.1)
|
||||
- purchases_ui_flutter (9.10.6):
|
||||
- Flutter
|
||||
- PurchasesHybridCommonUI (= 17.27.1)
|
||||
- PurchasesHybridCommon (17.27.1):
|
||||
- RevenueCat (= 5.54.1)
|
||||
- PurchasesHybridCommonUI (17.27.1):
|
||||
- PurchasesHybridCommon (= 17.27.1)
|
||||
- RevenueCatUI (= 5.54.1)
|
||||
- restart_app (0.0.1):
|
||||
- Flutter
|
||||
- RevenueCat (5.54.1)
|
||||
- RevenueCatUI (5.54.1):
|
||||
- RevenueCat (= 5.54.1)
|
||||
- sensors_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sign_in_with_apple (0.0.1):
|
||||
- Flutter
|
||||
- universal_ble (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -40,26 +110,55 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- bluetooth_low_energy_darwin (from `.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`)
|
||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||
- google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- ios_receipt (from `.symlinks/plugins/ios_receipt/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_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`)
|
||||
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
|
||||
- purchases_ui_flutter (from `.symlinks/plugins/purchases_ui_flutter/ios`)
|
||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
|
||||
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- AppAuth
|
||||
- AppCheckCore
|
||||
- GoogleSignIn
|
||||
- GoogleUtilities
|
||||
- GTMAppAuth
|
||||
- GTMSessionFetcher
|
||||
- PromisesObjC
|
||||
- PurchasesHybridCommon
|
||||
- PurchasesHybridCommonUI
|
||||
- RevenueCat
|
||||
- RevenueCatUI
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_links:
|
||||
:path: ".symlinks/plugins/app_links/ios"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
bluetooth_low_energy_darwin:
|
||||
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
|
||||
device_info_plus:
|
||||
@@ -68,28 +167,46 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
flutter_volume_controller:
|
||||
:path: ".symlinks/plugins/flutter_volume_controller/ios"
|
||||
gamepads_ios:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
google_sign_in_ios:
|
||||
:path: ".symlinks/plugins/google_sign_in_ios/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_purchase_storekit:
|
||||
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
ios_receipt:
|
||||
:path: ".symlinks/plugins/ios_receipt/ios"
|
||||
just_audio:
|
||||
:path: ".symlinks/plugins/just_audio/darwin"
|
||||
media_key_detector_ios:
|
||||
:path: ".symlinks/plugins/media_key_detector_ios/ios"
|
||||
nsd_ios:
|
||||
:path: ".symlinks/plugins/nsd_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"
|
||||
purchases_flutter:
|
||||
:path: ".symlinks/plugins/purchases_flutter/ios"
|
||||
purchases_ui_flutter:
|
||||
:path: ".symlinks/plugins/purchases_ui_flutter/ios"
|
||||
restart_app:
|
||||
:path: ".symlinks/plugins/restart_app/ios"
|
||||
sensors_plus:
|
||||
:path: ".symlinks/plugins/sensors_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sign_in_with_apple:
|
||||
:path: ".symlinks/plugins/sign_in_with_apple/ios"
|
||||
universal_ble:
|
||||
:path: ".symlinks/plugins/universal_ble/darwin"
|
||||
url_launcher_ios:
|
||||
@@ -98,25 +215,47 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
|
||||
app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe
|
||||
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
flutter_volume_controller: e4d5832f08008180f76e30faf671ffd5a425e529
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601
|
||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6
|
||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
purchases_flutter: b3c0792197f69cd7af4c2449b71df6ac6378aace
|
||||
purchases_ui_flutter: caae6d62ea23c6fe964992a28353211cc74b244a
|
||||
PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f
|
||||
PurchasesHybridCommonUI: 48afb5e29204958bff1276b0f7acb8e4b59fe99a
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
RevenueCat: ecbba580fa453b0d4a0475449b904196d74ef678
|
||||
RevenueCatUI: ac7492873928e9e7f297e5e27a7c4f23f9008326
|
||||
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -152,6 +153,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
@@ -487,12 +489,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -673,12 +677,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -699,12 +705,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -8,6 +8,8 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>GIDClientID</key>
|
||||
<string>709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -30,19 +43,22 @@
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>BikeControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_wahoo-fitness-tnp._tcp</string>
|
||||
<string>_openbikecontrol._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_wahoo-fitness-tnp._tcp</string>
|
||||
<string>_openbikecontrol._tcp</string>
|
||||
</array>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>Access your accelerometer and gyroscope for steering support via your phone.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
|
||||
12
ios/Runner/Runner.entitlements
Normal file
12
ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
2
ios_receipt/.gitattributes
vendored
Normal file
2
ios_receipt/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
30
ios_receipt/.gitignore
vendored
Normal file
30
ios_receipt/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
30
ios_receipt/.metadata
Normal file
30
ios_receipt/.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
- platform: ios
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
7
ios_receipt/CHANGELOG.md
Normal file
7
ios_receipt/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## 1.1.0
|
||||
|
||||
* Migrate to StoreKit2
|
||||
|
||||
## 1.0.0
|
||||
|
||||
* Implement main method
|
||||
21
ios_receipt/LICENSE
Normal file
21
ios_receipt/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Dmytro O. Kut'ko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
32
ios_receipt/README.md
Normal file
32
ios_receipt/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# IosReceipt
|
||||
|
||||
The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your `pubspec.yaml` file:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
ios_receipt: ^0.0.1 # Use the latest version of the package
|
||||
```
|
||||
Then, run flutter pub get to install the package.
|
||||
|
||||
## Usage
|
||||
Method for get transactions with StoreKit2
|
||||
```dart
|
||||
final transactions = await IosReceipt.getAllTransactions();
|
||||
```
|
||||
|
||||
## Based on
|
||||
This package is based on the [appStoreReceiptURL](https://developer.apple.com/documentation/foundation/nsbundle/1407276-appstorereceipturl) from the official Apple documentation.
|
||||
|
||||
## Note
|
||||
- The receipt isn't necessary if you use AppTransaction to validate the app download, or Transaction to validate in-app purchases.
|
||||
- If the receipt is invalid or missing in your app, use SKReceiptRefreshRequest to request a new receipt.
|
||||
|
||||
## Testing Environments
|
||||
Keep in mind that receipts aren't initially present in iOS and iPadOS apps in the sandbox environment and in Xcode. Apps receive a receipt after the tester completes the first in-app purchase.
|
||||
|
||||
## License
|
||||
This project is licensed under the MIT License.
|
||||
4
ios_receipt/analysis_options.yaml
Normal file
4
ios_receipt/analysis_options.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
38
ios_receipt/ios/.gitignore
vendored
Normal file
38
ios_receipt/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/ios/Assets/.gitkeep
Normal file
0
ios_receipt/ios/Assets/.gitkeep
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger())
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/ios/ios_receipt.podspec
Normal file
23
ios_receipt/ios/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '15.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
22
ios_receipt/lib/ios_receipt.dart
Normal file
22
ios_receipt/lib/ios_receipt.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:ios_receipt/models/transaction.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
class IosReceipt {
|
||||
static Future<String?> getAppleReceipt() {
|
||||
return IosReceiptPlatform.instance.getAppleReceipt();
|
||||
}
|
||||
|
||||
static Future<bool> isSandbox() {
|
||||
return IosReceiptPlatform.instance.isSandbox();
|
||||
}
|
||||
|
||||
static Future<List<Transaction>> getAllTransactions() async {
|
||||
final list = await IosReceiptPlatform.instance.getAllTransactions();
|
||||
final result = <Transaction>[];
|
||||
for (var data in list) {
|
||||
result.add(Transaction.fromMap(data));
|
||||
}
|
||||
return result..sort((a, b) => a.purchaseDate.compareTo(b.purchaseDate));
|
||||
}
|
||||
}
|
||||
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
/// An implementation of [IosReceiptPlatform] that uses method channels.
|
||||
class MethodChannelIosReceipt extends IosReceiptPlatform {
|
||||
/// The method channel used to interact with the native platform.
|
||||
@visibleForTesting
|
||||
final methodChannel = const MethodChannel('ios_receipt');
|
||||
|
||||
@override
|
||||
Future<String?> getAppleReceipt() async {
|
||||
final version = await methodChannel.invokeMethod<String>('getAppleReceipt');
|
||||
return version;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() async {
|
||||
final list = await methodChannel.invokeMethod('getAllTransactions');
|
||||
return (list as List).map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isSandbox() async {
|
||||
final isSandbox = await methodChannel.invokeMethod<bool>('isSandbox');
|
||||
return isSandbox ?? false;
|
||||
}
|
||||
}
|
||||
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'ios_receipt_method_channel.dart';
|
||||
|
||||
abstract class IosReceiptPlatform extends PlatformInterface {
|
||||
/// Constructs a IosReceiptPlatform.
|
||||
IosReceiptPlatform() : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
|
||||
static IosReceiptPlatform _instance = MethodChannelIosReceipt();
|
||||
|
||||
/// The default instance of [IosReceiptPlatform] to use.
|
||||
///
|
||||
/// Defaults to [MethodChannelIosReceipt].
|
||||
static IosReceiptPlatform get instance => _instance;
|
||||
|
||||
/// Platform-specific implementations should set this with their own
|
||||
/// platform-specific class that extends [IosReceiptPlatform] when
|
||||
/// they register themselves.
|
||||
static set instance(IosReceiptPlatform instance) {
|
||||
PlatformInterface.verifyToken(instance, _token);
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
Future<String?> getAppleReceipt() {
|
||||
throw UnimplementedError('platformVersion() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() {
|
||||
throw UnimplementedError('getAllTransactions() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<bool> isSandbox() async {
|
||||
throw UnimplementedError('isSandbox() has not been implemented.');
|
||||
}
|
||||
}
|
||||
41
ios_receipt/lib/models/transaction.dart
Normal file
41
ios_receipt/lib/models/transaction.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
class Transaction {
|
||||
const Transaction({
|
||||
required this.jws,
|
||||
required this.productId,
|
||||
required this.transactionId,
|
||||
required this.purchaseDate,
|
||||
required this.originalTransactionId,
|
||||
});
|
||||
|
||||
final String? jws;
|
||||
final String productId;
|
||||
final String transactionId;
|
||||
final DateTime purchaseDate;
|
||||
final String originalTransactionId;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Transaction other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other.jws == jws &&
|
||||
other.productId == productId &&
|
||||
other.transactionId == transactionId &&
|
||||
other.purchaseDate == purchaseDate &&
|
||||
other.originalTransactionId == originalTransactionId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
jws.hashCode ^
|
||||
productId.hashCode ^
|
||||
transactionId.hashCode ^
|
||||
purchaseDate.hashCode ^
|
||||
originalTransactionId.hashCode;
|
||||
|
||||
factory Transaction.fromMap(Map<String, dynamic> map) => Transaction(
|
||||
jws: map['jws'] as String?,
|
||||
productId: map['productId'] as String,
|
||||
transactionId: map['transactionId'] as String,
|
||||
purchaseDate: DateTime.parse(map['purchaseDate'] as String),
|
||||
originalTransactionId: map['originalTransactionId'] as String,
|
||||
);
|
||||
}
|
||||
38
ios_receipt/macos/.gitignore
vendored
Normal file
38
ios_receipt/macos/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/macos/Assets/.gitkeep
Normal file
0
ios_receipt/macos/Assets/.gitkeep
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
import FlutterMacOS
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger)
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/macos/ios_receipt.podspec
Normal file
23
ios_receipt/macos/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'FlutterMacOS'
|
||||
s.platform = :osx, '12.00'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
26
ios_receipt/pubspec.yaml
Normal file
26
ios_receipt/pubspec.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: ios_receipt
|
||||
description: The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
version: 1.1.0
|
||||
homepage: https://github.com/DimaKutko/ios_receipt
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
ios:
|
||||
pluginClass: IosReceiptPlugin
|
||||
macos:
|
||||
pluginClass: IosReceiptPlugin
|
||||
@@ -38,6 +38,11 @@ class KeyPressSimulator {
|
||||
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false);
|
||||
}
|
||||
|
||||
/// Simulate media key press.
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
|
||||
return _platform.simulateMediaKey(mediaKey);
|
||||
}
|
||||
|
||||
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
|
||||
Future<void> simulateCtrlCKeyPress() async {
|
||||
const key = PhysicalKeyboardKey.keyC;
|
||||
|
||||
@@ -22,6 +22,9 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
case "simulateMouseClick":
|
||||
simulateMouseClick(call, result: result)
|
||||
break
|
||||
case "simulateMediaKey":
|
||||
simulateMediaKey(call, result: result)
|
||||
break
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -114,4 +117,62 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
eventKeyPress!.flags = flags
|
||||
return eventKeyPress!
|
||||
}
|
||||
|
||||
public func simulateMediaKey(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args:[String: Any] = call.arguments as! [String: Any]
|
||||
let keyIdentifier: String = args["key"] as! String
|
||||
|
||||
// Map string identifier to macOS NX key codes
|
||||
var mediaKeyCode: Int32 = 0
|
||||
switch keyIdentifier {
|
||||
case "playPause":
|
||||
mediaKeyCode = NX_KEYTYPE_PLAY
|
||||
case "stop":
|
||||
// macOS doesn't have a dedicated stop key in its media control API.
|
||||
// Following macOS conventions, we map stop to play/pause which toggles playback.
|
||||
// This matches the behavior of the physical media keys on Mac keyboards.
|
||||
mediaKeyCode = NX_KEYTYPE_PLAY
|
||||
case "next":
|
||||
mediaKeyCode = NX_KEYTYPE_FAST
|
||||
case "previous":
|
||||
mediaKeyCode = NX_KEYTYPE_REWIND
|
||||
case "volumeUp":
|
||||
mediaKeyCode = NX_KEYTYPE_SOUND_UP
|
||||
case "volumeDown":
|
||||
mediaKeyCode = NX_KEYTYPE_SOUND_DOWN
|
||||
default:
|
||||
result(FlutterError(code: "UNSUPPORTED_KEY", message: "Unsupported media key identifier", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Create and post the media key event (key down)
|
||||
let eventDown = NSEvent.otherEvent(
|
||||
with: .systemDefined,
|
||||
location: NSPoint.zero,
|
||||
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xa00),
|
||||
timestamp: 0,
|
||||
windowNumber: 0,
|
||||
context: nil,
|
||||
subtype: 8,
|
||||
data1: Int((mediaKeyCode << 16) | (0xa << 8)),
|
||||
data2: -1
|
||||
)
|
||||
eventDown?.cgEvent?.post(tap: .cghidEventTap)
|
||||
|
||||
// Create and post the media key event (key up)
|
||||
let eventUp = NSEvent.otherEvent(
|
||||
with: .systemDefined,
|
||||
location: NSPoint.zero,
|
||||
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xb00),
|
||||
timestamp: 0,
|
||||
windowNumber: 0,
|
||||
context: nil,
|
||||
subtype: 8,
|
||||
data1: Int((mediaKeyCode << 16) | (0xb << 8)),
|
||||
data2: -1
|
||||
)
|
||||
eventUp?.cgEvent?.post(tap: .cghidEventTap)
|
||||
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +61,27 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMouseClick', arguments);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) async {
|
||||
// Map PhysicalKeyboardKey to string identifier since keyCode is null for media keys
|
||||
final keyMap = {
|
||||
PhysicalKeyboardKey.mediaPlayPause: 'playPause',
|
||||
PhysicalKeyboardKey.mediaStop: 'stop',
|
||||
PhysicalKeyboardKey.mediaTrackNext: 'next',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious: 'previous',
|
||||
PhysicalKeyboardKey.audioVolumeUp: 'volumeUp',
|
||||
PhysicalKeyboardKey.audioVolumeDown: 'volumeDown',
|
||||
};
|
||||
|
||||
final keyIdentifier = keyMap[mediaKey];
|
||||
if (keyIdentifier == null) {
|
||||
throw UnsupportedError('Unsupported media key: $mediaKey');
|
||||
}
|
||||
|
||||
final Map<String, Object?> arguments = {
|
||||
'key': keyIdentifier,
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMediaKey', arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
throw UnimplementedError('simulateMouseClick() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
|
||||
throw UnimplementedError('simulateMediaKey() has not been implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
#include "keypress_simulator_windows_plugin.h"
|
||||
|
||||
// This must be included before many other Windows headers.
|
||||
#include <windows.h>
|
||||
#include <flutter_windows.h>
|
||||
#include <psapi.h>
|
||||
#include <string.h>
|
||||
#include <flutter_windows.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
using flutter::EncodableList;
|
||||
using flutter::EncodableMap;
|
||||
@@ -27,7 +30,8 @@ struct FindWindowData {
|
||||
};
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam);
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle);
|
||||
HWND FindTargetWindow(const std::string& processName,
|
||||
const std::string& windowTitle);
|
||||
|
||||
// static
|
||||
void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
|
||||
@@ -68,26 +72,91 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
|
||||
// List of compatible training apps to look for
|
||||
std::vector<std::string> compatibleApps = {
|
||||
"MyWhooshHD.exe",
|
||||
"indieVelo.exe",
|
||||
"biketerra.exe"
|
||||
};
|
||||
std::vector<std::string> compatibleApps = {"MyWhooshHD.exe", "MyWhoosh.exe",
|
||||
"indieVelo.exe", "biketerra.exe",
|
||||
"Rouvy.exe"};
|
||||
|
||||
// Try to find and focus a compatible app
|
||||
// Try to find and focus (or directly target) a compatible app
|
||||
std::string foundProcessName;
|
||||
bool supportsBackgroundInput = true;
|
||||
HWND targetWindow = NULL;
|
||||
for (const std::string& processName : compatibleApps) {
|
||||
targetWindow = FindTargetWindow(processName, "");
|
||||
if (targetWindow != NULL) {
|
||||
// Only focus the window if it's not already in the foreground
|
||||
if (GetForegroundWindow() != targetWindow) {
|
||||
foundProcessName = processName;
|
||||
if (!supportsBackgroundInput && GetForegroundWindow() != targetWindow) {
|
||||
SetForegroundWindow(targetWindow);
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a target window that supports background input and it's not
|
||||
// focused, send messages directly
|
||||
auto postKeyMessage = [](HWND hwnd, UINT vkCode, bool down) {
|
||||
const WORD scanCode =
|
||||
static_cast<WORD>(MapVirtualKey(vkCode, MAPVK_VK_TO_VSC));
|
||||
// Build lParam with repeat count 1 and scan code; set transition states for
|
||||
// key up
|
||||
LPARAM lParam = 1 | (static_cast<LPARAM>(scanCode) << 16);
|
||||
if (vkCode == VK_LEFT || vkCode == VK_RIGHT || vkCode == VK_UP ||
|
||||
vkCode == VK_DOWN || vkCode == VK_INSERT || vkCode == VK_DELETE ||
|
||||
vkCode == VK_HOME || vkCode == VK_END || vkCode == VK_PRIOR ||
|
||||
vkCode == VK_NEXT) {
|
||||
lParam |= (1 << 24); // extended key
|
||||
}
|
||||
if (!down) {
|
||||
lParam |= (1 << 30); // previous key state
|
||||
lParam |= (1 << 31); // transition state
|
||||
}
|
||||
PostMessage(hwnd, down ? WM_KEYDOWN : WM_KEYUP, vkCode, lParam);
|
||||
};
|
||||
|
||||
auto sendKeyToWindow = [&postKeyMessage](HWND hwnd,
|
||||
const std::vector<std::string>& mods,
|
||||
UINT keyCode, bool down) {
|
||||
auto handleModifier = [&postKeyMessage, hwnd](UINT vk, bool press) {
|
||||
postKeyMessage(hwnd, vk, press);
|
||||
};
|
||||
|
||||
if (down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
handleModifier(VK_SHIFT, true);
|
||||
} else if (modifier == "controlModifier") {
|
||||
handleModifier(VK_CONTROL, true);
|
||||
} else if (modifier == "altModifier") {
|
||||
handleModifier(VK_MENU, true);
|
||||
} else if (modifier == "metaModifier") {
|
||||
handleModifier(VK_LWIN, true);
|
||||
}
|
||||
}
|
||||
postKeyMessage(hwnd, keyCode, true);
|
||||
} else {
|
||||
postKeyMessage(hwnd, keyCode, false);
|
||||
// release modifiers
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
handleModifier(VK_SHIFT, false);
|
||||
} else if (modifier == "controlModifier") {
|
||||
handleModifier(VK_CONTROL, false);
|
||||
} else if (modifier == "altModifier") {
|
||||
handleModifier(VK_MENU, false);
|
||||
} else if (modifier == "metaModifier") {
|
||||
handleModifier(VK_LWIN, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (targetWindow != NULL && !foundProcessName.empty() &&
|
||||
supportsBackgroundInput && GetForegroundWindow() != targetWindow) {
|
||||
sendKeyToWindow(targetWindow, modifiers, keyCode, keyDown);
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to send modifier key events
|
||||
auto sendModifierKey = [](UINT vkCode, bool down) {
|
||||
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
|
||||
@@ -100,7 +169,8 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
};
|
||||
|
||||
// Helper function to process modifiers
|
||||
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
|
||||
auto processModifiers = [&sendModifierKey](
|
||||
const std::vector<std::string>& mods, bool down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
sendModifierKey(VK_SHIFT, down);
|
||||
@@ -124,12 +194,13 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (keyDown ? 0 : KEYEVENTF_KEYUP);
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP || keyCode == VK_DOWN ||
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE || keyCode == VK_HOME || keyCode == VK_END ||
|
||||
keyCode == VK_PRIOR || keyCode == VK_NEXT) {
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP ||
|
||||
keyCode == VK_DOWN || keyCode == VK_INSERT || keyCode == VK_DELETE ||
|
||||
keyCode == VK_HOME || keyCode == VK_END || keyCode == VK_PRIOR ||
|
||||
keyCode == VK_NEXT) {
|
||||
in.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
@@ -148,7 +219,6 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
@@ -156,12 +226,12 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
|
||||
auto it_x = args.find(EncodableValue("x"));
|
||||
if (it_x != args.end() && std::holds_alternative<double>(it_x->second)) {
|
||||
x = std::get<double>(it_x->second);
|
||||
x = std::get<double>(it_x->second);
|
||||
}
|
||||
|
||||
auto it_y = args.find(EncodableValue("y"));
|
||||
if (it_y != args.end() && std::holds_alternative<double>(it_y->second)) {
|
||||
y = std::get<double>(it_y->second);
|
||||
y = std::get<double>(it_y->second);
|
||||
}
|
||||
|
||||
// Get the monitor containing the target point and its DPI
|
||||
@@ -169,7 +239,7 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
|
||||
// Scale the coordinates according to the DPI scaling
|
||||
int scaled_x = static_cast<int>(x * scale_factor);
|
||||
int scaled_y = static_cast<int>(y * scale_factor);
|
||||
@@ -182,14 +252,14 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
input.type = INPUT_MOUSE;
|
||||
|
||||
if (keyDown) {
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
|
||||
} else {
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
@@ -200,7 +270,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
|
||||
// Check if window is visible and not minimized
|
||||
if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) {
|
||||
return TRUE; // Continue enumeration
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
// Get window title
|
||||
@@ -210,7 +280,8 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
// Get process name
|
||||
DWORD processId;
|
||||
GetWindowThreadProcessId(hwnd, &processId);
|
||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
HANDLE hProcess =
|
||||
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
char processName[MAX_PATH];
|
||||
if (hProcess) {
|
||||
DWORD size = sizeof(processName);
|
||||
@@ -218,7 +289,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
// Extract just the filename from the full path
|
||||
char* filename = strrchr(processName, '\\');
|
||||
if (filename) {
|
||||
filename++; // Skip the backslash
|
||||
filename++; // Skip the backslash
|
||||
} else {
|
||||
filename = processName;
|
||||
}
|
||||
@@ -227,7 +298,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
if (!data->targetProcessName.empty() &&
|
||||
_stricmp(filename, data->targetProcessName.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
}
|
||||
CloseHandle(hProcess);
|
||||
@@ -237,13 +308,14 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
if (!data->targetWindowTitle.empty() &&
|
||||
_stricmp(windowTitle, data->targetWindowTitle.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
|
||||
return TRUE; // Continue enumeration
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle) {
|
||||
HWND FindTargetWindow(const std::string& processName,
|
||||
const std::string& windowTitle) {
|
||||
FindWindowData data;
|
||||
data.targetProcessName = processName;
|
||||
data.targetWindowTitle = windowTitle;
|
||||
@@ -253,7 +325,45 @@ HWND FindTargetWindow(const std::string& processName, const std::string& windowT
|
||||
return data.foundWindow;
|
||||
}
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
std::string keyIdentifier =
|
||||
std::get<std::string>(args.at(EncodableValue("key")));
|
||||
|
||||
// Map string identifier to Windows virtual key codes
|
||||
static const std::unordered_map<std::string, UINT> keyMap = {
|
||||
{"playPause", VK_MEDIA_PLAY_PAUSE}, {"stop", VK_MEDIA_STOP},
|
||||
{"next", VK_MEDIA_NEXT_TRACK}, {"previous", VK_MEDIA_PREV_TRACK},
|
||||
{"volumeUp", VK_VOLUME_UP}, {"volumeDown", VK_VOLUME_DOWN}};
|
||||
|
||||
auto it = keyMap.find(keyIdentifier);
|
||||
if (it == keyMap.end()) {
|
||||
result->Error("UNSUPPORTED_KEY", "Unsupported media key identifier");
|
||||
return;
|
||||
}
|
||||
UINT vkCode = it->second;
|
||||
|
||||
// Send key down event
|
||||
INPUT inputs[2] = {};
|
||||
inputs[0].type = INPUT_KEYBOARD;
|
||||
inputs[0].ki.wVk = static_cast<WORD>(vkCode);
|
||||
inputs[0].ki.dwFlags = 0; // Key down
|
||||
|
||||
// Send key up event
|
||||
inputs[1].type = INPUT_KEYBOARD;
|
||||
inputs[1].ki.wVk = static_cast<WORD>(vkCode);
|
||||
inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
|
||||
UINT eventsSent = SendInput(2, inputs, sizeof(INPUT));
|
||||
if (eventsSent != 2) {
|
||||
result->Error("SEND_INPUT_FAILED", "Failed to send media key input events");
|
||||
return;
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
@@ -262,6 +372,8 @@ void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
SimulateKeyPress(method_call, std::move(result));
|
||||
} else if (method_call.method_name().compare("simulateMouseClick") == 0) {
|
||||
SimulateMouseClick(method_call, std::move(result));
|
||||
} else if (method_call.method_name().compare("simulateMediaKey") == 0) {
|
||||
SimulateMediaKey(method_call, std::move(result));
|
||||
} else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
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:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'devices/base_device.dart';
|
||||
@@ -28,10 +27,12 @@ class Connection {
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<GyroscopeSteering> get gyroscopeDevices => devices.whereType<GyroscopeSteering>().toList();
|
||||
List<WahooKickrHeadwind> get accessories => devices.whereType<WahooKickrHeadwind>().toList();
|
||||
List<BaseDevice> get controllerDevices => [
|
||||
...bluetoothDevices.where((d) => d is! WahooKickrHeadwind),
|
||||
...gamepadDevices,
|
||||
...gyroscopeDevices,
|
||||
...devices.whereType<HidDevice>(),
|
||||
];
|
||||
|
||||
@@ -48,6 +49,8 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
|
||||
final StreamController<BluetoothDevice> _rssiConnectionStreams = StreamController<BluetoothDevice>.broadcast();
|
||||
Stream<BluetoothDevice> get rssiConnectionStream => _rssiConnectionStreams.stream;
|
||||
|
||||
final _lastScanResult = <BleDevice>[];
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
@@ -61,6 +64,12 @@ class Connection {
|
||||
lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList();
|
||||
});
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) {
|
||||
core.mediaKeyHandler.initialize();
|
||||
// Load saved media key detection state
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = core.settings.getMediaKeyDetectionEnabled();
|
||||
}
|
||||
|
||||
UniversalBle.onAvailabilityChange = (available) {
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
@@ -70,7 +79,8 @@ class Connection {
|
||||
}
|
||||
});
|
||||
} else if (available == AvailabilityState.poweredOff) {
|
||||
reset();
|
||||
disconnectAll();
|
||||
stop();
|
||||
}
|
||||
};
|
||||
UniversalBle.onScanResult = (result) {
|
||||
@@ -80,30 +90,42 @@ class Connection {
|
||||
);
|
||||
if (existingDevice != null && existingDevice.rssi != result.rssi) {
|
||||
existingDevice.rssi = result.rssi;
|
||||
_connectionStreams.add(existingDevice); // Notify UI of update
|
||||
_rssiConnectionStreams.add(existingDevice); // Notify UI of update
|
||||
}
|
||||
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
|
||||
_lastScanResult.add(result);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Scan result: ${result.name} - ${result.deviceId}');
|
||||
debugPrint('Scan result: ${result.name} - ${result.deviceId} - Services: ${result.services}');
|
||||
}
|
||||
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
try {
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
if (data != null && kDebugMode) {
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(
|
||||
LogNotification('Found unknown device ${result.name} with identifier: ${data.firstOrNull}'),
|
||||
LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'),
|
||||
);
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
if (data != null && kDebugMode) {
|
||||
_actionStreams.add(
|
||||
LogNotification('Found unknown device ${result.name} with identifier: ${data.firstOrNull}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification("Error processing scan result for device ${result.deviceId}: $e\n$backtrace"),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +142,7 @@ class Connection {
|
||||
// on web, log all characteristic changes for debugging
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -129,7 +151,7 @@ class Connection {
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
|
||||
"Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
@@ -154,6 +176,9 @@ class Connection {
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
if (core.settings.getPhoneSteeringEnabled()) {
|
||||
toggleGyroscopeSteering(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +189,10 @@ class Connection {
|
||||
isScanning.value = true;
|
||||
_actionStreams.add(LogNotification('Scanning for devices...'));
|
||||
|
||||
if (screenshotMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
@@ -202,14 +231,6 @@ class Connection {
|
||||
} else {
|
||||
isScanning.value = false;
|
||||
}
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
@@ -248,16 +269,28 @@ class Connection {
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
void toggleGyroscopeSteering(bool enable) {
|
||||
final existing = gyroscopeDevices.firstOrNull;
|
||||
if (existing != null && !enable) {
|
||||
// Remove gyroscope steering
|
||||
disconnect(existing, forget: true, persistForget: false);
|
||||
} else if (enable) {
|
||||
// Add gyroscope steering
|
||||
final gyroDevice = GyroscopeSteering();
|
||||
addDevices([gyroDevice]);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
// windows apparently has issues when connecting to multiple devices at once, so don't
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
@@ -267,11 +300,11 @@ class Connection {
|
||||
_handlingConnectionQueue = false;
|
||||
if (e is TimeoutException) {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.name}: Timeout'),
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'),
|
||||
);
|
||||
} else {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.name} - $e'),
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'),
|
||||
);
|
||||
}
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
@@ -287,9 +320,18 @@ class Connection {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
if (device is BluetoothDevice) {
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(device.device.deviceId).listen((state) {
|
||||
final connectionStateSubscription = device.device.connectionStream.listen((state) {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1338,
|
||||
'${device.toString()} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
|
||||
!state ? AppLocalizations.current.tryingToConnectAgain : null,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Connection', 'Connection Status'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true, presentSound: false),
|
||||
),
|
||||
);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: false, persistForget: false);
|
||||
// try reconnect
|
||||
@@ -302,22 +344,19 @@ class Connection {
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
final newButtons = device.availableButtons.filter(
|
||||
(button) => core.actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
core.actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
IAPManager.instance.setAttributes();
|
||||
|
||||
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
|
||||
|
||||
_streamSubscriptions[device] = actionSubscription;
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.addPersistentNotification().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification("$e\n$backtrace"));
|
||||
if (kDebugMode) {
|
||||
@@ -328,31 +367,6 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
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();
|
||||
}
|
||||
|
||||
void signalNotification(BaseNotification notification) {
|
||||
_actionStreams.add(notification);
|
||||
}
|
||||
@@ -369,8 +383,8 @@ class Connection {
|
||||
if (device is BluetoothDevice) {
|
||||
if (persistForget) {
|
||||
// Add device to ignored list when forgetting
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.name);
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.toString());
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.toString()}'));
|
||||
}
|
||||
if (!forget) {
|
||||
// allow reconnection
|
||||
@@ -383,6 +397,16 @@ class Connection {
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
} else if (device is GyroscopeSteering) {
|
||||
// Clean up subscriptions
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
@@ -390,4 +414,29 @@ class Connection {
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
|
||||
Future<void> disconnectAll() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
for (var device in bluetoothDevices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
device.disconnect();
|
||||
signalChange(device);
|
||||
devices.remove(device);
|
||||
}
|
||||
_gamePadSearchTimer?.cancel();
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:prop/prop.dart' show LogLevel;
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final String name;
|
||||
final String? _name;
|
||||
final bool isBeta;
|
||||
final String uniqueId;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false}) {
|
||||
BaseDevice(
|
||||
this._name, {
|
||||
required this.uniqueId,
|
||||
required this.availableButtons,
|
||||
this.isBeta = false,
|
||||
String? buttonPrefix,
|
||||
}) {
|
||||
if (availableButtons.isEmpty && core.actionHandler.supportedApp is CustomApp) {
|
||||
// TODO we should verify where the buttons came from
|
||||
final allButtons = core.actionHandler.supportedApp!.keymap.keyPairs.flatMap((e) => e.buttons);
|
||||
final allButtons = core.actionHandler.supportedApp!.keymap.keyPairs
|
||||
.flatMap((e) => e.buttons)
|
||||
.filter(
|
||||
(e) =>
|
||||
e.sourceDeviceId == uniqueId ||
|
||||
(e.sourceDeviceId == null && buttonPrefix != null && e.name.startsWith(buttonPrefix)),
|
||||
)
|
||||
.toSet();
|
||||
availableButtons.addAll(allButtons);
|
||||
}
|
||||
}
|
||||
@@ -29,17 +47,20 @@ abstract class BaseDevice {
|
||||
Timer? _longPressTimer;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
String get name => _name ?? runtimeType.toString();
|
||||
|
||||
String get buttonExplanation => isConnected ? 'Connecting...' : 'Click a button on this device to configure them.';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && toString() == other.toString();
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
}
|
||||
String toString() => name;
|
||||
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
|
||||
@@ -47,9 +68,32 @@ abstract class BaseDevice {
|
||||
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClickedWithoutLongPressSupport(List<ControllerButton> clickedButtons) async {
|
||||
if (clickedButtons.length == 1) {
|
||||
final keyPair = core.actionHandler.supportedApp?.keymap.getKeyPair(clickedButtons.single);
|
||||
if (keyPair != null && (keyPair.isLongPress || keyPair.inGameAction?.isLongPress == true)) {
|
||||
// For long press actions: perform down, wait, then release
|
||||
await handleButtonsClicked(clickedButtons, longPress: true);
|
||||
_longPressTimer?.cancel();
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
} else {
|
||||
// For non-long-press actions: perform a single click
|
||||
// First call performs the click action (isKeyDown: true, isKeyUp: true)
|
||||
await handleButtonsClicked(clickedButtons);
|
||||
// Second call cleans up state (clears timer, logs release, clears _previouslyPressedButtons)
|
||||
// but doesn't perform a release action since longPress: false
|
||||
await handleButtonsClicked([]);
|
||||
}
|
||||
} else {
|
||||
await handleButtonsClicked(clickedButtons);
|
||||
await handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked);
|
||||
await _handleButtonsClickedInternal(buttonsClicked, longPress: longPress);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
@@ -57,7 +101,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked, {required bool longPress}) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -67,8 +111,9 @@ abstract class BaseDevice {
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
@@ -79,15 +124,17 @@ abstract class BaseDevice {
|
||||
// Handle release events for buttons that are no longer pressed
|
||||
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
|
||||
final wasLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsClicked.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
|
||||
@@ -109,16 +156,46 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
String _getCommandLimitMessage() {
|
||||
return AppLocalizations.current.dailyCommandLimitReachedNotification;
|
||||
}
|
||||
|
||||
String _getCommandLimitTitle() {
|
||||
return AppLocalizations.current
|
||||
.dailyLimitReached(IAPManager.dailyCommandLimit, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit} / ${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
//actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_ERROR, _getCommandLimitMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: true);
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
@@ -126,8 +203,14 @@ abstract class BaseDevice {
|
||||
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: false, isKeyUp: true);
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +227,19 @@ abstract class BaseDevice {
|
||||
Widget showInformation(BuildContext context);
|
||||
|
||||
ControllerButton getOrAddButton(String key, ControllerButton Function() creator) {
|
||||
if (core.actionHandler.supportedApp == null) {
|
||||
return creator();
|
||||
}
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
// should we display this to the user?
|
||||
KeymapManager().duplicateSync(currentProfile, '$currentProfile (Copy)');
|
||||
}
|
||||
final button = core.actionHandler.supportedApp!.keymap.getOrAddButton(key, creator);
|
||||
var createdButton = creator();
|
||||
if (createdButton.sourceDeviceId == null) {
|
||||
createdButton = createdButton.copyWith(sourceDeviceId: uniqueId);
|
||||
}
|
||||
final button = core.actionHandler.supportedApp!.keymap.getOrAddButton(key, createdButton);
|
||||
|
||||
if (availableButtons.none((e) => e.name == button.name)) {
|
||||
availableButtons.add(button);
|
||||
@@ -157,4 +247,26 @@ abstract class BaseDevice {
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
void _showCommandLimitAlert() {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
_getCommandLimitMessage(),
|
||||
buttonTitle: AppLocalizations.current.purchase,
|
||||
onTap: () {
|
||||
IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!);
|
||||
},
|
||||
),
|
||||
);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1337,
|
||||
_getCommandLimitTitle(),
|
||||
_getCommandLimitMessage(),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Limit', 'Limit reached'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
@@ -13,13 +15,15 @@ import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/loading_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@@ -28,12 +32,26 @@ import 'package:universal_ble/universal_ble.dart';
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
import 'thinkrider/thinkrider_vs200.dart';
|
||||
|
||||
abstract class BluetoothDevice extends BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
|
||||
BluetoothDevice(this.scanResult, {required super.availableButtons, super.isBeta = false})
|
||||
: super(scanResult.name ?? 'Unknown Device') {
|
||||
BluetoothDevice(
|
||||
this.scanResult, {
|
||||
required List<ControllerButton> availableButtons,
|
||||
bool allowMultiple = false,
|
||||
bool isBeta = false,
|
||||
String? buttonPrefix,
|
||||
}) : super(
|
||||
scanResult.name,
|
||||
uniqueId: scanResult.deviceId,
|
||||
availableButtons: allowMultiple
|
||||
? availableButtons.toList().map((b) => b.copyWith(sourceDeviceId: scanResult.deviceId)).toList()
|
||||
: availableButtons.toList(),
|
||||
isBeta: isBeta,
|
||||
buttonPrefix: buttonPrefix,
|
||||
) {
|
||||
rssi = scanResult.rssi;
|
||||
}
|
||||
|
||||
@@ -43,6 +61,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
|
||||
static List<String> servicesToScan = [
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
@@ -52,15 +71,24 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
|
||||
OpenBikeControlConstants.SERVICE_UUID,
|
||||
ThinkRiderVs200Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static final List<String> _ignoredNames = ['ASSIOMA', 'QUARQ', 'POWERCRANK'];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
// skip devices with ignored names
|
||||
if (scanResult.name != null &&
|
||||
_ignoredNames.any((ignoredName) => scanResult.name!.toUpperCase().startsWith(ignoredName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 Play' => ZwiftPlay(scanResult, deviceType: ZwiftDeviceType.playLeft),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
'OpenBike' => OpenBikeControlDevice(scanResult),
|
||||
@@ -71,7 +99,9 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
@@ -79,7 +109,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
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 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('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
@@ -88,11 +118,16 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(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.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
//_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.containsAny(ProxyDevice.proxyServiceUUIDs) && kDebugMode => ProxyDevice(scanResult),
|
||||
_ when scanResult.services.contains(SramAxsConstants.SERVICE_UUID.toLowerCase()) => SramAxs(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(OpenBikeControlConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
@@ -106,6 +141,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
return device;
|
||||
} else if (scanResult.services.containsAny([
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID.toLowerCase(),
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
])) {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
@@ -115,28 +151,31 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
} else {
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
device = switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult, deviceType: type!),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult, deviceType: type!),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => 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
|
||||
_
|
||||
when scanResult.name == 'Zwift Ride' &&
|
||||
type != ZwiftDeviceType.rideRight &&
|
||||
type != ZwiftDeviceType.rideLeft =>
|
||||
ZwiftRide(scanResult), // e.g. old firmware
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scanResult.name == 'Zwift Ride' &&
|
||||
device == null &&
|
||||
core.connection.controllerDevices.none((d) => d is ZwiftRide)) {
|
||||
// Fallback for Zwift Ride if nothing else matched => old firmware
|
||||
buildToast(
|
||||
title: 'You may need to update your Zwift Ride firmware.',
|
||||
duration: Duration(seconds: 6),
|
||||
);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,11 +186,6 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
@override
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
|
||||
@override
|
||||
@@ -225,7 +259,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
toString().screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
@@ -273,113 +307,79 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Basic(
|
||||
title: Text(context.i18n.connection).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: Icon(switch (isConnected) {
|
||||
true => Icons.bluetooth_connected_outlined,
|
||||
false => Icons.bluetooth_disabled_outlined,
|
||||
}),
|
||||
subtitle: Text(
|
||||
isConnected ? context.i18n.connected : context.i18n.disconnected,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.connection,
|
||||
icon: switch (isConnected) {
|
||||
true => Icons.bluetooth_connected_outlined,
|
||||
false => Icons.bluetooth_disabled_outlined,
|
||||
},
|
||||
value: isConnected ? context.i18n.connected : context.i18n.disconnected,
|
||||
),
|
||||
|
||||
if (batteryLevel != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Basic(
|
||||
title: Text(context.i18n.battery).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: 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,
|
||||
}),
|
||||
subtitle: Text(
|
||||
'$batteryLevel%',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.battery,
|
||||
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,
|
||||
},
|
||||
value: '$batteryLevel%',
|
||||
),
|
||||
if (firmwareVersion != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
padding: EdgeInsets.all(12),
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
child: Basic(
|
||||
title: Text(context.i18n.firmware).xSmall,
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$firmwareVersion',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion)
|
||||
Text(
|
||||
' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icon(Icons.warning, color: Theme.of(context).colorScheme.destructive)
|
||||
: Icon(Icons.text_fields_sharp),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.firmware,
|
||||
icon: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icons.warning
|
||||
: Icons.text_fields_sharp,
|
||||
value: firmwareVersion!,
|
||||
additionalInfo: (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion)
|
||||
? Text(
|
||||
' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
if (rssi != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
padding: EdgeInsets.all(12),
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
child: Basic(
|
||||
title: Text(context.i18n.signal).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: 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,
|
||||
),
|
||||
subtitle: Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder(
|
||||
stream: core.connection.rssiConnectionStream
|
||||
.where((device) => device == this)
|
||||
.map((event) => event.rssi),
|
||||
builder: (context, rssiValue) {
|
||||
return DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: switch (rssiValue.data ?? 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,
|
||||
},
|
||||
value: '$rssi dBm',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void debugSubscribeToAll(List<BleService> services) {
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
if (characteristic.properties.contains(CharacteristicProperty.indicate)) {
|
||||
debugPrint('Subscribing to indications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
if (characteristic.properties.contains(CharacteristicProperty.notify)) {
|
||||
debugPrint('Subscribing to notifications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
allowMultiple: true,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -40,7 +41,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
|
||||
buttonsToPress.add(availableButtons[0]);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
@@ -49,7 +50,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
|
||||
buttonsToPress.add(availableButtons[1]);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
|
||||
GamepadDevice(super.name, {required this.id}) : super(availableButtons: []);
|
||||
GamepadDevice(super.name, {required this.id}) : super(availableButtons: [], uniqueId: id);
|
||||
|
||||
List<ControllerButton> _lastButtonsClicked = [];
|
||||
|
||||
@@ -30,7 +32,7 @@ class GamepadDevice extends BaseDevice {
|
||||
final buttonKey = event.type == KeyType.analog ? '${event.key}_$normalizedValue' : event.key;
|
||||
ControllerButton button = getOrAddButton(
|
||||
buttonKey,
|
||||
() => ControllerButton(buttonKey),
|
||||
() => ControllerButton(buttonKey, sourceDeviceId: id),
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
@@ -68,12 +70,20 @@ class GamepadDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid && !core.settings.getLocalEnabled())
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.',
|
||||
).small,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
485
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
485
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
@@ -0,0 +1,485 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
/// Gyroscope and Accelerometer based steering device
|
||||
/// Detects handlebar movement when the phone is mounted on the handlebar
|
||||
class GyroscopeSteering extends BaseDevice {
|
||||
GyroscopeSteering()
|
||||
: super(
|
||||
'Phone Steering',
|
||||
availableButtons: GyroscopeSteeringButtons.values,
|
||||
isBeta: true,
|
||||
uniqueId: 'gyroscope_steering_device',
|
||||
buttonPrefix: 'gyro',
|
||||
);
|
||||
|
||||
StreamSubscription<GyroscopeEvent>? _gyroscopeSubscription;
|
||||
StreamSubscription<AccelerometerEvent>? _accelerometerSubscription;
|
||||
StreamSubscription<MagnetometerEvent>? _magnetometerSubscription;
|
||||
|
||||
// Calibration state
|
||||
final SteeringEstimator _estimator = SteeringEstimator();
|
||||
bool _isCalibrated = false;
|
||||
ControllerButton? _lastSteeringButton;
|
||||
|
||||
// Accelerometer raw data
|
||||
bool _hasAccelData = false;
|
||||
|
||||
// Time tracking for integration
|
||||
DateTime? _lastGyroUpdate;
|
||||
|
||||
// Last rounded angle for change detection
|
||||
int? _lastRoundedAngle;
|
||||
|
||||
// Debounce timer for PWM-like keypress behavior
|
||||
Timer? _keypressTimer;
|
||||
|
||||
// Magnetometer mode
|
||||
bool _useMagnetometer = false;
|
||||
double? _magnetometerCalibrationHeading;
|
||||
double _currentMagnetometerAngle = 0.0;
|
||||
final List<double> _magnetometerCalibrationSamples = [];
|
||||
|
||||
// Magnetometer filtering state
|
||||
double? _filteredMagX;
|
||||
double? _filteredMagY;
|
||||
static const double _magnetometerFilterAlpha = 0.15; // Lower = more smoothing
|
||||
|
||||
// Configuration (can be made customizable later)
|
||||
static const double STEERING_THRESHOLD = 5.0; // degrees
|
||||
static const double LEVEL_DEGREE_STEP = 10.0; // degrees per level
|
||||
static const int MAX_LEVELS = 5;
|
||||
static const int KEY_REPEAT_INTERVAL_MS = 40;
|
||||
static const double COMPLEMENTARY_FILTER_ALPHA = 0.98; // Weight for gyroscope
|
||||
static const double LOW_PASS_FILTER_ALPHA = 0.9; // Smoothing factor
|
||||
|
||||
/// Start listening to the appropriate sensors based on the current mode
|
||||
Future<void> _startSensorStreams() async {
|
||||
// Cancel all existing subscriptions first
|
||||
await _gyroscopeSubscription?.cancel();
|
||||
await _accelerometerSubscription?.cancel();
|
||||
await _magnetometerSubscription?.cancel();
|
||||
_gyroscopeSubscription = null;
|
||||
_accelerometerSubscription = null;
|
||||
_magnetometerSubscription = null;
|
||||
|
||||
if (_useMagnetometer) {
|
||||
// Magnetometer mode: only listen to magnetometer
|
||||
_magnetometerSubscription = magnetometerEventStream().listen(
|
||||
_handleMagnetometerEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Magnetometer error: $error'));
|
||||
},
|
||||
);
|
||||
actionStreamInternal.add(LogNotification('Started magnetometer stream'));
|
||||
} else {
|
||||
// Gyroscope mode: listen to gyroscope and accelerometer
|
||||
_gyroscopeSubscription = gyroscopeEventStream().listen(
|
||||
_handleGyroscopeEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Gyroscope error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
_accelerometerSubscription = accelerometerEventStream().listen(
|
||||
_handleAccelerometerEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Accelerometer error: $error'));
|
||||
},
|
||||
);
|
||||
actionStreamInternal.add(LogNotification('Started gyroscope and accelerometer streams'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start listening to sensors based on current mode
|
||||
await _startSensorStreams();
|
||||
|
||||
isConnected = true;
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Connected - Calibrating...'));
|
||||
|
||||
// Reset calibration/estimator
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
} catch (e) {
|
||||
actionStreamInternal.add(LogNotification('Failed to connect Gyroscope Steering: $e'));
|
||||
isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleGyroscopeEvent(GyroscopeEvent event) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (!_hasAccelData) {
|
||||
_lastGyroUpdate = now;
|
||||
return;
|
||||
}
|
||||
|
||||
final dt = _lastGyroUpdate != null ? (now.difference(_lastGyroUpdate!).inMicroseconds / 1000000.0) : 0.0;
|
||||
_lastGyroUpdate = now;
|
||||
|
||||
if (dt <= 0 || dt >= 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS drift fix:
|
||||
// - integrate bias-corrected gyro z (yaw) into an estimator
|
||||
// - learn bias while the device is still
|
||||
final angleDeg = _estimator.updateGyro(wz: event.z, dt: dt);
|
||||
|
||||
if (!_isCalibrated) {
|
||||
// Consider calibration complete once we have a bit of stillness and sensor data.
|
||||
// This gives the bias estimator time to settle.
|
||||
if (_estimator.stillTimeSec >= 0.6) {
|
||||
_estimator.calibrate(seedBiasZRadPerSec: _estimator.biasZRadPerSec);
|
||||
_isCalibrated = true;
|
||||
/*actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibration complete.'),
|
||||
);*/
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_processSteeringAngle(angleDeg);
|
||||
}
|
||||
|
||||
void _handleAccelerometerEvent(AccelerometerEvent event) {
|
||||
_hasAccelData = true;
|
||||
_estimator.updateAccel(x: event.x, y: event.y, z: event.z);
|
||||
}
|
||||
|
||||
void _handleMagnetometerEvent(MagnetometerEvent event) {
|
||||
// Magnetometer mode: calculate heading from X and Y components
|
||||
// This is more stable than using a single axis
|
||||
|
||||
// Apply low-pass filter to reduce noise
|
||||
if (_filteredMagX == null || _filteredMagY == null) {
|
||||
// Initialize on first reading
|
||||
_filteredMagX = event.x;
|
||||
_filteredMagY = event.y;
|
||||
} else {
|
||||
// Exponential moving average (low-pass filter)
|
||||
_filteredMagX = _magnetometerFilterAlpha * event.x + (1 - _magnetometerFilterAlpha) * _filteredMagX!;
|
||||
_filteredMagY = _magnetometerFilterAlpha * event.y + (1 - _magnetometerFilterAlpha) * _filteredMagY!;
|
||||
}
|
||||
|
||||
// Calculate heading from filtered X and Y components
|
||||
// atan2(y, x) gives the angle in radians, convert to degrees
|
||||
double heading = atan2(_filteredMagY!, _filteredMagX!) * (180 / pi);
|
||||
|
||||
// Normalize heading to 0-360 range
|
||||
if (heading < 0) heading += 360;
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'Magnetometer - X: ${event.x.toStringAsFixed(2)}, Y: ${event.y.toStringAsFixed(2)}, '
|
||||
'Filtered X: ${_filteredMagX!.toStringAsFixed(2)}, Filtered Y: ${_filteredMagY!.toStringAsFixed(2)}, '
|
||||
'Heading: ${heading.toStringAsFixed(2)}°',
|
||||
);
|
||||
}
|
||||
|
||||
// During calibration, collect heading samples
|
||||
if (!_isCalibrated) {
|
||||
_magnetometerCalibrationSamples.add(heading);
|
||||
|
||||
// After 30 samples (~1 second at typical rates), calculate calibration heading
|
||||
if (_magnetometerCalibrationSamples.length >= 30) {
|
||||
// For heading, we need to handle the circular nature (0° and 360° are the same)
|
||||
// Use circular mean calculation
|
||||
double sumSin = 0, sumCos = 0;
|
||||
for (var h in _magnetometerCalibrationSamples) {
|
||||
final radians = h * (pi / 180);
|
||||
sumSin += sin(radians);
|
||||
sumCos += cos(radians);
|
||||
}
|
||||
final avgSin = sumSin / _magnetometerCalibrationSamples.length;
|
||||
final avgCos = sumCos / _magnetometerCalibrationSamples.length;
|
||||
_magnetometerCalibrationHeading = atan2(avgSin, avgCos) * (180 / pi);
|
||||
if (_magnetometerCalibrationHeading! < 0)
|
||||
_magnetometerCalibrationHeading = _magnetometerCalibrationHeading! + 360;
|
||||
|
||||
_magnetometerCalibrationSamples.clear();
|
||||
_isCalibrated = true;
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Magnetometer calibration complete. Reference heading: ${_magnetometerCalibrationHeading!.toStringAsFixed(2)}°',
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate steering angle relative to calibrated heading
|
||||
// This is the angular difference, accounting for wrap-around
|
||||
double angleDeg = heading - _magnetometerCalibrationHeading!;
|
||||
|
||||
// Normalize to -180 to +180 range
|
||||
if (angleDeg > 180) {
|
||||
angleDeg -= 360;
|
||||
} else if (angleDeg < -180) {
|
||||
angleDeg += 360;
|
||||
}
|
||||
|
||||
_currentMagnetometerAngle = angleDeg;
|
||||
|
||||
_processSteeringAngle(angleDeg);
|
||||
}
|
||||
|
||||
void _processSteeringAngle(double steeringAngleDeg) {
|
||||
final roundedAngle = steeringAngleDeg.round();
|
||||
|
||||
if (_lastRoundedAngle != roundedAngle) {
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Steering angle: $roundedAngle° (biasZ=${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s)',
|
||||
),
|
||||
);
|
||||
}
|
||||
_lastRoundedAngle = roundedAngle;
|
||||
_applyPWMSteering(roundedAngle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude
|
||||
void _applyPWMSteering(int roundedAngle) {
|
||||
// Cancel any pending keypress timer
|
||||
_keypressTimer?.cancel();
|
||||
|
||||
// Determine if we're steering
|
||||
if (roundedAngle.abs() > core.settings.getPhoneSteeringThreshold()) {
|
||||
// Determine direction
|
||||
final button = roundedAngle < 0 ? GyroscopeSteeringButtons.rightSteer : GyroscopeSteeringButtons.leftSteer;
|
||||
|
||||
if (_lastSteeringButton != button) {
|
||||
// New steering direction - reset any previous state
|
||||
_lastSteeringButton = button;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonsClicked([button]);
|
||||
} else {
|
||||
_lastSteeringButton = null;
|
||||
// Center position - release any held buttons
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await _gyroscopeSubscription?.cancel();
|
||||
await _accelerometerSubscription?.cancel();
|
||||
await _magnetometerSubscription?.cancel();
|
||||
_gyroscopeSubscription = null;
|
||||
_accelerometerSubscription = null;
|
||||
_magnetometerSubscription = null;
|
||||
_keypressTimer?.cancel();
|
||||
isConnected = false;
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_magnetometerCalibrationHeading = null;
|
||||
_magnetometerCalibrationSamples.clear();
|
||||
_currentMagnetometerAngle = 0.0;
|
||||
_filteredMagX = null;
|
||||
_filteredMagY = null;
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Disconnected'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
// Magnetometer mode toggle
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text('Use Magnetometer Mode')),
|
||||
state: _useMagnetometer ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_useMagnetometer = value == CheckboxState.checked;
|
||||
// Reset calibration when switching modes
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
_magnetometerCalibrationHeading = null;
|
||||
_magnetometerCalibrationSamples.clear();
|
||||
_currentMagnetometerAngle = 0.0;
|
||||
_filteredMagX = null;
|
||||
_filteredMagY = null;
|
||||
});
|
||||
|
||||
// Restart sensor streams if device is connected
|
||||
if (isConnected) {
|
||||
await _startSensorStreams();
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Switched to ${_useMagnetometer ? "magnetometer" : "gyroscope + accelerometer"} mode',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
DeviceInfo(
|
||||
title: 'Calibration',
|
||||
icon: BootstrapIcons.wrenchAdjustable,
|
||||
value: _isCalibrated ? 'Complete' : 'In Progress',
|
||||
),
|
||||
DeviceInfo(
|
||||
title: 'Steering Angle',
|
||||
icon: RadixIcons.angle,
|
||||
value: _isCalibrated
|
||||
? '${(_useMagnetometer ? _currentMagnetometerAngle : _estimator.angleDeg).toStringAsFixed(2)}°'
|
||||
: 'Calibrating...',
|
||||
),
|
||||
if (kDebugMode && !_useMagnetometer)
|
||||
DeviceInfo(
|
||||
title: 'Gyro Bias',
|
||||
icon: BootstrapIcons.speedometer,
|
||||
value: '${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s',
|
||||
),
|
||||
if (kDebugMode && _useMagnetometer && _magnetometerCalibrationHeading != null)
|
||||
DeviceInfo(
|
||||
title: 'Mag Heading',
|
||||
icon: BootstrapIcons.compass,
|
||||
value: '${_magnetometerCalibrationHeading!.toStringAsFixed(2)}°',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
leading: !_isCalibrated ? SmallProgressIndicator() : null,
|
||||
onPressed: !_isCalibrated
|
||||
? null
|
||||
: () {
|
||||
// Reset calibration
|
||||
_isCalibrated = false;
|
||||
if (_useMagnetometer) {
|
||||
_magnetometerCalibrationHeading = null;
|
||||
_magnetometerCalibrationSamples.clear();
|
||||
_currentMagnetometerAngle = 0.0;
|
||||
_filteredMagX = null;
|
||||
_filteredMagY = null;
|
||||
} else {
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
}
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(_isCalibrated ? 'Calibrate' : 'Calibrating...'),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${core.settings.getPhoneSteeringThreshold().toInt()}°'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [for (var i = 3; i <= 12; i += 1) i];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('$v°'),
|
||||
onPressed: (c) {
|
||||
core.settings.setPhoneSteeringThreshold(v);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Trigger Threshold:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!_isCalibrated)
|
||||
Text(
|
||||
_useMagnetometer
|
||||
? 'Calibrating the magnetometer now. Attach your phone/tablet on your handlebar and keep it still for a second.'
|
||||
: 'Calibrating the sensors now. Attach your phone/tablet on your handlebar and keep it still for a second.',
|
||||
).xSmall,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GyroscopeSteeringButtons {
|
||||
static final ControllerButton leftSteer = ControllerButton(
|
||||
'gyroLeftSteer',
|
||||
action: InGameAction.steerLeft,
|
||||
);
|
||||
static final ControllerButton rightSteer = ControllerButton(
|
||||
'gyroRightSteer',
|
||||
action: InGameAction.steerRight,
|
||||
);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
];
|
||||
}
|
||||
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Pure-Dart steering estimator for phone-on-handlebar steering.
|
||||
///
|
||||
/// Design goals:
|
||||
/// - Avoid long-term drift on platforms like iOS by continuously estimating
|
||||
/// and subtracting gyro bias.
|
||||
/// - Keep it testable (no Flutter/sensors dependencies).
|
||||
///
|
||||
/// NOTE: This is not a full AHRS. It uses bias-corrected integration and
|
||||
/// a "stillness" detector to learn gyro bias and optionally auto-recenter.
|
||||
class SteeringEstimator {
|
||||
SteeringEstimator({
|
||||
this.biasLearningRate = 0.02,
|
||||
this.gyroStillThresholdRadPerSec = 0.03,
|
||||
this.accelStillThresholdMS2 = 0.6,
|
||||
this.minStillTimeForBiasSec = 0.35,
|
||||
this.biasLearningDeadbandDeg = 3.0,
|
||||
this.minStillTimeForRecenterSec = double.infinity,
|
||||
this.recenterHalfLifeSec = 0.7,
|
||||
this.recenterDeadbandDeg = 2.0,
|
||||
this.maxAngleAbsDeg = 60,
|
||||
this.lowPassAlpha = 0.9,
|
||||
|
||||
// Responsiveness / smoothing tuning.
|
||||
// When steering changes quickly we reduce smoothing, but keep more
|
||||
// smoothing when stable to avoid jitter.
|
||||
this.lowPassAlphaStable = 0.9,
|
||||
this.lowPassAlphaMoving = 0.55,
|
||||
this.motionAngleRateDegPerSecForMinAlpha = 90.0,
|
||||
|
||||
// Cap dt to avoid "freezing" the estimator on occasional long frames.
|
||||
this.maxDtSec = 0.05,
|
||||
});
|
||||
|
||||
// Tunables
|
||||
final double biasLearningRate;
|
||||
final double gyroStillThresholdRadPerSec;
|
||||
final double accelStillThresholdMS2;
|
||||
final double minStillTimeForBiasSec;
|
||||
final double biasLearningDeadbandDeg;
|
||||
final double minStillTimeForRecenterSec;
|
||||
final double recenterHalfLifeSec;
|
||||
final double recenterDeadbandDeg;
|
||||
final double maxAngleAbsDeg;
|
||||
|
||||
/// Backwards-compatible, kept as-is.
|
||||
///
|
||||
/// If you set `lowPassAlpha = 0.0`, filtering is disabled.
|
||||
final double lowPassAlpha;
|
||||
|
||||
/// Smoothing used when the angle is stable.
|
||||
///
|
||||
/// Default mirrors the original behavior (`0.9`).
|
||||
final double lowPassAlphaStable;
|
||||
|
||||
/// Smoothing used when the angle is changing quickly.
|
||||
///
|
||||
/// Lower alpha => faster response.
|
||||
final double lowPassAlphaMoving;
|
||||
|
||||
/// Angle rate (deg/s) at which we reach `lowPassAlphaMoving`.
|
||||
final double motionAngleRateDegPerSecForMinAlpha;
|
||||
|
||||
/// Maximum timestep used for integration/bias learning.
|
||||
final double maxDtSec;
|
||||
|
||||
// State
|
||||
double _accelX = 0, _accelY = 0, _accelZ = 0;
|
||||
bool _hasAccel = false;
|
||||
|
||||
double _biasZ = 0.0; // rad/s
|
||||
double _yawDeg = 0.0;
|
||||
double _filteredYawDeg = 0.0;
|
||||
|
||||
double _stillTimeSec = 0.0;
|
||||
|
||||
/// Resets the estimator state.
|
||||
void reset() {
|
||||
_biasZ = 0.0;
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
_hasAccel = false;
|
||||
_accelX = _accelY = _accelZ = 0;
|
||||
}
|
||||
|
||||
/// One-time calibration: assume device is held still and centered.
|
||||
///
|
||||
/// This resets yaw and also seeds the bias to the current z gyro rate.
|
||||
void calibrate({double? seedBiasZRadPerSec}) {
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
if (seedBiasZRadPerSec != null) {
|
||||
_biasZ = seedBiasZRadPerSec;
|
||||
}
|
||||
}
|
||||
|
||||
void updateAccel({required double x, required double y, required double z}) {
|
||||
_accelX = x;
|
||||
_accelY = y;
|
||||
_accelZ = z;
|
||||
_hasAccel = true;
|
||||
}
|
||||
|
||||
/// Update with gyro z-rate (rad/s) and dt (seconds).
|
||||
///
|
||||
/// Returns the current filtered steering angle in degrees.
|
||||
double updateGyro({required double wz, required double dt}) {
|
||||
if (dt <= 0) {
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
// If dt spikes (app paused/jank), cap it instead of bailing out.
|
||||
// This keeps the estimator responsive and avoids "stuck" output.
|
||||
final usedDt = dt > maxDtSec ? maxDtSec : dt;
|
||||
|
||||
final still = _isStill(wz);
|
||||
if (still) {
|
||||
_stillTimeSec += usedDt;
|
||||
|
||||
// Learn gyro bias only when we're still AND near our calibrated center.
|
||||
// Otherwise, if the user holds a steady steering angle, wz≈0 and we'd
|
||||
// incorrectly move bias towards 0 and cause the angle to be wrong when
|
||||
// they return to center.
|
||||
final nearCenter = _yawDeg.abs() <= biasLearningDeadbandDeg;
|
||||
if (nearCenter && _stillTimeSec >= minStillTimeForBiasSec) {
|
||||
// Exponential moving average towards the observed rate.
|
||||
_biasZ = (1.0 - biasLearningRate) * _biasZ + biasLearningRate * wz;
|
||||
}
|
||||
|
||||
// IMPORTANT: only auto-recenter when we're already close to center.
|
||||
// Users may hold a constant steering angle for several seconds.
|
||||
final canRecenter = _stillTimeSec >= minStillTimeForRecenterSec && _yawDeg.abs() <= recenterDeadbandDeg;
|
||||
if (canRecenter) {
|
||||
_applyRecenter(usedDt);
|
||||
}
|
||||
} else {
|
||||
_stillTimeSec = 0.0;
|
||||
}
|
||||
|
||||
final correctedWz = wz - _biasZ;
|
||||
_yawDeg += correctedWz * usedDt * (180.0 / pi);
|
||||
|
||||
// Clamp to avoid runaway if something goes wrong.
|
||||
_yawDeg = _yawDeg.clamp(-maxAngleAbsDeg, maxAngleAbsDeg).toDouble();
|
||||
|
||||
// Low-pass filter for noise smoothing.
|
||||
//
|
||||
// Make it adaptive: when the angle is changing fast, we reduce smoothing
|
||||
// (more responsive). When stable, we keep stronger smoothing.
|
||||
//
|
||||
// If user forces lowPassAlpha=0.0 (existing tests do), we keep behavior
|
||||
// equivalent (no filtering).
|
||||
if (lowPassAlpha <= 0.0) {
|
||||
_filteredYawDeg = _yawDeg;
|
||||
} else {
|
||||
final stableAlpha = ((lowPassAlphaStable.isFinite ? lowPassAlphaStable : lowPassAlpha)).clamp(0.0, 0.999);
|
||||
final movingAlpha = lowPassAlphaMoving.clamp(0.0, stableAlpha);
|
||||
|
||||
// Use a rate estimate derived from the filtered-vs-raw divergence.
|
||||
final rateDegPerSec = ((_yawDeg - _filteredYawDeg).abs()) / usedDt;
|
||||
final t = (rateDegPerSec / motionAngleRateDegPerSecForMinAlpha).clamp(0.0, 1.0);
|
||||
|
||||
final alpha = stableAlpha + (movingAlpha - stableAlpha) * t;
|
||||
_filteredYawDeg = alpha * _filteredYawDeg + (1 - alpha) * _yawDeg;
|
||||
}
|
||||
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
double get angleDeg => _filteredYawDeg;
|
||||
|
||||
double get biasZRadPerSec => _biasZ;
|
||||
|
||||
double get stillTimeSec => _stillTimeSec;
|
||||
|
||||
bool _isStill(double wz) {
|
||||
// If we don't have accel yet, be conservative: don't learn bias.
|
||||
if (!_hasAccel) return false;
|
||||
|
||||
final gyroOk = wz.abs() < gyroStillThresholdRadPerSec;
|
||||
|
||||
// Check accel magnitude close to gravity (device not being bumped).
|
||||
final aMag = sqrt(_accelX * _accelX + _accelY * _accelY + _accelZ * _accelZ);
|
||||
const g = 9.80665;
|
||||
final accelOk = (aMag - g).abs() < accelStillThresholdMS2;
|
||||
|
||||
return gyroOk && accelOk;
|
||||
}
|
||||
|
||||
void _applyRecenter(double dt) {
|
||||
// Exponential decay towards 0 with given half-life.
|
||||
// decay = 0.5^(dt/halfLife)
|
||||
if (recenterHalfLifeSec <= 0) return;
|
||||
final decay = pow(0.5, dt / recenterHalfLifeSec).toDouble();
|
||||
_yawDeg *= decay;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:flutter/material.dart' show PopupMenuButton, PopupMenuItem;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
HidDevice(super.name) : super(availableButtons: [], uniqueId: name!);
|
||||
|
||||
@override
|
||||
Future<void> connect() {
|
||||
@@ -13,24 +17,37 @@ class HidDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Ignore'),
|
||||
onTap: () {
|
||||
core.connection.disconnect(this, forget: true, persistForget: true);
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
(core.actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(toString()).bold),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Ignore'),
|
||||
onTap: () {
|
||||
core.connection.disconnect(this, forget: true, persistForget: true);
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
(core.actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
|
||||
core.settings.setMediaKeyDetectionEnabled(false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid && !core.settings.getLocalEnabled())
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.',
|
||||
).small,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
@@ -11,14 +9,18 @@ import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class WhooshLink extends TrainerConnection {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static const String connectionTitle = 'MyWhoosh Link';
|
||||
|
||||
WhooshLink()
|
||||
: super(
|
||||
title: 'MyWhoosh Link',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
@@ -64,16 +66,17 @@ class WhooshLink extends TrainerConnection {
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
(Socket socket) async {
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
SharedLogic.keepAlive();
|
||||
_socket = socket;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.myWhooshLinkConnected),
|
||||
);
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
@@ -87,6 +90,8 @@ class WhooshLink extends TrainerConnection {
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
|
||||
SharedLogic.stopKeepAlive();
|
||||
isConnected.value = false;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'MyWhoosh Link disconnected'),
|
||||
@@ -155,7 +160,7 @@ class WhooshLink extends TrainerConnection {
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(keyPair.inGameAction)) {
|
||||
return Success('No Action sent on key down for action: ${keyPair.inGameAction}');
|
||||
return Ignored('No Action sent on key down for action: ${keyPair.inGameAction}');
|
||||
} else if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification;
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/training_peaks.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../messages/notification.dart' show AlertNotification;
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
@@ -24,9 +25,11 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
|
||||
late GATTCharacteristic _buttonCharacteristic;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl BLE Emulator';
|
||||
|
||||
OpenBikeControlBluetoothEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl BLE Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
@@ -77,6 +80,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
eventArgs.request,
|
||||
value: Uint8List.fromList([100]),
|
||||
);
|
||||
return;
|
||||
default:
|
||||
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
@@ -96,17 +105,28 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
|
||||
Uint8List? firstAppInfoMessage;
|
||||
|
||||
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
final characteristic = eventArgs.characteristic;
|
||||
final request = eventArgs.request;
|
||||
final value = request.value;
|
||||
print(
|
||||
'Write request for characteristic: ${characteristic.uuid}',
|
||||
);
|
||||
var value = request.value;
|
||||
if (kDebugMode) {
|
||||
print('Write request for characteristic: ${characteristic.uuid}: ${bytesToReadableHex(value)}');
|
||||
}
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toLowerCase()) {
|
||||
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
|
||||
try {
|
||||
if (core.settings.getTrainerApp() is TrainingPeaks) {
|
||||
if (firstAppInfoMessage == null) {
|
||||
firstAppInfoMessage = value;
|
||||
return;
|
||||
} else {
|
||||
value = Uint8List.fromList([...firstAppInfoMessage!, ...value]);
|
||||
}
|
||||
}
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
@@ -114,9 +134,9 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
print('Parsed App Info: $appInfo');
|
||||
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
|
||||
} catch (e) {
|
||||
print('Error parsing App Info: $e');
|
||||
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -220,6 +240,8 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
if (kDebugMode) {
|
||||
print('Stopping OpenBikeControl BLE server...');
|
||||
}
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
|
||||
39
lib/bluetooth/devices/openbikecontrol/obc_dircon.dart
Normal file
39
lib/bluetooth/devices/openbikecontrol/obc_dircon.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:prop/emulators/dircon/dircon.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class OnMessage {
|
||||
void onMessage(List<int> message);
|
||||
}
|
||||
|
||||
class ObcDircon extends DirCon {
|
||||
final OnMessage onMessageCallback;
|
||||
ObcDircon({required super.socket, required this.onMessageCallback});
|
||||
|
||||
@override
|
||||
List<BleCharacteristic> getCharacteristics(String serviceUUID) {
|
||||
if (serviceUUID.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID) {
|
||||
return [
|
||||
BleCharacteristic(
|
||||
OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID,
|
||||
[CharacteristicProperty.notify],
|
||||
),
|
||||
BleCharacteristic(
|
||||
OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID,
|
||||
[CharacteristicProperty.writeWithoutResponse, CharacteristicProperty.write],
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
void processWriteCallback(String characteristicUUID, List<int> characteristicData) {
|
||||
if (characteristicUUID.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID) {
|
||||
onMessageCallback.onMessage(characteristicData);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get serviceUUIDs => [OpenBikeControlConstants.SERVICE_UUID];
|
||||
}
|
||||
@@ -1,33 +1,40 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_dircon.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage {
|
||||
ServerSocket? _server;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
|
||||
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
|
||||
|
||||
Socket? _socket;
|
||||
ObcDircon? _dirCon;
|
||||
|
||||
OpenBikeControlMdnsEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl mDNS Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
bool get _useDirCon =>
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.dircon) ?? false;
|
||||
|
||||
Future<void> startServer() async {
|
||||
print('Starting mDNS server...');
|
||||
isStarted.value = true;
|
||||
@@ -50,7 +57,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
await _createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
@@ -63,18 +70,23 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
_mdnsRegistration = await register(
|
||||
Service(
|
||||
name: 'BikeControl',
|
||||
type: '_openbikecontrol._tcp',
|
||||
type: _useDirCon ? '_wahoo-fitness-tnp._tcp' : '_openbikecontrol._tcp',
|
||||
port: 36867,
|
||||
//hostName: 'KICKR BIKE SHIFT B84D.local',
|
||||
addresses: [localIP],
|
||||
txt: {
|
||||
'version': Uint8List.fromList([0x01]),
|
||||
'id': Uint8List.fromList('1337'.codeUnits),
|
||||
'name': Uint8List.fromList('BikeControl'.codeUnits),
|
||||
'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
|
||||
'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits),
|
||||
'model': Uint8List.fromList('BikeControl app'.codeUnits),
|
||||
},
|
||||
txt: _useDirCon
|
||||
? {
|
||||
'ble-service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
|
||||
'mac-address': Uint8List.fromList('00:11:22:33:44:55'.codeUnits),
|
||||
'serial-number': Uint8List.fromList('1234567890'.codeUnits),
|
||||
}
|
||||
: {
|
||||
'version': Uint8List.fromList([0x01]),
|
||||
'id': Uint8List.fromList('1337'.codeUnits),
|
||||
'name': Uint8List.fromList('BikeControl'.codeUnits),
|
||||
'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
|
||||
'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits),
|
||||
'model': Uint8List.fromList('BikeControl app'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
|
||||
@@ -103,7 +115,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
Future<void> _createTcpServer() async {
|
||||
try {
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
InternetAddress.anyIPv4,
|
||||
36867,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
@@ -118,36 +130,33 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
(Socket socket) async {
|
||||
SharedLogic.keepAlive();
|
||||
_socket = socket;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
if (_useDirCon) {
|
||||
_dirCon = ObcDircon(socket: socket, onMessageCallback: this);
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
final messageType = data[0];
|
||||
switch (messageType) {
|
||||
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
print('Unknown message type: $messageType');
|
||||
if (_dirCon != null) {
|
||||
_dirCon!.handleIncomingData(data);
|
||||
return;
|
||||
}
|
||||
onMessage(data);
|
||||
},
|
||||
onDone: () {
|
||||
_dirCon = null;
|
||||
SharedLogic.stopKeepAlive();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
@@ -200,6 +209,40 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
debugPrint('Sending response: ${bytesToHex(responseData)}');
|
||||
socket.add(responseData);
|
||||
if (_dirCon != null) {
|
||||
_dirCon!.sendCharacteristicNotification(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, responseData);
|
||||
return;
|
||||
} else {
|
||||
socket.add(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onMessage(List<int> message) {
|
||||
if (kDebugMode) {
|
||||
print('Received message from OBC: ${bytesToHex(message)}');
|
||||
}
|
||||
final messageType = message[0];
|
||||
switch (messageType) {
|
||||
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
|
||||
try {
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(message));
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
|
||||
}
|
||||
break;
|
||||
case OpenBikeProtocolParser.MSG_TYPE_HAPTIC_FEEDBACK:
|
||||
// noop
|
||||
break;
|
||||
default:
|
||||
print('Unknown message type: $messageType');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class ProtocolParseException implements Exception {
|
||||
final String message;
|
||||
@@ -17,7 +18,7 @@ class ProtocolParseException implements Exception {
|
||||
ProtocolParseException(this.message, [this.raw]);
|
||||
|
||||
@override
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${raw!.length}' : ''}';
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}';
|
||||
}
|
||||
|
||||
class OpenBikeProtocolParser {
|
||||
@@ -28,20 +29,19 @@ class OpenBikeProtocolParser {
|
||||
0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown),
|
||||
0x03: ControllerButton('Gear Set', identifier: 0x03),
|
||||
// Navigation (0x10-0x1F)
|
||||
0x10: ControllerButton('Up/Steer Left', identifier: 0x10, action: InGameAction.steerLeft),
|
||||
0x11: ControllerButton('Down/Steer Right', identifier: 0x11, action: InGameAction.steerRight),
|
||||
0x10: ControllerButton('Up', identifier: 0x10, action: InGameAction.up),
|
||||
0x11: ControllerButton('Down', identifier: 0x11, action: InGameAction.down),
|
||||
0x12: ControllerButton('Left/Look Left', identifier: 0x12, action: InGameAction.navigateLeft),
|
||||
0x13: ControllerButton('Right/Look Right', identifier: 0x13, action: InGameAction.navigateRight),
|
||||
0x14: ControllerButton('Select/Confirm', identifier: 0x14, action: InGameAction.select),
|
||||
0x15: ControllerButton('Back/Cancel', identifier: 0x15, action: InGameAction.back),
|
||||
0x16: ControllerButton('Menu', identifier: 0x16),
|
||||
0x17: ControllerButton('Home', identifier: 0x17),
|
||||
0x16: ControllerButton('Menu', identifier: 0x16, action: InGameAction.menu),
|
||||
0x17: ControllerButton('Home', identifier: 0x17, action: InGameAction.home),
|
||||
0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft),
|
||||
0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight),
|
||||
// Social/Emotes (0x20-0x2F)
|
||||
0x20: ControllerButton('Wave', identifier: 0x20, action: InGameAction.emote),
|
||||
0x21: ControllerButton('Thumbs Up', identifier: 0x21, action: InGameAction.emote),
|
||||
0x22: ControllerButton('Hammer Time', identifier: 0x22, action: InGameAction.emote),
|
||||
0x23: ControllerButton('Bell', identifier: 0x23),
|
||||
0x24: ControllerButton('Screenshot', identifier: 0x24),
|
||||
0x20: ControllerButton('Emote', identifier: 0x20, action: InGameAction.emote),
|
||||
0x21: ControllerButton('Push to Talk', identifier: 0x21),
|
||||
// Training Controls (0x30-0x3F)
|
||||
0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance),
|
||||
0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance),
|
||||
|
||||
66
lib/bluetooth/devices/proxy/proxy_device.dart
Normal file
66
lib/bluetooth/devices/proxy/proxy_device.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:prop/emulators/ftms_emulator.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class ProxyDevice extends BluetoothDevice {
|
||||
static final List<String> proxyServiceUUIDs = [
|
||||
'0000180d-0000-1000-8000-00805f9b34fb', // Heart Rate
|
||||
'00001818-0000-1000-8000-00805f9b34fb', // Cycling Power
|
||||
'00001826-0000-1000-8000-00805f9b34fb', // Fitness Machine
|
||||
];
|
||||
|
||||
final FtmsEmulator emulator = FtmsEmulator();
|
||||
|
||||
ProxyDevice(super.scanResult)
|
||||
: super(
|
||||
availableButtons: const [],
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
late final List<BleService> services;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
emulator.setScanResult(scanResult);
|
||||
emulator.handleServices(services);
|
||||
|
||||
emulator.startServer();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
emulator.processCharacteristic(characteristic, bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
if (!isConnected)
|
||||
Button.primary(
|
||||
style: ButtonStyle.primary(size: ButtonSize.small),
|
||||
onPressed: () {
|
||||
super.connect();
|
||||
},
|
||||
child: Text('Proxy'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect() async {}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() {
|
||||
emulator.stop();
|
||||
return super.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ShimanoDi2 extends BluetoothDevice {
|
||||
ShimanoDi2(super.scanResult) : super(availableButtons: []);
|
||||
ShimanoDi2(super.scanResult) : super(availableButtons: [], buttonPrefix: 'D-Fly Channel ');
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
@@ -26,11 +26,15 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
final _lastButtons = <int, int>{};
|
||||
final _lastButtons = <int, ({int value, _Di2State type})>{};
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
String get buttonExplanation => 'Click a D-Fly button to configure them.';
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
Logger.info('Received data from $characteristic: ${bytesToReadableHex(bytes)}');
|
||||
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
|
||||
final channels = bytes.sublist(1);
|
||||
|
||||
@@ -38,40 +42,72 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
if (!_isInitialized) {
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final readableIndex = index + 1;
|
||||
_lastButtons[index] = value;
|
||||
_lastButtons[index] = (value: value, type: _Di2State.released);
|
||||
|
||||
getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
() => ControllerButton('D-Fly Channel $readableIndex', sourceDeviceId: device.deviceId),
|
||||
);
|
||||
});
|
||||
_isInitialized = true;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final clickedButtons = <ControllerButton>[];
|
||||
|
||||
var actualChange = false;
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final didChange = _lastButtons[index] != value;
|
||||
_lastButtons[index] = value;
|
||||
final didChange = _lastButtons[index]?.value != value;
|
||||
|
||||
final readableIndex = index + 1;
|
||||
|
||||
final button = getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
if (didChange) {
|
||||
clickedButtons.add(button);
|
||||
if ((value & 0x10) != 0) {
|
||||
if (_lastButtons[index]?.type == _Di2State.longPress || _lastButtons[index]?.type == _Di2State.keep) {
|
||||
// short press is triggered after long press, until it's released later on
|
||||
_lastButtons[index] = (value: value, type: _Di2State.keep);
|
||||
Logger.info('Button $readableIndex still long pressed');
|
||||
} else {
|
||||
_lastButtons[index] = (value: value, type: _Di2State.shortPress);
|
||||
actualChange = true;
|
||||
Logger.info('Button $readableIndex short pressed');
|
||||
}
|
||||
} else if ((value & 0x20) != 0) {
|
||||
_lastButtons[index] = (value: value, type: _Di2State.longPress);
|
||||
actualChange = true;
|
||||
Logger.info('Button $readableIndex long pressed');
|
||||
} else if ((value & 0x40) != 0) {
|
||||
_lastButtons[index] = (value: value, type: _Di2State.doublePress);
|
||||
actualChange = true;
|
||||
Logger.info('Button $readableIndex double pressed');
|
||||
} else {
|
||||
_lastButtons[index] = (value: value, type: _Di2State.released);
|
||||
actualChange = true;
|
||||
Logger.info('Button $readableIndex released');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
handleButtonsClicked(clickedButtons);
|
||||
handleButtonsClicked([]);
|
||||
if (actualChange) {
|
||||
final buttonsToTrigger = _lastButtons.entries
|
||||
.where((entry) {
|
||||
final type = entry.value.type;
|
||||
return type != _Di2State.released;
|
||||
})
|
||||
.map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}'))
|
||||
.toList();
|
||||
|
||||
Logger.debug('Buttons to trigger: ${buttonsToTrigger.map((b) => b.name).join(', ')}');
|
||||
handleButtonsClicked(buttonsToTrigger);
|
||||
|
||||
final doublePress = _lastButtons.entries
|
||||
.filter((entry) => entry.value.type == _Di2State.doublePress)
|
||||
.map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}'))
|
||||
.toList();
|
||||
if (doublePress.isNotEmpty) {
|
||||
Logger.debug('Buttons to still trigger: ${doublePress.map((b) => b.name).join(', ')}');
|
||||
handleButtonsClicked(doublePress);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,13 +116,9 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
if (!core.settings.getShowOnboarding())
|
||||
Text(
|
||||
'Use a custom keymap to support ${scanResult.name}',
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
@@ -100,3 +132,11 @@ class ShimanoDi2Constants {
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
|
||||
enum _Di2State {
|
||||
shortPress,
|
||||
longPress,
|
||||
keep,
|
||||
doublePress,
|
||||
released,
|
||||
}
|
||||
|
||||
179
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
179
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class SramAxs extends BluetoothDevice {
|
||||
SramAxs(super.scanResult) : super(availableButtons: [], isBeta: true);
|
||||
|
||||
Timer? _singleClickTimer;
|
||||
int _tapCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_tapCount = 0;
|
||||
await super.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstOrNullWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.SERVICE_UUID_RELEVANT.toLowerCase(),
|
||||
);
|
||||
|
||||
if (service == null) {
|
||||
actionStreamInternal.add(LogNotification('SramAxs: Relevant service not found: ${SramAxsConstants.SERVICE_UUID_RELEVANT}'));
|
||||
return;
|
||||
}
|
||||
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${SramAxsConstants.TRIGGER_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
// add both buttons
|
||||
_singleClickButton();
|
||||
_doubleClickButton();
|
||||
}
|
||||
|
||||
ControllerButton _singleClickButton() => getOrAddButton(
|
||||
'SRAM Tap',
|
||||
() => ControllerButton('SRAM Tap', action: InGameAction.shiftUp, sourceDeviceId: device.deviceId),
|
||||
);
|
||||
|
||||
ControllerButton _doubleClickButton() => getOrAddButton(
|
||||
'SRAM Double Tap',
|
||||
() => ControllerButton('SRAM Double Tap', action: InGameAction.shiftDown, sourceDeviceId: device.deviceId),
|
||||
);
|
||||
|
||||
void _emitClick(ControllerButton button) {
|
||||
// Use the common pipeline so long-press handling and app action execution stays consistent.
|
||||
handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
}
|
||||
|
||||
void _registerTap() {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
_tapCount++;
|
||||
|
||||
// First tap: start a timer. If no second tap arrives in time => single click.
|
||||
if (_tapCount == 1) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = Timer(Duration(milliseconds: windowMs), () {
|
||||
if (_tapCount == 1) {
|
||||
_emitClick(_singleClickButton());
|
||||
}
|
||||
_tapCount = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Second tap within window: double click.
|
||||
if (_tapCount == 2) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get more than two taps fast, treat as a double click and restart counting.
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('SramAxs: Received data on characteristic $characteristic: ${bytesToHex(bytes)}');
|
||||
}
|
||||
|
||||
if (characteristic.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase()) {
|
||||
// At the moment we can only detect "some button pressed". We therefore interpret each
|
||||
// notification as a tap and provide two logical buttons (single & double click).
|
||||
_registerTap();
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
"Don't forget to turn off the function of the button you want to use in the SRAM AXS app!\n"
|
||||
"Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n"
|
||||
'So the app exposes two logical buttons:\n'
|
||||
'• SRAM Tap, assigned to Shift Up\n'
|
||||
'• SRAM Double Tap, assigned to Shift Down\n\n'
|
||||
'You can assign an action to each in the app settings.',
|
||||
).xSmall,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${windowMs}ms'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [
|
||||
for (var v = 150; v <= 600; v += 50) v,
|
||||
];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('${v}ms'),
|
||||
onPressed: (c) async {
|
||||
await core.settings.setSramAxsDoubleClickWindowMs(v);
|
||||
// Force rebuild to show new value.
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Double-click window:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SramAxsConstants {
|
||||
static const String SERVICE_UUID = "0000fe51-0000-1000-8000-00805f9b34fb";
|
||||
static const String SERVICE_UUID_RELEVANT = "d9050053-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
|
||||
static const String TRIGGER_UUID = "d9050054-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
}
|
||||
90
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
90
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ThinkRiderVs200 extends BluetoothDevice {
|
||||
ThinkRiderVs200(super.scanResult)
|
||||
: super(
|
||||
availableButtons: ThinkRiderVs200Buttons.values,
|
||||
isBeta: true,
|
||||
allowMultiple: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
// Only subscribe to service 0xFEA0
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ThinkRiderVs200Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ThinkRiderVs200Constants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
final hexValue = _bytesToHex(bytes).toLowerCase();
|
||||
|
||||
// Log all received values while in beta
|
||||
if (isBeta) {
|
||||
actionStreamInternal.add(LogNotification('VS200 received: $hexValue'));
|
||||
}
|
||||
|
||||
// Check for specific byte patterns
|
||||
if (hexValue == ThinkRiderVs200Constants.SHIFT_UP_PATTERN) {
|
||||
// Plus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Up detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([availableButtons[0]]);
|
||||
} else if (hexValue == ThinkRiderVs200Constants.SHIFT_DOWN_PATTERN) {
|
||||
// Minus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Down detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([availableButtons[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Constants {
|
||||
// Service and characteristic UUIDs based on the nRF Connect screenshot
|
||||
static const String SERVICE_UUID = "0000fea0-0000-1000-8000-00805f9b34fb";
|
||||
static const String CHARACTERISTIC_UUID = "0000fea1-0000-1000-8000-00805f9b34fb";
|
||||
|
||||
// Byte patterns for button detection
|
||||
static const String SHIFT_UP_PATTERN = "f3050301fc";
|
||||
static const String SHIFT_DOWN_PATTERN = "f3050300fb";
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Buttons {
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
@@ -38,7 +36,6 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
// Analyze the received bytes to determine current state
|
||||
actionStreamInternal.add(LogNotification('Received ${bytesToHex(bytes)} from Headwind $characteristic'));
|
||||
if (bytes.length >= 4 && bytes[0] == 0xFD && bytes[1] == 0x01) {
|
||||
final mode = bytes[3];
|
||||
final speed = bytes[2];
|
||||
@@ -80,8 +77,12 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
manualModeData,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.manual;
|
||||
|
||||
// Small delay to ensure mode change is processed before speed command
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// Command format: [0x02, speed_value]
|
||||
@@ -93,6 +94,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentSpeed = speedPercent;
|
||||
}
|
||||
@@ -109,6 +111,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ZwiftConstants {
|
||||
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
|
||||
static const ZWIFT_CUSTOM_SERVICE_SHORT_UUID = "0001";
|
||||
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb";
|
||||
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT = "fc82";
|
||||
static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1";
|
||||
@@ -59,13 +60,13 @@ class ZwiftButtons {
|
||||
// left controller
|
||||
static const ControllerButton navigationUp = ControllerButton(
|
||||
'navigationUp',
|
||||
action: InGameAction.toggleUi,
|
||||
action: InGameAction.up,
|
||||
icon: Icons.keyboard_arrow_up,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationDown = ControllerButton(
|
||||
'navigationDown',
|
||||
action: InGameAction.uturn,
|
||||
action: InGameAction.down,
|
||||
icon: Icons.keyboard_arrow_down,
|
||||
color: Colors.black,
|
||||
);
|
||||
@@ -101,8 +102,12 @@ class ZwiftButtons {
|
||||
// right controller
|
||||
static const ControllerButton a = ControllerButton('a', action: InGameAction.select, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent);
|
||||
static const ControllerButton z = ControllerButton('z', action: null, color: Colors.deepOrangeAccent);
|
||||
static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue);
|
||||
static const ControllerButton z = ControllerButton(
|
||||
'z',
|
||||
action: InGameAction.rideOnBomb,
|
||||
color: Colors.deepOrangeAccent,
|
||||
);
|
||||
static const ControllerButton y = ControllerButton('y', action: InGameAction.menu, color: Colors.lightBlue);
|
||||
static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi);
|
||||
static const ControllerButton sideButtonRight = ControllerButton('sideButtonRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton paddleRight = ControllerButton('paddleRight', action: InGameAction.shiftUp);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
@@ -14,17 +6,18 @@ import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart' hide RideButtonMask;
|
||||
|
||||
class FtmsMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _tcpServer;
|
||||
Registration? _mdnsRegistration;
|
||||
static const String connectionTitle = 'Zwift Network Emulator';
|
||||
|
||||
Socket? _socket;
|
||||
late final ClickEmulator clickEmulator = ClickEmulator();
|
||||
var lastMessageId = 0;
|
||||
|
||||
FtmsMdnsEmulator()
|
||||
: super(
|
||||
title: 'Zwift Network Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
@@ -37,298 +30,30 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
print('Starting mDNS server...');
|
||||
|
||||
// Get local IP
|
||||
final interfaces = await NetworkInterface.list();
|
||||
InternetAddress? localIP;
|
||||
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
localIP = addr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (localIP != null) break;
|
||||
}
|
||||
|
||||
if (localIP == null) {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
enableLogging(LogTopic.errors);
|
||||
}
|
||||
disableServiceTypeValidation(true);
|
||||
|
||||
_mdnsRegistration = await register(
|
||||
Service(
|
||||
name: 'KICKR BIKE PRO 1337',
|
||||
addresses: [localIP],
|
||||
port: 36867,
|
||||
type: '_wahoo-fitness-tnp._tcp',
|
||||
txt: {
|
||||
'ble-service-uuids': Uint8List.fromList('FC82'.codeUnits),
|
||||
'mac-address': Uint8List.fromList('50-50-25-6C-66-9C'.codeUnits),
|
||||
'serial-number': Uint8List.fromList('244700181'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
print('Server started - advertising service!');
|
||||
}
|
||||
|
||||
void stop() {
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
_tcpServer?.close();
|
||||
if (_mdnsRegistration != null) {
|
||||
unregister(_mdnsRegistration!);
|
||||
}
|
||||
_tcpServer = null;
|
||||
_mdnsRegistration = null;
|
||||
_socket = null;
|
||||
print('Stopped FtmsMdnsEmulator');
|
||||
}
|
||||
|
||||
Future<void> _createTcpServer() async {
|
||||
try {
|
||||
_tcpServer = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
36867,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_tcpServer!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_tcpServer!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
) {
|
||||
clickEmulator.isStarted.addListener(() {
|
||||
isStarted.value = clickEmulator.isStarted.value;
|
||||
});
|
||||
clickEmulator.isConnected.addListener(() {
|
||||
isConnected.value = clickEmulator.isConnected.value;
|
||||
if (isConnected.value) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected),
|
||||
);
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
|
||||
final mutable = data.toList();
|
||||
while (mutable.isNotEmpty) {
|
||||
final msgVersion = mutable.takeUInt8();
|
||||
final msgId = mutable.takeUInt8();
|
||||
lastMessageId = msgId;
|
||||
final seqNum = mutable.takeUInt8();
|
||||
final respCode = mutable.takeUInt8(); // Response Code
|
||||
final length = mutable.takeUInt16BE(); // Length of the message body
|
||||
|
||||
final body = mutable.takeBytes(length);
|
||||
if (kDebugMode) {
|
||||
print('Parsed message: ID: $msgId, Body: ${bytesToHex(body)}');
|
||||
}
|
||||
|
||||
Uint8List buildHeader(int responseCode, int bodyLength) {
|
||||
return Uint8List.fromList([
|
||||
msgVersion,
|
||||
msgId,
|
||||
seqNum,
|
||||
responseCode,
|
||||
(bodyLength >> 8) & 0xFF,
|
||||
bodyLength & 0xFF,
|
||||
]);
|
||||
}
|
||||
|
||||
switch (msgId) {
|
||||
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_SERVICES:
|
||||
final body = hexToBytes(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toNonDash());
|
||||
|
||||
final header = buildHeader(FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY, body.length);
|
||||
final bytes = [...header, ...body];
|
||||
|
||||
// Expected 0101000000100000fc8200001000800000805f9b34fb
|
||||
// Got 0101000000100000fc8200001000800000805f9b34fb
|
||||
_write(socket, bytes);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_CHARACTERISTICS:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final serviceUUID = bytesToHex(rawUUID).toUUID();
|
||||
if (serviceUUID == ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID) {
|
||||
final responseBody = [
|
||||
...rawUUID,
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['write']),
|
||||
],
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['notify']),
|
||||
],
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['notify']),
|
||||
],
|
||||
];
|
||||
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
// OK: 0102010000430000fc8200001000800000805f9b34fb0000000319ca465186e5fa29dcdd09d1020000000219ca465186e5fa29dcdd09d1040000000419ca465186e5fa29dcdd09d104
|
||||
_write(socket, responseData);
|
||||
}
|
||||
case FtmsMdnsConstants.DC_MESSAGE_READ_CHARACTERISTIC:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
|
||||
print(
|
||||
'Got Read Characteristic UUID: $characteristicUUID',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_WRITE_CHARACTERISTIC:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
final characteristicData = body.takeBytes(body.length);
|
||||
|
||||
print(
|
||||
'Got Write Characteristic UUID: $characteristicUUID, Data: ${bytesToHex(characteristicData)}',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
|
||||
final response = core.zwiftEmulator.handleWriteRequest(
|
||||
characteristicUUID,
|
||||
Uint8List.fromList(characteristicData),
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
final seqNum = (lastMessageId + 1) % 256;
|
||||
lastMessageId = seqNum;
|
||||
|
||||
final responseBody = [
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase().toNonDash()),
|
||||
...response,
|
||||
];
|
||||
final responseData = [
|
||||
// header
|
||||
...Uint8List.fromList([
|
||||
msgVersion,
|
||||
FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION,
|
||||
seqNum,
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
(responseBody.length >> 8) & 0xFF,
|
||||
responseBody.length & 0xFF,
|
||||
]),
|
||||
// body
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
// 0106050000180000000419ca465186e5fa29dcdd09d1526964654f6e0203
|
||||
_write(socket, responseData);
|
||||
|
||||
if (response.contentEquals(ZwiftConstants.RIDE_ON)) {
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
return;
|
||||
case FtmsMdnsConstants.DC_MESSAGE_ENABLE_CHARACTERISTIC_NOTIFICATIONS:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
final enabled = body.takeUInt8();
|
||||
print(
|
||||
'Got Enable Notifications for Characteristic UUID: $characteristicUUID, Enabled: $enabled',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION:
|
||||
print('Hamlo');
|
||||
default:
|
||||
throw 'DC_ERROR_UNKNOWN_MESSAGE_TYPE';
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
isConnected.value = false;
|
||||
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
_socket = null;
|
||||
},
|
||||
} else {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
if (kDebugMode) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
}
|
||||
socket.add(responseData);
|
||||
Future<void> startServer() async {
|
||||
return clickEmulator.startServer();
|
||||
}
|
||||
|
||||
int _propertyVal(List<String> properties) {
|
||||
int res = 0;
|
||||
|
||||
if (properties.contains('read')) res |= 0x01;
|
||||
if (properties.contains('write')) res |= 0x02;
|
||||
if (properties.contains('indicate')) res |= 0x03;
|
||||
if (properties.contains('notify')) res |= 0x04;
|
||||
|
||||
return res;
|
||||
void stop() {
|
||||
clickEmulator.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -358,125 +83,19 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
final commandProto = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]),
|
||||
);
|
||||
|
||||
_write(_socket!, commandProto);
|
||||
clickEmulator.writeNotification(bytes);
|
||||
}
|
||||
|
||||
if (isKeyUp) {
|
||||
final zero = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
|
||||
clickEmulator.writeNotification(
|
||||
Uint8List.fromList([0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
|
||||
);
|
||||
|
||||
_write(_socket!, zero);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Sent action ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
print('Sent action up $isKeyUp vs down $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
}
|
||||
return Success('Sent action: ${keyPair.inGameAction!.title}');
|
||||
return Success('Sent action ${isKeyDown ? 'down' : ''} ${isKeyUp ? 'up' : ''}: ${keyPair.inGameAction!.title}');
|
||||
}
|
||||
|
||||
List<int> _buildNotify(String uuid, final List<int> data) {
|
||||
final seqNum = (lastMessageId + 1) % 256;
|
||||
lastMessageId = seqNum;
|
||||
|
||||
final responseBody = [
|
||||
...hexToBytes(uuid.toLowerCase().toNonDash()),
|
||||
...data,
|
||||
];
|
||||
final responseData = [
|
||||
// header
|
||||
...Uint8List.fromList([
|
||||
0x01,
|
||||
FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION,
|
||||
seqNum,
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
(responseBody.length >> 8) & 0xFF,
|
||||
responseBody.length & 0xFF,
|
||||
]),
|
||||
// body
|
||||
...responseBody,
|
||||
];
|
||||
return responseData;
|
||||
}
|
||||
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (_socket != null) {
|
||||
_write(
|
||||
_socket!,
|
||||
_buildNotify(
|
||||
ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
hexToBytes('B70100002041201C00180004001B4F00B701000020798EC5BDEFCBE4563418269E4926FBE1'),
|
||||
),
|
||||
);
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String toNonDash() {
|
||||
return replaceAll('-', '');
|
||||
}
|
||||
|
||||
String toUUID() {
|
||||
return '${substring(0, 8)}-${substring(8, 12)}-${substring(12, 16)}-${substring(16, 20)}-${substring(20)}';
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<int> {
|
||||
int takeUInt8() {
|
||||
final value = this[0];
|
||||
removeAt(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
int readUInt8(int offset) {
|
||||
return this[offset];
|
||||
}
|
||||
|
||||
int takeUInt16BE() {
|
||||
final value = (this[0] << 8) | this[0 + 1];
|
||||
removeAt(0);
|
||||
removeAt(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
List<int> takeBytes(int length) {
|
||||
final value = sublist(0, length);
|
||||
removeRange(0, length);
|
||||
return value;
|
||||
}
|
||||
|
||||
int readUInt16BE(int i) {
|
||||
final value = (this[i] << 8) | this[i + 1];
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
String bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
String bytesToReadableHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
List<int> hexToBytes(String hex) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
final byte = hex.substring(i, i + 2);
|
||||
bytes.add(int.parse(byte, radix: 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
class FtmsMdnsConstants {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zp.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
export 'zp.pb.dart';
|
||||
|
||||
@@ -1,896 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zp_vendor.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
import 'zp_vendor.pbenum.dart';
|
||||
|
||||
export 'zp_vendor.pbenum.dart';
|
||||
|
||||
class ControllerSync extends $pb.GeneratedMessage {
|
||||
factory ControllerSync({
|
||||
ControllerSyncStatus? status,
|
||||
$core.int? timeStamp,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (status != null) {
|
||||
$result.status = status;
|
||||
}
|
||||
if (timeStamp != null) {
|
||||
$result.timeStamp = timeStamp;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
ControllerSync._() : super();
|
||||
factory ControllerSync.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory ControllerSync.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ControllerSync', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..e<ControllerSyncStatus>(1, _omitFieldNames ? '' : 'status', $pb.PbFieldType.OE, defaultOrMaker: ControllerSyncStatus.NOT_CONNECTED, valueOf: ControllerSyncStatus.valueOf, enumValues: ControllerSyncStatus.values)
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'timeStamp', $pb.PbFieldType.O3, protoName: 'timeStamp')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
ControllerSync clone() => ControllerSync()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
ControllerSync copyWith(void Function(ControllerSync) updates) => super.copyWith((message) => updates(message as ControllerSync)) as ControllerSync;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ControllerSync create() => ControllerSync._();
|
||||
ControllerSync createEmptyInstance() => create();
|
||||
static $pb.PbList<ControllerSync> createRepeated() => $pb.PbList<ControllerSync>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ControllerSync getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ControllerSync>(create);
|
||||
static ControllerSync? _defaultInstance;
|
||||
|
||||
/// optional in code; proto3 treats as present when non-zero
|
||||
@$pb.TagNumber(1)
|
||||
ControllerSyncStatus get status => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set status(ControllerSyncStatus v) { setField(1, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasStatus() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearStatus() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get timeStamp => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set timeStamp($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasTimeStamp() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearTimeStamp() => clearField(2);
|
||||
}
|
||||
|
||||
class EnableTestMode extends $pb.GeneratedMessage {
|
||||
factory EnableTestMode({
|
||||
$core.bool? enable,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (enable != null) {
|
||||
$result.enable = enable;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
EnableTestMode._() : super();
|
||||
factory EnableTestMode.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory EnableTestMode.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'EnableTestMode', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'enable')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
EnableTestMode clone() => EnableTestMode()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
EnableTestMode copyWith(void Function(EnableTestMode) updates) => super.copyWith((message) => updates(message as EnableTestMode)) as EnableTestMode;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static EnableTestMode create() => EnableTestMode._();
|
||||
EnableTestMode createEmptyInstance() => create();
|
||||
static $pb.PbList<EnableTestMode> createRepeated() => $pb.PbList<EnableTestMode>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static EnableTestMode getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<EnableTestMode>(create);
|
||||
static EnableTestMode? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get enable => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set enable($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasEnable() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearEnable() => clearField(1);
|
||||
}
|
||||
|
||||
class PairDevices extends $pb.GeneratedMessage {
|
||||
factory PairDevices({
|
||||
$core.bool? pair,
|
||||
PairDeviceType? type,
|
||||
$core.List<$core.int>? deviceId,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (pair != null) {
|
||||
$result.pair = pair;
|
||||
}
|
||||
if (type != null) {
|
||||
$result.type = type;
|
||||
}
|
||||
if (deviceId != null) {
|
||||
$result.deviceId = deviceId;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
PairDevices._() : super();
|
||||
factory PairDevices.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory PairDevices.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PairDevices', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'pair')
|
||||
..e<PairDeviceType>(2, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: PairDeviceType.BLE, valueOf: PairDeviceType.valueOf, enumValues: PairDeviceType.values)
|
||||
..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'deviceId', $pb.PbFieldType.OY, protoName: 'deviceId')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
PairDevices clone() => PairDevices()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
PairDevices copyWith(void Function(PairDevices) updates) => super.copyWith((message) => updates(message as PairDevices)) as PairDevices;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PairDevices create() => PairDevices._();
|
||||
PairDevices createEmptyInstance() => create();
|
||||
static $pb.PbList<PairDevices> createRepeated() => $pb.PbList<PairDevices>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PairDevices getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PairDevices>(create);
|
||||
static PairDevices? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get pair => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set pair($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasPair() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearPair() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
PairDeviceType get type => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set type(PairDeviceType v) { setField(2, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasType() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearType() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<$core.int> get deviceId => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set deviceId($core.List<$core.int> v) { $_setBytes(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasDeviceId() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearDeviceId() => clearField(3);
|
||||
}
|
||||
|
||||
class DevicePairingDataPage_PairedDevice extends $pb.GeneratedMessage {
|
||||
factory DevicePairingDataPage_PairedDevice({
|
||||
$core.List<$core.int>? device,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (device != null) {
|
||||
$result.device = device;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
DevicePairingDataPage_PairedDevice._() : super();
|
||||
factory DevicePairingDataPage_PairedDevice.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory DevicePairingDataPage_PairedDevice.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevicePairingDataPage.PairedDevice', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'device', $pb.PbFieldType.OY)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
DevicePairingDataPage_PairedDevice clone() => DevicePairingDataPage_PairedDevice()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
DevicePairingDataPage_PairedDevice copyWith(void Function(DevicePairingDataPage_PairedDevice) updates) => super.copyWith((message) => updates(message as DevicePairingDataPage_PairedDevice)) as DevicePairingDataPage_PairedDevice;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static DevicePairingDataPage_PairedDevice create() => DevicePairingDataPage_PairedDevice._();
|
||||
DevicePairingDataPage_PairedDevice createEmptyInstance() => create();
|
||||
static $pb.PbList<DevicePairingDataPage_PairedDevice> createRepeated() => $pb.PbList<DevicePairingDataPage_PairedDevice>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static DevicePairingDataPage_PairedDevice getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<DevicePairingDataPage_PairedDevice>(create);
|
||||
static DevicePairingDataPage_PairedDevice? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.List<$core.int> get device => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set device($core.List<$core.int> v) { $_setBytes(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasDevice() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearDevice() => clearField(1);
|
||||
}
|
||||
|
||||
class DevicePairingDataPage extends $pb.GeneratedMessage {
|
||||
factory DevicePairingDataPage({
|
||||
$core.int? devicesCount,
|
||||
$core.int? pairingStatus,
|
||||
$core.Iterable<DevicePairingDataPage_PairedDevice>? pairingDevList,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (devicesCount != null) {
|
||||
$result.devicesCount = devicesCount;
|
||||
}
|
||||
if (pairingStatus != null) {
|
||||
$result.pairingStatus = pairingStatus;
|
||||
}
|
||||
if (pairingDevList != null) {
|
||||
$result.pairingDevList.addAll(pairingDevList);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
DevicePairingDataPage._() : super();
|
||||
factory DevicePairingDataPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory DevicePairingDataPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevicePairingDataPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'devicesCount', $pb.PbFieldType.O3, protoName: 'devicesCount')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'pairingStatus', $pb.PbFieldType.O3, protoName: 'pairingStatus')
|
||||
..pc<DevicePairingDataPage_PairedDevice>(3, _omitFieldNames ? '' : 'pairingDevList', $pb.PbFieldType.PM, protoName: 'pairingDevList', subBuilder: DevicePairingDataPage_PairedDevice.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
DevicePairingDataPage clone() => DevicePairingDataPage()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
DevicePairingDataPage copyWith(void Function(DevicePairingDataPage) updates) => super.copyWith((message) => updates(message as DevicePairingDataPage)) as DevicePairingDataPage;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static DevicePairingDataPage create() => DevicePairingDataPage._();
|
||||
DevicePairingDataPage createEmptyInstance() => create();
|
||||
static $pb.PbList<DevicePairingDataPage> createRepeated() => $pb.PbList<DevicePairingDataPage>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static DevicePairingDataPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<DevicePairingDataPage>(create);
|
||||
static DevicePairingDataPage? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get devicesCount => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set devicesCount($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasDevicesCount() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearDevicesCount() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get pairingStatus => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set pairingStatus($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasPairingStatus() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearPairingStatus() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<DevicePairingDataPage_PairedDevice> get pairingDevList => $_getList(2);
|
||||
}
|
||||
|
||||
enum SetDfuTest_TestCase {
|
||||
failedEnterDfu,
|
||||
failedStartAdvertising,
|
||||
crcFailure,
|
||||
notSet
|
||||
}
|
||||
|
||||
class SetDfuTest extends $pb.GeneratedMessage {
|
||||
factory SetDfuTest({
|
||||
$core.bool? failedEnterDfu,
|
||||
$core.bool? failedStartAdvertising,
|
||||
$core.int? crcFailure,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (failedEnterDfu != null) {
|
||||
$result.failedEnterDfu = failedEnterDfu;
|
||||
}
|
||||
if (failedStartAdvertising != null) {
|
||||
$result.failedStartAdvertising = failedStartAdvertising;
|
||||
}
|
||||
if (crcFailure != null) {
|
||||
$result.crcFailure = crcFailure;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetDfuTest._() : super();
|
||||
factory SetDfuTest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetDfuTest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static const $core.Map<$core.int, SetDfuTest_TestCase> _SetDfuTest_TestCaseByTag = {
|
||||
1 : SetDfuTest_TestCase.failedEnterDfu,
|
||||
2 : SetDfuTest_TestCase.failedStartAdvertising,
|
||||
3 : SetDfuTest_TestCase.crcFailure,
|
||||
0 : SetDfuTest_TestCase.notSet
|
||||
};
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetDfuTest', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..oo(0, [1, 2, 3])
|
||||
..aOB(1, _omitFieldNames ? '' : 'failedEnterDfu', protoName: 'failedEnterDfu')
|
||||
..aOB(2, _omitFieldNames ? '' : 'failedStartAdvertising', protoName: 'failedStartAdvertising')
|
||||
..a<$core.int>(3, _omitFieldNames ? '' : 'crcFailure', $pb.PbFieldType.O3, protoName: 'crcFailure')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetDfuTest clone() => SetDfuTest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetDfuTest copyWith(void Function(SetDfuTest) updates) => super.copyWith((message) => updates(message as SetDfuTest)) as SetDfuTest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetDfuTest create() => SetDfuTest._();
|
||||
SetDfuTest createEmptyInstance() => create();
|
||||
static $pb.PbList<SetDfuTest> createRepeated() => $pb.PbList<SetDfuTest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetDfuTest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetDfuTest>(create);
|
||||
static SetDfuTest? _defaultInstance;
|
||||
|
||||
SetDfuTest_TestCase whichTestCase() => _SetDfuTest_TestCaseByTag[$_whichOneof(0)]!;
|
||||
void clearTestCase() => clearField($_whichOneof(0));
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get failedEnterDfu => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set failedEnterDfu($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasFailedEnterDfu() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearFailedEnterDfu() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool get failedStartAdvertising => $_getBF(1);
|
||||
@$pb.TagNumber(2)
|
||||
set failedStartAdvertising($core.bool v) { $_setBool(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasFailedStartAdvertising() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearFailedStartAdvertising() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.int get crcFailure => $_getIZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set crcFailure($core.int v) { $_setSignedInt32(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasCrcFailure() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearCrcFailure() => clearField(3);
|
||||
}
|
||||
|
||||
class SetGearTestData extends $pb.GeneratedMessage {
|
||||
factory SetGearTestData({
|
||||
$core.int? frontGearIdx,
|
||||
$core.int? rearGearIdx,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (frontGearIdx != null) {
|
||||
$result.frontGearIdx = frontGearIdx;
|
||||
}
|
||||
if (rearGearIdx != null) {
|
||||
$result.rearGearIdx = rearGearIdx;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetGearTestData._() : super();
|
||||
factory SetGearTestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetGearTestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetGearTestData', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'frontGearIdx', $pb.PbFieldType.O3, protoName: 'frontGearIdx')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'rearGearIdx', $pb.PbFieldType.O3, protoName: 'rearGearIdx')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetGearTestData clone() => SetGearTestData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetGearTestData copyWith(void Function(SetGearTestData) updates) => super.copyWith((message) => updates(message as SetGearTestData)) as SetGearTestData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetGearTestData create() => SetGearTestData._();
|
||||
SetGearTestData createEmptyInstance() => create();
|
||||
static $pb.PbList<SetGearTestData> createRepeated() => $pb.PbList<SetGearTestData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetGearTestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetGearTestData>(create);
|
||||
static SetGearTestData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get frontGearIdx => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set frontGearIdx($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasFrontGearIdx() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearFrontGearIdx() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get rearGearIdx => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set rearGearIdx($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasRearGearIdx() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearRearGearIdx() => clearField(2);
|
||||
}
|
||||
|
||||
class SetHrmTestData extends $pb.GeneratedMessage {
|
||||
factory SetHrmTestData({
|
||||
$core.bool? hrmPresent,
|
||||
$core.int? heartRate,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (hrmPresent != null) {
|
||||
$result.hrmPresent = hrmPresent;
|
||||
}
|
||||
if (heartRate != null) {
|
||||
$result.heartRate = heartRate;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetHrmTestData._() : super();
|
||||
factory SetHrmTestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetHrmTestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetHrmTestData', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'hrmPresent', protoName: 'hrmPresent')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'heartRate', $pb.PbFieldType.O3, protoName: 'heartRate')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetHrmTestData clone() => SetHrmTestData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetHrmTestData copyWith(void Function(SetHrmTestData) updates) => super.copyWith((message) => updates(message as SetHrmTestData)) as SetHrmTestData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetHrmTestData create() => SetHrmTestData._();
|
||||
SetHrmTestData createEmptyInstance() => create();
|
||||
static $pb.PbList<SetHrmTestData> createRepeated() => $pb.PbList<SetHrmTestData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetHrmTestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetHrmTestData>(create);
|
||||
static SetHrmTestData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get hrmPresent => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set hrmPresent($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasHrmPresent() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearHrmPresent() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get heartRate => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set heartRate($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasHeartRate() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearHeartRate() => clearField(2);
|
||||
}
|
||||
|
||||
class SetInputDeviceTestData_ControllerAnalogEvent extends $pb.GeneratedMessage {
|
||||
factory SetInputDeviceTestData_ControllerAnalogEvent({
|
||||
$core.int? sensorId,
|
||||
$core.int? value,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (sensorId != null) {
|
||||
$result.sensorId = sensorId;
|
||||
}
|
||||
if (value != null) {
|
||||
$result.value = value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetInputDeviceTestData_ControllerAnalogEvent._() : super();
|
||||
factory SetInputDeviceTestData_ControllerAnalogEvent.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetInputDeviceTestData_ControllerAnalogEvent.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetInputDeviceTestData.ControllerAnalogEvent', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.O3, protoName: 'sensorId')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'value', $pb.PbFieldType.O3)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetInputDeviceTestData_ControllerAnalogEvent clone() => SetInputDeviceTestData_ControllerAnalogEvent()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetInputDeviceTestData_ControllerAnalogEvent copyWith(void Function(SetInputDeviceTestData_ControllerAnalogEvent) updates) => super.copyWith((message) => updates(message as SetInputDeviceTestData_ControllerAnalogEvent)) as SetInputDeviceTestData_ControllerAnalogEvent;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetInputDeviceTestData_ControllerAnalogEvent create() => SetInputDeviceTestData_ControllerAnalogEvent._();
|
||||
SetInputDeviceTestData_ControllerAnalogEvent createEmptyInstance() => create();
|
||||
static $pb.PbList<SetInputDeviceTestData_ControllerAnalogEvent> createRepeated() => $pb.PbList<SetInputDeviceTestData_ControllerAnalogEvent>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetInputDeviceTestData_ControllerAnalogEvent getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetInputDeviceTestData_ControllerAnalogEvent>(create);
|
||||
static SetInputDeviceTestData_ControllerAnalogEvent? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get sensorId => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set sensorId($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasSensorId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearSensorId() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get value => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set value($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasValue() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearValue() => clearField(2);
|
||||
}
|
||||
|
||||
class SetInputDeviceTestData extends $pb.GeneratedMessage {
|
||||
factory SetInputDeviceTestData({
|
||||
$core.int? duration,
|
||||
$core.int? buttonEvent,
|
||||
$core.Iterable<SetInputDeviceTestData_ControllerAnalogEvent>? analogEventList,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (duration != null) {
|
||||
$result.duration = duration;
|
||||
}
|
||||
if (buttonEvent != null) {
|
||||
$result.buttonEvent = buttonEvent;
|
||||
}
|
||||
if (analogEventList != null) {
|
||||
$result.analogEventList.addAll(analogEventList);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetInputDeviceTestData._() : super();
|
||||
factory SetInputDeviceTestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetInputDeviceTestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetInputDeviceTestData', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'duration', $pb.PbFieldType.O3)
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'buttonEvent', $pb.PbFieldType.O3, protoName: 'buttonEvent')
|
||||
..pc<SetInputDeviceTestData_ControllerAnalogEvent>(3, _omitFieldNames ? '' : 'analogEventList', $pb.PbFieldType.PM, protoName: 'analogEventList', subBuilder: SetInputDeviceTestData_ControllerAnalogEvent.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetInputDeviceTestData clone() => SetInputDeviceTestData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetInputDeviceTestData copyWith(void Function(SetInputDeviceTestData) updates) => super.copyWith((message) => updates(message as SetInputDeviceTestData)) as SetInputDeviceTestData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetInputDeviceTestData create() => SetInputDeviceTestData._();
|
||||
SetInputDeviceTestData createEmptyInstance() => create();
|
||||
static $pb.PbList<SetInputDeviceTestData> createRepeated() => $pb.PbList<SetInputDeviceTestData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetInputDeviceTestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetInputDeviceTestData>(create);
|
||||
static SetInputDeviceTestData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get duration => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set duration($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasDuration() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearDuration() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get buttonEvent => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set buttonEvent($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasButtonEvent() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearButtonEvent() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<SetInputDeviceTestData_ControllerAnalogEvent> get analogEventList => $_getList(2);
|
||||
}
|
||||
|
||||
class SetTrainerTestData extends $pb.GeneratedMessage {
|
||||
factory SetTrainerTestData({
|
||||
$core.int? dataMode,
|
||||
$core.int? interfaces,
|
||||
TestTrainerData? testTrainerData,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (dataMode != null) {
|
||||
$result.dataMode = dataMode;
|
||||
}
|
||||
if (interfaces != null) {
|
||||
$result.interfaces = interfaces;
|
||||
}
|
||||
if (testTrainerData != null) {
|
||||
$result.testTrainerData = testTrainerData;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
SetTrainerTestData._() : super();
|
||||
factory SetTrainerTestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory SetTrainerTestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetTrainerTestData', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'dataMode', $pb.PbFieldType.O3, protoName: 'dataMode')
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'interfaces', $pb.PbFieldType.O3)
|
||||
..aOM<TestTrainerData>(3, _omitFieldNames ? '' : 'testTrainerData', protoName: 'testTrainerData', subBuilder: TestTrainerData.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetTrainerTestData clone() => SetTrainerTestData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
SetTrainerTestData copyWith(void Function(SetTrainerTestData) updates) => super.copyWith((message) => updates(message as SetTrainerTestData)) as SetTrainerTestData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetTrainerTestData create() => SetTrainerTestData._();
|
||||
SetTrainerTestData createEmptyInstance() => create();
|
||||
static $pb.PbList<SetTrainerTestData> createRepeated() => $pb.PbList<SetTrainerTestData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SetTrainerTestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<SetTrainerTestData>(create);
|
||||
static SetTrainerTestData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get dataMode => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set dataMode($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasDataMode() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearDataMode() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get interfaces => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set interfaces($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasInterfaces() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearInterfaces() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
TestTrainerData get testTrainerData => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set testTrainerData(TestTrainerData v) { setField(3, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasTestTrainerData() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearTestTrainerData() => clearField(3);
|
||||
@$pb.TagNumber(3)
|
||||
TestTrainerData ensureTestTrainerData() => $_ensure(2);
|
||||
}
|
||||
|
||||
class TestTrainerData extends $pb.GeneratedMessage {
|
||||
factory TestTrainerData({
|
||||
$core.int? power,
|
||||
$core.int? cadence,
|
||||
$core.int? bikeSpeed,
|
||||
$core.int? averagedPower,
|
||||
$core.int? wheelSpeed,
|
||||
$core.int? calculatedRealGearRatio,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (power != null) {
|
||||
$result.power = power;
|
||||
}
|
||||
if (cadence != null) {
|
||||
$result.cadence = cadence;
|
||||
}
|
||||
if (bikeSpeed != null) {
|
||||
$result.bikeSpeed = bikeSpeed;
|
||||
}
|
||||
if (averagedPower != null) {
|
||||
$result.averagedPower = averagedPower;
|
||||
}
|
||||
if (wheelSpeed != null) {
|
||||
$result.wheelSpeed = wheelSpeed;
|
||||
}
|
||||
if (calculatedRealGearRatio != null) {
|
||||
$result.calculatedRealGearRatio = calculatedRealGearRatio;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
TestTrainerData._() : super();
|
||||
factory TestTrainerData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory TestTrainerData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TestTrainerData', package: const $pb.PackageName(_omitMessageNames ? '' : 'com.zwift.protobuf'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'power', $pb.PbFieldType.O3)
|
||||
..a<$core.int>(2, _omitFieldNames ? '' : 'cadence', $pb.PbFieldType.O3)
|
||||
..a<$core.int>(3, _omitFieldNames ? '' : 'bikeSpeed', $pb.PbFieldType.O3, protoName: 'bikeSpeed')
|
||||
..a<$core.int>(4, _omitFieldNames ? '' : 'averagedPower', $pb.PbFieldType.O3, protoName: 'averagedPower')
|
||||
..a<$core.int>(5, _omitFieldNames ? '' : 'wheelSpeed', $pb.PbFieldType.O3, protoName: 'wheelSpeed')
|
||||
..a<$core.int>(6, _omitFieldNames ? '' : 'calculatedRealGearRatio', $pb.PbFieldType.O3, protoName: 'calculatedRealGearRatio')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
TestTrainerData clone() => TestTrainerData()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
TestTrainerData copyWith(void Function(TestTrainerData) updates) => super.copyWith((message) => updates(message as TestTrainerData)) as TestTrainerData;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TestTrainerData create() => TestTrainerData._();
|
||||
TestTrainerData createEmptyInstance() => create();
|
||||
static $pb.PbList<TestTrainerData> createRepeated() => $pb.PbList<TestTrainerData>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TestTrainerData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<TestTrainerData>(create);
|
||||
static TestTrainerData? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get power => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set power($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasPower() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearPower() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.int get cadence => $_getIZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set cadence($core.int v) { $_setSignedInt32(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasCadence() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearCadence() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.int get bikeSpeed => $_getIZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set bikeSpeed($core.int v) { $_setSignedInt32(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasBikeSpeed() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearBikeSpeed() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.int get averagedPower => $_getIZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set averagedPower($core.int v) { $_setSignedInt32(3, v); }
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasAveragedPower() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearAveragedPower() => clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$core.int get wheelSpeed => $_getIZ(4);
|
||||
@$pb.TagNumber(5)
|
||||
set wheelSpeed($core.int v) { $_setSignedInt32(4, v); }
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasWheelSpeed() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearWheelSpeed() => clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
$core.int get calculatedRealGearRatio => $_getIZ(5);
|
||||
@$pb.TagNumber(6)
|
||||
set calculatedRealGearRatio($core.int v) { $_setSignedInt32(5, v); }
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasCalculatedRealGearRatio() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearCalculatedRealGearRatio() => clearField(6);
|
||||
}
|
||||
|
||||
|
||||
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
@@ -1,101 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zp_vendor.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class VendorOpcode extends $pb.ProtobufEnum {
|
||||
static const VendorOpcode UNDEFINED = VendorOpcode._(0, _omitEnumNames ? '' : 'UNDEFINED');
|
||||
static const VendorOpcode CONTROLLER_SYNC = VendorOpcode._(1, _omitEnumNames ? '' : 'CONTROLLER_SYNC');
|
||||
static const VendorOpcode PAIR_DEVICES = VendorOpcode._(2, _omitEnumNames ? '' : 'PAIR_DEVICES');
|
||||
static const VendorOpcode ENABLE_TEST_MODE = VendorOpcode._(65280, _omitEnumNames ? '' : 'ENABLE_TEST_MODE');
|
||||
static const VendorOpcode SET_DFU_TEST = VendorOpcode._(65281, _omitEnumNames ? '' : 'SET_DFU_TEST');
|
||||
static const VendorOpcode SET_TRAINER_TEST_DATA = VendorOpcode._(65282, _omitEnumNames ? '' : 'SET_TRAINER_TEST_DATA');
|
||||
static const VendorOpcode SET_INPUT_DEVICE_TEST_DATA = VendorOpcode._(65283, _omitEnumNames ? '' : 'SET_INPUT_DEVICE_TEST_DATA');
|
||||
static const VendorOpcode SET_GEAR_TEST_DATA = VendorOpcode._(65284, _omitEnumNames ? '' : 'SET_GEAR_TEST_DATA');
|
||||
static const VendorOpcode SET_HRM_TEST_DATA = VendorOpcode._(65285, _omitEnumNames ? '' : 'SET_HRM_TEST_DATA');
|
||||
static const VendorOpcode SET_TEST_DATA = VendorOpcode._(65286, _omitEnumNames ? '' : 'SET_TEST_DATA');
|
||||
|
||||
static const $core.List<VendorOpcode> values = <VendorOpcode> [
|
||||
UNDEFINED,
|
||||
CONTROLLER_SYNC,
|
||||
PAIR_DEVICES,
|
||||
ENABLE_TEST_MODE,
|
||||
SET_DFU_TEST,
|
||||
SET_TRAINER_TEST_DATA,
|
||||
SET_INPUT_DEVICE_TEST_DATA,
|
||||
SET_GEAR_TEST_DATA,
|
||||
SET_HRM_TEST_DATA,
|
||||
SET_TEST_DATA,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, VendorOpcode> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static VendorOpcode? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const VendorOpcode._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
class PairDeviceType extends $pb.ProtobufEnum {
|
||||
static const PairDeviceType BLE = PairDeviceType._(0, _omitEnumNames ? '' : 'BLE');
|
||||
static const PairDeviceType ANT = PairDeviceType._(1, _omitEnumNames ? '' : 'ANT');
|
||||
|
||||
static const $core.List<PairDeviceType> values = <PairDeviceType> [
|
||||
BLE,
|
||||
ANT,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, PairDeviceType> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static PairDeviceType? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const PairDeviceType._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
/// Status used by ControllerSync
|
||||
class ControllerSyncStatus extends $pb.ProtobufEnum {
|
||||
static const ControllerSyncStatus NOT_CONNECTED = ControllerSyncStatus._(0, _omitEnumNames ? '' : 'NOT_CONNECTED');
|
||||
static const ControllerSyncStatus CONNECTED = ControllerSyncStatus._(1, _omitEnumNames ? '' : 'CONNECTED');
|
||||
|
||||
static const $core.List<ControllerSyncStatus> values = <ControllerSyncStatus> [
|
||||
NOT_CONNECTED,
|
||||
CONNECTED,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, ControllerSyncStatus> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static ControllerSyncStatus? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const ControllerSyncStatus._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
/// Looks like “data object / page” IDs used with pairing pages
|
||||
class VendorDO extends $pb.ProtobufEnum {
|
||||
static const VendorDO NO_CLUE = VendorDO._(0, _omitEnumNames ? '' : 'NO_CLUE');
|
||||
static const VendorDO PAGE_DEVICE_PAIRING = VendorDO._(61440, _omitEnumNames ? '' : 'PAGE_DEVICE_PAIRING');
|
||||
static const VendorDO DEVICE_COUNT = VendorDO._(61441, _omitEnumNames ? '' : 'DEVICE_COUNT');
|
||||
static const VendorDO PAIRING_STATUS = VendorDO._(61442, _omitEnumNames ? '' : 'PAIRING_STATUS');
|
||||
static const VendorDO PAIRED_DEVICE = VendorDO._(61443, _omitEnumNames ? '' : 'PAIRED_DEVICE');
|
||||
|
||||
static const $core.List<VendorDO> values = <VendorDO> [
|
||||
NO_CLUE,
|
||||
PAGE_DEVICE_PAIRING,
|
||||
DEVICE_COUNT,
|
||||
PAIRING_STATUS,
|
||||
PAIRED_DEVICE,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, VendorDO> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static VendorDO? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const VendorDO._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
|
||||
const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
@@ -1,267 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zp_vendor.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use vendorOpcodeDescriptor instead')
|
||||
const VendorOpcode$json = {
|
||||
'1': 'VendorOpcode',
|
||||
'2': [
|
||||
{'1': 'UNDEFINED', '2': 0},
|
||||
{'1': 'CONTROLLER_SYNC', '2': 1},
|
||||
{'1': 'PAIR_DEVICES', '2': 2},
|
||||
{'1': 'ENABLE_TEST_MODE', '2': 65280},
|
||||
{'1': 'SET_DFU_TEST', '2': 65281},
|
||||
{'1': 'SET_TRAINER_TEST_DATA', '2': 65282},
|
||||
{'1': 'SET_INPUT_DEVICE_TEST_DATA', '2': 65283},
|
||||
{'1': 'SET_GEAR_TEST_DATA', '2': 65284},
|
||||
{'1': 'SET_HRM_TEST_DATA', '2': 65285},
|
||||
{'1': 'SET_TEST_DATA', '2': 65286},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `VendorOpcode`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List vendorOpcodeDescriptor = $convert.base64Decode(
|
||||
'CgxWZW5kb3JPcGNvZGUSDQoJVU5ERUZJTkVEEAASEwoPQ09OVFJPTExFUl9TWU5DEAESEAoMUE'
|
||||
'FJUl9ERVZJQ0VTEAISFgoQRU5BQkxFX1RFU1RfTU9ERRCA/gMSEgoMU0VUX0RGVV9URVNUEIH+'
|
||||
'AxIbChVTRVRfVFJBSU5FUl9URVNUX0RBVEEQgv4DEiAKGlNFVF9JTlBVVF9ERVZJQ0VfVEVTVF'
|
||||
'9EQVRBEIP+AxIYChJTRVRfR0VBUl9URVNUX0RBVEEQhP4DEhcKEVNFVF9IUk1fVEVTVF9EQVRB'
|
||||
'EIX+AxITCg1TRVRfVEVTVF9EQVRBEIb+Aw==');
|
||||
|
||||
@$core.Deprecated('Use pairDeviceTypeDescriptor instead')
|
||||
const PairDeviceType$json = {
|
||||
'1': 'PairDeviceType',
|
||||
'2': [
|
||||
{'1': 'BLE', '2': 0},
|
||||
{'1': 'ANT', '2': 1},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PairDeviceType`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List pairDeviceTypeDescriptor = $convert.base64Decode(
|
||||
'Cg5QYWlyRGV2aWNlVHlwZRIHCgNCTEUQABIHCgNBTlQQAQ==');
|
||||
|
||||
@$core.Deprecated('Use controllerSyncStatusDescriptor instead')
|
||||
const ControllerSyncStatus$json = {
|
||||
'1': 'ControllerSyncStatus',
|
||||
'2': [
|
||||
{'1': 'NOT_CONNECTED', '2': 0},
|
||||
{'1': 'CONNECTED', '2': 1},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ControllerSyncStatus`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List controllerSyncStatusDescriptor = $convert.base64Decode(
|
||||
'ChRDb250cm9sbGVyU3luY1N0YXR1cxIRCg1OT1RfQ09OTkVDVEVEEAASDQoJQ09OTkVDVEVEEA'
|
||||
'E=');
|
||||
|
||||
@$core.Deprecated('Use vendorDODescriptor instead')
|
||||
const VendorDO$json = {
|
||||
'1': 'VendorDO',
|
||||
'2': [
|
||||
{'1': 'NO_CLUE', '2': 0},
|
||||
{'1': 'PAGE_DEVICE_PAIRING', '2': 61440},
|
||||
{'1': 'DEVICE_COUNT', '2': 61441},
|
||||
{'1': 'PAIRING_STATUS', '2': 61442},
|
||||
{'1': 'PAIRED_DEVICE', '2': 61443},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `VendorDO`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List vendorDODescriptor = $convert.base64Decode(
|
||||
'CghWZW5kb3JETxILCgdOT19DTFVFEAASGQoTUEFHRV9ERVZJQ0VfUEFJUklORxCA4AMSEgoMRE'
|
||||
'VWSUNFX0NPVU5UEIHgAxIUCg5QQUlSSU5HX1NUQVRVUxCC4AMSEwoNUEFJUkVEX0RFVklDRRCD'
|
||||
'4AM=');
|
||||
|
||||
@$core.Deprecated('Use controllerSyncDescriptor instead')
|
||||
const ControllerSync$json = {
|
||||
'1': 'ControllerSync',
|
||||
'2': [
|
||||
{'1': 'status', '3': 1, '4': 1, '5': 14, '6': '.com.zwift.protobuf.ControllerSyncStatus', '10': 'status'},
|
||||
{'1': 'timeStamp', '3': 2, '4': 1, '5': 5, '10': 'timeStamp'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ControllerSync`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List controllerSyncDescriptor = $convert.base64Decode(
|
||||
'Cg5Db250cm9sbGVyU3luYxJACgZzdGF0dXMYASABKA4yKC5jb20uendpZnQucHJvdG9idWYuQ2'
|
||||
'9udHJvbGxlclN5bmNTdGF0dXNSBnN0YXR1cxIcCgl0aW1lU3RhbXAYAiABKAVSCXRpbWVTdGFt'
|
||||
'cA==');
|
||||
|
||||
@$core.Deprecated('Use enableTestModeDescriptor instead')
|
||||
const EnableTestMode$json = {
|
||||
'1': 'EnableTestMode',
|
||||
'2': [
|
||||
{'1': 'enable', '3': 1, '4': 1, '5': 8, '10': 'enable'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `EnableTestMode`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List enableTestModeDescriptor = $convert.base64Decode(
|
||||
'Cg5FbmFibGVUZXN0TW9kZRIWCgZlbmFibGUYASABKAhSBmVuYWJsZQ==');
|
||||
|
||||
@$core.Deprecated('Use pairDevicesDescriptor instead')
|
||||
const PairDevices$json = {
|
||||
'1': 'PairDevices',
|
||||
'2': [
|
||||
{'1': 'pair', '3': 1, '4': 1, '5': 8, '10': 'pair'},
|
||||
{'1': 'type', '3': 2, '4': 1, '5': 14, '6': '.com.zwift.protobuf.PairDeviceType', '10': 'type'},
|
||||
{'1': 'deviceId', '3': 3, '4': 1, '5': 12, '10': 'deviceId'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PairDevices`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List pairDevicesDescriptor = $convert.base64Decode(
|
||||
'CgtQYWlyRGV2aWNlcxISCgRwYWlyGAEgASgIUgRwYWlyEjYKBHR5cGUYAiABKA4yIi5jb20uen'
|
||||
'dpZnQucHJvdG9idWYuUGFpckRldmljZVR5cGVSBHR5cGUSGgoIZGV2aWNlSWQYAyABKAxSCGRl'
|
||||
'dmljZUlk');
|
||||
|
||||
@$core.Deprecated('Use devicePairingDataPageDescriptor instead')
|
||||
const DevicePairingDataPage$json = {
|
||||
'1': 'DevicePairingDataPage',
|
||||
'2': [
|
||||
{'1': 'devicesCount', '3': 1, '4': 1, '5': 5, '10': 'devicesCount'},
|
||||
{'1': 'pairingStatus', '3': 2, '4': 1, '5': 5, '10': 'pairingStatus'},
|
||||
{'1': 'pairingDevList', '3': 3, '4': 3, '5': 11, '6': '.com.zwift.protobuf.DevicePairingDataPage.PairedDevice', '10': 'pairingDevList'},
|
||||
],
|
||||
'3': [DevicePairingDataPage_PairedDevice$json],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use devicePairingDataPageDescriptor instead')
|
||||
const DevicePairingDataPage_PairedDevice$json = {
|
||||
'1': 'PairedDevice',
|
||||
'2': [
|
||||
{'1': 'device', '3': 1, '4': 1, '5': 12, '10': 'device'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `DevicePairingDataPage`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List devicePairingDataPageDescriptor = $convert.base64Decode(
|
||||
'ChVEZXZpY2VQYWlyaW5nRGF0YVBhZ2USIgoMZGV2aWNlc0NvdW50GAEgASgFUgxkZXZpY2VzQ2'
|
||||
'91bnQSJAoNcGFpcmluZ1N0YXR1cxgCIAEoBVINcGFpcmluZ1N0YXR1cxJeCg5wYWlyaW5nRGV2'
|
||||
'TGlzdBgDIAMoCzI2LmNvbS56d2lmdC5wcm90b2J1Zi5EZXZpY2VQYWlyaW5nRGF0YVBhZ2UuUG'
|
||||
'FpcmVkRGV2aWNlUg5wYWlyaW5nRGV2TGlzdBomCgxQYWlyZWREZXZpY2USFgoGZGV2aWNlGAEg'
|
||||
'ASgMUgZkZXZpY2U=');
|
||||
|
||||
@$core.Deprecated('Use setDfuTestDescriptor instead')
|
||||
const SetDfuTest$json = {
|
||||
'1': 'SetDfuTest',
|
||||
'2': [
|
||||
{'1': 'failedEnterDfu', '3': 1, '4': 1, '5': 8, '9': 0, '10': 'failedEnterDfu'},
|
||||
{'1': 'failedStartAdvertising', '3': 2, '4': 1, '5': 8, '9': 0, '10': 'failedStartAdvertising'},
|
||||
{'1': 'crcFailure', '3': 3, '4': 1, '5': 5, '9': 0, '10': 'crcFailure'},
|
||||
],
|
||||
'8': [
|
||||
{'1': 'test_case'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SetDfuTest`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List setDfuTestDescriptor = $convert.base64Decode(
|
||||
'CgpTZXREZnVUZXN0EigKDmZhaWxlZEVudGVyRGZ1GAEgASgISABSDmZhaWxlZEVudGVyRGZ1Ej'
|
||||
'gKFmZhaWxlZFN0YXJ0QWR2ZXJ0aXNpbmcYAiABKAhIAFIWZmFpbGVkU3RhcnRBZHZlcnRpc2lu'
|
||||
'ZxIgCgpjcmNGYWlsdXJlGAMgASgFSABSCmNyY0ZhaWx1cmVCCwoJdGVzdF9jYXNl');
|
||||
|
||||
@$core.Deprecated('Use setGearTestDataDescriptor instead')
|
||||
const SetGearTestData$json = {
|
||||
'1': 'SetGearTestData',
|
||||
'2': [
|
||||
{'1': 'frontGearIdx', '3': 1, '4': 1, '5': 5, '10': 'frontGearIdx'},
|
||||
{'1': 'rearGearIdx', '3': 2, '4': 1, '5': 5, '10': 'rearGearIdx'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SetGearTestData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List setGearTestDataDescriptor = $convert.base64Decode(
|
||||
'Cg9TZXRHZWFyVGVzdERhdGESIgoMZnJvbnRHZWFySWR4GAEgASgFUgxmcm9udEdlYXJJZHgSIA'
|
||||
'oLcmVhckdlYXJJZHgYAiABKAVSC3JlYXJHZWFySWR4');
|
||||
|
||||
@$core.Deprecated('Use setHrmTestDataDescriptor instead')
|
||||
const SetHrmTestData$json = {
|
||||
'1': 'SetHrmTestData',
|
||||
'2': [
|
||||
{'1': 'hrmPresent', '3': 1, '4': 1, '5': 8, '10': 'hrmPresent'},
|
||||
{'1': 'heartRate', '3': 2, '4': 1, '5': 5, '10': 'heartRate'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SetHrmTestData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List setHrmTestDataDescriptor = $convert.base64Decode(
|
||||
'Cg5TZXRIcm1UZXN0RGF0YRIeCgpocm1QcmVzZW50GAEgASgIUgpocm1QcmVzZW50EhwKCWhlYX'
|
||||
'J0UmF0ZRgCIAEoBVIJaGVhcnRSYXRl');
|
||||
|
||||
@$core.Deprecated('Use setInputDeviceTestDataDescriptor instead')
|
||||
const SetInputDeviceTestData$json = {
|
||||
'1': 'SetInputDeviceTestData',
|
||||
'2': [
|
||||
{'1': 'duration', '3': 1, '4': 1, '5': 5, '10': 'duration'},
|
||||
{'1': 'buttonEvent', '3': 2, '4': 1, '5': 5, '10': 'buttonEvent'},
|
||||
{'1': 'analogEventList', '3': 3, '4': 3, '5': 11, '6': '.com.zwift.protobuf.SetInputDeviceTestData.ControllerAnalogEvent', '10': 'analogEventList'},
|
||||
],
|
||||
'3': [SetInputDeviceTestData_ControllerAnalogEvent$json],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use setInputDeviceTestDataDescriptor instead')
|
||||
const SetInputDeviceTestData_ControllerAnalogEvent$json = {
|
||||
'1': 'ControllerAnalogEvent',
|
||||
'2': [
|
||||
{'1': 'sensorId', '3': 1, '4': 1, '5': 5, '10': 'sensorId'},
|
||||
{'1': 'value', '3': 2, '4': 1, '5': 5, '10': 'value'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SetInputDeviceTestData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List setInputDeviceTestDataDescriptor = $convert.base64Decode(
|
||||
'ChZTZXRJbnB1dERldmljZVRlc3REYXRhEhoKCGR1cmF0aW9uGAEgASgFUghkdXJhdGlvbhIgCg'
|
||||
'tidXR0b25FdmVudBgCIAEoBVILYnV0dG9uRXZlbnQSagoPYW5hbG9nRXZlbnRMaXN0GAMgAygL'
|
||||
'MkAuY29tLnp3aWZ0LnByb3RvYnVmLlNldElucHV0RGV2aWNlVGVzdERhdGEuQ29udHJvbGxlck'
|
||||
'FuYWxvZ0V2ZW50Ug9hbmFsb2dFdmVudExpc3QaSQoVQ29udHJvbGxlckFuYWxvZ0V2ZW50EhoK'
|
||||
'CHNlbnNvcklkGAEgASgFUghzZW5zb3JJZBIUCgV2YWx1ZRgCIAEoBVIFdmFsdWU=');
|
||||
|
||||
@$core.Deprecated('Use setTrainerTestDataDescriptor instead')
|
||||
const SetTrainerTestData$json = {
|
||||
'1': 'SetTrainerTestData',
|
||||
'2': [
|
||||
{'1': 'dataMode', '3': 1, '4': 1, '5': 5, '10': 'dataMode'},
|
||||
{'1': 'interfaces', '3': 2, '4': 1, '5': 5, '10': 'interfaces'},
|
||||
{'1': 'testTrainerData', '3': 3, '4': 1, '5': 11, '6': '.com.zwift.protobuf.TestTrainerData', '10': 'testTrainerData'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SetTrainerTestData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List setTrainerTestDataDescriptor = $convert.base64Decode(
|
||||
'ChJTZXRUcmFpbmVyVGVzdERhdGESGgoIZGF0YU1vZGUYASABKAVSCGRhdGFNb2RlEh4KCmludG'
|
||||
'VyZmFjZXMYAiABKAVSCmludGVyZmFjZXMSTQoPdGVzdFRyYWluZXJEYXRhGAMgASgLMiMuY29t'
|
||||
'Lnp3aWZ0LnByb3RvYnVmLlRlc3RUcmFpbmVyRGF0YVIPdGVzdFRyYWluZXJEYXRh');
|
||||
|
||||
@$core.Deprecated('Use testTrainerDataDescriptor instead')
|
||||
const TestTrainerData$json = {
|
||||
'1': 'TestTrainerData',
|
||||
'2': [
|
||||
{'1': 'power', '3': 1, '4': 1, '5': 5, '10': 'power'},
|
||||
{'1': 'cadence', '3': 2, '4': 1, '5': 5, '10': 'cadence'},
|
||||
{'1': 'bikeSpeed', '3': 3, '4': 1, '5': 5, '10': 'bikeSpeed'},
|
||||
{'1': 'averagedPower', '3': 4, '4': 1, '5': 5, '10': 'averagedPower'},
|
||||
{'1': 'wheelSpeed', '3': 5, '4': 1, '5': 5, '10': 'wheelSpeed'},
|
||||
{'1': 'calculatedRealGearRatio', '3': 6, '4': 1, '5': 5, '10': 'calculatedRealGearRatio'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `TestTrainerData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List testTrainerDataDescriptor = $convert.base64Decode(
|
||||
'Cg9UZXN0VHJhaW5lckRhdGESFAoFcG93ZXIYASABKAVSBXBvd2VyEhgKB2NhZGVuY2UYAiABKA'
|
||||
'VSB2NhZGVuY2USHAoJYmlrZVNwZWVkGAMgASgFUgliaWtlU3BlZWQSJAoNYXZlcmFnZWRQb3dl'
|
||||
'chgEIAEoBVINYXZlcmFnZWRQb3dlchIeCgp3aGVlbFNwZWVkGAUgASgFUgp3aGVlbFNwZWVkEj'
|
||||
'gKF2NhbGN1bGF0ZWRSZWFsR2VhclJhdGlvGAYgASgFUhdjYWxjdWxhdGVkUmVhbEdlYXJSYXRp'
|
||||
'bw==');
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zp_vendor.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
export 'zp_vendor.pb.dart';
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zwift.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use playButtonStatusDescriptor instead')
|
||||
const PlayButtonStatus$json = {
|
||||
'1': 'PlayButtonStatus',
|
||||
'2': [
|
||||
{'1': 'ON', '2': 0},
|
||||
{'1': 'OFF', '2': 1},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PlayButtonStatus`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List playButtonStatusDescriptor = $convert.base64Decode(
|
||||
'ChBQbGF5QnV0dG9uU3RhdHVzEgYKAk9OEAASBwoDT0ZGEAE=');
|
||||
|
||||
@$core.Deprecated('Use rideButtonMaskDescriptor instead')
|
||||
const RideButtonMask$json = {
|
||||
'1': 'RideButtonMask',
|
||||
'2': [
|
||||
{'1': 'LEFT_BTN', '2': 1},
|
||||
{'1': 'UP_BTN', '2': 2},
|
||||
{'1': 'RIGHT_BTN', '2': 4},
|
||||
{'1': 'DOWN_BTN', '2': 8},
|
||||
{'1': 'A_BTN', '2': 16},
|
||||
{'1': 'B_BTN', '2': 32},
|
||||
{'1': 'Y_BTN', '2': 64},
|
||||
{'1': 'Z_BTN', '2': 256},
|
||||
{'1': 'SHFT_UP_L_BTN', '2': 512},
|
||||
{'1': 'SHFT_DN_L_BTN', '2': 1024},
|
||||
{'1': 'POWERUP_L_BTN', '2': 2048},
|
||||
{'1': 'ONOFF_L_BTN', '2': 4096},
|
||||
{'1': 'SHFT_UP_R_BTN', '2': 8192},
|
||||
{'1': 'SHFT_DN_R_BTN', '2': 16384},
|
||||
{'1': 'POWERUP_R_BTN', '2': 65536},
|
||||
{'1': 'ONOFF_R_BTN', '2': 131072},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideButtonMask`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List rideButtonMaskDescriptor = $convert.base64Decode(
|
||||
'Cg5SaWRlQnV0dG9uTWFzaxIMCghMRUZUX0JUThABEgoKBlVQX0JUThACEg0KCVJJR0hUX0JUTh'
|
||||
'AEEgwKCERPV05fQlROEAgSCQoFQV9CVE4QEBIJCgVCX0JUThAgEgkKBVlfQlROEEASCgoFWl9C'
|
||||
'VE4QgAISEgoNU0hGVF9VUF9MX0JUThCABBISCg1TSEZUX0ROX0xfQlROEIAIEhIKDVBPV0VSVV'
|
||||
'BfTF9CVE4QgBASEAoLT05PRkZfTF9CVE4QgCASEgoNU0hGVF9VUF9SX0JUThCAQBITCg1TSEZU'
|
||||
'X0ROX1JfQlROEICAARITCg1QT1dFUlVQX1JfQlROEICABBIRCgtPTk9GRl9SX0JUThCAgAg=');
|
||||
|
||||
@$core.Deprecated('Use rideAnalogLocationDescriptor instead')
|
||||
const RideAnalogLocation$json = {
|
||||
'1': 'RideAnalogLocation',
|
||||
'2': [
|
||||
{'1': 'LEFT', '2': 0},
|
||||
{'1': 'RIGHT', '2': 1},
|
||||
{'1': 'UP', '2': 2},
|
||||
{'1': 'DOWN', '2': 3},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideAnalogLocation`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List rideAnalogLocationDescriptor = $convert.base64Decode(
|
||||
'ChJSaWRlQW5hbG9nTG9jYXRpb24SCAoETEVGVBAAEgkKBVJJR0hUEAESBgoCVVAQAhIICgRET1'
|
||||
'dOEAM=');
|
||||
|
||||
@$core.Deprecated('Use playKeyPadStatusDescriptor instead')
|
||||
const PlayKeyPadStatus$json = {
|
||||
'1': 'PlayKeyPadStatus',
|
||||
'2': [
|
||||
{'1': 'RightPad', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'RightPad'},
|
||||
{'1': 'Button_Y_Up', '3': 2, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonYUp'},
|
||||
{'1': 'Button_Z_Left', '3': 3, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonZLeft'},
|
||||
{'1': 'Button_A_Right', '3': 4, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonARight'},
|
||||
{'1': 'Button_B_Down', '3': 5, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonBDown'},
|
||||
{'1': 'Button_Shift', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'},
|
||||
{'1': 'Button_On', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'},
|
||||
{'1': 'Analog_LR', '3': 8, '4': 1, '5': 17, '10': 'AnalogLR'},
|
||||
{'1': 'Analog_UD', '3': 9, '4': 1, '5': 17, '10': 'AnalogUD'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PlayKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List playKeyPadStatusDescriptor = $convert.base64Decode(
|
||||
'ChBQbGF5S2V5UGFkU3RhdHVzEjoKCFJpZ2h0UGFkGAEgASgOMh4uZGUuam9uYXNiYXJrLlBsYX'
|
||||
'lCdXR0b25TdGF0dXNSCFJpZ2h0UGFkEj4KC0J1dHRvbl9ZX1VwGAIgASgOMh4uZGUuam9uYXNi'
|
||||
'YXJrLlBsYXlCdXR0b25TdGF0dXNSCUJ1dHRvbllVcBJCCg1CdXR0b25fWl9MZWZ0GAMgASgOMh'
|
||||
'4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSC0J1dHRvblpMZWZ0EkQKDkJ1dHRvbl9B'
|
||||
'X1JpZ2h0GAQgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSDEJ1dHRvbkFSaW'
|
||||
'dodBJCCg1CdXR0b25fQl9Eb3duGAUgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0'
|
||||
'dXNSC0J1dHRvbkJEb3duEkEKDEJ1dHRvbl9TaGlmdBgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbG'
|
||||
'F5QnV0dG9uU3RhdHVzUgtCdXR0b25TaGlmdBI7CglCdXR0b25fT24YByABKA4yHi5kZS5qb25h'
|
||||
'c2JhcmsuUGxheUJ1dHRvblN0YXR1c1IIQnV0dG9uT24SGwoJQW5hbG9nX0xSGAggASgRUghBbm'
|
||||
'Fsb2dMUhIbCglBbmFsb2dfVUQYCSABKBFSCEFuYWxvZ1VE');
|
||||
|
||||
@$core.Deprecated('Use playCommandParametersDescriptor instead')
|
||||
const PlayCommandParameters$json = {
|
||||
'1': 'PlayCommandParameters',
|
||||
'2': [
|
||||
{'1': 'param1', '3': 1, '4': 1, '5': 13, '10': 'param1'},
|
||||
{'1': 'param2', '3': 2, '4': 1, '5': 13, '10': 'param2'},
|
||||
{'1': 'HapticPattern', '3': 3, '4': 1, '5': 13, '10': 'HapticPattern'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PlayCommandParameters`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List playCommandParametersDescriptor = $convert.base64Decode(
|
||||
'ChVQbGF5Q29tbWFuZFBhcmFtZXRlcnMSFgoGcGFyYW0xGAEgASgNUgZwYXJhbTESFgoGcGFyYW'
|
||||
'0yGAIgASgNUgZwYXJhbTISJAoNSGFwdGljUGF0dGVybhgDIAEoDVINSGFwdGljUGF0dGVybg==');
|
||||
|
||||
@$core.Deprecated('Use playCommandContentsDescriptor instead')
|
||||
const PlayCommandContents$json = {
|
||||
'1': 'PlayCommandContents',
|
||||
'2': [
|
||||
{'1': 'CommandParameters', '3': 1, '4': 1, '5': 11, '6': '.de.jonasbark.PlayCommandParameters', '10': 'CommandParameters'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PlayCommandContents`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List playCommandContentsDescriptor = $convert.base64Decode(
|
||||
'ChNQbGF5Q29tbWFuZENvbnRlbnRzElEKEUNvbW1hbmRQYXJhbWV0ZXJzGAEgASgLMiMuZGUuam'
|
||||
'9uYXNiYXJrLlBsYXlDb21tYW5kUGFyYW1ldGVyc1IRQ29tbWFuZFBhcmFtZXRlcnM=');
|
||||
|
||||
@$core.Deprecated('Use playCommandDescriptor instead')
|
||||
const PlayCommand$json = {
|
||||
'1': 'PlayCommand',
|
||||
'2': [
|
||||
{'1': 'CommandContents', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.PlayCommandContents', '10': 'CommandContents'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PlayCommand`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List playCommandDescriptor = $convert.base64Decode(
|
||||
'CgtQbGF5Q29tbWFuZBJLCg9Db21tYW5kQ29udGVudHMYAiABKAsyIS5kZS5qb25hc2JhcmsuUG'
|
||||
'xheUNvbW1hbmRDb250ZW50c1IPQ29tbWFuZENvbnRlbnRz');
|
||||
|
||||
@$core.Deprecated('Use idleDescriptor instead')
|
||||
const Idle$json = {
|
||||
'1': 'Idle',
|
||||
'2': [
|
||||
{'1': 'Unknown2', '3': 2, '4': 1, '5': 13, '10': 'Unknown2'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Idle`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List idleDescriptor = $convert.base64Decode(
|
||||
'CgRJZGxlEhoKCFVua25vd24yGAIgASgNUghVbmtub3duMg==');
|
||||
|
||||
@$core.Deprecated('Use rideAnalogKeyPressDescriptor instead')
|
||||
const RideAnalogKeyPress$json = {
|
||||
'1': 'RideAnalogKeyPress',
|
||||
'2': [
|
||||
{'1': 'Location', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.RideAnalogLocation', '10': 'Location'},
|
||||
{'1': 'AnalogValue', '3': 2, '4': 1, '5': 17, '10': 'AnalogValue'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideAnalogKeyPress`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List rideAnalogKeyPressDescriptor = $convert.base64Decode(
|
||||
'ChJSaWRlQW5hbG9nS2V5UHJlc3MSPAoITG9jYXRpb24YASABKA4yIC5kZS5qb25hc2JhcmsuUm'
|
||||
'lkZUFuYWxvZ0xvY2F0aW9uUghMb2NhdGlvbhIgCgtBbmFsb2dWYWx1ZRgCIAEoEVILQW5hbG9n'
|
||||
'VmFsdWU=');
|
||||
|
||||
@$core.Deprecated('Use rideKeyPadStatusDescriptor instead')
|
||||
const RideKeyPadStatus$json = {
|
||||
'1': 'RideKeyPadStatus',
|
||||
'2': [
|
||||
{'1': 'ButtonMap', '3': 1, '4': 1, '5': 13, '10': 'ButtonMap'},
|
||||
{'1': 'AnalogPaddles', '3': 3, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'AnalogPaddles'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List rideKeyPadStatusDescriptor = $convert.base64Decode(
|
||||
'ChBSaWRlS2V5UGFkU3RhdHVzEhwKCUJ1dHRvbk1hcBgBIAEoDVIJQnV0dG9uTWFwEkYKDUFuYW'
|
||||
'xvZ1BhZGRsZXMYAyADKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleVByZXNzUg1BbmFs'
|
||||
'b2dQYWRkbGVz');
|
||||
|
||||
@$core.Deprecated('Use clickKeyPadStatusDescriptor instead')
|
||||
const ClickKeyPadStatus$json = {
|
||||
'1': 'ClickKeyPadStatus',
|
||||
'2': [
|
||||
{'1': 'Button_Plus', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonPlus'},
|
||||
{'1': 'Button_Minus', '3': 2, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonMinus'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ClickKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List clickKeyPadStatusDescriptor = $convert.base64Decode(
|
||||
'ChFDbGlja0tleVBhZFN0YXR1cxI/CgtCdXR0b25fUGx1cxgBIAEoDjIeLmRlLmpvbmFzYmFyay'
|
||||
'5QbGF5QnV0dG9uU3RhdHVzUgpCdXR0b25QbHVzEkEKDEJ1dHRvbl9NaW51cxgCIAEoDjIeLmRl'
|
||||
'LmpvbmFzYmFyay5QbGF5QnV0dG9uU3RhdHVzUgtCdXR0b25NaW51cw==');
|
||||
|
||||
@$core.Deprecated('Use deviceInformationContentDescriptor instead')
|
||||
const DeviceInformationContent$json = {
|
||||
'1': 'DeviceInformationContent',
|
||||
'2': [
|
||||
{'1': 'Unknown1', '3': 1, '4': 1, '5': 13, '10': 'Unknown1'},
|
||||
{'1': 'SoftwareVersion', '3': 2, '4': 3, '5': 13, '10': 'SoftwareVersion'},
|
||||
{'1': 'DeviceName', '3': 3, '4': 1, '5': 9, '10': 'DeviceName'},
|
||||
{'1': 'Unknown4', '3': 4, '4': 1, '5': 13, '10': 'Unknown4'},
|
||||
{'1': 'Unknown5', '3': 5, '4': 1, '5': 13, '10': 'Unknown5'},
|
||||
{'1': 'SerialNumber', '3': 6, '4': 1, '5': 9, '10': 'SerialNumber'},
|
||||
{'1': 'HardwareVersion', '3': 7, '4': 1, '5': 9, '10': 'HardwareVersion'},
|
||||
{'1': 'ReplyData', '3': 8, '4': 3, '5': 13, '10': 'ReplyData'},
|
||||
{'1': 'Unknown9', '3': 9, '4': 1, '5': 13, '10': 'Unknown9'},
|
||||
{'1': 'Unknown10', '3': 10, '4': 1, '5': 13, '10': 'Unknown10'},
|
||||
{'1': 'Unknown13', '3': 13, '4': 1, '5': 13, '10': 'Unknown13'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `DeviceInformationContent`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List deviceInformationContentDescriptor = $convert.base64Decode(
|
||||
'ChhEZXZpY2VJbmZvcm1hdGlvbkNvbnRlbnQSGgoIVW5rbm93bjEYASABKA1SCFVua25vd24xEi'
|
||||
'gKD1NvZnR3YXJlVmVyc2lvbhgCIAMoDVIPU29mdHdhcmVWZXJzaW9uEh4KCkRldmljZU5hbWUY'
|
||||
'AyABKAlSCkRldmljZU5hbWUSGgoIVW5rbm93bjQYBCABKA1SCFVua25vd240EhoKCFVua25vd2'
|
||||
'41GAUgASgNUghVbmtub3duNRIiCgxTZXJpYWxOdW1iZXIYBiABKAlSDFNlcmlhbE51bWJlchIo'
|
||||
'Cg9IYXJkd2FyZVZlcnNpb24YByABKAlSD0hhcmR3YXJlVmVyc2lvbhIcCglSZXBseURhdGEYCC'
|
||||
'ADKA1SCVJlcGx5RGF0YRIaCghVbmtub3duORgJIAEoDVIIVW5rbm93bjkSHAoJVW5rbm93bjEw'
|
||||
'GAogASgNUglVbmtub3duMTASHAoJVW5rbm93bjEzGA0gASgNUglVbmtub3duMTM=');
|
||||
|
||||
@$core.Deprecated('Use subContentDescriptor instead')
|
||||
const SubContent$json = {
|
||||
'1': 'SubContent',
|
||||
'2': [
|
||||
{'1': 'Content', '3': 1, '4': 1, '5': 11, '6': '.de.jonasbark.DeviceInformationContent', '10': 'Content'},
|
||||
{'1': 'Unknown2', '3': 2, '4': 1, '5': 13, '10': 'Unknown2'},
|
||||
{'1': 'Unknown4', '3': 4, '4': 1, '5': 13, '10': 'Unknown4'},
|
||||
{'1': 'Unknown5', '3': 5, '4': 1, '5': 13, '10': 'Unknown5'},
|
||||
{'1': 'Unknown6', '3': 6, '4': 1, '5': 13, '10': 'Unknown6'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SubContent`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List subContentDescriptor = $convert.base64Decode(
|
||||
'CgpTdWJDb250ZW50EkAKB0NvbnRlbnQYASABKAsyJi5kZS5qb25hc2JhcmsuRGV2aWNlSW5mb3'
|
||||
'JtYXRpb25Db250ZW50UgdDb250ZW50EhoKCFVua25vd24yGAIgASgNUghVbmtub3duMhIaCghV'
|
||||
'bmtub3duNBgEIAEoDVIIVW5rbm93bjQSGgoIVW5rbm93bjUYBSABKA1SCFVua25vd241EhoKCF'
|
||||
'Vua25vd242GAYgASgNUghVbmtub3duNg==');
|
||||
|
||||
@$core.Deprecated('Use deviceInformationDescriptor instead')
|
||||
const DeviceInformation$json = {
|
||||
'1': 'DeviceInformation',
|
||||
'2': [
|
||||
{'1': 'InformationId', '3': 1, '4': 1, '5': 13, '10': 'InformationId'},
|
||||
{'1': 'SubContent', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.SubContent', '10': 'SubContent'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `DeviceInformation`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List deviceInformationDescriptor = $convert.base64Decode(
|
||||
'ChFEZXZpY2VJbmZvcm1hdGlvbhIkCg1JbmZvcm1hdGlvbklkGAEgASgNUg1JbmZvcm1hdGlvbk'
|
||||
'lkEjgKClN1YkNvbnRlbnQYAiABKAsyGC5kZS5qb25hc2JhcmsuU3ViQ29udGVudFIKU3ViQ29u'
|
||||
'dGVudA==');
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: zwift.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
export 'zwift.pb.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/pages/unlock.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:prop/emulators/ftms_emulator.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
final FtmsEmulator ftmsEmulator = FtmsEmulator();
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult)
|
||||
@@ -26,9 +30,9 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftButtons.shiftUpLeft,
|
||||
ZwiftButtons.shiftUpRight,
|
||||
],
|
||||
);
|
||||
|
||||
bool _noLongerSendsEvents = false;
|
||||
) {
|
||||
ftmsEmulator.setScanResult(scanResult);
|
||||
}
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2;
|
||||
@@ -40,22 +44,42 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
String toString() {
|
||||
return "$name V2";
|
||||
}
|
||||
|
||||
bool get isUnlocked {
|
||||
final lastUnlock = propPrefs.getZwiftClickV2LastUnlock(scanResult.deviceId);
|
||||
if (lastUnlock == null) {
|
||||
return false;
|
||||
}
|
||||
return lastUnlock > DateTime.now().subtract(const Duration(days: 1));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) {
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2)) {
|
||||
_noLongerSendsEvents = true;
|
||||
Future<void> setupHandshake() async {
|
||||
if (isUnlocked) {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
ftmsEmulator.handleServices(services);
|
||||
await super.handleServices(services);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (!ftmsEmulator.processCharacteristic(characteristic, bytes)) {
|
||||
await super.processCharacteristic(characteristic, bytes);
|
||||
}
|
||||
return super.processData(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
final lastUnlockDate = propPrefs.getZwiftClickV2LastUnlock(scanResult.deviceId);
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Column(
|
||||
@@ -65,74 +89,125 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected && _noLongerSendsEvents)
|
||||
if (core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
if (isConnected && !core.settings.getShowOnboarding())
|
||||
if (isUnlocked && lastUnlockDate != null)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
).xSmall,
|
||||
),
|
||||
IconButton.link(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(Icons.lock_open_rounded, color: Colors.white),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
Flexible(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).unlock_unlockedUntilAroundDate(
|
||||
DateFormat('EEEE, HH:MM').format(lastUnlockDate.add(const Duration(days: 1))),
|
||||
),
|
||||
).xSmall,
|
||||
),
|
||||
Tooltip(
|
||||
tooltip: (c) => Text('Unlock again'),
|
||||
child: IconButton.ghost(
|
||||
icon: Icon(Icons.lock_reset_rounded),
|
||||
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => UnlockPage(device: this),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (kDebugMode)
|
||||
Button(
|
||||
onPressed: () {
|
||||
test();
|
||||
},
|
||||
leading: const Icon(Icons.translate_sharp),
|
||||
style: ButtonStyle.primary(size: ButtonSize.small),
|
||||
child: Text('Reset'),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2EventInfo,
|
||||
).xSmall,
|
||||
LinkButton(
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
);
|
||||
},
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(Icons.lock_rounded, color: Colors.white),
|
||||
),
|
||||
Flexible(child: Text(AppLocalizations.of(context).unlock_deviceIsCurrentlyLocked).xSmall),
|
||||
Button(
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => UnlockPage(device: this),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.lock_open_rounded),
|
||||
style: ButtonStyle.primary(size: ButtonSize.small),
|
||||
child: Text(AppLocalizations.of(context).unlock_unlockNow),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (kDebugMode && !isUnlocked)
|
||||
Button(
|
||||
onPressed: () {
|
||||
super.setupHandshake();
|
||||
},
|
||||
leading: const Icon(Icons.handshake),
|
||||
style: ButtonStyle.primary(size: ButtonSize.small),
|
||||
child: Text('Handshake'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
Button(
|
||||
onPressed: () {
|
||||
test();
|
||||
},
|
||||
leading: const Icon(Icons.translate_sharp),
|
||||
style: ButtonStyle.primary(size: ButtonSize.small),
|
||||
child: Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
/*else
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2EventInfo,
|
||||
).xSmall,
|
||||
LinkButton(
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),*/
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/single_line_exception.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@@ -87,10 +89,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
if (kDebugMode && false) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -152,10 +154,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
// the same messages are sent multiple times, so ignore
|
||||
if (_lastButtonsClicked == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
super.handleButtonsClicked(buttonsClicked);
|
||||
super.handleButtonsClicked(buttonsClicked, longPress: longPress);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
}
|
||||
@@ -191,4 +193,24 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (canVibrate)
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import 'dart:io';
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pbserver.dart' hide RideButtonMask;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
@@ -18,10 +15,13 @@ import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:prop/prop.dart' hide RideButtonMask;
|
||||
|
||||
class ZwiftEmulator extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
static const String connectionTitle = 'Zwift BLE Emulator';
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
@@ -32,7 +32,7 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
|
||||
ZwiftEmulator()
|
||||
: super(
|
||||
title: 'Zwift BLE Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
@@ -161,7 +161,7 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
);
|
||||
|
||||
final request = eventArgs.request;
|
||||
final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
|
||||
final response = SharedLogic.handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
|
||||
if (response != null) {
|
||||
await _peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
@@ -298,6 +298,8 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
}
|
||||
|
||||
Future<void> stopAdvertising() async {
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
@@ -306,7 +308,7 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (isConnected.value) {
|
||||
if (isConnected.value && _central != null) {
|
||||
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero);
|
||||
_sendKeepAlive();
|
||||
@@ -360,103 +362,14 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
return Success('Sent action: ${keyPair.inGameAction!.name}');
|
||||
}
|
||||
|
||||
Uint8List? handleWriteRequest(String characteristic, Uint8List value) {
|
||||
print(
|
||||
'Write request for characteristic: $characteristic',
|
||||
);
|
||||
|
||||
switch (characteristic.toUpperCase()) {
|
||||
case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID:
|
||||
print(
|
||||
'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}',
|
||||
);
|
||||
|
||||
Opcode? opcode = Opcode.valueOf(value[0]);
|
||||
Uint8List message = value.sublist(1);
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
print('Sending handshake');
|
||||
return ZwiftConstants.RIDE_ON;
|
||||
case Opcode.GET:
|
||||
final response = Get.fromBuffer(message);
|
||||
final dataObjectType = DO.valueOf(response.dataObjectId);
|
||||
print('Received GET for data object: $dataObjectType');
|
||||
switch (dataObjectType) {
|
||||
case DO.PAGE_DEV_INFO:
|
||||
/*final devInfo = DevInfoPage(
|
||||
deviceName: 'Zwift Click'.codeUnits,
|
||||
deviceUid: '0B-58D15ABB4363'.codeUnits,
|
||||
manufacturerId: 0x01,
|
||||
serialNumber: '58D15ABB4363'.codeUnits,
|
||||
protocolVersion: 515,
|
||||
systemFwVersion: [0, 0, 1, 1],
|
||||
productId: 11,
|
||||
systemHwRevision: 'B.0'.codeUnits,
|
||||
deviceCapabilities: [DevInfoPage_DeviceCapabilities(deviceType: 2, capabilities: 1)],
|
||||
);
|
||||
final serverInfoResponse = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_DEV_INFO.value,
|
||||
dataObjectData: devInfo.writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);*/
|
||||
// 3C080012460A440883041204000001011A0B5A7769667420436C69636B320F30422D3538443135414242343336333A03422E304204080210014801500B5A0C353844313541424234333633
|
||||
final expected = Uint8List.fromList(
|
||||
hexToBytes(
|
||||
'3C080012460A440883041204000001011A0B5A7769667420436C69636B320F30422D3538443135414242343336333A03422E304204080210014801500B5A0C353844313541424234333633',
|
||||
),
|
||||
);
|
||||
return expected;
|
||||
case DO.PAGE_CLIENT_SERVER_CONFIGURATION:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_CLIENT_SERVER_CONFIGURATION.value,
|
||||
dataObjectData: ClientServerCfgPage(
|
||||
notifications: 0,
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
case DO.PAGE_CONTROLLER_INPUT_CONFIG:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_CONTROLLER_INPUT_CONFIG.value,
|
||||
dataObjectData: ControllerInputConfigPage(
|
||||
supportedDigitalInputs: 4607,
|
||||
supportedAnalogInputs: 0,
|
||||
analogDeadZone: [],
|
||||
analogInputRange: [],
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
case DO.BATTERY_STATE:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.BATTERY_STATE.value,
|
||||
dataObjectData: BatteryStatus(
|
||||
chgState: ChargingState.CHARGING_IDLE,
|
||||
percLevel: 100,
|
||||
timeToEmpty: 0,
|
||||
timeToFull: 0,
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
default:
|
||||
print('Unhandled data object type for GET: $dataObjectType');
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: $characteristic $value');
|
||||
}
|
||||
return null;
|
||||
void cleanup() {
|
||||
_peripheralManager.stopAdvertising();
|
||||
_peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isSubscribedToEvents = false;
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
final ZwiftDeviceType deviceType;
|
||||
|
||||
ZwiftPlay(super.scanResult, {required this.deviceType})
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButtons.y,
|
||||
ZwiftButtons.z,
|
||||
ZwiftButtons.a,
|
||||
ZwiftButtons.b,
|
||||
ZwiftButtons.onOffRight,
|
||||
ZwiftButtons.sideButtonRight,
|
||||
ZwiftButtons.paddleRight,
|
||||
ZwiftButtons.navigationUp,
|
||||
ZwiftButtons.navigationLeft,
|
||||
ZwiftButtons.navigationRight,
|
||||
ZwiftButtons.navigationDown,
|
||||
ZwiftButtons.onOffLeft,
|
||||
ZwiftButtons.sideButtonLeft,
|
||||
ZwiftButtons.paddleLeft,
|
||||
if (deviceType == ZwiftDeviceType.playLeft) ...[
|
||||
ZwiftButtons.navigationUp,
|
||||
ZwiftButtons.navigationLeft,
|
||||
ZwiftButtons.navigationRight,
|
||||
ZwiftButtons.navigationDown,
|
||||
ZwiftButtons.onOffLeft,
|
||||
ZwiftButtons.sideButtonLeft,
|
||||
ZwiftButtons.paddleLeft,
|
||||
],
|
||||
if (deviceType == ZwiftDeviceType.playRight) ...[
|
||||
ZwiftButtons.y,
|
||||
ZwiftButtons.z,
|
||||
ZwiftButtons.a,
|
||||
ZwiftButtons.b,
|
||||
ZwiftButtons.onOffRight,
|
||||
ZwiftButtons.sideButtonRight,
|
||||
ZwiftButtons.paddleRight,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -34,6 +38,9 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
@override
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
String get name => '${super.name} (${deviceType.name.splitByUpperCase().split(' ').last})';
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
@@ -62,23 +69,4 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.3.1';
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class ZwiftRide extends ZwiftDevice {
|
||||
@@ -56,7 +53,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'${DateTime.now().toString().split(" ").last} Received $opcode: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')} => ${String.fromCharCodes(bytes)} ',
|
||||
'${DateTime.now().toString().split(" ").last} Received $opcode: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,41 +97,12 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
break;
|
||||
case null:
|
||||
final vendorDO = VendorDO.valueOf(response.dataObjectId);
|
||||
if (kDebugMode) {
|
||||
print('VendorDO: $vendorDO');
|
||||
}
|
||||
switch (vendorDO) {
|
||||
case VendorDO.DEVICE_COUNT:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case VendorDO.NO_CLUE:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case VendorDO.PAGE_DEVICE_PAIRING:
|
||||
final page = DevicePairingDataPage.fromBuffer(response.dataObjectData);
|
||||
if (kDebugMode) {
|
||||
// this should show the right click device
|
||||
// pairingStatus = 1 => connected and paired, otherwise it can be paired but not connected
|
||||
print(
|
||||
'PageDevicePairing: $page => ${page.pairingDevList.map((e) => e.device.reversed.map((d) => d.toRadixString(16).padLeft(2, '0'))).join(', ')}',
|
||||
);
|
||||
}
|
||||
break;
|
||||
case VendorDO.PAIRED_DEVICE:
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case VendorDO.PAIRING_STATUS:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case Opcode.VENDOR_MESSAGE:
|
||||
final vendorOpCode = VendorOpcode.valueOf(message.second);
|
||||
print('VendorOpcode: $vendorOpCode');
|
||||
break;
|
||||
case Opcode.LOG_DATA:
|
||||
final logMessage = LogDataNotification.fromBuffer(message);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:prop/prop.dart';
|
||||
|
||||
class BaseNotification {}
|
||||
|
||||
@@ -11,9 +11,7 @@ class LogNotification extends BaseNotification {
|
||||
final String message;
|
||||
|
||||
LogNotification(this.message) {
|
||||
if (kDebugMode) {
|
||||
print('LogNotification: $message');
|
||||
}
|
||||
Logger.debug('LogNotification: $message');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -68,8 +66,10 @@ class ActionNotification extends BaseNotification {
|
||||
class AlertNotification extends LogNotification {
|
||||
final LogLevel level;
|
||||
final String alertMessage;
|
||||
final VoidCallback? onTap;
|
||||
final String? buttonTitle;
|
||||
|
||||
AlertNotification(this.level, this.alertMessage) : super(alertMessage);
|
||||
AlertNotification(this.level, this.alertMessage, {this.onTap, this.buttonTitle}) : super(alertMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user