mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
568 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
383055bfe2 | ||
|
|
24212e8e4c | ||
|
|
1513a53dd4 | ||
|
|
fe9dd29964 | ||
|
|
eece2bcc0f | ||
|
|
3ca63c0523 | ||
|
|
b46982918d | ||
|
|
13b075a1f3 | ||
|
|
f71a417ac5 | ||
|
|
27065f2906 | ||
|
|
b5d938fd47 | ||
|
|
46300fc0d4 | ||
|
|
93882b8b36 | ||
|
|
f19c6b8dd0 | ||
|
|
968e2c5928 | ||
|
|
44599b2d33 | ||
|
|
613f75fd25 | ||
|
|
c09ab5482e | ||
|
|
6f68e6cb62 | ||
|
|
43ac412efd | ||
|
|
7149c98564 | ||
|
|
0f4e46a758 | ||
|
|
23fb927cd6 | ||
|
|
d055a260ab | ||
|
|
d55fa5f7c0 | ||
|
|
5cc49bd246 | ||
|
|
c3c49decd1 | ||
|
|
127c997ea1 | ||
|
|
724d52ba10 | ||
|
|
5cee0fbf55 | ||
|
|
e44760d0e3 | ||
|
|
29be3c4411 | ||
|
|
0eba068910 | ||
|
|
04dee3f14c | ||
|
|
f4fd658c36 | ||
|
|
0e80d5612c | ||
|
|
9302ebc667 | ||
|
|
2265866f58 | ||
|
|
8ec6ee5ef0 | ||
|
|
a03d250bdb | ||
|
|
a0ebac41ea | ||
|
|
b4b3f5db67 | ||
|
|
b291f59e10 | ||
|
|
02de453952 | ||
|
|
4c53f6e408 | ||
|
|
4f4d67cccc | ||
|
|
ef056f0503 | ||
|
|
a6f5755b42 | ||
|
|
5ce6e37973 | ||
|
|
ebebd7ad8b | ||
|
|
f6c47e3dab | ||
|
|
de711e12dc | ||
|
|
b94fed2f21 | ||
|
|
9316881048 | ||
|
|
c60a990938 | ||
|
|
e9aaa96185 | ||
|
|
cb497daee4 | ||
|
|
4881fe4778 | ||
|
|
5d5d8ffb18 | ||
|
|
b707812a7e | ||
|
|
9de50dc6fc | ||
|
|
11d308b53b | ||
|
|
0e03ec4a03 | ||
|
|
68a04fad96 | ||
|
|
ce75fd0f34 | ||
|
|
d46b71b2d0 | ||
|
|
6492afc46f | ||
|
|
c9b068e1b3 | ||
|
|
5cdf15a419 | ||
|
|
24db720927 | ||
|
|
94754d3d9b | ||
|
|
bffdae1a9b | ||
|
|
a8b68c2d89 | ||
|
|
84f70f13d8 | ||
|
|
ef1048ec08 | ||
|
|
37bc2110f5 | ||
|
|
84fd828d36 | ||
|
|
a51b4d7958 | ||
|
|
117467d708 | ||
|
|
789509f9cf | ||
|
|
a323dc213d | ||
|
|
0ec998a618 | ||
|
|
0f5e9d59a8 | ||
|
|
2280fda916 | ||
|
|
8d4db788a3 | ||
|
|
c2bfc472fe | ||
|
|
09ffd258b7 | ||
|
|
4ae92ca557 | ||
|
|
e066054681 | ||
|
|
a0f4aadd37 | ||
|
|
3566dbc37c | ||
|
|
73a23e06ba | ||
|
|
a03576d415 | ||
|
|
079db14127 | ||
|
|
2f4764a01f | ||
|
|
2671a9807b | ||
|
|
da46deb495 | ||
|
|
0700bd331f | ||
|
|
14676d9277 | ||
|
|
3f9c3611ec | ||
|
|
26263f4d6b | ||
|
|
105a644599 | ||
|
|
ba54234734 | ||
|
|
d39af2c8ff | ||
|
|
eb07b78cce | ||
|
|
f9a13a90a8 | ||
|
|
6302091c54 | ||
|
|
1647fe9818 | ||
|
|
3ece6cb4db | ||
|
|
b04732cc24 | ||
|
|
828819907b | ||
|
|
c8c449d2ef | ||
|
|
6b5c202e93 | ||
|
|
56c67ae9a5 | ||
|
|
94d9467bc3 | ||
|
|
fb65616cee | ||
|
|
7e488b3cb1 | ||
|
|
9c6084397e | ||
|
|
73a0fe6203 | ||
|
|
cac1872459 | ||
|
|
65f4ca6356 | ||
|
|
d771b3da57 | ||
|
|
d52a062e0c | ||
|
|
ef350398e1 | ||
|
|
69fa8834ee | ||
|
|
ac9bbd3986 | ||
|
|
6f99f0762a | ||
|
|
999fc3faba | ||
|
|
ebd33666fc | ||
|
|
8537c5b8c3 | ||
|
|
3ff59eff6e | ||
|
|
46752de0e8 | ||
|
|
27629db924 | ||
|
|
1f00fd9d6c | ||
|
|
637bf87dad | ||
|
|
41415432db | ||
|
|
745d26dade | ||
|
|
134de0fcd9 | ||
|
|
ee0c4c083f | ||
|
|
5873ce34e4 | ||
|
|
9904999dd2 | ||
|
|
f5a35ad04b | ||
|
|
7301734b82 | ||
|
|
19ada28cf3 | ||
|
|
7655b35b77 | ||
|
|
0c200eae20 | ||
|
|
18d6c9ec1f | ||
|
|
cc16a2c8de | ||
|
|
91a6502310 | ||
|
|
bb8720247e | ||
|
|
b01470339b | ||
|
|
855fe00346 | ||
|
|
f957258a2c | ||
|
|
945a247274 | ||
|
|
59ecfe5186 | ||
|
|
dc5e9939c7 | ||
|
|
79821783e2 | ||
|
|
531e53eaec | ||
|
|
4f01f23458 | ||
|
|
3760b84fb7 | ||
|
|
afdb442597 | ||
|
|
eadcd17bbb | ||
|
|
67ca63b047 | ||
|
|
17d3450bd2 | ||
|
|
d7c700a4e5 | ||
|
|
fd659e40fe | ||
|
|
bdd7c75ebd | ||
|
|
ca5648fec4 | ||
|
|
978325ceff | ||
|
|
87ad9c630d | ||
|
|
6b0ca1b6dd | ||
|
|
3c9a505f4f | ||
|
|
7bb4ace875 | ||
|
|
499cd86955 | ||
|
|
79bad50ce3 | ||
|
|
2dff9c92fd | ||
|
|
d05557819e | ||
|
|
22a39406ff | ||
|
|
91d0fd656e | ||
|
|
4440a04ab8 | ||
|
|
5bf617d1db | ||
|
|
3cf209edd9 | ||
|
|
ad95ce23ec | ||
|
|
9e29d5dd04 | ||
|
|
4678ac0255 | ||
|
|
8660f979b9 | ||
|
|
c20265ddf8 | ||
|
|
2b90606d9c | ||
|
|
c75fd8ab80 | ||
|
|
fc13d71960 | ||
|
|
354179de30 | ||
|
|
e4ea924b1a | ||
|
|
f9b5dda123 | ||
|
|
4cd38d4502 | ||
|
|
70c9a5b6a3 | ||
|
|
4fb46de074 | ||
|
|
b0487f534c | ||
|
|
719eb9b50f | ||
|
|
f3148405d7 | ||
|
|
6da13f18af | ||
|
|
b3b6f510d8 | ||
|
|
01400ab955 | ||
|
|
8348848ef8 | ||
|
|
04920e7eee | ||
|
|
464c5208dc | ||
|
|
981254875b | ||
|
|
d941135fff | ||
|
|
28663f5cdf | ||
|
|
bfc12711ad | ||
|
|
076e729e39 | ||
|
|
570f5ca82d | ||
|
|
9749a9018d | ||
|
|
4724f55e6b | ||
|
|
8f68636bfc | ||
|
|
fefcb1db53 | ||
|
|
98fd5c5d7c | ||
|
|
e97a76e488 | ||
|
|
02f25899e9 | ||
|
|
3a0ef5110d | ||
|
|
36c5df7d03 | ||
|
|
4252ed05ae | ||
|
|
2adf3026fd | ||
|
|
2cc705c556 | ||
|
|
faff8ab219 | ||
|
|
e63e647ebc | ||
|
|
e2339321bb | ||
|
|
f7c57b1f15 | ||
|
|
a1fad509cf | ||
|
|
daec33f827 | ||
|
|
cf66845a38 | ||
|
|
7199f5b545 | ||
|
|
0490956551 | ||
|
|
8be2108cdc | ||
|
|
4595e509c9 | ||
|
|
17d7953d11 | ||
|
|
4adfe0812d | ||
|
|
7ca9c8752b | ||
|
|
ab53d23404 | ||
|
|
9894271145 | ||
|
|
5d9960156c | ||
|
|
aa6782d29b | ||
|
|
98d683a6a5 | ||
|
|
55a2e4db79 | ||
|
|
4db985e2e5 | ||
|
|
bbd95beb36 | ||
|
|
a9ee0dc9a1 | ||
|
|
46d3770a28 | ||
|
|
99ee63ce1f | ||
|
|
cb10ad685e | ||
|
|
748a21fb54 | ||
|
|
b3ffe867c6 | ||
|
|
fb1ffec37d | ||
|
|
9ea4f7157a | ||
|
|
a9b43bd347 | ||
|
|
b9ac193e77 | ||
|
|
1d4947b3ae | ||
|
|
74e098e9b1 | ||
|
|
e5dae225f1 | ||
|
|
0339089972 | ||
|
|
1e8bd61264 | ||
|
|
79613bc8de | ||
|
|
d0ec785e32 | ||
|
|
020b91fd21 | ||
|
|
f2406152fd | ||
|
|
ab3ef7be53 | ||
|
|
bb7484ff2e | ||
|
|
80061fd076 | ||
|
|
124e005fb1 | ||
|
|
8e760ef202 | ||
|
|
de740f6453 | ||
|
|
3bde90ae62 | ||
|
|
aee8dc2e07 | ||
|
|
b3952542f8 | ||
|
|
910f23a3f6 | ||
|
|
5cc9ac85af | ||
|
|
e10e22d038 | ||
|
|
aaeaec36a2 | ||
|
|
c16a593f3c | ||
|
|
50d9f47576 | ||
|
|
a53fb578ef | ||
|
|
1f89859a03 | ||
|
|
7c1cee6748 | ||
|
|
a4949ad615 | ||
|
|
a57f4654b0 | ||
|
|
8192d3addf | ||
|
|
308f461ad4 | ||
|
|
513b2ba367 | ||
|
|
69f47fa984 | ||
|
|
4daf553514 | ||
|
|
aeae148e0b | ||
|
|
1a9a265671 | ||
|
|
b06e9ad4b3 | ||
|
|
7ecce43a3d | ||
|
|
82cb41c207 | ||
|
|
c356242a8f | ||
|
|
61ad7c2eef | ||
|
|
311104ddf0 | ||
|
|
2b106fd1c9 | ||
|
|
1784e008ee | ||
|
|
33ccdbd7af | ||
|
|
d15f1ddc13 | ||
|
|
e7ea01cd60 | ||
|
|
f7ed426441 | ||
|
|
d30485b82e | ||
|
|
4e646ab922 | ||
|
|
4183ede58d | ||
|
|
dd85e99e4b | ||
|
|
2334a88452 | ||
|
|
ee64b18f75 | ||
|
|
647dac9e7c | ||
|
|
df2496eb67 | ||
|
|
859424b895 | ||
|
|
dde3f38bde |
154
.github/workflows/build.yml
vendored
154
.github/workflows/build.yml
vendored
@@ -36,12 +36,12 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.7
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Release
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -53,6 +53,10 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Install certificates
|
||||
if: inputs.build_mac || inputs.build_ios
|
||||
env:
|
||||
@@ -102,12 +106,24 @@ jobs:
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Release macOS
|
||||
if: inputs.build_mac
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
args: "-- --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
|
||||
@@ -121,29 +137,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: android
|
||||
args: "--artifact=apk"
|
||||
|
||||
- name: Set Up Flutter
|
||||
if: inputs.build_web
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Build Web
|
||||
if: 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: "-- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
|
||||
|
||||
- name: Extract latest changelog
|
||||
id: changelog
|
||||
@@ -152,13 +146,19 @@ jobs:
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: Generate release body
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
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
|
||||
@@ -176,7 +176,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
|
||||
@@ -185,8 +185,8 @@ jobs:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
run: |
|
||||
productbuild --component "build/macos/Build/Products/Release/SwiftControl.app" /Applications "SwiftControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
productbuild --component "build/macos/Build/Products/Release/BikeControl.app" /Applications "BikeControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f BikeControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Upload to iOS App Store
|
||||
if: inputs.build_ios
|
||||
@@ -196,41 +196,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
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Handle macOS archives
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: inputs.build_android && inputs.build_github
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
@@ -245,29 +210,47 @@ jobs:
|
||||
if: inputs.build_github
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: inputs.build_windows
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2025
|
||||
|
||||
steps:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Release Windows
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
@@ -296,7 +279,7 @@ jobs:
|
||||
Write-Warning "$dll not found in $source"
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/bike_control.windows.zip"
|
||||
|
||||
- uses: microsoft/setup-msstore-cli@v1
|
||||
if: false
|
||||
@@ -305,12 +288,6 @@ jobs:
|
||||
if: false
|
||||
run: msstore reconfigure --tenantId $ --clientId $ --clientSecret $ --sellerId $
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Create MSIX package
|
||||
run: dart run msix:create
|
||||
|
||||
@@ -318,35 +295,10 @@ jobs:
|
||||
if: false
|
||||
run: msstore publish -v "build/windows/x64/runner/Release/"
|
||||
|
||||
- name: Rename swift_control.msix to SwiftControl.windows.msix
|
||||
shell: pwsh
|
||||
run: |
|
||||
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "SwiftControl.windows.msix"
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.zip
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.msix
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
build/windows/x64/runner/Release/bike_control.msix
|
||||
|
||||
63
.github/workflows/patch.yml
vendored
63
.github/workflows/patch.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.5
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -27,6 +27,17 @@ jobs:
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: Install certificates
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
|
||||
@@ -80,58 +91,62 @@ jobs:
|
||||
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 -- --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'
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
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 }}'
|
||||
|
||||
# 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 SwiftControl.app -v;
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/;
|
||||
/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/SwiftControl.macos.zip
|
||||
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/SwiftControl.macos.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
artifacts: "build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
@@ -145,18 +160,22 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Patch Windows
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
|
||||
6
.github/workflows/web.yml
vendored
6
.github/workflows/web.yml
vendored
@@ -34,6 +34,12 @@ jobs:
|
||||
with:
|
||||
channel: 'stable'
|
||||
|
||||
- name: Generate translation files
|
||||
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
#4 Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,10 +41,15 @@ app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
localazy.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
lib/gen/
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
lib/generated
|
||||
|
||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"name": "swiftcontrol",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (profile mode)",
|
||||
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@@ -1,3 +1,90 @@
|
||||
### 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**:
|
||||
- control your trainer manually without requiring a controller - just like a Companion app
|
||||
- support for Wahoo KICKR HEADWIND: control the fan via your controller
|
||||
|
||||
**Fixes**:
|
||||
- Gamepads: handle analog values correctly on Windows
|
||||
- MyWhoosh: updated default keymap to use the new A+D keys for steering
|
||||
|
||||
### 4.0.0 (07-12-2025)
|
||||
|
||||
- a brand-new design
|
||||
- Accessibility Permission is now optional on Android
|
||||
- Zwift is now fully supported on all operating systems
|
||||
- you can choose between network based control or bluetooth based control
|
||||
- MyWhoosh can now also be controlled with BikeControl running on the same iPad / iPhone
|
||||
- Translations available in German and French
|
||||
- support for Wahoo KICKR BIKE PRO
|
||||
- support for the OpenBikeControl protocol for supported Trainer apps
|
||||
- this enables seamless and official integration, independent of the operating system
|
||||
- learn more at https://openbikecontrol.org
|
||||
|
||||
### 3.6.0 (23-11-2025)
|
||||
|
||||
SwiftControl is now called BikeControl!
|
||||
|
||||
**Features:**
|
||||
- show a list of predefined keymaps for the selected trainer app when using a custom keymap
|
||||
- status icons so it's clear what's missing
|
||||
|
||||
**Fixes:**
|
||||
- Update Rouvy keymap to support virtual shifting in their latest version
|
||||
|
||||
### 3.5.0 (16-11-2025)
|
||||
**New Features:**
|
||||
- Dark mode support
|
||||
- Cycplus BC2 support (thanks @schneewoehner)
|
||||
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
|
||||
|
||||
**Fixes:**
|
||||
- resolve issues during app start
|
||||
|
||||
### 3.4.0 (08-11-2025)
|
||||
**New Features:**
|
||||
- Support for Shimano Di2
|
||||
@@ -7,7 +94,7 @@
|
||||
|
||||
**Fixes:**
|
||||
- fix detection of Elite Square Sterzo devices
|
||||
- recognize cheap Bluetooth device clicks also when SwiftControl is in the background
|
||||
- recognize cheap Bluetooth device clicks also when BikeControl is in the background
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
@@ -29,7 +116,7 @@
|
||||
### 3.2.0 (2025-10-22)
|
||||
- a brand-new way of controlling MyWhoosh:
|
||||
- device pairing no longer required as mouse emulation is no longer needed
|
||||
- SwiftControl can now stay in the background
|
||||
- BikeControl can now stay in the background
|
||||
- more devices can be controlled
|
||||
- do more, such as define Emotes, Camera angles and steering
|
||||
|
||||
@@ -39,16 +126,16 @@
|
||||
- support for Wahook Kickr Bike Shift (thanks @MattW2)
|
||||
- initial support for Elite Square Smart Frame
|
||||
- reconnects to your device automatically when connection is lost
|
||||
- SwiftControl now warns you if your device firmware is outdated
|
||||
- SwiftControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
- BikeControl now warns you if your device firmware is outdated
|
||||
- BikeControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
|
||||
### 3.0.3 (2025-10-12)
|
||||
- SwiftControl now supports iOS!
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- BikeControl now supports iOS!
|
||||
- Note that you can't run BikeControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use BikeControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs BikeControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed)
|
||||
- after pairing BikeControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- Ride: analog paddles are now supported thanks to contributor @jmoro
|
||||
- you can now zoom in and out in the Keymap customization screen
|
||||
|
||||
@@ -68,8 +155,8 @@
|
||||
|
||||
### 2.5.0 (2025-09-25)
|
||||
- Improve usability
|
||||
- SwiftControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- SwiftControl will continue to be available to download for free on GitHub
|
||||
- BikeControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- BikeControl will continue to be available to download for free on GitHub
|
||||
- contact me if you already donated and I'll get a voucher for you :)
|
||||
|
||||
### 2.4.0+1 (2025-09-17)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Instructions will be added soon
|
||||
@@ -1,12 +1 @@
|
||||
**Instructions for using the MyWhoosh Direct Connect method**
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) close MyWhoosh Link
|
||||
4) open SwiftControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in SwiftControl 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:
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
Moved to [INSTRUCTIONS_MYWHOOSH_LINK.md](INSTRUCTIONS_MYWHOOSH_LINK.md)
|
||||
|
||||
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 +0,0 @@
|
||||
Instructions will be added soon
|
||||
38
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
38
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
|
||||
3) open BikeControl, follow the on-screen instructions
|
||||
|
||||
|
||||
Here's a video with a few explanations. Note 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)
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
|
||||
|
||||
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 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.
|
||||
|
||||
Official MyWhoosh troubleshooting links:
|
||||
- https://mywhoosh.com/troubleshoot/
|
||||
- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Remote control is not working - nothing happens
|
||||
*
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in BikeControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
*
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
4
INSTRUCTIONS_ROUVY.md
Normal file
4
INSTRUCTIONS_ROUVY.md
Normal file
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
Instructions will be added soon
|
||||
0
INSTRUCTIONS_ZWIFT.md
Normal file
0
INSTRUCTIONS_ZWIFT.md
Normal file
94
README.md
94
README.md
@@ -1,15 +1,15 @@
|
||||
# SwiftControl
|
||||
# BikeControl (formerly SwiftControl)
|
||||
|
||||
<img src="logo.png" alt="SwiftControl Logo"/>
|
||||
<img src="logo.png" alt="BikeControl Logo"/>
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
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 SwiftControl
|
||||
- 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
|
||||
@@ -17,8 +17,8 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
|
||||
## Downloads
|
||||
Check the compatibility matrix below!
|
||||
## 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>
|
||||
|
||||
@@ -30,11 +30,11 @@ Check the compatibility matrix below!
|
||||
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- Zwift
|
||||
- TrainingPeaks Virtual
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- Zwift
|
||||
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
|
||||
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
|
||||
- any other!
|
||||
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
|
||||
@@ -45,57 +45,61 @@ Check the compatibility matrix below!
|
||||
- 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)
|
||||
- Gamepads (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)
|
||||
- BLE HID devices and classic Bluetooth HID devices are supported
|
||||
- works on Android
|
||||
- on iOS and macOS requires SwiftControl to act as media player
|
||||
- works out of the box on Android
|
||||
- on Windows, iOS and macOS requires BikeControl to act as media player
|
||||
- We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps
|
||||
- register your interest [here](https://openbikecontrol.org/#HARDWARE)
|
||||
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Accessories
|
||||
- Wahoo KICKR HEADWIND (beta)
|
||||
- control fan speed using your controller
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
|
||||
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.
|
||||
|
||||
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically, you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
|
||||
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
|
||||
|
||||
|
||||
For testing purposes, you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/), but this is just a tech demo - you won't be able to control other apps.
|
||||
|
||||
## Troubleshooting
|
||||
## Help
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- If you want to use MyWhoosh, you can use the Link method to directly connect to MyWhoosh
|
||||
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- There are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- You can also create your own Keymaps for any other app
|
||||
- You can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
|
||||
## 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.
|
||||
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, 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, 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 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 :)
|
||||
|
||||
- [via PayPal](https://paypal.me/boni)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc. (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc. (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
|
||||
|
||||
@@ -1,53 +1,38 @@
|
||||
## Click device cannot be found
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
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.
|
||||
|
||||
## Click device does not send any data
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
## My Click v2 disconnects after a minute or buttons do not work
|
||||
*
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
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.
|
||||
If you don't do that SwiftControl will need to reconnect every minute.
|
||||
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. Close the Zwift app again and connect again in SwiftControl
|
||||
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/jonasbark/swiftcontrol/issues/68
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
*
|
||||
- especially for Redmi and other chinese Android devices please follow the instructions on [https://dontkillmyapp.com/](https://dontkillmyapp.com/):
|
||||
- disable battery optimization for SwiftControl
|
||||
- enable auto start of SwiftControl
|
||||
- grant accessibility permission for SwiftControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/jonasbark/swiftcontrol/issues/38) for more details
|
||||
- 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
|
||||
|
||||
## Remote control is not working - nothing happens
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in SwiftControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in SwiftControl / restart SwiftControl.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or SwiftControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
|
||||
## SwiftControl crashes on Windows when searching for the device
|
||||
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
## MyWhoosh Direct Connect never connects
|
||||
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
|
||||
|
||||
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
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
*
|
||||
Make sure you've enabled Virtual Shifting in MyWhoosh's settings
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.0
|
||||
4.4.1
|
||||
|
||||
@@ -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,
|
||||
@@ -129,6 +146,43 @@ data class WindowEvent (
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class AKeyEvent (
|
||||
val source: String,
|
||||
val hidKey: String,
|
||||
val keyDown: Boolean,
|
||||
val keyUp: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): AKeyEvent {
|
||||
val source = pigeonVar_list[0] as String
|
||||
val hidKey = pigeonVar_list[1] as String
|
||||
val keyDown = pigeonVar_list[2] as Boolean
|
||||
val keyUp = pigeonVar_list[3] as Boolean
|
||||
return AKeyEvent(source, hidKey, keyDown, keyUp)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
source,
|
||||
hidKey,
|
||||
keyDown,
|
||||
keyUp,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is AKeyEvent) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -138,10 +192,20 @@ 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)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AKeyEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -151,8 +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(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -167,8 +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. */
|
||||
@@ -231,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) {
|
||||
@@ -249,6 +342,21 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.isRunning$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isRunning())
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
@@ -265,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,14 +444,14 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
|
||||
}
|
||||
}
|
||||
|
||||
abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper<String> {
|
||||
abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper<AKeyEvent> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: HidKeyPressedStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = AccessibilityApiPigeonStreamHandler<String>(streamHandler)
|
||||
val internalStreamHandler = AccessibilityApiPigeonStreamHandler<AKeyEvent>(streamHandler)
|
||||
EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jonasbark.accessibility
|
||||
|
||||
import AKeyEvent
|
||||
import Accessibility
|
||||
import GlobalAction
|
||||
import HidKeyPressedStreamHandler
|
||||
import MediaAction
|
||||
import PigeonEventSink
|
||||
@@ -51,6 +53,10 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
return enabledServices != null && enabledServices.contains(context.packageName)
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return Observable.toService != null
|
||||
}
|
||||
|
||||
override fun openPermissions() {
|
||||
startActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
@@ -61,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) {
|
||||
@@ -85,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 {
|
||||
@@ -112,9 +127,9 @@ class WindowEventListener : StreamEventsStreamHandler(), Receiver {
|
||||
|
||||
class HidEventListener : HidKeyPressedStreamHandler(), Receiver {
|
||||
|
||||
private var keyEventSink: PigeonEventSink<String>? = null
|
||||
private var keyEventSink: PigeonEventSink<AKeyEvent>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: PigeonEventSink<String>) {
|
||||
override fun onListen(p0: Any?, sink: PigeonEventSink<AKeyEvent>) {
|
||||
keyEventSink = sink
|
||||
}
|
||||
|
||||
@@ -124,6 +139,13 @@ class HidEventListener : HidKeyPressedStreamHandler(), Receiver {
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent) {
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
keyEventSink?.success(keyString)
|
||||
keyEventSink?.success(
|
||||
AKeyEvent(
|
||||
hidKey = keyString,
|
||||
source = event.device.name,
|
||||
keyUp = event.action == KeyEvent.ACTION_UP,
|
||||
keyDown = event.action == KeyEvent.ACTION_DOWN
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +71,17 @@ 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}"
|
||||
)
|
||||
|
||||
// Forward key events to the plugin (Flutter) and swallow them so they don't propagate.
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
Observable.fromServiceKeys?.onKeyEvent(event)
|
||||
}
|
||||
Observable.fromServiceKeys?.onKeyEvent(event)
|
||||
// Return true to indicate we've handled the event and it should be swallowed.
|
||||
return true
|
||||
} else {
|
||||
@@ -97,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,13 +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;
|
||||
@@ -31,8 +48,17 @@ class WindowEvent {
|
||||
});
|
||||
}
|
||||
|
||||
class AKeyEvent {
|
||||
final String source;
|
||||
final String hidKey;
|
||||
final bool keyDown;
|
||||
final bool keyUp;
|
||||
|
||||
AKeyEvent({required this.source, required this.hidKey, required this.keyDown, required this.keyUp});
|
||||
}
|
||||
|
||||
@EventChannelApi()
|
||||
abstract class EventChannelMethods {
|
||||
WindowEvent streamEvents();
|
||||
String hidKeyPressed();
|
||||
AKeyEvent hidKeyPressed();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,17 @@ enum MediaAction {
|
||||
volumeDown,
|
||||
}
|
||||
|
||||
enum GlobalAction {
|
||||
back,
|
||||
dpadCenter,
|
||||
down,
|
||||
right,
|
||||
up,
|
||||
left,
|
||||
home,
|
||||
recents,
|
||||
}
|
||||
|
||||
class WindowEvent {
|
||||
WindowEvent({
|
||||
required this.packageName,
|
||||
@@ -97,6 +108,62 @@ class WindowEvent {
|
||||
;
|
||||
}
|
||||
|
||||
class AKeyEvent {
|
||||
AKeyEvent({
|
||||
required this.source,
|
||||
required this.hidKey,
|
||||
required this.keyDown,
|
||||
required this.keyUp,
|
||||
});
|
||||
|
||||
String source;
|
||||
|
||||
String hidKey;
|
||||
|
||||
bool keyDown;
|
||||
|
||||
bool keyUp;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
source,
|
||||
hidKey,
|
||||
keyDown,
|
||||
keyUp,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static AKeyEvent decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return AKeyEvent(
|
||||
source: result[0]! as String,
|
||||
hidKey: result[1]! as String,
|
||||
keyDown: result[2]! as bool,
|
||||
keyUp: result[3]! as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! AKeyEvent || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@@ -108,8 +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(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -123,7 +196,12 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : MediaAction.values[value];
|
||||
case 130:
|
||||
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);
|
||||
}
|
||||
@@ -219,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?>(
|
||||
@@ -242,6 +343,34 @@ class Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isRunning() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.isRunning$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
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 if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as bool?)!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ignoreHidDevices() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
@@ -264,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 = ''}) {
|
||||
@@ -277,14 +429,14 @@ Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
});
|
||||
}
|
||||
|
||||
Stream<String> hidKeyPressed( {String instanceName = ''}) {
|
||||
Stream<AKeyEvent> hidKeyPressed( {String instanceName = ''}) {
|
||||
if (instanceName.isNotEmpty) {
|
||||
instanceName = '.$instanceName';
|
||||
}
|
||||
final EventChannel hidKeyPressedChannel =
|
||||
EventChannel('dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed$instanceName', pigeonMethodCodec);
|
||||
return hidKeyPressedChannel.receiveBroadcastStream().map((dynamic event) {
|
||||
return event as String;
|
||||
return event as AKeyEvent;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -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" />
|
||||
@@ -25,7 +27,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="SwiftControl"
|
||||
android:label="BikeControl"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
100
ios/Podfile.lock
100
ios/Podfile.lock
@@ -7,21 +7,50 @@ 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -38,18 +67,34 @@ DEPENDENCIES:
|
||||
- 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`)
|
||||
- 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`)
|
||||
- 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`)
|
||||
- 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:
|
||||
- PurchasesHybridCommon
|
||||
- PurchasesHybridCommonUI
|
||||
- RevenueCat
|
||||
- RevenueCatUI
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
bluetooth_low_energy_darwin:
|
||||
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
|
||||
@@ -59,20 +104,38 @@ 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"
|
||||
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"
|
||||
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"
|
||||
universal_ble:
|
||||
@@ -83,22 +146,35 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
|
||||
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
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SwiftControl</string>
|
||||
<string>BikeControl</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -29,16 +29,22 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
|
||||
<string>BikeControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>Access your accelerometer and gyroscope for steering support via your phone.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_wahoo-fitness-tnp._tcp</string>
|
||||
<string>_openbikecontrol._tcp</string>
|
||||
</array>
|
||||
<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>
|
||||
|
||||
8
ios/Runner/Runner.entitlements
Normal file
8
ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?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>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,25 +1,25 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
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/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.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:media_key_detector/media_key_detector.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../utils/keymap/apps/my_whoosh.dart';
|
||||
import 'devices/base_device.dart';
|
||||
import 'devices/link/link_device.dart';
|
||||
import 'devices/zwift/constants.dart';
|
||||
import 'messages/notification.dart';
|
||||
|
||||
@@ -28,9 +28,14 @@ class Connection {
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices, ...devices.whereType<HidDevice>()];
|
||||
List<BaseDevice> get remoteDevices =>
|
||||
devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice || d is HidDevice).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>(),
|
||||
];
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
@@ -40,37 +45,41 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
List<({DateTime date, String entry})> lastLogEntries = [];
|
||||
|
||||
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);
|
||||
final ValueNotifier<bool> isScanning = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isMediaKeyDetectionEnabled = ValueNotifier(false);
|
||||
|
||||
Timer? _gamePadSearchTimer;
|
||||
|
||||
final _dontAllowReconnectDevices = <String>{};
|
||||
|
||||
void initialize() {
|
||||
isMediaKeyDetectionEnabled.addListener(() {
|
||||
if (!isMediaKeyDetectionEnabled.value) {
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: false);
|
||||
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
|
||||
} else {
|
||||
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: true);
|
||||
}
|
||||
actionStream.listen((log) {
|
||||
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
|
||||
lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList();
|
||||
});
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
|
||||
core.mediaKeyHandler.initialize();
|
||||
}
|
||||
|
||||
UniversalBle.onAvailabilityChange = (available) {
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
performScanning();
|
||||
core.permissions.getScanRequirements().then((perms) {
|
||||
if (perms.isEmpty) {
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
} else if (available == AvailabilityState.poweredOff) {
|
||||
reset();
|
||||
disconnectAll();
|
||||
stop();
|
||||
}
|
||||
};
|
||||
UniversalBle.onScanResult = (result) {
|
||||
@@ -80,20 +89,22 @@ 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}');
|
||||
}
|
||||
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_actionStreams.add(
|
||||
LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'),
|
||||
);
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
@@ -101,20 +112,42 @@ class Connection {
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
if (data != null && kDebugMode) {
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data.firstOrNull}'));
|
||||
_actionStreams.add(
|
||||
LogNotification('Found unknown device ${result.name} with identifier: ${data.firstOrNull}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device == null) {
|
||||
_actionStreams.add(LogNotification('Device not found: $deviceId'));
|
||||
UniversalBle.disconnect(deviceId);
|
||||
return;
|
||||
} else {
|
||||
device.processCharacteristic(characteristicUuid, value);
|
||||
if (kIsWeb) {
|
||||
// on web, log all characteristic changes for debugging
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await device.processCharacteristic(characteristicUuid, value);
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,6 +158,17 @@ class Connection {
|
||||
_lastScanResult.removeWhere((d) => d.deviceId == deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!kIsWeb && !screenshotMode) {
|
||||
core.permissions.getScanRequirements().then((perms) {
|
||||
if (perms.isEmpty) {
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
if (core.settings.getPhoneSteeringEnabled()) {
|
||||
toggleGyroscopeSteering(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performScanning() async {
|
||||
@@ -134,6 +178,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(
|
||||
@@ -155,7 +203,7 @@ class Connection {
|
||||
if (!kIsWeb) {
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name.isEmpty ? 'Gamepad' : pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
@@ -169,78 +217,85 @@ class Connection {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
|
||||
startMyWhooshServer();
|
||||
} else {
|
||||
isScanning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
return whooshLink.startServer(
|
||||
onConnected: (socket) {
|
||||
final existing = remoteDevices.firstOrNullWhere(
|
||||
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
|
||||
);
|
||||
if (existing != null) {
|
||||
existing.isConnected = true;
|
||||
signalChange(existing);
|
||||
}
|
||||
},
|
||||
onDisconnected: (socket) {
|
||||
final device = devices.firstOrNullWhere(
|
||||
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
|
||||
);
|
||||
if (device != null) {
|
||||
devices.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
},
|
||||
);
|
||||
return core.whooshLink.startServer().catchError((e) {
|
||||
core.settings.setMyWhooshLinkEnabled(false);
|
||||
_actionStreams.add(LogNotification('Error starting MyWhoosh "Link" server: $e'));
|
||||
_actionStreams.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
'Error starting MyWhoosh "Link" server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev
|
||||
.where((device) => !devices.contains(device) && !_dontAllowReconnectDevices.contains(device.name))
|
||||
.toList();
|
||||
final ignoredDevices = core.settings.getIgnoredDevices();
|
||||
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
|
||||
final newDevices = dev.where((device) {
|
||||
if (devices.contains(device)) return false;
|
||||
|
||||
// Check if device is in the ignored list
|
||||
if (device is BluetoothDevice) {
|
||||
if (ignoredDeviceIds.contains(device.device.deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
devices.addAll(newDevices);
|
||||
_connectionQueue.addAll(newDevices);
|
||||
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection finished: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
device.isConnected = false;
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(
|
||||
LogNotification('Connection failed: ${device.name} - $e'),
|
||||
);
|
||||
if (e is TimeoutException) {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'),
|
||||
);
|
||||
} else {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'),
|
||||
);
|
||||
}
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
@@ -254,11 +309,20 @@ 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: true);
|
||||
disconnect(device, forget: false, persistForget: false);
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
@@ -269,22 +333,19 @@ class Connection {
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
final newButtons = device.availableButtons.filter(
|
||||
(button) => actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
@@ -295,31 +356,6 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
if (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);
|
||||
}
|
||||
@@ -328,39 +364,68 @@ class Connection {
|
||||
_connectionStreams.add(baseDevice);
|
||||
}
|
||||
|
||||
Future<void> disconnect(BaseDevice device, {required bool forget}) async {
|
||||
Future<void> disconnect(BaseDevice device, {required bool persistForget, required bool forget}) async {
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
if (device is! LinkDevice) {
|
||||
// keep it in the list to allow reconnect
|
||||
devices.remove(device);
|
||||
if (forget) {
|
||||
_dontAllowReconnectDevices.add(device.name);
|
||||
|
||||
if (device is BluetoothDevice) {
|
||||
if (persistForget) {
|
||||
// Add device to ignored list when forgetting
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.toString());
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.toString()}'));
|
||||
}
|
||||
}
|
||||
if (!forget && device is BluetoothDevice) {
|
||||
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
|
||||
if (!forget) {
|
||||
// allow reconnection
|
||||
_lastScanResult.removeWhere((d) => d.deviceId == device.device.deviceId);
|
||||
}
|
||||
|
||||
// Clean up subscriptions and scan results for reconnection
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_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;
|
||||
}
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
|
||||
void _onMediaKeyDetectedListener(MediaKey mediaKey) {
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
final keyPressed = mediaKey.name;
|
||||
|
||||
final button = actionHandler.supportedApp!.keymap.getOrAddButton(keyPressed, () => ControllerButton(keyPressed));
|
||||
|
||||
var availableDevice = connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
if (availableDevice == null) {
|
||||
connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
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);
|
||||
}
|
||||
availableDevice.handleButtonsClicked([button]);
|
||||
availableDevice.handleButtonsClicked([]);
|
||||
_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,36 +1,51 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' show LogLevel;
|
||||
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:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final String name;
|
||||
final String? _name;
|
||||
final bool isBeta;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false});
|
||||
BaseDevice(this._name, {required this.availableButtons, this.isBeta = false}) {
|
||||
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);
|
||||
availableButtons.addAll(allButtons);
|
||||
}
|
||||
}
|
||||
|
||||
bool isConnected = false;
|
||||
|
||||
Timer? _longPressTimer;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
String get name => _name ?? runtimeType.toString();
|
||||
|
||||
@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();
|
||||
|
||||
@@ -38,7 +53,34 @@ abstract class BaseDevice {
|
||||
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClickedWithoutLongPressSupport(List<ControllerButton> clickedButtons) async {
|
||||
await handleButtonsClicked(clickedButtons, longPress: true);
|
||||
if (clickedButtons.length == 1) {
|
||||
final keyPair = core.actionHandler.supportedApp?.keymap.getKeyPair(clickedButtons.single);
|
||||
if (keyPair != null && (keyPair.isLongPress || keyPair.inGameAction?.isLongPress == true)) {
|
||||
// simulate release after click
|
||||
_longPressTimer?.cancel();
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
} else {
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
}
|
||||
} else {
|
||||
await handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked, longPress: longPress);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked, {required bool longPress}) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -48,8 +90,9 @@ abstract class BaseDevice {
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
@@ -60,15 +103,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 &&
|
||||
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 &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
|
||||
@@ -90,40 +135,113 @@ 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)
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false)),
|
||||
);
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
);
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
);
|
||||
// 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(LogNotification(result.message));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_longPressTimer?.cancel();
|
||||
// Release any held keys in long press mode
|
||||
if (actionHandler is DesktopActions) {
|
||||
await (actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
|
||||
if (core.actionHandler is DesktopActions) {
|
||||
await (core.actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (availableButtons.none((e) => e.name == button.name)) {
|
||||
availableButtons.add(button);
|
||||
core.settings.setKeyMap(core.actionHandler.supportedApp!);
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
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/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';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
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:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
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,
|
||||
}) : super(
|
||||
scanResult.name,
|
||||
availableButtons: allowMultiple
|
||||
? availableButtons.map((b) => b.copyWith(sourceDeviceId: scanResult.deviceId)).toList()
|
||||
: availableButtons,
|
||||
isBeta: isBeta,
|
||||
) {
|
||||
rssi = scanResult.rssi;
|
||||
}
|
||||
|
||||
@@ -39,26 +61,43 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
WahooKickrHeadwindConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
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),
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
_ 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 {
|
||||
@@ -66,15 +105,31 @@ 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),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
_ 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.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()) =>
|
||||
WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.services.contains(ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase()) => ThinkRiderVs200(
|
||||
scanResult,
|
||||
),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
@@ -93,47 +148,54 @@ 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
|
||||
_ => 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
|
||||
if (navigatorKey.currentContext?.mounted ?? false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'You may need to update your Zwift Ride firmware.',
|
||||
duration: Duration(seconds: 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
try {
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
} catch (e) {
|
||||
isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
await UniversalBle.requestMtu(device.deviceId, 517);
|
||||
@@ -153,7 +215,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
|
||||
final batteryService = services.firstOrNullWhere(
|
||||
@@ -171,7 +234,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
);
|
||||
if (batteryData.isNotEmpty) {
|
||||
batteryLevel = batteryData.first;
|
||||
connection.signalChange(this);
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,63 +252,134 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
if (batteryLevel != null) ...[
|
||||
Icon(switch (batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
Text('$batteryLevel%'),
|
||||
],
|
||||
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
|
||||
if (firmwareVersion != null &&
|
||||
this is ZwiftDevice &&
|
||||
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||
Text(
|
||||
' (latest: ${(this as ZwiftDevice).latestFirmwareVersion})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (rssi != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Tooltip(
|
||||
message: 'Signal Strength: $rssi dBm',
|
||||
child: Icon(
|
||||
switch (rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
},
|
||||
size: 18,
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
toString().screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(child: SizedBox()),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Disconnect and Forget'),
|
||||
onTap: () {
|
||||
connection.disconnect(this, forget: true);
|
||||
if (isBeta) BetaPill(),
|
||||
Expanded(child: SizedBox()),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return LoadingWidget(
|
||||
futureCallback: () async {
|
||||
final completer = showDropdown<bool>(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text('Disconnect and Forget for this session'),
|
||||
onPressed: (context) {
|
||||
closeOverlay(context, false);
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text('Disconnect and Forget'),
|
||||
onPressed: (context) {
|
||||
closeOverlay(context, true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final persist = await completer.future;
|
||||
if (persist != null) {
|
||||
await core.connection.disconnect(this, forget: true, persistForget: persist);
|
||||
}
|
||||
},
|
||||
renderChild: (isLoading, tap) => IconButton(
|
||||
variance: ButtonVariance.muted,
|
||||
icon: isLoading ? SmallProgressIndicator() : Icon(Icons.clear),
|
||||
onPressed: tap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package: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';
|
||||
@@ -11,6 +11,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
allowMultiple: true,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -27,43 +28,74 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
// Track last state for index 6 and 7
|
||||
int _lastStateIndex6 = 0x00;
|
||||
int _lastStateIndex7 = 0x00;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
// Process CYCPLUS BC2 data
|
||||
// The BC2 typically sends button press data as simple byte values
|
||||
// Common patterns for virtual shifters:
|
||||
// - 0x01 or similar for shift up
|
||||
// - 0x02 or similar for shift down
|
||||
// - 0x00 for button release
|
||||
if (bytes.length > 7) {
|
||||
final buttonsToPress = <ControllerButton>[];
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
final buttonCode = bytes[0];
|
||||
|
||||
switch (buttonCode) {
|
||||
case 0x01:
|
||||
// Shift up button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
|
||||
break;
|
||||
case 0x02:
|
||||
// Shift down button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
|
||||
break;
|
||||
case 0x00:
|
||||
// Button released
|
||||
handleButtonsClicked([]);
|
||||
break;
|
||||
default:
|
||||
// Unknown button code - log for debugging
|
||||
actionStreamInternal.add(
|
||||
LogNotification('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}'),
|
||||
);
|
||||
break;
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(availableButtons[0]);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
}
|
||||
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(availableButtons[1]);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
}
|
||||
|
||||
handleButtonsClicked(buttonsToPress);
|
||||
} else {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
|
||||
),
|
||||
);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// Check if we should trigger a shift based on current and last state
|
||||
bool _shouldTriggerShift(int currentByte, int lastByte) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
|
||||
// State change from one pressed value to another different pressed value
|
||||
// This is the ONLY time we trigger a shift
|
||||
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update state tracking
|
||||
void _updateState(int currentByte, void Function(int) setState) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
const releaseValue = 0x00;
|
||||
|
||||
// Button released: current is 0x00 and last was pressed
|
||||
if (currentByte == releaseValue) {
|
||||
setState(releaseValue);
|
||||
}
|
||||
// Lock the new pressed state
|
||||
else if (pressedValues.contains(currentByte)) {
|
||||
setState(currentByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
@@ -37,19 +36,19 @@ class EliteSquare extends BluetoothDevice {
|
||||
if (characteristic == SquareConstants.CHARACTERISTIC_UUID) {
|
||||
final fullValue = _bytesToHex(bytes);
|
||||
final currentValue = _extractButtonCode(fullValue);
|
||||
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
|
||||
}
|
||||
|
||||
if (_lastValue != null) {
|
||||
final currentRelevantPart = fullValue.length >= 14
|
||||
? fullValue.substring(6, 14)
|
||||
: fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 14
|
||||
? _lastValue!.substring(6, 14)
|
||||
: _lastValue!.substring(6);
|
||||
final currentRelevantPart = fullValue.length >= 14 ? fullValue.substring(6, 14) : fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 14 ? _lastValue!.substring(6, 14) : _lastValue!.substring(6);
|
||||
|
||||
if (currentRelevantPart != lastRelevantPart) {
|
||||
final buttonClicked = SquareConstants.BUTTON_MAPPING[currentValue];
|
||||
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
|
||||
}
|
||||
handleButtonsClicked([
|
||||
if (buttonClicked != null) buttonClicked,
|
||||
]);
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
@@ -1,38 +1,61 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../../widgets/warning.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: [], isBeta: true);
|
||||
GamepadDevice(super.name, {required this.id}) : super(availableButtons: []);
|
||||
|
||||
List<ControllerButton> _lastButtonsClicked = [];
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
Gamepads.eventsByGamepad(id).listen((event) {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: $event'));
|
||||
Gamepads.eventsByGamepad(id).listen((event) async {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: ${event.key} value ${event.value} type ${event.type}'));
|
||||
|
||||
ControllerButton? button = actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
event.key,
|
||||
() => ControllerButton(event.key),
|
||||
final int normalizedValue = switch (event.value) {
|
||||
> 1.0 => 1,
|
||||
< -1.0 => -1,
|
||||
_ => event.value.toInt(),
|
||||
};
|
||||
|
||||
final buttonKey = event.type == KeyType.analog ? '${event.key}_$normalizedValue' : event.key;
|
||||
ControllerButton button = getOrAddButton(
|
||||
buttonKey,
|
||||
() => ControllerButton(buttonKey),
|
||||
);
|
||||
|
||||
final buttonsClicked = event.value == 0.0 && button != null ? [button] : <ControllerButton>[];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
switch (event.type) {
|
||||
case KeyType.analog:
|
||||
final releasedValue = Platform.isWindows ? 1 : 0;
|
||||
|
||||
if (event.value.round().abs() != releasedValue) {
|
||||
final buttonsClicked = [button];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
} else {
|
||||
_lastButtonsClicked = [];
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
case KeyType.button:
|
||||
final buttonsClicked = event.value.toInt() != 1 ? [button] : <ControllerButton>[];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,19 +67,21 @@ class GamepadDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
if (Platform.isAndroid && !core.settings.getLocalEnabled())
|
||||
Warning(
|
||||
children: [
|
||||
Text('Use a custom keymap to use the buttons on $name.'),
|
||||
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,
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.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,
|
||||
);
|
||||
|
||||
StreamSubscription<GyroscopeEvent>? _gyroscopeSubscription;
|
||||
StreamSubscription<AccelerometerEvent>? _accelerometerSubscription;
|
||||
|
||||
// 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;
|
||||
bool _isProcessingKeypresses = false;
|
||||
|
||||
// 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
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start listening to sensors
|
||||
_gyroscopeSubscription = gyroscopeEventStream().listen(
|
||||
_handleGyroscopeEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Gyroscope error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
_accelerometerSubscription = accelerometerEventStream().listen(
|
||||
_handleAccelerometerEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Accelerometer error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
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 _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();
|
||||
_gyroscopeSubscription = null;
|
||||
_accelerometerSubscription = null;
|
||||
_keypressTimer?.cancel();
|
||||
isConnected = false;
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
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(),
|
||||
],
|
||||
),
|
||||
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 ? '${_estimator.angleDeg.toStringAsFixed(2)}°' : 'Calibrating...',
|
||||
),
|
||||
if (kDebugMode)
|
||||
DeviceInfo(
|
||||
title: 'Gyro Bias',
|
||||
icon: BootstrapIcons.speedometer,
|
||||
value: '${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
leading: !_isCalibrated ? SmallProgressIndicator() : null,
|
||||
onPressed: !_isCalibrated
|
||||
? null
|
||||
: () {
|
||||
// Reset calibration
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibrating the sensors now.'),
|
||||
);
|
||||
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(
|
||||
'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 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.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 = const []});
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> connect() {
|
||||
@@ -13,24 +17,36 @@ 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: () {
|
||||
connection.disconnect(this, forget: true);
|
||||
if (actionHandler is AndroidActions) {
|
||||
(actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (connection.isMediaKeyDetectionEnabled.value) {
|
||||
connection.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(toString())),
|
||||
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;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
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,153 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
class WhooshLink {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static final List<InGameAction> supportedActions = [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.cameraAngle,
|
||||
InGameAction.emote,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
|
||||
void stopServer() async {
|
||||
if (isStarted.value) {
|
||||
await _socket?.close();
|
||||
await _server?.close();
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
if (kDebugMode) {
|
||||
print('Server stopped.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startServer({
|
||||
required void Function(Socket socket) onConnected,
|
||||
required void Function(Socket socket) onDisconnected,
|
||||
}) async {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
isStarted.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
onConnected(socket);
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
onDisconnected(socket);
|
||||
isConnected.value = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String sendAction(InGameAction action, int? value) {
|
||||
final jsonObject = switch (action) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '1',
|
||||
},
|
||||
},
|
||||
InGameAction.shiftDown => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '-1',
|
||||
},
|
||||
},
|
||||
InGameAction.cameraAngle => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'CameraAngle': '$value',
|
||||
},
|
||||
},
|
||||
InGameAction.emote => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Emote': '$value',
|
||||
},
|
||||
},
|
||||
InGameAction.uturn => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'UTurn': 'true',
|
||||
},
|
||||
},
|
||||
InGameAction.steerLeft => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': '-1',
|
||||
},
|
||||
},
|
||||
InGameAction.steerRight => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': '1',
|
||||
},
|
||||
},
|
||||
InGameAction.increaseResistance => null,
|
||||
InGameAction.decreaseResistance => null,
|
||||
InGameAction.navigateLeft => null,
|
||||
InGameAction.navigateRight => null,
|
||||
InGameAction.toggleUi => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return 'Sent action to MyWhoosh: $action ${value ?? ''}';
|
||||
} else {
|
||||
return 'No action available for button: $action';
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LinkDevice extends BaseDevice {
|
||||
String identifier;
|
||||
|
||||
LinkDevice(this.identifier) : super('MyWhoosh Direct Connect', availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
super.disconnect();
|
||||
whooshLink.stopServer();
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final myWhooshExplanation = actionHandler is RemoteActions
|
||||
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
|
||||
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: settings.getMyWhooshLinkEnabled(),
|
||||
onChanged: (value) {
|
||||
settings.setMyWhooshLinkEnabled(value);
|
||||
if (!value) {
|
||||
disconnect();
|
||||
connection.disconnect(this, forget: true);
|
||||
} else if (value) {
|
||||
connection.startMyWhooshServer();
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable MyWhoosh Direct Connect'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!settings.getMyWhooshLinkEnabled())
|
||||
Expanded(
|
||||
child: Text(
|
||||
myWhooshExplanation,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
|
||||
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/bluetooth/devices/mywhoosh/link.dart
Normal file
178
lib/bluetooth/devices/mywhoosh/link.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
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';
|
||||
|
||||
class WhooshLink extends TrainerConnection {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static const String connectionTitle = 'MyWhoosh Link';
|
||||
|
||||
WhooshLink()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.cameraAngle,
|
||||
InGameAction.emote,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
],
|
||||
);
|
||||
|
||||
void stopServer() async {
|
||||
await _socket?.close();
|
||||
await _server?.close();
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
if (kDebugMode) {
|
||||
print('Server stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
try {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_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) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
isConnected.value = false;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'MyWhoosh Link disconnected'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final jsonObject = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '1',
|
||||
},
|
||||
},
|
||||
InGameAction.shiftDown => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '-1',
|
||||
},
|
||||
},
|
||||
InGameAction.cameraAngle => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'CameraAngle': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.emote => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Emote': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.uturn => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'UTurn': 'true',
|
||||
},
|
||||
},
|
||||
InGameAction.steerLeft => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': isKeyDown ? '-1' : '0',
|
||||
},
|
||||
},
|
||||
InGameAction.steerRight => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': isKeyDown ? '1' : '0',
|
||||
},
|
||||
},
|
||||
InGameAction.increaseResistance => null,
|
||||
InGameAction.decreaseResistance => null,
|
||||
InGameAction.navigateLeft => null,
|
||||
InGameAction.navigateRight => null,
|
||||
InGameAction.toggleUi => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final supportsIsKeyUpActions = [
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(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);
|
||||
return Success('Sent action to MyWhoosh: ${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ''}');
|
||||
} else {
|
||||
return NotHandled('No action available for button: ${keyPair.inGameAction}');
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => !Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
274
lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart
Normal file
274
lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
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/ftms_mdns_emulator.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/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';
|
||||
|
||||
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
|
||||
late GATTCharacteristic _buttonCharacteristic;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl BLE Emulator';
|
||||
|
||||
OpenBikeControlBluetoothEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
_peripheralManager.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
if (connectedApp.value != null) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
}
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_central = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
_buttonCharacteristic = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.notify,
|
||||
],
|
||||
permissions: [],
|
||||
);
|
||||
|
||||
if (!_isServiceAdded) {
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
case 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}');
|
||||
}
|
||||
|
||||
final request = eventArgs.request;
|
||||
final trimmedValue = Uint8List.fromList([]);
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
request,
|
||||
value: trimmedValue,
|
||||
);
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
_central = char.central;
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
final characteristic = eventArgs.characteristic;
|
||||
final request = eventArgs.request;
|
||||
final value = request.value;
|
||||
print(
|
||||
'Write request for characteristic: ${characteristic.uuid}',
|
||||
);
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toLowerCase()) {
|
||||
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
|
||||
try {
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
|
||||
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}'),
|
||||
);
|
||||
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
|
||||
await _peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('1.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A19'),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.read,
|
||||
GATTCharacteristicProperty.notify,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Unknown Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString(OpenBikeControlConstants.SERVICE_UUID),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
_buttonCharacteristic,
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.writeWithoutResponse,
|
||||
GATTCharacteristicProperty.write,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
GATTCharacteristicPermission.write,
|
||||
],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
_isServiceAdded = true;
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name: 'BikeControl',
|
||||
serviceUUIDs: [UUID.fromString(OpenBikeControlConstants.SERVICE_UUID)],
|
||||
);
|
||||
print('Starting advertising with OpenBikeControl service...');
|
||||
|
||||
await _peripheralManager.startAdvertising(advertisement);
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
if (kDebugMode) {
|
||||
print('Stopping OpenBikeControl BLE server...');
|
||||
}
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_central == null) {
|
||||
return Error('No central connected');
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support all buttons for action: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
|
||||
}
|
||||
|
||||
return Success('Buttons ${inGameAction.title} sent');
|
||||
}
|
||||
}
|
||||
211
lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart
Normal file
211
lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/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';
|
||||
|
||||
class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _server;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
|
||||
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
|
||||
|
||||
Socket? _socket;
|
||||
|
||||
OpenBikeControlMdnsEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
print('Starting mDNS server...');
|
||||
isStarted.value = true;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
await _createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
enableLogging(LogTopic.errors);
|
||||
}
|
||||
disableServiceTypeValidation(true);
|
||||
|
||||
try {
|
||||
// Create service
|
||||
_mdnsRegistration = await register(
|
||||
Service(
|
||||
name: 'BikeControl',
|
||||
type: '_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),
|
||||
},
|
||||
),
|
||||
);
|
||||
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
|
||||
print('Server started - advertising service!');
|
||||
} catch (e, s) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e'));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
if (kDebugMode) {
|
||||
print('Stopping OpenBikeControl mDNS server...');
|
||||
}
|
||||
if (_mdnsRegistration != null) {
|
||||
unregister(_mdnsRegistration!);
|
||||
_mdnsRegistration = null;
|
||||
}
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket?.destroy();
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
Future<void> _createTcpServer() async {
|
||||
try {
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
36867,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e'));
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// 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:
|
||||
try {
|
||||
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}'),
|
||||
);
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unknown message type: $messageType');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket = null;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_socket == null) {
|
||||
print('No client connected, cannot send button press');
|
||||
return Error('No client connected');
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseData);
|
||||
}
|
||||
|
||||
return Success('Sent ${inGameAction.title} button press');
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
debugPrint('Sending response: ${bytesToHex(responseData)}');
|
||||
socket.add(responseData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
import 'protocol_parser.dart';
|
||||
|
||||
class OpenBikeControlDevice extends BluetoothDevice {
|
||||
OpenBikeControlDevice(super.scanResult)
|
||||
: super(
|
||||
availableButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${OpenBikeControlConstants.SERVICE_UUID}'),
|
||||
);
|
||||
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () =>
|
||||
throw Exception('Characteristic not found: ${OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
final appInfoCharacteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () =>
|
||||
throw Exception('Characteristic not found: ${OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service.uuid,
|
||||
appInfoCharacteristic.uuid,
|
||||
OpenBikeProtocolParser.encodeAppInfo(
|
||||
appId: 'BikeControl',
|
||||
appVersion: packageInfoValue!.version,
|
||||
supportedButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
final charLower = characteristic.toLowerCase();
|
||||
|
||||
if (charLower == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
try {
|
||||
final parsed = OpenBikeProtocolParser.parseButtonState(bytes);
|
||||
|
||||
final buttonsToPress = parsed.where((e) => e.state == 1).map((e) => e.button).toList();
|
||||
|
||||
await handleButtonsClicked(buttonsToPress);
|
||||
} catch (e) {
|
||||
actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Error parsing OpenBike message: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OpenBikeControlConstants {
|
||||
// OpenBikeControl BLE service and characteristic UUIDs (see BLE.md)
|
||||
static const String SERVICE_UUID = 'd273f680-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// Button State Characteristic (Notify)
|
||||
static const String BUTTON_STATE_CHARACTERISTIC_UUID = 'd273f681-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// Haptic Feedback Characteristic (Write)
|
||||
static const String HAPTIC_CHARACTERISTIC_UUID = 'd273f682-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// App Info Characteristic (Write)
|
||||
static const String APPINFO_CHARACTERISTIC_UUID = 'd273f683-d548-419d-b9d1-fa0472345229';
|
||||
}
|
||||
308
lib/bluetooth/devices/openbikecontrol/protocol_parser.dart
Normal file
308
lib/bluetooth/devices/openbikecontrol/protocol_parser.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
// OpenBikeControl Protocol Parser (Dart)
|
||||
|
||||
// This file is a translation of the Python `protocol_parser.py` example into Dart.
|
||||
// It provides simple encoding/decoding utilities for the OpenBikeControl message
|
||||
// types used in the Python example. This is intentionally a small, focused
|
||||
// module that mirrors the original Python API.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
|
||||
class ProtocolParseException implements Exception {
|
||||
final String message;
|
||||
final Uint8List? raw;
|
||||
ProtocolParseException(this.message, [this.raw]);
|
||||
|
||||
@override
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}';
|
||||
}
|
||||
|
||||
class OpenBikeProtocolParser {
|
||||
// Button ID to name mapping (based on PROTOCOL.md in Python example)
|
||||
static const Map<int, ControllerButton> BUTTON_NAMES = {
|
||||
// Gear Shifting (0x01-0x0F)
|
||||
0x01: ControllerButton('Shift Up', identifier: 0x01, action: InGameAction.shiftUp),
|
||||
0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown),
|
||||
0x03: ControllerButton('Gear Set', identifier: 0x03),
|
||||
// Navigation (0x10-0x1F)
|
||||
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, 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('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),
|
||||
0x32: ControllerButton('Skip Interval', identifier: 0x32),
|
||||
0x33: ControllerButton('Pause', identifier: 0x33),
|
||||
0x34: ControllerButton('Resume', identifier: 0x34),
|
||||
0x35: ControllerButton('Lap', identifier: 0x35),
|
||||
// View Controls (0x40-0x4F)
|
||||
0x40: ControllerButton('Camera Angle', identifier: 0x40, action: InGameAction.cameraAngle),
|
||||
0x41: ControllerButton('Camera 1', identifier: 0x41, action: InGameAction.cameraAngle),
|
||||
0x42: ControllerButton('Camera 2', identifier: 0x42, action: InGameAction.cameraAngle),
|
||||
0x43: ControllerButton('Camera 3', identifier: 0x43, action: InGameAction.cameraAngle),
|
||||
0x44: ControllerButton('HUD Toggle', identifier: 0x44, action: InGameAction.toggleUi),
|
||||
0x45: ControllerButton('Map Toggle', identifier: 0x45),
|
||||
// Power-ups (0x50-0x5F)
|
||||
0x50: ControllerButton('Power-up 1', identifier: 0x50, action: InGameAction.usePowerUp),
|
||||
0x51: ControllerButton('Power-up 2', identifier: 0x51, action: InGameAction.usePowerUp),
|
||||
0x52: ControllerButton('Power-up 3', identifier: 0x52, action: InGameAction.usePowerUp),
|
||||
};
|
||||
|
||||
// Haptic feedback patterns
|
||||
static const Map<String, int> HAPTIC_PATTERNS = {
|
||||
'none': 0x00,
|
||||
'short': 0x01,
|
||||
'double': 0x02,
|
||||
'triple': 0x03,
|
||||
'long': 0x04,
|
||||
'success': 0x05,
|
||||
'warning': 0x06,
|
||||
'error': 0x07,
|
||||
};
|
||||
|
||||
// Message types (for TCP/mDNS protocol)
|
||||
static const int MSG_TYPE_BUTTON_STATE = 0x01;
|
||||
static const int MSG_TYPE_DEVICE_STATUS = 0x02;
|
||||
static const int MSG_TYPE_HAPTIC_FEEDBACK = 0x03;
|
||||
static const int MSG_TYPE_APP_INFO = 0x04;
|
||||
|
||||
/// Parse button state data from binary format.
|
||||
/// Data format: [Message_Type, Button_ID_1, State_1, Button_ID_2, State_2, ...]
|
||||
static List<ButtonState> parseButtonState(Uint8List data) {
|
||||
final buttons = <ButtonState>[];
|
||||
|
||||
if (data.isEmpty) return buttons;
|
||||
|
||||
if (data[0] != MSG_TYPE_BUTTON_STATE) return buttons;
|
||||
|
||||
for (var i = 1; i < data.length; i += 2) {
|
||||
if (i + 1 < data.length) {
|
||||
final buttonId = data[i];
|
||||
final state = data[i + 1];
|
||||
if (BUTTON_NAMES[buttonId] != null) {
|
||||
buttons.add(ButtonState(BUTTON_NAMES[buttonId]!, state));
|
||||
} else {
|
||||
throw ProtocolParseException('Unknown button ID: 0x${buttonId.toRadixString(16).padLeft(2, '0')}', data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
static Uint8List encodeButtonState(List<ButtonState> buttons) {
|
||||
final bytes = BytesBuilder();
|
||||
bytes.addByte(MSG_TYPE_BUTTON_STATE);
|
||||
for (final b in buttons) {
|
||||
bytes.addByte(b.button.identifier!);
|
||||
bytes.addByte(b.state);
|
||||
}
|
||||
return bytes.toBytes();
|
||||
}
|
||||
|
||||
static DeviceStatus parseDeviceStatus(Uint8List data) {
|
||||
if (data.length < 3) {
|
||||
throw ProtocolParseException('Device status message too short', data);
|
||||
}
|
||||
if (data[0] != MSG_TYPE_DEVICE_STATUS) {
|
||||
throw ProtocolParseException('Invalid message type: ${data[0]}, expected $MSG_TYPE_DEVICE_STATUS', data);
|
||||
}
|
||||
final battery = data[1] == 0xFF ? null : data[1];
|
||||
final connected = data[2] == 0x01;
|
||||
return DeviceStatus(battery: battery, connected: connected);
|
||||
}
|
||||
|
||||
static Uint8List encodeDeviceStatus({int? battery, bool connected = true}) {
|
||||
final batteryByte = battery == null ? 0xFF : (battery & 0xFF);
|
||||
final connectedByte = connected ? 0x01 : 0x00;
|
||||
return Uint8List.fromList([MSG_TYPE_DEVICE_STATUS, batteryByte, connectedByte]);
|
||||
}
|
||||
|
||||
static Uint8List encodeHapticFeedback({String pattern = 'short', int duration = 0, int intensity = 0}) {
|
||||
final patternByte = HAPTIC_PATTERNS[pattern] ?? HAPTIC_PATTERNS['short']!;
|
||||
final bytes = Uint8List(4);
|
||||
bytes[0] = MSG_TYPE_HAPTIC_FEEDBACK;
|
||||
bytes[1] = patternByte;
|
||||
bytes[2] = duration & 0xFF;
|
||||
bytes[3] = intensity & 0xFF;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static HapticFeedbackMessage parseHapticFeedback(Uint8List data) {
|
||||
if (data.length < 4) {
|
||||
throw ProtocolParseException('Haptic feedback message too short', data);
|
||||
}
|
||||
if (data[0] != MSG_TYPE_HAPTIC_FEEDBACK) {
|
||||
throw ProtocolParseException('Invalid message type: ${data[0]}', data);
|
||||
}
|
||||
final patternByte = data[1];
|
||||
final duration = data[2];
|
||||
final intensity = data[3];
|
||||
String patternName = 'unknown';
|
||||
HAPTIC_PATTERNS.forEach((name, value) {
|
||||
if (value == patternByte) patternName = name;
|
||||
});
|
||||
|
||||
return HapticFeedbackMessage(
|
||||
pattern: patternName,
|
||||
patternByte: patternByte,
|
||||
duration: duration,
|
||||
intensity: intensity,
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List encodeAppInfo({
|
||||
required String appId,
|
||||
required String appVersion,
|
||||
required List<ControllerButton> supportedButtons,
|
||||
}) {
|
||||
final appIdBytes = utf8.encode(appId).take(32).toList();
|
||||
final appVersionBytes = utf8.encode(appVersion).take(32).toList();
|
||||
|
||||
final builder = BytesBuilder();
|
||||
builder.addByte(MSG_TYPE_APP_INFO);
|
||||
builder.addByte(0x01); // Version
|
||||
builder.addByte(appIdBytes.length);
|
||||
builder.add(appIdBytes);
|
||||
builder.addByte(appVersionBytes.length);
|
||||
builder.add(appVersionBytes);
|
||||
builder.addByte(supportedButtons.length);
|
||||
builder.add(supportedButtons.map((e) => e.identifier!).toList());
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static AppInfo parseAppInfo(Uint8List data) {
|
||||
if (data.isEmpty || data[0] != MSG_TYPE_APP_INFO) {
|
||||
throw ProtocolParseException('Invalid message type', data);
|
||||
}
|
||||
|
||||
var idx = 1;
|
||||
if (data.length < idx + 3) {
|
||||
throw ProtocolParseException('App info message too short', data);
|
||||
}
|
||||
|
||||
final version = data[idx];
|
||||
idx += 1;
|
||||
if (version != 0x01) {
|
||||
throw ProtocolParseException('Unsupported app info version: $version', data);
|
||||
}
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing app ID length', data);
|
||||
final appIdLen = data[idx];
|
||||
idx += 1;
|
||||
if (idx + appIdLen > data.length) throw ProtocolParseException('App ID length exceeds buffer', data);
|
||||
final appId = utf8.decode(data.sublist(idx, idx + appIdLen));
|
||||
idx += appIdLen;
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing app version length', data);
|
||||
final appVersionLen = data[idx];
|
||||
idx += 1;
|
||||
if (idx + appVersionLen > data.length) throw ProtocolParseException('App version length exceeds buffer', data);
|
||||
final appVersion = utf8.decode(data.sublist(idx, idx + appVersionLen));
|
||||
idx += appVersionLen;
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing button count', data);
|
||||
final buttonCount = data[idx];
|
||||
idx += 1;
|
||||
if (idx + buttonCount > data.length) throw ProtocolParseException('Button count exceeds buffer', data);
|
||||
final buttonIds = data.sublist(idx, idx + buttonCount).toList();
|
||||
|
||||
final controllerButtons = buttonIds.mapNotNull((id) => BUTTON_NAMES[id]).toList();
|
||||
|
||||
return AppInfo(
|
||||
appId: appId,
|
||||
appVersion: appVersion,
|
||||
supportedButtons: controllerButtons,
|
||||
supportedActions: controllerButtons.mapNotNull((b) => b.action).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppInfo {
|
||||
final String appId;
|
||||
final String appVersion;
|
||||
final List<ControllerButton> supportedButtons;
|
||||
final List<InGameAction> supportedActions;
|
||||
|
||||
AppInfo({
|
||||
required this.appId,
|
||||
required this.appVersion,
|
||||
required this.supportedButtons,
|
||||
required this.supportedActions,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppInfo(appId: $appId, appVersion: $appVersion, supportedButtons: $supportedButtons, supportedActions: $supportedActions)';
|
||||
}
|
||||
|
||||
/// DeviceStatus message representation
|
||||
class DeviceStatus {
|
||||
final int? battery; // 0-100, null if 0xFF
|
||||
final bool connected;
|
||||
|
||||
DeviceStatus({required this.battery, required this.connected});
|
||||
|
||||
@override
|
||||
String toString() => 'DeviceStatus(battery: ${battery ?? 'unknown'}, connected: $connected)';
|
||||
}
|
||||
|
||||
class ButtonState {
|
||||
/// Represents a single button id/state pair.class ButtonState {
|
||||
final ControllerButton button;
|
||||
final int state; // 0=released,1=pressed,2-255=analog
|
||||
|
||||
const ButtonState(this.button, this.state);
|
||||
|
||||
@override
|
||||
String toString() => formatButtonState(button, state);
|
||||
|
||||
String formatButtonState(ControllerButton button, int state) {
|
||||
final buttonName = button.name;
|
||||
|
||||
String stateStr;
|
||||
if (state == 0) {
|
||||
stateStr = 'RELEASED';
|
||||
} else if (state == 1) {
|
||||
stateStr = 'PRESSED';
|
||||
} else {
|
||||
final percentage = ((state - 2) / (255 - 2) * 100).round();
|
||||
stateStr = 'ANALOG $percentage%';
|
||||
}
|
||||
|
||||
return '$buttonName: $stateStr';
|
||||
}
|
||||
}
|
||||
|
||||
/// Haptic feedback representation
|
||||
class HapticFeedbackMessage {
|
||||
final String pattern;
|
||||
final int patternByte;
|
||||
final int duration; // in 10ms units
|
||||
final int intensity; // 0-255
|
||||
|
||||
HapticFeedbackMessage({
|
||||
required this.pattern,
|
||||
required this.patternByte,
|
||||
required this.duration,
|
||||
required this.intensity,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'HapticFeedback(pattern: $pattern, duration: $duration, intensity: $intensity)';
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
@@ -30,7 +30,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
|
||||
final channels = bytes.sublist(1);
|
||||
|
||||
@@ -40,7 +40,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
final readableIndex = index + 1;
|
||||
_lastButtons[index] = value;
|
||||
|
||||
actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
@@ -57,18 +57,17 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
|
||||
final readableIndex = index + 1;
|
||||
|
||||
final button = actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
final button = getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
if (didChange && button != null) {
|
||||
if (didChange) {
|
||||
clickedButtons.add(button);
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
handleButtonsClicked(clickedButtons);
|
||||
handleButtonsClicked([]);
|
||||
await handleButtonsClickedWithoutLongPressSupport(clickedButtons);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
@@ -84,7 +83,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
'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 (actionHandler.supportedApp is! CustomApp)
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Use a custom keymap to support ${scanResult.name}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
@@ -96,6 +95,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
|
||||
class ShimanoDi2Constants {
|
||||
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
|
||||
static const String SERVICE_UUID_ALTERNATIVE = "000018ff-5348-494d-414e-4f5f424c4500";
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
|
||||
172
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
172
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.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.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.SERVICE_UUID_RELEVANT.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${SramAxsConstants.SERVICE_UUID_RELEVANT}'),
|
||||
);
|
||||
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',
|
||||
() => const ControllerButton('SRAM Tap', action: InGameAction.shiftUp),
|
||||
);
|
||||
|
||||
ControllerButton _doubleClickButton() => getOrAddButton(
|
||||
'SRAM Double Tap',
|
||||
() => const ControllerButton('SRAM Double Tap', action: InGameAction.shiftDown),
|
||||
);
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
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';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract class TrainerConnection {
|
||||
final String title;
|
||||
List<InGameAction> supportedActions;
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
|
||||
TrainerConnection({required this.title, required this.supportedActions});
|
||||
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp});
|
||||
}
|
||||
10
lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart
Normal file
10
lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
import '../zwift/constants.dart';
|
||||
|
||||
class WahooKickrBikePro extends ZwiftRide {
|
||||
WahooKickrBikePro(super.scanResult) : super();
|
||||
|
||||
@override
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
154
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
154
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
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';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class WahooKickrHeadwind extends BluetoothDevice {
|
||||
// Current mode state
|
||||
HeadwindMode _currentMode = HeadwindMode.unknown;
|
||||
int _currentSpeed = 0;
|
||||
|
||||
WahooKickrHeadwind(super.scanResult)
|
||||
: super(
|
||||
availableButtons: const [],
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${WahooKickrHeadwindConstants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${WahooKickrHeadwindConstants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
// Subscribe to notifications for status updates
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@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];
|
||||
|
||||
switch (mode) {
|
||||
case 0x02:
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
break;
|
||||
case 0x03:
|
||||
_currentMode = HeadwindMode.speed;
|
||||
break;
|
||||
case 0x01:
|
||||
_currentMode = HeadwindMode.off;
|
||||
break;
|
||||
case 0x04:
|
||||
_currentMode = HeadwindMode.manual;
|
||||
_currentSpeed = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
Future<void> setSpeed(int speedPercent) async {
|
||||
// Validate against the allowed speed values
|
||||
const allowedSpeeds = [0, 25, 50, 75, 100];
|
||||
if (!allowedSpeeds.contains(speedPercent)) {
|
||||
throw ArgumentError('Speed must be one of: ${allowedSpeeds.join(", ")}');
|
||||
}
|
||||
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Check if manual mode is enabled, if not enable it first
|
||||
if (_currentMode != HeadwindMode.manual) {
|
||||
final manualModeData = Uint8List.fromList([0x04, 0x04]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
manualModeData,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.manual;
|
||||
}
|
||||
|
||||
// Command format: [0x02, speed_value]
|
||||
// Speed value: 0x00 to 0x64 (0-100 in hex)
|
||||
final data = Uint8List.fromList([0x02, speedPercent]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentSpeed = speedPercent;
|
||||
}
|
||||
|
||||
Future<void> setHeartRateMode() async {
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Command format: [0x04, 0x02] for HR mode
|
||||
final data = Uint8List.fromList([0x04, 0x02]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
}
|
||||
|
||||
Future<ActionResult> handleKeypair(KeyPair keyPair, {required bool isKeyDown}) async {
|
||||
if (!isKeyDown) {
|
||||
return NotHandled('');
|
||||
}
|
||||
|
||||
try {
|
||||
if (keyPair.inGameAction == InGameAction.headwindSpeed) {
|
||||
final speed = keyPair.inGameActionValue ?? 0;
|
||||
await setSpeed(speed);
|
||||
return Success('Headwind speed set to $speed%');
|
||||
} else if (keyPair.inGameAction == InGameAction.headwindHeartRateMode) {
|
||||
await setHeartRateMode();
|
||||
return Success('Headwind set to Heart Rate mode');
|
||||
}
|
||||
} catch (e) {
|
||||
return Error('Failed to control Headwind: $e');
|
||||
}
|
||||
|
||||
return NotHandled('');
|
||||
}
|
||||
}
|
||||
|
||||
class WahooKickrHeadwindConstants {
|
||||
// Wahoo KICKR Headwind service and characteristic UUIDs
|
||||
// These are standard Wahoo fitness equipment UUIDs
|
||||
static const String SERVICE_UUID = "A026EE0C-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
static const String CHARACTERISTIC_UUID = "A026E038-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
}
|
||||
|
||||
enum HeadwindMode {
|
||||
unknown,
|
||||
heartRate, // HR mode (0x02)
|
||||
speed, // Speed mode (0x03)
|
||||
off, // OFF mode (0x01)
|
||||
manual, // Manual speed mode (0x04)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftConstants {
|
||||
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
|
||||
@@ -59,25 +59,25 @@ 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,
|
||||
);
|
||||
static const ControllerButton navigationLeft = ControllerButton(
|
||||
'navigationLeft',
|
||||
action: InGameAction.navigateLeft,
|
||||
action: InGameAction.steerLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationRight = ControllerButton(
|
||||
'navigationRight',
|
||||
action: InGameAction.navigateRight,
|
||||
action: InGameAction.steerRight,
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
);
|
||||
@@ -99,10 +99,14 @@ class ZwiftButtons {
|
||||
static const ControllerButton powerUpLeft = ControllerButton('powerUpLeft', action: InGameAction.shiftDown);
|
||||
|
||||
// right controller
|
||||
static const ControllerButton a = ControllerButton('a', action: null, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: null, 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 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: 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);
|
||||
|
||||
504
lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart
Normal file
504
lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart
Normal file
@@ -0,0 +1,504 @@
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
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:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
|
||||
class FtmsMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _tcpServer;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'Zwift Network Emulator';
|
||||
|
||||
Socket? _socket;
|
||||
var lastMessageId = 0;
|
||||
|
||||
FtmsMdnsEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
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';
|
||||
}
|
||||
|
||||
await _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),
|
||||
'manufacturer-data': Uint8List.fromList('094A0BAAAA'.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}');
|
||||
}
|
||||
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;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
if (kDebugMode) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
}
|
||||
socket.add(responseData);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
InGameAction.steerLeft => RideButtonMask.LEFT_BTN,
|
||||
InGameAction.steerRight => RideButtonMask.RIGHT_BTN,
|
||||
InGameAction.openActionBar => RideButtonMask.UP_BTN,
|
||||
InGameAction.usePowerUp => RideButtonMask.Y_BTN,
|
||||
InGameAction.select => RideButtonMask.A_BTN,
|
||||
InGameAction.back => RideButtonMask.B_BTN,
|
||||
InGameAction.rideOnBomb => RideButtonMask.Z_BTN,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
if (isKeyDown) {
|
||||
final status = RideKeyPadStatus()
|
||||
..buttonMap = (~button.mask) & 0xFFFFFFFF
|
||||
..analogPaddles.clear();
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
final commandProto = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]),
|
||||
);
|
||||
|
||||
_write(_socket!, commandProto);
|
||||
}
|
||||
|
||||
if (isKeyUp) {
|
||||
final zero = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
|
||||
);
|
||||
|
||||
_write(_socket!, zero);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
}
|
||||
return Success('Sent action: ${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, {bool spaced = false}) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
|
||||
}
|
||||
|
||||
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 {
|
||||
static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully
|
||||
static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type
|
||||
static const DC_RC_UNEXPECTED_ERROR = 2; // Unexpected Error
|
||||
static const DC_RC_SERVICE_NOT_FOUND = 3; // Service Not Found
|
||||
static const DC_RC_CHARACTERISTIC_NOT_FOUND = 4; // Characteristic Not Found
|
||||
static const DC_RC_CHARACTERISTIC_OPERATION_NOT_SUPPORTED =
|
||||
5; // Characteristic Operation Not Supported (See Characteristic Properties)
|
||||
static const DC_RC_CHARACTERISTIC_WRITE_FAILED_INVALID_SIZE =
|
||||
6; // Characteristic Write Failed – Invalid characteristic data size
|
||||
static const DC_RC_UNKNOWN_PROTOCOL_VERSION =
|
||||
7; // Unknown Protocol Version – the command contains a protocol version that the device does not recognize
|
||||
|
||||
static const DC_MESSAGE_DISCOVER_SERVICES = 0x01; // Discover Services
|
||||
static const DC_MESSAGE_DISCOVER_CHARACTERISTICS = 0x02; // Discover Characteristics
|
||||
static const DC_MESSAGE_READ_CHARACTERISTIC = 0x03; // Read Characteristic
|
||||
static const DC_MESSAGE_WRITE_CHARACTERISTIC = 0x04; // Write Characteristic
|
||||
static const DC_MESSAGE_ENABLE_CHARACTERISTIC_NOTIFICATIONS = 0x05; // Enable Characteristic Notifications
|
||||
static const DC_MESSAGE_CHARACTERISTIC_NOTIFICATION = 0x06; // Characteristic Notification
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.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 'constants.dart';
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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/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:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult)
|
||||
@@ -24,67 +28,125 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
],
|
||||
);
|
||||
|
||||
bool _noLongerSendsEvents = false;
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2;
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.1.0';
|
||||
|
||||
@override
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$name V2";
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
return super.processData(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected)
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
|
||||
if (isConnected)
|
||||
if (core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Stack(
|
||||
children: [
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'Important Setup Information',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).small,
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).xSmall,
|
||||
if (kDebugMode)
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
|
||||
1. Open Zwift app
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Zwift Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
Button.secondary(
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.help_outline_outlined),
|
||||
child: Text(context.i18n.instructions),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
test();
|
||||
},
|
||||
child: Text('Test'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton.link(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
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,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
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:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@@ -17,27 +20,41 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
List<ControllerButton>? _lastButtonsClicked;
|
||||
|
||||
BleService? customService;
|
||||
|
||||
String get latestFirmwareVersion;
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId.toLowerCase());
|
||||
customService =
|
||||
services.firstOrNullWhere(
|
||||
(service) => service.uuid == ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
) ??
|
||||
services.firstOrNullWhere(
|
||||
(service) => service.uuid == ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
if (customService == null) {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
'You may need to update the firmware of ${scanResult.name} in Zwift Companion app',
|
||||
),
|
||||
);
|
||||
throw Exception(
|
||||
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
'Custom service ${[ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID, ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]} not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
);
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
final asyncCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
final syncTxCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
syncRxCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
@@ -45,14 +62,15 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService!.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService!.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
if (firmwareVersion != latestFirmwareVersion && firmwareVersion != null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_WARNING,
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
@@ -62,7 +80,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
Future<void> setupHandshake() async {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
ZwiftConstants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
@@ -71,9 +89,11 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
if (kDebugMode && false) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
@@ -117,7 +137,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
case ZwiftConstants.BATTERY_LEVEL_TYPE:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case ZwiftConstants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
@@ -134,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;
|
||||
}
|
||||
@@ -147,7 +167,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@override
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
core.settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performDown(buttonsClicked);
|
||||
@@ -156,7 +176,8 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
core.settings.getVibrationEnabled() &&
|
||||
canVibrate) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
@@ -166,10 +187,30 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
final vibrateCommand = Uint8List.fromList([...ZwiftConstants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
vibrateCommand,
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +1,103 @@
|
||||
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';
|
||||
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:bike_control/utils/requirements/multi.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 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import 'protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
|
||||
final zwiftEmulator = ZwiftEmulator();
|
||||
|
||||
class ZwiftEmulator {
|
||||
static final List<InGameAction> supportedActions = [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
];
|
||||
|
||||
ValueNotifier<bool> isConnected = ValueNotifier<bool>(false);
|
||||
bool get isAdvertising => _isAdvertising;
|
||||
class ZwiftEmulator extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
static const String connectionTitle = 'Zwift BLE Emulator';
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
GATTCharacteristic? _asyncCharacteristic;
|
||||
GATTCharacteristic? _syncTxCharacteristic;
|
||||
|
||||
ZwiftEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> startAdvertising(VoidCallback onUpdate) async {
|
||||
_isLoading = true;
|
||||
isStarted.value = true;
|
||||
onUpdate();
|
||||
|
||||
peripheralManager.stateChanged.forEach((state) {
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
if (Platform.isAndroid) {
|
||||
peripheralManager.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
_peripheralManager.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
_isAdvertising = false;
|
||||
isStarted.value = false;
|
||||
onUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
|
||||
core.settings.getZwiftBleEmulatorEnabled()) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
if (settings.getLastTarget() == Target.thisDevice) {
|
||||
if (core.settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
final syncTxCharacteristic = GATTCharacteristic.mutable(
|
||||
_syncTxCharacteristic = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
@@ -116,7 +123,7 @@ class ZwiftEmulator {
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
@@ -124,7 +131,7 @@ class ZwiftEmulator {
|
||||
print('Handling read request for SYNC TX characteristic');
|
||||
break;
|
||||
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
eventArgs.request,
|
||||
value: Uint8List.fromList([100]),
|
||||
);
|
||||
@@ -135,89 +142,78 @@ class ZwiftEmulator {
|
||||
|
||||
final request = eventArgs.request;
|
||||
final trimmedValue = Uint8List.fromList([]);
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
request,
|
||||
value: trimmedValue,
|
||||
);
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
_central = eventArgs.central;
|
||||
isConnected.value = true;
|
||||
|
||||
final characteristic = eventArgs.characteristic;
|
||||
final request = eventArgs.request;
|
||||
final value = request.value;
|
||||
print(
|
||||
'Write request for characteristic: ${characteristic.uuid}',
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected),
|
||||
);
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().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)}',
|
||||
);
|
||||
|
||||
final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2];
|
||||
final handshakeAlternative = ZwiftConstants.RIDE_ON; // e.g. Rouvy
|
||||
|
||||
if (value.contentEquals(handshake) || value.contentEquals(handshakeAlternative)) {
|
||||
print('Sending handshake');
|
||||
await peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
syncTxCharacteristic,
|
||||
value: ZwiftConstants.RIDE_ON,
|
||||
);
|
||||
onUpdate();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
final request = eventArgs.request;
|
||||
final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
|
||||
if (response != null) {
|
||||
await _peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
_syncTxCharacteristic!,
|
||||
value: response,
|
||||
);
|
||||
onUpdate();
|
||||
if (response == ZwiftConstants.RIDE_ON) {
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
await peripheralManager.respondWriteRequest(request);
|
||||
await _peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
// Device Information
|
||||
await peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('SwiftControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
@@ -239,9 +235,9 @@ class ZwiftEmulator {
|
||||
);
|
||||
|
||||
// Unknown Service
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT),
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
_asyncCharacteristic!,
|
||||
@@ -253,7 +249,7 @@ class ZwiftEmulator {
|
||||
],
|
||||
permissions: [],
|
||||
),
|
||||
syncTxCharacteristic,
|
||||
_syncTxCharacteristic!,
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'),
|
||||
descriptors: [],
|
||||
@@ -284,9 +280,9 @@ class ZwiftEmulator {
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name: 'SwiftControl',
|
||||
name: 'KICKR BIKE PRO 1337',
|
||||
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)],
|
||||
serviceData: {
|
||||
/*serviceData: {
|
||||
UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]),
|
||||
},
|
||||
manufacturerSpecificData: [
|
||||
@@ -294,24 +290,34 @@ class ZwiftEmulator {
|
||||
id: 0x094A,
|
||||
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]),
|
||||
),
|
||||
],
|
||||
],*/
|
||||
);
|
||||
print('Starting advertising with Zwift service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
await _peripheralManager.startAdvertising(advertisement);
|
||||
_isLoading = false;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
Future<void> stopAdvertising() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Future<String> sendAction(InGameAction inGameAction, int? inGameActionValue) async {
|
||||
final button = switch (inGameAction) {
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
@@ -326,7 +332,7 @@ class ZwiftEmulator {
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return 'Action ${inGameAction.name} not supported by Zwift Emulator';
|
||||
return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
final status = RideKeyPadStatus()
|
||||
@@ -335,33 +341,135 @@ class ZwiftEmulator {
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
final commandProto = Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]);
|
||||
if (isKeyDown) {
|
||||
final commandProto = Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]);
|
||||
|
||||
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto);
|
||||
_peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
_asyncCharacteristic!,
|
||||
value: commandProto,
|
||||
);
|
||||
}
|
||||
|
||||
final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
return 'Sent action: ${inGameAction.name}';
|
||||
if (isKeyUp) {
|
||||
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
}
|
||||
|
||||
return Success('Sent action: ${keyPair.inGameAction!.name}');
|
||||
}
|
||||
}
|
||||
|
||||
class ZwiftEmulatorInformation extends StatelessWidget {
|
||||
const ZwiftEmulatorInformation({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: zwiftEmulator.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Text('Zwift is ${isConnected ? 'connected' : 'not connected'}');
|
||||
},
|
||||
);
|
||||
},
|
||||
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,11 +1,14 @@
|
||||
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/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
final ZwiftDeviceType deviceType;
|
||||
|
||||
ZwiftPlay(super.scanResult, {required this.deviceType})
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButtons.y,
|
||||
@@ -28,6 +31,12 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_PLAY;
|
||||
|
||||
@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);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
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:universal_ble/universal_ble.dart';
|
||||
|
||||
class ZwiftRide extends ZwiftDevice {
|
||||
@@ -44,10 +44,10 @@ class ZwiftRide extends ZwiftDevice {
|
||||
);
|
||||
|
||||
@override
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
|
||||
String get latestFirmwareVersion => '1.2.0';
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.2.0';
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
@@ -146,7 +146,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
final notification = BatteryNotification.fromBuffer(message);
|
||||
if (batteryLevel != notification.newPercLevel) {
|
||||
batteryLevel = notification.newPercLevel;
|
||||
connection.signalChange(this);
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Opcode.CONTROLLER_NOTIFICATION:
|
||||
@@ -230,7 +230,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
buffer,
|
||||
withoutResponse: true,
|
||||
@@ -244,7 +244,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
buffer,
|
||||
withoutResponse: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user