Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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) |

Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
bleak>=0.21.0
pynput>=1.7.6
pynput>=1.7.6
pydirectinput>=1.0.4; platform_system=="Windows"
154 changes: 105 additions & 49 deletions square_controller.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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())
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nStopped")
except Exception as e:
print(f"Error: {e}")