From d9bca1072b541a83f2cff8b96465b24b785e5c09 Mon Sep 17 00:00:00 2001 From: Kochka Date: Sat, 27 Sep 2025 15:02:24 +0200 Subject: [PATCH] fix: use PyDirectInput on Windows (DirectInput scan codes) to make arrow keys work in Rouvy --- README.md | 7 +- requirements.txt | 3 +- square_controller.py | 154 +++++++++++++++++++++++++++++-------------- 3 files changed, 111 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 12b4d92..0b3f23b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ControlSquare -This project enables Bluetooth connectivity with the Elite Square device, allowing you to subscribe to button press notifications and simulate keyboard shortcuts in indoor cycling applications such as Zwift, MyWhoosh, and TrainingPeaks Virtual. +This project enables Bluetooth connectivity with the Elite Square device, allowing you to subscribe to button press notifications and simulate keyboard shortcuts in indoor cycling applications such as Zwift, Rouvy, MyWhoosh, and TrainingPeaks Virtual. Currently, the Elite Square device only allows steering control in Zwift using the X and Circle buttons, while the remaining buttons have no functionality. This Python script extends the device's capabilities by enabling all buttons to be used for menu navigation and other controls. @@ -9,6 +9,7 @@ Currently, the Elite Square device only allows steering control in Zwift using t - Python 3.7+ - bleak>=0.21.0 - pynput>=1.7.6 +- pydirectinput>=1.0.4 (Windows) - Windows 11 (tested and verified) ## Installation @@ -57,7 +58,7 @@ Below is the mapping of buttons and their corresponding button codes (all codes | X | 00002000 | None | Left steering control (default; you need pairing Square in Zwift) | | Square | 00001000 | h | Toggle HUD | | Left Campagnolo | 00008000 | Left Arrow| Navigate left | -| Left brake | 00004000 | Toggles '6'/'1' | Toggles backward/forward camera view | +| Left brake | 00004000 | Toggles '6'/'1' | Toggles backward/forward camera view | | Left shift 1 | 00000002 | None | (Reserved for virtual gears) | | Left shift 2 | 00000001 | None | (Reserved for virtual gears) | | Y | 02000000 | g | Alternate power and watts and FC view | @@ -67,7 +68,7 @@ Below is the mapping of buttons and their corresponding button codes (all codes | Circle | 20000000 | None | Rigth steering control (default; you need pairing Square in Zwift) | | Triangle | 10000000 | Space | Activate powerup | | Right Campagnolo | 80000000 | Rigth Arrow | Navigate Right | -| Right brake | 40000000 | None | No view change function | +| Right brake | 40000000 | None | No view change function | | Right shift 1 | 00020000 | None | (Reserved for virtual gears) | | Right shift 2 | 00010000 | None | (Reserved for virtual gears) | diff --git a/requirements.txt b/requirements.txt index c26de1c..f9ada84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ bleak>=0.21.0 -pynput>=1.7.6 \ No newline at end of file +pynput>=1.7.6 +pydirectinput>=1.0.4; platform_system=="Windows" diff --git a/square_controller.py b/square_controller.py index c254600..06bdd66 100644 --- a/square_controller.py +++ b/square_controller.py @@ -1,7 +1,25 @@ import asyncio from bleak import BleakClient, BleakScanner import time -from pynput.keyboard import Key, Controller, KeyCode +import platform +import sys + +# Detect OS and import appropriate library +OS_TYPE = platform.system() + +if OS_TYPE == "Windows": + try: + import pydirectinput + USE_PYDIRECTINPUT = True + pydirectinput.PAUSE = 0.05 + except ImportError: + USE_PYDIRECTINPUT = False + from pynput.keyboard import Key, Controller, KeyCode + keyboard = Controller() +else: + USE_PYDIRECTINPUT = False + from pynput.keyboard import Key, Controller, KeyCode + keyboard = Controller() # Constants DEVICE_NAME = "SQUARE" @@ -32,33 +50,58 @@ "00010000": "Right shift 2" } -# Key mapping for button presses -KEY_MAPPING = { - "Up": Key.up, - "Left": Key.left, - "Down": Key.down, - "Right": Key.right, - "X": None, # left steering - "Square": KeyCode.from_char('r'), # pairing screen, - "Left Campagnolo": Key.left, - "Left brake": None, # Toggles backward/forward view ('6'/'1') - "Left shift 1": None, - "Left shift 2": None, - "Y": KeyCode.from_char('g'), # alternate power and watts and FC - "A": Key.enter, - "B": Key.esc, - "Z": KeyCode.from_char('t'), # garage screen - "Circle": None, # Right steering - "Triangle": Key.space, # Activate powerup - "Right Campagnolo": Key.right, - "Right brake": None, - "Right shift 1": None, - "Right shift 2": None -} +# Key mapping based on the input library being used +if USE_PYDIRECTINPUT: + # Key mapping for pydirectinput (strings) + KEY_MAPPING = { + "Up": "up", + "Left": "left", + "Down": "down", + "Right": "right", + "X": None, # left steering + "Square": "r", # pairing screen + "Left Campagnolo": "left", + "Left brake": None, # Toggles backward/forward view ('6'/'1') + "Left shift 1": None, + "Left shift 2": None, + "Y": "g", # alternate power and watts and FC + "A": "enter", + "B": "esc", + "Z": "t", # garage screen + "Circle": None, # Right steering + "Triangle": "space", # Activate powerup + "Right Campagnolo": "right", + "Right brake": None, + "Right shift 1": None, + "Right shift 2": None + } +else: + # Key mapping for pynput (Key objects) + KEY_MAPPING = { + "Up": Key.up, + "Left": Key.left, + "Down": Key.down, + "Right": Key.right, + "X": None, # left steering + "Square": KeyCode.from_char('r'), # pairing screen + "Left Campagnolo": Key.left, + "Left brake": None, # Toggles backward/forward view ('6'/'1') + "Left shift 1": None, + "Left shift 2": None, + "Y": KeyCode.from_char('g'), # alternate power and watts and FC + "A": Key.enter, + "B": Key.esc, + "Z": KeyCode.from_char('t'), # garage screen + "Circle": None, # Right steering + "Triangle": Key.space, # Activate powerup + "Right Campagnolo": Key.right, + "Right brake": None, + "Right shift 1": None, + "Right shift 2": None + } # Variable to store the last value last_value = None -keyboard = Controller() left_brake_is_forward_view = False def extract_button_code(hex_value): @@ -77,6 +120,21 @@ def extract_button_code(hex_value): # If we can't identify a specific pattern, return the whole value return hex_value +def press_key(key_value): + """Press a key using the appropriate method based on the library""" + if USE_PYDIRECTINPUT: + try: + pydirectinput.press(key_value) + except Exception as e: + print(f"Error: {e}") + else: + try: + keyboard.press(key_value) + time.sleep(0.05) + keyboard.release(key_value) + except Exception as e: + print(f"Error: {e}") + def notification_handler(sender, data): """Handler for notifications received from the BLE device""" global last_value, left_brake_is_forward_view @@ -97,30 +155,22 @@ def notification_handler(sender, data): # If the value is in our mapping, it's a button press # Otherwise, consider it as "No button pressed" button_name = BUTTON_MAPPING.get(current_value, "No button pressed") - if button_name == "No button pressed": - # Simplified log message for unpressed button - print("Unpressed button") - else: - print(f"Button pressed: {button_name} (full hex: {full_value}, button code: {current_value})") - - # Simulate key press if it's a valid button - if button_name == "Left brake": - left_brake_is_forward_view = not left_brake_is_forward_view - if left_brake_is_forward_view: - key = KeyCode.from_char('1') - print(f"Simulating key press: {key} (Left brake - forward view)") + + if button_name != "No button pressed": + print(f"{button_name}") + + # Simulate key press if it's a valid button + if button_name == "Left brake": + left_brake_is_forward_view = not left_brake_is_forward_view + if USE_PYDIRECTINPUT: + press_key('1' if left_brake_is_forward_view else '6') + else: + press_key(KeyCode.from_char('1' if left_brake_is_forward_view else '6')) else: - key = KeyCode.from_char('6') - print(f"Simulating key press: {key} (Left brake - backward view)") - keyboard.press(key) - keyboard.release(key) - elif button_name != "No button pressed": - key = KEY_MAPPING.get(button_name) - if key: - print(f"Simulating key press: {key}") - keyboard.press(key) - keyboard.release(key) - + key = KEY_MAPPING.get(button_name) + if key: + press_key(key) + last_value = full_value async def connect_and_listen(): @@ -158,7 +208,13 @@ async def connect_and_listen(): await asyncio.sleep(RECONNECT_DELAY) async def main(): + print(f"BLE Bridge - {OS_TYPE} - {'pydirectinput' if USE_PYDIRECTINPUT else 'pynput'}") await connect_and_listen() if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nStopped") + except Exception as e: + print(f"Error: {e}")