From 7bb08bca0734f1f6f14f77af4105a682ef07ab95 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 16 Nov 2023 21:42:57 +0100 Subject: [PATCH] [joystick] Rewritten `joystick` integration as a plugin. And removed legacy `joystick*` backends and `inputs` plugin. Closes: #290 --- platypush/backend/joystick/__init__.py | 42 - platypush/backend/joystick/jstest/__init__.py | 283 -- .../backend/joystick/jstest/manifest.yaml | 19 - platypush/backend/joystick/linux/__init__.py | 206 - .../backend/joystick/linux/manifest.yaml | 16 - platypush/message/event/joystick.py | 98 +- platypush/plugins/inputs/__init__.py | 156 - platypush/plugins/inputs/manifest.yaml | 7 - platypush/plugins/joystick/__init__.py | 124 + platypush/plugins/joystick/_inputs.py | 3660 +++++++++++++++++ platypush/plugins/joystick/_manager.py | 175 + platypush/plugins/joystick/_state.py | 50 + .../joystick/manifest.yaml | 4 +- .../plugins/media/chromecast/__init__.py | 3 + platypush/schemas/joystick.py | 199 + 15 files changed, 4230 insertions(+), 812 deletions(-) delete mode 100644 platypush/backend/joystick/__init__.py delete mode 100644 platypush/backend/joystick/jstest/__init__.py delete mode 100644 platypush/backend/joystick/jstest/manifest.yaml delete mode 100644 platypush/backend/joystick/linux/__init__.py delete mode 100644 platypush/backend/joystick/linux/manifest.yaml delete mode 100644 platypush/plugins/inputs/__init__.py delete mode 100644 platypush/plugins/inputs/manifest.yaml create mode 100644 platypush/plugins/joystick/__init__.py create mode 100644 platypush/plugins/joystick/_inputs.py create mode 100644 platypush/plugins/joystick/_manager.py create mode 100644 platypush/plugins/joystick/_state.py rename platypush/{backend => plugins}/joystick/manifest.yaml (83%) create mode 100644 platypush/schemas/joystick.py diff --git a/platypush/backend/joystick/__init__.py b/platypush/backend/joystick/__init__.py deleted file mode 100644 index 247f9a01..00000000 --- a/platypush/backend/joystick/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -import time - -from platypush.backend import Backend -from platypush.message.event.joystick import JoystickEvent - - -class JoystickBackend(Backend): - """ - This backend will listen for events from a joystick device and post a - JoystickEvent whenever a new event is captured. - """ - - def __init__(self, device, *args, **kwargs): - """ - :param device: Path to the joystick device (e.g. `/dev/input/js0`) - :type device_name: str - """ - - super().__init__(*args, **kwargs) - - self.device = device - - def run(self): - import inputs - - super().run() - self.logger.info( - 'Initialized joystick backend on device {}'.format(self.device) - ) - - while not self.should_stop(): - try: - events = inputs.get_gamepad() - for event in events: - if event.ev_type == 'Key' or event.ev_type == 'Absolute': - self.bus.post(JoystickEvent(code=event.code, state=event.state)) - except Exception as e: - self.logger.exception(e) - time.sleep(1) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/joystick/jstest/__init__.py b/platypush/backend/joystick/jstest/__init__.py deleted file mode 100644 index 79bb1a71..00000000 --- a/platypush/backend/joystick/jstest/__init__.py +++ /dev/null @@ -1,283 +0,0 @@ -import json -import os -import re -import subprocess -import time -from typing import Optional, List - -from platypush.backend import Backend -from platypush.message.event.joystick import ( - JoystickConnectedEvent, - JoystickDisconnectedEvent, - JoystickStateEvent, - JoystickButtonPressedEvent, - JoystickButtonReleasedEvent, - JoystickAxisEvent, -) - - -class JoystickState: - def __init__(self, axes: List[int], buttons: List[bool]): - self.axes = axes - self.buttons = buttons - - def __str__(self): - return json.dumps(self.__dict__) - - def __eq__(self, obj): - return obj.axes == self.axes and obj.buttons == self.buttons - - def __sub__(self, obj) -> dict: - if len(obj.axes) < len(self.axes) or len(obj.buttons) < len(self.buttons): - return {} - - diff = { - 'axes': { - axis: obj.axes[axis] - for axis in range(len(self.axes)) - if self.axes[axis] != obj.axes[axis] - }, - 'buttons': { - button: obj.buttons[button] - for button in range(len(self.buttons)) - if self.buttons[button] != obj.buttons[button] - }, - } - - return {k: v for k, v in diff.items() if v} - - -class JoystickJstestBackend(Backend): - """ - This backend can be used to intercept events from a joystick device if the device does not work with the standard - :class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth - joysticks that don't support the ``ioctl`` requests used by ``inputs``). - - This backend only works on Linux, and it requires the ``joystick`` package to be installed. - - **NOTE**: This backend can be quite slow, since it has to run another program (``jstest``) and parse its output. - Consider it as a last resort if your joystick works with neither :class:`platypush.backend.joystick.JoystickBackend` - nor :class:`platypush.backend.joystick.JoystickLinuxBackend`. - - To test if your joystick is compatible, connect it to your device, check for its path (usually under - ``/dev/input/js*``) and run:: - - $ jstest /dev/input/js[n] - - """ - - js_axes_regex = re.compile(r'Axes:\s+(((\d+):\s*([\-\d]+)\s*)+)') - js_buttons_regex = re.compile(r'Buttons:\s+(((\d+):\s*(on|off)\s*)+)') - js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)') - js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)') - - def __init__( - self, - device: str = '/dev/input/js0', - jstest_path: str = '/usr/bin/jstest', - **kwargs, - ): - """ - :param device: Path to the joystick device (default: ``/dev/input/js0``). - :param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package - (default: ``/usr/bin/jstest``). - """ - super().__init__(device=device, **kwargs) - - self.device = device - self.jstest_path = jstest_path - self._process: Optional[subprocess.Popen] = None - self._state: Optional[JoystickState] = None - - def _wait_ready(self): - self.logger.info(f'Waiting for joystick device on {self.device}') - - while not self.should_stop(): - if not os.path.exists(self.device): - time.sleep(1) - - try: - with open(self.device, 'rb'): - break - except Exception as e: - self.logger.debug(e) - time.sleep(0.1) - continue - - self.bus.post(JoystickConnectedEvent(device=self.device)) - - def _read_states(self): - while not self.should_stop(): - yield self._get_state() - - def _get_state(self) -> JoystickState: - axes = [] - buttons = [] - line = '' - - while os.path.exists(self.device) and not self.should_stop(): - ch = self._process.stdout.read(1).decode() - if not ch: - continue - - if ch in ['\r', '\n']: - line = '' - continue - - line += ch - if line.endswith('Axes: '): - break - - while ( - os.path.exists(self.device) - and not self.should_stop() - and len(axes) < len(self._state.axes) - ): - ch = ' ' - while ch == ' ': - ch = self._process.stdout.read(1).decode() - - self._process.stdout.read(len(f'{len(axes)}')) - value = '' - - while os.path.exists(self.device) and not self.should_stop(): - ch = self._process.stdout.read(1).decode() - if ch == ' ': - if not value: - continue - break - - if ch == ':': - break - - value += ch - - if value: - axes.append(int(value)) - - line = '' - - while os.path.exists(self.device) and not self.should_stop(): - ch = self._process.stdout.read(1).decode() - if not ch: - continue - - line += ch - if line.endswith('Buttons: '): - break - - while ( - os.path.exists(self.device) - and not self.should_stop() - and len(buttons) < len(self._state.buttons) - ): - ch = ' ' - while ch == ' ': - ch = self._process.stdout.read(1).decode() - - self._process.stdout.read(len(f'{len(buttons)}')) - value = '' - - while os.path.exists(self.device) and not self.should_stop(): - ch = self._process.stdout.read(1).decode() - if ch == ' ': - continue - - value += ch - if value in ['on', 'off']: - buttons.append(value == 'on') - break - - return JoystickState(axes=axes, buttons=buttons) - - def _initialize(self): - while ( - self._process.poll() is None - and os.path.exists(self.device) - and not self.should_stop() - and not self._state - ): - line = b'' - ch = None - - while ch not in [b'\r', b'\n']: - ch = self._process.stdout.read(1) - line += ch - - line = line.decode().strip() - if not (line and line.startswith('Axes:')): - continue - - re_axes = self.js_axes_regex.search(line) - re_buttons = self.js_buttons_regex.search(line) - - if not (re_axes and re_buttons): - return - - state = { - 'axes': [], - 'buttons': [], - } - - axes = re_axes.group(1) - while axes: - m = self.js_axis_regex.search(axes) - state['axes'].append(int(m.group(2))) - axes = m.group(3) - - buttons = re_buttons.group(1) - while buttons: - m = self.js_button_regex.search(buttons) - state['buttons'].append(m.group(2) == 'on') - buttons = m.group(3) - - self._state = JoystickState(**state) - - def _process_state(self, state: JoystickState): - diff = self._state - state - if not diff: - return - - self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__)) - - for button, pressed in diff.get('buttons', {}).items(): - evt_class = ( - JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent - ) - self.bus.post(evt_class(device=self.device, button=button)) - - for axis, value in diff.get('axes', {}).items(): - self.bus.post(JoystickAxisEvent(device=self.device, axis=axis, value=value)) - - self._state = state - - def run(self): - super().run() - - try: - while not self.should_stop(): - self._wait_ready() - - with subprocess.Popen( - [self.jstest_path, '--normal', self.device], stdout=subprocess.PIPE - ) as self._process: - self.logger.info('Device opened') - self._initialize() - - if self._process.poll() is not None: - break - - for state in self._read_states(): - if self._process.poll() is not None or not os.path.exists( - self.device - ): - self.logger.warning(f'Connection to {self.device} lost') - self.bus.post(JoystickDisconnectedEvent(self.device)) - break - - self._process_state(state) - finally: - self._process = None - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/joystick/jstest/manifest.yaml b/platypush/backend/joystick/jstest/manifest.yaml deleted file mode 100644 index d0269b76..00000000 --- a/platypush/backend/joystick/jstest/manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -manifest: - events: - - platypush.message.event.joystick.JoystickAxisEvent - - platypush.message.event.joystick.JoystickButtonPressedEvent - - platypush.message.event.joystick.JoystickButtonReleasedEvent - - platypush.message.event.joystick.JoystickConnectedEvent - - platypush.message.event.joystick.JoystickDisconnectedEvent - - platypush.message.event.joystick.JoystickStateEvent - install: - apk: - - linuxconsoletools - apt: - - joystick - dnf: - - joystick - pacman: - - joyutils - package: platypush.backend.joystick.jstest - type: backend diff --git a/platypush/backend/joystick/linux/__init__.py b/platypush/backend/joystick/linux/__init__.py deleted file mode 100644 index 61ed80c5..00000000 --- a/platypush/backend/joystick/linux/__init__.py +++ /dev/null @@ -1,206 +0,0 @@ -import array -import struct -import time -from fcntl import ioctl -from typing import IO - -from platypush.backend import Backend -from platypush.message.event.joystick import ( - JoystickConnectedEvent, - JoystickDisconnectedEvent, - JoystickButtonPressedEvent, - JoystickButtonReleasedEvent, - JoystickAxisEvent, -) - - -class JoystickLinuxBackend(Backend): - """ - This backend intercepts events from joystick devices through the native Linux API implementation. - - It is loosely based on https://gist.github.com/rdb/8864666, which itself uses the - `Linux kernel joystick API `_ to interact with - the devices. - """ - - # These constants were borrowed from linux/input.h - axis_names = { - 0x00: 'x', - 0x01: 'y', - 0x02: 'z', - 0x03: 'rx', - 0x04: 'ry', - 0x05: 'rz', - 0x06: 'throttle', - 0x07: 'rudder', - 0x08: 'wheel', - 0x09: 'gas', - 0x0A: 'brake', - 0x10: 'hat0x', - 0x11: 'hat0y', - 0x12: 'hat1x', - 0x13: 'hat1y', - 0x14: 'hat2x', - 0x15: 'hat2y', - 0x16: 'hat3x', - 0x17: 'hat3y', - 0x18: 'pressure', - 0x19: 'distance', - 0x1A: 'tilt_x', - 0x1B: 'tilt_y', - 0x1C: 'tool_width', - 0x20: 'volume', - 0x28: 'misc', - } - - button_names = { - 0x120: 'trigger', - 0x121: 'thumb', - 0x122: 'thumb2', - 0x123: 'top', - 0x124: 'top2', - 0x125: 'pinkie', - 0x126: 'base', - 0x127: 'base2', - 0x128: 'base3', - 0x129: 'base4', - 0x12A: 'base5', - 0x12B: 'base6', - 0x12F: 'dead', - 0x130: 'a', - 0x131: 'b', - 0x132: 'c', - 0x133: 'x', - 0x134: 'y', - 0x135: 'z', - 0x136: 'tl', - 0x137: 'tr', - 0x138: 'tl2', - 0x139: 'tr2', - 0x13A: 'select', - 0x13B: 'start', - 0x13C: 'mode', - 0x13D: 'thumbl', - 0x13E: 'thumbr', - 0x220: 'dpad_up', - 0x221: 'dpad_down', - 0x222: 'dpad_left', - 0x223: 'dpad_right', - # XBox 360 controller uses these codes. - 0x2C0: 'dpad_left', - 0x2C1: 'dpad_right', - 0x2C2: 'dpad_up', - 0x2C3: 'dpad_down', - } - - def __init__(self, device: str = '/dev/input/js0', *args, **kwargs): - """ - :param device: Joystick device to monitor (default: ``/dev/input/js0``). - """ - super().__init__(*args, **kwargs) - self.device = device - self._axis_states = {} - self._button_states = {} - self._axis_map = [] - self._button_map = [] - - def _init_joystick(self, dev: IO): - # Get the device name. - buf = array.array('B', [0] * 64) - ioctl(dev, 0x80006A13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len) - js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8') - - # Get number of axes and buttons. - buf = array.array('B', [0]) - ioctl(dev, 0x80016A11, buf) # JSIOCGAXES - num_axes = buf[0] - - buf = array.array('B', [0]) - ioctl(dev, 0x80016A12, buf) # JSIOCGBUTTONS - num_buttons = buf[0] - - # Get the axis map. - buf = array.array('B', [0] * 0x40) - ioctl(dev, 0x80406A32, buf) # JSIOCGAXMAP - - for axis in buf[:num_axes]: - axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis) - self._axis_map.append(axis_name) - self._axis_states[axis_name] = 0.0 - - # Get the button map. - buf = array.array('H', [0] * 200) - ioctl(dev, 0x80406A34, buf) # JSIOCGBTNMAP - - for btn in buf[:num_buttons]: - btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn) - self._button_map.append(btn_name) - self._button_states[btn_name] = 0 - - self.bus.post( - JoystickConnectedEvent( - device=self.device, - name=js_name, - axes=self._axis_map, - buttons=self._button_map, - ) - ) - - def run(self): - super().run() - self.logger.info(f'Opening {self.device}...') - - while not self.should_stop(): - # Open the joystick device. - try: - jsdev = open(self.device, 'rb') # noqa - self._init_joystick(jsdev) - except Exception as e: - self.logger.debug( - 'Joystick device on %s not available: %s', self.device, e - ) - time.sleep(5) - continue - - try: - # Joystick event loop - while not self.should_stop(): - try: - evbuf = jsdev.read(8) - if evbuf: - _, value, evt_type, number = struct.unpack('IhBB', evbuf) - - if evt_type & 0x80: # Initial state notification - continue - - if evt_type & 0x01: - button = self._button_map[number] - if button: - self._button_states[button] = value - evt_class = ( - JoystickButtonPressedEvent - if value - else JoystickButtonReleasedEvent - ) - # noinspection PyTypeChecker - self.bus.post( - evt_class(device=self.device, button=button) - ) - - if evt_type & 0x02: - axis = self._axis_map[number] - if axis: - fvalue = value / 32767.0 - self._axis_states[axis] = fvalue - # noinspection PyTypeChecker - self.bus.post( - JoystickAxisEvent( - device=self.device, axis=axis, value=fvalue - ) - ) - except OSError as e: - self.logger.warning(f'Connection to {self.device} lost: {e}') - self.bus.post(JoystickDisconnectedEvent(device=self.device)) - break - finally: - jsdev.close() diff --git a/platypush/backend/joystick/linux/manifest.yaml b/platypush/backend/joystick/linux/manifest.yaml deleted file mode 100644 index 227b328a..00000000 --- a/platypush/backend/joystick/linux/manifest.yaml +++ /dev/null @@ -1,16 +0,0 @@ -manifest: - events: - platypush.message.event.joystick.JoystickAxisEvent: when an axis value of the - joystick changes. - platypush.message.event.joystick.JoystickButtonPressedEvent: when a joystick button - is pressed. - platypush.message.event.joystick.JoystickButtonReleasedEvent: when a joystick - button is released. - platypush.message.event.joystick.JoystickConnectedEvent: when the joystick is - connected. - platypush.message.event.joystick.JoystickDisconnectedEvent: when the joystick - is disconnected. - install: - pip: [] - package: platypush.backend.joystick.linux - type: backend diff --git a/platypush/message/event/joystick.py b/platypush/message/event/joystick.py index 57bf10f8..44a71841 100644 --- a/platypush/message/event/joystick.py +++ b/platypush/message/event/joystick.py @@ -1,108 +1,46 @@ -from abc import ABCMeta -from typing import Optional, Iterable, Union +from abc import ABC from platypush.message.event import Event -class JoystickEvent(Event): - """ - Generic joystick event. - """ - - def __init__(self, code, state, *args, **kwargs): - """ - :param code: Event code, usually the code of the source key/handle - :type code: str - - :param state: State of the triggering element. Can be 0/1 for a button, -1/0/1 for an axis, a discrete integer - for an analog input etc. - :type state: int - """ - - super().__init__(*args, code=code, state=state, **kwargs) - - -class _JoystickEvent(Event, ABCMeta): +class JoystickEvent(Event, ABC): """ Base joystick event class. """ - def __init__(self, device: str, **kwargs): - super().__init__(device=device, **kwargs) + def __init__(self, device: dict, *args, **kwargs): + """ + :param device: Joystick device info as a dictionary: + + .. schema:: joystick.JoystickDeviceSchema + """ + super().__init__(*args, device=device, **kwargs) -class JoystickConnectedEvent(_JoystickEvent): +class JoystickConnectedEvent(JoystickEvent): """ Event triggered upon joystick connection. """ - def __init__(self, - *args, - name: Optional[str] = None, - axes: Optional[Iterable[Union[int, str]]] = None, - buttons: Optional[Iterable[Union[int, str]]] = None, - **kwargs): - """ - :param name: Device name. - :param axes: List of the device axes, as indices or names. - :param buttons: List of the device buttons, as indices or names. - """ - super().__init__(*args, name=name, axes=axes, buttons=buttons, **kwargs) -class JoystickDisconnectedEvent(_JoystickEvent): +class JoystickDisconnectedEvent(JoystickEvent): """ Event triggered upon joystick disconnection. """ -class JoystickStateEvent(_JoystickEvent): +class JoystickStateEvent(JoystickEvent): """ - Event triggered when the state of the joystick changes. + Base joystick state event class. """ - def __init__(self, *args, axes: Iterable[int], buttons: Iterable[bool], **kwargs): + def __init__(self, *args, state: dict, **kwargs): """ - :param axes: Joystick axes values, as a list of integer values. - :param buttons: Joystick buttons values, as a list of boolean values (True for pressed, False for released). + :param state: Joystick state as a dictionary: + + .. schema:: joystick.JoystickStateSchema """ - super().__init__(*args, axes=axes, buttons=buttons, **kwargs) - - -class JoystickButtonPressedEvent(_JoystickEvent): - """ - Event triggered when a joystick button is pressed. - """ - - def __init__(self, *args, button: Union[int, str], **kwargs): - """ - :param button: Button index or name. - """ - super().__init__(*args, button=button, **kwargs) - - -class JoystickButtonReleasedEvent(_JoystickEvent): - """ - Event triggered when a joystick button is released. - """ - - def __init__(self, *args, button: Union[int, str], **kwargs): - """ - :param button: Button index or name. - """ - super().__init__(*args, button=button, **kwargs) - - -class JoystickAxisEvent(_JoystickEvent): - """ - Event triggered when an axis value of the joystick changes. - """ - - def __init__(self, *args, axis: Union[int, str], value: int, **kwargs): - """ - :param axis: Axis index or name. - :param value: Axis value. - """ - super().__init__(*args, axis=axis, value=value, **kwargs) + super().__init__(*args, state=state, **kwargs) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/inputs/__init__.py b/platypush/plugins/inputs/__init__.py deleted file mode 100644 index 7919f3f4..00000000 --- a/platypush/plugins/inputs/__init__.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import List - -from platypush.plugins import Plugin, action - - -class InputsPlugin(Plugin): - """ - This plugin emulates user input on a keyboard/mouse. It requires the a graphical server (X server or Mac/Win - interface) to be running - it won't work in console mode. - """ - - @staticmethod - def _get_keyboard(): - # noinspection PyPackageRequirements - from pykeyboard import PyKeyboard - - return PyKeyboard() - - @staticmethod - def _get_mouse(): - # noinspection PyPackageRequirements - from pymouse import PyMouse - - return PyMouse() - - @classmethod - def _parse_key(cls, key: str): - k = cls._get_keyboard() - keymap = { - 'ctrl': k.control_key, - 'alt': k.alt_key, - 'meta': k.windows_l_key, - 'shift': k.shift_key, - 'enter': k.enter_key, - 'tab': k.tab_key, - 'home': k.home_key, - 'end': k.end_key, - 'capslock': k.caps_lock_key, - 'back': k.backspace_key, - 'del': k.delete_key, - 'up': k.up_key, - 'down': k.down_key, - 'left': k.left_key, - 'right': k.right_key, - 'pageup': k.page_up_key, - 'pagedown': k.page_down_key, - 'esc': k.escape_key, - 'find': k.find_key, - 'f1': k.function_keys[1], - 'f2': k.function_keys[2], - 'f3': k.function_keys[3], - 'f4': k.function_keys[4], - 'f5': k.function_keys[5], - 'f6': k.function_keys[6], - 'f7': k.function_keys[7], - 'f8': k.function_keys[8], - 'f9': k.function_keys[9], - 'f10': k.function_keys[10], - 'f11': k.function_keys[11], - 'f12': k.function_keys[12], - 'help': k.help_key, - 'media_next': k.media_next_track_key, - 'media_prev': k.media_prev_track_key, - 'media_play': k.media_play_pause_key, - 'menu': k.menu_key, - 'numlock': k.num_lock_key, - 'print': k.print_key, - 'print_screen': k.print_screen_key, - 'sleep': k.sleep_key, - 'space': k.space_key, - 'voldown': k.volume_down_key, - 'volup': k.volume_up_key, - 'volmute': k.volume_mute_key, - 'zoom': k.zoom_key, - } - - lkey = key.lower() - if lkey in keymap: - return keymap[lkey] - - return key - - @action - def press_key(self, key: str): - """ - Emulate the pressure of a key. - :param key: Key to be pressed - """ - kbd = self._get_keyboard() - key = self._parse_key(key) - kbd.press_key(key) - - @action - def release_key(self, key: str): - """ - Release a pressed key. - :param key: Key to be released - """ - kbd = self._get_keyboard() - key = self._parse_key(key) - kbd.release_key(key) - - @action - def press_keys(self, keys: List[str]): - """ - Emulate the pressure of multiple keys. - :param keys: List of keys to be pressed. - """ - kbd = self._get_keyboard() - keys = [self._parse_key(k) for k in keys] - kbd.press_keys(keys) - - @action - def tap_key(self, key: str, repeat: int = 1, interval: float = 0): - """ - Emulate a key tap. - :param key: Key to be pressed - :param repeat: Number of iterations (default: 1) - :param interval: Repeat interval in seconds (default: 0) - """ - kbd = self._get_keyboard() - key = self._parse_key(key) - kbd.tap_key(key, n=repeat, interval=interval) - - @action - def type_string(self, string: str, interval: float = 0): - """ - Type a string. - :param string: String to be typed - :param interval: Interval between key strokes in seconds (default: 0) - """ - kbd = self._get_keyboard() - kbd.type_string(string, interval=interval) - - @action - def get_screen_size(self) -> List[int]: - """ - Get the size of the screen in pixels. - """ - m = self._get_mouse() - return [m.screen_size()] - - @action - def mouse_click(self, x: int, y: int, btn: int, repeat: int = 1): - """ - Mouse click. - :param x: x screen position - :param y: y screen position - :param btn: Button number (1 for left, 2 for right, 3 for middle) - :param repeat: Number of clicks (default: 1) - """ - m = self._get_mouse() - m.click(x, y, btn, n=repeat) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/inputs/manifest.yaml b/platypush/plugins/inputs/manifest.yaml deleted file mode 100644 index 332f693f..00000000 --- a/platypush/plugins/inputs/manifest.yaml +++ /dev/null @@ -1,7 +0,0 @@ -manifest: - events: {} - install: - pip: - - pyuserinput - package: platypush.plugins.inputs - type: plugin diff --git a/platypush/plugins/joystick/__init__.py b/platypush/plugins/joystick/__init__.py new file mode 100644 index 00000000..6504e82c --- /dev/null +++ b/platypush/plugins/joystick/__init__.py @@ -0,0 +1,124 @@ +import multiprocessing +import threading +from queue import Empty +from typing import Dict, List, Optional + +from platypush.plugins import RunnablePlugin, action +from platypush.schemas.joystick import JoystickStatusSchema + +from ._inputs import DeviceManager, GamePad +from ._manager import JoystickManager + + +class JoystickPlugin(RunnablePlugin): + """ + A plugin to monitor joystick events. + """ + + def __init__(self, poll_interval: float = 0.025, **kwargs): + """ + :param poll_interval: Polling interval in seconds (default: 0.025) + """ + + super().__init__(poll_interval=poll_interval, **kwargs) + self._dev_managers: Dict[str, JoystickManager] = {} + self._state_queue = multiprocessing.Queue() + self._state_monitor_thread: Optional[threading.Thread] = None + + @staticmethod + def _key(device: GamePad) -> str: + return device.get_char_device_path() + + def _start_manager(self, device: GamePad): + if device in self._dev_managers: + return + + dev_manager = JoystickManager( + device=device, + poll_interval=self.poll_interval, + state_queue=self._state_queue, + ) + + dev_manager.start() + self._dev_managers[self._key(device)] = dev_manager + + def _stop_manager(self, device: str): + dev_manager = self._dev_managers.get(device) + if not dev_manager: + return + + dev_manager.stop() + dev_manager.join(1) + if dev_manager.is_alive(): + dev_manager.kill() + + del self._dev_managers[device] + + def _state_monitor(self): + while not self.should_stop(): + try: + state = self._state_queue.get(timeout=0.5) + device = state.device + if device not in self._dev_managers: + continue + + self._dev_managers[device].state = state.state + except Empty: + pass + except Exception as e: + self.logger.exception(e) + + def main(self): + if not self._state_monitor_thread: + self._state_monitor_thread = threading.Thread( + target=self._state_monitor, daemon=True + ) + self._state_monitor_thread.start() + + while not self.should_stop(): + try: + devices = DeviceManager().gamepads + missing_devices_keys = set(self._dev_managers.keys()) - { + self._key(dev) for dev in devices + } + + new_devices = { + dev for dev in devices if self._key(dev) not in self._dev_managers + } + + # Stop managers for devices that are no longer connected + for dev in missing_devices_keys: + self._stop_manager(dev) + + # Start managers for new devices + for dev in new_devices: + self._start_manager(dev) + except Exception as e: + self.logger.exception(e) + finally: + self.wait_stop(max(0.5, min(10, (self.poll_interval or 0) * 10))) + + def stop(self): + for dev in list(self._dev_managers.keys()): + self._stop_manager(dev) + + super().stop() + + @action + def status(self) -> List[dict]: + """ + :return: .. schema:: joystick.JoystickStatusSchema(many=True) + """ + return JoystickStatusSchema().dump( + [ + { + 'device': dev.device, + 'state': dev.state, + } + for dev in self._dev_managers.values() + ], + many=True, + ) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/joystick/_inputs.py b/platypush/plugins/joystick/_inputs.py new file mode 100644 index 00000000..19060f3b --- /dev/null +++ b/platypush/plugins/joystick/_inputs.py @@ -0,0 +1,3660 @@ +"""Inputs - user input for humans. + +Inputs aims to provide easy to use, cross-platform, user input device +support for Python. I.e. keyboards, mice, gamepads, etc. + +Currently supported platforms are the Raspberry Pi, Linux, Windows and +Mac OS X. + +""" + +# Forked by Fabio Manganiello from: +# Copyright (c) 2016, 2018: Zeth +# All rights reserved. +# +# BSD Licence +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from __future__ import print_function +from __future__ import division + +import os +import sys +import io +import glob +import struct +import platform +import math +import time +import codecs +from warnings import warn +from itertools import count +from operator import itemgetter +from multiprocessing import Process, Pipe +import ctypes + +__version__ = "0.5" + +WIN = platform.system() == 'Windows' +MAC = platform.system() == 'Darwin' +NIX = platform.system() == 'Linux' + +if WIN: + # pylint: disable=wrong-import-position + import ctypes.wintypes + + DWORD = ctypes.wintypes.DWORD + HANDLE = ctypes.wintypes.HANDLE + WPARAM = ctypes.wintypes.WPARAM + LPARAM = ctypes.wintypes.WPARAM + MSG = ctypes.wintypes.MSG +else: + DWORD = ctypes.c_ulong + HANDLE = ctypes.c_void_p + WPARAM = ctypes.c_ulonglong + LPARAM = ctypes.c_ulonglong + MSG = ctypes.Structure + +if NIX: + from fcntl import ioctl + +OLD = sys.version_info < (3, 4) + +PERMISSIONS_ERROR_TEXT = ( + "The user (that this program is being run as) does " + "not have permission to access the input events, " + "check groups and permissions, for example, on " + "Debian, the user needs to be in the input group." +) + +# Standard event format for most devices. +# long, long, unsigned short, unsigned short, int +EVENT_FORMAT = str('llHHi') + +EVENT_SIZE = struct.calcsize(EVENT_FORMAT) + + +def chunks(raw): + """Yield successive EVENT_SIZE sized chunks from raw.""" + for i in range(0, len(raw), EVENT_SIZE): + yield struct.unpack(EVENT_FORMAT, raw[i : i + EVENT_SIZE]) + + +if OLD: + + def iter_unpack(raw): + """Yield successive EVENT_SIZE chunks from message.""" + return chunks(raw) + +else: + + def iter_unpack(raw): + """Yield successive EVENT_SIZE chunks from message.""" + return struct.iter_unpack(EVENT_FORMAT, raw) + + +def convert_timeval(seconds_since_epoch): + """Convert time into C style timeval.""" + frac, whole = math.modf(seconds_since_epoch) + microseconds = math.floor(frac * 1000000) + seconds = math.floor(whole) + return seconds, microseconds + + +SPECIAL_DEVICES = ( + ( + "Raspberry Pi Sense HAT Joystick", + "/dev/input/by-id/gpio-Raspberry_Pi_Sense_HAT_Joystick-event-kbd", + ), + ( + "Nintendo Wii Remote", + "/dev/input/by-id/bluetooth-Nintendo_Wii_Remote-event-joystick", + ), + ( + "FT5406 memory based driver", + "/dev/input/by-id/gpio-Raspberry_Pi_Touchscreen_Display-event-mouse", + ), +) + +XINPUT_MAPPING = ( + (1, 0x11), + (2, 0x11), + (3, 0x10), + (4, 0x10), + (5, 0x13A), + (6, 0x13B), + (7, 0x13D), + (8, 0x13E), + (9, 0x136), + (10, 0x137), + (13, 0x130), + (14, 0x131), + (15, 0x134), + (16, 0x133), + (17, 0x11), + ('l_thumb_x', 0x00), + ('l_thumb_y', 0x01), + ('left_trigger', 0x02), + ('r_thumb_x', 0x03), + ('r_thumb_y', 0x04), + ('right_trigger', 0x05), +) + +XINPUT_DLL_NAMES = ( + "XInput1_4.dll", + "XInput9_1_0.dll", + "XInput1_3.dll", + "XInput1_2.dll", + "XInput1_1.dll", +) + +XINPUT_ERROR_DEVICE_NOT_CONNECTED = 1167 +XINPUT_ERROR_SUCCESS = 0 + +XBOX_STYLE_LED_CONTROL = { + 0: 'off', + 1: 'all blink, then previous setting', + 2: '1/top-left blink, then on', + 3: '2/top-right blink, then on', + 4: '3/bottom-left blink, then on', + 5: '4/bottom-right blink, then on', + 6: '1/top-left on', + 7: '2/top-right on', + 8: '3/bottom-left on', + 9: '4/bottom-right on', + 10: 'rotate', + 11: 'blink, based on previous setting', + 12: 'slow blink, based on previous setting', + 13: 'rotate with two lights', + 14: 'persistent slow all blink', + 15: 'blink once, then previous setting', +} + +DEVICE_PROPERTIES = ( + (0x00, "INPUT_PROP_POINTER"), # needs a pointer + (0x01, "INPUT_PROP_DIRECT"), # direct input devices + (0x02, "INPUT_PROP_BUTTONPAD"), # has button(s) under pad + (0x03, "INPUT_PROP_SEMI_MT"), # touch rectangle only + (0x04, "INPUT_PROP_TOPBUTTONPAD"), # softbuttons at top of pad + (0x05, "INPUT_PROP_POINTING_STICK"), # is a pointing stick + (0x06, "INPUT_PROP_ACCELEROMETER"), # has accelerometer + (0x1F, "INPUT_PROP_MAX"), + (0x1F + 1, "INPUT_PROP_CNT"), +) + +EVENT_TYPES = ( + (0x00, "Sync"), + (0x01, "Key"), + (0x02, "Relative"), + (0x03, "Absolute"), + (0x04, "Misc"), + (0x05, "Switch"), + (0x11, "LED"), + (0x12, "Sound"), + (0x14, "Repeat"), + (0x15, "ForceFeedback"), + (0x16, "Power"), + (0x17, "ForceFeedbackStatus"), + (0x1F, "Max"), + (0x1F + 1, "Current"), +) + +SYNCHRONIZATION_EVENTS = ( + (0, "SYN_REPORT"), + (1, "SYN_CONFIG"), + (2, "SYN_MT_REPORT"), + (3, "SYN_DROPPED"), + (0xF, "SYN_MAX"), + (0xF + 1, "SYN_CNT"), +) + +KEYS_AND_BUTTONS = ( + (0, "KEY_RESERVED"), + (1, "KEY_ESC"), + (2, "KEY_1"), + (3, "KEY_2"), + (4, "KEY_3"), + (5, "KEY_4"), + (6, "KEY_5"), + (7, "KEY_6"), + (8, "KEY_7"), + (9, "KEY_8"), + (10, "KEY_9"), + (11, "KEY_0"), + (12, "KEY_MINUS"), + (13, "KEY_EQUAL"), + (14, "KEY_BACKSPACE"), + (15, "KEY_TAB"), + (16, "KEY_Q"), + (17, "KEY_W"), + (18, "KEY_E"), + (19, "KEY_R"), + (20, "KEY_T"), + (21, "KEY_Y"), + (22, "KEY_U"), + (23, "KEY_I"), + (24, "KEY_O"), + (25, "KEY_P"), + (26, "KEY_LEFTBRACE"), + (27, "KEY_RIGHTBRACE"), + (28, "KEY_ENTER"), + (29, "KEY_LEFTCTRL"), + (30, "KEY_A"), + (31, "KEY_S"), + (32, "KEY_D"), + (33, "KEY_F"), + (34, "KEY_G"), + (35, "KEY_H"), + (36, "KEY_J"), + (37, "KEY_K"), + (38, "KEY_L"), + (39, "KEY_SEMICOLON"), + (40, "KEY_APOSTROPHE"), + (41, "KEY_GRAVE"), + (42, "KEY_LEFTSHIFT"), + (43, "KEY_BACKSLASH"), + (44, "KEY_Z"), + (45, "KEY_X"), + (46, "KEY_C"), + (47, "KEY_V"), + (48, "KEY_B"), + (49, "KEY_N"), + (50, "KEY_M"), + (51, "KEY_COMMA"), + (52, "KEY_DOT"), + (53, "KEY_SLASH"), + (54, "KEY_RIGHTSHIFT"), + (55, "KEY_KPASTERISK"), + (56, "KEY_LEFTALT"), + (57, "KEY_SPACE"), + (58, "KEY_CAPSLOCK"), + (59, "KEY_F1"), + (60, "KEY_F2"), + (61, "KEY_F3"), + (62, "KEY_F4"), + (63, "KEY_F5"), + (64, "KEY_F6"), + (65, "KEY_F7"), + (66, "KEY_F8"), + (67, "KEY_F9"), + (68, "KEY_F10"), + (69, "KEY_NUMLOCK"), + (70, "KEY_SCROLLLOCK"), + (71, "KEY_KP7"), + (72, "KEY_KP8"), + (73, "KEY_KP9"), + (74, "KEY_KPMINUS"), + (75, "KEY_KP4"), + (76, "KEY_KP5"), + (77, "KEY_KP6"), + (78, "KEY_KPPLUS"), + (79, "KEY_KP1"), + (80, "KEY_KP2"), + (81, "KEY_KP3"), + (82, "KEY_KP0"), + (83, "KEY_KPDOT"), + (85, "KEY_ZENKAKUHANKAKU"), + (86, "KEY_102ND"), + (87, "KEY_F11"), + (88, "KEY_F12"), + (89, "KEY_RO"), + (90, "KEY_KATAKANA"), + (91, "KEY_HIRAGANA"), + (92, "KEY_HENKAN"), + (93, "KEY_KATAKANAHIRAGANA"), + (94, "KEY_MUHENKAN"), + (95, "KEY_KPJPCOMMA"), + (96, "KEY_KPENTER"), + (97, "KEY_RIGHTCTRL"), + (98, "KEY_KPSLASH"), + (99, "KEY_SYSRQ"), + (100, "KEY_RIGHTALT"), + (101, "KEY_LINEFEED"), + (102, "KEY_HOME"), + (103, "KEY_UP"), + (104, "KEY_PAGEUP"), + (105, "KEY_LEFT"), + (106, "KEY_RIGHT"), + (107, "KEY_END"), + (108, "KEY_DOWN"), + (109, "KEY_PAGEDOWN"), + (110, "KEY_INSERT"), + (111, "KEY_DELETE"), + (112, "KEY_MACRO"), + (113, "KEY_MUTE"), + (114, "KEY_VOLUMEDOWN"), + (115, "KEY_VOLUMEUP"), + (116, "KEY_POWER"), # SC System Power Down + (117, "KEY_KPEQUAL"), + (118, "KEY_KPPLUSMINUS"), + (119, "KEY_PAUSE"), + (120, "KEY_SCALE"), # AL Compiz Scale (Expose) + (121, "KEY_KPCOMMA"), + (122, "KEY_HANGEUL"), + (123, "KEY_HANJA"), + (124, "KEY_YEN"), + (125, "KEY_LEFTMETA"), + (126, "KEY_RIGHTMETA"), + (127, "KEY_COMPOSE"), + (128, "KEY_STOP"), # AC Stop + (129, "KEY_AGAIN"), + (130, "KEY_PROPS"), # AC Properties + (131, "KEY_UNDO"), # AC Undo + (132, "KEY_FRONT"), + (133, "KEY_COPY"), # AC Copy + (134, "KEY_OPEN"), # AC Open + (135, "KEY_PASTE"), # AC Paste + (136, "KEY_FIND"), # AC Search + (137, "KEY_CUT"), # AC Cut + (138, "KEY_HELP"), # AL Integrated Help Center + (139, "KEY_MENU"), # Menu (show menu) + (140, "KEY_CALC"), # AL Calculator + (141, "KEY_SETUP"), + (142, "KEY_SLEEP"), # SC System Sleep + (143, "KEY_WAKEUP"), # System Wake Up + (144, "KEY_FILE"), # AL Local Machine Browser + (145, "KEY_SENDFILE"), + (146, "KEY_DELETEFILE"), + (147, "KEY_XFER"), + (148, "KEY_PROG1"), + (149, "KEY_PROG2"), + (150, "KEY_WWW"), # AL Internet Browser + (151, "KEY_MSDOS"), + (152, "KEY_COFFEE"), # AL Terminal Lock/Screensaver + (153, "KEY_ROTATE_DISPLAY"), # Display orientation for e.g. tablets + (154, "KEY_CYCLEWINDOWS"), + (155, "KEY_MAIL"), + (156, "KEY_BOOKMARKS"), # AC Bookmarks + (157, "KEY_COMPUTER"), + (158, "KEY_BACK"), # AC Back + (159, "KEY_FORWARD"), # AC Forward + (160, "KEY_CLOSECD"), + (161, "KEY_EJECTCD"), + (162, "KEY_EJECTCLOSECD"), + (163, "KEY_NEXTSONG"), + (164, "KEY_PLAYPAUSE"), + (165, "KEY_PREVIOUSSONG"), + (166, "KEY_STOPCD"), + (167, "KEY_RECORD"), + (168, "KEY_REWIND"), + (169, "KEY_PHONE"), # Media Select Telephone + (170, "KEY_ISO"), + (171, "KEY_CONFIG"), # AL Consumer Control Configuration + (172, "KEY_HOMEPAGE"), # AC Home + (173, "KEY_REFRESH"), # AC Refresh + (174, "KEY_EXIT"), # AC Exit + (175, "KEY_MOVE"), + (176, "KEY_EDIT"), + (177, "KEY_SCROLLUP"), + (178, "KEY_SCROLLDOWN"), + (179, "KEY_KPLEFTPAREN"), + (180, "KEY_KPRIGHTPAREN"), + (181, "KEY_NEW"), # AC New + (182, "KEY_REDO"), # AC Redo/Repeat + (183, "KEY_F13"), + (184, "KEY_F14"), + (185, "KEY_F15"), + (186, "KEY_F16"), + (187, "KEY_F17"), + (188, "KEY_F18"), + (189, "KEY_F19"), + (190, "KEY_F20"), + (191, "KEY_F21"), + (192, "KEY_F22"), + (193, "KEY_F23"), + (194, "KEY_F24"), + (200, "KEY_PLAYCD"), + (201, "KEY_PAUSECD"), + (202, "KEY_PROG3"), + (203, "KEY_PROG4"), + (204, "KEY_DASHBOARD"), # AL Dashboard + (205, "KEY_SUSPEND"), + (206, "KEY_CLOSE"), # AC Close + (207, "KEY_PLAY"), + (208, "KEY_FASTFORWARD"), + (209, "KEY_BASSBOOST"), + (210, "KEY_PRINT"), # AC Print + (211, "KEY_HP"), + (212, "KEY_CAMERA"), + (213, "KEY_SOUND"), + (214, "KEY_QUESTION"), + (215, "KEY_EMAIL"), + (216, "KEY_CHAT"), + (217, "KEY_SEARCH"), + (218, "KEY_CONNECT"), + (219, "KEY_FINANCE"), # AL Checkbook/Finance + (220, "KEY_SPORT"), + (221, "KEY_SHOP"), + (222, "KEY_ALTERASE"), + (223, "KEY_CANCEL"), # AC Cancel + (224, "KEY_BRIGHTNESSDOWN"), + (225, "KEY_BRIGHTNESSUP"), + (226, "KEY_MEDIA"), + (227, "KEY_SWITCHVIDEOMODE"), # Cycle between available video + (228, "KEY_KBDILLUMTOGGLE"), + (229, "KEY_KBDILLUMDOWN"), + (230, "KEY_KBDILLUMUP"), + (231, "KEY_SEND"), # AC Send + (232, "KEY_REPLY"), # AC Reply + (233, "KEY_FORWARDMAIL"), # AC Forward Msg + (234, "KEY_SAVE"), # AC Save + (235, "KEY_DOCUMENTS"), + (236, "KEY_BATTERY"), + (237, "KEY_BLUETOOTH"), + (238, "KEY_WLAN"), + (239, "KEY_UWB"), + (240, "KEY_UNKNOWN"), + (241, "KEY_VIDEO_NEXT"), # drive next video source + (242, "KEY_VIDEO_PREV"), # drive previous video source + (243, "KEY_BRIGHTNESS_CYCLE"), # brightness up, after max is min + (244, "KEY_BRIGHTNESS_AUTO"), # Set Auto Brightness: manual + (245, "KEY_DISPLAY_OFF"), # display device to off state + (246, "KEY_WWAN"), # Wireless WAN (LTE, UMTS, GSM, etc.) + (247, "KEY_RFKILL"), # Key that controls all radios + (248, "KEY_MICMUTE"), # Mute / unmute the microphone + (0x100, "BTN_MISC"), + (0x100, "BTN_0"), + (0x101, "BTN_1"), + (0x102, "BTN_2"), + (0x103, "BTN_3"), + (0x104, "BTN_4"), + (0x105, "BTN_5"), + (0x106, "BTN_6"), + (0x107, "BTN_7"), + (0x108, "BTN_8"), + (0x109, "BTN_9"), + (0x110, "BTN_MOUSE"), + (0x110, "BTN_LEFT"), + (0x111, "BTN_RIGHT"), + (0x112, "BTN_MIDDLE"), + (0x113, "BTN_SIDE"), + (0x114, "BTN_EXTRA"), + (0x115, "BTN_FORWARD"), + (0x116, "BTN_BACK"), + (0x117, "BTN_TASK"), + (0x120, "BTN_JOYSTICK"), + (0x120, "BTN_TRIGGER"), + (0x121, "BTN_THUMB"), + (0x122, "BTN_THUMB2"), + (0x123, "BTN_TOP"), + (0x124, "BTN_TOP2"), + (0x125, "BTN_PINKIE"), + (0x126, "BTN_BASE"), + (0x127, "BTN_BASE2"), + (0x128, "BTN_BASE3"), + (0x129, "BTN_BASE4"), + (0x12A, "BTN_BASE5"), + (0x12B, "BTN_BASE6"), + (0x12F, "BTN_DEAD"), + (0x130, "BTN_GAMEPAD"), + (0x130, "BTN_SOUTH"), + (0x131, "BTN_EAST"), + (0x132, "BTN_C"), + (0x133, "BTN_NORTH"), + (0x134, "BTN_WEST"), + (0x135, "BTN_Z"), + (0x136, "BTN_TL"), + (0x137, "BTN_TR"), + (0x138, "BTN_TL2"), + (0x139, "BTN_TR2"), + (0x13A, "BTN_SELECT"), + (0x13B, "BTN_START"), + (0x13C, "BTN_MODE"), + (0x13D, "BTN_THUMBL"), + (0x13E, "BTN_THUMBR"), + (0x140, "BTN_DIGI"), + (0x140, "BTN_TOOL_PEN"), + (0x141, "BTN_TOOL_RUBBER"), + (0x142, "BTN_TOOL_BRUSH"), + (0x143, "BTN_TOOL_PENCIL"), + (0x144, "BTN_TOOL_AIRBRUSH"), + (0x145, "BTN_TOOL_FINGER"), + (0x146, "BTN_TOOL_MOUSE"), + (0x147, "BTN_TOOL_LENS"), + (0x148, "BTN_TOOL_QUINTTAP"), # Five fingers on trackpad + (0x14A, "BTN_TOUCH"), + (0x14B, "BTN_STYLUS"), + (0x14C, "BTN_STYLUS2"), + (0x14D, "BTN_TOOL_DOUBLETAP"), + (0x14E, "BTN_TOOL_TRIPLETAP"), + (0x14F, "BTN_TOOL_QUADTAP"), # Four fingers on trackpad + (0x150, "BTN_WHEEL"), + (0x150, "BTN_GEAR_DOWN"), + (0x151, "BTN_GEAR_UP"), + (0x160, "KEY_OK"), + (0x161, "KEY_SELECT"), + (0x162, "KEY_GOTO"), + (0x163, "KEY_CLEAR"), + (0x164, "KEY_POWER2"), + (0x165, "KEY_OPTION"), + (0x166, "KEY_INFO"), # AL OEM Features/Tips/Tutorial + (0x167, "KEY_TIME"), + (0x168, "KEY_VENDOR"), + (0x169, "KEY_ARCHIVE"), + (0x16A, "KEY_PROGRAM"), # Media Select Program Guide + (0x16B, "KEY_CHANNEL"), + (0x16C, "KEY_FAVORITES"), + (0x16D, "KEY_EPG"), + (0x16E, "KEY_PVR"), # Media Select Home + (0x16F, "KEY_MHP"), + (0x170, "KEY_LANGUAGE"), + (0x171, "KEY_TITLE"), + (0x172, "KEY_SUBTITLE"), + (0x173, "KEY_ANGLE"), + (0x174, "KEY_ZOOM"), + (0x175, "KEY_MODE"), + (0x176, "KEY_KEYBOARD"), + (0x177, "KEY_SCREEN"), + (0x178, "KEY_PC"), # Media Select Computer + (0x179, "KEY_TV"), # Media Select TV + (0x17A, "KEY_TV2"), # Media Select Cable + (0x17B, "KEY_VCR"), # Media Select VCR + (0x17C, "KEY_VCR2"), # VCR Plus + (0x17D, "KEY_SAT"), # Media Select Satellite + (0x17E, "KEY_SAT2"), + (0x17F, "KEY_CD"), # Media Select CD + (0x180, "KEY_TAPE"), # Media Select Tape + (0x181, "KEY_RADIO"), + (0x182, "KEY_TUNER"), # Media Select Tuner + (0x183, "KEY_PLAYER"), + (0x184, "KEY_TEXT"), + (0x185, "KEY_DVD"), # Media Select DVD + (0x186, "KEY_AUX"), + (0x187, "KEY_MP3"), + (0x188, "KEY_AUDIO"), # AL Audio Browser + (0x189, "KEY_VIDEO"), # AL Movie Browser + (0x18A, "KEY_DIRECTORY"), + (0x18B, "KEY_LIST"), + (0x18C, "KEY_MEMO"), # Media Select Messages + (0x18D, "KEY_CALENDAR"), + (0x18E, "KEY_RED"), + (0x18F, "KEY_GREEN"), + (0x190, "KEY_YELLOW"), + (0x191, "KEY_BLUE"), + (0x192, "KEY_CHANNELUP"), # Channel Increment + (0x193, "KEY_CHANNELDOWN"), # Channel Decrement + (0x194, "KEY_FIRST"), + (0x195, "KEY_LAST"), # Recall Last + (0x196, "KEY_AB"), + (0x197, "KEY_NEXT"), + (0x198, "KEY_RESTART"), + (0x199, "KEY_SLOW"), + (0x19A, "KEY_SHUFFLE"), + (0x19B, "KEY_BREAK"), + (0x19C, "KEY_PREVIOUS"), + (0x19D, "KEY_DIGITS"), + (0x19E, "KEY_TEEN"), + (0x19F, "KEY_TWEN"), + (0x1A0, "KEY_VIDEOPHONE"), # Media Select Video Phone + (0x1A1, "KEY_GAMES"), # Media Select Games + (0x1A2, "KEY_ZOOMIN"), # AC Zoom In + (0x1A3, "KEY_ZOOMOUT"), # AC Zoom Out + (0x1A4, "KEY_ZOOMRESET"), # AC Zoom + (0x1A5, "KEY_WORDPROCESSOR"), # AL Word Processor + (0x1A6, "KEY_EDITOR"), # AL Text Editor + (0x1A7, "KEY_SPREADSHEET"), # AL Spreadsheet + (0x1A8, "KEY_GRAPHICSEDITOR"), # AL Graphics Editor + (0x1A9, "KEY_PRESENTATION"), # AL Presentation App + (0x1AA, "KEY_DATABASE"), # AL Database App + (0x1AB, "KEY_NEWS"), # AL Newsreader + (0x1AC, "KEY_VOICEMAIL"), # AL Voicemail + (0x1AD, "KEY_ADDRESSBOOK"), # AL Contacts/Address Book + (0x1AE, "KEY_MESSENGER"), # AL Instant Messaging + (0x1AF, "KEY_DISPLAYTOGGLE"), # Turn display (LCD) on and off + (0x1B0, "KEY_SPELLCHECK"), # AL Spell Check + (0x1B1, "KEY_LOGOFF"), # AL Logoff + (0x1B2, "KEY_DOLLAR"), + (0x1B3, "KEY_EURO"), + (0x1B4, "KEY_FRAMEBACK"), # Consumer - transport controls + (0x1B5, "KEY_FRAMEFORWARD"), + (0x1B6, "KEY_CONTEXT_MENU"), # GenDesc - system context menu + (0x1B7, "KEY_MEDIA_REPEAT"), # Consumer - transport control + (0x1B8, "KEY_10CHANNELSUP"), # 10 channels up (10+) + (0x1B9, "KEY_10CHANNELSDOWN"), # 10 channels down (10-) + (0x1BA, "KEY_IMAGES"), # AL Image Browser + (0x1C0, "KEY_DEL_EOL"), + (0x1C1, "KEY_DEL_EOS"), + (0x1C2, "KEY_INS_LINE"), + (0x1C3, "KEY_DEL_LINE"), + (0x1D0, "KEY_FN"), + (0x1D1, "KEY_FN_ESC"), + (0x1D2, "KEY_FN_F1"), + (0x1D3, "KEY_FN_F2"), + (0x1D4, "KEY_FN_F3"), + (0x1D5, "KEY_FN_F4"), + (0x1D6, "KEY_FN_F5"), + (0x1D7, "KEY_FN_F6"), + (0x1D8, "KEY_FN_F7"), + (0x1D9, "KEY_FN_F8"), + (0x1DA, "KEY_FN_F9"), + (0x1DB, "KEY_FN_F10"), + (0x1DC, "KEY_FN_F11"), + (0x1DD, "KEY_FN_F12"), + (0x1DE, "KEY_FN_1"), + (0x1DF, "KEY_FN_2"), + (0x1E0, "KEY_FN_D"), + (0x1E1, "KEY_FN_E"), + (0x1E2, "KEY_FN_F"), + (0x1E3, "KEY_FN_S"), + (0x1E4, "KEY_FN_B"), + (0x1F1, "KEY_BRL_DOT1"), + (0x1F2, "KEY_BRL_DOT2"), + (0x1F3, "KEY_BRL_DOT3"), + (0x1F4, "KEY_BRL_DOT4"), + (0x1F5, "KEY_BRL_DOT5"), + (0x1F6, "KEY_BRL_DOT6"), + (0x1F7, "KEY_BRL_DOT7"), + (0x1F8, "KEY_BRL_DOT8"), + (0x1F9, "KEY_BRL_DOT9"), + (0x1FA, "KEY_BRL_DOT10"), + (0x200, "KEY_NUMERIC_0"), # used by phones, remote controls, + (0x201, "KEY_NUMERIC_1"), # and other keypads + (0x202, "KEY_NUMERIC_2"), + (0x203, "KEY_NUMERIC_3"), + (0x204, "KEY_NUMERIC_4"), + (0x205, "KEY_NUMERIC_5"), + (0x206, "KEY_NUMERIC_6"), + (0x207, "KEY_NUMERIC_7"), + (0x208, "KEY_NUMERIC_8"), + (0x209, "KEY_NUMERIC_9"), + (0x20A, "KEY_NUMERIC_STAR"), + (0x20B, "KEY_NUMERIC_POUND"), + (0x20C, "KEY_NUMERIC_A"), # Phone key A - HUT Telephony 0xb9 + (0x20D, "KEY_NUMERIC_B"), + (0x20E, "KEY_NUMERIC_C"), + (0x20F, "KEY_NUMERIC_D"), + (0x210, "KEY_CAMERA_FOCUS"), + (0x211, "KEY_WPS_BUTTON"), # WiFi Protected Setup key + (0x212, "KEY_TOUCHPAD_TOGGLE"), # Request switch touchpad on or off + (0x213, "KEY_TOUCHPAD_ON"), + (0x214, "KEY_TOUCHPAD_OFF"), + (0x215, "KEY_CAMERA_ZOOMIN"), + (0x216, "KEY_CAMERA_ZOOMOUT"), + (0x217, "KEY_CAMERA_UP"), + (0x218, "KEY_CAMERA_DOWN"), + (0x219, "KEY_CAMERA_LEFT"), + (0x21A, "KEY_CAMERA_RIGHT"), + (0x21B, "KEY_ATTENDANT_ON"), + (0x21C, "KEY_ATTENDANT_OFF"), + (0x21D, "KEY_ATTENDANT_TOGGLE"), # Attendant call on or off + (0x21E, "KEY_LIGHTS_TOGGLE"), # Reading light on or off + (0x220, "BTN_DPAD_UP"), + (0x221, "BTN_DPAD_DOWN"), + (0x222, "BTN_DPAD_LEFT"), + (0x223, "BTN_DPAD_RIGHT"), + (0x230, "KEY_ALS_TOGGLE"), # Ambient light sensor + (0x240, "KEY_BUTTONCONFIG"), # AL Button Configuration + (0x241, "KEY_TASKMANAGER"), # AL Task/Project Manager + (0x242, "KEY_JOURNAL"), # AL Log/Journal/Timecard + (0x243, "KEY_CONTROLPANEL"), # AL Control Panel + (0x244, "KEY_APPSELECT"), # AL Select Task/Application + (0x245, "KEY_SCREENSAVER"), # AL Screen Saver + (0x246, "KEY_VOICECOMMAND"), # Listening Voice Command + (0x250, "KEY_BRIGHTNESS_MIN"), # Set Brightness to Minimum + (0x251, "KEY_BRIGHTNESS_MAX"), # Set Brightness to Maximum + (0x260, "KEY_KBDINPUTASSIST_PREV"), + (0x261, "KEY_KBDINPUTASSIST_NEXT"), + (0x262, "KEY_KBDINPUTASSIST_PREVGROUP"), + (0x263, "KEY_KBDINPUTASSIST_NEXTGROUP"), + (0x264, "KEY_KBDINPUTASSIST_ACCEPT"), + (0x265, "KEY_KBDINPUTASSIST_CANCEL"), + (0x2C0, "BTN_TRIGGER_HAPPY"), + (0x2C0, "BTN_TRIGGER_HAPPY1"), + (0x2C1, "BTN_TRIGGER_HAPPY2"), + (0x2C2, "BTN_TRIGGER_HAPPY3"), + (0x2C3, "BTN_TRIGGER_HAPPY4"), + (0x2C4, "BTN_TRIGGER_HAPPY5"), + (0x2C5, "BTN_TRIGGER_HAPPY6"), + (0x2C6, "BTN_TRIGGER_HAPPY7"), + (0x2C7, "BTN_TRIGGER_HAPPY8"), + (0x2C8, "BTN_TRIGGER_HAPPY9"), + (0x2C9, "BTN_TRIGGER_HAPPY10"), + (0x2CA, "BTN_TRIGGER_HAPPY11"), + (0x2CB, "BTN_TRIGGER_HAPPY12"), + (0x2CC, "BTN_TRIGGER_HAPPY13"), + (0x2CD, "BTN_TRIGGER_HAPPY14"), + (0x2CE, "BTN_TRIGGER_HAPPY15"), + (0x2CF, "BTN_TRIGGER_HAPPY16"), + (0x2D0, "BTN_TRIGGER_HAPPY17"), + (0x2D1, "BTN_TRIGGER_HAPPY18"), + (0x2D2, "BTN_TRIGGER_HAPPY19"), + (0x2D3, "BTN_TRIGGER_HAPPY20"), + (0x2D4, "BTN_TRIGGER_HAPPY21"), + (0x2D5, "BTN_TRIGGER_HAPPY22"), + (0x2D6, "BTN_TRIGGER_HAPPY23"), + (0x2D7, "BTN_TRIGGER_HAPPY24"), + (0x2D8, "BTN_TRIGGER_HAPPY25"), + (0x2D9, "BTN_TRIGGER_HAPPY26"), + (0x2DA, "BTN_TRIGGER_HAPPY27"), + (0x2DB, "BTN_TRIGGER_HAPPY28"), + (0x2DC, "BTN_TRIGGER_HAPPY29"), + (0x2DD, "BTN_TRIGGER_HAPPY30"), + (0x2DE, "BTN_TRIGGER_HAPPY31"), + (0x2DF, "BTN_TRIGGER_HAPPY32"), + (0x2E0, "BTN_TRIGGER_HAPPY33"), + (0x2E1, "BTN_TRIGGER_HAPPY34"), + (0x2E2, "BTN_TRIGGER_HAPPY35"), + (0x2E3, "BTN_TRIGGER_HAPPY36"), + (0x2E4, "BTN_TRIGGER_HAPPY37"), + (0x2E5, "BTN_TRIGGER_HAPPY38"), + (0x2E6, "BTN_TRIGGER_HAPPY39"), + (0x2E7, "BTN_TRIGGER_HAPPY40"), + (0x2FF, "KEY_MAX"), + (0x2FF + 1, "KEY_CNT"), +) + +RELATIVE_AXES = ( + (0x00, "REL_X"), + (0x01, "REL_Y"), + (0x02, "REL_Z"), + (0x03, "REL_RX"), + (0x04, "REL_RY"), + (0x05, "REL_RZ"), + (0x06, "REL_HWHEEL"), + (0x07, "REL_DIAL"), + (0x08, "REL_WHEEL"), + (0x09, "REL_MISC"), + (0x0F, "REL_MAX"), + (0x0F + 1, "REL_CNT"), +) + +ABSOLUTE_AXES = ( + (0x00, "ABS_X"), + (0x01, "ABS_Y"), + (0x02, "ABS_Z"), + (0x03, "ABS_RX"), + (0x04, "ABS_RY"), + (0x05, "ABS_RZ"), + (0x06, "ABS_THROTTLE"), + (0x07, "ABS_RUDDER"), + (0x08, "ABS_WHEEL"), + (0x09, "ABS_GAS"), + (0x0A, "ABS_BRAKE"), + (0x10, "ABS_HAT0X"), + (0x11, "ABS_HAT0Y"), + (0x12, "ABS_HAT1X"), + (0x13, "ABS_HAT1Y"), + (0x14, "ABS_HAT2X"), + (0x15, "ABS_HAT2Y"), + (0x16, "ABS_HAT3X"), + (0x17, "ABS_HAT3Y"), + (0x18, "ABS_PRESSURE"), + (0x19, "ABS_DISTANCE"), + (0x1A, "ABS_TILT_X"), + (0x1B, "ABS_TILT_Y"), + (0x1C, "ABS_TOOL_WIDTH"), + (0x20, "ABS_VOLUME"), + (0x28, "ABS_MISC"), + (0x2F, "ABS_MT_SLOT"), # MT slot being modified + (0x30, "ABS_MT_TOUCH_MAJOR"), # Major axis of touching ellipse + (0x31, "ABS_MT_TOUCH_MINOR"), # Minor axis (omit if circular) + (0x32, "ABS_MT_WIDTH_MAJOR"), # Major axis of approaching ellipse + (0x33, "ABS_MT_WIDTH_MINOR"), # Minor axis (omit if circular) + (0x34, "ABS_MT_ORIENTATION"), # Ellipse orientation + (0x35, "ABS_MT_POSITION_X"), # Center X touch position + (0x36, "ABS_MT_POSITION_Y"), # Center Y touch position + (0x37, "ABS_MT_TOOL_TYPE"), # Type of touching device + (0x38, "ABS_MT_BLOB_ID"), # Group a set of packets as a blob + (0x39, "ABS_MT_TRACKING_ID"), # Unique ID of initiated contact + (0x3A, "ABS_MT_PRESSURE"), # Pressure on contact area + (0x3B, "ABS_MT_DISTANCE"), # Contact hover distance + (0x3C, "ABS_MT_TOOL_X"), # Center X tool position + (0x3D, "ABS_MT_TOOL_Y"), # Center Y tool position + (0x3F, "ABS_MAX"), + (0x3F + 1, "ABS_CNT"), +) + +SWITCH_EVENTS = ( + (0x00, "SW_LID"), # set = lid shut + (0x01, "SW_TABLET_MODE"), # set = tablet mode + (0x02, "SW_HEADPHONE_INSERT"), # set = inserted + (0x03, "SW_RFKILL_ALL"), # rfkill master switch, type "any" + (0x04, "SW_MICROPHONE_INSERT"), # set = inserted + (0x05, "SW_DOCK"), # set = plugged into dock + (0x06, "SW_LINEOUT_INSERT"), # set = inserted + (0x07, "SW_JACK_PHYSICAL_INSERT"), # set = mechanical switch set + (0x08, "SW_VIDEOOUT_INSERT"), # set = inserted + (0x09, "SW_CAMERA_LENS_COVER"), # set = lens covered + (0x0A, "SW_KEYPAD_SLIDE"), # set = keypad slide out + (0x0B, "SW_FRONT_PROXIMITY"), # set = front proximity sensor active + (0x0C, "SW_ROTATE_LOCK"), # set = rotate locked/disabled + (0x0D, "SW_LINEIN_INSERT"), # set = inserted + (0x0E, "SW_MUTE_DEVICE"), # set = device disabled + (0x0F, "SW_MAX"), + (0x0F + 1, "SW_CNT"), +) + +MISC_EVENTS = ( + (0x00, "MSC_SERIAL"), + (0x01, "MSC_PULSELED"), + (0x02, "MSC_GESTURE"), + (0x03, "MSC_RAW"), + (0x04, "MSC_SCAN"), + (0x05, "MSC_TIMESTAMP"), + (0x07, "MSC_MAX"), + (0x07 + 1, "MSC_CNT"), +) + +LEDS = ( + (0x00, "LED_NUML"), + (0x01, "LED_CAPSL"), + (0x02, "LED_SCROLLL"), + (0x03, "LED_COMPOSE"), + (0x04, "LED_KANA"), + (0x05, "LED_SLEEP"), + (0x06, "LED_SUSPEND"), + (0x07, "LED_MUTE"), + (0x08, "LED_MISC"), + (0x09, "LED_MAIL"), + (0x0A, "LED_CHARGING"), + (0x0F, "LED_MAX"), + (0x0F + 1, "LED_CNT"), +) + +LED_TYPE_CODES = ( + ('numlock', 0x00), + ('capslock', 0x01), + ('scrolllock', 0x02), + ('compose', 0x03), + ('kana', 0x04), + ('sleep', 0x05), + ('suspend', 0x06), + ('mute', 0x07), + ('misc', 0x08), + ('mail', 0x09), + ('charging', 0x0A), + ('max', 0x0F), + ('cnt', 0x0F + 1), +) + +AUTOREPEAT_VALUES = ( + (0x00, "REP_DELAY"), + (0x01, "REP_PERIOD"), + (0x01, "REP_MAX"), + (0x01 + 1, "REP_CNT"), +) + +SOUNDS = ( + (0x00, "SND_CLICK"), + (0x01, "SND_BELL"), + (0x02, "SND_TONE"), + (0x07, "SND_MAX"), + (0x07 + 1, "SND_CNT"), +) + +WIN_KEYBOARD_CODES = { + 0x0100: 1, + 0x0101: 0, + 0x104: 1, + 0x105: 0, +} + +WIN_MOUSE_CODES = { + 0x0201: (0x110, 1, 589825), # WM_LBUTTONDOWN --> BTN_LEFT + 0x0202: (0x110, 0, 589825), # WM_LBUTTONUP --> BTN_LEFT + 0x0204: (0x111, 1, 589826), # WM_RBUTTONDOWN --> BTN_RIGHT + 0x0205: (0x111, 0, 589826), # WM_RBUTTONUP --> BTN_RIGHT + 0x0207: (0x112, 1, 589827), # WM_MBUTTONDOWN --> BTN_MIDDLE + 0x0208: (0x112, 0, 589827), # WM_MBUTTONU --> BTN_MIDDLE + 0x020B: (0x113, 1, 589828), # WM_XBUTTONDOWN --> BTN_SIDE + 0x020C: (0x113, 0, 589828), # WM_XBUTTONUP --> BTN_SIDE + 0x020B2: (0x114, 1, 589829), # WM_XBUTTONDOWN --> BTN_EXTRA + 0x020C2: (0x114, 0, 589829), # WM_XBUTTONUP --> BTN_EXTRA +} + +# THING SING That thing can sing! +# SONG LONG A long, long song. +# Good-bye, Thing. You sing too long. +# pylint: disable=too-many-lines + +WINCODES = ( + (0x01, 0x110), # Left mouse button + (0x02, 0x111), # Right mouse button + (0x03, 0), # Control-break processing + (0x04, 0x112), # Middle mouse button (three-button mouse) + (0x05, 0x113), # X1 mouse button + (0x06, 0x114), # X2 mouse button + (0x07, 0), # Undefined + (0x08, 14), # BACKSPACE key + (0x09, 15), # TAB key + (0x0A, 0), # Reserved + (0x0B, 0), # Reserved + (0x0C, 0x163), # CLEAR key + (0x0D, 28), # ENTER key + (0x0E, 0), # Undefined + (0x0F, 0), # Undefined + (0x10, 42), # SHIFT key + (0x11, 29), # CTRL key + (0x12, 56), # ALT key + (0x13, 119), # PAUSE key + (0x14, 58), # CAPS LOCK key + (0x15, 90), # IME Kana mode + (0x15, 91), # IME Hanguel mode (maintained for compatibility; use + # VK_HANGUL) + (0x15, 91), # IME Hangul mode + (0x16, 0), # Undefined + (0x17, 92), # IME Junja mode - These all need to be fixed + (0x18, 93), # IME final mode - By someone who + (0x19, 94), # IME Hanja mode - Knows how + (0x19, 95), # IME Kanji mode - Japanese Keyboards work + (0x1A, 0), # Undefined + (0x1B, 1), # ESC key + (0x1C, 0), # IME convert + (0x1D, 0), # IME nonconvert + (0x1E, 0), # IME accept + (0x1F, 0), # IME mode change request + (0x20, 57), # SPACEBAR + (0x21, 104), # PAGE UP key + (0x22, 109), # PAGE DOWN key + (0x23, 107), # END key + (0x24, 102), # HOME key + (0x25, 105), # LEFT ARROW key + (0x26, 103), # UP ARROW key + (0x27, 106), # RIGHT ARROW key + (0x28, 108), # DOWN ARROW key + (0x29, 0x161), # SELECT key + (0x2A, 210), # PRINT key + (0x2B, 28), # EXECUTE key + (0x2C, 99), # PRINT SCREEN key + (0x2D, 110), # INS key + (0x2E, 111), # DEL key + (0x2F, 138), # HELP key + (0x30, 11), # 0 key + (0x31, 2), # 1 key + (0x32, 3), # 2 key + (0x33, 4), # 3 key + (0x34, 5), # 4 key + (0x35, 6), # 5 key + (0x36, 7), # 6 key + (0x37, 8), # 7 key + (0x38, 9), # 8 key + (0x39, 10), # 9 key + # (0x3A-40, 0), # Undefined + (0x41, 30), # A key + (0x42, 48), # B key + (0x43, 46), # C key + (0x44, 32), # D key + (0x45, 18), # E key + (0x46, 33), # F key + (0x47, 34), # G key + (0x48, 35), # H key + (0x49, 23), # I key + (0x4A, 36), # J key + (0x4B, 37), # K key + (0x4C, 38), # L key + (0x4D, 50), # M key + (0x4E, 49), # N key + (0x4F, 24), # O key + (0x50, 25), # P key + (0x51, 16), # Q key + (0x52, 19), # R key + (0x53, 31), # S key + (0x54, 20), # T key + (0x55, 22), # U key + (0x56, 47), # V key + (0x57, 17), # W key + (0x58, 45), # X key + (0x59, 21), # Y key + (0x5A, 44), # Z key + (0x5B, 125), # Left Windows key (Natural keyboard) + (0x5C, 126), # Right Windows key (Natural keyboard) + (0x5D, 139), # Applications key (Natural keyboard) + (0x5E, 0), # Reserved + (0x5F, 142), # Computer Sleep key + (0x60, 82), # Numeric keypad 0 key + (0x61, 79), # Numeric keypad 1 key + (0x62, 80), # Numeric keypad 2 key + (0x63, 81), # Numeric keypad 3 key + (0x64, 75), # Numeric keypad 4 key + (0x65, 76), # Numeric keypad 5 key + (0x66, 77), # Numeric keypad 6 key + (0x67, 71), # Numeric keypad 7 key + (0x68, 72), # Numeric keypad 8 key + (0x69, 73), # Numeric keypad 9 key + (0x6A, 55), # Multiply key + (0x6B, 78), # Add key + (0x6C, 96), # Separator key + (0x6D, 74), # Subtract key + (0x6E, 83), # Decimal key + (0x6F, 98), # Divide key + (0x70, 59), # F1 key + (0x71, 60), # F2 key + (0x72, 61), # F3 key + (0x73, 62), # F4 key + (0x74, 63), # F5 key + (0x75, 64), # F6 key + (0x76, 65), # F7 key + (0x77, 66), # F8 key + (0x78, 67), # F9 key + (0x79, 68), # F10 key + (0x7A, 87), # F11 key + (0x7B, 88), # F12 key + (0x7C, 183), # F13 key + (0x7D, 184), # F14 key + (0x7E, 185), # F15 key + (0x7F, 186), # F16 key + (0x80, 187), # F17 key + (0x81, 188), # F18 key + (0x82, 189), # F19 key + (0x83, 190), # F20 key + (0x84, 191), # F21 key + (0x85, 192), # F22 key + (0x86, 192), # F23 key + (0x87, 194), # F24 key + # (0x88-8F, 0), # Unassigned + (0x90, 69), # NUM LOCK key + (0x91, 70), # SCROLL LOCK key + # (0x92-96, 0), # OEM specific + # (0x97-9F, 0), # Unassigned + (0xA0, 42), # Left SHIFT key + (0xA1, 54), # Right SHIFT key + (0xA2, 29), # Left CONTROL key + (0xA3, 97), # Right CONTROL key + (0xA4, 125), # Left MENU key + (0xA5, 126), # Right MENU key + (0xA6, 158), # Browser Back key + (0xA7, 159), # Browser Forward key + (0xA8, 173), # Browser Refresh key + (0xA9, 128), # Browser Stop key + (0xAA, 217), # Browser Search key + (0xAB, 0x16C), # Browser Favorites key + (0xAC, 150), # Browser Start and Home key + (0xAD, 113), # Volume Mute key + (0xAE, 114), # Volume Down key + (0xAF, 115), # Volume Up key + (0xB0, 163), # Next Track key + (0xB1, 165), # Previous Track key + (0xB2, 166), # Stop Media key + (0xB3, 164), # Play/Pause Media key + (0xB4, 155), # Start Mail key + (0xB5, 0x161), # Select Media key + (0xB6, 148), # Start Application 1 key + (0xB7, 149), # Start Application 2 key + # (0xB8-B9, 0), # Reserved + (0xBA, 39), # Used for miscellaneous characters; it can vary by keyboard. + (0xBB, 13), # For any country/region, the '+' key + (0xBC, 51), # For any country/region, the ',' key + (0xBD, 12), # For any country/region, the '-' key + (0xBE, 52), # For any country/region, the '.' key + (0xBF, 53), # Slash + (0xC0, 40), # Apostrophe + # (0xC1-D7, 0), # Reserved + # (0xD8-DA, 0), # Unassigned + (0xDB, 26), # [ + (0xDC, 86), # \ + (0xDD, 27), # ] + (0xDE, 43), # ' + (0xDF, 119), # VK_OFF - What's that? + (0xE0, 0), # Reserved + (0xE1, 0), # OEM Specific + (0xE2, 43), # Either the angle bracket key or the backslash key + # on the RT 102-key keyboard (0xE3-E4, 0), # OEM + # specific + (0xE5, 0), # IME PROCESS key + (0xE6, 0), # OEM specific + (0xE7, 0), # Used to pass Unicode characters as if they were + # keystrokes. The VK_PACKET key is the low word of a + # 32-bit Virtual Key value used for non-keyboard input + # methods. For more information, see Remark in + # KEYBDINPUT, SendInput, WM_KEYDOWN, and WM_KEYUP + (0xE8, 0), # Unassigned + # (0xE9-F5, 0), # OEM specific + (0xF6, 0), # Attn key + (0xF7, 0), # CrSel key + (0xF8, 0), # ExSel key + (0xF9, 222), # Erase EOF key + (0xFA, 207), # Play key + (0xFB, 0x174), # Zoom key + (0xFC, 0), # Reserved + (0xFD, 0x19B), # PA1 key + (0xFE, 0x163), # Clear key + (0xFF, 185), +) + +MAC_EVENT_CODES = ( + # NSLeftMouseDown Quartz.kCGEventLeftMouseDown + (1, ("Key", 0x110, 1, 589825)), + # NSLeftMouseUp Quartz.kCGEventLeftMouseUp + (2, ("Key", 0x110, 0, 589825)), + # NSRightMouseDown Quartz.kCGEventRightMouseDown + (3, ("Key", 0x111, 1, 589826)), + # NSRightMouseUp Quartz.kCGEventRightMouseUp + (4, ("Key", 0x111, 0, 589826)), + (5, (None, 0, 0, 0)), # NSMouseMoved Quartz.kCGEventMouseMoved + (6, (None, 0, 0, 0)), # NSLeftMouseDragged Quartz.kCGEventLeftMouseDragged + # NSRightMouseDragged Quartz.kCGEventRightMouseDragged + (7, (None, 0, 0, 0)), + (8, (None, 0, 0, 0)), # NSMouseEntered + (9, (None, 0, 0, 0)), # NSMouseExited + (10, (None, 0, 0, 0)), # NSKeyDown + (11, (None, 0, 0, 0)), # NSKeyUp + (12, (None, 0, 0, 0)), # NSFlagsChanged + (13, (None, 0, 0, 0)), # NSAppKitDefined + (14, (None, 0, 0, 0)), # NSSystemDefined + (15, (None, 0, 0, 0)), # NSApplicationDefined + (16, (None, 0, 0, 0)), # NSPeriodic + (17, (None, 0, 0, 0)), # NSCursorUpdate + (22, (None, 0, 0, 0)), # NSScrollWheel Quartz.kCGEventScrollWheel + (23, (None, 0, 0, 0)), # NSTabletPoint Quartz.kCGEventTabletPointer + (24, (None, 0, 0, 0)), # NSTabletProximity Quartz.kCGEventTabletProximity + (25, (None, 0, 0, 0)), # NSOtherMouseDown Quartz.kCGEventOtherMouseDown + (25.2, ("Key", 0x112, 1, 589827)), # BTN_MIDDLE + (25.3, ("Key", 0x113, 1, 589828)), # BTN_SIDE + (25.4, ("Key", 0x114, 1, 589829)), # BTN_EXTRA + (26, (None, 0, 0, 0)), # NSOtherMouseUp Quartz.kCGEventOtherMouseUp + (26.2, ("Key", 0x112, 0, 589827)), # BTN_MIDDLE + (26.3, ("Key", 0x113, 0, 589828)), # BTN_SIDE + (26.4, ("Key", 0x114, 0, 589829)), # BTN_EXTRA + (27, (None, 0, 0, 0)), # NSOtherMouseDragged + (29, (None, 0, 0, 0)), # NSEventTypeGesture + (30, (None, 0, 0, 0)), # NSEventTypeMagnify + (31, (None, 0, 0, 0)), # NSEventTypeSwipe + (18, (None, 0, 0, 0)), # NSEventTypeRotate + (19, (None, 0, 0, 0)), # NSEventTypeBeginGesture + (20, (None, 0, 0, 0)), # NSEventTypeEndGesture + (27, (None, 0, 0, 0)), # Quartz.kCGEventOtherMouseDragged + (32, (None, 0, 0, 0)), # NSEventTypeSmartMagnify + (33, (None, 0, 0, 0)), # NSEventTypeQuickLook + (34, (None, 0, 0, 0)), # NSEventTypePressure +) + +MAC_KEYS = ( + (0x00, 30), # kVK_ANSI_A + (0x01, 31), # kVK_ANSI_S (0x02, 32), # kVK_ANSI_D + (0x03, 33), # kVK_ANSI_F + (0x04, 35), # kVK_ANSI_H + (0x05, 34), # kVK_ANSI_G + (0x06, 44), # kVK_ANSI_Z + (0x07, 45), # kVK_ANSI_X + (0x08, 46), # kVK_ANSI_C + (0x09, 47), # kVK_ANSI_V + (0x0B, 48), # kVK_ANSI_B + (0x0C, 16), # kVK_ANSI_Q + (0x0D, 17), # kVK_ANSI_W + (0x0E, 18), # kVK_ANSI_E + (0x0F, 33), # kVK_ANSI_R + (0x10, 21), # kVK_ANSI_Y + (0x11, 20), # kVK_ANSI_T + (0x12, 2), # kVK_ANSI_1 + (0x13, 3), # kVK_ANSI_2 + (0x14, 4), # kVK_ANSI_3 + (0x15, 5), # kVK_ANSI_4 + (0x16, 7), # kVK_ANSI_6 + (0x17, 6), # kVK_ANSI_5 + (0x18, 13), # kVK_ANSI_Equal + (0x19, 10), # kVK_ANSI_9 + (0x1A, 8), # kVK_ANSI_7 + (0x1B, 12), # kVK_ANSI_Minus + (0x1C, 9), # kVK_ANSI_8 + (0x1D, 11), # kVK_ANSI_0 + (0x1E, 27), # kVK_ANSI_RightBracket + (0x1F, 24), # kVK_ANSI_O + (0x20, 22), # kVK_ANSI_U + (0x21, 26), # kVK_ANSI_LeftBracket + (0x22, 23), # kVK_ANSI_I + (0x23, 25), # kVK_ANSI_P + (0x25, 38), # kVK_ANSI_L + (0x26, 36), # kVK_ANSI_J + (0x27, 40), # kVK_ANSI_Quote + (0x28, 37), # kVK_ANSI_K + (0x29, 39), # kVK_ANSI_Semicolon + (0x2A, 43), # kVK_ANSI_Backslash + (0x2B, 51), # kVK_ANSI_Comma + (0x2C, 53), # kVK_ANSI_Slash + (0x2D, 49), # kVK_ANSI_N + (0x2E, 50), # kVK_ANSI_M + (0x2F, 52), # kVK_ANSI_Period + (0x32, 41), # kVK_ANSI_Grave + (0x41, 83), # kVK_ANSI_KeypadDecimal + (0x43, 55), # kVK_ANSI_KeypadMultiply + (0x45, 78), # kVK_ANSI_KeypadPlus + (0x47, 69), # kVK_ANSI_KeypadClear + (0x4B, 98), # kVK_ANSI_KeypadDivide + (0x4C, 96), # kVK_ANSI_KeypadEnter + (0x4E, 74), # kVK_ANSI_KeypadMinus + (0x51, 117), # kVK_ANSI_KeypadEquals + (0x52, 82), # kVK_ANSI_Keypad0 + (0x53, 79), # kVK_ANSI_Keypad1 + (0x54, 80), # kVK_ANSI_Keypad2 + (0x55, 81), # kVK_ANSI_Keypad3 + (0x56, 75), # kVK_ANSI_Keypad4 + (0x57, 76), # kVK_ANSI_Keypad5 + (0x58, 77), # kVK_ANSI_Keypad6 + (0x59, 71), # kVK_ANSI_Keypad7 + (0x5B, 72), # kVK_ANSI_Keypad8 + (0x5C, 73), # kVK_ANSI_Keypad9 + (0x24, 28), # kVK_Return + (0x30, 15), # kVK_Tab + (0x31, 57), # kVK_Space + (0x33, 111), # kVK_Delete + (0x35, 1), # kVK_Escape + (0x37, 125), # kVK_Command + (0x38, 42), # kVK_Shift + (0x39, 58), # kVK_CapsLock + (0x3A, 56), # kVK_Option + (0x3B, 29), # kVK_Control + (0x3C, 54), # kVK_RightShift + (0x3D, 100), # kVK_RightOption + (0x3E, 126), # kVK_RightControl + (0x36, 126), # Right Meta + (0x3F, 0x1D0), # kVK_Function + (0x40, 187), # kVK_F17 + (0x48, 115), # kVK_VolumeUp + (0x49, 114), # kVK_VolumeDown + (0x4A, 113), # kVK_Mute + (0x4F, 188), # kVK_F18 + (0x50, 189), # kVK_F19 + (0x5A, 190), # kVK_F20 + (0x60, 63), # kVK_F5 + (0x61, 64), # kVK_F6 + (0x62, 65), # kVK_F7 + (0x63, 61), # kVK_F3 + (0x64, 66), # kVK_F8 + (0x65, 67), # kVK_F9 + (0x67, 87), # kVK_F11 + (0x69, 183), # kVK_F13 + (0x6A, 186), # kVK_F16 + (0x6B, 184), # kVK_F14 + (0x6D, 68), # kVK_F10 + (0x6F, 88), # kVK_F12 + (0x71, 185), # kVK_F15 + (0x72, 138), # kVK_Help + (0x73, 102), # kVK_Home + (0x74, 104), # kVK_PageUp + (0x75, 111), # kVK_ForwardDelete + (0x76, 62), # kVK_F4 + (0x77, 107), # kVK_End + (0x78, 60), # kVK_F2 + (0x79, 109), # kVK_PageDown + (0x7A, 59), # kVK_F1 + (0x7B, 105), # kVK_LeftArrow + (0x7C, 106), # kVK_RightArrow + (0x7D, 108), # kVK_DownArrow + (0x7E, 103), # kVK_UpArrow + (0x0A, 170), # kVK_ISO_Section + (0x5D, 124), # kVK_JIS_Yen + (0x5E, 92), # kVK_JIS_Underscore + (0x5F, 95), # kVK_JIS_KeypadComma + (0x66, 94), # kVK_JIS_Eisu + (0x68, 90), # kVK_JIS_Kana +) + + +# We have yet to support force feedback but probably should +# eventually: + +FORCE_FEEDBACK = () # Motor in gamepad +FORCE_FEEDBACK_STATUS = () # Status of motor + +POWER = () # Power switch + +# These two are internal workings of evdev we probably will never care +# about. + +MAX = () +CURRENT = () + + +EVENT_MAP = ( + ('types', EVENT_TYPES), + ('type_codes', ((value, key) for key, value in EVENT_TYPES)), + ('wincodes', WINCODES), + ('specials', SPECIAL_DEVICES), + ('xpad', XINPUT_MAPPING), + ('Sync', SYNCHRONIZATION_EVENTS), + ('Key', KEYS_AND_BUTTONS), + ('Relative', RELATIVE_AXES), + ('Absolute', ABSOLUTE_AXES), + ('Misc', MISC_EVENTS), + ('Switch', SWITCH_EVENTS), + ('LED', LEDS), + ('LED_type_codes', LED_TYPE_CODES), + ('Sound', SOUNDS), + ('Repeat', AUTOREPEAT_VALUES), + ('ForceFeedback', FORCE_FEEDBACK), + ('Power', POWER), + ('ForceFeedbackStatus', FORCE_FEEDBACK_STATUS), + ('Max', MAX), + ('Current', CURRENT), +) + +# Evdev style paths for the Mac + +APPKIT_KB_PATH = "/dev/input/by-id/usb-AppKit_Keyboard-event-kbd" +QUARTZ_MOUSE_PATH = "/dev/input/by-id/usb-Quartz_Mouse-event-mouse" +APPKIT_MOUSE_PATH = "/dev/input/by-id/usb-AppKit_Mouse-event-mouse" + + +# Now comes all the structs we need to parse the infomation coming +# from Windows. + + +class KBDLLHookStruct(ctypes.Structure): + """Contains information about a low-level keyboard input event. + + For full details see Microsoft's documentation: + + https://msdn.microsoft.com/en-us/library/windows/desktop/ + ms644967%28v=vs.85%29.aspx + """ + + # pylint: disable=too-few-public-methods + _fields_ = [ + ("vk_code", DWORD), + ("scan_code", DWORD), + ("flags", DWORD), + ("time", ctypes.c_int), + ] + + +class MSLLHookStruct(ctypes.Structure): + """Contains information about a low-level mouse input event. + + For full details see Microsoft's documentation: + + https://msdn.microsoft.com/en-us/library/windows/desktop/ + ms644970%28v=vs.85%29.aspx + """ + + # pylint: disable=too-few-public-methods + _fields_ = [ + ("x_pos", ctypes.c_long), + ("y_pos", ctypes.c_long), + ('reserved', ctypes.c_short), + ('mousedata', ctypes.c_short), + ("flags", DWORD), + ("time", DWORD), + ("extrainfo", ctypes.c_ulong), + ] + + +class XinputGamepad(ctypes.Structure): + """Describes the current state of the Xbox 360 Controller. + + For full details see Microsoft's documentation: + + https://msdn.microsoft.com/en-us/library/windows/desktop/ + microsoft.directx_sdk.reference.xinput_gamepad%28v=vs.85%29.aspx + + """ + + # pylint: disable=too-few-public-methods + _fields_ = [ + ('buttons', ctypes.c_ushort), # wButtons + ('left_trigger', ctypes.c_ubyte), # bLeftTrigger + ('right_trigger', ctypes.c_ubyte), # bLeftTrigger + ('l_thumb_x', ctypes.c_short), # sThumbLX + ('l_thumb_y', ctypes.c_short), # sThumbLY + ('r_thumb_x', ctypes.c_short), # sThumbRx + ('r_thumb_y', ctypes.c_short), # sThumbRy + ] + + +class XinputState(ctypes.Structure): + """Represents the state of a controller. + + For full details see Microsoft's documentation: + + https://msdn.microsoft.com/en-us/library/windows/desktop/ + microsoft.directx_sdk.reference.xinput_state%28v=vs.85%29.aspx + + """ + + # pylint: disable=too-few-public-methods + _fields_ = [ + ('packet_number', ctypes.c_ulong), # dwPacketNumber + ('gamepad', XinputGamepad), # Gamepad + ] + + +class XinputVibration(ctypes.Structure): + """Specifies motor speed levels for the vibration function of a + controller. + + For full details see Microsoft's documentation: + + https://msdn.microsoft.com/en-us/library/windows/desktop/ + microsoft.directx_sdk.reference.xinput_vibration%28v=vs.85%29.aspx + + """ + + # pylint: disable=too-few-public-methods + _fields_ = [ + ("wLeftMotorSpeed", ctypes.c_ushort), + ("wRightMotorSpeed", ctypes.c_ushort), + ] + + +if sys.version_info.major == 2: + # pylint: disable=redefined-builtin + class PermissionError(IOError): + """Raised when trying to run an operation without the adequate access + rights - for example filesystem permissions. Corresponds to errno + EACCES and EPERM.""" + + +class UnpluggedError(RuntimeError): + """The device requested is not plugged in.""" + + pass + + +class NoDevicePath(RuntimeError): + """No evdev device path was given.""" + + pass + + +class UnknownEventType(IndexError): + """We don't know what this event is.""" + + pass + + +class UnknownEventCode(IndexError): + """We don't know what this event is.""" + + pass + + +class InputEvent: + """A user event.""" + + # pylint: disable=too-few-public-methods + def __init__(self, device, event_info): + self.device = device + self.timestamp = event_info["timestamp"] + self.code = event_info["code"] + self.state = event_info["state"] + self.ev_type = event_info["ev_type"] + + +class BaseListener: + """Loosely emulate Evdev keyboard behaviour on other platforms. + Listen (hook in Windows terminology) for key events then buffer + them in a pipe. + """ + + def __init__(self, pipe, events=None, codes=None): + self.pipe = pipe + self.events = events if events else [] + self.codes = codes if codes else None + self.app = None + self.timeval = None + self.type_codes = dict(EVENT_TYPES) + + self.install_handle_input() + + def install_handle_input(self): + """Install the input handler.""" + pass + + def uninstall_handle_input(self): + """Un-install the input handler.""" + pass + + def __del__(self): + """Clean up when deleted.""" + self.uninstall_handle_input() + + @staticmethod + def get_timeval(): + """Get the time in seconds and microseconds.""" + return convert_timeval(time.time()) + + def update_timeval(self): + """Update the timeval with the current time.""" + self.timeval = self.get_timeval() + + def create_event_object(self, event_type, code, value, timeval=None): + """Create an evdev style structure.""" + if not timeval: + self.update_timeval() + timeval = self.timeval + try: + event_code = self.type_codes[event_type] + except KeyError: + raise UnknownEventType( + "We don't know what kind of event a %s is." % event_type + ) + + event = struct.pack( + EVENT_FORMAT, timeval[0], timeval[1], event_code, code, value + ) + return event + + def write_to_pipe(self, event_list): + """Send event back to the mouse object.""" + self.pipe.send_bytes(b''.join(event_list)) + + def emulate_wheel(self, data, direction, timeval): + """Emulate rel values for the mouse wheel. + + In evdev, a single click forwards of the mouse wheel is 1 and + a click back is -1. Windows uses 120 and -120. We floor divide + the Windows number by 120. This is fine for the digital scroll + wheels found on the vast majority of mice. It also works on + the analogue ball on the top of the Apple mouse. + + What do the analogue scroll wheels found on 200 quid high end + gaming mice do? If the lowest unit is 120 then we are okay. If + they report changes of less than 120 units Windows, then this + might be an unacceptable loss of precision. Needless to say, I + don't have such a mouse to test one way or the other. + + """ + if direction == 'x': + code = 0x06 + elif direction == 'z': + # Not enitely sure if this exists + code = 0x07 + else: + code = 0x08 + + if WIN: + data = data // 120 + + return self.create_event_object("Relative", code, data, timeval) + + def emulate_rel(self, key_code, value, timeval): + """Emulate the relative changes of the mouse cursor.""" + return self.create_event_object("Relative", key_code, value, timeval) + + def emulate_press(self, key_code, scan_code, value, timeval): + """Emulate a button press. + + Currently supports 5 buttons. + + The Microsoft documentation does not define what happens with + a mouse with more than five buttons, and I don't have such a + mouse. + + From reading the Linux sources, I guess evdev can support up + to 255 buttons. + + Therefore, I guess we could support more buttons quite easily, + if we had any useful hardware. + """ + scan_event = self.create_event_object("Misc", 0x04, scan_code, timeval) + key_event = self.create_event_object("Key", key_code, value, timeval) + return scan_event, key_event + + def emulate_repeat(self, value, timeval): + """The repeat press of a key/mouse button, e.g. double click.""" + repeat_event = self.create_event_object("Repeat", 2, value, timeval) + return repeat_event + + def sync_marker(self, timeval): + """Separate groups of events.""" + return self.create_event_object("Sync", 0, 0, timeval) + + def emulate_abs(self, x_val, y_val, timeval): + """Emulate the absolute co-ordinates of the mouse cursor.""" + x_event = self.create_event_object("Absolute", 0x00, x_val, timeval) + y_event = self.create_event_object("Absolute", 0x01, y_val, timeval) + return x_event, y_event + + +class WindowsKeyboardListener(BaseListener): + """Loosely emulate Evdev keyboard behaviour on Windows. Listen (hook + in Windows terminology) for key events then buffer them in a pipe. + """ + + def __init__(self, pipe, codes=None): + self.pipe = pipe + self.hooked = None + self.pointer = None + super(WindowsKeyboardListener, self).__init__(pipe, codes) + + @staticmethod + def listen(): + """Listen for keyboard input.""" + msg = MSG() + ctypes.windll.user32.GetMessageA(ctypes.byref(msg), 0, 0, 0) + + def get_fptr(self): + """Get the function pointer.""" + cmpfunc = ctypes.CFUNCTYPE( + ctypes.c_int, WPARAM, LPARAM, ctypes.POINTER(KBDLLHookStruct) + ) + return cmpfunc(self.handle_input) + + def install_handle_input(self): + """Install the hook.""" + self.pointer = self.get_fptr() + + self.hooked = ctypes.windll.user32.SetWindowsHookExA( + 13, self.pointer, ctypes.windll.kernel32.GetModuleHandleW(None), 0 + ) + if not self.hooked: + return False + return True + + def uninstall_handle_input(self): + """Remove the hook.""" + if self.hooked is None: + return + ctypes.windll.user32.UnhookWindowsHookEx(self.hooked) + self.hooked = None + + def handle_input(self, ncode, wparam, lparam): + """Process the key input.""" + value = WIN_KEYBOARD_CODES[wparam] + scan_code = lparam.contents.scan_code + vk_code = lparam.contents.vk_code + self.update_timeval() + + events = [] + # Add key event + scan_key, key_event = self.emulate_press( + vk_code, scan_code, value, self.timeval + ) + events.append(scan_key) + events.append(key_event) + + # End with a sync marker + events.append(self.sync_marker(self.timeval)) + + # We are done + self.write_to_pipe(events) + + return ctypes.windll.user32.CallNextHookEx(self.hooked, ncode, wparam, lparam) + + +def keyboard_process(pipe): + """Single subprocess for reading keyboard events on Windows.""" + keyboard = WindowsKeyboardListener(pipe) + keyboard.listen() + + +class WindowsMouseListener(BaseListener): + """Loosely emulate Evdev mouse behaviour on Windows. Listen (hook + in Windows terminology) for key events then buffer them in a pipe. + """ + + def __init__(self, pipe): + self.pipe = pipe + self.hooked = None + self.pointer = None + self.mouse_codes = WIN_MOUSE_CODES + super(WindowsMouseListener, self).__init__(pipe) + + @staticmethod + def listen(): + """Listen for mouse input.""" + msg = MSG() + ctypes.windll.user32.GetMessageA(ctypes.byref(msg), 0, 0, 0) + + def get_fptr(self): + """Get the function pointer.""" + cmpfunc = ctypes.CFUNCTYPE( + ctypes.c_int, WPARAM, LPARAM, ctypes.POINTER(MSLLHookStruct) + ) + return cmpfunc(self.handle_input) + + def install_handle_input(self): + """Install the hook.""" + self.pointer = self.get_fptr() + + self.hooked = ctypes.windll.user32.SetWindowsHookExA( + 14, self.pointer, ctypes.windll.kernel32.GetModuleHandleW(None), 0 + ) + if not self.hooked: + return False + return True + + def uninstall_handle_input(self): + """Remove the hook.""" + if self.hooked is None: + return + ctypes.windll.user32.UnhookWindowsHookEx(self.hooked) + self.hooked = None + + def handle_input(self, ncode, wparam, lparam): + """Process the key input.""" + x_pos = lparam.contents.x_pos + y_pos = lparam.contents.y_pos + data = lparam.contents.mousedata + + # This is how we can distinguish mouse 1 from mouse 2 + # extrainfo = lparam.contents.extrainfo + # The way windows seems to do it is there is primary mouse + # and all other mouses report as mouse 2 + + # Also useful later will be to support the flags field + # flags = lparam.contents.flags + # This shows if the event was from a real device or whether it + # was injected somehow via software + + self.emulate_mouse(wparam, x_pos, y_pos, data) + + # Give back control to Windows to wait for and process the + # next event + return ctypes.windll.user32.CallNextHookEx(self.hooked, ncode, wparam, lparam) + + def emulate_mouse(self, key_code, x_val, y_val, data): + """Emulate the ev codes using the data Windows has given us. + + Note that by default in Windows, to recognise a double click, + you just notice two clicks in a row within a reasonablely + short time period. + + However, if the application developer sets the application + window's class style to CS_DBLCLKS, the operating system will + notice the four button events (down, up, down, up), intercept + them and then send a single key code instead. + + There are no such special double click codes on other + platforms, so not obvious what to do with them. It might be + best to just convert them back to four events. + + Currently we do nothing. + + ((0x0203, 'WM_LBUTTONDBLCLK'), + (0x0206, 'WM_RBUTTONDBLCLK'), + (0x0209, 'WM_MBUTTONDBLCLK'), + (0x020D, 'WM_XBUTTONDBLCLK')) + + """ + # Once again ignore Windows' relative time (since system + # startup) and use the absolute time (since epoch i.e. 1st Jan + # 1970). + self.update_timeval() + + events = [] + + if key_code == 0x0200: + # We have a mouse move alone. + # So just pass through to below + pass + elif key_code == 0x020A: + # We have a vertical mouse wheel turn + events.append(self.emulate_wheel(data, 'y', self.timeval)) + elif key_code == 0x020E: + # We have a horizontal mouse wheel turn + # https://msdn.microsoft.com/en-us/library/windows/desktop/ + # ms645614%28v=vs.85%29.aspx + events.append(self.emulate_wheel(data, 'x', self.timeval)) + else: + # We have a button press. + + # Distinguish the second extra button + if key_code == 0x020B and data == 2: + key_code = 0x020B2 + elif key_code == 0x020C and data == 2: + key_code = 0x020C2 + + # Get the mouse codes + code, value, scan_code = self.mouse_codes[key_code] + # Add in the press events + scan_event, key_event = self.emulate_press( + code, scan_code, value, self.timeval + ) + events.append(scan_event) + events.append(key_event) + + # Add in the absolute position of the mouse cursor + x_event, y_event = self.emulate_abs(x_val, y_val, self.timeval) + events.append(x_event) + events.append(y_event) + + # End with a sync marker + events.append(self.sync_marker(self.timeval)) + + # We are done + self.write_to_pipe(events) + + +def mouse_process(pipe): + """Single subprocess for reading mouse events on Windows.""" + mouse = WindowsMouseListener(pipe) + mouse.listen() + + +class QuartzMouseBaseListener(BaseListener): + """Emulate evdev mouse behaviour on mac.""" + + def __init__(self, pipe): + super(QuartzMouseBaseListener, self).__init__(pipe, codes=dict(MAC_EVENT_CODES)) + self.active = True + self.events = [] + + def _get_mouse_button_number(self, event): + """Get the mouse button number from an event.""" + raise NotImplementedError + + def _get_click_state(self, event): + """The click state from an event.""" + raise NotImplementedError + + def _get_scroll(self, event): + """The scroll values from an event.""" + raise NotImplementedError + + def _get_absolute(self, event): + """Get abolute cursor location.""" + raise NotImplementedError + + def _get_relative(self, event): + """Get the relative mouse movement.""" + raise NotImplementedError + + def handle_button(self, event, event_type): + """Convert the button information from quartz into evdev format.""" + # 0 for left + # 1 for right + # 2 for middle/center + # 3 for side + mouse_button_number = self._get_mouse_button_number(event) + + # Identify buttons 3,4,5 + if event_type in (25, 26): + event_type = event_type + (mouse_button_number * 0.1) + + # Add buttons to events + event_type_string, event_code, value, scan = self.codes[event_type] + if event_type_string == "Key": + scan_event, key_event = self.emulate_press( + event_code, scan, value, self.timeval + ) + self.events.append(scan_event) + self.events.append(key_event) + + # doubleclick/n-click of button + click_state = self._get_click_state(event) + + repeat = self.emulate_repeat(click_state, self.timeval) + self.events.append(repeat) + + def handle_scrollwheel(self, event): + """Handle the scrollwheel (it is a ball on the mighty mouse).""" + # relative Scrollwheel + scroll_x, scroll_y = self._get_scroll(event) + + if scroll_x: + self.events.append(self.emulate_wheel(scroll_x, 'x', self.timeval)) + + if scroll_y: + self.events.append(self.emulate_wheel(scroll_y, 'y', self.timeval)) + + def handle_absolute(self, event): + """Absolute mouse position on the screen.""" + (x_val, y_val) = self._get_absolute(event) + x_event, y_event = self.emulate_abs(int(x_val), int(y_val), self.timeval) + self.events.append(x_event) + self.events.append(y_event) + + def handle_relative(self, event): + """Relative mouse movement.""" + delta_x, delta_y = self._get_relative(event) + if delta_x: + self.events.append(self.emulate_rel(0x00, delta_x, self.timeval)) + if delta_y: + self.events.append(self.emulate_rel(0x01, delta_y, self.timeval)) + + # pylint: disable=unused-argument + def handle_input(self, proxy, event_type, event, refcon): + """Handle an input event.""" + self.update_timeval() + self.events = [] + + if event_type in (1, 2, 3, 4, 25, 26, 27): + self.handle_button(event, event_type) + + if event_type == 22: + self.handle_scrollwheel(event) + + # Add in the absolute position of the mouse cursor + self.handle_absolute(event) + + # Add in the relative position of the mouse cursor + self.handle_relative(event) + + # End with a sync marker + self.events.append(self.sync_marker(self.timeval)) + + # We are done + self.write_to_pipe(self.events) + + +def quartz_mouse_process(pipe): + """Single subprocess for reading mouse events on Mac using newer Quartz.""" + # Quartz only on the mac, so don't warn about Quartz + # pylint: disable=import-error + import Quartz + + # pylint: disable=no-member + + class QuartzMouseListener(QuartzMouseBaseListener): + """Loosely emulate Evdev mouse behaviour on the Macs. + Listen for key events then buffer them in a pipe. + """ + + def install_handle_input(self): + """Constants below listed at: + https://developer.apple.com/documentation/coregraphics/ + cgeventtype?language=objc#topics + """ + # Keep Mac Names to make it easy to find the documentation + # pylint: disable=invalid-name + + NSMachPort = Quartz.CGEventTapCreate( + Quartz.kCGSessionEventTap, + Quartz.kCGHeadInsertEventTap, + Quartz.kCGEventTapOptionDefault, + Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) + | Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDragged) + | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDragged) + | Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel) + | Quartz.CGEventMaskBit(Quartz.kCGEventTabletPointer) + | Quartz.CGEventMaskBit(Quartz.kCGEventTabletProximity) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDragged), + self.handle_input, + None, + ) + + CFRunLoopSourceRef = Quartz.CFMachPortCreateRunLoopSource( + None, NSMachPort, 0 + ) + CFRunLoopRef = Quartz.CFRunLoopGetCurrent() + Quartz.CFRunLoopAddSource( + CFRunLoopRef, CFRunLoopSourceRef, Quartz.kCFRunLoopDefaultMode + ) + Quartz.CGEventTapEnable(NSMachPort, True) + + def listen(self): + """Listen for quartz events.""" + while self.active: + Quartz.CFRunLoopRunInMode(Quartz.kCFRunLoopDefaultMode, 5, False) + + def uninstall_handle_input(self): + self.active = False + + def _get_mouse_button_number(self, event): + """Get the mouse button number from an event.""" + return Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGMouseEventButtonNumber + ) + + def _get_click_state(self, event): + """The click state from an event.""" + return Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGMouseEventClickState + ) + + def _get_scroll(self, event): + """The scroll values from an event.""" + scroll_y = Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGScrollWheelEventDeltaAxis1 + ) + scroll_x = Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGScrollWheelEventDeltaAxis2 + ) + return scroll_x, scroll_y + + def _get_absolute(self, event): + """Get abolute cursor location.""" + return Quartz.CGEventGetLocation(event) + + def _get_relative(self, event): + """Get the relative mouse movement.""" + delta_x = Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGMouseEventDeltaX + ) + delta_y = Quartz.CGEventGetIntegerValueField( + event, Quartz.kCGMouseEventDeltaY + ) + return delta_x, delta_y + + mouse = QuartzMouseListener(pipe) + mouse.listen() + + +class AppKitMouseBaseListener(BaseListener): + """Emulate evdev behaviour on the the Mac.""" + + def __init__(self, pipe, events=None): + super(AppKitMouseBaseListener, self).__init__( + pipe, events, codes=dict(MAC_EVENT_CODES) + ) + + @staticmethod + def _get_mouse_button_number(event): + """Get the button number.""" + return event.buttonNumber() + + @staticmethod + def _get_absolute(event): + """Get the absolute (pixel) location of the mouse cursor.""" + return event.locationInWindow() + + @staticmethod + def _get_event_type(event): + """Get the appkit event type of the event.""" + return event.type() + + @staticmethod + def _get_deltas(event): + """Get the changes from the appkit event.""" + delta_x = round(event.deltaX()) + delta_y = round(event.deltaY()) + delta_z = round(event.deltaZ()) + return delta_x, delta_y, delta_z + + def handle_button(self, event, event_type): + """Handle mouse click.""" + mouse_button_number = self._get_mouse_button_number(event) + # Identify buttons 3,4,5 + if event_type in (25, 26): + event_type = event_type + (mouse_button_number * 0.1) + # Add buttons to events + event_type_name, event_code, value, scan = self.codes[event_type] + if event_type_name == "Key": + scan_event, key_event = self.emulate_press( + event_code, scan, value, self.timeval + ) + self.events.append(scan_event) + self.events.append(key_event) + + def handle_absolute(self, event): + """Absolute mouse position on the screen.""" + point = self._get_absolute(event) + x_pos = round(point.x) + y_pos = round(point.y) + x_event, y_event = self.emulate_abs(x_pos, y_pos, self.timeval) + self.events.append(x_event) + self.events.append(y_event) + + def handle_scrollwheel(self, event): + """Make endev from appkit scroll wheel event.""" + delta_x, delta_y, delta_z = self._get_deltas(event) + if delta_x: + self.events.append(self.emulate_wheel(delta_x, 'x', self.timeval)) + if delta_y: + self.events.append(self.emulate_wheel(delta_y, 'y', self.timeval)) + if delta_z: + self.events.append(self.emulate_wheel(delta_z, 'z', self.timeval)) + + def handle_relative(self, event): + """Get the position of the mouse on the screen.""" + delta_x, delta_y, delta_z = self._get_deltas(event) + if delta_x: + self.events.append(self.emulate_rel(0x00, delta_x, self.timeval)) + if delta_y: + self.events.append(self.emulate_rel(0x01, delta_y, self.timeval)) + if delta_z: + self.events.append(self.emulate_rel(0x02, delta_z, self.timeval)) + + def handle_input(self, event): + """Process the mouse event.""" + self.update_timeval() + self.events = [] + code = self._get_event_type(event) + + # Deal with buttons + self.handle_button(event, code) + + # Mouse wheel + if code == 22: + self.handle_scrollwheel(event) + # Other relative mouse movements + else: + self.handle_relative(event) + + # Add in the absolute position of the mouse cursor + self.handle_absolute(event) + + # End with a sync marker + self.events.append(self.sync_marker(self.timeval)) + + # We are done + self.write_to_pipe(self.events) + + +def appkit_mouse_process(pipe): + """Single subprocess for reading mouse events on Mac using older AppKit.""" + # pylint: disable=import-error,too-many-locals + + # Note Objective C does not support a Unix style fork. + # So these imports have to be inside the child subprocess since + # otherwise the child process cannot use them. + + # pylint: disable=no-member, no-name-in-module + from Foundation import NSObject + from AppKit import NSApplication, NSApp + from Cocoa import ( + NSEvent, + NSLeftMouseDownMask, + NSLeftMouseUpMask, + NSRightMouseDownMask, + NSRightMouseUpMask, + NSMouseMovedMask, + NSLeftMouseDraggedMask, + NSRightMouseDraggedMask, + NSMouseEnteredMask, + NSMouseExitedMask, + NSScrollWheelMask, + NSOtherMouseDownMask, + NSOtherMouseUpMask, + ) + from PyObjCTools import AppHelper + import objc + + class MacMouseSetup(NSObject): + """Setup the handler.""" + + @objc.python_method + def init_with_handler(self, handler): + """ + Init method that receives the write end of the pipe. + """ + # ALWAYS call the super's designated initializer. + # Also, make sure to re-bind "self" just in case it + # returns something else! + # pylint: disable=self-cls-assignment + self = super(MacMouseSetup, self).init() + self.handler = handler + # Unlike Python's __init__, initializers MUST return self, + # because they are allowed to return any object! + return self + + # pylint: disable=invalid-name, unused-argument + def applicationDidFinishLaunching_(self, notification): + """Bind the listen method as the handler for mouse events.""" + + mask = ( + NSLeftMouseDownMask + | NSLeftMouseUpMask + | NSRightMouseDownMask + | NSRightMouseUpMask + | NSMouseMovedMask + | NSLeftMouseDraggedMask + | NSRightMouseDraggedMask + | NSScrollWheelMask + | NSMouseEnteredMask + | NSMouseExitedMask + | NSOtherMouseDownMask + | NSOtherMouseUpMask + ) + NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(mask, self.handler) + + class MacMouseListener(AppKitMouseBaseListener): + """Loosely emulate Evdev mouse behaviour on the Macs. + Listen for key events then buffer them in a pipe. + """ + + def install_handle_input(self): + """Install the hook.""" + self.app = NSApplication.sharedApplication() + # pylint: disable=no-member + delegate = MacMouseSetup.alloc().init_with_handler(self.handle_input) + NSApp().setDelegate_(delegate) + AppHelper.runEventLoop() + + def __del__(self): + """Stop the listener on deletion.""" + AppHelper.stopEventLoop() + + MacMouseListener(pipe, events=[]) + + +class AppKitKeyboardListener(BaseListener): + """Emulate an evdev keyboard on the Mac.""" + + def __init__(self, pipe): + super(AppKitKeyboardListener, self).__init__(pipe, codes=dict(MAC_KEYS)) + + @staticmethod + def _get_event_key_code(event): + """Get the key code.""" + return event.keyCode() + + @staticmethod + def _get_event_type(event): + """Get the event type.""" + return event.type() + + @staticmethod + def _get_flag_value(event): + """Note, this may be able to be made more accurate, + i.e. handle two modifier keys at once.""" + flags = event.modifierFlags() + if flags == 0x100: + value = 0 + else: + value = 1 + return value + + def _get_key_value(self, event, event_type): + """Get the key value.""" + if event_type == 10: + value = 1 + elif event_type == 11: + value = 0 + elif event_type == 12: + value = self._get_flag_value(event) + else: + value = -1 + return value + + def handle_input(self, event): + """Process they keyboard input.""" + self.update_timeval() + self.events = [] + code = self._get_event_key_code(event) + new_code = (self.codes or {}).get(code, 0) + event_type = self._get_event_type(event) + value = self._get_key_value(event, event_type) + scan_event, key_event = self.emulate_press(new_code, code, value, self.timeval) + + self.events.append(scan_event) + self.events.append(key_event) + # End with a sync marker + self.events.append(self.sync_marker(self.timeval)) + # We are done + self.write_to_pipe(self.events) + + +def mac_keyboard_process(pipe): + """Single subprocesses for reading keyboard on Mac.""" + # pylint: disable=import-error,too-many-locals + # Note Objective C does not support a Unix style fork. + # So these imports have to be inside the child subprocess since + # otherwise the child process cannot use them. + + # pylint: disable=no-member, no-name-in-module + from AppKit import NSApplication, NSApp + from Foundation import NSObject + from Cocoa import NSEvent, NSKeyDownMask, NSKeyUpMask, NSFlagsChangedMask + from PyObjCTools import AppHelper + import objc + + class MacKeyboardSetup(NSObject): + """Setup the handler.""" + + @objc.python_method + def init_with_handler(self, handler): + """ + Init method that receives the write end of the pipe. + """ + # ALWAYS call the super's designated initializer. + # Also, make sure to re-bind "self" just in case it + # returns something else! + + # pylint: disable=self-cls-assignment + self = super(MacKeyboardSetup, self).init() + + self.handler = handler + + # Unlike Python's __init__, initializers MUST return self, + # because they are allowed to return any object! + return self + + # pylint: disable=invalid-name, unused-argument + def applicationDidFinishLaunching_(self, notification): + """Bind the handler to listen to keyboard events.""" + mask = NSKeyDownMask | NSKeyUpMask | NSFlagsChangedMask + NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(mask, self.handler) + + class MacKeyboardListener(AppKitKeyboardListener): + """Loosely emulate Evdev keyboard behaviour on the Mac. + Listen for key events then buffer them in a pipe. + """ + + def install_handle_input(self): + """Install the hook.""" + self.app = NSApplication.sharedApplication() + # pylint: disable=no-member + delegate = MacKeyboardSetup.alloc().init_with_handler(self.handle_input) + NSApp().setDelegate_(delegate) + AppHelper.runEventLoop() + + def __del__(self): + """Stop the listener on deletion.""" + AppHelper.stopEventLoop() + + MacKeyboardListener(pipe) + + +class InputDevice: + """A user input device.""" + + # pylint: disable=too-many-instance-attributes + def __init__(self, manager, device_path=None, char_path_override=None, read_size=1): + self.read_size = read_size + self.manager = manager + self.__pipe = None + self._listener = None + self.leds = None + if device_path: + self._device_path = device_path + else: + self._set_device_path() + # We should by now have a device_path + + try: + if not self._device_path: + raise NoDevicePath + except AttributeError: + raise NoDevicePath + + self.protocol, _, self.device_type = self._get_path_infomation() + if char_path_override: + self._character_device_path = char_path_override + else: + self._character_device_path = os.path.realpath(self._device_path) + + self._character_file = None + + self._evdev = False + self._set_evdev_state() + + self.name = "Unknown Device" + self._set_name() + + def _set_device_path(self): + """Set the device path, overridden on the MAC and Windows.""" + pass + + def _set_evdev_state(self): + """Set whether the device is a real evdev device.""" + if NIX: + self._evdev = True + + def _set_name(self): + if NIX: + with open( + "/sys/class/input/%s/device/name" % self.get_char_name() + ) as name_file: + self.name = name_file.read().strip() + self.leds = [] + + def _get_path_infomation(self): + """Get useful infomation from the device path.""" + long_identifier = self._device_path.split('/')[4] + protocol, remainder = long_identifier.split('-', 1) + identifier, _, device_type = remainder.rsplit('-', 2) + return (protocol, identifier, device_type) + + def get_char_name(self): + """Get short version of char device name.""" + return self._character_device_path.split('/')[-1] + + def get_char_device_path(self): + """Get the char device path.""" + return self._character_device_path + + def __str__(self): + try: + return self.name + except AttributeError: + return "Unknown Device" + + def __repr__(self): + return '%s.%s("%s")' % ( + self.__module__, + self.__class__.__name__, + self._device_path, + ) + + @property + def _character_device(self): + if not self._character_file: + if WIN: + self._character_file = io.BytesIO() + return self._character_file + try: + self._character_file = io.open(self._character_device_path, 'rb') + except PermissionError: + # Python 3 + raise PermissionError(PERMISSIONS_ERROR_TEXT) + except IOError as err: + # Python 2 + if err.errno == 13: + raise PermissionError(PERMISSIONS_ERROR_TEXT) + else: + raise + + return self._character_file + + def __iter__(self): + while True: + event = self._do_iter() + if event: + yield event + + def _get_data(self, read_size): + """Get data from the character device.""" + return self._character_device.read(read_size) + + @staticmethod + def _get_target_function(): + """Get the correct target function. This is only used by Windows + subclasses.""" + return False + + def _get_total_read_size(self): + """How much event data to process at once.""" + if self.read_size: + read_size = EVENT_SIZE * self.read_size + else: + read_size = EVENT_SIZE + return read_size + + def _do_iter(self): + read_size = self._get_total_read_size() + data = self._get_data(read_size) + if not data: + return None + evdev_objects = iter_unpack(data) + events = [self._make_event(*event) for event in evdev_objects] + return events + + # pylint: disable=too-many-arguments + def _make_event(self, tv_sec, tv_usec, ev_type, code, value): + """Create a friendly Python object from an evdev style event.""" + event_type = self.manager.get_event_type(ev_type) + eventinfo = { + "ev_type": event_type, + "state": value, + "timestamp": tv_sec + (tv_usec / 1000000), + "code": self.manager.get_event_string(event_type, code), + } + + return InputEvent(self, eventinfo) + + def read(self): + """Read the next input event.""" + return next(iter(self)) + + @property + def _pipe(self): + """On Windows we use a pipe to emulate a Linux style character + buffer.""" + if self._evdev: + return None + + if not self.__pipe: + target_function = self._get_target_function() + if not target_function: + return None + + self.__pipe, child_conn = Pipe(duplex=False) + self._listener = Process(target=target_function, args=(child_conn,)) + self._listener.start() + return self.__pipe + + def __del__(self): + if ('WIN' in globals() or 'MAC' in globals()) and (WIN or MAC) and self.__pipe: + self._listener.terminate() + + +class Keyboard(InputDevice): + """A keyboard or other key-like device. + + Original umapped scan code, followed by the important key info + followed by a sync. + """ + + def _set_device_path(self): + super(Keyboard, self)._set_device_path() + if MAC: + self._device_path = APPKIT_KB_PATH + + def _set_name(self): + super(Keyboard, self)._set_name() + if WIN: + self.name = "Microsoft Keyboard" + elif MAC: + self.name = "AppKit Keyboard" + + @staticmethod + def _get_target_function(): + """Get the correct target function.""" + if WIN: + return keyboard_process + if MAC: + return mac_keyboard_process + return None + + def _get_data(self, read_size): + """Get data from the character device.""" + if NIX: + return super(Keyboard, self)._get_data(read_size) + return self._pipe.recv_bytes() + + +class Mouse(InputDevice): + """A mouse or other pointing-like device.""" + + def _set_device_path(self): + super(Mouse, self)._set_device_path() + if MAC: + self._device_path = APPKIT_MOUSE_PATH + + def _set_name(self): + super(Mouse, self)._set_name() + if WIN: + self.name = "Microsoft Mouse" + elif MAC: + self.name = "AppKit Mouse" + + @staticmethod + def _get_target_function(): + """Get the correct target function.""" + if WIN: + return mouse_process + if MAC: + return appkit_mouse_process + return None + + def _get_data(self, read_size): + """Get data from the character device.""" + if NIX: + return super(Mouse, self)._get_data(read_size) + return self._pipe.recv_bytes() + + +class MightyMouse(Mouse): + """A mouse or other pointing device on the Mac.""" + + def _set_device_path(self): + super(MightyMouse, self)._set_device_path() + if MAC: + self._device_path = QUARTZ_MOUSE_PATH + + def _set_name(self): + self.name = "Quartz Mouse" + + @staticmethod + def _get_target_function(): + """Get the correct target function.""" + return quartz_mouse_process + + +def delay_and_stop(duration, dll, device_number): + """Stop vibration aka force feedback aka rumble on + Windows after duration miliseconds.""" + xinput = getattr(ctypes.windll, dll) + time.sleep(duration / 1000) + xinput_set_state = xinput.XInputSetState + xinput_set_state.argtypes = [ctypes.c_uint, ctypes.POINTER(XinputVibration)] + xinput_set_state.restype = ctypes.c_uint + vibration = XinputVibration(0, 0) + xinput_set_state(device_number, ctypes.byref(vibration)) + + +# I made this GamePad class before Mouse and Keyboard above, and have +# learned a lot about Windows in the process. This can probably be +# simplified massively and made to match Mouse and Keyboard more. + + +class GamePad(InputDevice): + """A gamepad or other joystick-like device.""" + + def __init__(self, manager, device_path, char_path_override=None): + super(GamePad, self).__init__(manager, device_path, char_path_override) + self._write_file = None + self.__device_number = None + if WIN and "Microsoft_Corporation_Controller" in self._device_path: + self.name = "Microsoft X-Box 360 pad" + identifier = self._get_path_infomation()[1] + self.__device_number = int(identifier.split('_')[-1]) + self.__received_packets = 0 + self.__missed_packets = 0 + self.__last_state = self.__read_device() + if NIX: + self._number_xpad() + + def _number_xpad(self): + """Get the number of the joystick.""" + js_path = self._device_path.replace('-event', '') + js_chardev = os.path.realpath(js_path) + try: + number_text = js_chardev.split('js')[1] + except IndexError: + return + try: + number = int(number_text) + except ValueError: + return + self.__device_number = number + + def get_number(self): + """Return the joystick number of the gamepad.""" + return self.__device_number + + def __iter__(self): + while True: + if WIN: + self.__check_state() + event = self._do_iter() + if event: + yield event + + def __check_state(self): + """On Windows, check the state and fill the event character device.""" + state = self.__read_device() + if not state: + raise UnpluggedError("Gamepad %d is not connected" % self.__device_number) + if state.packet_number != self.__last_state.packet_number: + # state has changed, handle the change + self.__handle_changed_state(state) + self.__last_state = state + + @staticmethod + def __get_timeval(): + """Get the time and make it into C style timeval.""" + return convert_timeval(time.time()) + + def create_event_object(self, event_type, code, value, timeval=None): + """Create an evdev style object.""" + if not timeval: + timeval = self.__get_timeval() + try: + event_code = self.manager.codes['type_codes'][event_type] + except KeyError: + raise UnknownEventType( + "We don't know what kind of event a %s is." % event_type + ) + event = struct.pack( + EVENT_FORMAT, timeval[0], timeval[1], event_code, code, value + ) + return event + + def __write_to_character_device(self, event_list, timeval=None): + """Emulate the Linux character device on other platforms such as + Windows.""" + # Remember the position of the stream + pos = self._character_device.tell() + # Go to the end of the stream + self._character_device.seek(0, 2) + # Write the new data to the end + for event in event_list: + self._character_device.write(event) + # Add a sync marker + sync = self.create_event_object("Sync", 0, 0, timeval) + self._character_device.write(sync) + # Put the stream back to its original position + self._character_device.seek(pos) + + def __handle_changed_state(self, state): + """ + we need to pack a struct with the following five numbers: + tv_sec, tv_usec, ev_type, code, value + + then write it using __write_to_character_device + + seconds, mircroseconds, ev_type, code, value + time we just use now + ev_type we look up + code we look up + value is 0 or 1 for the buttons + axis value is maybe the same as Linux? Hope so! + """ + timeval = self.__get_timeval() + events = self.__get_button_events(state, timeval) + events.extend(self.__get_axis_events(state, timeval)) + if events: + self.__write_to_character_device(events, timeval) + + def __map_button(self, button): + """Get the linux xpad code from the Windows xinput code.""" + _, start_code, start_value = button + value = start_value + ev_type = "Key" + code = self.manager.codes['xpad'][start_code] + if 1 <= start_code <= 4: + ev_type = "Absolute" + if (start_code == 1 and start_value == 1) or ( + start_code == 3 and start_value == 1 + ): + value = -1 + return code, value, ev_type + + def __map_axis(self, axis): + """Get the linux xpad code from the Windows xinput code.""" + start_code, start_value = axis + value = start_value + code = self.manager.codes['xpad'][start_code] + return code, value + + def __get_button_events(self, state, timeval=None): + """Get the button events from xinput.""" + changed_buttons = self.__detect_button_events(state) + events = self.__emulate_buttons(changed_buttons, timeval) + return events + + def __get_axis_events(self, state, timeval=None): + """Get the stick events from xinput.""" + axis_changes = self.__detect_axis_events(state) + events = self.__emulate_axis(axis_changes, timeval) + return events + + def __emulate_axis(self, axis_changes, timeval=None): + """Make the axis events use the Linux style format.""" + events = [] + for axis in axis_changes: + code, value = self.__map_axis(axis) + event = self.create_event_object("Absolute", code, value, timeval=timeval) + events.append(event) + return events + + def __emulate_buttons(self, changed_buttons, timeval=None): + """Make the button events use the Linux style format.""" + events = [] + for button in changed_buttons: + code, value, ev_type = self.__map_button(button) + event = self.create_event_object(ev_type, code, value, timeval=timeval) + events.append(event) + return events + + @staticmethod + def __gen_bit_values(number): + """ + Return a zero or one for each bit of a numeric value up to the most + significant 1 bit, beginning with the least significant bit. + """ + number = int(number) + while number: + yield number & 0x1 + number >>= 1 + + def __get_bit_values(self, number, size=32): + """Get bit values as a list for a given number + + >>> get_bit_values(1) == [0]*31 + [1] + True + + >>> get_bit_values(0xDEADBEEF) + [1L, 1L, 0L, 1L, 1L, 1L, 1L, + 0L, 1L, 0L, 1L, 0L, 1L, 1L, 0L, 1L, 1L, 0L, 1L, 1L, 1L, 1L, + 1L, 0L, 1L, 1L, 1L, 0L, 1L, 1L, 1L, 1L] + + You may override the default word size of 32-bits to match your actual + application. + >>> get_bit_values(0x3, 2) + [1L, 1L] + + >>> get_bit_values(0x3, 4) + [0L, 0L, 1L, 1L] + + """ + res = list(self.__gen_bit_values(number)) + res.reverse() + # 0-pad the most significant bit + res = [0] * (size - len(res)) + res + return res + + def __detect_button_events(self, state): + changed = state.gamepad.buttons ^ self.__last_state.gamepad.buttons + changed = self.__get_bit_values(changed, 16) + buttons_state = self.__get_bit_values(state.gamepad.buttons, 16) + changed.reverse() + buttons_state.reverse() + button_numbers = count(1) + changed_buttons = list( + filter(itemgetter(0), list(zip(changed, button_numbers, buttons_state))) + ) + # returns for example [(1,15,1)] type, code, value? + return changed_buttons + + def __detect_axis_events(self, state): + # axis fields are everything but the buttons + # pylint: disable=protected-access + # Attribute name _fields_ is special name set by ctypes + axis_fields = dict(XinputGamepad._fields_) + axis_fields.pop('buttons') + changed_axes = [] + + # Ax_type might be useful when we support high-level deadzone + # methods. + # pylint: disable=unused-variable + for axis, _ in list(axis_fields.items()): + old_val = getattr(self.__last_state.gamepad, axis) + new_val = getattr(state.gamepad, axis) + if old_val != new_val: + changed_axes.append((axis, new_val)) + return changed_axes + + def __read_device(self): + """Read the state of the gamepad.""" + state = XinputState() + res = self.manager.xinput.XInputGetState( + self.__device_number, ctypes.byref(state) + ) + if res == XINPUT_ERROR_SUCCESS: + return state + if res != XINPUT_ERROR_DEVICE_NOT_CONNECTED: + raise RuntimeError( + "Unknown error %d attempting to get state of device %d" + % (res, self.__device_number) + ) + # else (device is not connected) + return None + + @property + def _write_device(self): + if not self._write_file: + if not NIX: + return None + try: + self._write_file = io.open(self._character_device_path, 'wb') + except PermissionError: + # Python 3 + raise PermissionError(PERMISSIONS_ERROR_TEXT) + except IOError as err: + # Python 2 + if err.errno == 13: + raise PermissionError(PERMISSIONS_ERROR_TEXT) + else: + raise + + return self._write_file + + def _start_vibration_win(self, left_motor, right_motor): + """Start the vibration, which will run until stopped.""" + xinput_set_state = self.manager.xinput.XInputSetState + xinput_set_state.argtypes = [ctypes.c_uint, ctypes.POINTER(XinputVibration)] + xinput_set_state.restype = ctypes.c_uint + vibration = XinputVibration(int(left_motor * 65535), int(right_motor * 65535)) + xinput_set_state(self.__device_number, ctypes.byref(vibration)) + + def _stop_vibration_win(self): + """Stop the vibration.""" + xinput_set_state = self.manager.xinput.XInputSetState + xinput_set_state.argtypes = [ctypes.c_uint, ctypes.POINTER(XinputVibration)] + xinput_set_state.restype = ctypes.c_uint + stop_vibration = ctypes.byref(XinputVibration(0, 0)) + xinput_set_state(self.__device_number, stop_vibration) + + def _set_vibration_win(self, left_motor, right_motor, duration): + """Control the motors on Windows.""" + self._start_vibration_win(left_motor, right_motor) + stop_process = Process( + target=delay_and_stop, + args=(duration, self.manager.xinput_dll, self.__device_number), + ) + stop_process.start() + + def __get_vibration_code(self, left_motor, right_motor, duration): + """This is some crazy voodoo, if you can simplify it, please do.""" + inner_event = struct.pack( + '2h6x2h2x2H28x', + 0x50, + -1, + duration, + 0, + int(left_motor * 65535), + int(right_motor * 65535), + ) + buf_conts = ioctl(self._write_device, 1076905344, inner_event) + return int(codecs.encode(buf_conts[1:3], 'hex'), 16) + + def _set_vibration_nix(self, left_motor, right_motor, duration): + """Control the motors on Linux. + Duration is in miliseconds.""" + code = self.__get_vibration_code(left_motor, right_motor, duration) + secs, msecs = convert_timeval(time.time()) + outer_event = struct.pack(EVENT_FORMAT, secs, msecs, 0x15, code, 1) + self._write_device.write(outer_event) + self._write_device.flush() + + def set_vibration(self, left_motor, right_motor, duration): + """Control the speed of both motors seperately or together. + left_motor and right_motor arguments require a number between + 0 (off) and 1 (full). + duration is miliseconds, e.g. 1000 for a second.""" + if WIN: + self._set_vibration_win(left_motor, right_motor, duration) + elif NIX: + self._set_vibration_nix(left_motor, right_motor, duration) + else: + raise NotImplementedError + + +class OtherDevice(InputDevice): + """A device of which its is type is either undetectable or has not + been implemented yet. + """ + + pass + + +class LED: + """A light source.""" + + def __init__(self, manager, path, name): + self.manager = manager + self.path = path + self.name = name + self._write_file = None + self._character_device_path = None + self._post_init() + + def _post_init(self): + """Post init setup.""" + pass + + def __str__(self): + return self.name + + def __repr__(self): + return '%s.%s("%s")' % (self.__module__, self.__class__.__name__, self.path) + + def status(self): + """Get the device status, i.e. the brightness level.""" + status_filename = os.path.join(self.path, 'brightness') + with open(status_filename) as status_fp: + result = status_fp.read() + status_text = result.strip() + try: + status = int(status_text) + except ValueError: + return status_text + return status + + def max_brightness(self): + """Get the device's maximum brightness level.""" + status_filename = os.path.join(self.path, 'max_brightness') + with open(status_filename) as status_fp: + result = status_fp.read() + status_text = result.strip() + try: + status = int(status_text) + except ValueError: + return status_text + return status + + @property + def _write_device(self): + """The output device.""" + if not self._write_file: + if not NIX: + return None + try: + self._write_file = io.open(self._character_device_path, 'wb') + except PermissionError: + # Python 3 + raise PermissionError(PERMISSIONS_ERROR_TEXT) + except IOError as err: + # Python 2 only + if err.errno == 13: # pragma: no cover + raise PermissionError(PERMISSIONS_ERROR_TEXT) + else: + raise + + return self._write_file + + def _make_event(self, event_type, code, value): + """Make a new event and send it to the character device.""" + secs, msecs = convert_timeval(time.time()) + data = struct.pack(EVENT_FORMAT, secs, msecs, event_type, code, value) + self._write_device.write(data) + self._write_device.flush() + + +class SystemLED(LED): + """An LED on your system e.g. caps lock.""" + + def __init__(self, manager, path, name): + self.code = None + self.device_path = None + self.device = None + super(SystemLED, self).__init__(manager, path, name) + + def _post_init(self): + """Set up the device path and type code.""" + self._led_type_code = self.manager.get_typecode('LED') + self.device_path = os.path.realpath(os.path.join(self.path, 'device')) + if '::' in self.name: + chardev, code_name = self.name.split('::') + if code_name in self.manager.codes['LED_type_codes']: + self.code = self.manager.codes['LED_type_codes'][code_name] + try: + event_number = chardev.split('input')[1] + except IndexError: + print("Failed with", self.name) + raise + else: + self._character_device_path = '/dev/input/event' + event_number + self._match_device() + + def on(self): # pylint: disable=invalid-name + """Turn the light on.""" + self._make_event(1) + + def off(self): + """Turn the light off.""" + self._make_event(0) + + def _make_event(self, value): # pylint: disable=arguments-differ + """Make a new event and send it to the character device.""" + super(SystemLED, self)._make_event(self._led_type_code, self.code, value) + + def _match_device(self): + """If the LED is connected to an input device, + associate the objects.""" + for device in self.manager.all_devices: + if device.get_char_device_path() == self._character_device_path: + self.device = device + device.leds.append(self) + break + + +class GamepadLED(LED): + """A light source on a gamepad.""" + + def __init__(self, manager, path, name): + self.code = None + self.device = None + self.gamepad = None + super(GamepadLED, self).__init__(manager, path, name) + + def _post_init(self): + self._match_device() + self._character_device_path = ( + self.gamepad.get_char_device_path() if self.gamepad else None + ) + + def _match_device(self): + number = int(self.name.split('xpad')[1]) + for gamepad in self.manager.gamepads: + if number == gamepad.get_number(): + self.gamepad = gamepad + gamepad.leds.append(self) + break + + +class RawInputDeviceList(ctypes.Structure): + """ + Contains information about a raw input device. + + For full details see Microsoft's documentation: + + http://msdn.microsoft.com/en-us/library/windows/desktop/ + ms645568(v=vs.85).aspx + """ + + # pylint: disable=too-few-public-methods + _fields_ = [("hDevice", HANDLE), ("dwType", DWORD)] + + +class DeviceManager: + """Provides access to all connected and detectible user input + devices.""" + + # pylint: disable=too-many-instance-attributes + + def __init__(self): + self.codes = {key: dict(value) for key, value in EVENT_MAP} + self._raw = [] + self.keyboards = [] + self.mice = [] + self.gamepads = [] + self.other_devices = [] + self.all_devices = [] + self.leds = [] + self.microbits = [] + self.xinput = None + self.xinput_dll = None + if WIN: + self._raw_device_counts = { + 'mice': 0, + 'keyboards': 0, + 'otherhid': 0, + 'unknown': 0, + } + self._post_init() + + def _post_init(self): + """Call the find devices method for the relevant platform.""" + if WIN: + self._find_devices_win() + elif MAC: + self._find_devices_mac() + else: + self._find_devices() + self._update_all_devices() + if NIX: + self._find_leds() + + def _update_all_devices(self): + """Update the all_devices list.""" + self.all_devices = [] + self.all_devices.extend(self.keyboards) + self.all_devices.extend(self.mice) + self.all_devices.extend(self.gamepads) + self.all_devices.extend(self.other_devices) + + def _parse_device_path(self, device_path, char_path_override=None): + """Parse each device and add to the approriate list.""" + + # 1. Make sure that we can parse the device path. + try: + device_type = device_path.rsplit('-', 1)[1] + except IndexError: + warn( + "The following device path was skipped as it could " + "not be parsed: %s" % device_path, + RuntimeWarning, + ) + return + + # 2. Make sure each device is only added once. + realpath = os.path.realpath(device_path) + if realpath in self._raw: + return + self._raw.append(realpath) + + # 3. All seems good, append the device to the relevant list. + if device_type == 'kbd': + self.keyboards.append(Keyboard(self, device_path, char_path_override)) + elif device_type == 'mouse': + self.mice.append(Mouse(self, device_path, char_path_override)) + elif device_type == 'joystick': + self.gamepads.append(GamePad(self, device_path, char_path_override)) + else: + self.other_devices.append( + OtherDevice(self, device_path, char_path_override) + ) + + def _find_xinput(self): + """Find most recent xinput library.""" + for dll in XINPUT_DLL_NAMES: + try: + self.xinput = getattr(ctypes.windll, dll) + except OSError: + pass + else: + # We found an xinput driver + self.xinput_dll = dll + break + else: + # We didn't find an xinput library + warn("No xinput driver dll found, gamepads not supported.", RuntimeWarning) + + def _find_devices_win(self): + """Find devices on Windows.""" + self._find_xinput() + self._detect_gamepads() + self._count_devices() + if self._raw_device_counts['keyboards'] > 0: + self.keyboards.append( + Keyboard(self, "/dev/input/by-id/usb-A_Nice_Keyboard-event-kbd") + ) + + if self._raw_device_counts['mice'] > 0: + self.mice.append( + Mouse( + self, "/dev/input/by-id/usb-A_Nice_Mouse_called_Arthur-event-mouse" + ) + ) + + def _find_devices_mac(self): + """Find devices on Mac.""" + self.keyboards.append(Keyboard(self)) + self.mice.append(MightyMouse(self)) + self.mice.append(Mouse(self)) + + def _detect_gamepads(self): + """Find gamepads.""" + state = XinputState() + # Windows allows up to 4 gamepads. + for device_number in range(4): + res = self.xinput.XInputGetState(device_number, ctypes.byref(state)) + if res == XINPUT_ERROR_SUCCESS: + # We found a gamepad + device_path = ( + "/dev/input/by_id/" + + "usb-Microsoft_Corporation_Controller_%s-event-joystick" + % device_number + ) + self.gamepads.append(GamePad(self, device_path)) + continue + if res != XINPUT_ERROR_DEVICE_NOT_CONNECTED: + raise RuntimeError( + "Unknown error %d attempting to get state of device %d" + % (res, device_number) + ) + + def _count_devices(self): + """See what Windows' GetRawInputDeviceList wants to tell us. + + For now, we are just seeing if there is at least one keyboard + and/or mouse attached. + + GetRawInputDeviceList could be used to help distinguish between + different keyboards and mice on the system in the way Linux + can. However, Roma uno die non est condita. + + """ + number_of_devices = ctypes.c_uint() + + if ( + ctypes.windll.user32.GetRawInputDeviceList( + ctypes.POINTER(ctypes.c_int)(), + ctypes.byref(number_of_devices), + ctypes.sizeof(RawInputDeviceList), + ) + == -1 + ): + warn( + "Call to GetRawInputDeviceList was unsuccessful." + "We have no idea if a mouse or keyboard is attached.", + RuntimeWarning, + ) + return + + devices_found = (RawInputDeviceList * number_of_devices.value)() + + if ( + ctypes.windll.user32.GetRawInputDeviceList( + devices_found, + ctypes.byref(number_of_devices), + ctypes.sizeof(RawInputDeviceList), + ) + == -1 + ): + warn( + "Call to GetRawInputDeviceList was unsuccessful." + "We have no idea if a mouse or keyboard is attached.", + RuntimeWarning, + ) + return + + for device in devices_found: + if device.dwType == 0: + self._raw_device_counts['mice'] += 1 + elif device.dwType == 1: + self._raw_device_counts['keyboards'] += 1 + elif device.dwType == 2: + self._raw_device_counts['otherhid'] += 1 + else: + self._raw_device_counts['unknown'] += 1 + + def _find_devices(self): + """Find available devices.""" + self._find_by('id') + self._find_by('path') + self._find_special() + + def _find_by(self, key): + """Find devices.""" + by_path = glob.glob('/dev/input/by-{key}/*-event-*'.format(key=key)) + for device_path in by_path: + self._parse_device_path(device_path) + + def _find_leds(self): + """Find LED devices, Linux-only so far.""" + for path in glob.glob('/sys/class/leds/*'): + self._parse_led_path(path) + + def _parse_led_path(self, path): + name = path.rsplit('/', 1)[1] + if name.startswith('xpad'): + self.leds.append(GamepadLED(self, path, name)) + elif name.startswith('input'): + self.leds.append(SystemLED(self, path, name)) + else: + self.leds.append(LED(self, path, name)) + + def _get_char_names(self): + """Get a list of already found devices.""" + return [device.get_char_name() for device in self.all_devices] + + def _find_special(self): + """Look for special devices.""" + charnames = self._get_char_names() + for eventdir in glob.glob('/sys/class/input/event*'): + char_name = os.path.split(eventdir)[1] + if char_name in charnames: + continue + name_file = os.path.join(eventdir, 'device', 'name') + with open(name_file) as name_file: + device_name = name_file.read().strip() + if device_name in self.codes['specials']: + self._parse_device_path( + self.codes['specials'][device_name], + os.path.join('/dev/input', char_name), + ) + + def __iter__(self): + return iter(self.all_devices) + + def __getitem__(self, index): + try: + return self.all_devices[index] + except IndexError: + raise IndexError("list index out of range") + + def get_event_type(self, raw_type): + """Convert the code to a useful string name.""" + try: + return self.codes['types'][raw_type] + except KeyError: + raise UnknownEventType("We don't know this event type") + + def get_event_string(self, evtype, code): + """Get the string name of the event.""" + if WIN and evtype == 'Key': + # If we can map the code to a common one then do it + try: + code = self.codes['wincodes'][code] + except KeyError: + pass + try: + return self.codes[evtype][code] + except KeyError: + raise UnknownEventCode("We don't know this event.", evtype, code) + + def get_typecode(self, name): + """Returns type code for `name`.""" + return self.codes['type_codes'].get(name) + + def detect_microbit(self): + """Detect a microbit.""" + try: + gpad = MicroBitPad(self) + except ModuleNotFoundError: + warn( + "The microbit library could not be found in the pythonpath. \n" + "For more information, please visit \n" + "https://inputs.readthedocs.io/en/latest/user/microbit.html", + RuntimeWarning, + ) + else: + self.microbits.append(gpad) + self.gamepads.append(gpad) + + +SPIN_UP_MOTOR = ( + '00000', + '00001', + '00011', + '00111', + '01111', + '11111', + '01111', + '00011', + '00001', + '00000', + '00001', + '00011', + '00111', + '01111', + '11111', + '00000', + '11111', + '00000', + '11111', + '00000', +) + + +class MicroBitPad(GamePad): + """A BBC Micro:bit flashed with bitio.""" + + def __init__(self, manager, device_path=None, char_path_override=None): + if not device_path: + device_path = '/dev/input/by-id/dialup-BBC_MicroBit-event-joystick' + if not char_path_override: + char_path_override = '/dev/input/microbit0' + + super(MicroBitPad, self).__init__(manager, device_path, char_path_override) + + # pylint: disable=no-member,import-error + import microbit + + self.microbit = microbit + self.default_image = microbit.Image("00500:00500:00500:00500:00500") + self._setup_rumble() + self.set_display() + + def set_display(self, index=None): + """Show an image on the display.""" + # pylint: disable=no-member + if index: + image = self.microbit.Image.STD_IMAGES[index] + else: + image = self.default_image + self.microbit.display.show(image) + + def _setup_rumble(self): + """Setup the three animations which simulate a rumble.""" + self.left_rumble = self._get_ready_to('99500') + self.right_rumble = self._get_ready_to('00599') + self.double_rumble = self._get_ready_to('99599') + + def _set_name(self): + self.name = "BBC microbit Gamepad" + + def _set_evdev_state(self): + self._evdev = False + + @staticmethod + def _get_target_function(): + return microbit_process + + def _get_data(self, read_size): + """Get data from the character device.""" + return self._pipe.recv_bytes() + + def _get_ready_to(self, rumble): + """Watch us wreck the mike! + PSYCHE!""" + # pylint: disable=no-member + return [ + self.microbit.Image( + ':'.join([rumble if char == '1' else '00500' for char in code]) + ) + for code in SPIN_UP_MOTOR + ] + + def _full_speed_rumble(self, images, duration): + """Simulate the motors running at full.""" + while duration > 0: + self.microbit.display.show(images[0]) # pylint: disable=no-member + time.sleep(0.04) + self.microbit.display.show(images[1]) # pylint: disable=no-member + time.sleep(0.04) + duration -= 0.08 + + def _spin_up(self, images, duration): + """Simulate the motors getting warmed up.""" + total = 0 + # pylint: disable=no-member + + for image in images: + self.microbit.display.show(image) + time.sleep(0.05) + total += 0.05 + if total >= duration: + return + remaining = duration - total + self._full_speed_rumble(images[-2:], remaining) + self.set_display() + + def set_vibration(self, left_motor, right_motor, duration): + """Control the speed of both motors seperately or together. + left_motor and right_motor arguments require a number: + 0 (off) or 1 (full). + duration is miliseconds, e.g. 1000 for a second.""" + if left_motor and right_motor: + return self._spin_up(self.double_rumble, duration / 1000) + if left_motor: + return self._spin_up(self.left_rumble, duration / 1000) + if right_motor: + return self._spin_up(self.right_rumble, duration / 1000) + return -1 + + +def microbit_process(pipe): + """Simple subprocess for reading mouse events on the microbit.""" + gamepad_listener = MicroBitListener(pipe) + gamepad_listener.listen() + + +class MicroBitListener(BaseListener): + """Tracks the current state and sends changes to the MicroBitPad + device class.""" + + def __init__(self, pipe): + super(MicroBitListener, self).__init__(pipe) + self.active = True + self.events = [] + self.state = { + ('Absolute', 0x10, 0), + ('Absolute', 0x11, 0), + ('Key', 0x130, 0), + ('Key', 0x131, 0), + ('Key', 0x13A, 0), + ('Key', 0x133, 0), + ('Key', 0x134, 0), + } + self.dpad = True + self.sensitivity = 300 + # pylint: disable=import-error + import microbit + + self.microbit = microbit + + def listen(self): + """Listen while the device is active.""" + while self.active: + self.handle_input() + + def uninstall_handle_input(self): + """Stop listing when active is false.""" + self.active = False + + def handle_new_events(self, events): + """Add each new events to the event queue.""" + for event in events: + self.events.append( + self.create_event_object(event[0], event[1], int(event[2])) + ) + + def handle_abs(self): + """Gets the state as the raw abolute numbers.""" + # pylint: disable=no-member + x_raw = self.microbit.accelerometer.get_x() + y_raw = self.microbit.accelerometer.get_y() + x_abs = ('Absolute', 0x00, x_raw) + y_abs = ('Absolute', 0x01, y_raw) + return x_abs, y_abs + + def handle_dpad(self): + """Gets the state of the virtual dpad.""" + # pylint: disable=no-member + x_raw = self.microbit.accelerometer.get_x() + y_raw = self.microbit.accelerometer.get_y() + minus_sens = self.sensitivity * -1 + if x_raw < minus_sens: + x_state = ('Absolute', 0x10, -1) + elif x_raw > self.sensitivity: + x_state = ('Absolute', 0x10, 1) + else: + x_state = ('Absolute', 0x10, 0) + + if y_raw < minus_sens: + y_state = ('Absolute', 0x11, -1) + elif y_raw > self.sensitivity: + y_state = ('Absolute', 0x11, 1) + else: + y_state = ('Absolute', 0x11, 1) + + return x_state, y_state + + def check_state(self): + """Tracks differences in the device state.""" + if self.dpad: + x_state, y_state = self.handle_dpad() + else: + x_state, y_state = self.handle_abs() + + new_state = { + x_state, + y_state, + ('Key', 0x130, int(self.microbit.button_a.is_pressed())), + ('Key', 0x131, int(self.microbit.button_b.is_pressed())), + ('Key', 0x13A, int(self.microbit.pin0.is_touched())), + ('Key', 0x133, int(self.microbit.pin1.is_touched())), + ('Key', 0x134, int(self.microbit.pin2.is_touched())), + } + events = new_state - self.state + self.state = new_state + return events + + def handle_input(self): + """Sends differences in the device state to the MicroBitPad + as events.""" + difference = self.check_state() + if not difference: + return + self.events = [] + self.handle_new_events(difference) + self.update_timeval() + self.events.append(self.sync_marker(self.timeval)) + self.write_to_pipe(self.events) + + +devices = DeviceManager() # pylint: disable=invalid-name + + +def get_key(): + """Get a single keypress from a keyboard.""" + try: + keyboard = devices.keyboards[0] + except IndexError: + raise UnpluggedError("No keyboard found.") + return keyboard.read() + + +def get_mouse(): + """Get a single movement or click from a mouse.""" + try: + mouse = devices.mice[0] + except IndexError: + raise UnpluggedError("No mice found.") + return mouse.read() + + +def get_gamepad(): + """Get a single action from a gamepad.""" + try: + gamepad = devices.gamepads[0] + except IndexError: + raise UnpluggedError("No gamepad found.") + return gamepad.read() diff --git a/platypush/plugins/joystick/_manager.py b/platypush/plugins/joystick/_manager.py new file mode 100644 index 00000000..9c97cd70 --- /dev/null +++ b/platypush/plugins/joystick/_manager.py @@ -0,0 +1,175 @@ +from dataclasses import asdict +import multiprocessing +from threading import Timer +from typing import Optional, Type +from time import time + +from platypush.context import get_bus +from platypush.message.event.joystick import ( + JoystickConnectedEvent, + JoystickDisconnectedEvent, + JoystickEvent, + JoystickStateEvent, +) +from platypush.schemas.joystick import JoystickDeviceSchema + +from ._inputs import GamePad, InputEvent, UnpluggedError +from ._state import ConnectedState, JoystickDeviceState, JoystickState + + +class JoystickManager(multiprocessing.Process): + """ + A process that monitors and publishes joystick events. + """ + + MAX_TRIG_VAL = 1 << 8 + MAX_JOY_VAL = 1 << 15 + THROTTLE_INTERVAL = 0.2 + + def __init__( + self, + device: GamePad, + poll_interval: Optional[float], + state_queue: multiprocessing.Queue, + ): + super().__init__() + self.device = device + self.poll_interval = poll_interval + self.state = JoystickState() + self._state_queue = state_queue + self._connected_state = ConnectedState.UNKNOWN + self._stop_evt = multiprocessing.Event() + self._state_throttler: Optional[Timer] = None + self._state_timestamp: float = 0 + + @property + def should_stop(self): + return self._stop_evt.is_set() + + def wait_stop(self, timeout: Optional[float] = None): + self._stop_evt.wait(timeout=timeout or self.poll_interval) + + def _enqueue_state(self): + now = time() + self._state_queue.put_nowait( + JoystickDeviceState( + device=self.device.get_char_device_path(), state=self.state + ) + ) + + if now - self._state_timestamp >= self.THROTTLE_INTERVAL: + self._post_state() + self._state_timestamp = now + return + + self._state_timestamp = now + self._state_throttler = Timer(self.THROTTLE_INTERVAL, self._post_state) + self._state_throttler.start() + + def _stop_state_throrttler(self): + if self._state_throttler: + self._state_throttler.cancel() + self._state_throttler = None + + def _post_state(self): + self._post_event(JoystickStateEvent, state=asdict(self.state)) + self._stop_state_throrttler() + + def _parse_event(self, event: InputEvent): # pylint: disable=too-many-branches + """ + Solution adapted from https://stackoverflow.com/questions/46506850. + """ + if event.code == "ABS_Y": + # normalize between -1 and 1 + self.state.left_joystick_y = event.state / self.MAX_JOY_VAL + elif event.code == "ABS_X": + # normalize between -1 and 1 + self.state.left_joystick_x = event.state / self.MAX_JOY_VAL + elif event.code == "ABS_RY": + # normalize between -1 and 1 + self.state.right_joystick_y = event.state / self.MAX_JOY_VAL + elif event.code == "ABS_RX": + # normalize between -1 and 1 + self.state.right_joystick_x = event.state / self.MAX_JOY_VAL + elif event.code == "ABS_Z": + # normalize between 0 and 1 + self.state.left_trigger = event.state / self.MAX_TRIG_VAL + elif event.code == "ABS_RZ": + # normalize between 0 and 1 + self.state.right_trigger = event.state / self.MAX_TRIG_VAL + elif event.code == "BTN_TL": + self.state.left_bumper = event.state + elif event.code == "BTN_TR": + self.state.right_bumper = event.state + elif event.code == "BTN_SOUTH": + self.state.a = event.state + elif event.code == "BTN_NORTH": + # previously switched with X + self.state.y = event.state + elif event.code == "BTN_WEST": + # previously switched with Y + self.state.x = event.state + elif event.code == "BTN_EAST": + self.state.b = event.state + elif event.code == "BTN_THUMBL": + self.state.left_thumb = event.state + elif event.code == "BTN_THUMBR": + self.state.right_thumb = event.state + elif event.code == "BTN_SELECT": + self.state.back = event.state + elif event.code == "BTN_START": + self.state.start = event.state + elif event.code == "BTN_TRIGGER_HAPPY1": + self.state.left_dir_pad = event.state + elif event.code == "BTN_TRIGGER_HAPPY2": + self.state.right_dir_pad = event.state + elif event.code == "BTN_TRIGGER_HAPPY3": + self.state.up_dir_pad = event.state + elif event.code == "BTN_TRIGGER_HAPPY4": + self.state.down_dir_pad = event.state + + def _post_event( + self, type: Type[JoystickEvent], **kwargs # pylint: disable=redefined-builtin + ): + get_bus().post( + type(device=dict(JoystickDeviceSchema().dump(self.device)), **kwargs) + ) + + def _on_connect(self): + if self._connected_state != ConnectedState.CONNECTED: + self._connected_state = ConnectedState.CONNECTED + self._post_event(JoystickConnectedEvent) + + def _on_disconnect(self): + if self._connected_state != ConnectedState.DISCONNECTED: + self._connected_state = ConnectedState.DISCONNECTED + self._post_event(JoystickDisconnectedEvent) + + def _loop(self): + try: + for event in self.device.read(): + self._on_connect() + prev_state = asdict(self.state) + self._parse_event(event) + new_state = asdict(self.state) + + if prev_state != new_state: + self._enqueue_state() + except (UnpluggedError, OSError): + self._on_disconnect() + finally: + self.wait_stop(self.poll_interval) + + def run(self): + try: + while not self.should_stop: + try: + self._loop() + except KeyboardInterrupt: + break + finally: + self._on_disconnect() + + def stop(self): + self._stop_evt.set() + self._stop_state_throrttler() diff --git a/platypush/plugins/joystick/_state.py b/platypush/plugins/joystick/_state.py new file mode 100644 index 00000000..0681307e --- /dev/null +++ b/platypush/plugins/joystick/_state.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from enum import Enum + + +class ConnectedState(Enum): + """ + Enum to represent the connection state of a joystick. + """ + + UNKNOWN = 0 + CONNECTED = 1 + DISCONNECTED = 2 + + +@dataclass +class JoystickState: + """ + Dataclass that holds the joystick state properties. + """ + + left_joystick_y: float = 0 + left_joystick_x: float = 0 + right_joystick_y: float = 0 + right_joystick_x: float = 0 + left_trigger: float = 0 + right_trigger: float = 0 + left_bumper: int = 0 + right_bumper: int = 0 + a: int = 0 + x: int = 0 + y: int = 0 + b: int = 0 + left_thumb: int = 0 + right_thumb: int = 0 + back: int = 0 + start: int = 0 + left_dir_pad: int = 0 + right_dir_pad: int = 0 + up_dir_pad: int = 0 + down_dir_pad: int = 0 + + +@dataclass +class JoystickDeviceState: + """ + Dataclass that holds the joystick device state properties. + """ + + device: str + state: JoystickState diff --git a/platypush/backend/joystick/manifest.yaml b/platypush/plugins/joystick/manifest.yaml similarity index 83% rename from platypush/backend/joystick/manifest.yaml rename to platypush/plugins/joystick/manifest.yaml index c3fb77fe..a5ce406c 100644 --- a/platypush/backend/joystick/manifest.yaml +++ b/platypush/plugins/joystick/manifest.yaml @@ -1,8 +1,6 @@ manifest: events: platypush.message.event.joystick.JoystickEvent: when a new joystick event is received - install: - pip: - - inputs + install: {} package: platypush.backend.joystick type: backend diff --git a/platypush/plugins/media/chromecast/__init__.py b/platypush/plugins/media/chromecast/__init__.py index 5c900760..63eeeff0 100644 --- a/platypush/plugins/media/chromecast/__init__.py +++ b/platypush/plugins/media/chromecast/__init__.py @@ -287,6 +287,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin): @action def stop(self, *_, chromecast: Optional[str] = None, **__): chromecast = chromecast or self.chromecast + if not chromecast: + return + cast = self.get_chromecast(chromecast) cast.media_controller.stop() cast.wait() diff --git a/platypush/schemas/joystick.py b/platypush/schemas/joystick.py new file mode 100644 index 00000000..4b0e4c81 --- /dev/null +++ b/platypush/schemas/joystick.py @@ -0,0 +1,199 @@ +from marshmallow import EXCLUDE, fields +from marshmallow.schema import Schema + + +class JoystickStateSchema(Schema): + """ + Joystick state schema. + """ + + left_joystick_y = fields.Float( + metadata={ + 'description': 'Left joystick Y axis value', + 'example': 0.5, + } + ) + + left_joystick_x = fields.Float( + metadata={ + 'description': 'Left joystick X axis value', + 'example': 0.5, + } + ) + + right_joystick_y = fields.Float( + metadata={ + 'description': 'Right joystick Y axis value', + 'example': 0.5, + } + ) + + right_joystick_x = fields.Float( + metadata={ + 'description': 'Right joystick X axis value', + 'example': 0.5, + } + ) + + left_trigger = fields.Float( + metadata={ + 'description': 'Left trigger value', + 'example': 0.5, + } + ) + + right_trigger = fields.Float( + metadata={ + 'description': 'Right trigger value', + 'example': 0.5, + } + ) + + left_bumper = fields.Integer( + metadata={ + 'description': 'Left bumper state', + 'example': 1, + } + ) + + right_bumper = fields.Integer( + metadata={ + 'description': 'Right bumper state', + 'example': 1, + } + ) + + a = fields.Integer( + metadata={ + 'description': 'A button state', + 'example': 1, + } + ) + + x = fields.Integer( + metadata={ + 'description': 'X button state', + 'example': 1, + } + ) + + y = fields.Integer( + metadata={ + 'description': 'Y button state', + 'example': 1, + } + ) + + b = fields.Integer( + metadata={ + 'description': 'B button state', + 'example': 1, + } + ) + + left_thumb = fields.Integer( + metadata={ + 'description': 'Left thumb button state', + 'example': 1, + } + ) + + right_thumb = fields.Integer( + metadata={ + 'description': 'Right thumb button state', + 'example': 1, + } + ) + + back = fields.Integer( + metadata={ + 'description': 'Back button state', + 'example': 1, + } + ) + + start = fields.Integer( + metadata={ + 'description': 'Start button state', + 'example': 1, + } + ) + + left_dir_pad = fields.Integer( + metadata={ + 'description': 'Left direction pad button state', + 'example': 1, + } + ) + + right_dir_pad = fields.Integer( + metadata={ + 'description': 'Right direction pad button state', + 'example': 1, + } + ) + + up_dir_pad = fields.Integer( + metadata={ + 'description': 'Up direction pad button state', + 'example': 1, + } + ) + + down_dir_pad = fields.Integer( + metadata={ + 'description': 'Down direction pad button state', + 'example': 1, + } + ) + + +class JoystickDeviceSchema(Schema): + """ + Joystick device schema. + """ + + class Meta: + """ + Meta class. + """ + + unknown = EXCLUDE + + name = fields.String( + metadata={ + 'description': 'Joystick name', + 'example': 'Xbox 360 Controller', + } + ) + + path = fields.Function( + lambda obj: obj.get_char_device_path(), + metadata={ + 'description': 'Joystick character device path', + 'example': '/dev/input/event0', + }, + ) + + number = fields.Integer( + metadata={ + 'description': 'Joystick number', + 'example': 0, + } + ) + + protocol = fields.String( + metadata={ + 'description': 'Joystick protocol', + 'example': 'usb', + } + ) + + +class JoystickStatusSchema(Schema): + """ + Joystick status schema. + """ + + device = fields.Nested(JoystickDeviceSchema, required=True) + state = fields.Nested(JoystickStateSchema, required=True)