├── .gitignore ├── PS4Joystick.py ├── README.md ├── install.sh ├── joystick.service ├── local_or_remote.py ├── mac_joystick.py ├── rover_example.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | -------------------------------------------------------------------------------- /PS4Joystick.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import time 4 | import subprocess 5 | import math 6 | 7 | from threading import Thread 8 | from collections import OrderedDict, deque 9 | 10 | from ds4drv.actions import ActionRegistry 11 | from ds4drv.backends import BluetoothBackend, HidrawBackend 12 | from ds4drv.config import load_options 13 | from ds4drv.daemon import Daemon 14 | from ds4drv.eventloop import EventLoop 15 | from ds4drv.exceptions import BackendError 16 | from ds4drv.action import ReportAction 17 | 18 | from ds4drv.__main__ import create_controller_thread 19 | 20 | 21 | class ActionShim(ReportAction): 22 | """ intercepts the joystick report""" 23 | 24 | def __init__(self, *args, **kwargs): 25 | super(ActionShim, self).__init__(*args, **kwargs) 26 | self.timer = self.create_timer(0.02, self.intercept) 27 | self.values = None 28 | self.timestamps = deque(range(10), maxlen=10) 29 | 30 | def enable(self): 31 | self.timer.start() 32 | 33 | def disable(self): 34 | self.timer.stop() 35 | self.values = None 36 | 37 | def load_options(self, options): 38 | pass 39 | 40 | def deadzones(self,values): 41 | deadzone = 0.14 42 | if math.sqrt( values['left_analog_x'] ** 2 + values['left_analog_y'] ** 2) < deadzone: 43 | values['left_analog_y'] = 0.0 44 | values['left_analog_x'] = 0.0 45 | if math.sqrt( values['right_analog_x'] ** 2 + values['right_analog_y'] ** 2) < deadzone: 46 | values['right_analog_y'] = 0.0 47 | values['right_analog_x'] = 0.0 48 | 49 | return values 50 | 51 | def intercept(self, report): 52 | new_out = OrderedDict() 53 | for key in report.__slots__: 54 | value = getattr(report, key) 55 | new_out[key] = value 56 | 57 | for key in ["left_analog_x", "left_analog_y", 58 | "right_analog_x", "right_analog_y", 59 | "l2_analog", "r2_analog"]: 60 | new_out[key] = 2*( new_out[key]/255 ) - 1 61 | 62 | new_out = self.deadzones(new_out) 63 | 64 | self.timestamps.append(new_out['timestamp']) 65 | if len(set(self.timestamps)) <= 1: 66 | self.values = None 67 | else: 68 | self.values = new_out 69 | 70 | return True 71 | 72 | class Joystick: 73 | def __init__(self): 74 | self.thread = None 75 | 76 | options = load_options() 77 | 78 | if options.hidraw: 79 | raise ValueError("HID mode not supported") 80 | backend = HidrawBackend(Daemon.logger) 81 | else: 82 | subprocess.run(["hciconfig", "hciX", "up"]) 83 | backend = BluetoothBackend(Daemon.logger) 84 | 85 | backend.setup() 86 | 87 | self.thread = create_controller_thread(1, options.controllers[0]) 88 | 89 | self.thread.controller.setup_device(next(backend.devices)) 90 | 91 | self.shim = ActionShim(self.thread.controller) 92 | self.thread.controller.actions.append(self.shim) 93 | self.shim.enable() 94 | 95 | self._color = (None, None, None) 96 | self._rumble = (None, None) 97 | self._flash = (None, None) 98 | 99 | # ensure we get a value before returning 100 | while self.shim.values is None: 101 | pass 102 | 103 | def close(self): 104 | if self.thread is None: 105 | return 106 | self.thread.controller.exit("Cleaning up...") 107 | self.thread.controller.loop.stop() 108 | 109 | def __del__(self): 110 | self.close() 111 | 112 | @staticmethod 113 | def map(val, in_min, in_max, out_min, out_max): 114 | """ helper static method that helps with rescaling """ 115 | in_span = in_max - in_min 116 | out_span = out_max - out_min 117 | 118 | value_scaled = float(val - in_min) / float(in_span) 119 | value_mapped = (value_scaled * out_span) + out_min 120 | 121 | if value_mapped < out_min: 122 | value_mapped = out_min 123 | 124 | if value_mapped > out_max: 125 | value_mapped = out_max 126 | 127 | return value_mapped 128 | 129 | def get_input(self): 130 | """ returns ordered dict with state of all inputs """ 131 | if self.thread.controller.error: 132 | raise IOError("Encountered error with controller") 133 | if self.shim.values is None: 134 | raise TimeoutError("Joystick hasn't updated values in last 200ms") 135 | 136 | return self.shim.values 137 | 138 | def led_color(self, red=0, green=0, blue=0): 139 | """ set RGB color in range 0-255""" 140 | color = (int(red),int(green),int(blue)) 141 | if( self._color == color ): 142 | return 143 | self._color = color 144 | self.thread.controller.device.set_led( *self._color ) 145 | 146 | def rumble(self, small=0, big=0): 147 | """ rumble in range 0-255 """ 148 | rumble = (int(small),int(big)) 149 | if( self._rumble == rumble ): 150 | return 151 | self._rumble = rumble 152 | self.thread.controller.device.rumble( *self._rumble ) 153 | 154 | def led_flash(self, on=0, off=0): 155 | """ flash led: on and off times in range 0 - 255 """ 156 | flash = (int(on),int(off)) 157 | if( self._flash == flash ): 158 | return 159 | self._flash = flash 160 | 161 | if( self._flash == (0,0) ): 162 | self.thread.controller.device.stop_led_flash() 163 | else: 164 | self.thread.controller.device.start_led_flash( *self._flash ) 165 | 166 | 167 | if __name__ == "__main__": 168 | j = Joystick() 169 | while 1: 170 | for key, value in j.get_input().items(): 171 | print(key,value) 172 | print() 173 | 174 | time.sleep(0.1) 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PS4Joystick 2 | 3 | Code allowing the use of a DualShock (PS4 Joystick) over Bluetooth on Linux. Over USB comming soon. `mac_joystick.py` shows how to emulate something similar on macOS, but without the fancy features. 4 | 5 | Note: We have updated from the previous version so make sure you disable ds4drv from running automatically at startup as they will conflict. 6 | 7 | This method still requires [ds4drv](https://github.com/chrippa/ds4drv) however it doesn't run it as a separate service and then separately pull joystick data using Pygame. Instead it imports ds4drv directly which gives us much more control over the joystick behaviour. Specifically: 8 | 9 | - It will only pair to one joystick allowing us to run multiple robots at a time 10 | - Allows for launching joystick code via systemd at boot using `sudo systemctl enable joystick` 11 | - Can change joystick colors and using rumble directly from Python (can also access the touchpad and IMU!) 12 | - Is a much nicer interface than using Pygame, as the axes are actually named as opposed to arbitrarly numbered! The axis directions are consistant with Pygame. 13 | - Doesn't need $DISPLAY hacks to run on headless devices 14 | 15 | ### Usage 16 | 17 | Take a look at `rover_example.py` as it demonstrates most features. 18 | To implement this functionality to a new repository (say [PupperCommand](https://github.com/stanfordroboticsclub/PupperCommand)) you can just call `from PS4Joystick import Joystick` anywhere once you've installed the module. Replicate `joystick.service` in that repository. 19 | 20 | 21 | ### Install 22 | 23 | ``` sudo bash install.sh ``` 24 | 25 | 26 | ### macOS 27 | 28 | Sadly ds4drv doesn't work on Macs. But you can get some of the functionality by installing Pygame with `sudo pip3 install Pygame`. Take a look in `mac_joystick.py` for an example. Note this only works over USB (plug the controller in using a micro usb cable) and the mapping is different than using Pygame with ds4drv -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ "$(uname)" == "Darwin" ]; then 4 | echo "ds4drv doesn't work on Mac OS!" 5 | echo "try installing pygame (commented out in this script) and running mac_joystick.py" 6 | exit 0 7 | fi 8 | 9 | FOLDER=$(dirname $(realpath "$0")) 10 | cd $FOLDER 11 | 12 | 13 | #pygame is no longer needed! 14 | # sudo apt-get install -y libsdl-ttf2.0-0 15 | # yes | sudo pip3 install pygame 16 | 17 | yes | sudo pip3 install ds4drv 18 | sudo python3 setup.py clean --all install 19 | 20 | exit 21 | # we don't want the example joystick service installed by default 22 | 23 | for file in *.service; do 24 | [ -f "$file" ] || break 25 | sudo ln -s $FOLDER/$file /lib/systemd/system/ 26 | done 27 | 28 | 29 | sudo systemctl daemon-reload 30 | -------------------------------------------------------------------------------- /joystick.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Rover Joystick service 3 | 4 | [Service] 5 | ExecStart=/usr/bin/python3 /home/pi/PS4Joystick/rover_example.py 6 | Restart=always 7 | RestartSec=5 8 | TimeoutStopSec=5 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /local_or_remote.py: -------------------------------------------------------------------------------- 1 | import os 2 | import RPi.GPIO as GPIO 3 | import time 4 | GPIO.setmode(GPIO.BCM) # Broadcom pin-numbering scheme 5 | GPIO.setup(21, GPIO.IN, pull_up_down=GPIO.PUD_UP) 6 | 7 | while 1: 8 | if not GPIO.input(21): 9 | print("eanbling joystick") 10 | os.system("sudo systemctl start ds4drv") 11 | os.system("sudo systemctl start joystick") 12 | #os.system("screen sudo python3 /home/pi/RoverCommand/joystick.py") 13 | 14 | else: 15 | os.system("sudo systemctl stop ds4drv") 16 | os.system("sudo systemctl stop joystick") 17 | print("not eanbling joystick") 18 | 19 | time.sleep(5) 20 | -------------------------------------------------------------------------------- /mac_joystick.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pygame 3 | from UDPComms import Publisher 4 | 5 | os.environ["SDL_VIDEODRIVER"] = "dummy" 6 | drive_pub = Publisher(8830) 7 | arm_pub = Publisher(8410) 8 | 9 | pygame.display.init() 10 | pygame.joystick.init() 11 | 12 | # wait until joystick is connected 13 | while 1: 14 | try: 15 | pygame.joystick.Joystick(0).init() 16 | break 17 | except pygame.error: 18 | pygame.time.wait(500) 19 | 20 | # Prints the joystick's name 21 | JoyName = pygame.joystick.Joystick(0).get_name() 22 | print("Name of the joystick:") 23 | print(JoyName) 24 | # Gets the number of axes 25 | JoyAx = pygame.joystick.Joystick(0).get_numaxes() 26 | print("Number of axis:") 27 | print(JoyAx) 28 | 29 | while True: 30 | pygame.event.pump() 31 | 32 | forward = (pygame.joystick.Joystick(0).get_axis(3)) 33 | twist = (pygame.joystick.Joystick(0).get_axis(2)) 34 | on = (pygame.joystick.Joystick(0).get_button(5)) 35 | 36 | if on: 37 | print({'f':-150*forward,'t':-80*twist}) 38 | drive_pub.send({'f':-150*forward,'t':-80*twist}) 39 | else: 40 | drive_pub.send({'f':0,'t':0}) 41 | 42 | pygame.time.wait(100) 43 | -------------------------------------------------------------------------------- /rover_example.py: -------------------------------------------------------------------------------- 1 | from UDPComms import Publisher 2 | from PS4Joystick import Joystick 3 | 4 | import time 5 | from enum import Enum 6 | 7 | drive_pub = Publisher(8830) 8 | arm_pub = Publisher(8410) 9 | 10 | j=Joystick() 11 | 12 | MODES = Enum('MODES', 'SAFE DRIVE ARM') 13 | mode = MODES.SAFE 14 | 15 | while True: 16 | values = j.get_input() 17 | 18 | if( values['button_ps'] ): 19 | if values['dpad_up']: 20 | mode = MODES.DRIVE 21 | j.led_color(red=255) 22 | elif values['dpad_right']: 23 | mode = MODES.ARM 24 | j.led_color(blue=255) 25 | elif values['dpad_down']: 26 | mode = MODES.SAFE 27 | j.led_color(green=255) 28 | 29 | # overwrite when swiching modes to prevent phantom motions 30 | values['dpad_down'] = 0 31 | values['dpad_up'] = 0 32 | values['dpad_right'] = 0 33 | values['dpad_left'] = 0 34 | 35 | if mode == MODES.DRIVE: 36 | forward_left = - values['left_analog_y'] 37 | forward_right = - values['right_analog_y'] 38 | twist = values['right_analog_x'] 39 | 40 | on_right = values['button_r1'] 41 | on_left = values['button_l1'] 42 | l_trigger = values['l2_analog'] 43 | 44 | if on_left or on_right: 45 | if on_right: 46 | forward = forward_right 47 | else: 48 | forward = forward_left 49 | 50 | slow = 150 51 | fast = 500 52 | 53 | max_speed = (fast+slow)/2 + l_trigger*(fast-slow)/2 54 | 55 | out = {'f':(max_speed*forward),'t':-150*twist} 56 | drive_pub.send(out) 57 | print(out) 58 | else: 59 | drive_pub.send({'f':0,'t':0}) 60 | 61 | elif mode == MODES.ARM: 62 | r_forward = - values['right_analog_y'] 63 | r_side = values['right_analog_x'] 64 | 65 | l_forward = - values['left_analog_y'] 66 | l_side = values['left_analog_x'] 67 | 68 | r_shoulder = values['button_r1'] 69 | l_shoulder = values['button_l1'] 70 | 71 | r_trigger = values['r2_analog'] 72 | l_trigger = values['l2_analog'] 73 | 74 | square = values['button_square'] 75 | cross = values['button_cross'] 76 | circle = values['button_circle'] 77 | triangle = values['button_triangle'] 78 | 79 | PS = values['button_ps'] 80 | 81 | # hat directions could be reversed from previous version 82 | hat = [ values["dpad_up"] - values["dpad_down"], 83 | values["dpad_right"] - values["dpad_left"] ] 84 | 85 | reset = (PS == 1) and (triangle == 1) 86 | reset_dock = (PS==1) and (square ==1) 87 | 88 | target_vel = {"x": l_side, 89 | "y": l_forward, 90 | "z": (r_trigger - l_trigger)/2, 91 | "yaw": r_side, 92 | "pitch": r_forward, 93 | "roll": (r_shoulder - l_shoulder), 94 | "grip": cross - square, 95 | "hat": hat, 96 | "reset": reset, 97 | "resetdock":reset_dock, 98 | "trueXYZ": circle, 99 | "dock": triangle} 100 | 101 | print(target_vel) 102 | arm_pub.send(target_vel) 103 | elif mode == MODES.SAFE: 104 | # random stuff to demo color features 105 | triangle = values['button_triangle'] 106 | square = values['button_square'] 107 | 108 | j.rumble(small = 255*triangle, big = 255*square) 109 | 110 | r2 = values['r2_analog'] 111 | r2 = j.map( r2, -1, 1, 0 ,255) 112 | j.led_color( green = 255, blue = r2) 113 | 114 | else: 115 | pass 116 | 117 | time.sleep(0.1) 118 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name='PS4Joystick', 6 | version='2.0', 7 | py_modules=['PS4Joystick'], 8 | description='Interfaces with a PS4 joystick over Bluetooth', 9 | author='Michal Adamkiewicz', 10 | author_email='mikadam@stanford.edu', 11 | url='https://github.com/stanfordroboticsclub/JoystickUDP', 12 | ) 13 | 14 | --------------------------------------------------------------------------------