From 88096c47d53afc3923ef8abf5bb9b8e35666c388 Mon Sep 17 00:00:00 2001 From: Christopher Rosell Date: Tue, 11 Mar 2014 23:07:57 +0100 Subject: [PATCH] Split actions into their own modules. --- ds4drv/__main__.py | 19 +- ds4drv/action.py | 76 ++++++ ds4drv/actions.py | 474 ------------------------------------- ds4drv/actions/__init__.py | 9 + ds4drv/actions/battery.py | 40 ++++ ds4drv/actions/binding.py | 148 ++++++++++++ ds4drv/actions/btsignal.py | 46 ++++ ds4drv/actions/dump.py | 34 +++ ds4drv/actions/input.py | 120 ++++++++++ ds4drv/actions/led.py | 17 ++ ds4drv/actions/status.py | 62 +++++ ds4drv/config.py | 56 +---- ds4drv/utils.py | 5 + setup.py | 5 +- 14 files changed, 574 insertions(+), 537 deletions(-) create mode 100644 ds4drv/action.py delete mode 100644 ds4drv/actions.py create mode 100644 ds4drv/actions/__init__.py create mode 100644 ds4drv/actions/battery.py create mode 100644 ds4drv/actions/binding.py create mode 100644 ds4drv/actions/btsignal.py create mode 100644 ds4drv/actions/dump.py create mode 100644 ds4drv/actions/input.py create mode 100644 ds4drv/actions/led.py create mode 100644 ds4drv/actions/status.py diff --git a/ds4drv/__main__.py b/ds4drv/__main__.py index 4ed804f..c341ff3 100644 --- a/ds4drv/__main__.py +++ b/ds4drv/__main__.py @@ -2,13 +2,7 @@ from threading import Thread -from .actions import (ActionLED, - ReportActionBattery, - ReportActionBinding, - ReportActionBluetoothSignal, - ReportActionDump, - ReportActionInput, - ReportActionStatus) +from .actions import ActionRegistry from .backends import BluetoothBackend, HidrawBackend from .config import load_options from .daemon import Daemon @@ -16,15 +10,6 @@ from .exceptions import BackendError -ACTIONS = (ActionLED, - ReportActionBattery, - ReportActionBinding, - ReportActionBluetoothSignal, - ReportActionDump, - ReportActionInput, - ReportActionStatus) - - class DS4Controller(object): def __init__(self, index, options, dynamic=False): self.index = index @@ -35,7 +20,7 @@ def __init__(self, index, options, dynamic=False): self.device = None self.loop = EventLoop() - self.actions = [cls(self) for cls in ACTIONS] + self.actions = [cls(self) for cls in ActionRegistry.actions] self.bindings = options.parent.bindings self.current_profile = "default" self.default_profile = options diff --git a/ds4drv/action.py b/ds4drv/action.py new file mode 100644 index 0000000..c320fa2 --- /dev/null +++ b/ds4drv/action.py @@ -0,0 +1,76 @@ +from .config import add_controller_option +from .utils import with_metaclass + +from functools import wraps + +BASE_CLASSES = ["Action", "ReportAction"] + + +class ActionRegistry(type): + def __init__(cls, name, bases, attrs): + if name not in BASE_CLASSES: + if not hasattr(ActionRegistry, "actions"): + ActionRegistry.actions = [] + else: + ActionRegistry.actions.append(cls) + + +class Action(with_metaclass(ActionRegistry)): + """Actions are what drives most of the functionality of ds4drv.""" + + @classmethod + def add_option(self, *args, **kwargs): + add_controller_option(*args, **kwargs) + + def __init__(self, controller): + self.controller = controller + self.logger = controller.logger + + self.register_event("device-setup", self.setup) + self.register_event("device-cleanup", self.disable) + self.register_event("load-options", self.load_options) + + def create_timer(self, interval, func): + return self.controller.loop.create_timer(interval, func) + + def register_event(self, event, func): + self.controller.loop.register_event(event, func) + + def unregister_event(self, event, func): + self.controller.loop.unregister_event(event, func) + + def setup(self, device): + pass + + def enable(self): + pass + + def disable(self): + pass + + def load_options(self, options): + pass + + +class ReportAction(Action): + def __init__(self, controller): + super(ReportAction, self).__init__(controller) + + self._last_report = None + self.register_event("device-report", self._handle_report) + + def create_timer(self, interval, callback): + @wraps(callback) + def wrapper(*args, **kwargs): + if self._last_report: + return callback(self._last_report, *args, **kwargs) + return True + + return super(ReportAction, self).create_timer(interval, wrapper) + + def _handle_report(self, report): + self._last_report = report + self.handle_report(report) + + def handle_report(self, report): + pass diff --git a/ds4drv/actions.py b/ds4drv/actions.py deleted file mode 100644 index 6657df0..0000000 --- a/ds4drv/actions.py +++ /dev/null @@ -1,474 +0,0 @@ -import os -import re -import shlex -import subprocess - -from collections import namedtuple -from functools import wraps -from itertools import chain - -from .exceptions import DeviceError -from .uinput import create_uinput_device - - -BATTERY_MAX = 8 -BATTERY_MAX_CHARGING = 11 -BATTERY_WARNING = 2 - -BINDING_ACTIONS = {} - - -class Action(object): - """Actions are what drives most of the functionality of ds4drv.""" - - def __init__(self, controller): - self.controller = controller - self.logger = controller.logger - - self.register_event("device-setup", self.setup) - self.register_event("device-cleanup", self.disable) - self.register_event("load-options", self.load_options) - - def create_timer(self, interval, func): - return self.controller.loop.create_timer(interval, func) - - def register_event(self, event, func): - self.controller.loop.register_event(event, func) - - def unregister_event(self, event, func): - self.controller.loop.unregister_event(event, func) - - def setup(self, device): - pass - - def enable(self): - pass - - def disable(self): - pass - - def load_options(self, options): - pass - - -class ReportAction(Action): - def __init__(self, controller): - super(ReportAction, self).__init__(controller) - - self._last_report = None - self.register_event("device-report", self._handle_report) - - def create_timer(self, interval, callback): - @wraps(callback) - def wrapper(*args, **kwargs): - if self._last_report: - return callback(self._last_report, *args, **kwargs) - return True - - return super(ReportAction, self).create_timer(interval, wrapper) - - def _handle_report(self, report): - self._last_report = report - self.handle_report(report) - - def handle_report(self, report): - pass - - -class ActionLED(Action): - """Sets the LED color on the device.""" - - def setup(self, device): - device.set_led(*self.controller.options.led) - - def load_options(self, options): - if self.controller.device: - self.controller.device.set_led(*options.led) - - -class ReportActionBattery(ReportAction): - """Flashes the LED when battery is low.""" - - def __init__(self, *args, **kwargs): - super(ReportActionBattery, self).__init__(*args, **kwargs) - - self.timer_check = self.create_timer(60, self.check_battery) - self.timer_flash = self.create_timer(5, self.stop_flash) - - def enable(self): - self.timer_check.start() - - def disable(self): - self.timer_check.stop() - self.timer_flash.stop() - - def load_options(self, options): - if options.battery_flash: - self.enable() - else: - self.disable() - - def stop_flash(self, report): - self.controller.device.stop_led_flash() - - def check_battery(self, report): - if report.battery < BATTERY_WARNING and not report.plug_usb: - self.controller.device.start_led_flash(30, 30) - self.timer_flash.start() - - return True - - -ActionBinding = namedtuple("ActionBinding", "modifiers button callback args") - -class ReportActionBinding(ReportAction): - """Listens for button presses and executes actions.""" - - def __init__(self, controller): - super(ReportActionBinding, self).__init__(controller) - - self.bindings = [] - self.active = set() - - def add_binding(self, combo, callback, *args): - modifiers, button = combo[:-1], combo[-1] - binding = ActionBinding(modifiers, button, callback, args) - self.bindings.append(binding) - - def load_options(self, options): - self.active = set() - self.bindings = [] - - bindings = (self.controller.bindings["global"].items(), - self.controller.bindings.get(options.bindings, {}).items()) - - for binding, action in chain(*bindings): - self.add_binding(binding, self.handle_binding_action, action) - - have_profiles = (self.controller.profiles and - len(self.controller.profiles) > 1) - if have_profiles and self.controller.default_profile.profile_toggle: - self.add_binding(self.controller.default_profile.profile_toggle, - lambda r: self.controller.next_profile()) - - def handle_binding_action(self, report, action): - info = dict(name=self.controller.device.name, - profile=self.controller.current_profile, - device_addr=self.controller.device.device_addr, - report=report) - - def replace_var(match): - var, attr = match.group("var", "attr") - var = info.get(var) - if attr: - var = getattr(var, attr, None) - return str(var) - - action = re.sub(r"\$(?P\w+)(\.(?P\w+))?", - replace_var, action) - action_split = shlex.split(action) - action_type = action_split[0] - action_args = action_split[1:] - - func = BINDING_ACTIONS.get(action_type) - if func: - try: - func(self.controller, *action_args) - except Exception as err: - self.logger.error("Failed to execute action: {0}", err) - else: - self.logger.error("Invalid action type: {0}", action_type) - - def handle_report(self, report): - for binding in self.bindings: - modifiers = True - for button in binding.modifiers: - modifiers = modifiers and getattr(report, button) - - active = getattr(report, binding.button) - released = not active - - if modifiers and active and binding not in self.active: - self.active.add(binding) - elif released and binding in self.active: - self.active.remove(binding) - binding.callback(report, *binding.args) - - -class ReportActionBluetoothSignal(ReportAction): - """Warns when a low report rate is discovered and may impact usability.""" - - def __init__(self, *args, **kwargs): - super(ReportActionBluetoothSignal, self).__init__(*args, **kwargs) - - self.timer_check = self.create_timer(2.5, self.check_signal) - self.timer_reset = self.create_timer(60, self.reset_warning) - - def setup(self, device): - self.reports = 0 - self.signal_warned = False - - if device.type == "bluetooth": - self.enable() - else: - self.disable() - - def enable(self): - self.timer_check.start() - - def disable(self): - self.timer_check.stop() - self.timer_reset.stop() - - def check_signal(self, report): - # Less than 60 reports/s means we are probably dropping - # reports between frames in a 60 FPS game. - rps = int(self.reports / 2.5) - if not self.signal_warned and rps < 60: - self.logger.warning("Signal strength is low ({0} reports/s)", rps) - self.signal_warned = True - self.timer_reset.start() - - self.reports = 0 - - return True - - def reset_warning(self, report): - self.signal_warned = False - - def handle_report(self, report): - self.reports += 1 - - -class ReportActionInput(ReportAction): - """Creates virtual input devices via uinput.""" - - def __init__(self, *args, **kwargs): - super(ReportActionInput, self).__init__(*args, **kwargs) - - self.joystick = None - self.joystick_layout = None - self.mouse = None - - # USB has a report frequency of 4 ms while BT is 2 ms, so we - # use 5 ms between each mouse emit to keep it consistent and to - # allow for at least one fresh report to be received inbetween - self.timer = self.create_timer(0.005, self.emit_mouse) - - def setup(self, device): - self.timer.start() - - def disable(self): - self.timer.stop() - - if self.joystick: - self.joystick.emit_reset() - - if self.mouse: - self.mouse.emit_reset() - - def load_options(self, options): - try: - if options.mapping: - joystick_layout = options.mapping - elif options.emulate_xboxdrv: - joystick_layout = "xboxdrv" - elif options.emulate_xpad: - joystick_layout = "xpad" - elif options.emulate_xpad_wireless: - joystick_layout = "xpad_wireless" - else: - joystick_layout = "ds4" - - if not self.mouse and options.trackpad_mouse: - self.mouse = create_uinput_device("mouse") - elif self.mouse and not options.trackpad_mouse: - self.mouse.device.close() - self.mouse = None - - if self.joystick and self.joystick_layout != joystick_layout: - self.joystick.device.close() - joystick = create_uinput_device(joystick_layout) - self.joystick = joystick - elif not self.joystick: - joystick = create_uinput_device(joystick_layout) - self.joystick = joystick - if joystick.device.device: - self.logger.info("Created devices {0} (joystick) " - "{1} (evdev) ", joystick.joystick_dev, - joystick.device.device.fn) - else: - joystick = None - - self.joystick.ignored_buttons = set() - for button in options.ignored_buttons: - self.joystick.ignored_buttons.add(button) - - if joystick: - self.joystick_layout = joystick_layout - - # If the profile binding is a single button we don't want to - # send it to the joystick at all - if (self.controller.profiles and - self.controller.default_profile.profile_toggle and - len(self.controller.default_profile.profile_toggle) == 1): - - button = self.controller.default_profile.profile_toggle[0] - self.joystick.ignored_buttons.add(button) - except DeviceError as err: - self.controller.exit("Failed to create input device: {0}", err) - - def emit_mouse(self, report): - if self.joystick: - self.joystick.emit_mouse(report) - - if self.mouse: - self.mouse.emit_mouse(report) - - return True - - def handle_report(self, report): - if self.joystick: - self.joystick.emit(report) - - if self.mouse: - self.mouse.emit(report) - - -class ReportActionStatus(ReportAction): - """Reports device statuses such as battery percentage to the log.""" - - def __init__(self, *args, **kwargs): - super(ReportActionStatus, self).__init__(*args, **kwargs) - self.timer = self.create_timer(1, self.check_status) - - def setup(self, device): - self.report = None - self.timer.start() - - def disable(self): - self.timer.stop() - - def check_status(self, report): - if not self.report: - self.report = report - show_battery = True - else: - show_battery = False - - # USB cable - if self.report.plug_usb != report.plug_usb: - plug_usb = report.plug_usb and "Connected" or "Disconnected" - show_battery = True - - self.logger.info("USB: {0}", plug_usb) - - # Battery level - if self.report.battery != report.battery or show_battery: - max_value = report.plug_usb and BATTERY_MAX_CHARGING or BATTERY_MAX - battery = 100 * report.battery // max_value - - if battery < 100: - self.logger.info("Battery: {0}%", battery) - else: - self.logger.info("Battery: Fully charged") - - # Audio cable - if (self.report.plug_audio != report.plug_audio or - self.report.plug_mic != report.plug_mic): - - if report.plug_audio and report.plug_mic: - plug_audio = "Headset" - elif report.plug_audio: - plug_audio = "Headphones" - elif report.plug_mic: - plug_audio = "Mic" - else: - plug_audio = "Speaker" - - self.logger.info("Audio: {0}", plug_audio) - - self.report = report - - return True - - -class ReportActionDump(ReportAction): - """Pretty prints the reports to the log.""" - - def __init__(self, *args, **kwargs): - super(ReportActionDump, self).__init__(*args, **kwargs) - self.timer = self.create_timer(0.02, self.dump) - - def enable(self): - self.timer.start() - - def disable(self): - self.timer.stop() - - def load_options(self, options): - if options.dump_reports: - self.enable() - else: - self.disable() - - def dump(self, report): - dump = "Report dump\n" - for key in report.__slots__: - value = getattr(report, key) - dump += " {0}: {1}\n".format(key, value) - - self.logger.info(dump) - - return True - - -def bindingaction(name): - def decorator(func): - BINDING_ACTIONS[name] = func - return func - return decorator - - -@bindingaction("exec") -def _exec(controller, cmd, *args): - """Executes a subprocess in the foreground, blocking until returned.""" - controller.logger.info("Executing: {0} {1}", cmd, " ".join(args)) - - try: - subprocess.check_call([cmd] + list(args)) - except (OSError, subprocess.CalledProcessError) as err: - controller.logger.error("Failed to execute process: {0}", err) - - -@bindingaction("exec-background") -def _exec_background(controller, cmd, *args): - """Executes a subprocess in the background.""" - controller.logger.info("Executing in the background: {0} {1}", - cmd, " ".join(args)) - - try: - subprocess.Popen([cmd] + list(args), - stdout=open(os.devnull, "wb"), - stderr=open(os.devnull, "wb")) - except OSError as err: - controller.logger.error("Failed to execute process: {0}", err) - - -@bindingaction("next-profile") -def _next_profile(controller): - """Loads the next profile.""" - controller.next_profile() - - -@bindingaction("prev-profile") -def _prev_profile(controller): - """Loads the previous profile.""" - controller.prev_profile() - - -@bindingaction("load-profile") -def _load_profile(controller, profile): - """Loads the specified profile.""" - controller.load_profile(profile) - diff --git a/ds4drv/actions/__init__.py b/ds4drv/actions/__init__.py new file mode 100644 index 0000000..2d37a0c --- /dev/null +++ b/ds4drv/actions/__init__.py @@ -0,0 +1,9 @@ +from ..action import ActionRegistry + +from . import battery +from . import binding +from . import btsignal +from . import dump +from . import input +from . import led +from . import status diff --git a/ds4drv/actions/battery.py b/ds4drv/actions/battery.py new file mode 100644 index 0000000..bef480a --- /dev/null +++ b/ds4drv/actions/battery.py @@ -0,0 +1,40 @@ +from ..action import ReportAction + +BATTERY_WARNING = 2 + +ReportAction.add_option("--battery-flash", action="store_true", + help="Flashes the LED once a minute if the " + "battery is low") + + +class ReportActionBattery(ReportAction): + """Flashes the LED when battery is low.""" + + def __init__(self, *args, **kwargs): + super(ReportActionBattery, self).__init__(*args, **kwargs) + + self.timer_check = self.create_timer(60, self.check_battery) + self.timer_flash = self.create_timer(5, self.stop_flash) + + def enable(self): + self.timer_check.start() + + def disable(self): + self.timer_check.stop() + self.timer_flash.stop() + + def load_options(self, options): + if options.battery_flash: + self.enable() + else: + self.disable() + + def stop_flash(self, report): + self.controller.device.stop_led_flash() + + def check_battery(self, report): + if report.battery < BATTERY_WARNING and not report.plug_usb: + self.controller.device.start_led_flash(30, 30) + self.timer_flash.start() + + return True diff --git a/ds4drv/actions/binding.py b/ds4drv/actions/binding.py new file mode 100644 index 0000000..e3bfdc1 --- /dev/null +++ b/ds4drv/actions/binding.py @@ -0,0 +1,148 @@ +import os +import re +import shlex +import subprocess + +from collections import namedtuple +from itertools import chain + +from ..action import ReportAction +from ..config import buttoncombo + +ReportAction.add_option("--bindings", metavar="bindings", + help="Use custom action bindings specified in the " + "config file") + +ReportAction.add_option("--profile-toggle", metavar="button(s)", + type=buttoncombo("+"), + help="A button combo that will trigger profile " + "cycling, e.g. 'R1+L1+PS'") + +ActionBinding = namedtuple("ActionBinding", "modifiers button callback args") + + +class ReportActionBinding(ReportAction): + """Listens for button presses and executes actions.""" + + actions = {} + + @classmethod + def action(cls, name): + def decorator(func): + cls.actions[name] = func + return func + + return decorator + + def __init__(self, controller): + super(ReportActionBinding, self).__init__(controller) + + self.bindings = [] + self.active = set() + + def add_binding(self, combo, callback, *args): + modifiers, button = combo[:-1], combo[-1] + binding = ActionBinding(modifiers, button, callback, args) + self.bindings.append(binding) + + def load_options(self, options): + self.active = set() + self.bindings = [] + + bindings = (self.controller.bindings["global"].items(), + self.controller.bindings.get(options.bindings, {}).items()) + + for binding, action in chain(*bindings): + self.add_binding(binding, self.handle_binding_action, action) + + have_profiles = (self.controller.profiles and + len(self.controller.profiles) > 1) + if have_profiles and self.controller.default_profile.profile_toggle: + self.add_binding(self.controller.default_profile.profile_toggle, + lambda r: self.controller.next_profile()) + + def handle_binding_action(self, report, action): + info = dict(name=self.controller.device.name, + profile=self.controller.current_profile, + device_addr=self.controller.device.device_addr, + report=report) + + def replace_var(match): + var, attr = match.group("var", "attr") + var = info.get(var) + if attr: + var = getattr(var, attr, None) + return str(var) + + action = re.sub(r"\$(?P\w+)(\.(?P\w+))?", + replace_var, action) + action_split = shlex.split(action) + action_type = action_split[0] + action_args = action_split[1:] + + func = self.actions.get(action_type) + if func: + try: + func(self.controller, *action_args) + except Exception as err: + self.logger.error("Failed to execute action: {0}", err) + else: + self.logger.error("Invalid action type: {0}", action_type) + + def handle_report(self, report): + for binding in self.bindings: + modifiers = True + for button in binding.modifiers: + modifiers = modifiers and getattr(report, button) + + active = getattr(report, binding.button) + released = not active + + if modifiers and active and binding not in self.active: + self.active.add(binding) + elif released and binding in self.active: + self.active.remove(binding) + binding.callback(report, *binding.args) + + +@ReportActionBinding.action("exec") +def exec_(controller, cmd, *args): + """Executes a subprocess in the foreground, blocking until returned.""" + controller.logger.info("Executing: {0} {1}", cmd, " ".join(args)) + + try: + subprocess.check_call([cmd] + list(args)) + except (OSError, subprocess.CalledProcessError) as err: + controller.logger.error("Failed to execute process: {0}", err) + + +@ReportActionBinding.action("exec-background") +def exec_background(controller, cmd, *args): + """Executes a subprocess in the background.""" + controller.logger.info("Executing in the background: {0} {1}", + cmd, " ".join(args)) + + try: + subprocess.Popen([cmd] + list(args), + stdout=open(os.devnull, "wb"), + stderr=open(os.devnull, "wb")) + except OSError as err: + controller.logger.error("Failed to execute process: {0}", err) + + +@ReportActionBinding.action("next-profile") +def next_profile(controller): + """Loads the next profile.""" + controller.next_profile() + + +@ReportActionBinding.action("prev-profile") +def prev_profile(controller): + """Loads the previous profile.""" + controller.prev_profile() + + +@ReportActionBinding.action("load-profile") +def load_profile(controller, profile): + """Loads the specified profile.""" + controller.load_profile(profile) diff --git a/ds4drv/actions/btsignal.py b/ds4drv/actions/btsignal.py new file mode 100644 index 0000000..8409064 --- /dev/null +++ b/ds4drv/actions/btsignal.py @@ -0,0 +1,46 @@ +from ..action import ReportAction + + +class ReportActionBTSignal(ReportAction): + """Warns when a low report rate is discovered and may impact usability.""" + + def __init__(self, *args, **kwargs): + super(ReportActionBTSignal, self).__init__(*args, **kwargs) + + self.timer_check = self.create_timer(2.5, self.check_signal) + self.timer_reset = self.create_timer(60, self.reset_warning) + + def setup(self, device): + self.reports = 0 + self.signal_warned = False + + if device.type == "bluetooth": + self.enable() + else: + self.disable() + + def enable(self): + self.timer_check.start() + + def disable(self): + self.timer_check.stop() + self.timer_reset.stop() + + def check_signal(self, report): + # Less than 60 reports/s means we are probably dropping + # reports between frames in a 60 FPS game. + rps = int(self.reports / 2.5) + if not self.signal_warned and rps < 60: + self.logger.warning("Signal strength is low ({0} reports/s)", rps) + self.signal_warned = True + self.timer_reset.start() + + self.reports = 0 + + return True + + def reset_warning(self, report): + self.signal_warned = False + + def handle_report(self, report): + self.reports += 1 diff --git a/ds4drv/actions/dump.py b/ds4drv/actions/dump.py new file mode 100644 index 0000000..093d8cc --- /dev/null +++ b/ds4drv/actions/dump.py @@ -0,0 +1,34 @@ +from ..action import ReportAction + +ReportAction.add_option("--dump-reports", action="store_true", + help="Prints controller input reports") + + +class ReportActionDump(ReportAction): + """Pretty prints the reports to the log.""" + + def __init__(self, *args, **kwargs): + super(ReportActionDump, self).__init__(*args, **kwargs) + self.timer = self.create_timer(0.02, self.dump) + + def enable(self): + self.timer.start() + + def disable(self): + self.timer.stop() + + def load_options(self, options): + if options.dump_reports: + self.enable() + else: + self.disable() + + def dump(self, report): + dump = "Report dump\n" + for key in report.__slots__: + value = getattr(report, key) + dump += " {0}: {1}\n".format(key, value) + + self.logger.info(dump) + + return True diff --git a/ds4drv/actions/input.py b/ds4drv/actions/input.py new file mode 100644 index 0000000..bcf09a2 --- /dev/null +++ b/ds4drv/actions/input.py @@ -0,0 +1,120 @@ +from ..action import ReportAction +from ..config import buttoncombo +from ..exceptions import DeviceError +from ..uinput import create_uinput_device + +ReportAction.add_option("--emulate-xboxdrv", action="store_true", + help="Emulates the same joystick layout as a " + "Xbox 360 controller used via xboxdrv") +ReportAction.add_option("--emulate-xpad", action="store_true", + help="Emulates the same joystick layout as a wired " + "Xbox 360 controller used via the xpad module") +ReportAction.add_option("--emulate-xpad-wireless", action="store_true", + help="Emulates the same joystick layout as a wireless " + "Xbox 360 controller used via the xpad module") +ReportAction.add_option("--ignored-buttons", metavar="button(s)", + type=buttoncombo(","), default=[], + help="A comma-separated list of buttons to never send " + "as joystick events. For example specify 'PS' to " + "disable Steam's big picture mode shortcut when " + "using the --emulate-* options") +ReportAction.add_option("--mapping", metavar="mapping", + help="Use a custom button mapping specified in the " + "config file") +ReportAction.add_option("--trackpad-mouse", action="store_true", + help="Makes the trackpad control the mouse") + + +class ReportActionInput(ReportAction): + """Creates virtual input devices via uinput.""" + + def __init__(self, *args, **kwargs): + super(ReportActionInput, self).__init__(*args, **kwargs) + + self.joystick = None + self.joystick_layout = None + self.mouse = None + + # USB has a report frequency of 4 ms while BT is 2 ms, so we + # use 5 ms between each mouse emit to keep it consistent and to + # allow for at least one fresh report to be received inbetween + self.timer = self.create_timer(0.005, self.emit_mouse) + + def setup(self, device): + self.timer.start() + + def disable(self): + self.timer.stop() + + if self.joystick: + self.joystick.emit_reset() + + if self.mouse: + self.mouse.emit_reset() + + def load_options(self, options): + try: + if options.mapping: + joystick_layout = options.mapping + elif options.emulate_xboxdrv: + joystick_layout = "xboxdrv" + elif options.emulate_xpad: + joystick_layout = "xpad" + elif options.emulate_xpad_wireless: + joystick_layout = "xpad_wireless" + else: + joystick_layout = "ds4" + + if not self.mouse and options.trackpad_mouse: + self.mouse = create_uinput_device("mouse") + elif self.mouse and not options.trackpad_mouse: + self.mouse.device.close() + self.mouse = None + + if self.joystick and self.joystick_layout != joystick_layout: + self.joystick.device.close() + joystick = create_uinput_device(joystick_layout) + self.joystick = joystick + elif not self.joystick: + joystick = create_uinput_device(joystick_layout) + self.joystick = joystick + if joystick.device.device: + self.logger.info("Created devices {0} (joystick) " + "{1} (evdev) ", joystick.joystick_dev, + joystick.device.device.fn) + else: + joystick = None + + self.joystick.ignored_buttons = set() + for button in options.ignored_buttons: + self.joystick.ignored_buttons.add(button) + + if joystick: + self.joystick_layout = joystick_layout + + # If the profile binding is a single button we don't want to + # send it to the joystick at all + if (self.controller.profiles and + self.controller.default_profile.profile_toggle and + len(self.controller.default_profile.profile_toggle) == 1): + + button = self.controller.default_profile.profile_toggle[0] + self.joystick.ignored_buttons.add(button) + except DeviceError as err: + self.controller.exit("Failed to create input device: {0}", err) + + def emit_mouse(self, report): + if self.joystick: + self.joystick.emit_mouse(report) + + if self.mouse: + self.mouse.emit_mouse(report) + + return True + + def handle_report(self, report): + if self.joystick: + self.joystick.emit(report) + + if self.mouse: + self.mouse.emit(report) diff --git a/ds4drv/actions/led.py b/ds4drv/actions/led.py new file mode 100644 index 0000000..76f06de --- /dev/null +++ b/ds4drv/actions/led.py @@ -0,0 +1,17 @@ +from ..action import Action +from ..config import hexcolor + +Action.add_option("--led", metavar="color", default="0000ff", type=hexcolor, + help="Sets color of the LED. Uses hex color codes, " + "e.g. 'ff0000' is red. Default is '0000ff' (blue)") + + +class ActionLED(Action): + """Sets the LED color on the device.""" + + def setup(self, device): + device.set_led(*self.controller.options.led) + + def load_options(self, options): + if self.controller.device: + self.controller.device.set_led(*options.led) diff --git a/ds4drv/actions/status.py b/ds4drv/actions/status.py new file mode 100644 index 0000000..ff1e6d3 --- /dev/null +++ b/ds4drv/actions/status.py @@ -0,0 +1,62 @@ +from ..action import ReportAction + +BATTERY_MAX = 8 +BATTERY_MAX_CHARGING = 11 + + +class ReportActionStatus(ReportAction): + """Reports device statuses such as battery percentage to the log.""" + + def __init__(self, *args, **kwargs): + super(ReportActionStatus, self).__init__(*args, **kwargs) + self.timer = self.create_timer(1, self.check_status) + + def setup(self, device): + self.report = None + self.timer.start() + + def disable(self): + self.timer.stop() + + def check_status(self, report): + if not self.report: + self.report = report + show_battery = True + else: + show_battery = False + + # USB cable + if self.report.plug_usb != report.plug_usb: + plug_usb = report.plug_usb and "Connected" or "Disconnected" + show_battery = True + + self.logger.info("USB: {0}", plug_usb) + + # Battery level + if self.report.battery != report.battery or show_battery: + max_value = report.plug_usb and BATTERY_MAX_CHARGING or BATTERY_MAX + battery = 100 * report.battery // max_value + + if battery < 100: + self.logger.info("Battery: {0}%", battery) + else: + self.logger.info("Battery: Fully charged") + + # Audio cable + if (self.report.plug_audio != report.plug_audio or + self.report.plug_mic != report.plug_mic): + + if report.plug_audio and report.plug_mic: + plug_audio = "Headset" + elif report.plug_audio: + plug_audio = "Headphones" + elif report.plug_mic: + plug_audio = "Mic" + else: + plug_audio = "Speaker" + + self.logger.info("Audio: {0}", plug_audio) + + self.report = report + + return True diff --git a/ds4drv/config.py b/ds4drv/config.py index 4c35d89..021b1b8 100644 --- a/ds4drv/config.py +++ b/ds4drv/config.py @@ -23,7 +23,10 @@ class SortingHelpFormatter(argparse.HelpFormatter): def add_argument(self, action): - action.help = action.help.capitalize() + # Force the built in options to be capitalized + if action.option_strings[-1] in ("--version", "--help"): + action.help = action.help.capitalize() + super(SortingHelpFormatter, self).add_argument(action) self.add_text("") @@ -36,7 +39,6 @@ def add_arguments(self, actions): super(SortingHelpFormatter, self).add_arguments(actions) - parser = argparse.ArgumentParser(prog="ds4drv", formatter_class=SortingHelpFormatter) parser.add_argument("--version", action="version", @@ -45,22 +47,22 @@ def add_arguments(self, actions): configopt = parser.add_argument_group("configuration options") configopt.add_argument("--config", metavar="filename", type=os.path.expanduser, - help="configuration file to read settings from. " + help="Configuration file to read settings from. " "Default is ~/.config/ds4drv.conf or " "/etc/ds4drv.conf, whichever is found first") backendopt = parser.add_argument_group("backend options") backendopt.add_argument("--hidraw", action="store_true", - help="use hidraw devices. This can be used to access " + help="Use hidraw devices. This can be used to access " "USB and paired bluetooth devices. Note: " "Bluetooth devices does currently not support " "any LED functionality") daemonopt = parser.add_argument_group("daemon options") daemonopt.add_argument("--daemon", action="store_true", - help="run in the background as a daemon") + help="Run in the background as a daemon") daemonopt.add_argument("--daemon-log", default=DAEMON_LOG_FILE, metavar="file", - help="log file to create in daemon mode") + help="Log file to create in daemon mode") daemonopt.add_argument("--daemon-pid", default=DAEMON_PID_FILE, metavar="file", help="PID file to create in daemon mode") @@ -152,6 +154,8 @@ def __call__(self, parser, namespace, values, option_string=None): namespace.controllers.append(controller) +controllopt.add_argument("--next-controller", nargs=0, action=ControllerAction, + help="Creates another controller") def hexcolor(color): color = color.strip("#") @@ -242,47 +246,9 @@ def add_controller_option(name, **options): ControllerAction.__options__.append(option_name) -add_controller_option("--battery-flash", action="store_true", - help="flashes the LED once a minute if the " - "battery is low") -add_controller_option("--emulate-xboxdrv", action="store_true", - help="emulates the same joystick layout as a " - "Xbox 360 controller used via xboxdrv") -add_controller_option("--emulate-xpad", action="store_true", - help="emulates the same joystick layout as a wired " - "Xbox 360 controller used via the xpad module") -add_controller_option("--emulate-xpad-wireless", action="store_true", - help="emulates the same joystick layout as a wireless " - "Xbox 360 controller used via the xpad module") -add_controller_option("--ignored-buttons", metavar="button(s)", - type=buttoncombo(","), default=[], - help="a comma-separated list of buttons to never send " - "as joystick events. For example specify 'PS' to " - "disable Steam's big picture mode shortcut when " - "using the --emulate-* options") -add_controller_option("--led", metavar="color", default="0000ff", - type=hexcolor, - help="sets color of the LED. Uses hex color codes, " - "e.g. 'ff0000' is red. Default is '0000ff' (blue)") -add_controller_option("--bindings", metavar="bindings", - help="use custom action bindings specified in the " - "config file") -add_controller_option("--mapping", metavar="mapping", - help="use a custom button mapping specified in the " - "config file") -add_controller_option("--profile-toggle", metavar="button(s)", - type=buttoncombo("+"), - help="a button combo that will trigger profile " - "cycling, e.g. 'R1+L1+PS'") add_controller_option("--profiles", metavar="profiles", type=stringlist, - help="profiles to cycle through using the button " + help="Profiles to cycle through using the button " "specified by --profile-toggle, e.g. " "'profile1,profile2'") -add_controller_option("--trackpad-mouse", action="store_true", - help="makes the trackpad control the mouse") -add_controller_option("--dump-reports", action="store_true", - help="prints controller input reports") -controllopt.add_argument("--next-controller", nargs=0, action=ControllerAction, - help="creates another controller") diff --git a/ds4drv/utils.py b/ds4drv/utils.py index 38afc54..550d482 100644 --- a/ds4drv/utils.py +++ b/ds4drv/utils.py @@ -38,6 +38,11 @@ def button_prefix(button): return tuple(map(button_prefix, combo.lower().split(sep))) +def with_metaclass(meta, base=object): + """Create a base class with a metaclass.""" + return meta("NewBase", (base,), {}) + + def zero_copy_slice(buf, start=None, end=None): # No need for an extra copy on Python 3.3+ if sys.version_info[0] == 3 and sys.version_info[1] >= 3: diff --git a/setup.py b/setup.py index 91b7438..393efd9 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,10 @@ entry_points={ "console_scripts": ["ds4drv=ds4drv.__main__:main"] }, - packages=["ds4drv", "ds4drv.backends", "ds4drv.packages"], + packages=["ds4drv", + "ds4drv.actions", + "ds4drv.backends", + "ds4drv.packages"], install_requires=["evdev>=0.3.0", "pyudev>=0.16"], classifiers=[ "Development Status :: 4 - Beta",