From 49e165acbd314cf131e0e37c2130c28242594175 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 17 Jan 2021 15:09:47 +0100 Subject: [PATCH] improved gamepad code, more tests --- keymapper/config.py | 1 + keymapper/dev/event_producer.py | 4 +- keymapper/dev/injector.py | 84 +++--- keymapper/getdevices.py | 53 ++-- tests/test.py | 1 - tests/testcases/test_getdevices.py | 40 +-- tests/testcases/test_injector.py | 414 ++++++++++++++++++++++------- tests/testcases/test_reader.py | 1 - 8 files changed, 419 insertions(+), 179 deletions(-) diff --git a/keymapper/config.py b/keymapper/config.py index d1ef5d1b..bf1b40b4 100644 --- a/keymapper/config.py +++ b/keymapper/config.py @@ -34,6 +34,7 @@ from keymapper.logger import logger MOUSE = 'mouse' WHEEL = 'wheel' BUTTONS = 'buttons' +NONE = 'none' INITIAL_CONFIG = { 'autoload': {}, diff --git a/keymapper/dev/event_producer.py b/keymapper/dev/event_producer.py index 81be7a34..55df38f5 100644 --- a/keymapper/dev/event_producer.py +++ b/keymapper/dev/event_producer.py @@ -47,7 +47,7 @@ def abs_max(value_1, value_2): class EventProducer: """Keeps producing events at 60hz if needed. - Can debounce writes or map joysticks to mouse movements. + Can debounce arbitrary functions. Maps joysticks to mouse movements. This class does not handle injecting macro stuff over time, that is done by the keycode_mapper. @@ -140,6 +140,8 @@ class EventProducer: This information is needed for abs -> rel mapping. """ if device is None: + # I don't think this ever happened + logger.error('Expected device to not be None') return max_abs = utils.get_max_abs(device) diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 5d7a8027..5f40d4ae 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -32,13 +32,14 @@ import evdev from evdev.ecodes import EV_KEY, EV_REL from keymapper.logger import logger -from keymapper.getdevices import get_devices, map_abs_to_rel +from keymapper.getdevices import get_devices, is_gamepad from keymapper.dev.keycode_mapper import handle_keycode from keymapper.dev import utils from keymapper.dev.event_producer import EventProducer from keymapper.dev.macros import parse, is_this_a_macro from keymapper.state import system_mapping from keymapper.mapping import DISABLE_CODE +from keymapper.config import NONE, MOUSE, WHEEL DEV_NAME = 'key-mapper' @@ -58,9 +59,6 @@ STOPPED = 5 NO_GRAB = 6 -# TODO joystick unmodified? forward abs_rel and abs_rel in capabilities - - def is_numlock_on(): """Get the current state of the numlock.""" try: @@ -150,13 +148,35 @@ class Injector: mapping : Mapping """ self.device = device + self.mapping = mapping + self._process = None self._msg_pipe = multiprocessing.Pipe() self._key_to_code = self._map_keys_to_codes() self._state = UNKNOWN self._event_producer = None + def _forwards_joystick(self): + """If at least one of the joysticks remains a regular joystick.""" + left_purpose = self.mapping.get('gamepad.joystick.left_purpose') + right_purpose = self.mapping.get('gamepad.joystick.right_purpose') + return NONE in (left_purpose, right_purpose) + + def _maps_joystick(self): + """If at least one of the joysticks will serve a special purpose.""" + left_purpose = self.mapping.get('gamepad.joystick.left_purpose') + right_purpose = self.mapping.get('gamepad.joystick.right_purpose') + return (left_purpose, right_purpose) != (NONE, NONE) + + def _joystick_as_mouse(self): + """If at least one joystick maps to an EV_REL capability.""" + purposes = ( + self.mapping.get('gamepad.joystick.left_purpose'), + self.mapping.get('gamepad.joystick.right_purpose') + ) + return MOUSE in purposes or WHEEL in purposes + def _map_keys_to_codes(self): """To quickly get target keycodes during operation. @@ -219,17 +239,13 @@ class Injector: return self._state - def _prepare_device(self, path): - """Try to grab the device, return if not needed/possible. - - Also return if ABS events are changed to REL mouse movements, - because the capabilities of the returned device are changed - so this cannot be checked later anymore. - """ + def _grab_device(self, path): + """Try to grab the device, return None if not needed/possible.""" try: device = evdev.InputDevice(path) except FileNotFoundError: - return None, False + logger.error('Could not find "%s"', path) + return None capabilities = device.capabilities(absinfo=False) @@ -239,16 +255,16 @@ class Injector: needed = True break - abs_to_rel = map_abs_to_rel(capabilities) + gamepad = is_gamepad(device) - if abs_to_rel: + if gamepad and self._maps_joystick(): needed = True if not needed: # skipping reading and checking on events from those devices # may be beneficial for performance. logger.debug('No need to grab %s', path) - return None, False + return None attempts = 0 while True: @@ -270,13 +286,13 @@ class Injector: if attempts >= 4: logger.error('Cannot grab %s, it is possibly in use', path) logger.error(str(error)) - return None, False + return None time.sleep(self.regrab_timeout) - return device, abs_to_rel + return device - def _modify_capabilities(self, macros, input_device, abs_to_rel): + def _modify_capabilities(self, macros, input_device, gamepad): """Adds all used keycodes into a copy of a devices capabilities. Sometimes capabilities are a bit tricky and change how the system @@ -287,7 +303,7 @@ class Injector: macros : dict mapping of int to _Macro input_device : evdev.InputDevice - abs_to_rel : bool + gamepad : bool if ABS capabilities should be removed in favor of REL """ ecodes = evdev.ecodes @@ -311,7 +327,7 @@ class Injector: for macro in macros.values(): capabilities[EV_KEY] += list(macro.get_capabilities()) - if abs_to_rel: + if gamepad and self._joystick_as_mouse(): # REL_WHEEL was also required to recognize the gamepad # as mouse, even if no joystick is used as wheel. capabilities[EV_REL] = [ @@ -334,15 +350,18 @@ class Injector: del capabilities[ecodes.EV_SYN] if ecodes.EV_FF in capabilities: del capabilities[ecodes.EV_FF] - if ecodes.EV_ABS in capabilities: - # EV_KEY events are ignoerd by the os when EV_ABS capabilities - # are present + if gamepad and not self._forwards_joystick(): + # key input to text inputs and such only works without ABS + # events in the capabilities, possibly due to some intentional + # constraints in wayland/X. So if the joysticks are not used + # as joysticks remove ABS. del capabilities[ecodes.EV_ABS] return capabilities - async def _msg_listener(self, loop): + async def _msg_listener(self): """Wait for messages from the main process to do special stuff.""" + loop = asyncio.get_event_loop() while True: frame_available = asyncio.Event() loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set) @@ -374,7 +393,6 @@ class Injector: numlock_state = is_numlock_on() - loop = asyncio.get_event_loop() coroutines = [] logger.info('Starting injecting the mapping for "%s"', self.device) @@ -385,7 +403,7 @@ class Injector: # Watch over each one of the potentially multiple devices per hardware for path in paths: - source, abs_to_rel = self._prepare_device(path) + source = self._grab_device(path) if source is None: # this path doesn't need to be grabbed for injection, because # it doesn't provide the events needed to execute the mapping @@ -409,10 +427,11 @@ class Injector: # certain capabilities can have side effects apparently. with an # EV_ABS capability, EV_REL won't move the mouse pointer anymore. # so don't merge all InputDevices into one UInput device. + gamepad = is_gamepad(source) uinput = evdev.UInput( name=f'{DEV_NAME} {self.device}', phys=DEV_NAME, - events=self._modify_capabilities(macros, source, abs_to_rel) + events=self._modify_capabilities(macros, source, gamepad) ) logger.spam( @@ -429,16 +448,12 @@ class Injector: macro.set_handler(handler) # actual reading of events - coroutines.append(self._event_consumer( - macros, - source, - uinput - )) + coroutines.append(self._event_consumer(macros, source, uinput)) # The event source of the current iteration will deliver events # that are needed for this. It is that one that will be mapped # to a mouse-like devnode. - if abs_to_rel: + if gamepad and self._joystick_as_mouse(): self._event_producer.set_max_abs_from(source) self._event_producer.set_mouse_uinput(uinput) @@ -447,7 +462,7 @@ class Injector: self._msg_pipe[0].send(NO_GRAB) return - coroutines.append(self._msg_listener(loop)) + coroutines.append(self._msg_listener()) # run besides this stuff coroutines.append(self._event_producer.run()) @@ -536,6 +551,7 @@ class Injector: continue # forward the rest + # TODO triggers should retain their original value if not mapped uinput.write(event.type, event.code, event.value) # this already includes SYN events, so need to syn here again diff --git a/keymapper/getdevices.py b/keymapper/getdevices.py index 651916d8..6650f1af 100644 --- a/keymapper/getdevices.py +++ b/keymapper/getdevices.py @@ -28,7 +28,7 @@ import time import asyncio import evdev -from evdev.ecodes import EV_KEY, EV_ABS +from evdev.ecodes import EV_KEY, EV_ABS, KEY_CAMERA from keymapper.logger import logger @@ -45,13 +45,14 @@ if not hasattr(evdev.InputDevice, 'path'): evdev.InputDevice.path = path -def map_abs_to_rel(capabilities): - """Check if joystick movements can and should be mapped.""" - # mapping buttons only works without ABS events in the capabilities, - # possibly due to some intentional constraints in the os. So always - # just map those events to REL if possible and remove ABS from - # the capabilities, because ABS events prevent regular button - # mappings from working, +def is_gamepad(device): + """Check if joystick movements are available for mapping. + + Parameters + ---------- + device : InputDevice + """ + capabilities = device.capabilities(absinfo=False) abs_capabilities = capabilities.get(EV_ABS) if abs_capabilities is not None: if evdev.ecodes.ABS_MT_TRACKING_ID in abs_capabilities: @@ -107,14 +108,20 @@ class _GetDevices(threading.Thread): if device.name == 'Power Button': continue - # only keyboard devices + gamepad = is_gamepad(device) + # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) - if EV_KEY not in capabilities and EV_ABS not in capabilities: - # or gamepads, because they can be mapped like a keyboard + + key_capa = capabilities.get(EV_KEY) + + if key_capa is None and not gamepad: + # skip devices that don't provide buttons that can be mapped continue - is_gamepad = map_abs_to_rel(capabilities) + if len(key_capa) == 1 and key_capa[0] == KEY_CAMERA: + # skip cameras + continue name = device.name path = device.path @@ -129,31 +136,31 @@ class _GetDevices(threading.Thread): if grouped.get(info) is None: grouped[info] = [] - logger.spam('Found "%s", "%s", "%s"', info, path, name) + logger.spam( + 'Found "%s", "%s", "%s" %s', + info, path, name, '(gamepad)' if gamepad else '' + ) - grouped[info].append((name, path, is_gamepad)) + grouped[info].append((name, path, gamepad)) # now write down all the paths of that group result = {} for group in grouped.values(): names = [entry[0] for entry in group] devs = [entry[1] for entry in group] - is_gamepad = True in [entry[2] for entry in group] + gamepad = True in [entry[2] for entry in group] shortest_name = sorted(names, key=len)[0] result[shortest_name] = { 'paths': devs, 'devices': names, - 'gamepad': is_gamepad + 'gamepad': gamepad } self.pipe.send(result) def refresh_devices(): - """Get new devices, e.g. new ones created by key-mapper. - - This should be called whenever devices in /dev are added or removed. - """ + """This can be called to discover new devices.""" # it may take a little bit of time until devices are visible after # changes time.sleep(0.1) @@ -185,11 +192,7 @@ def get_devices(include_keymapper=False): # block until devices are available _devices = pipe[0].recv() if len(_devices) == 0: - logger.error( - 'Did not find any device. If you added yourself to the ' - 'needed groups (see `ls -l /dev/input`) already, make sure ' - 'you also logged out and back in.' - ) + logger.error('Did not find any input device') else: names = [f'"{name}"' for name in _devices] logger.info('Found %s', ', '.join(names)) diff --git a/tests/test.py b/tests/test.py index 906f791b..e671b8bc 100644 --- a/tests/test.py +++ b/tests/test.py @@ -327,7 +327,6 @@ class UInput: self.device = InputDevice('justdoit') self.name = name self.events = events - pass def capabilities(self, *args, **kwargs): return self.events diff --git a/tests/testcases/test_getdevices.py b/tests/testcases/test_getdevices.py index 52184f53..2f6e4507 100644 --- a/tests/testcases/test_getdevices.py +++ b/tests/testcases/test_getdevices.py @@ -23,7 +23,7 @@ import unittest import evdev -from keymapper.getdevices import _GetDevices, get_devices, map_abs_to_rel +from keymapper.getdevices import _GetDevices, get_devices, is_gamepad class FakePipe: @@ -96,35 +96,43 @@ class TestGetDevices(unittest.TestCase): }, }) - def test_map_abs_to_rel(self): + def test_is_gamepad(self): # properly detects if the device is a gamepad EV_ABS = evdev.ecodes.EV_ABS EV_KEY = evdev.ecodes.EV_KEY - self.assertTrue(map_abs_to_rel({ + class FakeDevice: + def __init__(self, capabilities): + self.c = capabilities + + def capabilities(self, absinfo): + assert not absinfo + return self.c + + self.assertTrue(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X] - })) - self.assertTrue(map_abs_to_rel({ + }))) + self.assertTrue(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_RY], EV_KEY: [evdev.ecodes.KEY_A] - })) - self.assertFalse(map_abs_to_rel({ + }))) + self.assertFalse(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_MT_TRACKING_ID] - })) - self.assertFalse(map_abs_to_rel({ + }))) + self.assertFalse(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_HAT2X] - })) - self.assertFalse(map_abs_to_rel({ + }))) + self.assertFalse(is_gamepad(FakeDevice({ EV_KEY: [evdev.ecodes.ABS_X] # intentionally ABS_X (0) on EV_KEY - })) - self.assertFalse(map_abs_to_rel({ + }))) + self.assertFalse(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_TOOL_BRUSH] - })) - self.assertFalse(map_abs_to_rel({ + }))) + self.assertFalse(is_gamepad(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_STYLUS] - })) + }))) if __name__ == "__main__": diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index c65b3601..ed4b6995 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -25,18 +25,18 @@ import copy import evdev from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_LEFT, KEY_A, \ - REL_X, REL_Y, REL_WHEEL, REL_HWHEEL + REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, BTN_A, ABS_X, ABS_Y from keymapper.dev.injector import is_numlock_on, set_numlock, \ ensure_numlock, Injector, is_in_capabilities, \ STARTING, RUNNING, STOPPED, NO_GRAB, UNKNOWN from keymapper.state import custom_mapping, system_mapping from keymapper.mapping import Mapping, DISABLE_CODE, DISABLE_NAME -from keymapper.config import config +from keymapper.config import config, NONE, MOUSE, WHEEL, BUTTONS from keymapper.key import Key from keymapper.dev.macros import parse from keymapper.dev import utils -from keymapper.getdevices import get_devices +from keymapper.getdevices import get_devices, is_gamepad from tests.test import new_event, pending_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ @@ -76,71 +76,6 @@ class TestInjector(unittest.TestCase): cleanup() - def test_modify_capabilities(self): - class FakeDevice: - def capabilities(self, absinfo=True): - assert absinfo is False - return { - evdev.ecodes.EV_SYN: [1, 2, 3], - evdev.ecodes.EV_FF: [1, 2, 3], - evdev.ecodes.EV_ABS: [1, 2, 3] - } - - mapping = Mapping() - mapping.change(Key(EV_KEY, 80, 1), 'a') - mapping.change(Key(EV_KEY, 81, 1), DISABLE_NAME) - - macro_code = 'r(2, m(sHiFt_l, r(2, k(1).k(2))))' - macro = parse(macro_code, mapping) - - mapping.change(Key(EV_KEY, 60, 111), macro_code) - - # going to be ignored, because EV_REL cannot be mapped, that's - # mouse movements. - mapping.change(Key(EV_REL, 1234, 3), 'b') - - a = system_mapping.get('a') - shift_l = system_mapping.get('ShIfT_L') - one = system_mapping.get(1) - two = system_mapping.get('2') - btn_left = system_mapping.get('BtN_lEfT') - - self.injector = Injector('foo', mapping) - fake_device = FakeDevice() - capabilities_1 = self.injector._modify_capabilities( - {60: macro}, - fake_device, - abs_to_rel=False - ) - - self.assertIn(EV_KEY, capabilities_1) - keys = capabilities_1[EV_KEY] - self.assertIn(a, keys) - self.assertIn(one, keys) - self.assertIn(two, keys) - self.assertIn(shift_l, keys) - self.assertNotIn(DISABLE_CODE, keys) - # abs_to_rel is false, so mouse capabilities are not needed - self.assertNotIn(btn_left, keys) - - self.assertNotIn(evdev.ecodes.EV_SYN, capabilities_1) - self.assertNotIn(evdev.ecodes.EV_FF, capabilities_1) - self.assertNotIn(evdev.ecodes.EV_REL, capabilities_1) - self.assertNotIn(evdev.ecodes.EV_ABS, capabilities_1) - - # abs_to_rel makes sure that BTN_LEFT is present - capabilities_2 = self.injector._modify_capabilities( - {60: macro}, - fake_device, - abs_to_rel=True - ) - keys = capabilities_2[EV_KEY] - self.assertIn(a, keys) - self.assertIn(one, keys) - self.assertIn(two, keys) - self.assertIn(shift_l, keys) - self.assertIn(btn_left, keys) - def test_grab(self): # path is from the fixtures custom_mapping.change(Key(EV_KEY, 10, 1), 'a') @@ -148,8 +83,9 @@ class TestInjector(unittest.TestCase): self.injector = Injector('device 1', custom_mapping) path = '/dev/input/event10' # this test needs to pass around all other constraints of - # _prepare_device - device, abs_to_rel = self.injector._prepare_device(path) + # _grab_device + device = self.injector._grab_device(path) + abs_to_rel = is_gamepad(device) self.assertFalse(abs_to_rel) self.assertEqual(self.failed, 2) # success on the third try @@ -161,10 +97,9 @@ class TestInjector(unittest.TestCase): self.injector = Injector('device 1', custom_mapping) path = '/dev/input/event10' - device, abs_to_rel = self.injector._prepare_device(path) - self.assertFalse(abs_to_rel) - self.assertGreaterEqual(self.failed, 1) + device = self.injector._grab_device(path) self.assertIsNone(device) + self.assertGreaterEqual(self.failed, 1) self.assertEqual(self.injector.get_state(), UNKNOWN) self.injector.start_injecting() @@ -175,27 +110,29 @@ class TestInjector(unittest.TestCase): self.assertFalse(self.injector._process.is_alive()) self.assertEqual(self.injector.get_state(), NO_GRAB) - def test_prepare_device_1(self): + def test_grab_device_1(self): # according to the fixtures, /dev/input/event30 can do ABS_HAT0X custom_mapping.change(Key(EV_ABS, ABS_HAT0X, 1), 'a') self.injector = Injector('foobar', custom_mapping) - _prepare_device = self.injector._prepare_device - self.assertIsNone(_prepare_device('/dev/input/event10')[0]) - self.assertIsNotNone(_prepare_device('/dev/input/event30')[0]) + _grab_device = self.injector._grab_device + self.assertIsNone(_grab_device('/dev/input/event10')) + self.assertIsNotNone(_grab_device('/dev/input/event30')) - def test_prepare_device_non_existing(self): + def test_grab_device_non_existing(self): custom_mapping.change(Key(EV_ABS, ABS_HAT0X, 1), 'a') self.injector = Injector('foobar', custom_mapping) - _prepare_device = self.injector._prepare_device - self.assertIsNone(_prepare_device('/dev/input/event1234')[0]) + _grab_device = self.injector._grab_device + self.assertIsNone(_grab_device('/dev/input/event1234')) def test_gamepad_capabilities(self): self.injector = Injector('gamepad', custom_mapping) path = '/dev/input/event30' - device, abs_to_rel = self.injector._prepare_device(path) + device = self.injector._grab_device(path) + abs_to_rel = is_gamepad(device) + self.assertIsNotNone(device) self.assertTrue(abs_to_rel) capabilities = self.injector._modify_capabilities( @@ -214,6 +151,65 @@ class TestInjector(unittest.TestCase): self.assertIn(EV_KEY, capabilities) self.assertIn(evdev.ecodes.BTN_LEFT, capabilities[EV_KEY]) + def test_gamepad_purpose_none(self): + # forward abs joystick events + custom_mapping.set('gamepad.joystick.left_purpose', NONE) + config.set('gamepad.joystick.right_purpose', NONE) + + self.injector = Injector('gamepad', custom_mapping) + + path = '/dev/input/event30' + device = self.injector._grab_device(path) + self.assertIsNone(device) # no capability is used, so it won't grab + + custom_mapping.change(Key(EV_KEY, BTN_A, 1), 'a') + device = self.injector._grab_device(path) + self.assertIsNotNone(device) + gamepad = is_gamepad(device) + self.assertTrue(gamepad) + capabilities = self.injector._modify_capabilities( + {}, + device, + gamepad + ) + self.assertIn(evdev.ecodes.EV_ABS, capabilities) + + def test_gamepad_purpose_none_2(self): + # forward abs joystick events for the left joystick only + custom_mapping.set('gamepad.joystick.left_purpose', NONE) + config.set('gamepad.joystick.right_purpose', MOUSE) + + self.injector = Injector('gamepad', custom_mapping) + + path = '/dev/input/event30' + device = self.injector._grab_device(path) + # the right joystick maps as mouse, so it is grabbed + # even with an empty mapping + self.assertIsNotNone(device) + gamepad = is_gamepad(device) + self.assertTrue(gamepad) + capabilities = self.injector._modify_capabilities( + {}, + device, + gamepad + ) + self.assertIn(evdev.ecodes.EV_ABS, capabilities) + self.assertIn(evdev.ecodes.EV_REL, capabilities) + + custom_mapping.change(Key(EV_KEY, BTN_A, 1), 'a') + device = self.injector._grab_device(path) + gamepad = is_gamepad(device) + self.assertIsNotNone(device) + self.assertTrue(gamepad) + capabilities = self.injector._modify_capabilities( + {}, + device, + gamepad + ) + self.assertIn(evdev.ecodes.EV_ABS, capabilities) + self.assertIn(evdev.ecodes.EV_REL, capabilities) + self.assertIn(evdev.ecodes.EV_KEY, capabilities) + def test_adds_ev_key(self): # for some reason, having any EV_KEY capability is needed to # be able to control the mouse. it probably wants the mouse click. @@ -228,10 +224,11 @@ class TestInjector(unittest.TestCase): 'capabilities': gamepad_template['capabilities'] } del fixtures[path]['capabilities'][EV_KEY] - device, abs_to_rel = self.injector._prepare_device(path) + device = self.injector._grab_device(path) + gamepad = is_gamepad(device) self.assertNotIn(EV_KEY, device.capabilities()) capabilities = self.injector._modify_capabilities( - {}, device, abs_to_rel + {}, device, gamepad ) self.assertIn(EV_KEY, capabilities) self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY]) @@ -246,9 +243,10 @@ class TestInjector(unittest.TestCase): } fixtures[path]['capabilities'][EV_KEY].append(BTN_LEFT) fixtures[path]['capabilities'][EV_KEY].append(KEY_A) - device, abs_to_rel = self.injector._prepare_device(path) + device = self.injector._grab_device(path) + gamepad = is_gamepad(device) capabilities = self.injector._modify_capabilities( - {}, device, abs_to_rel + {}, device, gamepad ) self.assertIn(EV_KEY, capabilities) self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY]) @@ -257,11 +255,12 @@ class TestInjector(unittest.TestCase): """gamepad with existing key capabilities, but not btn_mouse""" path = '/dev/input/event30' - device, abs_to_rel = self.injector._prepare_device(path) + device = self.injector._grab_device(path) + gamepad = is_gamepad(device) self.assertIn(EV_KEY, device.capabilities()) self.assertNotIn(evdev.ecodes.BTN_MOUSE, device.capabilities()[EV_KEY]) capabilities = self.injector._modify_capabilities( - {}, device, abs_to_rel + {}, device, gamepad ) self.assertIn(EV_KEY, capabilities) self.assertGreater(len(capabilities), 1) @@ -272,16 +271,15 @@ class TestInjector(unittest.TestCase): custom_mapping.change(Key(EV_KEY, 10, 1), 'a') self.injector = Injector('device 1', custom_mapping) path = '/dev/input/event11' - device, abs_to_rel = self.injector._prepare_device(path) - self.assertFalse(abs_to_rel) - self.assertEqual(self.failed, 0) + device = self.injector._grab_device(path) self.assertIsNone(device) + self.assertEqual(self.failed, 0) def test_skip_unknown_device(self): # skips a device because its capabilities are not used in the mapping self.injector = Injector('device 1', custom_mapping) path = '/dev/input/event11' - device, _ = self.injector._prepare_device(path) + device = self.injector._grab_device(path) # make sure the test uses a fixture without interesting capabilities capabilities = evdev.InputDevice(path).capabilities() @@ -316,7 +314,7 @@ class TestInjector(unittest.TestCase): set_numlock(before) self.assertEqual(before, is_numlock_on()) - def test_abs_to_rel(self): + def test_gamepad_to_mouse(self): # maps gamepad joystick events to mouse events config.set('gamepad.joystick.non_linearity', 1) pointer_speed = 80 @@ -327,10 +325,10 @@ class TestInjector(unittest.TestCase): x = MAX_ABS / pointer_speed / divisor y = MAX_ABS / pointer_speed / divisor pending_events['gamepad'] = [ - new_event(EV_ABS, REL_X, x), - new_event(EV_ABS, REL_Y, y), - new_event(EV_ABS, REL_X, -x), - new_event(EV_ABS, REL_Y, -y), + new_event(EV_ABS, ABS_X, x), + new_event(EV_ABS, ABS_Y, y), + new_event(EV_ABS, ABS_X, -x), + new_event(EV_ABS, ABS_Y, -y), ] self.injector = Injector('gamepad', custom_mapping) @@ -344,10 +342,7 @@ class TestInjector(unittest.TestCase): time.sleep(sleep) # convert the write history to some easier to manage list - history = [] - while uinput_write_history_pipe[0].poll(): - event = uinput_write_history_pipe[0].recv() - history.append((event.type, event.code, event.value)) + history = read_write_history_pipe() if history[0][0] == EV_ABS: raise AssertionError( @@ -368,6 +363,72 @@ class TestInjector(unittest.TestCase): # only those two types of events were written self.assertEqual(len(history), count_x + count_y) + def test_gamepad_forward_joysticks(self): + pending_events['gamepad'] = [ + # should forward them unmodified + new_event(EV_ABS, ABS_X, 10), + new_event(EV_ABS, ABS_Y, 20), + new_event(EV_ABS, ABS_X, -30), + new_event(EV_ABS, ABS_Y, -40), + new_event(EV_KEY, BTN_A, 1), + new_event(EV_KEY, BTN_A, 0) + ] * 2 + + custom_mapping.set('gamepad.joystick.left_purpose', NONE) + custom_mapping.set('gamepad.joystick.right_purpose', NONE) + # BTN_A -> 77 + custom_mapping.change(Key((1, BTN_A, 1)), 'b') + system_mapping._set('b', 77) + self.injector = Injector('gamepad', custom_mapping) + self.injector.start_injecting() + + # wait for the injector to start sending, at most 1s + uinput_write_history_pipe[0].poll(1) + time.sleep(0.2) + + # convert the write history to some easier to manage list + history = read_write_history_pipe() + + self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 2) + self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 2) + self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 2) + self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 2) + self.assertEqual(history.count((EV_KEY, 77, 1)), 2) + self.assertEqual(history.count((EV_KEY, 77, 0)), 2) + + def test_gamepad_to_mouse_event_producer(self): + custom_mapping.set('gamepad.joystick.left_purpose', MOUSE) + custom_mapping.set('gamepad.joystick.right_purpose', NONE) + self.injector = Injector('gamepad', custom_mapping) + # the stop message will be available in the pipe right away, + # so _start_injecting won't block and just stop. all the stuff + # will be initialized though, so that stuff can be tested + self.injector.stop_injecting() + self.injector._start_injecting() + # not in a process, so the event_producer state can be checked + self.assertEqual(self.injector._event_producer.max_abs, MAX_ABS) + self.assertIsNotNone(self.injector._event_producer.mouse_uinput) + + def test_gamepad_to_buttons_event_producer(self): + custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) + custom_mapping.set('gamepad.joystick.right_purpose', BUTTONS) + self.injector = Injector('gamepad', custom_mapping) + self.injector.stop_injecting() + self.injector._start_injecting() + self.assertIsNone(self.injector._event_producer.max_abs, MAX_ABS) + self.assertIsNone(self.injector._event_producer.mouse_uinput) + + def test_device1_event_producer(self): + custom_mapping.set('gamepad.joystick.left_purpose', MOUSE) + custom_mapping.set('gamepad.joystick.right_purpose', WHEEL) + self.injector = Injector('device 1', custom_mapping) + self.injector.stop_injecting() + self.injector._start_injecting() + # not a gamepad, so _event_producer is not initialized for that. + # it can still debounce stuff though + self.assertIsNone(self.injector._event_producer.max_abs) + self.assertIsNone(self.injector._event_producer.mouse_uinput) + def test_injector(self): # the tests in test_keycode_mapper.py test this stuff in detail @@ -513,7 +574,7 @@ class TestInjector(unittest.TestCase): # the injector will otherwise skip the device because # the capabilities don't contain EV_TYPE input = InputDevice('/dev/input/event30') - self.injector._prepare_device = lambda *args: (input, False) + self.injector._grab_device = lambda *args: input self.injector.start_injecting() uinput_write_history_pipe[0].poll(timeout=1) @@ -692,5 +753,156 @@ class TestInjector(unittest.TestCase): self.assertTrue(is_in_capabilities(key, capabilities)) +class TestModifyCapabilities(unittest.TestCase): + def setUp(self): + class FakeDevice: + def capabilities(self, absinfo=True): + assert absinfo is False + return { + evdev.ecodes.EV_SYN: [1, 2, 3], + evdev.ecodes.EV_FF: [1, 2, 3], + evdev.ecodes.EV_ABS: [1, 2, 3] + } + + mapping = Mapping() + mapping.change(Key(EV_KEY, 80, 1), 'a') + mapping.change(Key(EV_KEY, 81, 1), DISABLE_NAME) + + macro_code = 'r(2, m(sHiFt_l, r(2, k(1).k(2))))' + macro = parse(macro_code, mapping) + + mapping.change(Key(EV_KEY, 60, 111), macro_code) + + # going to be ignored, because EV_REL cannot be mapped, that's + # mouse movements. + mapping.change(Key(EV_REL, 1234, 3), 'b') + + self.a = system_mapping.get('a') + self.shift_l = system_mapping.get('ShIfT_L') + self.one = system_mapping.get(1) + self.two = system_mapping.get('2') + self.left = system_mapping.get('BtN_lEfT') + self.fake_device = FakeDevice() + self.mapping = mapping + self.macro = macro + + def check_keys(self, capabilities): + """No matter the configuration, EV_KEY will be mapped to EV_KEY.""" + self.assertIn(EV_KEY, capabilities) + keys = capabilities[EV_KEY] + self.assertIn(self.a, keys) + self.assertIn(self.one, keys) + self.assertIn(self.two, keys) + self.assertIn(self.shift_l, keys) + self.assertNotIn(DISABLE_CODE, keys) + + def tearDown(self): + cleanup() + + def test_modify_capabilities(self): + self.injector = Injector('foo', self.mapping) + + capabilities = self.injector._modify_capabilities( + {60: self.macro}, + self.fake_device, + gamepad=False + ) + + self.assertIn(EV_ABS, capabilities) + self.check_keys(capabilities) + keys = capabilities[EV_KEY] + # mouse capabilities are not needed + self.assertNotIn(self.left, keys) + + self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) + self.assertNotIn(evdev.ecodes.EV_FF, capabilities) + self.assertNotIn(evdev.ecodes.EV_REL, capabilities) + + # keeps that stuff since modify_capabilities is told that it is not + # a gamepad, so it probably serves some special purpose for that + # device type. + self.assertIn(evdev.ecodes.EV_ABS, capabilities) + + def test_modify_capabilities_gamepad(self): + config.set('gamepad.joystick.left_purpose', MOUSE) + self.mapping.set('gamepad.joystick.right_purpose', WHEEL) + + self.injector = Injector('foo', self.mapping) + self.assertFalse(self.injector._forwards_joystick()) + self.assertTrue(self.injector._maps_joystick()) + self.assertTrue(self.injector._joystick_as_mouse()) + + capabilities = self.injector._modify_capabilities( + {60: self.macro}, + self.fake_device, + gamepad=True + ) + # because ABS is translated to REL, ABS is not a capability anymore + self.assertNotIn(EV_ABS, capabilities) + + self.check_keys(capabilities) + keys = capabilities[EV_KEY] + + # now that it is told that it is a gamepad, btn_left is inserted + # to ensure the operating system interprets it as mouse. + self.assertIn(self.left, keys) + + def test_modify_capabilities_gamepad_none_none(self): + config.set('gamepad.joystick.left_purpose', NONE) + self.mapping.set('gamepad.joystick.right_purpose', NONE) + + self.injector = Injector('foo', self.mapping) + self.assertTrue(self.injector._forwards_joystick()) + self.assertFalse(self.injector._maps_joystick()) + self.assertFalse(self.injector._joystick_as_mouse()) + + capabilities = self.injector._modify_capabilities( + {60: self.macro}, + self.fake_device, + gamepad=True + ) + + self.check_keys(capabilities) + self.assertIn(EV_ABS, capabilities) + + def test_modify_capabilities_gamepad_buttons_buttons(self): + config.set('gamepad.joystick.left_purpose', BUTTONS) + self.mapping.set('gamepad.joystick.right_purpose', BUTTONS) + + self.injector = Injector('foo', self.mapping) + self.assertFalse(self.injector._forwards_joystick()) + self.assertTrue(self.injector._maps_joystick()) + self.assertFalse(self.injector._joystick_as_mouse()) + + capabilities = self.injector._modify_capabilities( + {60: self.macro}, + self.fake_device, + gamepad=True + ) + + self.check_keys(capabilities) + self.assertNotIn(EV_ABS, capabilities) + self.assertNotIn(EV_REL, capabilities) + + def test_modify_capabilities_buttons_buttons(self): + # those settings shouldn't have an effect with gamepad=False + config.set('gamepad.joystick.left_purpose', BUTTONS) + self.mapping.set('gamepad.joystick.right_purpose', BUTTONS) + + self.injector = Injector('foo', self.mapping) + + capabilities = self.injector._modify_capabilities( + {60: self.macro}, + self.fake_device, + gamepad=False + ) + + self.check_keys(capabilities) + # not a gamepad, keeps EV_ABS because it probably has some special + # purpose + self.assertIn(EV_ABS, capabilities) + self.assertNotIn(EV_REL, capabilities) + + if __name__ == "__main__": unittest.main() diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index 93e2f95d..09cdb95a 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -241,7 +241,6 @@ class TestReader(unittest.TestCase): pipe[1].send(new_event(EV_KEY, CODE_1, 0, 1004)) read = keycode_reader.read() - print(read) self.assertEqual(read, None) pipe[1].send(new_event(EV_ABS, ABS_Y, 0, 1007))