Repository navigation
Add PS5 DualSense controller support on Windows #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ovilikaris-dotcom
wants to merge
16
commits into
main
Choose a base branch
from
feature/ps5-controller-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
32273c4
Add the SolidWorks-exported arm model and joint demo
yassinsolim d46d9f3
Add simulated Xbox teleop for the arm
yassinsolim 5813a42
Merge remote-tracking branch 'origin/main' into feature/new-arm-urdf
yassinsolim 0919f04
Merge branch 'feature/new-arm-urdf' into feature/xbox-teleop
yassinsolim cbd6c45
Run the bringup launch tests on isolated ROS domains
yassinsolim 10db4db
Merge branch 'feature/new-arm-urdf' into feature/xbox-teleop
yassinsolim 11b8765
Parse launch booleans like IfCondition and reject bad demo speeds
yassinsolim fe66c95
Merge branch 'feature/new-arm-urdf' into feature/xbox-teleop
yassinsolim bd4ca65
Keep the camera target for the fixed view and parse teleop booleans
yassinsolim 59e415f
Add PS5 DualSense Windows controller bridge
ovilikaris-dotcom 74d778c
Fix Pygame controller constant lookup
ovilikaris-dotcom b18183c
Document supported Python versions for DualSense
ovilikaris-dotcom 3b48941
Report missing DualSense controller
ovilikaris-dotcom 47a6f5d
Merge main into the PS5 controller support
yassinsolim 3673e71
Rumble the DualSense when the ground station asks
yassinsolim 7973952
Note that the self-running demo opens the Xbox bridge
yassinsolim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| """DualSense input must match the teleop layout and fail safe on reconnect.""" | ||
|
|
||
| import socket | ||
| import sys | ||
| import time | ||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
|
|
||
| from waybionic_teleop import gamepad, sdl_bridge | ||
|
|
||
|
|
||
| class FakeController: | ||
| """An SDL controller whose input can change during a test.""" | ||
|
|
||
| def __init__(self): | ||
| """Start with neutral controls and a connected device.""" | ||
| self.axes = {} | ||
| self.buttons = {} | ||
| self.connected = True | ||
| self.rumbles = [] | ||
|
|
||
| def get_axis(self, index): | ||
| """Return a simulated SDL axis.""" | ||
| return self.axes.get(index, 0) | ||
|
|
||
| def get_button(self, index): | ||
| """Return a simulated SDL button.""" | ||
| return self.buttons.get(index, False) | ||
|
|
||
| def attached(self): | ||
| """Report whether the simulated device is connected.""" | ||
| return self.connected | ||
|
|
||
| def rumble(self, low, high, duration): | ||
| """Record a simulated SDL rumble request.""" | ||
| self.rumbles.append((low, high, duration)) | ||
| return True | ||
|
|
||
| def stop_rumble(self): | ||
| """Record a simulated SDL rumble stop.""" | ||
| self.rumbles.append('stop') | ||
|
|
||
| def quit(self): # noqa: A003 | ||
| """Match the SDL controller cleanup API.""" | ||
| pass | ||
|
|
||
|
|
||
| class FakeSDL: | ||
| """Expose the controller constants used by the production bridge.""" | ||
|
|
||
| def __init__(self): | ||
| """Assign test constants and start without devices.""" | ||
| self.devices = [] | ||
| for index, name in enumerate(sdl_bridge.AXIS_NAMES): | ||
| setattr(self, 'CONTROLLER_AXIS_' + name, index) | ||
| for index, name in enumerate(sdl_bridge.BUTTON_NAMES.values()): | ||
| setattr(self, 'CONTROLLER_BUTTON_' + name, index) | ||
|
|
||
| def get_count(self): | ||
| """Return the current SDL device count.""" | ||
| return len(self.devices) | ||
|
|
||
| def is_controller(self, index): | ||
| """Mark every test device as an SDL controller.""" | ||
| return True | ||
|
|
||
| def Controller(self, index): | ||
| """Open one simulated SDL controller.""" | ||
| return self.devices[index] | ||
|
|
||
| def name_forindex(self, index): | ||
| """Name the test controller like a DualSense.""" | ||
| return 'DualSense Wireless Controller' | ||
|
|
||
|
|
||
| def pressed(buttons): | ||
| """List normalized names of pressed buttons.""" | ||
| return [name for name, value in zip(gamepad.BUTTONS, buttons) if value] | ||
|
|
||
|
|
||
| def fake_pygame(sdl): | ||
| """Expose SDL constants where Pygame 2.6 publishes them.""" | ||
| pygame = SimpleNamespace(event=SimpleNamespace(pump=lambda: None)) | ||
| for name in sdl_bridge.AXIS_NAMES: | ||
| setattr(pygame, 'CONTROLLER_AXIS_' + name, | ||
| getattr(sdl, 'CONTROLLER_AXIS_' + name)) | ||
| for name in sdl_bridge.BUTTON_NAMES.values(): | ||
| setattr(pygame, 'CONTROLLER_BUTTON_' + name, | ||
| getattr(sdl, 'CONTROLLER_BUTTON_' + name)) | ||
| return pygame | ||
|
|
||
|
|
||
| def test_dualsense_controls_match_existing_teleop_layout(): | ||
| """Keep DualSense axes and buttons compatible with teleop packets.""" | ||
| sdl = FakeSDL() | ||
| pad = FakeController() | ||
| pad.axes = {sdl.CONTROLLER_AXIS_LEFTX: -32768, | ||
| sdl.CONTROLLER_AXIS_LEFTY: -32768, | ||
| sdl.CONTROLLER_AXIS_RIGHTX: 16384, | ||
| sdl.CONTROLLER_AXIS_RIGHTY: 32767, | ||
| sdl.CONTROLLER_AXIS_TRIGGERLEFT: 32768} | ||
| pad.buttons = {sdl.CONTROLLER_BUTTON_A: True, # Cross | ||
| sdl.CONTROLLER_BUTTON_B: True, # Circle | ||
| sdl.CONTROLLER_BUTTON_Y: True, # Triangle | ||
| sdl.CONTROLLER_BUTTON_START: True, # Options | ||
| sdl.CONTROLLER_BUTTON_DPAD_UP: True} | ||
| axes, buttons = sdl_bridge.to_joy(pad, sdl) | ||
| assert axes == pytest.approx([1.0, 1.0, -0.5, -32767 / 32768, -1.0, 0.0]) | ||
| assert pressed(buttons) == ['a', 'b', 'y', 'start', 'dpad_up'] | ||
| _, connected, unpacked_axes, unpacked_buttons = gamepad.unpack( | ||
| gamepad.pack(1, axes, buttons)) | ||
| assert connected | ||
| assert unpacked_axes == pytest.approx(axes) | ||
| assert unpacked_buttons == buttons | ||
|
|
||
|
|
||
| def test_disconnect_sends_neutral_and_reconnect_requires_release(): | ||
| """Do not expose controls until a new device has been released.""" | ||
| sdl = FakeSDL() | ||
| first = FakeController() | ||
| sdl.devices = [first] | ||
| bridge = sdl_bridge.SDLBridge(fake_pygame(sdl), sdl) | ||
| assert bridge.poll(0.0)[2] is False # neutral reset | ||
| assert bridge.poll(0.01)[2] is True | ||
| first.buttons[sdl.CONTROLLER_BUTTON_START] = True | ||
| assert 'start' in pressed(bridge.poll(0.02)[1]) | ||
|
|
||
| first.connected = False | ||
| axes, buttons, connected, _ = bridge.poll(0.03) | ||
| assert not connected and (axes, buttons) == sdl_bridge.NEUTRAL | ||
|
|
||
| second = FakeController() | ||
| second.buttons[sdl.CONTROLLER_BUTTON_START] = True | ||
| sdl.devices = [second] | ||
| # Held Options cannot enable teleop on reconnect. | ||
| assert bridge.poll(1.04)[2] is False | ||
| second.buttons.clear() | ||
| assert bridge.poll(1.05)[2] is False # wait for a fresh Options press | ||
| assert bridge.poll(1.06)[2] is True | ||
| second.buttons[sdl.CONTROLLER_BUTTON_START] = True | ||
| assert 'start' in pressed(bridge.poll(1.07)[1]) | ||
|
|
||
|
|
||
| def test_trigger_or_stick_held_blocks_connection(): | ||
| """A held control must block the reset after connection.""" | ||
| sdl = FakeSDL() | ||
| pad = FakeController() | ||
| pad.axes[sdl.CONTROLLER_AXIS_TRIGGERLEFT] = 20000 | ||
| sdl.devices = [pad] | ||
| bridge = sdl_bridge.SDLBridge(fake_pygame(sdl), sdl) | ||
| assert bridge.poll(0.0)[2] is False | ||
| pad.axes.clear() | ||
| pad.axes[sdl.CONTROLLER_AXIS_LEFTX] = 20000 | ||
| assert bridge.poll(0.01)[2] is False | ||
| pad.axes.clear() | ||
| assert bridge.poll(0.02)[2] is False | ||
| assert bridge.poll(0.03)[2] is True | ||
|
|
||
|
|
||
| def test_missing_controller_reports_a_useful_status(): | ||
| """Tell the operator when the bridge is running without a controller.""" | ||
| sdl = FakeSDL() | ||
| bridge = sdl_bridge.SDLBridge(fake_pygame(sdl), sdl) | ||
|
|
||
| axes, buttons, connected, message = bridge.poll(0.0) | ||
|
|
||
| assert not connected and (axes, buttons) == sdl_bridge.NEUTRAL | ||
| assert message == ('No controller detected; connect a PS5 DualSense ' | ||
| 'controller') | ||
| assert bridge.poll(0.5)[3] is None | ||
|
|
||
|
|
||
| def test_rumble_reaches_only_an_open_controller(): | ||
| """Rumble for a second per request like the Xbox bridge; zero stops it.""" | ||
| sdl = FakeSDL() | ||
| bridge = sdl_bridge.SDLBridge(fake_pygame(sdl), sdl) | ||
| bridge.rumble(1.0) | ||
| pad = FakeController() | ||
| sdl.devices = [pad] | ||
| bridge.poll(0.0) | ||
| bridge.rumble(0.5) | ||
| bridge.rumble(0.0) | ||
| assert pad.rumbles == [(0.5, 0.5, 1000), 'stop'] | ||
|
|
||
|
|
||
| def test_rumble_replies_do_not_interrupt_the_controller_packets(monkeypatch): | ||
| """Only the ground station's replies rumble; the bridge keeps sending.""" | ||
| sdl = FakeSDL() | ||
| pad = FakeController() | ||
| sdl.devices = [pad] | ||
| sdl.init = sdl.quit = lambda: None | ||
| pygame = fake_pygame(sdl) | ||
| pygame.HIDDEN = 0 | ||
| pygame.display = SimpleNamespace( | ||
| init=lambda: None, set_mode=lambda size, flags: None, | ||
| quit=lambda: None) | ||
| monkeypatch.setitem(sys.modules, 'pygame', pygame) | ||
| monkeypatch.setitem(sys.modules, 'pygame._sdl2', | ||
| SimpleNamespace(controller=sdl)) | ||
| monkeypatch.setenv('SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS', '1') | ||
| station = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | ||
| stranger = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | ||
| station.bind(('127.0.0.1', 0)) | ||
| station.settimeout(2.0) | ||
| packets = [] | ||
|
|
||
| def sleep(seconds): | ||
| data, address = station.recvfrom(64) | ||
| packets.append(gamepad.unpack(data)) | ||
| if len(packets) == 3: | ||
| stranger.sendto(gamepad.pack_rumble(1.0), address) | ||
| station.sendto(gamepad.pack_rumble(0.5), address) | ||
| elif len(packets) == 6: | ||
| raise KeyboardInterrupt | ||
|
|
||
| monkeypatch.setattr(sdl_bridge, 'time', SimpleNamespace( | ||
| monotonic=time.monotonic, sleep=sleep)) | ||
| monkeypatch.setattr(sys, 'argv', [ | ||
| 'sdl_bridge', '--port', str(station.getsockname()[1])]) | ||
| try: | ||
| sdl_bridge.main() | ||
| finally: | ||
| station.close() | ||
| stranger.close() | ||
| assert [packet[1] for packet in packets] == [False] + [True] * 5 | ||
| assert pad.rumbles == [(0.5, 0.5, 1000), 'stop'] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pygame 2.6.1 is still the latest release, and its Windows wheels stop at Python 3.13. On Python 3.14, pip tries to build it from source, which fails without the SDL development files. Please say "Python 3.13 or older" here. pygame-ce has newer wheels, but its
_sdl2.controllermodule differs, so it isn't a drop-in replacement.