├── ds4drv ├── packages │ ├── __init__.py │ └── timerfd.py ├── backends │ ├── __init__.py │ ├── bluetooth.py │ └── hidraw.py ├── exceptions.py ├── actions │ ├── __init__.py │ ├── led.py │ ├── dump.py │ ├── battery.py │ ├── btsignal.py │ ├── status.py │ ├── input.py │ └── binding.py ├── __init__.py ├── backend.py ├── utils.py ├── logger.py ├── action.py ├── daemon.py ├── eventloop.py ├── __main__.py ├── device.py ├── config.py └── uinput.py ├── setup.cfg ├── MANIFEST.in ├── systemd └── ds4drv.service ├── .gitignore ├── udev └── 50-ds4drv.rules ├── LICENSE ├── setup.py ├── HISTORY.rst ├── ds4drv.conf └── README.rst /ds4drv/packages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /ds4drv/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from .bluetooth import BluetoothBackend 2 | from .hidraw import HidrawBackend 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include HISTORY.rst 2 | include README.rst 3 | include LICENSE 4 | include systemd/* 5 | include ds4drv.conf 6 | -------------------------------------------------------------------------------- /ds4drv/exceptions.py: -------------------------------------------------------------------------------- 1 | class BackendError(Exception): 2 | """Backend related errors.""" 3 | 4 | class DeviceError(Exception): 5 | """Device related errors.""" 6 | -------------------------------------------------------------------------------- /systemd/ds4drv.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ds4drv daemon 3 | Requires=bluetooth.service 4 | After=bluetooth.service 5 | 6 | [Service] 7 | ExecStart=/usr/bin/ds4drv 8 | Restart=on-abort 9 | 10 | [Install] 11 | WantedBy=bluetooth.target 12 | -------------------------------------------------------------------------------- /ds4drv/actions/__init__.py: -------------------------------------------------------------------------------- 1 | from ..action import ActionRegistry 2 | 3 | from . import battery 4 | from . import binding 5 | from . import btsignal 6 | from . import dump 7 | from . import input 8 | from . import led 9 | from . import status 10 | -------------------------------------------------------------------------------- /ds4drv/__init__.py: -------------------------------------------------------------------------------- 1 | """ds4drv is a Sony DualShock 4 userspace driver for Linux.""" 2 | 3 | __title__ = "ds4drv" 4 | __version__ = "0.5.1" 5 | __author__ = "Christopher Rosell" 6 | __credits__ = ["Christopher Rosell", "George Gibbs", "Lauri Niskanen"] 7 | __license__ = "MIT" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mo 2 | *.egg-info 3 | *.egg 4 | *.EGG 5 | *.EGG-INFO 6 | bin 7 | build 8 | develop-eggs 9 | downloads 10 | eggs 11 | fake-eggs 12 | parts 13 | dist 14 | .installed.cfg 15 | .mr.developer.cfg 16 | .hg 17 | .bzr 18 | .svn 19 | *.pyc 20 | *.pyo 21 | *.pyd 22 | *.tmp* 23 | *.swp 24 | *.so 25 | __pycache__ 26 | docs/_build 27 | -------------------------------------------------------------------------------- /udev/50-ds4drv.rules: -------------------------------------------------------------------------------- 1 | KERNEL=="uinput", MODE="0666" 2 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="05c4", MODE="0666" 3 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:054C:05C4.*", MODE="0666" 4 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="09cc", MODE="0666" 5 | KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:054C:09CC.*", MODE="0666" 6 | -------------------------------------------------------------------------------- /ds4drv/backend.py: -------------------------------------------------------------------------------- 1 | class Backend(object): 2 | """The backend is responsible for finding and creating DS4 devices.""" 3 | 4 | __name__ = "backend" 5 | 6 | def __init__(self, manager): 7 | self.logger = manager.new_module(self.__name__) 8 | 9 | def setup(self): 10 | """Initialize the backend and make it ready for scanning. 11 | 12 | Raises BackendError on failure. 13 | """ 14 | 15 | raise NotImplementedError 16 | 17 | @property 18 | def devices(self): 19 | """This iterator yields any devices found.""" 20 | 21 | raise NotImplementedError 22 | -------------------------------------------------------------------------------- /ds4drv/actions/led.py: -------------------------------------------------------------------------------- 1 | from ..action import Action 2 | from ..config import hexcolor 3 | 4 | Action.add_option("--led", metavar="color", default="0000ff", type=hexcolor, 5 | help="Sets color of the LED. Uses hex color codes, " 6 | "e.g. 'ff0000' is red. Default is '0000ff' (blue)") 7 | 8 | 9 | class ActionLED(Action): 10 | """Sets the LED color on the device.""" 11 | 12 | def setup(self, device): 13 | device.set_led(*self.controller.options.led) 14 | 15 | def load_options(self, options): 16 | if self.controller.device: 17 | self.controller.device.set_led(*options.led) 18 | -------------------------------------------------------------------------------- /ds4drv/actions/dump.py: -------------------------------------------------------------------------------- 1 | from ..action import ReportAction 2 | 3 | ReportAction.add_option("--dump-reports", action="store_true", 4 | help="Prints controller input reports") 5 | 6 | 7 | class ReportActionDump(ReportAction): 8 | """Pretty prints the reports to the log.""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | super(ReportActionDump, self).__init__(*args, **kwargs) 12 | self.timer = self.create_timer(0.02, self.dump) 13 | 14 | def enable(self): 15 | self.timer.start() 16 | 17 | def disable(self): 18 | self.timer.stop() 19 | 20 | def load_options(self, options): 21 | if options.dump_reports: 22 | self.enable() 23 | else: 24 | self.disable() 25 | 26 | def dump(self, report): 27 | dump = "Report dump\n" 28 | for key in report.__slots__: 29 | value = getattr(report, key) 30 | dump += " {0}: {1}\n".format(key, value) 31 | 32 | self.logger.info(dump) 33 | 34 | return True 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 Christopher Rosell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | readme = open("README.rst").read() 6 | history = open("HISTORY.rst").read() 7 | 8 | setup(name="ds4drv", 9 | version="0.5.1", 10 | description="A Sony DualShock 4 userspace driver for Linux", 11 | url="https://github.com/chrippa/ds4drv", 12 | author="Christopher Rosell", 13 | author_email="chrippa@tanuki.se", 14 | license="MIT", 15 | long_description=readme + "\n\n" + history, 16 | entry_points={ 17 | "console_scripts": ["ds4drv=ds4drv.__main__:main"] 18 | }, 19 | packages=["ds4drv", 20 | "ds4drv.actions", 21 | "ds4drv.backends", 22 | "ds4drv.packages"], 23 | install_requires=["evdev>=0.3.0", "pyudev>=0.16"], 24 | classifiers=[ 25 | "Development Status :: 4 - Beta", 26 | "Environment :: Console", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: POSIX :: Linux", 29 | "Programming Language :: Python :: 2.7", 30 | "Programming Language :: Python :: 3.3", 31 | "Topic :: Games/Entertainment" 32 | ] 33 | ) 34 | 35 | -------------------------------------------------------------------------------- /ds4drv/actions/battery.py: -------------------------------------------------------------------------------- 1 | from ..action import ReportAction 2 | 3 | BATTERY_WARNING = 2 4 | 5 | ReportAction.add_option("--battery-flash", action="store_true", 6 | help="Flashes the LED once a minute if the " 7 | "battery is low") 8 | 9 | 10 | class ReportActionBattery(ReportAction): 11 | """Flashes the LED when battery is low.""" 12 | 13 | def __init__(self, *args, **kwargs): 14 | super(ReportActionBattery, self).__init__(*args, **kwargs) 15 | 16 | self.timer_check = self.create_timer(60, self.check_battery) 17 | self.timer_flash = self.create_timer(5, self.stop_flash) 18 | 19 | def enable(self): 20 | self.timer_check.start() 21 | 22 | def disable(self): 23 | self.timer_check.stop() 24 | self.timer_flash.stop() 25 | 26 | def load_options(self, options): 27 | if options.battery_flash: 28 | self.enable() 29 | else: 30 | self.disable() 31 | 32 | def stop_flash(self, report): 33 | self.controller.device.stop_led_flash() 34 | 35 | def check_battery(self, report): 36 | if report.battery < BATTERY_WARNING and not report.plug_usb: 37 | self.controller.device.start_led_flash(30, 30) 38 | self.timer_flash.start() 39 | 40 | return True 41 | -------------------------------------------------------------------------------- /ds4drv/actions/btsignal.py: -------------------------------------------------------------------------------- 1 | from ..action import ReportAction 2 | 3 | 4 | class ReportActionBTSignal(ReportAction): 5 | """Warns when a low report rate is discovered and may impact usability.""" 6 | 7 | def __init__(self, *args, **kwargs): 8 | super(ReportActionBTSignal, self).__init__(*args, **kwargs) 9 | 10 | self.timer_check = self.create_timer(2.5, self.check_signal) 11 | self.timer_reset = self.create_timer(60, self.reset_warning) 12 | 13 | def setup(self, device): 14 | self.reports = 0 15 | self.signal_warned = False 16 | 17 | if device.type == "bluetooth": 18 | self.enable() 19 | else: 20 | self.disable() 21 | 22 | def enable(self): 23 | self.timer_check.start() 24 | 25 | def disable(self): 26 | self.timer_check.stop() 27 | self.timer_reset.stop() 28 | 29 | def check_signal(self, report): 30 | # Less than 60 reports/s means we are probably dropping 31 | # reports between frames in a 60 FPS game. 32 | rps = int(self.reports / 2.5) 33 | if not self.signal_warned and rps < 60: 34 | self.logger.warning("Signal strength is low ({0} reports/s)", rps) 35 | self.signal_warned = True 36 | self.timer_reset.start() 37 | 38 | self.reports = 0 39 | 40 | return True 41 | 42 | def reset_warning(self, report): 43 | self.signal_warned = False 44 | 45 | def handle_report(self, report): 46 | self.reports += 1 47 | -------------------------------------------------------------------------------- /ds4drv/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .device import DS4Report 4 | 5 | 6 | VALID_BUTTONS = DS4Report.__slots__ 7 | 8 | 9 | def iter_except(func, exception, first=None): 10 | """Call a function repeatedly until an exception is raised. 11 | 12 | Converts a call-until-exception interface to an iterator interface. 13 | Like __builtin__.iter(func, sentinel) but uses an exception instead 14 | of a sentinel to end the loop. 15 | """ 16 | try: 17 | if first is not None: 18 | yield first() 19 | while True: 20 | yield func() 21 | except exception: 22 | pass 23 | 24 | 25 | def parse_button_combo(combo, sep="+"): 26 | def button_prefix(button): 27 | button = button.strip() 28 | if button in ("up", "down", "left", "right"): 29 | prefix = "dpad_" 30 | else: 31 | prefix = "button_" 32 | 33 | if prefix + button not in VALID_BUTTONS: 34 | raise ValueError("Invalid button: {0}".format(button)) 35 | 36 | return prefix + button 37 | 38 | return tuple(map(button_prefix, combo.lower().split(sep))) 39 | 40 | 41 | def with_metaclass(meta, base=object): 42 | """Create a base class with a metaclass.""" 43 | return meta("NewBase", (base,), {}) 44 | 45 | 46 | def zero_copy_slice(buf, start=None, end=None): 47 | # No need for an extra copy on Python 3.3+ 48 | if sys.version_info[0] == 3 and sys.version_info[1] >= 3: 49 | buf = memoryview(buf) 50 | 51 | return buf[start:end] 52 | -------------------------------------------------------------------------------- /ds4drv/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from threading import Lock 4 | 5 | 6 | LEVELS = ["none", "error", "warning", "info"] 7 | FORMAT = "[{level}][{module}] {msg}\n" 8 | 9 | 10 | class Logger(object): 11 | def __init__(self): 12 | self.output = sys.stdout 13 | self.level = 0 14 | self.lock = Lock() 15 | 16 | def new_module(self, module): 17 | return LoggerModule(self, module) 18 | 19 | def set_level(self, level): 20 | try: 21 | index = LEVELS.index(level) 22 | except ValueError: 23 | return 24 | 25 | self.level = index 26 | 27 | def set_output(self, output): 28 | self.output = output 29 | 30 | def msg(self, module, level, msg, *args, **kwargs): 31 | if self.level < level or level > len(LEVELS): 32 | return 33 | 34 | msg = str(msg).format(*args, **kwargs) 35 | 36 | with self.lock: 37 | self.output.write(FORMAT.format(module=module, 38 | level=LEVELS[level], 39 | msg=msg)) 40 | if hasattr(self.output, "flush"): 41 | self.output.flush() 42 | 43 | 44 | class LoggerModule(object): 45 | def __init__(self, manager, module): 46 | self.manager = manager 47 | self.module = module 48 | 49 | def error(self, msg, *args, **kwargs): 50 | self.manager.msg(self.module, 1, msg, *args, **kwargs) 51 | 52 | def warning(self, msg, *args, **kwargs): 53 | self.manager.msg(self.module, 2, msg, *args, **kwargs) 54 | 55 | def info(self, msg, *args, **kwargs): 56 | self.manager.msg(self.module, 3, msg, *args, **kwargs) 57 | 58 | def debug(self, msg, *args, **kwargs): 59 | self.manager.msg(self.module, 4, msg, *args, **kwargs) 60 | -------------------------------------------------------------------------------- /ds4drv/actions/status.py: -------------------------------------------------------------------------------- 1 | from ..action import ReportAction 2 | 3 | BATTERY_MAX = 8 4 | BATTERY_MAX_CHARGING = 11 5 | 6 | 7 | class ReportActionStatus(ReportAction): 8 | """Reports device statuses such as battery percentage to the log.""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | super(ReportActionStatus, self).__init__(*args, **kwargs) 12 | self.timer = self.create_timer(1, self.check_status) 13 | 14 | def setup(self, device): 15 | self.report = None 16 | self.timer.start() 17 | 18 | def disable(self): 19 | self.timer.stop() 20 | 21 | def check_status(self, report): 22 | if not self.report: 23 | self.report = report 24 | show_battery = True 25 | else: 26 | show_battery = False 27 | 28 | # USB cable 29 | if self.report.plug_usb != report.plug_usb: 30 | plug_usb = report.plug_usb and "Connected" or "Disconnected" 31 | show_battery = True 32 | 33 | self.logger.info("USB: {0}", plug_usb) 34 | 35 | # Battery level 36 | if self.report.battery != report.battery or show_battery: 37 | max_value = report.plug_usb and BATTERY_MAX_CHARGING or BATTERY_MAX 38 | battery = 100 * report.battery // max_value 39 | 40 | if battery < 100: 41 | self.logger.info("Battery: {0}%", battery) 42 | else: 43 | self.logger.info("Battery: Fully charged") 44 | 45 | # Audio cable 46 | if (self.report.plug_audio != report.plug_audio or 47 | self.report.plug_mic != report.plug_mic): 48 | 49 | if report.plug_audio and report.plug_mic: 50 | plug_audio = "Headset" 51 | elif report.plug_audio: 52 | plug_audio = "Headphones" 53 | elif report.plug_mic: 54 | plug_audio = "Mic" 55 | else: 56 | plug_audio = "Speaker" 57 | 58 | self.logger.info("Audio: {0}", plug_audio) 59 | 60 | self.report = report 61 | 62 | return True 63 | -------------------------------------------------------------------------------- /ds4drv/action.py: -------------------------------------------------------------------------------- 1 | from .config import add_controller_option 2 | from .utils import with_metaclass 3 | 4 | from functools import wraps 5 | 6 | BASE_CLASSES = ["Action", "ReportAction"] 7 | 8 | 9 | class ActionRegistry(type): 10 | def __init__(cls, name, bases, attrs): 11 | if name not in BASE_CLASSES: 12 | if not hasattr(ActionRegistry, "actions"): 13 | ActionRegistry.actions = [] 14 | else: 15 | ActionRegistry.actions.append(cls) 16 | 17 | 18 | class Action(with_metaclass(ActionRegistry)): 19 | """Actions are what drives most of the functionality of ds4drv.""" 20 | 21 | @classmethod 22 | def add_option(self, *args, **kwargs): 23 | add_controller_option(*args, **kwargs) 24 | 25 | def __init__(self, controller): 26 | self.controller = controller 27 | self.logger = controller.logger 28 | 29 | self.register_event("device-setup", self.setup) 30 | self.register_event("device-cleanup", self.disable) 31 | self.register_event("load-options", self.load_options) 32 | 33 | def create_timer(self, interval, func): 34 | return self.controller.loop.create_timer(interval, func) 35 | 36 | def register_event(self, event, func): 37 | self.controller.loop.register_event(event, func) 38 | 39 | def unregister_event(self, event, func): 40 | self.controller.loop.unregister_event(event, func) 41 | 42 | def setup(self, device): 43 | pass 44 | 45 | def enable(self): 46 | pass 47 | 48 | def disable(self): 49 | pass 50 | 51 | def load_options(self, options): 52 | pass 53 | 54 | 55 | class ReportAction(Action): 56 | def __init__(self, controller): 57 | super(ReportAction, self).__init__(controller) 58 | 59 | self._last_report = None 60 | self.register_event("device-report", self._handle_report) 61 | 62 | def create_timer(self, interval, callback): 63 | @wraps(callback) 64 | def wrapper(*args, **kwargs): 65 | if self._last_report: 66 | return callback(self._last_report, *args, **kwargs) 67 | return True 68 | 69 | return super(ReportAction, self).create_timer(interval, wrapper) 70 | 71 | def _handle_report(self, report): 72 | self._last_report = report 73 | self.handle_report(report) 74 | 75 | def handle_report(self, report): 76 | pass 77 | -------------------------------------------------------------------------------- /ds4drv/daemon.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import os 3 | import sys 4 | 5 | from signal import signal, SIGTERM 6 | 7 | from .logger import Logger 8 | 9 | 10 | class Daemon(object): 11 | logger = Logger() 12 | logger.set_level("info") 13 | logger_module = logger.new_module("daemon") 14 | 15 | @classmethod 16 | def fork(cls, logfile, pidfile): 17 | if os.path.exists(pidfile): 18 | cls.exit("ds4drv appears to already be running. Kill it " 19 | "or remove {0} if it's not really running.", pidfile) 20 | 21 | cls.logger_module.info("Forking into background, writing log to {0}", 22 | logfile) 23 | 24 | try: 25 | pid = os.fork() 26 | except OSError as err: 27 | cls.exit("Failed to fork: {0}", err) 28 | 29 | if pid == 0: 30 | os.setsid() 31 | 32 | try: 33 | pid = os.fork() 34 | except OSError as err: 35 | cls.exit("Failed to fork child process: {0}", err) 36 | 37 | if pid == 0: 38 | os.chdir("/") 39 | cls.open_log(logfile) 40 | else: 41 | sys.exit(0) 42 | else: 43 | sys.exit(0) 44 | 45 | cls.create_pid(pidfile) 46 | 47 | @classmethod 48 | def create_pid(cls, pidfile): 49 | @atexit.register 50 | def remove_pid(): 51 | if os.path.exists(pidfile): 52 | os.remove(pidfile) 53 | 54 | signal(SIGTERM, lambda *a: sys.exit()) 55 | 56 | try: 57 | with open(pidfile, "w") as fd: 58 | fd.write(str(os.getpid())) 59 | except OSError: 60 | pass 61 | 62 | @classmethod 63 | def open_log(cls, logfile): 64 | logfile = os.path.expanduser(logfile) 65 | dirname = os.path.dirname(logfile) 66 | if not os.path.exists(dirname): 67 | try: 68 | os.makedirs(dirname) 69 | except OSError as err: 70 | cls.exit("Failed to open log file: {0} ({1})", logfile, err) 71 | 72 | try: 73 | output = open(logfile, "w") 74 | except OSError as err: 75 | cls.exit("Failed to open log file: {0} ({1})", logfile, err) 76 | 77 | cls.logger.set_output(output) 78 | 79 | @classmethod 80 | def exit(cls, *args, **kwargs): 81 | cls.logger_module.error(*args, **kwargs) 82 | sys.exit(1) 83 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | 2 | Release history 3 | --------------- 4 | 5 | 0.5.1 (2016-04-30) 6 | ^^^^^^^^^^^^^^^^^^ 7 | 8 | - Fixed compatibility with python-evdev 0.6.0 (#70) 9 | - Fixed spurious input from unconnected devices (#59) 10 | 11 | 12 | 0.5.0 (2014-03-07) 13 | ^^^^^^^^^^^^^^^^^^ 14 | 15 | - Added a ``--ignored-buttons`` option. 16 | - Added signal strength warnings to the log output. 17 | - Changed deadzone to 5 (down from 15). 18 | - Switched to event loop based report reading and timers. 19 | - Mouse movement should now be smoother since it is now based on a timer 20 | instead of relying on reports arriving at a constant rate. 21 | - Fixed issue where keys and buttons where not released on disconnect. 22 | - Fixed crash when hcitool returns non-ascii data. 23 | 24 | 25 | 0.4.3 (2014-02-21) 26 | ^^^^^^^^^^^^^^^^^^ 27 | 28 | - A few performance improvements. 29 | - Fixed prev-profile action. 30 | 31 | 32 | 0.4.2 (2014-02-15) 33 | ^^^^^^^^^^^^^^^^^^ 34 | 35 | - Fixed regressions in controller options handling causing issues 36 | with device creation and using joystick layouts in profiles. 37 | 38 | 39 | 0.4.1 (2014-02-14) 40 | ^^^^^^^^^^^^^^^^^^ 41 | 42 | - Daemon mode was accidentally left on by default in ds4drv.conf. 43 | 44 | 45 | 0.4.0 (2014-02-14) 46 | ^^^^^^^^^^^^^^^^^^ 47 | 48 | - Added ``--dump-reports`` option, patch by Lauri Niskanen. 49 | - Added support for binding buttons combos to special actions. 50 | - Fixed crash when multiple controllers where used. 51 | - Fixed python-evdev version requirement. 52 | - Fixed pyudev version requirement. 53 | - Fixed duplicate devices when connecting a USB cable to a already 54 | connected Bluetooth device in hidraw mode. 55 | - Improved mouse movement and configuration, patch by Lauri Niskanen. 56 | - Changed button combo behaviour slightly. Now triggers when the 57 | last button of a combo is released instead of waiting for the 58 | whole combo to be released. 59 | 60 | 61 | 0.3.0 (2014-02-08) 62 | ^^^^^^^^^^^^^^^^^^ 63 | 64 | - Added hidraw mode, patch by Lauri Niskanen. 65 | - Added config file support. 66 | - Added custom button mapping. 67 | - Added profiles. 68 | 69 | - Fixed crash when using Python <2.7.4 70 | 71 | 72 | 0.2.1 (2014-01-26) 73 | ^^^^^^^^^^^^^^^^^^ 74 | 75 | - Updated ds4drv.service to read a config file, patch by George Gibbs. 76 | - ``--led`` now accepts colors in the "#ffffff" format aswell. 77 | - Added status updates in the log, patch by Lauri Niskanen. 78 | 79 | 80 | 0.2.0 (2014-01-24) 81 | ^^^^^^^^^^^^^^^^^^ 82 | 83 | - Added systemd service file, patch by George Gibbs. 84 | - Added options: ``--emulate-xboxdrv`` and ``--emulate-xpad-wireless``. 85 | - Fixed ``--emulate-xpad`` issues. 86 | 87 | 88 | 0.1.1 (2014-01-12) 89 | ^^^^^^^^^^^^^^^^^^ 90 | 91 | - Fixed incorrect dpad parsing. 92 | - Handle uinput errors instead of printing exception. 93 | 94 | 95 | 0.1.0 (2014-01-07) 96 | ^^^^^^^^^^^^^^^^^^ 97 | 98 | - First release. 99 | 100 | 101 | -------------------------------------------------------------------------------- /ds4drv/eventloop.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from collections import defaultdict, deque 4 | from functools import wraps 5 | from select import epoll, EPOLLIN 6 | 7 | from .packages import timerfd 8 | from .utils import iter_except 9 | 10 | 11 | class Timer(object): 12 | """Simple interface around a timerfd connected to a event loop.""" 13 | 14 | def __init__(self, loop, interval, callback): 15 | self.callback = callback 16 | self.interval = interval 17 | self.loop = loop 18 | self.timer = timerfd.create(timerfd.CLOCK_MONOTONIC) 19 | 20 | def start(self, *args, **kwargs): 21 | """Starts the timer. 22 | 23 | If the callback returns True the timer will be restarted. 24 | """ 25 | 26 | @wraps(self.callback) 27 | def callback(): 28 | os.read(self.timer, 8) 29 | repeat = self.callback(*args, **kwargs) 30 | if not repeat: 31 | self.stop() 32 | 33 | spec = timerfd.itimerspec(self.interval, self.interval) 34 | timerfd.settime(self.timer, 0, spec) 35 | 36 | self.loop.remove_watcher(self.timer) 37 | self.loop.add_watcher(self.timer, callback) 38 | 39 | def stop(self): 40 | """Stops the timer if it's running.""" 41 | self.loop.remove_watcher(self.timer) 42 | 43 | 44 | class EventLoop(object): 45 | """Basic IO, event and timer loop with callbacks.""" 46 | 47 | def __init__(self): 48 | self.stop() 49 | 50 | # Timeout value well over the expected controller poll time, but 51 | # short enough for ds4drv to shut down in a reasonable time. 52 | self.epoll_timeout = 1 53 | 54 | def create_timer(self, interval, callback): 55 | """Creates a timer.""" 56 | 57 | return Timer(self, interval, callback) 58 | 59 | def add_watcher(self, fd, callback): 60 | """Starts watching a non-blocking fd for data.""" 61 | 62 | if not isinstance(fd, int): 63 | fd = fd.fileno() 64 | 65 | self.callbacks[fd] = callback 66 | self.epoll.register(fd, EPOLLIN) 67 | 68 | def remove_watcher(self, fd): 69 | """Stops watching a fd.""" 70 | if not isinstance(fd, int): 71 | fd = fd.fileno() 72 | 73 | if fd not in self.callbacks: 74 | return 75 | 76 | self.callbacks.pop(fd, None) 77 | self.epoll.unregister(fd) 78 | 79 | def register_event(self, event, callback): 80 | """Registers a handler for an event.""" 81 | self.event_callbacks[event].add(callback) 82 | 83 | def unregister_event(self, event, callback): 84 | """Unregisters a event handler.""" 85 | self.event_callbacks[event].remove(callback) 86 | 87 | def fire_event(self, event, *args, **kwargs): 88 | """Fires a event.""" 89 | self.event_queue.append((event, args)) 90 | self.process_events() 91 | 92 | def process_events(self): 93 | """Processes any events in the queue.""" 94 | for event, args in iter_except(self.event_queue.popleft, IndexError): 95 | for callback in self.event_callbacks[event]: 96 | callback(*args) 97 | 98 | def run(self): 99 | """Starts the loop.""" 100 | self.running = True 101 | while self.running: 102 | for fd, event in self.epoll.poll(self.epoll_timeout): 103 | callback = self.callbacks.get(fd) 104 | if callback: 105 | callback() 106 | 107 | def stop(self): 108 | """Stops the loop.""" 109 | self.running = False 110 | self.callbacks = {} 111 | self.epoll = epoll() 112 | 113 | self.event_queue = deque() 114 | self.event_callbacks = defaultdict(set) 115 | 116 | -------------------------------------------------------------------------------- /ds4drv/packages/timerfd.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2010 Timo Savola 3 | 4 | Permission is hereby granted, free of charge, to any person 5 | obtaining a copy of this software and associated documentation 6 | files (the "Software"), to deal in the Software without 7 | restriction, including without limitation the rights to use, 8 | copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 21 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 23 | OTHER DEALINGS IN THE SOFTWARE. 24 | """ 25 | 26 | __all__ = [ 27 | "CLOEXEC", 28 | "NONBLOCK", 29 | 30 | "TIMER_ABSTIME", 31 | 32 | "CLOCK_REALTIME", 33 | "CLOCK_MONOTONIC", 34 | 35 | "bufsize", 36 | 37 | "timespec", 38 | "itimerspec", 39 | 40 | "create", 41 | "settime", 42 | "gettime", 43 | "unpack", 44 | ] 45 | 46 | import ctypes 47 | import ctypes.util 48 | import math 49 | import os 50 | import struct 51 | 52 | CLOEXEC = 0o02000000 53 | NONBLOCK = 0o00004000 54 | 55 | TIMER_ABSTIME = 0x00000001 56 | 57 | CLOCK_REALTIME = 0 58 | CLOCK_MONOTONIC = 1 59 | 60 | bufsize = 8 61 | 62 | libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) 63 | 64 | class timespec(ctypes.Structure): 65 | 66 | _fields_ = [ 67 | ("tv_sec", libc.time.restype), 68 | ("tv_nsec", ctypes.c_long), 69 | ] 70 | 71 | def __init__(self, time=None): 72 | ctypes.Structure.__init__(self) 73 | 74 | if time is not None: 75 | self.set_time(time) 76 | 77 | def __repr__(self): 78 | return "timerfd.timespec(%s)" % self.get_time() 79 | 80 | def set_time(self, time): 81 | fraction, integer = math.modf(time) 82 | 83 | self.tv_sec = int(integer) 84 | self.tv_nsec = int(fraction * 1000000000) 85 | 86 | def get_time(self): 87 | if self.tv_nsec: 88 | return self.tv_sec + self.tv_nsec / 1000000000.0 89 | else: 90 | return self.tv_sec 91 | 92 | class itimerspec(ctypes.Structure): 93 | 94 | _fields_ = [ 95 | ("it_interval", timespec), 96 | ("it_value", timespec), 97 | ] 98 | 99 | def __init__(self, interval=None, value=None): 100 | ctypes.Structure.__init__(self) 101 | 102 | if interval is not None: 103 | self.it_interval.set_time(interval) 104 | 105 | if value is not None: 106 | self.it_value.set_time(value) 107 | 108 | def __repr__(self): 109 | items = [("interval", self.it_interval), ("value", self.it_value)] 110 | args = ["%s=%s" % (name, value.get_time()) for name, value in items] 111 | return "timerfd.itimerspec(%s)" % ", ".join(args) 112 | 113 | def set_interval(self, time): 114 | self.it_interval.set_time(time) 115 | 116 | def get_interval(self): 117 | return self.it_interval.get_time() 118 | 119 | def set_value(self, time): 120 | self.it_value.set_time(time) 121 | 122 | def get_value(self): 123 | return self.it_value.get_time() 124 | 125 | def errcheck(result, func, arguments): 126 | if result < 0: 127 | errno = ctypes.get_errno() 128 | raise OSError(errno, os.strerror(errno)) 129 | 130 | return result 131 | 132 | libc.timerfd_create.argtypes = [ctypes.c_int, ctypes.c_int] 133 | libc.timerfd_create.errcheck = errcheck 134 | 135 | libc.timerfd_settime.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.POINTER(itimerspec), ctypes.POINTER(itimerspec)] 136 | libc.timerfd_settime.errcheck = errcheck 137 | 138 | libc.timerfd_gettime.argtypes = [ctypes.c_int, ctypes.POINTER(itimerspec)] 139 | libc.timerfd_gettime.errcheck = errcheck 140 | 141 | def create(clock_id, flags=0): 142 | return libc.timerfd_create(clock_id, flags) 143 | 144 | def settime(ufd, flags, new_value): 145 | old_value = itimerspec() 146 | libc.timerfd_settime(ufd, flags, ctypes.pointer(new_value), ctypes.pointer(old_value)) 147 | return old_value 148 | 149 | def gettime(ufd): 150 | curr_value = itimerspec() 151 | libc.timerfd_gettime(ufd, ctypes.pointer(curr_value)) 152 | return curr_value 153 | 154 | def unpack(buf): 155 | count, = struct.unpack("Q", buf[:bufsize]) 156 | return count 157 | -------------------------------------------------------------------------------- /ds4drv/actions/input.py: -------------------------------------------------------------------------------- 1 | from ..action import ReportAction 2 | from ..config import buttoncombo 3 | from ..exceptions import DeviceError 4 | from ..uinput import create_uinput_device 5 | 6 | ReportAction.add_option("--emulate-xboxdrv", action="store_true", 7 | help="Emulates the same joystick layout as a " 8 | "Xbox 360 controller used via xboxdrv") 9 | ReportAction.add_option("--emulate-xpad", action="store_true", 10 | help="Emulates the same joystick layout as a wired " 11 | "Xbox 360 controller used via the xpad module") 12 | ReportAction.add_option("--emulate-xpad-wireless", action="store_true", 13 | help="Emulates the same joystick layout as a wireless " 14 | "Xbox 360 controller used via the xpad module") 15 | ReportAction.add_option("--ignored-buttons", metavar="button(s)", 16 | type=buttoncombo(","), default=[], 17 | help="A comma-separated list of buttons to never send " 18 | "as joystick events. For example specify 'PS' to " 19 | "disable Steam's big picture mode shortcut when " 20 | "using the --emulate-* options") 21 | ReportAction.add_option("--mapping", metavar="mapping", 22 | help="Use a custom button mapping specified in the " 23 | "config file") 24 | ReportAction.add_option("--trackpad-mouse", action="store_true", 25 | help="Makes the trackpad control the mouse") 26 | 27 | 28 | class ReportActionInput(ReportAction): 29 | """Creates virtual input devices via uinput.""" 30 | 31 | def __init__(self, *args, **kwargs): 32 | super(ReportActionInput, self).__init__(*args, **kwargs) 33 | 34 | self.joystick = None 35 | self.joystick_layout = None 36 | self.mouse = None 37 | 38 | # USB has a report frequency of 4 ms while BT is 2 ms, so we 39 | # use 5 ms between each mouse emit to keep it consistent and to 40 | # allow for at least one fresh report to be received inbetween 41 | self.timer = self.create_timer(0.005, self.emit_mouse) 42 | 43 | def setup(self, device): 44 | self.timer.start() 45 | 46 | def disable(self): 47 | self.timer.stop() 48 | 49 | if self.joystick: 50 | self.joystick.emit_reset() 51 | 52 | if self.mouse: 53 | self.mouse.emit_reset() 54 | 55 | def load_options(self, options): 56 | try: 57 | if options.mapping: 58 | joystick_layout = options.mapping 59 | elif options.emulate_xboxdrv: 60 | joystick_layout = "xboxdrv" 61 | elif options.emulate_xpad: 62 | joystick_layout = "xpad" 63 | elif options.emulate_xpad_wireless: 64 | joystick_layout = "xpad_wireless" 65 | else: 66 | joystick_layout = "ds4" 67 | 68 | if not self.mouse and options.trackpad_mouse: 69 | self.mouse = create_uinput_device("mouse") 70 | elif self.mouse and not options.trackpad_mouse: 71 | self.mouse.device.close() 72 | self.mouse = None 73 | 74 | if self.joystick and self.joystick_layout != joystick_layout: 75 | self.joystick.device.close() 76 | joystick = create_uinput_device(joystick_layout) 77 | self.joystick = joystick 78 | elif not self.joystick: 79 | joystick = create_uinput_device(joystick_layout) 80 | self.joystick = joystick 81 | if joystick.device.device: 82 | self.logger.info("Created devices {0} (joystick) " 83 | "{1} (evdev) ", joystick.joystick_dev, 84 | joystick.device.device.fn) 85 | else: 86 | joystick = None 87 | 88 | self.joystick.ignored_buttons = set() 89 | for button in options.ignored_buttons: 90 | self.joystick.ignored_buttons.add(button) 91 | 92 | if joystick: 93 | self.joystick_layout = joystick_layout 94 | 95 | # If the profile binding is a single button we don't want to 96 | # send it to the joystick at all 97 | if (self.controller.profiles and 98 | self.controller.default_profile.profile_toggle and 99 | len(self.controller.default_profile.profile_toggle) == 1): 100 | 101 | button = self.controller.default_profile.profile_toggle[0] 102 | self.joystick.ignored_buttons.add(button) 103 | except DeviceError as err: 104 | self.controller.exit("Failed to create input device: {0}", err) 105 | 106 | def emit_mouse(self, report): 107 | if self.joystick: 108 | self.joystick.emit_mouse(report) 109 | 110 | if self.mouse: 111 | self.mouse.emit_mouse(report) 112 | 113 | return True 114 | 115 | def handle_report(self, report): 116 | if self.joystick: 117 | self.joystick.emit(report) 118 | 119 | if self.mouse: 120 | self.mouse.emit(report) 121 | -------------------------------------------------------------------------------- /ds4drv/backends/bluetooth.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import subprocess 3 | 4 | from ..backend import Backend 5 | from ..exceptions import BackendError, DeviceError 6 | from ..device import DS4Device 7 | from ..utils import zero_copy_slice 8 | 9 | 10 | L2CAP_PSM_HIDP_CTRL = 0x11 11 | L2CAP_PSM_HIDP_INTR = 0x13 12 | 13 | HIDP_TRANS_SET_REPORT = 0x50 14 | HIDP_DATA_RTYPE_OUTPUT = 0x02 15 | 16 | REPORT_ID = 0x11 17 | REPORT_SIZE = 79 18 | 19 | 20 | class BluetoothDS4Device(DS4Device): 21 | @classmethod 22 | def connect(cls, addr): 23 | ctl_socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, 24 | socket.BTPROTO_L2CAP) 25 | 26 | int_socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, 27 | socket.BTPROTO_L2CAP) 28 | 29 | try: 30 | ctl_socket.connect((addr, L2CAP_PSM_HIDP_CTRL)) 31 | int_socket.connect((addr, L2CAP_PSM_HIDP_INTR)) 32 | int_socket.setblocking(False) 33 | except socket.error as err: 34 | DeviceError("Failed to connect: {0}".format(err)) 35 | 36 | return cls(addr, ctl_socket, int_socket) 37 | 38 | def __init__(self, addr, ctl_sock, int_sock): 39 | self.buf = bytearray(REPORT_SIZE) 40 | self.ctl_sock = ctl_sock 41 | self.int_sock = int_sock 42 | self.report_fd = int_sock.fileno() 43 | 44 | super(BluetoothDS4Device, self).__init__(addr.upper(), addr, 45 | "bluetooth") 46 | 47 | def read_report(self): 48 | try: 49 | ret = self.int_sock.recv_into(self.buf) 50 | except IOError: 51 | return 52 | 53 | # Disconnection 54 | if ret == 0: 55 | return 56 | 57 | # Invalid report size or id, just ignore it 58 | if ret < REPORT_SIZE or self.buf[1] != REPORT_ID: 59 | return False 60 | 61 | # Cut off bluetooth data 62 | buf = zero_copy_slice(self.buf, 3) 63 | 64 | return self.parse_report(buf) 65 | 66 | def write_report(self, report_id, data): 67 | hid = bytearray((HIDP_TRANS_SET_REPORT | HIDP_DATA_RTYPE_OUTPUT, 68 | report_id)) 69 | 70 | self.ctl_sock.sendall(hid + data) 71 | 72 | def set_operational(self): 73 | try: 74 | self.set_led(255, 255, 255) 75 | except socket.error as err: 76 | raise DeviceError("Failed to set operational mode: {0}".format(err)) 77 | 78 | def close(self): 79 | self.int_sock.close() 80 | self.ctl_sock.close() 81 | 82 | 83 | class BluetoothBackend(Backend): 84 | __name__ = "bluetooth" 85 | 86 | def setup(self): 87 | """Check if the bluetooth controller is available.""" 88 | try: 89 | subprocess.check_output(["hcitool", "clock"], 90 | stderr=subprocess.STDOUT) 91 | except subprocess.CalledProcessError: 92 | raise BackendError("'hcitool clock' returned error. Make sure " 93 | "your bluetooth device is powered up with " 94 | "'hciconfig hciX up'.") 95 | except OSError: 96 | raise BackendError("'hcitool' could not be found, make sure you " 97 | "have bluez-utils installed.") 98 | 99 | def scan(self): 100 | """Scan for bluetooth devices.""" 101 | try: 102 | res = subprocess.check_output(["hcitool", "scan", "--flush"], 103 | stderr=subprocess.STDOUT) 104 | except subprocess.CalledProcessError: 105 | raise BackendError("'hcitool scan' returned error. Make sure " 106 | "your bluetooth device is powered up with " 107 | "'hciconfig hciX up'.") 108 | 109 | devices = [] 110 | res = res.splitlines()[1:] 111 | for _, bdaddr, name in map(lambda l: l.split(b"\t"), res): 112 | devices.append((bdaddr.decode("utf8"), name.decode("utf8"))) 113 | 114 | return devices 115 | 116 | def find_device(self): 117 | """Scan for bluetooth devices and return a DS4 device if found.""" 118 | for bdaddr, name in self.scan(): 119 | if name == "Wireless Controller": 120 | self.logger.info("Found device {0}", bdaddr) 121 | return BluetoothDS4Device.connect(bdaddr) 122 | 123 | @property 124 | def devices(self): 125 | """Wait for new DS4 devices to appear.""" 126 | log_msg = True 127 | while True: 128 | if log_msg: 129 | self.logger.info("Scanning for devices") 130 | 131 | try: 132 | device = self.find_device() 133 | if device: 134 | yield device 135 | log_msg = True 136 | else: 137 | log_msg = False 138 | except BackendError as err: 139 | self.logger.error("Error while scanning for devices: {0}", 140 | err) 141 | return 142 | except DeviceError as err: 143 | self.logger.error("Unable to connect to detected device: {0}", 144 | err) 145 | 146 | -------------------------------------------------------------------------------- /ds4drv.conf: -------------------------------------------------------------------------------- 1 | # Many of the settings used here are directly connected to their command line 2 | # counterparts, see "ds4drv --help" for more information about available options. 3 | 4 | ## 5 | # Global options 6 | ## 7 | [ds4drv] 8 | # Run ds4drv in background as a daemon 9 | #daemon = true 10 | 11 | # Location of the log file in daemon mode 12 | #daemon-log = ~/.cache/ds4drv.log 13 | 14 | # Location of the PID file in daemon mode 15 | #daemon-pid = /tmp/ds4drv.pid 16 | 17 | # Enable hidraw mode 18 | #hidraw = true 19 | 20 | 21 | ## 22 | # Controller settings 23 | # 24 | # This is the default profile for each controller. 25 | # Multiple controllers slots are defined by increasing the number. 26 | # 27 | # Controller sections contain: 28 | # Key: A option, these are the same options that can used on the command line 29 | # but without the "--" prefix. 30 | # Value: The option's value, should be "true" if no value is needed. 31 | # 32 | # See "ds4drv --help" for a complete list of available options. 33 | ## 34 | [controller:1] 35 | # Enables LED flash on low battery 36 | #battery-flash = true 37 | 38 | # Sets LED color 39 | #led = 0000ff 40 | 41 | # Enables profile switching 42 | #profile-toggle = PS 43 | 44 | # Profiles to cycle through 45 | #profiles = xpad,kbmouse 46 | 47 | 48 | ## 49 | # Profiles 50 | # 51 | # Profiles allows switching controller settings during runtime. 52 | # 53 | # Profile sections always require a name and are then enabled on a controller 54 | # with "profiles = [,]". 55 | # 56 | # The same settings available for controllers are used here. 57 | ## 58 | [profile:xpad] 59 | led = ff0000 60 | # Emulate the same button mapping as wired Xbox 360 controllers 61 | emulate-xpad = true 62 | 63 | [profile:kbmouse] 64 | led = 00ff00 65 | # Enable trackpad mouse 66 | trackpad-mouse = true 67 | # Custom button mapping 68 | mapping = keyboard 69 | # Custom action bindings 70 | bindings = exec_stuff 71 | 72 | 73 | ## 74 | # Mappings 75 | # 76 | # Mappings let you map buttons and sticks to mouse, key and joystick events. 77 | # 78 | # Mapping sections always require a name and are then enabled in a profile 79 | # with "mapping = ". 80 | # 81 | # Mapping sections contain: 82 | # Key: A Linux input event, see /usr/include/linux/input-event-codes.h for a complete list 83 | # Value: Button on the DS4, use --dump-reports to see all the available buttons 84 | ## 85 | 86 | [mapping:keyboard] 87 | # General button to key mapping 88 | KEY_UP = dpad_up 89 | KEY_LEFT = dpad_left 90 | KEY_DOWN = dpad_down 91 | KEY_RIGHT = dpad_right 92 | KEY_Z = button_cross 93 | KEY_X = button_circle 94 | 95 | # Turn analog stick directions into buttons 96 | KEY_W = -left_analog_y 97 | KEY_A = -left_analog_x 98 | KEY_S = +left_analog_y 99 | KEY_D = +left_analog_x 100 | 101 | # Map relative mouse movement to a analog stick 102 | REL_X = right_analog_x 103 | REL_Y = right_analog_y 104 | 105 | # Map mouse buttons 106 | BTN_LEFT = button_r2 107 | BTN_RIGHT = button_l2 108 | 109 | # Emulate mouse wheel on r1 and l1 110 | REL_WHEELUP = button_l1 111 | REL_WHEELDOWN = button_r1 112 | 113 | # Mouse settings 114 | #mouse_sensitivity = 0.6 115 | #mouse_deadzone = 5 116 | 117 | # Scroll wheel emulation settings (values are in seconds) 118 | #mouse_scroll_repeat_delay = 0.25 # How long to wait before continual scrolling 119 | #mouse_scroll_delay = 0.05 # Lower this to scroll faster; raise to scroll slower 120 | 121 | 122 | ## 123 | # Bindings 124 | # 125 | # Bindings let you bind button combos to special built-in actions. 126 | # 127 | # Binding sections can be defined with a name and are then enabled in a profile 128 | # with "bindings = ". 129 | # 130 | # It's also possible to define a global bindings section that is enabled 131 | # on all profiles. 132 | # 133 | # Sections contains: 134 | # Key: A button combo 135 | # Value: An action, see next section for valid actions. 136 | # 137 | # 138 | # Valid actions: 139 | # next-profile Loads the next profile 140 | # prev-profile Loads the previous profile 141 | # load-profile Loads the specified profile 142 | # exec [arg1] [arg2] ... Executes the command with 143 | # specified arguments 144 | # exec-background [arg1] [arg2] ... Same as exec but launches in 145 | # the background 146 | # 147 | # 148 | # Actions will be pre-processed and replace variables with real values. 149 | # 150 | # Valid variables: 151 | # $profile The current profile 152 | # $name Pretty name of the current device 153 | # $device_addr Bluetooth address of the device 154 | # $report. Replace with a valid attribute, 155 | # use --dump-reports to see which are available 156 | ## 157 | 158 | [bindings] 159 | # Cycle profiles 160 | #PS+Right = next-profile 161 | #PS+Left = prev-profile 162 | 163 | # Go directly to specified profile 164 | #PS+Up = load-profile kbmouse 165 | #PS+Down = load-profile default 166 | 167 | 168 | [bindings:exec_stuff] 169 | # Execute a command in the foreground, blocking until it has finished 170 | PS+Cross = exec echo '$name' 171 | 172 | # Execute a command in the background 173 | PS+Triangle = exec-background sh -c 'echo "disconnect $device_addr" | bluetoothctl' 174 | 175 | -------------------------------------------------------------------------------- /ds4drv/actions/binding.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shlex 4 | import subprocess 5 | 6 | from collections import namedtuple 7 | from itertools import chain 8 | 9 | from ..action import ReportAction 10 | from ..config import buttoncombo 11 | 12 | ReportAction.add_option("--bindings", metavar="bindings", 13 | help="Use custom action bindings specified in the " 14 | "config file") 15 | 16 | ReportAction.add_option("--profile-toggle", metavar="button(s)", 17 | type=buttoncombo("+"), 18 | help="A button combo that will trigger profile " 19 | "cycling, e.g. 'R1+L1+PS'") 20 | 21 | ActionBinding = namedtuple("ActionBinding", "modifiers button callback args") 22 | 23 | 24 | class ReportActionBinding(ReportAction): 25 | """Listens for button presses and executes actions.""" 26 | 27 | actions = {} 28 | 29 | @classmethod 30 | def action(cls, name): 31 | def decorator(func): 32 | cls.actions[name] = func 33 | return func 34 | 35 | return decorator 36 | 37 | def __init__(self, controller): 38 | super(ReportActionBinding, self).__init__(controller) 39 | 40 | self.bindings = [] 41 | self.active = set() 42 | 43 | def add_binding(self, combo, callback, *args): 44 | modifiers, button = combo[:-1], combo[-1] 45 | binding = ActionBinding(modifiers, button, callback, args) 46 | self.bindings.append(binding) 47 | 48 | def load_options(self, options): 49 | self.active = set() 50 | self.bindings = [] 51 | 52 | bindings = (self.controller.bindings["global"].items(), 53 | self.controller.bindings.get(options.bindings, {}).items()) 54 | 55 | for binding, action in chain(*bindings): 56 | self.add_binding(binding, self.handle_binding_action, action) 57 | 58 | have_profiles = (self.controller.profiles and 59 | len(self.controller.profiles) > 1) 60 | if have_profiles and self.controller.default_profile.profile_toggle: 61 | self.add_binding(self.controller.default_profile.profile_toggle, 62 | lambda r: self.controller.next_profile()) 63 | 64 | def handle_binding_action(self, report, action): 65 | info = dict(name=self.controller.device.name, 66 | profile=self.controller.current_profile, 67 | device_addr=self.controller.device.device_addr, 68 | report=report) 69 | 70 | def replace_var(match): 71 | var, attr = match.group("var", "attr") 72 | var = info.get(var) 73 | if attr: 74 | var = getattr(var, attr, None) 75 | return str(var) 76 | 77 | action = re.sub(r"\$(?P\w+)(\.(?P\w+))?", 78 | replace_var, action) 79 | action_split = shlex.split(action) 80 | action_type = action_split[0] 81 | action_args = action_split[1:] 82 | 83 | func = self.actions.get(action_type) 84 | if func: 85 | try: 86 | func(self.controller, *action_args) 87 | except Exception as err: 88 | self.logger.error("Failed to execute action: {0}", err) 89 | else: 90 | self.logger.error("Invalid action type: {0}", action_type) 91 | 92 | def handle_report(self, report): 93 | for binding in self.bindings: 94 | modifiers = True 95 | for button in binding.modifiers: 96 | modifiers = modifiers and getattr(report, button) 97 | 98 | active = getattr(report, binding.button) 99 | released = not active 100 | 101 | if modifiers and active and binding not in self.active: 102 | self.active.add(binding) 103 | elif released and binding in self.active: 104 | self.active.remove(binding) 105 | binding.callback(report, *binding.args) 106 | 107 | 108 | @ReportActionBinding.action("exec") 109 | def exec_(controller, cmd, *args): 110 | """Executes a subprocess in the foreground, blocking until returned.""" 111 | controller.logger.info("Executing: {0} {1}", cmd, " ".join(args)) 112 | 113 | try: 114 | subprocess.check_call([cmd] + list(args)) 115 | except (OSError, subprocess.CalledProcessError) as err: 116 | controller.logger.error("Failed to execute process: {0}", err) 117 | 118 | 119 | @ReportActionBinding.action("exec-background") 120 | def exec_background(controller, cmd, *args): 121 | """Executes a subprocess in the background.""" 122 | controller.logger.info("Executing in the background: {0} {1}", 123 | cmd, " ".join(args)) 124 | 125 | try: 126 | subprocess.Popen([cmd] + list(args), 127 | stdout=open(os.devnull, "wb"), 128 | stderr=open(os.devnull, "wb")) 129 | except OSError as err: 130 | controller.logger.error("Failed to execute process: {0}", err) 131 | 132 | 133 | @ReportActionBinding.action("next-profile") 134 | def next_profile(controller): 135 | """Loads the next profile.""" 136 | controller.next_profile() 137 | 138 | 139 | @ReportActionBinding.action("prev-profile") 140 | def prev_profile(controller): 141 | """Loads the previous profile.""" 142 | controller.prev_profile() 143 | 144 | 145 | @ReportActionBinding.action("load-profile") 146 | def load_profile(controller, profile): 147 | """Loads the specified profile.""" 148 | controller.load_profile(profile) 149 | -------------------------------------------------------------------------------- /ds4drv/backends/hidraw.py: -------------------------------------------------------------------------------- 1 | import fcntl 2 | import itertools 3 | import os 4 | 5 | from io import FileIO 6 | from time import sleep 7 | 8 | from evdev import InputDevice 9 | from pyudev import Context, Monitor 10 | 11 | from ..backend import Backend 12 | from ..exceptions import DeviceError 13 | from ..device import DS4Device 14 | from ..utils import zero_copy_slice 15 | 16 | 17 | IOC_RW = 3221243904 18 | HIDIOCSFEATURE = lambda size: IOC_RW | (0x06 << 0) | (size << 16) 19 | HIDIOCGFEATURE = lambda size: IOC_RW | (0x07 << 0) | (size << 16) 20 | 21 | 22 | class HidrawDS4Device(DS4Device): 23 | def __init__(self, name, addr, type, hidraw_device, event_device): 24 | try: 25 | self.report_fd = os.open(hidraw_device, os.O_RDWR | os.O_NONBLOCK) 26 | self.fd = FileIO(self.report_fd, "rb+", closefd=False) 27 | self.input_device = InputDevice(event_device) 28 | self.input_device.grab() 29 | except (OSError, IOError) as err: 30 | raise DeviceError(err) 31 | 32 | self.buf = bytearray(self.report_size) 33 | 34 | super(HidrawDS4Device, self).__init__(name, addr, type) 35 | 36 | def read_report(self): 37 | try: 38 | ret = self.fd.readinto(self.buf) 39 | except IOError: 40 | return 41 | 42 | # Disconnection 43 | if ret == 0: 44 | return 45 | 46 | # Invalid report size or id, just ignore it 47 | if ret < self.report_size or self.buf[0] != self.valid_report_id: 48 | return False 49 | 50 | if self.type == "bluetooth": 51 | # Cut off bluetooth data 52 | buf = zero_copy_slice(self.buf, 2) 53 | else: 54 | buf = self.buf 55 | 56 | return self.parse_report(buf) 57 | 58 | def read_feature_report(self, report_id, size): 59 | op = HIDIOCGFEATURE(size + 1) 60 | buf = bytearray(size + 1) 61 | buf[0] = report_id 62 | 63 | return fcntl.ioctl(self.fd, op, bytes(buf)) 64 | 65 | def write_report(self, report_id, data): 66 | hid = bytearray((report_id,)) 67 | self.fd.write(hid + data) 68 | 69 | def close(self): 70 | try: 71 | # Reset LED to original hidraw pairing colour. 72 | self.set_led(0, 0, 1) 73 | 74 | self.fd.close() 75 | self.input_device.ungrab() 76 | except IOError: 77 | pass 78 | 79 | 80 | class HidrawBluetoothDS4Device(HidrawDS4Device): 81 | __type__ = "bluetooth" 82 | 83 | report_size = 78 84 | valid_report_id = 0x11 85 | 86 | def set_operational(self): 87 | self.read_feature_report(0x02, 37) 88 | 89 | 90 | class HidrawUSBDS4Device(HidrawDS4Device): 91 | __type__ = "usb" 92 | 93 | report_size = 64 94 | valid_report_id = 0x01 95 | 96 | def set_operational(self): 97 | # Get the bluetooth MAC 98 | addr = self.read_feature_report(0x81, 6)[1:] 99 | addr = ["{0:02x}".format(c) for c in bytearray(addr)] 100 | addr = ":".join(reversed(addr)).upper() 101 | 102 | self.device_name = "{0} {1}".format(addr, self.device_name) 103 | self.device_addr = addr 104 | 105 | 106 | HID_DEVICES = { 107 | "Sony Interactive Entertainment Wireless Controller": HidrawUSBDS4Device, 108 | "Sony Computer Entertainment Wireless Controller": HidrawUSBDS4Device, 109 | "Wireless Controller": HidrawBluetoothDS4Device, 110 | } 111 | 112 | 113 | class HidrawBackend(Backend): 114 | __name__ = "hidraw" 115 | 116 | def setup(self): 117 | pass 118 | 119 | def _get_future_devices(self, context): 120 | """Return a generator yielding new devices.""" 121 | monitor = Monitor.from_netlink(context) 122 | monitor.filter_by("hidraw") 123 | monitor.start() 124 | 125 | self._scanning_log_message() 126 | for device in iter(monitor.poll, None): 127 | if device.action == "add": 128 | # Sometimes udev rules has not been applied at this point, 129 | # causing permission denied error if we are running in user 130 | # mode. With this sleep this will hopefully not happen. 131 | sleep(1) 132 | 133 | yield device 134 | self._scanning_log_message() 135 | 136 | def _scanning_log_message(self): 137 | self.logger.info("Scanning for devices") 138 | 139 | @property 140 | def devices(self): 141 | """Wait for new DS4 devices to appear.""" 142 | context = Context() 143 | 144 | existing_devices = context.list_devices(subsystem="hidraw") 145 | future_devices = self._get_future_devices(context) 146 | 147 | for hidraw_device in itertools.chain(existing_devices, future_devices): 148 | hid_device = hidraw_device.parent 149 | if hid_device.subsystem != "hid": 150 | continue 151 | 152 | cls = HID_DEVICES.get(hid_device.get("HID_NAME")) 153 | if not cls: 154 | continue 155 | 156 | for child in hid_device.parent.children: 157 | event_device = child.get("DEVNAME", "") 158 | if event_device.startswith("/dev/input/event"): 159 | break 160 | else: 161 | continue 162 | 163 | 164 | try: 165 | device_addr = hid_device.get("HID_UNIQ", "").upper() 166 | if device_addr: 167 | device_name = "{0} {1}".format(device_addr, 168 | hidraw_device.sys_name) 169 | else: 170 | device_name = hidraw_device.sys_name 171 | 172 | yield cls(name=device_name, 173 | addr=device_addr, 174 | type=cls.__type__, 175 | hidraw_device=hidraw_device.device_node, 176 | event_device=event_device) 177 | 178 | except DeviceError as err: 179 | self.logger.error("Unable to open DS4 device: {0}", err) 180 | -------------------------------------------------------------------------------- /ds4drv/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import signal 3 | 4 | from threading import Thread 5 | 6 | from .actions import ActionRegistry 7 | from .backends import BluetoothBackend, HidrawBackend 8 | from .config import load_options 9 | from .daemon import Daemon 10 | from .eventloop import EventLoop 11 | from .exceptions import BackendError 12 | 13 | 14 | class DS4Controller(object): 15 | def __init__(self, index, options, dynamic=False): 16 | self.index = index 17 | self.dynamic = dynamic 18 | self.logger = Daemon.logger.new_module("controller {0}".format(index)) 19 | 20 | self.error = None 21 | self.device = None 22 | self.loop = EventLoop() 23 | 24 | self.actions = [cls(self) for cls in ActionRegistry.actions] 25 | self.bindings = options.parent.bindings 26 | self.current_profile = "default" 27 | self.default_profile = options 28 | self.options = self.default_profile 29 | self.profiles = options.profiles 30 | self.profile_options = dict(options.parent.profiles) 31 | self.profile_options["default"] = self.default_profile 32 | 33 | if self.profiles: 34 | self.profiles.append("default") 35 | 36 | self.load_options(self.options) 37 | 38 | def fire_event(self, event, *args): 39 | self.loop.fire_event(event, *args) 40 | 41 | def load_profile(self, profile): 42 | if profile == self.current_profile: 43 | return 44 | 45 | profile_options = self.profile_options.get(profile) 46 | if profile_options: 47 | self.logger.info("Switching to profile: {0}", profile) 48 | self.load_options(profile_options) 49 | self.current_profile = profile 50 | self.fire_event("load-profile", profile) 51 | else: 52 | self.logger.warning("Ignoring invalid profile: {0}", profile) 53 | 54 | def next_profile(self): 55 | if not self.profiles: 56 | return 57 | 58 | next_index = self.profiles.index(self.current_profile) + 1 59 | if next_index >= len(self.profiles): 60 | next_index = 0 61 | 62 | self.load_profile(self.profiles[next_index]) 63 | 64 | def prev_profile(self): 65 | if not self.profiles: 66 | return 67 | 68 | next_index = self.profiles.index(self.current_profile) - 1 69 | if next_index < 0: 70 | next_index = len(self.profiles) - 1 71 | 72 | self.load_profile(self.profiles[next_index]) 73 | 74 | def setup_device(self, device): 75 | self.logger.info("Connected to {0}", device.name) 76 | 77 | self.device = device 78 | self.device.set_led(*self.options.led) 79 | self.fire_event("device-setup", device) 80 | self.loop.add_watcher(device.report_fd, self.read_report) 81 | self.load_options(self.options) 82 | 83 | def cleanup_device(self): 84 | self.logger.info("Disconnected") 85 | self.fire_event("device-cleanup") 86 | self.loop.remove_watcher(self.device.report_fd) 87 | self.device.close() 88 | self.device = None 89 | 90 | if self.dynamic: 91 | self.loop.stop() 92 | 93 | def load_options(self, options): 94 | self.fire_event("load-options", options) 95 | self.options = options 96 | 97 | def read_report(self): 98 | report = self.device.read_report() 99 | 100 | if not report: 101 | if report is False: 102 | return 103 | 104 | self.cleanup_device() 105 | return 106 | 107 | self.fire_event("device-report", report) 108 | 109 | def run(self): 110 | self.loop.run() 111 | 112 | def exit(self, *args, **kwargs): 113 | error = kwargs.pop('error', True) 114 | 115 | if self.device: 116 | self.cleanup_device() 117 | 118 | if error == True: 119 | self.logger.error(*args) 120 | self.error = True 121 | else: 122 | self.logger.info(*args) 123 | 124 | 125 | def create_controller_thread(index, controller_options, dynamic=False): 126 | controller = DS4Controller(index, controller_options, dynamic=dynamic) 127 | 128 | thread = Thread(target=controller.run) 129 | thread.controller = controller 130 | thread.start() 131 | 132 | return thread 133 | 134 | 135 | class SigintHandler(object): 136 | def __init__(self, threads): 137 | self.threads = threads 138 | 139 | def cleanup_controller_threads(self): 140 | for thread in self.threads: 141 | thread.controller.exit("Cleaning up...", error=False) 142 | thread.controller.loop.stop() 143 | thread.join() 144 | 145 | def __call__(self, signum, frame): 146 | signal.signal(signum, signal.SIG_DFL) 147 | 148 | self.cleanup_controller_threads() 149 | sys.exit(0) 150 | 151 | 152 | def main(): 153 | threads = [] 154 | 155 | sigint_handler = SigintHandler(threads) 156 | signal.signal(signal.SIGINT, sigint_handler) 157 | 158 | try: 159 | options = load_options() 160 | except ValueError as err: 161 | Daemon.exit("Failed to parse options: {0}", err) 162 | 163 | if options.hidraw: 164 | backend = HidrawBackend(Daemon.logger) 165 | else: 166 | backend = BluetoothBackend(Daemon.logger) 167 | 168 | try: 169 | backend.setup() 170 | except BackendError as err: 171 | Daemon.exit(err) 172 | 173 | if options.daemon: 174 | Daemon.fork(options.daemon_log, options.daemon_pid) 175 | 176 | for index, controller_options in enumerate(options.controllers): 177 | thread = create_controller_thread(index + 1, controller_options) 178 | threads.append(thread) 179 | 180 | for device in backend.devices: 181 | connected_devices = [] 182 | for thread in threads: 183 | # Controller has received a fatal error, exit 184 | if thread.controller.error: 185 | sys.exit(1) 186 | 187 | if thread.controller.device: 188 | connected_devices.append(thread.controller.device.device_addr) 189 | 190 | # Clean up dynamic threads 191 | if not thread.is_alive(): 192 | threads.remove(thread) 193 | 194 | if device.device_addr in connected_devices: 195 | backend.logger.warning("Ignoring already connected device: {0}", 196 | device.device_addr) 197 | continue 198 | 199 | for thread in filter(lambda t: not t.controller.device, threads): 200 | break 201 | else: 202 | thread = create_controller_thread(len(threads) + 1, 203 | options.default_controller, 204 | dynamic=True) 205 | threads.append(thread) 206 | 207 | thread.controller.setup_device(device) 208 | 209 | if __name__ == "__main__": 210 | main() 211 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | ds4drv 3 | ====== 4 | 5 | ds4drv is a Sony DualShock 4 userspace driver for Linux. 6 | 7 | * Discussions: https://groups.google.com/forum/#!forum/ds4drv 8 | * GitHub: https://github.com/chrippa/ds4drv 9 | * PyPI: https://pypi.python.org/pypi/ds4drv 10 | 11 | Features 12 | -------- 13 | 14 | - Option to emulate the Xbox 360 controller for compatibility with Steam games 15 | - Setting the LED color 16 | - Reminding you about low battery by flashing the LED 17 | - Using the trackpad as a mouse 18 | - Custom mappings, map buttons and sticks to whatever mouse, key or joystick 19 | action you want 20 | - Settings profiles that can be cycled through with a button binding 21 | 22 | 23 | Installing 24 | ---------- 25 | 26 | Dependencies 27 | ^^^^^^^^^^^^ 28 | 29 | - `Python `_ 2.7 or 3.3+ (for Debian/Ubuntu you need to 30 | install the *python2.7-dev* or *python3.3-dev* package) 31 | - `python-setuptools `_ 32 | - hcitool (usually available in the *bluez-utils* or equivalent package) 33 | 34 | These packages will normally be installed automatically by the setup script, 35 | but you may want to use your distro's packages if available: 36 | 37 | - `pyudev `_ 0.16 or higher 38 | - `python-evdev `_ 0.3.0 or higher 39 | 40 | 41 | Stable release 42 | ^^^^^^^^^^^^^^ 43 | 44 | Installing the latest release is simple by using `pip `_: 45 | 46 | .. code-block:: bash 47 | 48 | $ sudo pip install ds4drv 49 | 50 | Development version 51 | ^^^^^^^^^^^^^^^^^^^ 52 | 53 | If you want to try out latest development code check out the source from 54 | Github and install it with: 55 | 56 | .. code-block:: bash 57 | 58 | $ git clone https://github.com/chrippa/ds4drv.git 59 | $ cd ds4drv 60 | $ sudo python setup.py install 61 | 62 | 63 | Using 64 | ----- 65 | 66 | ds4drv has two different modes to find DS4 devices, decide which one to use 67 | depending on your use case. 68 | 69 | Raw bluetooth mode 70 | ^^^^^^^^^^^^^^^^^^ 71 | 72 | Supported protocols: **Bluetooth** 73 | 74 | Unless your system is using BlueZ 5.14 (which was released recently) or higher 75 | it is not possible to pair with the DS4. Therefore this workaround exists, 76 | which connects directly to the DS4 when it has been started in pairing mode 77 | (by holding **Share + the PS button** until the LED starts blinking rapidly). 78 | 79 | This is the default mode when running without any options: 80 | 81 | .. code-block:: bash 82 | 83 | $ ds4drv 84 | 85 | 86 | Hidraw mode 87 | ^^^^^^^^^^^ 88 | 89 | Supported protocols: **Bluetooth** and **USB** 90 | 91 | This mode uses the Linux kernel feature *hidraw* to talk to already existing 92 | devices on the system. 93 | 94 | .. code-block:: bash 95 | 96 | $ ds4drv --hidraw 97 | 98 | 99 | To use the DS4 via bluetooth in this mode you must pair it first. This requires 100 | **BlueZ 5.14+** as there was a bug preventing pairing in earlier verions. How you 101 | actually pair the DS4 with your computer depends on how your system is setup, 102 | suggested googling: * bluetooth pairing* 103 | 104 | To use the DS4 via USB in this mode, simply connect your DS4 to your computer via 105 | a micro USB cable. 106 | 107 | 108 | Permissions 109 | ^^^^^^^^^^^ 110 | 111 | If you want to use ds4drv as a normal user, you need to make sure ds4drv has 112 | permissions to use certain features on your system. 113 | 114 | ds4drv uses the kernel module *uinput* to create input devices in user land and 115 | the module *hidraw* to communicate with DualShock 4 controllers (when using 116 | ``--hidraw``), but this usually requires root permissions. You can change the 117 | permissions by copying the `udev rules file `_ to 118 | ``/etc/udev/rules.d/``. 119 | 120 | You may have to reload your udev rules after this with: 121 | 122 | .. code-block:: bash 123 | 124 | $ sudo udevadm control --reload-rules 125 | $ sudo udevadm trigger 126 | 127 | 128 | Configuring 129 | ----------- 130 | 131 | Configuration file 132 | ^^^^^^^^^^^^^^^^^^ 133 | 134 | The preferred way of configuring ds4drv is via a config file. 135 | Take a look at `ds4drv.conf `_ for example usage. 136 | 137 | ds4drv will look for the config file in the following paths: 138 | 139 | - ``~/.config/ds4drv.conf`` 140 | - ``/etc/ds4drv.conf`` 141 | 142 | ... or you can specify your own location with ``--config``. 143 | 144 | 145 | Command line options 146 | ^^^^^^^^^^^^^^^^^^^^ 147 | You can also configure using command line options, this will set the LED 148 | to a bright red: 149 | 150 | .. code-block:: bash 151 | 152 | $ ds4drv --led ff0000 153 | 154 | See ``ds4drv --help`` for a list of all the options. 155 | 156 | 157 | Multiple controllers 158 | ^^^^^^^^^^^^^^^^^^^^ 159 | 160 | ds4drv does in theory support multiple controllers (I only have one 161 | controller myself, so this is untested). You can give each controller 162 | different options like this: 163 | 164 | .. code-block:: bash 165 | 166 | $ ds4drv --led ff0000 --next-controller --led 00ff00 167 | 168 | This will set the LED color to red on the first controller connected and 169 | green on the second. 170 | 171 | 172 | Known issues/limitations 173 | ------------------------ 174 | 175 | - `Bluetooth 2.0 dongles are known to have issues, 2.1+ is recommended. `_ 176 | - The controller will never be shut off, you need to do this manually by 177 | holding the PS button until the controller shuts off 178 | - No rumble support 179 | 180 | 181 | Troubleshooting 182 | --------------- 183 | 184 | Check here for frequently encountered issues. 185 | 186 | Failed to create input device: "/dev/uinput" cannot be opened for writing 187 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 188 | 189 | This could be because the uinput kernel module is not running on your 190 | computer. Doing ``lsmod | grep uinput`` should show if the module is loaded. 191 | If it is blank, run ``sudo modprobe uinput`` to load it. (The uinput module 192 | needs to be installed first. Please check with your distro's package 193 | manager.) 194 | 195 | To have the uinput module load on startup, you can add a file 196 | to ``/etc/modules-load.d``. For example: 197 | 198 | .. code-block:: bash 199 | 200 | # in file /etc/modules-load.d/uinput.conf 201 | # Load uinput module at boot 202 | uinput 203 | 204 | 205 | References 206 | ---------- 207 | 208 | The DualShock 4 report format is not open and had to be reverse engineered. 209 | These resources have been very helpful when creating ds4drv: 210 | 211 | - http://www.psdevwiki.com/ps4/DualShock_4 212 | - http://eleccelerator.com/wiki/index.php?title=DualShock_4 213 | - https://gist.github.com/johndrinkwater/7708901 214 | - https://github.com/ehd/node-ds4 215 | - http://forums.pcsx2.net/Thread-DS4-To-XInput-Wrapper 216 | 217 | 218 | ---- 219 | 220 | .. |dogecoin| image:: http://targetmoon.com/img/dogecoin.png 221 | :alt: Dogecoin 222 | :target: http://dogecoin.com/ 223 | 224 | |dogecoin| DCbQgDa4aEbm9QNm4ix6zYV9vMirUDQLNj 225 | -------------------------------------------------------------------------------- /ds4drv/device.py: -------------------------------------------------------------------------------- 1 | from struct import Struct 2 | from sys import version_info as sys_version 3 | 4 | 5 | class StructHack(Struct): 6 | """Python <2.7.4 doesn't support struct unpack from bytearray.""" 7 | def unpack_from(self, buf, offset=0): 8 | buf = buffer(buf) 9 | 10 | return Struct.unpack_from(self, buf, offset) 11 | 12 | 13 | if sys_version[:3] <= (2, 7, 4): 14 | S16LE = StructHack("> 7) == 0, 196 | ((buf[37] & 0x0f) << 8) | buf[36], 197 | buf[38] << 4 | ((buf[37] & 0xf0) >> 4), 198 | 199 | # Trackpad touch 2: id, active, x, y 200 | buf[39] & 0x7f, (buf[39] >> 7) == 0, 201 | ((buf[41] & 0x0f) << 8) | buf[40], 202 | buf[42] << 4 | ((buf[41] & 0xf0) >> 4), 203 | 204 | # Timestamp and battery 205 | buf[7] >> 2, 206 | buf[30] % 16, 207 | 208 | # External inputs (usb, audio, mic) 209 | (buf[30] & 16) != 0, (buf[30] & 32) != 0, 210 | (buf[30] & 64) != 0 211 | ) 212 | 213 | def read_report(self): 214 | """Read and parse a HID report.""" 215 | pass 216 | 217 | def write_report(self, report_id, data): 218 | """Writes a HID report to the control channel.""" 219 | pass 220 | 221 | def set_operational(self): 222 | """Tells the DS4 controller we want full HID reports.""" 223 | pass 224 | 225 | def close(self): 226 | """Disconnects from the device.""" 227 | pass 228 | 229 | @property 230 | def name(self): 231 | if self.type == "bluetooth": 232 | type_name = "Bluetooth" 233 | elif self.type == "usb": 234 | type_name = "USB" 235 | 236 | return "{0} Controller ({1})".format(type_name, self.device_name) 237 | -------------------------------------------------------------------------------- /ds4drv/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | import sys 5 | 6 | try: 7 | import ConfigParser as configparser 8 | except ImportError: 9 | import configparser 10 | 11 | from functools import partial 12 | from operator import attrgetter 13 | 14 | from . import __version__ 15 | from .uinput import parse_uinput_mapping 16 | from .utils import parse_button_combo 17 | 18 | 19 | CONFIG_FILES = ("~/.config/ds4drv.conf", "/etc/ds4drv.conf") 20 | DAEMON_LOG_FILE = "~/.cache/ds4drv.log" 21 | DAEMON_PID_FILE = "/tmp/ds4drv.pid" 22 | 23 | 24 | class SortingHelpFormatter(argparse.HelpFormatter): 25 | def add_argument(self, action): 26 | # Force the built in options to be capitalized 27 | if action.option_strings[-1] in ("--version", "--help"): 28 | action.help = action.help.capitalize() 29 | 30 | super(SortingHelpFormatter, self).add_argument(action) 31 | self.add_text("") 32 | 33 | def start_section(self, heading): 34 | heading = heading.capitalize() 35 | return super(SortingHelpFormatter, self).start_section(heading) 36 | 37 | def add_arguments(self, actions): 38 | actions = sorted(actions, key=attrgetter("option_strings")) 39 | super(SortingHelpFormatter, self).add_arguments(actions) 40 | 41 | 42 | parser = argparse.ArgumentParser(prog="ds4drv", 43 | formatter_class=SortingHelpFormatter) 44 | parser.add_argument("--version", action="version", 45 | version="%(prog)s {0}".format(__version__)) 46 | 47 | configopt = parser.add_argument_group("configuration options") 48 | configopt.add_argument("--config", metavar="filename", 49 | type=os.path.expanduser, 50 | help="Configuration file to read settings from. " 51 | "Default is ~/.config/ds4drv.conf or " 52 | "/etc/ds4drv.conf, whichever is found first") 53 | 54 | backendopt = parser.add_argument_group("backend options") 55 | backendopt.add_argument("--hidraw", action="store_true", 56 | help="Use hidraw devices. This can be used to access " 57 | "USB and paired bluetooth devices. Note: " 58 | "Bluetooth devices does currently not support " 59 | "any LED functionality") 60 | 61 | daemonopt = parser.add_argument_group("daemon options") 62 | daemonopt.add_argument("--daemon", action="store_true", 63 | help="Run in the background as a daemon") 64 | daemonopt.add_argument("--daemon-log", default=DAEMON_LOG_FILE, metavar="file", 65 | help="Log file to create in daemon mode") 66 | daemonopt.add_argument("--daemon-pid", default=DAEMON_PID_FILE, metavar="file", 67 | help="PID file to create in daemon mode") 68 | 69 | controllopt = parser.add_argument_group("controller options") 70 | 71 | 72 | class Config(configparser.SafeConfigParser): 73 | def load(self, filename): 74 | self.read([filename]) 75 | 76 | def section_to_args(self, section): 77 | args = [] 78 | 79 | for key, value in self.section(section).items(): 80 | if value.lower() == "true": 81 | args.append("--{0}".format(key)) 82 | elif value.lower() == "false": 83 | pass 84 | else: 85 | args.append("--{0}={1}".format(key, value)) 86 | 87 | return args 88 | 89 | def section(self, section, key_type=str, value_type=str): 90 | try: 91 | # Removes empty values and applies types 92 | return dict(map(lambda kv: (key_type(kv[0]), value_type(kv[1])), 93 | filter(lambda i: bool(i[1]), 94 | self.items(section)))) 95 | except configparser.NoSectionError: 96 | return {} 97 | 98 | def sections(self, prefix=None): 99 | for section in configparser.SafeConfigParser.sections(self): 100 | match = re.match(r"{0}:(.+)".format(prefix), section) 101 | if match: 102 | yield match.group(1), section 103 | 104 | def controllers(self): 105 | controller_sections = dict(self.sections("controller")) 106 | if not controller_sections: 107 | return ["--next-controller"] 108 | 109 | last_controller = max(map(lambda c: int(c[0]), controller_sections)) 110 | args = [] 111 | for i in range(1, last_controller + 1): 112 | section = controller_sections.get(str(i)) 113 | if section: 114 | for arg in self.section_to_args(section): 115 | args.append(arg) 116 | 117 | args.append("--next-controller") 118 | 119 | return args 120 | 121 | 122 | class ControllerAction(argparse.Action): 123 | # These options are moved from the normal options namespace to 124 | # a controller specific namespace on --next-controller. 125 | __options__ = [] 126 | 127 | @classmethod 128 | def default_controller(cls): 129 | controller = argparse.Namespace() 130 | defaults = parser.parse_args([]) 131 | for option in cls.__options__: 132 | value = getattr(defaults, option) 133 | setattr(controller, option, value) 134 | 135 | return controller 136 | 137 | def __call__(self, parser, namespace, values, option_string=None): 138 | if not hasattr(namespace, "controllers"): 139 | setattr(namespace, "controllers", []) 140 | 141 | controller = argparse.Namespace() 142 | defaults = parser.parse_args([]) 143 | for option in self.__options__: 144 | if hasattr(namespace, option): 145 | value = namespace.__dict__.pop(option) 146 | if isinstance(value, str): 147 | for action in filter(lambda a: a.dest == option, 148 | parser._actions): 149 | value = parser._get_value(action, value) 150 | else: 151 | value = getattr(defaults, option) 152 | 153 | setattr(controller, option, value) 154 | 155 | namespace.controllers.append(controller) 156 | 157 | controllopt.add_argument("--next-controller", nargs=0, action=ControllerAction, 158 | help="Creates another controller") 159 | 160 | def hexcolor(color): 161 | color = color.strip("#") 162 | 163 | if len(color) != 6: 164 | raise ValueError 165 | 166 | values = (color[:2], color[2:4], color[4:6]) 167 | values = map(lambda x: int(x, 16), values) 168 | 169 | return tuple(values) 170 | 171 | 172 | def stringlist(s): 173 | return list(filter(None, map(str.strip, s.split(",")))) 174 | 175 | 176 | def buttoncombo(sep): 177 | func = partial(parse_button_combo, sep=sep) 178 | func.__name__ = "button combo" 179 | return func 180 | 181 | 182 | def merge_options(src, dst, defaults): 183 | for key, value in src.__dict__.items(): 184 | if key == "controllers": 185 | continue 186 | 187 | default = getattr(defaults, key) 188 | 189 | if getattr(dst, key) == default and value != default: 190 | setattr(dst, key, value) 191 | 192 | 193 | def load_options(): 194 | options = parser.parse_args(sys.argv[1:] + ["--next-controller"]) 195 | 196 | config = Config() 197 | config_paths = options.config and (options.config,) or CONFIG_FILES 198 | for path in filter(os.path.exists, map(os.path.expanduser, config_paths)): 199 | config.load(path) 200 | break 201 | 202 | config_args = config.section_to_args("ds4drv") + config.controllers() 203 | config_options = parser.parse_args(config_args) 204 | 205 | defaults, remaining_args = parser.parse_known_args(["--next-controller"]) 206 | merge_options(config_options, options, defaults) 207 | 208 | controller_defaults = ControllerAction.default_controller() 209 | for idx, controller in enumerate(config_options.controllers): 210 | try: 211 | org_controller = options.controllers[idx] 212 | merge_options(controller, org_controller, controller_defaults) 213 | except IndexError: 214 | options.controllers.append(controller) 215 | 216 | options.profiles = {} 217 | for name, section in config.sections("profile"): 218 | args = config.section_to_args(section) 219 | profile_options = parser.parse_args(args) 220 | profile_options.parent = options 221 | options.profiles[name] = profile_options 222 | 223 | options.bindings = {} 224 | options.bindings["global"] = config.section("bindings", 225 | key_type=parse_button_combo) 226 | for name, section in config.sections("bindings"): 227 | options.bindings[name] = config.section(section, 228 | key_type=parse_button_combo) 229 | 230 | for name, section in config.sections("mapping"): 231 | mapping = config.section(section) 232 | for key, attr in mapping.items(): 233 | if '#' in attr: # Remove tailing comments on the line 234 | attr = attr.split('#', 1)[0].rstrip() 235 | mapping[key] = attr 236 | parse_uinput_mapping(name, mapping) 237 | 238 | for controller in options.controllers: 239 | controller.parent = options 240 | 241 | options.default_controller = ControllerAction.default_controller() 242 | options.default_controller.parent = options 243 | 244 | return options 245 | 246 | 247 | def add_controller_option(name, **options): 248 | option_name = name[2:].replace("-", "_") 249 | controllopt.add_argument(name, **options) 250 | ControllerAction.__options__.append(option_name) 251 | 252 | 253 | add_controller_option("--profiles", metavar="profiles", 254 | type=stringlist, 255 | help="Profiles to cycle through using the button " 256 | "specified by --profile-toggle, e.g. " 257 | "'profile1,profile2'") 258 | 259 | -------------------------------------------------------------------------------- /ds4drv/uinput.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import time 3 | 4 | from collections import namedtuple 5 | 6 | from evdev import UInput, UInputError, ecodes 7 | from evdev import util 8 | 9 | from .exceptions import DeviceError 10 | 11 | # Check for the existence of a "resolve_ecodes_dict" function. 12 | # Need to know if axis options tuples should be altered. 13 | # This is needed to keep the code compatible with python-evdev < 0.6.0. 14 | absInfoUsesValue = hasattr(util, "resolve_ecodes_dict") 15 | 16 | BUTTON_MODIFIERS = ("+", "-") 17 | 18 | DEFAULT_A2D_DEADZONE = 50 19 | DEFAULT_AXIS_OPTIONS = (0, 0, 255, 0, 5) 20 | DEFAULT_MOUSE_SENSITIVTY = 0.8 21 | DEFAULT_MOUSE_DEADZONE = 5 22 | DEFAULT_SCROLL_REPEAT_DELAY = .250 # Seconds to wait before continual scrolling 23 | DEFAULT_SCROLL_DELAY = .035 # Seconds to wait between scroll events 24 | 25 | UInputMapping = namedtuple("UInputMapping", 26 | "name bustype vendor product version " 27 | "axes axes_options buttons hats keys mouse " 28 | "mouse_options") 29 | 30 | _mappings = {} 31 | 32 | # Add our simulated mousewheel codes 33 | ecodes.REL_WHEELUP = 13 # Unique value for this lib 34 | ecodes.REL_WHEELDOWN = 14 # Ditto 35 | 36 | 37 | def parse_button(attr): 38 | if attr[0] in BUTTON_MODIFIERS: 39 | modifier = attr[0] 40 | attr = attr[1:] 41 | else: 42 | modifier = None 43 | 44 | return (attr, modifier) 45 | 46 | 47 | def create_mapping(name, description, bustype=0, vendor=0, product=0, 48 | version=0, axes={}, axes_options={}, buttons={}, 49 | hats={}, keys={}, mouse={}, mouse_options={}): 50 | axes = {getattr(ecodes, k): v for k,v in axes.items()} 51 | axes_options = {getattr(ecodes, k): v for k,v in axes_options.items()} 52 | buttons = {getattr(ecodes, k): parse_button(v) for k,v in buttons.items()} 53 | hats = {getattr(ecodes, k): v for k,v in hats.items()} 54 | mouse = {getattr(ecodes, k): parse_button(v) for k,v in mouse.items()} 55 | 56 | mapping = UInputMapping(description, bustype, vendor, product, version, 57 | axes, axes_options, buttons, hats, keys, mouse, 58 | mouse_options) 59 | _mappings[name] = mapping 60 | 61 | 62 | # Pre-configued mappings 63 | create_mapping( 64 | "ds4", "Sony Computer Entertainment Wireless Controller", 65 | # Bus type, vendor, product, version 66 | ecodes.BUS_USB, 1356, 1476, 273, 67 | # Axes 68 | { 69 | "ABS_X": "left_analog_x", 70 | "ABS_Y": "left_analog_y", 71 | "ABS_Z": "right_analog_x", 72 | "ABS_RZ": "right_analog_y", 73 | "ABS_RX": "l2_analog", 74 | "ABS_RY": "r2_analog", 75 | "ABS_THROTTLE": "orientation_roll", 76 | "ABS_RUDDER": "orientation_pitch", 77 | "ABS_WHEEL": "orientation_yaw", 78 | "ABS_DISTANCE": "motion_z", 79 | "ABS_TILT_X": "motion_x", 80 | "ABS_TILT_Y": "motion_y", 81 | }, 82 | # Axes options 83 | { 84 | "ABS_THROTTLE": (0, -16385, 16384, 0, 0), 85 | "ABS_RUDDER": (0, -16385, 16384, 0, 0), 86 | "ABS_WHEEL": (0, -16385, 16384, 0, 0), 87 | "ABS_DISTANCE": (0, -32768, 32767, 0, 10), 88 | "ABS_TILT_X": (0, -32768, 32767, 0, 10), 89 | "ABS_TILT_Y": (0, -32768, 32767, 0, 10), 90 | }, 91 | # Buttons 92 | { 93 | "BTN_TR2": "button_options", 94 | "BTN_MODE": "button_ps", 95 | "BTN_TL2": "button_share", 96 | "BTN_B": "button_cross", 97 | "BTN_C": "button_circle", 98 | "BTN_A": "button_square", 99 | "BTN_X": "button_triangle", 100 | "BTN_Y": "button_l1", 101 | "BTN_Z": "button_r1", 102 | "BTN_TL": "button_l2", 103 | "BTN_TR": "button_r2", 104 | "BTN_SELECT": "button_l3", 105 | "BTN_START": "button_r3", 106 | "BTN_THUMBL": "button_trackpad" 107 | }, 108 | # Hats 109 | { 110 | "ABS_HAT0X": ("dpad_left", "dpad_right"), 111 | "ABS_HAT0Y": ("dpad_up", "dpad_down") 112 | } 113 | ) 114 | 115 | create_mapping( 116 | "xboxdrv", "Xbox Gamepad (userspace driver)", 117 | # Bus type, vendor, product, version 118 | 0, 0, 0, 0, 119 | # Axes 120 | { 121 | "ABS_X": "left_analog_x", 122 | "ABS_Y": "left_analog_y", 123 | "ABS_RX": "right_analog_x", 124 | "ABS_RY": "right_analog_y", 125 | "ABS_BRAKE": "l2_analog", 126 | "ABS_GAS": "r2_analog" 127 | }, 128 | # Axes settings 129 | {}, 130 | #Buttons 131 | { 132 | "BTN_START": "button_options", 133 | "BTN_MODE": "button_ps", 134 | "BTN_SELECT": "button_share", 135 | "BTN_A": "button_cross", 136 | "BTN_B": "button_circle", 137 | "BTN_X": "button_square", 138 | "BTN_Y": "button_triangle", 139 | "BTN_TL": "button_l1", 140 | "BTN_TR": "button_r1", 141 | "BTN_THUMBL": "button_l3", 142 | "BTN_THUMBR": "button_r3" 143 | }, 144 | # Hats 145 | { 146 | "ABS_HAT0X": ("dpad_left", "dpad_right"), 147 | "ABS_HAT0Y": ("dpad_up", "dpad_down") 148 | } 149 | ) 150 | 151 | create_mapping( 152 | "xpad", "Microsoft X-Box 360 pad", 153 | # Bus type, vendor, product, version 154 | ecodes.BUS_USB, 1118, 654, 272, 155 | # Axes 156 | { 157 | "ABS_X": "left_analog_x", 158 | "ABS_Y": "left_analog_y", 159 | "ABS_RX": "right_analog_x", 160 | "ABS_RY": "right_analog_y", 161 | "ABS_Z": "l2_analog", 162 | "ABS_RZ": "r2_analog" 163 | }, 164 | # Axes settings 165 | {}, 166 | #Buttons 167 | { 168 | "BTN_START": "button_options", 169 | "BTN_MODE": "button_ps", 170 | "BTN_SELECT": "button_share", 171 | "BTN_A": "button_cross", 172 | "BTN_B": "button_circle", 173 | "BTN_X": "button_square", 174 | "BTN_Y": "button_triangle", 175 | "BTN_TL": "button_l1", 176 | "BTN_TR": "button_r1", 177 | "BTN_THUMBL": "button_l3", 178 | "BTN_THUMBR": "button_r3" 179 | }, 180 | # Hats 181 | { 182 | "ABS_HAT0X": ("dpad_left", "dpad_right"), 183 | "ABS_HAT0Y": ("dpad_up", "dpad_down") 184 | } 185 | ) 186 | 187 | create_mapping( 188 | "xpad_wireless", "Xbox 360 Wireless Receiver", 189 | # Bus type, vendor, product, version 190 | ecodes.BUS_USB, 1118, 1817, 256, 191 | # Axes 192 | { 193 | "ABS_X": "left_analog_x", 194 | "ABS_Y": "left_analog_y", 195 | "ABS_RX": "right_analog_x", 196 | "ABS_RY": "right_analog_y", 197 | "ABS_Z": "l2_analog", 198 | "ABS_RZ": "r2_analog" 199 | }, 200 | # Axes settings 201 | {}, 202 | #Buttons 203 | { 204 | "BTN_START": "button_options", 205 | "BTN_MODE": "button_ps", 206 | "BTN_SELECT": "button_share", 207 | "BTN_A": "button_cross", 208 | "BTN_B": "button_circle", 209 | "BTN_X": "button_square", 210 | "BTN_Y": "button_triangle", 211 | "BTN_TL": "button_l1", 212 | "BTN_TR": "button_r1", 213 | "BTN_THUMBL": "button_l3", 214 | "BTN_THUMBR": "button_r3", 215 | 216 | "BTN_TRIGGER_HAPPY1": "dpad_left", 217 | "BTN_TRIGGER_HAPPY2": "dpad_right", 218 | "BTN_TRIGGER_HAPPY3": "dpad_up", 219 | "BTN_TRIGGER_HAPPY4": "dpad_down", 220 | }, 221 | ) 222 | 223 | create_mapping( 224 | "mouse", "DualShock4 Mouse Emulation", 225 | buttons={ 226 | "BTN_LEFT": "button_trackpad", 227 | }, 228 | mouse={ 229 | "REL_X": "trackpad_touch0_x", 230 | "REL_Y": "trackpad_touch0_y" 231 | }, 232 | ) 233 | 234 | 235 | class UInputDevice(object): 236 | def __init__(self, layout): 237 | self.joystick_dev = None 238 | self.evdev_dev = None 239 | self.ignored_buttons = set() 240 | self.create_device(layout) 241 | 242 | self._write_cache = {} 243 | self._scroll_details = {} 244 | self.emit_reset() 245 | 246 | def create_device(self, layout): 247 | """Creates a uinput device using the specified layout.""" 248 | events = {ecodes.EV_ABS: [], ecodes.EV_KEY: [], 249 | ecodes.EV_REL: []} 250 | 251 | # Joystick device 252 | if layout.axes or layout.buttons or layout.hats: 253 | self.joystick_dev = next_joystick_device() 254 | 255 | for name in layout.axes: 256 | params = layout.axes_options.get(name, DEFAULT_AXIS_OPTIONS) 257 | if not absInfoUsesValue: 258 | params = params[1:] 259 | events[ecodes.EV_ABS].append((name, params)) 260 | 261 | for name in layout.hats: 262 | params = (0, -1, 1, 0, 0) 263 | if not absInfoUsesValue: 264 | params = params[1:] 265 | events[ecodes.EV_ABS].append((name, params)) 266 | 267 | for name in layout.buttons: 268 | events[ecodes.EV_KEY].append(name) 269 | 270 | if layout.mouse: 271 | self.mouse_pos = {} 272 | self.mouse_rel = {} 273 | self.mouse_analog_sensitivity = float( 274 | layout.mouse_options.get("MOUSE_SENSITIVITY", 275 | DEFAULT_MOUSE_SENSITIVTY) 276 | ) 277 | self.mouse_analog_deadzone = int( 278 | layout.mouse_options.get("MOUSE_DEADZONE", 279 | DEFAULT_MOUSE_DEADZONE) 280 | ) 281 | self.scroll_repeat_delay = float( 282 | layout.mouse_options.get("MOUSE_SCROLL_REPEAT_DELAY", 283 | DEFAULT_SCROLL_REPEAT_DELAY) 284 | ) 285 | self.scroll_delay = float( 286 | layout.mouse_options.get("MOUSE_SCROLL_DELAY", 287 | DEFAULT_SCROLL_DELAY) 288 | ) 289 | 290 | for name in layout.mouse: 291 | if name in (ecodes.REL_WHEELUP, ecodes.REL_WHEELDOWN): 292 | if ecodes.REL_WHEEL not in events[ecodes.EV_REL]: 293 | # This ensures that scroll wheel events can work 294 | events[ecodes.EV_REL].append(ecodes.REL_WHEEL) 295 | else: 296 | events[ecodes.EV_REL].append(name) 297 | self.mouse_rel[name] = 0.0 298 | 299 | self.device = UInput(name=layout.name, events=events, 300 | bustype=layout.bustype, vendor=layout.vendor, 301 | product=layout.product, version=layout.version) 302 | self.layout = layout 303 | 304 | def write_event(self, etype, code, value): 305 | """Writes a event to the device, if it has changed.""" 306 | last_value = self._write_cache.get(code) 307 | if last_value != value: 308 | self.device.write(etype, code, value) 309 | self._write_cache[code] = value 310 | 311 | def emit(self, report): 312 | """Writes axes, buttons and hats with values from the report to 313 | the device.""" 314 | for name, attr in self.layout.axes.items(): 315 | value = getattr(report, attr) 316 | self.write_event(ecodes.EV_ABS, name, value) 317 | 318 | for name, attr in self.layout.buttons.items(): 319 | attr, modifier = attr 320 | 321 | if attr in self.ignored_buttons: 322 | value = False 323 | else: 324 | value = getattr(report, attr) 325 | 326 | if modifier and "analog" in attr: 327 | if modifier == "+": 328 | value = value > (128 + DEFAULT_A2D_DEADZONE) 329 | elif modifier == "-": 330 | value = value < (128 - DEFAULT_A2D_DEADZONE) 331 | 332 | self.write_event(ecodes.EV_KEY, name, value) 333 | 334 | for name, attr in self.layout.hats.items(): 335 | if getattr(report, attr[0]): 336 | value = -1 337 | elif getattr(report, attr[1]): 338 | value = 1 339 | else: 340 | value = 0 341 | 342 | self.write_event(ecodes.EV_ABS, name, value) 343 | 344 | self.device.syn() 345 | 346 | def emit_reset(self): 347 | """Resets the device to a blank state.""" 348 | for name in self.layout.axes: 349 | params = self.layout.axes_options.get(name, DEFAULT_AXIS_OPTIONS) 350 | self.write_event(ecodes.EV_ABS, name, int(sum(params[1:3]) / 2)) 351 | 352 | for name in self.layout.buttons: 353 | self.write_event(ecodes.EV_KEY, name, False) 354 | 355 | for name in self.layout.hats: 356 | self.write_event(ecodes.EV_ABS, name, 0) 357 | 358 | self.device.syn() 359 | 360 | def emit_mouse(self, report): 361 | """Calculates relative mouse values from a report and writes them.""" 362 | for name, attr in self.layout.mouse.items(): 363 | # If the attr is a tuple like (left_analog_y, "-") 364 | # then set the attr to just be the first item 365 | attr, modifier = attr 366 | 367 | if attr.startswith("trackpad_touch"): 368 | active_attr = attr[:16] + "active" 369 | if not getattr(report, active_attr): 370 | self.mouse_pos.pop(name, None) 371 | continue 372 | 373 | pos = getattr(report, attr) 374 | if name not in self.mouse_pos: 375 | self.mouse_pos[name] = pos 376 | 377 | sensitivity = 0.5 378 | self.mouse_rel[name] += (pos - self.mouse_pos[name]) * sensitivity 379 | self.mouse_pos[name] = pos 380 | 381 | elif "analog" in attr: 382 | pos = getattr(report, attr) 383 | if (pos > (128 + self.mouse_analog_deadzone) 384 | or pos < (128 - self.mouse_analog_deadzone)): 385 | accel = (pos - 128) / 10 386 | else: 387 | continue 388 | 389 | # If a minus modifier has been given then minus the acceleration 390 | # to invert the direction. 391 | if (modifier and modifier == "-"): 392 | accel = -accel 393 | 394 | sensitivity = self.mouse_analog_sensitivity 395 | self.mouse_rel[name] += accel * sensitivity 396 | 397 | # Emulate mouse wheel (needs special handling) 398 | if name in (ecodes.REL_WHEELUP, ecodes.REL_WHEELDOWN): 399 | ecode = ecodes.REL_WHEEL # The real event we need to emit 400 | write = False 401 | if getattr(report, attr): 402 | self._scroll_details['direction'] = name 403 | now = time.time() 404 | last_write = self._scroll_details.get('last_write') 405 | if not last_write: 406 | # No delay for the first button press for fast feedback 407 | write = True 408 | self._scroll_details['count'] = 0 409 | if name == ecodes.REL_WHEELUP: 410 | value = 1 411 | elif name == ecodes.REL_WHEELDOWN: 412 | value = -1 413 | if last_write: 414 | # Delay at least one cycle before continual scrolling 415 | if self._scroll_details['count'] > 1: 416 | if now - last_write > self.scroll_delay: 417 | write = True 418 | elif now - last_write > self.scroll_repeat_delay: 419 | write = True 420 | if write: 421 | self.device.write(ecodes.EV_REL, ecode, value) 422 | self._scroll_details['last_write'] = now 423 | self._scroll_details['count'] += 1 424 | continue # No need to proceed further 425 | else: 426 | # Reset so you can quickly tap the button to scroll 427 | if self._scroll_details.get('direction') == name: 428 | self._scroll_details['last_write'] = 0 429 | self._scroll_details['count'] = 0 430 | 431 | rel = int(self.mouse_rel[name]) 432 | self.mouse_rel[name] = self.mouse_rel[name] - rel 433 | self.device.write(ecodes.EV_REL, name, rel) 434 | 435 | self.device.syn() 436 | 437 | 438 | def create_uinput_device(mapping): 439 | """Creates a uinput device.""" 440 | if mapping not in _mappings: 441 | raise DeviceError("Unknown device mapping: {0}".format(mapping)) 442 | 443 | try: 444 | mapping = _mappings[mapping] 445 | device = UInputDevice(mapping) 446 | except UInputError as err: 447 | raise DeviceError(err) 448 | 449 | return device 450 | 451 | 452 | def parse_uinput_mapping(name, mapping): 453 | """Parses a dict of mapping options.""" 454 | axes, buttons, mouse, mouse_options = {}, {}, {}, {} 455 | description = "ds4drv custom mapping ({0})".format(name) 456 | 457 | for key, attr in mapping.items(): 458 | key = key.upper() 459 | if key.startswith("BTN_") or key.startswith("KEY_"): 460 | buttons[key] = attr 461 | elif key.startswith("ABS_"): 462 | axes[key] = attr 463 | elif key.startswith("REL_"): 464 | mouse[key] = attr 465 | elif key.startswith("MOUSE_"): 466 | mouse_options[key] = attr 467 | 468 | create_mapping(name, description, axes=axes, buttons=buttons, 469 | mouse=mouse, mouse_options=mouse_options) 470 | 471 | 472 | def next_joystick_device(): 473 | """Finds the next available js device name.""" 474 | for i in range(100): 475 | dev = "/dev/input/js{0}".format(i) 476 | if not os.path.exists(dev): 477 | return dev 478 | --------------------------------------------------------------------------------