utils.sim.sdl2_controller_bridge

SDL2 controller bridge for macOS WPILib simulation.

Problem

The WPILib sim GUI uses GLFW for joystick input. On macOS, Apple's Game Controller framework claims Xbox and PlayStation controllers at the OS level, preventing GLFW from reading their axes or buttons (it reports 0 for both). This makes physical controllers unusable in the simulator on Mac.

Solution

SDL2 has native support for macOS's Game Controller framework and can read these controllers correctly. This module spawns a daemon thread that:

  1. Initializes SDL2 and discovers connected game controllers.
  2. Maps each controller to a WPILib joystick port (first → port 0, second → port 1).
  3. Polls controller state at ~100 Hz and writes it to the HAL sim via hal.simulation functions.

The bridge writes directly to the HAL joystick data, bypassing the sim GUI's GLFW-based joystick handling entirely.

Important

Do NOT assign the controller in the sim GUI's System Joystick panel. If a controller is assigned there, the sim GUI will overwrite the HAL data with GLFW's empty values each tick, cancelling out the bridge's writes.

If no physical controllers are detected, the bridge exits silently and the sim GUI's virtual joystick (keyboard-driven) panel continues to work normally.

Requirements

macOS only. Install with::

pip install pysdl2 pysdl2-dll

These are listed in requirements.txt with sys_platform == "darwin" markers so they only install on Mac.

