Skip to content

Commit 9fbe8f1

Browse files
committed
add Zwift Click V2 button control
1 parent b754ea1 commit 9fbe8f1

14 files changed

Lines changed: 1233 additions & 2 deletions

NOTICE

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ third_party/apache/LICENSE.Apache2
1919

2020
---
2121

22+
This project also includes code adapted from a GPL-3.0 licensed project.
23+
24+
Portions of the code in the following file are
25+
adapted from swiftcontrol by Jonas Bark
26+
(https://github.com/jonasbark/swiftcontrol)
27+
and licensed under the GNU General Public License v3.0:
28+
29+
- modules/sensor/ble/zwift_click_v2.py
30+
31+
The full text of the GNU General Public License v3.0 is available at:
32+
LICENSE
33+
34+
---
35+
2236
This project also includes code adapted from a BSD 3-Clause licensed project.
2337

2438
Portions of the code in the following file are

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ This project also includes code ported and modified from the Apache NuttX RTOS p
239239
- modules/sensor/gps/cython/cxd5610_rpi.c
240240
- modules/sensor/gps/cython/cxd5610_rpi.h
241241

242+
This project also includes code adapted from swiftcontrol under the GNU General Public License v3.0. See the NOTICE file for details.
243+
- modules/sensor/ble/zwift_click_v2.py
244+
242245
This project also includes code ported and modified from Bosch Sensortec GmbH's code under the BSD 3-Clause License. See the NOTICE file for details.
243246
- [BHI360_SensorAPI](https://github.com/boschsensortec/BHI360_SensorAPI)
244247
- modules/sensor/i2c/cython/bhi360_shuttle_board_3/common.c

modules/_qt_qtwidgets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
# QtWidgets
4747
QT_EXPANDING = QtWidgets.QSizePolicy.Policy.Expanding
4848
QT_FIXED = QtWidgets.QSizePolicy.Policy.Fixed
49+
QT_PREFERRED = QtWidgets.QSizePolicy.Policy.Preferred
4950

5051
# for textedit
5152
QT_SCROLLBAR_ALWAYSOFF = QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff

modules/button_config.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,81 @@ class Button_Config:
9696
"LAP": ("map_move_x_plus",),
9797
},
9898
},
99+
# Zwift Click V2 (BLE)
100+
"Zwift_Click_V2": {
101+
"MAIN": {
102+
"NAVIGATION_UP": ("", ""),
103+
"NAVIGATION_DOWN": ("", ""),
104+
"NAVIGATION_LEFT": ("scroll_prev", ""),
105+
"NAVIGATION_RIGHT": ("scroll_next", ""),
106+
"SHIFT_UP_LEFT": ("", ""),
107+
"SHIFT_UP_RIGHT": ("", ""),
108+
"A": ("start_and_stop_manual", ""),
109+
"B": ("count_laps", "reset_count"),
110+
"Y": ("enter_menu", ""),
111+
"Z": ("turn_on_off_light", ""),
112+
},
113+
"MENU": {
114+
"NAVIGATION_UP": ("press_shift_tab", ""),
115+
"NAVIGATION_DOWN": ("press_tab", ""),
116+
"NAVIGATION_LEFT": ("back_menu", ""),
117+
"NAVIGATION_RIGHT": ("press_space", ""),
118+
"SHIFT_UP_LEFT": ("", ""),
119+
"SHIFT_UP_RIGHT": ("", ""),
120+
"A": ("press_space", ""),
121+
"B": ("back_menu", ""),
122+
"Y": ("", ""),
123+
"Z": ("", ""),
124+
},
125+
"MAP": {
126+
"NAVIGATION_UP": ("", ""),
127+
"NAVIGATION_DOWN": ("", ""),
128+
"NAVIGATION_LEFT": ("scroll_prev", "map_overlay_prev_time"),
129+
"NAVIGATION_RIGHT": ("scroll_next", "map_overlay_next_time"),
130+
"SHIFT_UP_LEFT": ("map_zoom_minus", ""),
131+
"SHIFT_UP_RIGHT": ("map_zoom_plus", ""),
132+
"A": ("start_and_stop_manual", ""),
133+
"B": ("count_laps", "reset_count"),
134+
"Y": ("change_mode", ""),
135+
"Z": ("change_map_overlays", ""),
136+
},
137+
"MAP_1": {
138+
"NAVIGATION_UP": ("map_move_y_plus", ""),
139+
"NAVIGATION_DOWN": ("map_move_y_minus", ""),
140+
"NAVIGATION_LEFT": ("map_move_x_minus", "map_overlay_prev_time"),
141+
"NAVIGATION_RIGHT": ("map_move_x_plus", "map_overlay_next_time"),
142+
"SHIFT_UP_LEFT": ("map_zoom_minus", ""),
143+
"SHIFT_UP_RIGHT": ("map_zoom_plus", ""),
144+
"A": ("start_and_stop_manual", ""),
145+
"B": ("count_laps", "reset_count"),
146+
"Y": ("change_mode", ""),
147+
"Z": ("change_map_overlays", ""),
148+
},
149+
"COURSE_PROFILE": {
150+
"NAVIGATION_UP": ("", ""),
151+
"NAVIGATION_DOWN": ("", ""),
152+
"NAVIGATION_LEFT": ("scroll_prev", ""),
153+
"NAVIGATION_RIGHT": ("scroll_next", ""),
154+
"SHIFT_UP_LEFT": ("map_zoom_minus", ""),
155+
"SHIFT_UP_RIGHT": ("map_zoom_plus", ""),
156+
"A": ("start_and_stop_manual", ""),
157+
"B": ("count_laps", "reset_count"),
158+
"Y": ("change_mode", ""),
159+
"Z": ("", ""),
160+
},
161+
"COURSE_PROFILE_1": {
162+
"NAVIGATION_UP": ("", ""),
163+
"NAVIGATION_DOWN": ("", ""),
164+
"NAVIGATION_LEFT": ("map_move_x_minus", ""),
165+
"NAVIGATION_RIGHT": ("map_move_x_plus", ""),
166+
"SHIFT_UP_LEFT": ("map_zoom_minus", ""),
167+
"SHIFT_UP_RIGHT": ("map_zoom_plus", ""),
168+
"A": ("start_and_stop_manual", ""),
169+
"B": ("count_laps", "reset_count"),
170+
"Y": ("change_mode", ""),
171+
"Z": ("", ""),
172+
},
173+
},
99174
# GPIO button action (short press / long press) from gui_pyqt
100175
# call from SensorGPIO.my_callback(self, channel)
101176
# number is from GPIO.setmode(GPIO.BCM)

