From 07cc8e1cc6507e7e0a534e72ed6f2d4744d57cfd Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Fri, 2 Apr 2021 12:16:34 +0200 Subject: [PATCH] #73 #74 #76 improved gamepad support --- keymapper/getdevices.py | 50 ++++++++----- keymapper/gui/helper.py | 12 ++-- keymapper/gui/reader.py | 4 +- keymapper/gui/window.py | 11 ++- keymapper/injection/context.py | 2 +- keymapper/injection/event_producer.py | 98 +++++++++++++++++--------- keymapper/injection/injector.py | 5 +- keymapper/injection/keycode_mapper.py | 4 +- keymapper/utils.py | 51 ++++++++++---- readme/development.md | 4 ++ tests/test.py | 30 +++++--- tests/testcases/test_dev_utils.py | 64 ++++++++++++++--- tests/testcases/test_event_producer.py | 78 ++++++++++++++------ tests/testcases/test_getdevices.py | 25 +++---- tests/testcases/test_injector.py | 9 +-- tests/testcases/test_integration.py | 4 +- tests/testcases/test_keycode_mapper.py | 6 +- 17 files changed, 312 insertions(+), 145 deletions(-) diff --git a/keymapper/getdevices.py b/keymapper/getdevices.py index fc0f2673..539e2c14 100644 --- a/keymapper/getdevices.py +++ b/keymapper/getdevices.py @@ -29,7 +29,7 @@ import asyncio import evdev from evdev.ecodes import EV_KEY, EV_ABS, KEY_CAMERA, EV_REL, BTN_STYLUS, \ - BTN_A, ABS_MT_POSITION_X, REL_X, KEY_A, BTN_LEFT + BTN_A, ABS_MT_POSITION_X, REL_X, KEY_A, BTN_LEFT, REL_Y, REL_WHEEL from keymapper.logger import logger @@ -51,11 +51,6 @@ GRAPHICS_TABLET = 'graphics-tablet' CAMERA = 'camera' UNKNOWN = 'unknown' -# sort types that most devices would fall in easily to the right -PRIORITIES = [ - GRAPHICS_TABLET, TOUCHPAD, MOUSE, GAMEPAD, KEYBOARD, CAMERA, UNKNOWN -] - if not hasattr(evdev.InputDevice, 'path'): # for evdev < 1.0.0 patch the path property @@ -68,10 +63,17 @@ if not hasattr(evdev.InputDevice, 'path'): def _is_gamepad(capabilities): """Check if joystick movements are available for mapping.""" - if len(capabilities.get(EV_REL, [])) > 0: - return False - - if BTN_A not in capabilities.get(EV_KEY, []): + # A few buttons that indicate a gamepad + buttons = { + evdev.ecodes.BTN_BASE, + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_THUMB, + evdev.ecodes.BTN_TOP, + evdev.ecodes.BTN_DPAD_DOWN, + evdev.ecodes.BTN_GAMEPAD, + } + if not buttons.intersection(capabilities.get(EV_KEY, [])): + # no button is in the key capabilities return False # joysticks @@ -86,9 +88,20 @@ def _is_gamepad(capabilities): def _is_mouse(capabilities): """Check if the capabilities represent those of a mouse.""" + # Based on observation, those capabilities need to be present to get an + # UInput recognized as mouse + + # mouse movements if not REL_X in capabilities.get(EV_REL, []): return False + if not REL_Y in capabilities.get(EV_REL, []): + return False + + # at least the vertical mouse wheel + if not REL_WHEEL in capabilities.get(EV_REL, []): + return False + # and a mouse click button if not BTN_LEFT in capabilities.get(EV_KEY, []): return False @@ -138,12 +151,12 @@ def classify(device): if _is_touchpad(capabilities): return TOUCHPAD - if _is_mouse(capabilities): - return MOUSE - if _is_gamepad(capabilities): return GAMEPAD + if _is_mouse(capabilities): + return MOUSE + if _is_camera(capabilities): return CAMERA @@ -230,16 +243,15 @@ class _GetDevices(threading.Thread): names = [entry[0] for entry in group] devs = [entry[1] for entry in group] - # find the most specific type from all devices per group. - # e.g. a device with mouse and keyboard subdevices is a mouse. - types = sorted([entry[2] for entry in group], key=PRIORITIES.index) - device_type = types[0] - shortest_name = sorted(names, key=len)[0] result[shortest_name] = { 'paths': devs, 'devices': names, - 'type': device_type + # sort it alphabetically to be predictable in tests + 'types': sorted(list({ + item[2] for item in group + if item[2] != UNKNOWN + })) } self.pipe.send(result) diff --git a/keymapper/gui/helper.py b/keymapper/gui/helper.py index ec2eb387..701d2bef 100644 --- a/keymapper/gui/helper.py +++ b/keymapper/gui/helper.py @@ -34,7 +34,7 @@ import multiprocessing import subprocess import evdev -from evdev.ecodes import EV_KEY +from evdev.ecodes import EV_KEY, EV_ABS from keymapper.ipc.pipe import Pipe from keymapper.logger import logger @@ -159,7 +159,8 @@ class RootHelper: try: event = device.read_one() - self._send_event(event, device) + if event: + self._send_event(event, device) except OSError: logger.debug('Device "%s" disappeared', device.path) return @@ -188,8 +189,11 @@ class RootHelper: # which breaks the current workflow. return - max_abs = utils.get_max_abs(device) - event.value = utils.normalize_value(event, max_abs) + if event.type == EV_ABS: + abs_range = utils.get_abs_range(device, event.code) + event.value = utils.normalize_value(event, abs_range) + else: + event.value = utils.normalize_value(event) self._results.send({ 'type': 'event', diff --git a/keymapper/gui/reader.py b/keymapper/gui/reader.py index 75af4e7f..6e577f35 100644 --- a/keymapper/gui/reader.py +++ b/keymapper/gui/reader.py @@ -35,7 +35,7 @@ from keymapper.ipc.pipe import Pipe from keymapper.gui.helper import TERMINATE from keymapper import utils from keymapper.state import custom_mapping -from keymapper.getdevices import get_devices +from keymapper.getdevices import get_devices, GAMEPAD DEBOUNCE_TICKS = 3 @@ -133,7 +133,7 @@ class Reader: if event is None: continue - gamepad = get_devices()[self.device_name]['type'] == 'gamepad' + gamepad = GAMEPAD in get_devices()[self.device_name]['types'] if not utils.should_map_as_btn(event, custom_mapping, gamepad): continue diff --git a/keymapper/gui/window.py b/keymapper/gui/window.py index 6588039b..960814b5 100755 --- a/keymapper/gui/window.py +++ b/keymapper/gui/window.py @@ -70,6 +70,11 @@ ICON_NAMES = { UNKNOWN: None, } +# sort types that most devices would fall in easily to the right. +ICON_PRIORITIES = [ + GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN +] + def with_selected_device(func): """Decorate a function to only execute if a device is selected.""" @@ -251,7 +256,7 @@ class Window: def initialize_gamepad_config(self): """Set slider and dropdown values when a gamepad is selected.""" devices = get_devices() - if devices[self.selected_device]['type'] == 'gamepad': + if GAMEPAD in devices[self.selected_device]['types']: self.get('gamepad_separator').show() self.get('gamepad_config').show() else: @@ -342,7 +347,9 @@ class Window: with HandlerDisabled(device_selection, self.on_select_device): self.device_store.clear() for device in devices: - icon_name = ICON_NAMES[devices[device].get('type')] + types = devices[device]['types'] + significant_type = sorted(types, key=ICON_PRIORITIES.index)[0] + icon_name = ICON_NAMES[significant_type] self.device_store.append([icon_name, device]) self.select_newest_preset() diff --git a/keymapper/injection/context.py b/keymapper/injection/context.py index 83ba85cc..b599a840 100644 --- a/keymapper/injection/context.py +++ b/keymapper/injection/context.py @@ -124,7 +124,7 @@ class Context: target_code = system_mapping.get(output) if target_code is None: - logger.error('Don\'t know what %s is', output) + logger.error('Don\'t know what "%s" is', output) continue for permutation in key.get_permutations(): diff --git a/keymapper/injection/event_producer.py b/keymapper/injection/event_producer.py index 0b8ac28a..f5c9dcde 100644 --- a/keymapper/injection/event_producer.py +++ b/keymapper/injection/event_producer.py @@ -55,7 +55,7 @@ class EventProducer: """Construct the event producer without it doing anything yet.""" self.context = context - self.max_abs = None + self.abs_range = None # events only take ints, so a movement of 0.3 needs to add # up to 1.2 to affect the cursor, with 0.2 remaining self.pending_rel = {REL_X: 0, REL_Y: 0, REL_WHEEL: 0, REL_HWHEEL: 0} @@ -112,8 +112,8 @@ class EventProducer: self.pending_rel[code] -= output_value return output_value - def set_max_abs_from(self, device): - """Update the maximum value joysticks will report. + def set_abs_range_from(self, device): + """Update the min and max values joysticks will report. This information is needed for abs -> rel mapping. """ @@ -122,38 +122,72 @@ class EventProducer: logger.error('Expected device to not be None') return - max_abs = utils.get_max_abs(device) - if max_abs in [0, 1, None]: - # max_abs of joysticks is usually a much higher number + abs_range = utils.get_abs_range(device) + if abs_range is None: return - self.max_abs = max_abs - logger.debug('Max abs of "%s": %s', device.name, max_abs) + if abs_range[1] in [0, 1, None]: + # max abs_range of joysticks is usually a much higher number + return + + self.set_abs_range(*abs_range) + logger.debug('ABS range of "%s": %s', device.name, abs_range) + + def set_abs_range(self, min_abs, max_abs): + """Update the min and max values joysticks will report. + + This information is needed for abs -> rel mapping. + """ + self.abs_range = (min_abs, max_abs) + + # all joysticks in resting position by default + center = (self.abs_range[1] + self.abs_range[0]) / 2 + self.abs_state = { + ABS_X: center, + ABS_Y: center, + ABS_RX: center, + ABS_RY: center + } def get_abs_values(self): """Get the raw values for wheel and mouse movement. + Returned values center around 0 and are normalized into -1 and 1. + If two joysticks have the same purpose, the one that reports higher absolute values takes over the control. """ - mouse_x, mouse_y, wheel_x, wheel_y = 0, 0, 0, 0 + # center is the value of the resting position + center = (self.abs_range[1] + self.abs_range[0]) / 2 + # normalizer is the maximum possible value after centering + normalizer = (self.abs_range[1] - self.abs_range[0]) / 2 + + mouse_x = 0 + mouse_y = 0 + wheel_x = 0 + wheel_y = 0 + + def standardize(value): + return (value - center) / normalizer if self.context.left_purpose == MOUSE: - mouse_x = abs_max(mouse_x, self.abs_state[ABS_X]) - mouse_y = abs_max(mouse_y, self.abs_state[ABS_Y]) + mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_X])) + mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_Y])) if self.context.left_purpose == WHEEL: - wheel_x = abs_max(wheel_x, self.abs_state[ABS_X]) - wheel_y = abs_max(wheel_y, self.abs_state[ABS_Y]) + wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_X])) + wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_Y])) if self.context.right_purpose == MOUSE: - mouse_x = abs_max(mouse_x, self.abs_state[ABS_RX]) - mouse_y = abs_max(mouse_y, self.abs_state[ABS_RY]) + mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_RX])) + mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_RY])) if self.context.right_purpose == WHEEL: - wheel_x = abs_max(wheel_x, self.abs_state[ABS_RX]) - wheel_y = abs_max(wheel_y, self.abs_state[ABS_RY]) + wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_RX])) + wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_RY])) + # Some joysticks report from 0 to 255 (EMV101), + # others from -32768 to 32767 (X-Box 360 Pad) return mouse_x, mouse_y, wheel_x, wheel_y def is_handled(self, event): @@ -161,7 +195,7 @@ class EventProducer: if event.type != EV_ABS or event.code not in utils.JOYSTICK: return False - if self.max_abs is None: + if self.abs_range is None: return False purposes = [MOUSE, WHEEL] @@ -182,14 +216,15 @@ class EventProducer: Even if no new input event arrived because the joystick remained at its position, this will keep injecting the mouse movement events. """ - max_abs = self.max_abs + abs_range = self.abs_range mapping = self.context.mapping pointer_speed = mapping.get('gamepad.joystick.pointer_speed') non_linearity = mapping.get('gamepad.joystick.non_linearity') x_scroll_speed = mapping.get('gamepad.joystick.x_scroll_speed') y_scroll_speed = mapping.get('gamepad.joystick.y_scroll_speed') + max_speed = 2 ** 0.5 # for normalized abs event values - if max_abs is not None: + if abs_range is not None: logger.info( 'Left joystick as %s, right joystick as %s', self.context.left_purpose, @@ -217,20 +252,15 @@ class EventProducer: """mouse movement production""" - if max_abs is None: + if abs_range is None: # no ev_abs events will be mapped to ev_rel continue - max_speed = ((max_abs ** 2) * 2) ** 0.5 - abs_values = self.get_abs_values() - if len([val for val in abs_values if val > max_abs]) > 0: - logger.error( - 'Inconsistent values: %s, max_abs: %s', - abs_values, max_abs - ) - return + if len([val for val in abs_values if not (-1 <= val <= 1)]) > 0: + logger.error('Inconsistent values: %s', abs_values) + continue mouse_x, mouse_y, wheel_x, wheel_y = abs_values @@ -238,13 +268,13 @@ class EventProducer: if abs(mouse_x) > 0 or abs(mouse_y) > 0: if non_linearity != 1: # to make small movements smaller for more precision - speed = (mouse_x ** 2 + mouse_y ** 2) ** 0.5 + speed = (mouse_x ** 2 + mouse_y ** 2) ** 0.5 # pythagoras factor = (speed / max_speed) ** non_linearity else: factor = 1 - rel_x = (mouse_x / max_abs) * factor * pointer_speed - rel_y = (mouse_y / max_abs) * factor * pointer_speed + rel_x = mouse_x * factor * pointer_speed + rel_y = mouse_y * factor * pointer_speed rel_x = self.accumulate(REL_X, rel_x) rel_y = self.accumulate(REL_Y, rel_y) if rel_x != 0: @@ -254,13 +284,13 @@ class EventProducer: # wheel movements if abs(wheel_x) > 0: - change = wheel_x * x_scroll_speed / max_abs + change = wheel_x * x_scroll_speed value = self.accumulate(REL_WHEEL, change) if abs(change) > WHEEL_THRESHOLD * x_scroll_speed: self._write(EV_REL, REL_HWHEEL, value) if abs(wheel_y) > 0: - change = wheel_y * y_scroll_speed / max_abs + change = wheel_y * y_scroll_speed value = self.accumulate(REL_HWHEEL, change) if abs(change) > WHEEL_THRESHOLD * y_scroll_speed: self._write(EV_REL, REL_WHEEL, -value) diff --git a/keymapper/injection/injector.py b/keymapper/injection/injector.py index 086b215e..9f4076d9 100644 --- a/keymapper/injection/injector.py +++ b/keymapper/injection/injector.py @@ -338,10 +338,11 @@ class Injector(multiprocessing.Process): # where mapped events go to. # See the Context docstring on why this is needed. + is_gamepad = GAMEPAD in group['types'] self.context.uinput = evdev.UInput( name=self.get_udef_name(self.device, 'mapped'), phys=DEV_NAME, - events=self._construct_capabilities(group['type'] == 'gamepad') + events=self._construct_capabilities(is_gamepad) ) # Watch over each one of the potentially multiple devices per hardware @@ -369,7 +370,7 @@ class Injector(multiprocessing.Process): # that are needed for this. It is that one that will be mapped # to a mouse-like devnode. if gamepad and self.context.joystick_as_mouse(): - self._event_producer.set_max_abs_from(source) + self._event_producer.set_abs_range_from(source) if len(coroutines) == 0: logger.error('Did not grab any device') diff --git a/keymapper/injection/keycode_mapper.py b/keymapper/injection/keycode_mapper.py index 36c17396..1b4fba8f 100644 --- a/keymapper/injection/keycode_mapper.py +++ b/keymapper/injection/keycode_mapper.py @@ -216,7 +216,7 @@ class KeycodeMapper: where forwarded/unhandled events should be written to """ self.source = source - self.max_abs = utils.get_max_abs(source) + self.abs_range = utils.get_abs_range(source) self.context = context self.forward_to = forward_to @@ -337,7 +337,7 @@ class KeycodeMapper: # possible, because they might skip the 1 when pressed fast # enough. original_tuple = (event.type, event.code, event.value) - event.value = utils.normalize_value(event, self.max_abs) + event.value = utils.normalize_value(event, self.abs_range) # the tuple of the actual input event. Used to forward the event if # it is not mapped, and to index unreleased and active_macros. stays diff --git a/keymapper/utils.py b/keymapper/utils.py index 3fd8a79f..18d716dd 100644 --- a/keymapper/utils.py +++ b/keymapper/utils.py @@ -68,19 +68,27 @@ def sign(value): return 0 -def normalize_value(event, max_abs): +def normalize_value(event, abs_range=None): """Fit the event value to one of 0, 1 or -1.""" if event.type == EV_ABS and event.code in JOYSTICK: - if max_abs is None: + if abs_range is None: logger.error( 'Got %s, but max_abs is %s', - (event.type, event.code, event.value), max_abs + (event.type, event.code, event.value), abs_range ) return event.value - threshold = max_abs * JOYSTICK_BUTTON_THRESHOLD - triggered = abs(event.value) > threshold - return sign(event.value) if triggered else 0 + # center is the value of the resting position + center = (abs_range[1] + abs_range[0]) / 2 + # normalizer is the maximum possible value after centering + normalizer = (abs_range[1] - abs_range[0]) / 2 + + threshold = normalizer * JOYSTICK_BUTTON_THRESHOLD + triggered = abs(event.value - center) > threshold + return sign(event.value - center) if triggered else 0 + + # non-joystick abs events (triggers) usually start at 0 and go up to 255, + # but anything that is > 0 was safe to be treated as pressed so far return sign(event.value) @@ -157,8 +165,8 @@ def should_map_as_btn(event, mapping, gamepad): return False -def get_max_abs(device): - """Figure out the maximum value of EV_ABS events of that device. +def get_abs_range(device, code=ABS_X): + """Figure out the max and min value of EV_ABS events of that device. Like joystick movements or triggers. """ @@ -169,16 +177,31 @@ def get_max_abs(device): if EV_ABS not in capabilities: return None - absinfos = [ + absinfo = [ entry[1] for entry in capabilities[EV_ABS] - if isinstance(entry, tuple) and isinstance(entry[1], evdev.AbsInfo) + if ( + entry[0] == code + and isinstance(entry, tuple) + and isinstance(entry[1], evdev.AbsInfo) + ) ] - if len(absinfos) == 0: - logger.error('Failed to get max abs of "%s"') + if len(absinfo) == 0: + logger.error( + 'Failed to get ABS info of "%s" for key %d: %s', + device, code, capabilities + ) return None - max_abs = absinfos[0].max + absinfo = absinfo[0] + return absinfo.min, absinfo.max + - return max_abs +def get_max_abs(device, code=ABS_X): + """Figure out the max value of EV_ABS events of that device. + + Like joystick movements or triggers. + """ + abs_range = get_abs_range(device, code) + return abs_range and abs_range[1] diff --git a/readme/development.md b/readme/development.md index 08956dcd..f37f8a8f 100644 --- a/readme/development.md +++ b/readme/development.md @@ -43,6 +43,7 @@ file should give an overview about some internals of key-mapper. ```bash sudo pip install coverage pylint keymapper --extension-pkg-whitelist=evdev +sudo pkill -f key-mapper sudo pip install . && coverage run tests/test.py coverage combine && coverage report -m ``` @@ -56,6 +57,9 @@ Single tests can be executed via python3 tests/test.py test_paths.TestPaths.test_mkdir ``` +Don't use your computer during integration tests to avoid interacting +with the gui, which might make tests fail. + ## Releasing ssh/login into a debian/ubuntu environment diff --git a/tests/test.py b/tests/test.py index b0126837..e728a0e2 100644 --- a/tests/test.py +++ b/tests/test.py @@ -94,6 +94,8 @@ EVENT_READ_TIMEOUT = 0.01 # call to start_reading START_READING_DELAY = 0.05 +# for joysticks +MIN_ABS = -2 ** 15 MAX_ABS = 2 ** 15 @@ -375,7 +377,7 @@ class InputDevice: if absinfo and evdev.ecodes.EV_ABS in result: absinfo_obj = evdev.AbsInfo( - value=None, min=None, fuzz=None, flat=None, + value=None, min=MIN_ABS, fuzz=None, flat=None, resolution=None, max=MAX_ABS ) result[evdev.ecodes.EV_ABS] = [ @@ -517,9 +519,15 @@ def quick_cleanup(log=True): if log: print('quick cleanup') - for key in list(pending_events.keys()): - while pending_events[key][1].poll(): - pending_events[key][1].recv() + for device in list(pending_events.keys()): + try: + while pending_events[device][1].poll(): + pending_events[device][1].recv() + except EOFError: + # it broke, set up a new pipe + pending_events[device] = None + setup_pipe(device) + pass try: reader.terminate() @@ -547,10 +555,10 @@ def quick_cleanup(log=True): for name in list(uinputs.keys()): del uinputs[name] - for key in list(active_macros.keys()): - del active_macros[key] - for key in list(unreleased.keys()): - del unreleased[key] + for device in list(active_macros.keys()): + del active_macros[device] + for device in list(unreleased.keys()): + del unreleased[device] for path in list(fixtures.keys()): if path not in _fixture_copy: @@ -560,9 +568,9 @@ def quick_cleanup(log=True): fixtures[path] = _fixture_copy[path] os.environ.update(environ_copy) - for key in list(os.environ.keys()): - if key not in environ_copy: - del os.environ[key] + for device in list(os.environ.keys()): + if device not in environ_copy: + del os.environ[device] join_children() diff --git a/tests/testcases/test_dev_utils.py b/tests/testcases/test_dev_utils.py index 2af439fd..d0e9b7ec 100644 --- a/tests/testcases/test_dev_utils.py +++ b/tests/testcases/test_dev_utils.py @@ -29,13 +29,13 @@ from keymapper.config import config, BUTTONS from keymapper.mapping import Mapping from keymapper import utils -from tests.test import new_event, InputDevice, MAX_ABS +from tests.test import new_event, InputDevice, MAX_ABS, MIN_ABS class TestDevUtils(unittest.TestCase): def test_max_abs(self): - self.assertEqual(utils.get_max_abs(InputDevice('/dev/input/event30')), MAX_ABS) - self.assertIsNone(utils.get_max_abs(InputDevice('/dev/input/event10'))) + self.assertEqual(utils.get_abs_range(InputDevice('/dev/input/event30'))[1], MAX_ABS) + self.assertIsNone(utils.get_abs_range(InputDevice('/dev/input/event10'))) def test_will_report_key_up(self): self.assertFalse( @@ -127,22 +127,66 @@ class TestDevUtils(unittest.TestCase): self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MISC, -1))) def test_normalize_value(self): + """""" + + """0 to MAX_ABS""" + def do(event): - return utils.normalize_value(event, MAX_ABS) + return utils.normalize_value(event, (0, MAX_ABS)) event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(do(event), 1) - event = new_event(EV_ABS, ecodes.ABS_Y, -MAX_ABS) + event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS) + self.assertEqual(do(event), 1) + event = new_event(EV_ABS, ecodes.ABS_Y, 0) + self.assertEqual(do(event), -1) + event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4) self.assertEqual(do(event), -1) - event = new_event(EV_ABS, ecodes.ABS_X, -MAX_ABS // 4) + event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 2) self.assertEqual(do(event), 0) + + """MIN_ABS to MAX_ABS""" + + def do2(event): + return utils.normalize_value(event, (MIN_ABS, MAX_ABS)) + event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) - self.assertEqual(do(event), 1) + self.assertEqual(do2(event), 1) + event = new_event(EV_ABS, ecodes.ABS_Y, MIN_ABS) + self.assertEqual(do2(event), -1) + event = new_event(EV_ABS, ecodes.ABS_X, MIN_ABS // 4) + self.assertEqual(do2(event), 0) + event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) + self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS) - self.assertEqual(do(event), 1) + self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4) - self.assertEqual(do(event), 0) + self.assertEqual(do2(event), 0) + + """None""" - # if none, it just forwards the value + # it just forwards the value event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(utils.normalize_value(event, None), MAX_ABS) + + """Not a joystick""" + + event = new_event(EV_ABS, ecodes.ABS_Z, 1234) + self.assertEqual(do(event), 1) + self.assertEqual(do2(event), 1) + event = new_event(EV_ABS, ecodes.ABS_Z, 0) + self.assertEqual(do(event), 0) + self.assertEqual(do2(event), 0) + event = new_event(EV_ABS, ecodes.ABS_Z, -1234) + self.assertEqual(do(event), -1) + self.assertEqual(do2(event), -1) + + event = new_event(EV_KEY, ecodes.KEY_A, 1) + self.assertEqual(do(event), 1) + self.assertEqual(do2(event), 1) + event = new_event(EV_ABS, ecodes.ABS_HAT0X, 0) + self.assertEqual(do(event), 0) + self.assertEqual(do2(event), 0) + event = new_event(EV_ABS, ecodes.ABS_HAT0X, -1) + self.assertEqual(do(event), -1) + self.assertEqual(do2(event), -1) diff --git a/tests/testcases/test_event_producer.py b/tests/testcases/test_event_producer.py index e7ce15a7..f4850ae4 100644 --- a/tests/testcases/test_event_producer.py +++ b/tests/testcases/test_event_producer.py @@ -31,7 +31,7 @@ from keymapper.injection.context import Context from keymapper.injection.event_producer import EventProducer, MOUSE, WHEEL from tests.test import InputDevice, UInput, MAX_ABS, clear_write_history, \ - uinput_write_history, quick_cleanup, new_event + uinput_write_history, quick_cleanup, new_event, MIN_ABS abs_state = [0, 0, 0, 0] @@ -50,7 +50,7 @@ class TestEventProducer(unittest.TestCase): device = InputDevice('/dev/input/event30') self.event_producer = EventProducer(self.context) - self.event_producer.set_max_abs_from(device) + self.event_producer.set_abs_range_from(device) asyncio.ensure_future(self.event_producer.run()) config.set('gamepad.joystick.x_scroll_speed', 1) @@ -112,6 +112,26 @@ class TestEventProducer(unittest.TestCase): self.assertEqual(history[0], 1) self.assertEqual(history[1], 2) + def assertClose(self, a, b, within): + """a has to be within b - b * within, b + b * within.""" + self.assertLess(a - abs(a) * within, b) + self.assertGreater(a + abs(a) * within, b) + + def test_assertClose(self): + self.assertClose(5, 5, 0.1) + self.assertClose(5, 5, 1) + self.assertClose(6, 5, 0.2) + self.assertClose(4, 5, 0.3) + self.assertRaises(AssertionError, lambda: self.assertClose(6, 5, 0.1)) + self.assertRaises(AssertionError, lambda: self.assertClose(4, 5, 0.1)) + + self.assertClose(-5, -5, 0.1) + self.assertClose(-5, -5, 1) + self.assertClose(-6, -5, 0.2) + self.assertClose(-4, -5, 0.3) + self.assertRaises(AssertionError, lambda: self.assertClose(-6, -5, 0.1)) + self.assertRaises(AssertionError, lambda: self.assertClose(-4, -5, 0.1)) + def do(self, a, b, c, d, expectation): """Present fake values to the loop and observe the outcome.""" clear_write_history() @@ -127,7 +147,11 @@ class TestEventProducer(unittest.TestCase): # sleep long enough to test if multiple events are written self.assertGreater(len(history), 1) self.assertIn(expectation, history) - self.assertEqual(history.count(expectation), len(history)) + + for history_entry in history: + self.assertEqual(history_entry[:2], expectation[:2]) + # if the injected cursor movement is 19 or 20 doesn't really matter + self.assertClose(history_entry[2], expectation[2], 0.1) def test_joystick_purpose_1(self): speed = 20 @@ -136,16 +160,24 @@ class TestEventProducer(unittest.TestCase): self.mapping.set('gamepad.joystick.left_purpose', MOUSE) self.mapping.set('gamepad.joystick.right_purpose', WHEEL) - self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_X, speed)) - self.do(-MAX_ABS, 0, 0, 0, (EV_REL, REL_X, -speed)) - self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_Y, speed)) - self.do(0, -MAX_ABS, 0, 0, (EV_REL, REL_Y, -speed)) + min_abs = 0 + # if `rest` is not exactly `max_abs / 2` decimal places might add up + # and cause higher or lower values to be written after a few events, + # which might be difficult to test. + max_abs = 256 + rest = 128 # resting position of the cursor + self.event_producer.set_abs_range(min_abs, max_abs) + + self.do(max_abs, rest, rest, rest, (EV_REL, REL_X, speed)) + self.do(min_abs, rest, rest, rest, (EV_REL, REL_X, -speed)) + self.do(rest, max_abs, rest, rest, (EV_REL, REL_Y, speed)) + self.do(rest, min_abs, rest, rest, (EV_REL, REL_Y, -speed)) # vertical wheel event values are negative - self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_HWHEEL, 1)) - self.do(0, 0, -MAX_ABS, 0, (EV_REL, REL_HWHEEL, -1)) - self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_WHEEL, -1)) - self.do(0, 0, 0, -MAX_ABS, (EV_REL, REL_WHEEL, 1)) + self.do(rest, rest, max_abs, rest, (EV_REL, REL_HWHEEL, 1)) + self.do(rest, rest, min_abs, rest, (EV_REL, REL_HWHEEL, -1)) + self.do(rest, rest, rest, max_abs, (EV_REL, REL_WHEEL, -1)) + self.do(rest, rest, rest, min_abs, (EV_REL, REL_WHEEL, 1)) def test_joystick_purpose_2(self): speed = 30 @@ -158,14 +190,14 @@ class TestEventProducer(unittest.TestCase): # vertical wheel event values are negative self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 1)) - self.do(-MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -1)) + self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -1)) self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -2)) - self.do(0, -MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, 2)) + self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 2)) self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed)) - self.do(0, 0, -MAX_ABS, 0, (EV_REL, REL_X, -speed)) + self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed)) self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed)) - self.do(0, 0, 0, -MAX_ABS, (EV_REL, REL_Y, -speed)) + self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed)) def test_joystick_purpose_3(self): speed = 40 @@ -175,14 +207,14 @@ class TestEventProducer(unittest.TestCase): config.set('gamepad.joystick.right_purpose', MOUSE) self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_X, speed)) - self.do(-MAX_ABS, 0, 0, 0, (EV_REL, REL_X, -speed)) + self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_X, -speed)) self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_Y, speed)) - self.do(0, -MAX_ABS, 0, 0, (EV_REL, REL_Y, -speed)) + self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_Y, -speed)) self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed)) - self.do(0, 0, -MAX_ABS, 0, (EV_REL, REL_X, -speed)) + self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed)) self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed)) - self.do(0, 0, 0, -MAX_ABS, (EV_REL, REL_Y, -speed)) + self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed)) def test_joystick_purpose_4(self): config.set('gamepad.joystick.left_purpose', WHEEL) @@ -191,15 +223,15 @@ class TestEventProducer(unittest.TestCase): self.mapping.set('gamepad.joystick.y_scroll_speed', 3) self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 2)) - self.do(-MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -2)) + self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -2)) self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -3)) - self.do(0, -MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, 3)) + self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 3)) # vertical wheel event values are negative self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_HWHEEL, 2)) - self.do(0, 0, -MAX_ABS, 0, (EV_REL, REL_HWHEEL, -2)) + self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_HWHEEL, -2)) self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_WHEEL, -3)) - self.do(0, 0, 0, -MAX_ABS, (EV_REL, REL_WHEEL, 3)) + self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_WHEEL, 3)) if __name__ == "__main__": diff --git a/tests/testcases/test_getdevices.py b/tests/testcases/test_getdevices.py index 37f78e16..330f7ce5 100644 --- a/tests/testcases/test_getdevices.py +++ b/tests/testcases/test_getdevices.py @@ -57,22 +57,22 @@ class TestGetDevices(unittest.TestCase): 'device 1', 'device 1' ], - 'type': MOUSE + 'types': [KEYBOARD, MOUSE] }, 'device 2': { 'paths': ['/dev/input/event20'], 'devices': ['device 2'], - 'type': KEYBOARD + 'types': [KEYBOARD] }, 'gamepad': { 'paths': ['/dev/input/event30'], 'devices': ['gamepad'], - 'type': GAMEPAD + 'types': [GAMEPAD] }, 'key-mapper device 2': { 'paths': ['/dev/input/event40'], 'devices': ['key-mapper device 2'], - 'type': KEYBOARD + 'types': [KEYBOARD] }, }) self.assertDictEqual(pipe.devices, get_devices(include_keymapper=True)) @@ -90,17 +90,17 @@ class TestGetDevices(unittest.TestCase): 'device 1', 'device 1' ], - 'type': MOUSE + 'types': [KEYBOARD, MOUSE] }, 'device 2': { 'paths': ['/dev/input/event20'], 'devices': ['device 2'], - 'type': KEYBOARD + 'types': [KEYBOARD] }, 'gamepad': { 'paths': ['/dev/input/event30'], 'devices': ['gamepad'], - 'type': GAMEPAD + 'types': [GAMEPAD] }, }) @@ -169,7 +169,7 @@ class TestGetDevices(unittest.TestCase): """mice""" self.assertEqual(classify(FakeDevice({ - EV_REL: [evdev.ecodes.REL_X, evdev.ecodes.REL_Y], + EV_REL: [evdev.ecodes.REL_X, evdev.ecodes.REL_Y, evdev.ecodes.REL_WHEEL], EV_KEY: [evdev.ecodes.BTN_LEFT] })), MOUSE) @@ -186,13 +186,14 @@ class TestGetDevices(unittest.TestCase): EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X] })), TOUCHPAD) - """weird combos""" + """graphics tablets""" self.assertEqual(classify(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], - EV_KEY: [evdev.ecodes.BTN_A], - EV_REL: [evdev.ecodes.REL_X] - })), UNKNOWN) + EV_KEY: [evdev.ecodes.BTN_STYLUS] + })), GRAPHICS_TABLET) + + """weird combos""" self.assertEqual(classify(FakeDevice({ EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index 6324e1b3..ad9e8f1e 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -44,7 +44,7 @@ from keymapper.getdevices import get_devices, classify, GAMEPAD from tests.test import new_event, push_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice, uinputs, \ - keyboard_keys + keyboard_keys, MIN_ABS class TestInjector(unittest.TestCase): @@ -418,7 +418,8 @@ class TestInjector(unittest.TestCase): self.injector.run() # not in a process, so the event_producer state can be checked - self.assertEqual(self.injector._event_producer.max_abs, MAX_ABS) + self.assertEqual(self.injector._event_producer.abs_range[0], MIN_ABS) + self.assertEqual(self.injector._event_producer.abs_range[1], MAX_ABS) self.assertEqual( self.injector.context.mapping.get('gamepad.joystick.left_purpose'), MOUSE @@ -430,7 +431,7 @@ class TestInjector(unittest.TestCase): self.injector = Injector('gamepad', custom_mapping) self.injector.stop_injecting() self.injector.run() - self.assertIsNone(self.injector._event_producer.max_abs, MAX_ABS) + self.assertIsNone(self.injector._event_producer.abs_range) def test_device1_event_producer(self): custom_mapping.set('gamepad.joystick.left_purpose', MOUSE) @@ -440,7 +441,7 @@ class TestInjector(unittest.TestCase): self.injector.run() # 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.abs_range) def test_get_udef_name(self): self.injector = Injector('device 1', custom_mapping) diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index fad9b19f..a8355a2e 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -51,7 +51,7 @@ from keymapper.gui.helper import RootHelper from tests.test import tmp, push_events, new_event, spy, cleanup, \ uinput_write_history_pipe, MAX_ABS, EVENT_READ_TIMEOUT, \ - send_event_to_reader + send_event_to_reader, MIN_ABS def gtk_iteration(): @@ -1235,7 +1235,7 @@ class TestIntegration(unittest.TestCase): time.sleep(0.1) push_events('gamepad', [ - new_event(EV_ABS, ABS_RX, -MAX_ABS), + new_event(EV_ABS, ABS_RX, MIN_ABS), new_event(EV_ABS, ABS_X, MAX_ABS) ] * 100) diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 19514b44..f584861f 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -35,7 +35,7 @@ from keymapper.config import config, BUTTONS from keymapper.mapping import Mapping, DISABLE_CODE from tests.test import new_event, UInput, uinput_write_history, \ - quick_cleanup, InputDevice, MAX_ABS + quick_cleanup, InputDevice, MAX_ABS, MIN_ABS def wait(func, timeout=1.0): @@ -210,7 +210,7 @@ class TestKeycodeMapper(unittest.TestCase): # with the left joystick mapped as button, it will release the mapped # key when it goes back to close to its resting position ev_1 = (3, 0, MAX_ABS // 10) # release - ev_3 = (3, 0, -MAX_ABS) # press + ev_3 = (3, 0, MIN_ABS) # press uinput = UInput() @@ -422,7 +422,7 @@ class TestKeycodeMapper(unittest.TestCase): def test_combination_keycode_2(self): combination_1 = ( (EV_KEY, 1, 1), - (EV_ABS, ABS_Y, -MAX_ABS), + (EV_ABS, ABS_Y, MIN_ABS), (EV_KEY, 3, 1), (EV_KEY, 4, 1) )