Not needed on Windows or Linux where GLFW reads controllers correctly.

  1"""
  2SDL2 controller bridge for macOS WPILib simulation.
  3
  4Problem
  5-------
  6The WPILib sim GUI uses GLFW for joystick input.  On macOS, Apple's Game
  7Controller framework claims Xbox and PlayStation controllers at the OS level,
  8preventing GLFW from reading their axes or buttons (it reports 0 for both).
  9This makes physical controllers unusable in the simulator on Mac.
 10
 11Solution
 12--------
 13SDL2 has native support for macOS's Game Controller framework and can read
 14these controllers correctly.  This module spawns a daemon thread that:
 15
 161. Initializes SDL2 and discovers connected game controllers.
 172. Maps each controller to a WPILib joystick port (first → port 0, second → port 1).
 183. Polls controller state at ~100 Hz and writes it to the HAL sim via
 19   ``hal.simulation`` functions.
 20
 21The bridge writes directly to the HAL joystick data, bypassing the sim GUI's
 22GLFW-based joystick handling entirely.
 23
 24Important
 25---------
 26**Do NOT assign the controller in the sim GUI's System Joystick panel.**
 27If a controller is assigned there, the sim GUI will overwrite the HAL data
 28with GLFW's empty values each tick, cancelling out the bridge's writes.
 29
 30If no physical controllers are detected, the bridge exits silently and the
 31sim GUI's virtual joystick (keyboard-driven) panel continues to work normally.
 32
 33Requirements
 34------------
 35macOS only.  Install with::
 36
 37    pip install pysdl2 pysdl2-dll
 38
 39These are listed in ``requirements.txt`` with ``sys_platform == "darwin"``
 40markers so they only install on Mac.
 41
 42Not needed on Windows or Linux where GLFW reads controllers correctly.
 43"""
 44
 45import sys
 46import threading
 47import time
 48
 49import hal.simulation
 50import wpilib
 51
 52
 53def start_controller_bridge() -> None:
 54    """Start the SDL2 controller bridge if running on macOS.
 55
 56    Safe to call on any platform — returns immediately on non-Mac systems.
 57    Should be called once during ``PhysicsEngine.__init__``.
 58    """
 59    if sys.platform != "darwin":
 60        return
 61
 62    try:
 63        import sdl2
 64    except ImportError:
 65        wpilib.reportWarning(
 66            "pysdl2 not installed — Xbox controller input in sim requires "
 67            "'pip install pysdl2 pysdl2-dll' on macOS"
 68        )
 69        return
 70
 71    # --- Mapping tables: SDL2 game controller → WPILib XboxController ---
 72
 73    # SDL2 axis constant → WPILib axis index
 74    axis_map = {
 75        sdl2.SDL_CONTROLLER_AXIS_LEFTX: 0,        # kLeftX
 76        sdl2.SDL_CONTROLLER_AXIS_LEFTY: 1,         # kLeftY
 77        sdl2.SDL_CONTROLLER_AXIS_TRIGGERLEFT: 2,   # kLeftTrigger
 78        sdl2.SDL_CONTROLLER_AXIS_TRIGGERRIGHT: 3,   # kRightTrigger
 79        sdl2.SDL_CONTROLLER_AXIS_RIGHTX: 4,        # kRightX
 80        sdl2.SDL_CONTROLLER_AXIS_RIGHTY: 5,        # kRightY
 81    }
 82
 83    # SDL2 button constant → bit index in HAL button bitmask.
 84    # WPILib buttons are 1-based: bit 0 = button 1 (A), bit 1 = button 2 (B), etc.
 85    button_map = {
 86        sdl2.SDL_CONTROLLER_BUTTON_A: 0,
 87        sdl2.SDL_CONTROLLER_BUTTON_B: 1,
 88        sdl2.SDL_CONTROLLER_BUTTON_X: 2,
 89        sdl2.SDL_CONTROLLER_BUTTON_Y: 3,
 90        sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: 4,
 91        sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: 5,
 92        sdl2.SDL_CONTROLLER_BUTTON_BACK: 6,
 93        sdl2.SDL_CONTROLLER_BUTTON_START: 7,
 94        sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: 8,
 95        sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: 9,
 96    }
 97
 98    # D-pad buttons for POV conversion (up, down, left, right order)
 99    dpad_buttons = [
100        sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP,
101        sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN,
102        sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT,
103        sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT,
104    ]
105
106    def _dpad_to_pov(up, down, left, right):
107        """Convert four d-pad booleans to a WPILib POV angle (-1 if released)."""
108        if up and right:
109            return 45
110        if right and down:
111            return 135
112        if down and left:
113            return 225
114        if left and up:
115            return 315
116        if up:
117            return 0
118        if right:
119            return 90
120        if down:
121            return 180
122        if left:
123            return 270
124        return -1
125
126    def _bridge_thread():
127        sdl2.SDL_SetHint(sdl2.SDL_HINT_JOYSTICK_MFI, b"1")
128        sdl2.SDL_Init(sdl2.SDL_INIT_GAMECONTROLLER)
129
130        # Allow time for controller discovery
131        for _ in range(20):
132            sdl2.SDL_PumpEvents()
133            time.sleep(0.05)
134
135        # Open all game-controller-capable devices.
136        # First controller → port 0 (driver), second → port 1 (operator).
137        controllers = []  # list of (sdl2_gc_handle, wpilib_port)
138        for idx in range(sdl2.SDL_NumJoysticks()):
139            if not sdl2.SDL_IsGameController(idx):
140                continue
141            gc = sdl2.SDL_GameControllerOpen(idx)
142            if not gc:
143                continue
144            port = len(controllers)
145            gc_name = sdl2.SDL_GameControllerName(gc)
146            if gc_name and isinstance(gc_name, bytes):
147                gc_name = gc_name.decode()
148            wpilib.reportWarning(
149                f"SDL2 bridge: '{gc_name}' → HAL joystick port {port}. "
150                f"Do NOT assign this controller in the sim GUI System Joystick panel."
151            )
152            hal.simulation.setJoystickAxisCount(port, 6)
153            hal.simulation.setJoystickButtonCount(port, 10)
154            hal.simulation.setJoystickPOVCount(port, 1)
155            hal.simulation.setJoystickIsXbox(port, True)
156            hal.simulation.setJoystickName(port, gc_name or "SDL2 Controller")
157            controllers.append((gc, port))
158
159        if not controllers:
160            # No physical controllers found — exit silently so the sim GUI's
161            # virtual joystick panel continues to work.
162            sdl2.SDL_Quit()
163            return
164
165        # Poll loop at ~100 Hz
166        while True:
167            sdl2.SDL_PumpEvents()
168
169            for gc, port in controllers:
170                # Axes (SDL2: -32768..32767 → WPILib: -1.0..1.0)
171                for sdl_axis, wpi_axis in axis_map.items():
172                    raw = sdl2.SDL_GameControllerGetAxis(gc, sdl_axis)
173                    hal.simulation.setJoystickAxis(port, wpi_axis, raw / 32767.0)
174
175                # Buttons → packed bitmask
176                buttons = 0
177                for sdl_btn, bit_index in button_map.items():
178                    if sdl2.SDL_GameControllerGetButton(gc, sdl_btn):
179                        buttons |= (1 << bit_index)
180                hal.simulation.setJoystickButtonsValue(port, buttons)
181
182                # D-pad → POV angle
183                dpad = [
184                    bool(sdl2.SDL_GameControllerGetButton(gc, b))
185                    for b in dpad_buttons
186                ]
187                hal.simulation.setJoystickPOV(port, 0, _dpad_to_pov(*dpad))
188
189            hal.simulation.notifyDriverStationNewData()
190            time.sleep(0.01)
191
192    t = threading.Thread(
193        target=_bridge_thread, daemon=True, name="sdl2-controller-bridge"
194    )
195    t.start()
def start_controller_bridge() -> None:
 54def start_controller_bridge() -> None:
 55    """Start the SDL2 controller bridge if running on macOS.
 56
 57    Safe to call on any platform — returns immediately on non-Mac systems.
 58    Should be called once during ``PhysicsEngine.__init__``.
 59    """
 60    if sys.platform != "darwin":
 61        return
 62
 63    try:
 64        import sdl2
 65    except ImportError:
 66        wpilib.reportWarning(
 67            "pysdl2 not installed — Xbox controller input in sim requires "
 68            "'pip install pysdl2 pysdl2-dll' on macOS"
 69        )
 70        return
 71
 72    # --- Mapping tables: SDL2 game controller → WPILib XboxController ---
 73
 74    # SDL2 axis constant → WPILib axis index
 75    axis_map = {
 76        sdl2.SDL_CONTROLLER_AXIS_LEFTX: 0,        # kLeftX
 77        sdl2.SDL_CONTROLLER_AXIS_LEFTY: 1,         # kLeftY
 78        sdl2.SDL_CONTROLLER_AXIS_TRIGGERLEFT: 2,   # kLeftTrigger
 79        sdl2.SDL_CONTROLLER_AXIS_TRIGGERRIGHT: 3,   # kRightTrigger
 80        sdl2.SDL_CONTROLLER_AXIS_RIGHTX: 4,        # kRightX
 81        sdl2.SDL_CONTROLLER_AXIS_RIGHTY: 5,        # kRightY
 82    }
 83
 84    # SDL2 button constant → bit index in HAL button bitmask.
 85    # WPILib buttons are 1-based: bit 0 = button 1 (A), bit 1 = button 2 (B), etc.
 86    button_map = {
 87        sdl2.SDL_CONTROLLER_BUTTON_A: 0,
 88        sdl2.SDL_CONTROLLER_BUTTON_B: 1,
 89        sdl2.SDL_CONTROLLER_BUTTON_X: 2,
 90        sdl2.SDL_CONTROLLER_BUTTON_Y: 3,
 91        sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: 4,
 92        sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: 5,
 93        sdl2.SDL_CONTROLLER_BUTTON_BACK: 6,
 94        sdl2.SDL_CONTROLLER_BUTTON_START: 7,
 95        sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: 8,
 96        sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: 9,
 97    }
 98
 99    # D-pad buttons for POV conversion (up, down, left, right order)