modules/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,12 @@ class Config:
392392
G_BT_PAN_DEVICE = ""
393393
G_AUTO_BT_TETHERING = False
394394

395+
# Zwift Click V2 (BLE remote buttons)
396+
# Keep only the user-facing enable flag here; protocol/tuning defaults live in zwift_click_v2.py.
397+
G_ZWIFT_CLICK_V2 = {
398+
"STATUS": False,
399+
}
400+
395401
#######################
396402
# class objects #
397403
#######################

modules/gui_pyqt.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ def delay_init(self):
253253
SensorMenuWidget,
254254
ANTMenuWidget,
255255
ANTListWidget,
256+
BLEMenuWidget,
256257
)
257258
from modules.pyqt.menu.pyqt_course_menu_widget import (
258259
CoursesMenuWidget,
@@ -302,6 +303,7 @@ def delay_init(self):
302303

303304
# reverse order (make children widget first, then make parent widget)
304305
menus = [
306+
("BLE Sensors", BLEMenuWidget),
305307
("ANT+ Detail", ANTListWidget),
306308
("ANT+ Sensors", ANTMenuWidget),
307309
("Wheel Size", AdjustWheelCircumferenceWidget),

modules/helper/bluetooth/bluetooth_manager.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import asyncio
22
import shutil
33
import time
4-
from datetime import datetime
54
from enum import Enum
65

76
from modules.app_logger import app_logger

modules/helper/setting.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def read(self):
4848
self.config.G_BT_PAN_DEVICE = c["BT_PAN_DEVICE"]
4949
if "AUTO_BT_TETHERING" in c:
5050
self.config.G_AUTO_BT_TETHERING = c.getboolean("AUTO_BT_TETHERING")
51+
if "USE_ZWIFT_CLICK_V2" in c:
52+
self.config.G_ZWIFT_CLICK_V2["STATUS"] = c.getboolean("USE_ZWIFT_CLICK_V2")
5153
if "GADGETBRIDGE_STATUS" in c:
5254
self.config.G_GADGETBRIDGE["STATUS"] = c.getboolean("GADGETBRIDGE_STATUS")
5355
if "GADGETBRIDGE_USE_GPS" in c:
@@ -230,6 +232,7 @@ def write_config(self):
230232
c = self.config_parser["BT"]
231233
c["BT_PAN_DEVICE"] = str(self.config.G_BT_PAN_DEVICE)
232234
c["AUTO_BT_TETHERING"] = str(self.config.G_AUTO_BT_TETHERING)
235+
c["USE_ZWIFT_CLICK_V2"] = str(self.config.G_ZWIFT_CLICK_V2["STATUS"])
233236
c["GADGETBRIDGE_STATUS"] = str(self.config.G_GADGETBRIDGE["STATUS"])
234237
c["GADGETBRIDGE_USE_GPS"] = str(self.config.G_GADGETBRIDGE["USE_GPS"])
235238

modules/pyqt/menu/pyqt_sensor_menu_widget.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import os
2+
import signal
3+
import sys
4+
15
from modules.app_logger import app_logger
6+
from modules.utils.cmd import start_process
27
from modules._qt_qtwidgets import QtCore, QtWidgets, QtGui
38
from .pyqt_menu_widget import MenuWidget, ListWidget, ListItemWidget
49

@@ -9,6 +14,7 @@ def setup_menu(self):
914
button_conf = (
1015
# Name(page_name), button_attribute, connected functions, layout
1116
("ANT+ Sensors", "submenu", self.ant_sensors_menu),
17+
("BLE Sensors", "submenu", self.ble_sensors_menu),
1218
("Wheel Size", "submenu", self.adjust_wheel_circumference),
1319
("Auto Light", "toggle", lambda: self.onoff_auto_light(True)),
1420
("Auto Stop", None, None),
@@ -27,6 +33,9 @@ def ant_sensors_menu(self):
2733
return
2834
self.change_page("ANT+ Sensors")
2935

36+
def ble_sensors_menu(self):
37+
self.change_page("BLE Sensors")
38+
3039
def adjust_wheel_circumference(self):
3140
self.change_page("Wheel Size", preprocess=True)
3241

@@ -38,6 +47,7 @@ def onoff_auto_light(self, change=True):
3847
self.config.G_ANT["USE_AUTO_LIGHT"] = not self.config.G_ANT["USE_AUTO_LIGHT"]
3948
self.buttons["Auto Light"].change_toggle(self.config.G_ANT["USE_AUTO_LIGHT"])
4049

50+
4151
class ANTMenuWidget(MenuWidget):
4252
def setup_menu(self):
4353
button_conf = []
@@ -186,3 +196,130 @@ def setup_ui(self):
186196
# outer layout (custom)
187197
self.outer_layout.insertWidget(0, icon)
188198
self.enter_signal.connect(self.parentWidget().button_func)
199+
200+
201+
class BLEMenuWidget(MenuWidget):
202+
fake_trainer_proc = None
203+
204+
def setup_menu(self):
205+
button_conf = (
206+
# Name(page_name), button_attribute, connected functions, layout
207+
("Zwift Click V2", "toggle", lambda: self.onoff_zwift_click_v2(True)),
208+
("Fake Trainer for Zwift", "toggle", lambda: self.onoff_fake_trainer(True)),
209+
)
210+
self.add_buttons(button_conf)
211+
self.onoff_zwift_click_v2(False)
212+
#self.onoff_fake_trainer(False)
213+
214+
#if self.config.logger.sensor.sensor_ble.enabled():
215+
# self.buttons["Zwift Click V2"].disable()
216+
217+
if self.config.ble_uart is None:
218+
self.buttons["Fake Trainer for Zwift"].disable()
219+
220+
def onoff_zwift_click_v2(self, change=True):
221+
if change:
222+
self.config.G_ZWIFT_CLICK_V2["STATUS"] = not self.config.G_ZWIFT_CLICK_V2["STATUS"]
223+
sensor_ble = self.config.logger.sensor.sensor_ble
224+
if self.config.G_ZWIFT_CLICK_V2["STATUS"]:
225+
if not sensor_ble.connect_zwift_click_v2():
226+
app_logger.warning("Zwift Click V2 toggle skipped: BLE not enabled")
227+
else:
228+
sensor_ble.disconnect_zwift_click_v2()
229+
self.buttons["Zwift Click V2"].change_toggle(self.config.G_ZWIFT_CLICK_V2["STATUS"])
230+
231+
def onoff_fake_trainer(self, change=True):
232+
if change:
233+
if self._fake_trainer_is_running():
234+
self._stop_fake_trainer_process()
235+
else:
236+
started = self._start_fake_trainer_process()
237+
if not started:
238+
self._stop_fake_trainer_process()
239+
self.buttons["Fake Trainer for Zwift"].change_toggle(self._fake_trainer_is_running())
240+
241+
def on_back_menu(self):
242+
self._stop_fake_trainer_process()
243+
self.buttons["Fake Trainer for Zwift"].change_toggle(False)
244+
245+
def _fake_trainer_is_running(self):
246+
proc = self.fake_trainer_proc
247+
return proc is not None and proc.poll() is None
248+
249+
def _start_fake_trainer_process(self):
250+
if self._fake_trainer_is_running():
251+
return True
252+
253+
fake_trainer_path = os.path.abspath(
254+
os.path.join(
255+
os.path.dirname(__file__),
256+
"..",
257+
"..",
258+
"sensor",
259+
"ble",
260+
"fake_trainer.py",
261+
)
262+
)
263+
if not os.path.isfile(fake_trainer_path):
264+
app_logger.warning(f"Fake trainer not found: {fake_trainer_path}")
265+
self.buttons["Fake Trainer for Zwift"].change_toggle(False)
266+
return False
267+
268+
try:
269+
self.fake_trainer_proc = start_process(
270+
[sys.executable, fake_trainer_path],
271+
cmd_print=False,
272+
)
273+
except Exception as exc:
274+
app_logger.warning(f"Fake trainer start failed: {exc}")
275+
self.fake_trainer_proc = None
276+
self.buttons["Fake Trainer for Zwift"].change_toggle(False)
277+
return False
278+
279+
if self.fake_trainer_proc.poll() is not None:
280+
app_logger.warning("Fake trainer start failed: process exited early")
281+
self.fake_trainer_proc = None
282+
self.buttons["Fake Trainer for Zwift"].change_toggle(False)
283+
return False
284+
285+
QtCore.QTimer.singleShot(200, self._check_fake_trainer_process)
286+
app_logger.info("Fake trainer started")
287+
return True
288+
289+
def _check_fake_trainer_process(self):
290+
if self._fake_trainer_is_running():
291+
return
292+
self.fake_trainer_proc = None
293+
self.buttons["Fake Trainer for Zwift"].change_toggle(False)
294+
app_logger.warning("Fake trainer stopped unexpectedly")
295+
296+
def _stop_fake_trainer_process(self):
297+
proc = self.fake_trainer_proc
298+
if proc is None:
299+
return
300+
if proc.poll() is not None:
301+
self.fake_trainer_proc = None
302+
return
303+
304+
try:
305+
proc.send_signal(signal.SIGINT)
306+
proc.wait(timeout=1.0)
307+
except Exception:
308+
pass
309+
310+
if proc.poll() is None:
311+
try:
312+
proc.terminate()
313+
proc.wait(timeout=1.0)
314+
except Exception:
315+
pass
316+
317+
if proc.poll() is None:
318+
try:
319+
proc.kill()
320+
proc.wait(timeout=1.0)
321+
except Exception:
322+
pass
323+
324+
self.fake_trainer_proc = None
325+
app_logger.info("Fake trainer stopped")

0 commit comments

Comments
 (0)