100    dpad_buttons = [
101        sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP,
102        sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN,
103        sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT,
104        sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT,
105    ]
106
107    def _dpad_to_pov(up, down, left, right):
108        """Convert four d-pad booleans to a WPILib POV angle (-1 if released)."""
109        if up and right:
110            return 45
111        if right and down:
112            return 135
113        if down and left:
114            return 225
115        if left and up:
116            return 315
117        if up:
118            return 0
119        if right:
120            return 90
121        if down:
122            return 180
123        if left:
124            return 270
125        return -1
126
127    def _bridge_thread():
128        sdl2.SDL_SetHint(sdl2.SDL_HINT_JOYSTICK_MFI, b"1")
129        sdl2.SDL_Init(sdl2.SDL_INIT_GAMECONTROLLER)
130
131        # Allow time for controller discovery
132        for _ in range(20):
133            sdl2.SDL_PumpEvents()
134            time.sleep(0.05)
135
136        # Open all game-controller-capable devices.
137        # First controller → port 0 (driver), second → port 1 (operator).
138        controllers = []  # list of (sdl2_gc_handle, wpilib_port)
139        for idx in range(sdl2.SDL_NumJoysticks()):
140            if not sdl2.SDL_IsGameController(idx):
141                continue
142            gc = sdl2.SDL_GameControllerOpen(idx)
143            if not gc:
144                continue
145            port = len(controllers)
146            gc_name = sdl2.SDL_GameControllerName(gc)
147            if gc_name and isinstance(gc_name, bytes):
148                gc_name = gc_name.decode()
149            wpilib.reportWarning(
150                f"SDL2 bridge: '{gc_name}' → HAL joystick port {port}. "
151                f"Do NOT assign this controller in the sim GUI System Joystick panel."
152            )
153            hal.simulation.setJoystickAxisCount(port, 6)
154            hal.simulation.setJoystickButtonCount(port, 10)
155            hal.simulation.setJoystickPOVCount(port, 1)
156            hal.simulation.setJoystickIsXbox(port, True)
157            hal.simulation.setJoystickName(port, gc_name or "SDL2 Controller")
158            controllers.append((gc, port))
159
160        if not controllers:
161            # No physical controllers found — exit silently so the sim GUI's
162            # virtual joystick panel continues to work.
163            sdl2.SDL_Quit()
164            return
165
166        # Poll loop at ~100 Hz
167        while True:
168            sdl2.SDL_PumpEvents()
169
170            for gc, port in controllers:
171                # Axes (SDL2: -32768..32767 → WPILib: -1.0..1.0)
172                for sdl_axis, wpi_axis in axis_map.items():
173                    raw = sdl2.SDL_GameControllerGetAxis(gc, sdl_axis)
174                    hal.simulation.setJoystickAxis(port, wpi_axis, raw / 32767.0)
175
176                # Buttons → packed bitmask
177                buttons = 0
178                for sdl_btn, bit_index in button_map.items():
179                    if sdl2.SDL_GameControllerGetButton(gc, sdl_btn):
180                        buttons |= (1 << bit_index)
181                hal.simulation.setJoystickButtonsValue(port, buttons)
182
183                # D-pad → POV angle
184                dpad = [
185                    bool(sdl2.SDL_GameControllerGetButton(gc, b))
186                    for b in dpad_buttons
187                ]
188                hal.simulation.setJoystickPOV(port, 0, _dpad_to_pov(*dpad))
189
190            hal.simulation.notifyDriverStationNewData()
191            time.sleep(0.01)
192
193    t = threading.Thread(
194        target=_bridge_thread, daemon=True, name="sdl2-controller-bridge"
195    )
196    t.start()

Start the SDL2 controller bridge if running on macOS.

Safe to call on any platform — returns immediately on non-Mac systems. Should be called once during PhysicsEngine.__init__.