From 4884697fa3e88258fa713430ce5236d7f8239a26 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sat, 2 Jan 2021 02:26:44 +0100 Subject: [PATCH] fixes for graphics tablets, general improvements --- keymapper/data.py | 31 ++++---- keymapper/dev/ev_abs_mapper.py | 45 +++++++---- keymapper/dev/injector.py | 44 ++++++----- keymapper/dev/keycode_mapper.py | 61 ++------------- keymapper/dev/reader.py | 52 ++++++++----- keymapper/dev/utils.py | 92 +++++++++++++++++++++- keymapper/getdevices.py | 7 ++ keymapper/gtk/window.py | 11 ++- keymapper/util.py | 33 -------- readme/coverage.svg | 4 +- tests/test.py | 8 +- tests/testcases/test_dev_utils.py | 98 +++++++++++++++++++++++ tests/testcases/test_ev_abs_mapper.py | 5 -- tests/testcases/test_getdevices.py | 8 ++ tests/testcases/test_injector.py | 95 ++++++++++++++--------- tests/testcases/test_integration.py | 3 +- tests/testcases/test_keycode_mapper.py | 103 +++---------------------- tests/testcases/test_reader.py | 23 +++--- 18 files changed, 406 insertions(+), 317 deletions(-) delete mode 100644 keymapper/util.py create mode 100644 tests/testcases/test_dev_utils.py diff --git a/keymapper/data.py b/keymapper/data.py index b636711e..e510789d 100644 --- a/keymapper/data.py +++ b/keymapper/data.py @@ -42,9 +42,9 @@ def get_data_path(filename=''): """ global logged - source_path = None + source = None try: - source_path = pkg_resources.require('key-mapper')[0].location + source = pkg_resources.require('key-mapper')[0].location # failed in some ubuntu installations except pkg_resources.DistributionNotFound: pass @@ -53,17 +53,16 @@ def get_data_path(filename=''): # prefix path for data # https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long - data_path = None + data = None # python3.8/dist-packages python3.7/site-packages, /usr/share, # /usr/local/share, endless options - if source_path is not None: - if '-packages' not in source_path and 'python' not in source_path: - # probably installed with -e, running from the cloned git source - data_path = os.path.join(source_path, 'data') - if not os.path.exists(data_path): - if not logged: - logger.debug('-e, but data missing at "%s"', data_path) - data_path = None + if source and '-packages' not in source and 'python' not in source: + # probably installed with -e, running from the cloned git source + data = os.path.join(source, 'data') + if not os.path.exists(data): + if not logged: + logger.debug('-e, but data missing at "%s"', data) + data = None candidates = [ '/usr/share/key-mapper', @@ -71,19 +70,19 @@ def get_data_path(filename=''): os.path.join(site.USER_BASE, 'share/key-mapper'), ] - if data_path is None: + if data is None: # try any of the options for candidate in candidates: if os.path.exists(candidate): - data_path = candidate + data = candidate break - if data_path is None: + if data is None: logger.error('Could not find the application data') sys.exit(1) if not logged: - logger.debug('Found data at "%s"', data_path) + logger.debug('Found data at "%s"', data) logged = True - return os.path.join(data_path, filename) + return os.path.join(data, filename) diff --git a/keymapper/dev/ev_abs_mapper.py b/keymapper/dev/ev_abs_mapper.py index 1efea74a..0265ec0f 100644 --- a/keymapper/dev/ev_abs_mapper.py +++ b/keymapper/dev/ev_abs_mapper.py @@ -25,22 +25,13 @@ import asyncio import time -import evdev from evdev.ecodes import EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL from keymapper.logger import logger from keymapper.config import MOUSE, WHEEL -from keymapper.dev.utils import max_abs +from keymapper.dev.utils import get_max_abs -# other events for ABS include buttons -JOYSTICK = [ - evdev.ecodes.ABS_X, - evdev.ecodes.ABS_Y, - evdev.ecodes.ABS_RX, - evdev.ecodes.ABS_RY, -] - # miniscule movements on the joystick should not trigger a mouse wheel event WHEEL_THRESHOLD = 0.15 @@ -49,8 +40,12 @@ def _write(device, ev_type, keycode, value): """Inject.""" # if the mouse won't move even though correct stuff is written here, the # capabilities are probably wrong - device.write(ev_type, keycode, value) - device.syn() + try: + device.write(ev_type, keycode, value) + device.syn() + except OverflowError: + logger.error('OverflowError (%s, %s, %s)', ev_type, keycode, value) + pass def accumulate(pending, current): @@ -106,6 +101,9 @@ def get_values(abs_state, left_purpose, right_purpose): async def ev_abs_mapper(abs_state, input_device, keymapper_device, mapping): """Keep writing mouse movements based on the gamepad stick position. + Even if no new input event arrived because the joystick remained at + its position, this will keep injecting the mouse movement events. + Parameters ---------- abs_state : [int, int. int, int] @@ -118,11 +116,14 @@ async def ev_abs_mapper(abs_state, input_device, keymapper_device, mapping): mapping : Mapping the mapping object that configures the current injection """ - max_value = max_abs(input_device) + max_value = get_max_abs(input_device) - if max_value == 0 or max_value is None: + if max_value in [0, 1, None]: + # not something that was intended for this return + logger.debug('Max abs of "%s": %s', input_device.name, max_value) + max_speed = ((max_value ** 2) * 2) ** 0.5 # events only take ints, so a movement of 0.3 needs to add @@ -153,6 +154,18 @@ async def ev_abs_mapper(abs_state, input_device, keymapper_device, mapping): right_purpose ) + out_of_bounds = [ + val for val in [mouse_x, mouse_y, wheel_x, wheel_y] + if val > max_value + ] + if len(out_of_bounds) > 0: + logger.error( + 'Encountered inconsistent values: %s, max abs: %s', + out_of_bounds, + max_value + ) + return + # mouse movements if abs(mouse_x) > 0 or abs(mouse_y) > 0: if non_linearity != 1: @@ -162,8 +175,8 @@ async def ev_abs_mapper(abs_state, input_device, keymapper_device, mapping): else: factor = 1 - rel_x = mouse_x * factor * pointer_speed / max_value - rel_y = mouse_y * factor * pointer_speed / max_value + rel_x = (mouse_x / max_value) * factor * pointer_speed + rel_y = (mouse_y / max_value) * factor * pointer_speed pending_x_rel, rel_x = accumulate(pending_x_rel, rel_x) pending_y_rel, rel_y = accumulate(pending_y_rel, rel_y) if rel_x != 0: diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 77685fee..28e9d64f 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -33,9 +33,9 @@ from evdev.ecodes import EV_KEY, EV_ABS, EV_REL from keymapper.logger import logger from keymapper.getdevices import get_devices, map_abs_to_rel -from keymapper.dev.keycode_mapper import handle_keycode, \ - should_map_event_as_btn -from keymapper.dev.ev_abs_mapper import ev_abs_mapper, JOYSTICK +from keymapper.dev.keycode_mapper import handle_keycode +from keymapper.dev import utils +from keymapper.dev.ev_abs_mapper import ev_abs_mapper from keymapper.dev.macros import parse, is_this_a_macro from keymapper.state import system_mapping from keymapper.mapping import DISABLE_CODE @@ -261,9 +261,8 @@ class KeycodeInjector: # to act like the device. capabilities = input_device.capabilities(absinfo=False) - if len(self._key_to_code) > 0 or len(macros) > 0: - if capabilities.get(EV_KEY) is None: - capabilities[EV_KEY] = [] + if (self._key_to_code or macros) and capabilities.get(EV_KEY) is None: + capabilities[EV_KEY] = [] # Furthermore, support all injected keycodes for code in self._key_to_code.values(): @@ -327,6 +326,9 @@ class KeycodeInjector: Stuff is non-blocking by using asyncio in order to do multiple things somewhat concurrently. + + Use this function as starting point in a process. It creates + the loops needed to read and map events and keeps running them. """ # create a new event loop, because somehow running an infinite loop # that sleeps on iterations (ev_abs_mapper) in one process causes @@ -387,21 +389,25 @@ class KeycodeInjector: for macro in macros.values(): macro.set_handler(handler) - # keycode injection - coroutine = self._keycode_loop(macros, source, uinput, abs_to_rel) - coroutines.append(coroutine) + # actual reading of events + coroutines.append(self._event_consumer( + macros, + source, + uinput, + abs_to_rel + )) - # mouse movement injection + # mouse movement injection based on the results of the + # event consumer if abs_to_rel: self.abs_state[0] = 0 self.abs_state[1] = 0 - coroutine = ev_abs_mapper( + coroutines.append(ev_abs_mapper( self.abs_state, source, uinput, self.mapping - ) - coroutines.append(coroutine) + )) if len(coroutines) == 0: logger.error('Did not grab any device') @@ -430,14 +436,14 @@ class KeycodeInjector: uinput.write(EV_KEY, code, value) uinput.syn() - async def _keycode_loop(self, macros, source, uinput, abs_to_rel): - """Inject keycodes for one of the virtual devices. + async def _event_consumer(self, macros, source, uinput, abs_to_rel): + """Reads input events to inject keycodes or talk to the ev_abs_mapper. Can be stopped by stopping the asyncio loop. Parameters ---------- - macros : (int, int) -> _Macro + macros : int: _Macro macro with a handler that writes to the provided uinput source : evdev.InputDevice where to read keycodes from @@ -452,7 +458,7 @@ class KeycodeInjector: ) async for event in source.async_read_loop(): - if should_map_event_as_btn(source, event, self.mapping): + if utils.should_map_event_as_btn(source, event, self.mapping): handle_keycode( self._key_to_code, macros, @@ -461,7 +467,9 @@ class KeycodeInjector: ) continue - if abs_to_rel and event.type == EV_ABS and event.code in JOYSTICK: + is_joystick = event.type == EV_ABS and event.code in utils.JOYSTICK + if abs_to_rel and is_joystick: + # talks to the ev_abs_mapper via the abs_state array if event.code == evdev.ecodes.ABS_X: self.abs_state[0] = event.value elif event.code == evdev.ecodes.ABS_Y: diff --git a/keymapper/dev/keycode_mapper.py b/keymapper/dev/keycode_mapper.py index 7c06ee2d..880eb921 100644 --- a/keymapper/dev/keycode_mapper.py +++ b/keymapper/dev/keycode_mapper.py @@ -24,16 +24,11 @@ import itertools import asyncio -import math -from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY +from evdev.ecodes import EV_KEY, EV_ABS from keymapper.logger import logger, is_debug -from keymapper.util import sign from keymapper.mapping import DISABLE_CODE -from keymapper.config import BUTTONS -from keymapper.dev.ev_abs_mapper import JOYSTICK -from keymapper.dev.utils import max_abs # maps mouse buttons to macro instances that have been executed. They may @@ -58,49 +53,6 @@ active_macros = {} unreleased = {} -# a third of a quarter circle -JOYSTICK_BUTTON_THRESHOLD = math.sin((math.pi / 2) / 3 * 1) - - -# TODO test intuos again - - -def should_map_event_as_btn(device, event, mapping): - """Does this event describe a button. - - If it does, this function will make sure its value is one of [-1, 0, 1], - so that it matches the possible values in a mapping object. - - Especially important for gamepad events, some of the buttons - require special rules. - """ - if event.type == EV_KEY: - return True - - is_mousepad = event.type == EV_ABS and 47 <= event.code <= 61 - if is_mousepad: - return False - - if event.type == EV_ABS: - if event.code in JOYSTICK: - l_purpose = mapping.get('gamepad.joystick.left_purpose') - r_purpose = mapping.get('gamepad.joystick.right_purpose') - threshold = max_abs(device) * JOYSTICK_BUTTON_THRESHOLD - triggered = abs(event.value) > threshold - - if event.code in [ABS_X, ABS_Y] and l_purpose == BUTTONS: - event.value = sign(event.value) if triggered else 0 - return True - - if event.code in [ABS_RX, ABS_RY] and r_purpose == BUTTONS: - event.value = sign(event.value) if triggered else 0 - return True - else: - return True - - return False - - def is_key_down(event): """Is this event a key press.""" return event.value != 0 @@ -163,6 +115,10 @@ def log(key, msg, *args): def handle_keycode(key_to_code, macros, event, uinput): """Write mapped keycodes, forward unmapped ones and manage macros. + As long as the provided event is mapped it will handle it, it won't + check any type, code or capability anymore. Otherwise it forwards + it as it is. + Parameters ---------- key_to_code : dict @@ -181,15 +137,12 @@ def handle_keycode(key_to_code, macros, event, uinput): # no need to forward or map them. return - # normalize event numbers to one of -1, 0, +1. Otherwise mapping - # trigger values that are between 1 and 255 is not possible, because - # they might skip the 1 when pressed fast enough. # The key used to index the mappings `key_to_code` and `macros` - key = (event.type, event.code, sign(event.value)) + key = (event.type, event.code, event.value) # the tuple of the actual input event. Used to forward the event if it is # not mapped, and to index unreleased and active_macros - event_tuple = (event.type, event.code, sign(event.value)) + event_tuple = (event.type, event.code, event.value) type_code = (event.type, event.code) # the triggering key-down has to be the last element in combination, all diff --git a/keymapper/dev/reader.py b/keymapper/dev/reader.py index 41ae5be0..873746a8 100644 --- a/keymapper/dev/reader.py +++ b/keymapper/dev/reader.py @@ -28,14 +28,13 @@ import multiprocessing import threading import evdev -from evdev.ecodes import EV_KEY, EV_ABS +from evdev.ecodes import EV_KEY, EV_ABS, ABS_MISC from keymapper.logger import logger -from keymapper.util import sign from keymapper.key import Key from keymapper.state import custom_mapping from keymapper.getdevices import get_devices, refresh_devices -from keymapper.dev.keycode_mapper import should_map_event_as_btn +from keymapper.dev import utils CLOSE = 1 @@ -45,14 +44,21 @@ PRIORITIES = { EV_ABS: 50, } +FILTER_THRESHOLD = 0.01 + def prioritize(events): """Return the event that is most likely desired to be mapped. - High absolute values (down) over low values (up), KEY over ABS. + KEY over ABS and everything over ABS_MISC. """ + events = [ + event for event in events + if event is not None + ] return sorted(events, key=lambda e: ( PRIORITIES[e.type], + not (e.type == EV_ABS and e.code == ABS_MISC), abs(e.value) ))[-1] @@ -155,7 +161,7 @@ class _KeycodeReader: # which breaks the current workflow. return - if not should_map_event_as_btn(device, event, custom_mapping): + if not utils.should_map_event_as_btn(device, event, custom_mapping): return if not (event.value == 0 and event.type == EV_ABS): @@ -233,24 +239,30 @@ class _KeycodeReader: # no duplicate down events (gamepad triggers) continue - self._unreleased[without_value] = ( - event.type, - event.code, - sign(event.value) - ) - time = event.sec + event.usec / 1000000 delta = time - newest_time - if delta < 0.01 and prioritize([newest_event, event]) != event: - # two events happened very close, probably some weird - # spam from the device. The wacom intuos 5 adds an - # ABS_MISC event to every button press, filter that out - logger.spam( - 'Ignoring event (%s, %s, %s)', - event.type, event.code, event.value - ) - continue + if delta < FILTER_THRESHOLD: + if prioritize([newest_event, event]) != event: + # two events happened very close, probably some weird + # spam from the device. The wacom intuos 5 adds an + # ABS_MISC event to every button press, filter that out + logger.spam( + 'Ignoring event (%s, %s, %s)', + event.type, event.code, event.value + ) + continue + + # the previous event is ignored + previous_without_value = (newest_event.type, newest_event.code) + if previous_without_value in self._unreleased: + del self._unreleased[previous_without_value] + + self._unreleased[without_value] = ( + event.type, + event.code, + event.value + ) newest_event = event newest_time = time diff --git a/keymapper/dev/utils.py b/keymapper/dev/utils.py index 12b01204..70f934de 100644 --- a/keymapper/dev/utils.py +++ b/keymapper/dev/utils.py @@ -22,11 +22,94 @@ """Utility functions for all other modules in keymapper.dev""" +import math + import evdev -from evdev.ecodes import EV_ABS +from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY, \ + EV_REL, REL_WHEEL, REL_HWHEEL + +from keymapper.logger import logger +from keymapper.config import BUTTONS -def max_abs(device): +# other events for ABS include buttons +JOYSTICK = [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, +] + + +# a third of a quarter circle +JOYSTICK_BUTTON_THRESHOLD = math.sin((math.pi / 2) / 3 * 1) + + +def sign(value): + """Get the sign of the value, or 0 if 0.""" + if value > 0: + return 1 + + if value < 0: + return -1 + + return 0 + + +def should_map_event_as_btn(device, event, mapping): + """Does this event describe a button. + + If it does, this function will make sure its value is one of [-1, 0, 1], + so that it matches the possible values in a mapping object if needed. + + If a new kind of event should be mappable to buttons, this is the place + to add it. + + Especially important for gamepad events, some of the buttons + require special rules. + """ + if event.type == EV_KEY: + return True + + is_mousepad = event.type == EV_ABS and 47 <= event.code <= 61 + if is_mousepad: + return False + + if event.type == EV_ABS: + if event.code in JOYSTICK: + l_purpose = mapping.get('gamepad.joystick.left_purpose') + r_purpose = mapping.get('gamepad.joystick.right_purpose') + + max_abs = get_max_abs(device) + + if max_abs is None: + logger.error( + 'Got %s, but max_abs is %s', + (event.type, event.code, event.value), max_abs + ) + return False + + threshold = max_abs * JOYSTICK_BUTTON_THRESHOLD + triggered = abs(event.value) > threshold + + if event.code in [ABS_X, ABS_Y] and l_purpose == BUTTONS: + event.value = sign(event.value) if triggered else 0 + return True + + if event.code in [ABS_RX, ABS_RY] and r_purpose == BUTTONS: + event.value = sign(event.value) if triggered else 0 + return True + else: + # normalize event numbers to one of -1, 0, +1. Otherwise mapping + # trigger values that are between 1 and 255 is not possible, + # because they might skip the 1 when pressed fast enough. + event.value = sign(event.value) + return True + + return False + + +def get_max_abs(device): """Figure out the maximum value of EV_ABS events of that device. Like joystick movements or triggers. @@ -45,6 +128,9 @@ def max_abs(device): ] if len(absinfos) == 0: + logger.error('Failed to get max abs of "%s"') return None - return absinfos[0].max + max_abs = absinfos[0].max + + return max_abs diff --git a/keymapper/getdevices.py b/keymapper/getdevices.py index 5ea3be2c..9c0978ce 100644 --- a/keymapper/getdevices.py +++ b/keymapper/getdevices.py @@ -58,6 +58,13 @@ def map_abs_to_rel(capabilities): # check for some random mousepad capability return False + if evdev.ecodes.BTN_TOOL_BRUSH in capabilities.get(EV_KEY, []): + # a graphics tablet, not a gamepad + return False + if evdev.ecodes.BTN_STYLUS in capabilities.get(EV_KEY, []): + # another graphics tablet test + return False + if evdev.ecodes.ABS_X in abs_capabilities: # can be a joystick or a mousepad (already handled), so it's # a joystick diff --git a/keymapper/gtk/window.py b/keymapper/gtk/window.py index ab025136..6c644390 100755 --- a/keymapper/gtk/window.py +++ b/keymapper/gtk/window.py @@ -353,10 +353,10 @@ class Window: # they have already been read. key = keycode_reader.read() - if isinstance(focused, Gtk.ToggleButton): - if not keycode_reader.are_keys_pressed(): - row.release() - return True + keys_pressed = keycode_reader.are_keys_pressed() + if isinstance(focused, Gtk.ToggleButton) and not keys_pressed: + row.release() + return True if key is None: return True @@ -519,8 +519,7 @@ class Window: @with_selected_device def on_create_preset_clicked(self, _): """Create a new preset and select it.""" - if custom_mapping.changed: - if unsaved_changes_dialog() == GO_BACK: + if custom_mapping.changed and unsaved_changes_dialog() == GO_BACK: return try: diff --git a/keymapper/util.py b/keymapper/util.py deleted file mode 100644 index 8fd5665f..00000000 --- a/keymapper/util.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# key-mapper - GUI for device specific keyboard mappings -# Copyright (C) 2020 sezanzeb -# -# This file is part of key-mapper. -# -# key-mapper is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# key-mapper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with key-mapper. If not, see . - - -"""Utility functions.""" - - -def sign(value): - """Get the sign of the value, or 0 if 0.""" - if value > 0: - return 1 - - if value < 0: - return -1 - - return 0 diff --git a/readme/coverage.svg b/readme/coverage.svg index d6e67f4f..c6421901 100644 --- a/readme/coverage.svg +++ b/readme/coverage.svg @@ -17,7 +17,7 @@ coverage - 92% - 92% + 91% + 91% \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index e8903386..90a2c7ea 100644 --- a/tests/test.py +++ b/tests/test.py @@ -198,15 +198,17 @@ class InputEvent: self.code = code self.value = value - # tuple shorthand - self.t = (type, code, value) - if timestamp is None: timestamp = time.time() self.sec = int(timestamp) self.usec = timestamp % 1 * 1000000 + @property + def t(self): + # tuple shorthand + return self.type, self.code, self.value + def __str__(self): return f'InputEvent{self.t}' diff --git a/tests/testcases/test_dev_utils.py b/tests/testcases/test_dev_utils.py new file mode 100644 index 00000000..f282fc32 --- /dev/null +++ b/tests/testcases/test_dev_utils.py @@ -0,0 +1,98 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# key-mapper - GUI for device specific keyboard mappings +# Copyright (C) 2020 sezanzeb +# +# This file is part of key-mapper. +# +# key-mapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# key-mapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with key-mapper. If not, see . + + +import unittest + +from evdev import ecodes +from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_A, ABS_X, \ + EV_REL, REL_X, REL_WHEEL, REL_HWHEEL + +from keymapper.config import config, BUTTONS +from keymapper.mapping import Mapping +from keymapper.dev import utils + +from tests.test import InputEvent, InputDevice, MAX_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'))) + + def test_should_map_event_as_btn(self): + device = InputDevice('/dev/input/event30') + mapping = Mapping() + + # the function name is so horribly long + do = utils.should_map_event_as_btn + + """D-Pad""" + + self.assertTrue(do(device, InputEvent(EV_ABS, ABS_HAT0X, 1), mapping)) + self.assertTrue(do(device, InputEvent(EV_ABS, ABS_HAT0X, -1), mapping)) + + """Mouse movements""" + + self.assertFalse(do(device, InputEvent(EV_REL, REL_WHEEL, 1), mapping)) + self.assertFalse(do(device, InputEvent(EV_REL, REL_WHEEL, -1), mapping)) + self.assertFalse(do(device, InputEvent(EV_REL, REL_HWHEEL, 1), mapping)) + self.assertFalse(do(device, InputEvent(EV_REL, REL_HWHEEL, -1), mapping)) + self.assertFalse(do(device, InputEvent(EV_REL, REL_X, -1), mapping)) + + """regular keys and buttons""" + + self.assertTrue(do(device, InputEvent(EV_KEY, KEY_A, 1), mapping)) + self.assertTrue(do(device, InputEvent(EV_ABS, ABS_HAT0X, -1), mapping)) + + """mousepad events""" + + self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_SLOT, 1), mapping)) + self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1), mapping)) + self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_POSITION_X, 1), mapping)) + + """joysticks""" + + self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_RX, 1234), mapping)) + self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_Y, -1), mapping)) + + mapping.set('gamepad.joystick.left_purpose', BUTTONS) + + event = InputEvent(EV_ABS, ecodes.ABS_RX, MAX_ABS) + self.assertFalse(do(device, event, mapping)) + self.assertEqual(event.value, MAX_ABS) + event = InputEvent(EV_ABS, ecodes.ABS_Y, -MAX_ABS) + self.assertTrue(do(device, event, mapping)) + self.assertEqual(event.value, -1) + event = InputEvent(EV_ABS, ecodes.ABS_X, -MAX_ABS / 4) + self.assertTrue(do(device, event, mapping)) + self.assertEqual(event.value, 0) + + config.set('gamepad.joystick.right_purpose', BUTTONS) + + event = InputEvent(EV_ABS, ecodes.ABS_RX, MAX_ABS) + self.assertTrue(do(device, event, mapping)) + self.assertEqual(event.value, 1) + event = InputEvent(EV_ABS, ecodes.ABS_Y, MAX_ABS) + self.assertTrue(do(device, event, mapping)) + self.assertEqual(event.value, 1) + event = InputEvent(EV_ABS, ecodes.ABS_X, MAX_ABS / 4) + self.assertTrue(do(device, event, mapping)) + self.assertEqual(event.value, 0) diff --git a/tests/testcases/test_ev_abs_mapper.py b/tests/testcases/test_ev_abs_mapper.py index 26d06408..d950b3c8 100644 --- a/tests/testcases/test_ev_abs_mapper.py +++ b/tests/testcases/test_ev_abs_mapper.py @@ -27,7 +27,6 @@ from evdev.ecodes import EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL from keymapper.dev.ev_abs_mapper import ev_abs_mapper from keymapper.config import config from keymapper.mapping import Mapping -from keymapper.dev.utils import max_abs from keymapper.dev.ev_abs_mapper import MOUSE, WHEEL from tests.test import InputDevice, UInput, MAX_ABS, clear_write_history, \ @@ -60,10 +59,6 @@ class TestEvAbsMapper(unittest.TestCase): def tearDown(self): cleanup() - def test_max_abs(self): - self.assertEqual(max_abs(InputDevice('/dev/input/event30')), MAX_ABS) - self.assertIsNone(max_abs(InputDevice('/dev/input/event10'))) - def do(self, a, b, c, d, expectation): """Present fake values to the loop and observe the outcome.""" clear_write_history() diff --git a/tests/testcases/test_getdevices.py b/tests/testcases/test_getdevices.py index 41cb6e17..56e8e922 100644 --- a/tests/testcases/test_getdevices.py +++ b/tests/testcases/test_getdevices.py @@ -117,6 +117,14 @@ class TestGetDevices(unittest.TestCase): self.assertFalse(map_abs_to_rel({ EV_KEY: [evdev.ecodes.ABS_X] # intentionally ABS_X (0) on EV_KEY })) + self.assertFalse(map_abs_to_rel({ + EV_ABS: [evdev.ecodes.ABS_X], + EV_KEY: [evdev.ecodes.BTN_TOOL_BRUSH] + })) + self.assertFalse(map_abs_to_rel({ + 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 e8a328c7..91d1b533 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -24,19 +24,23 @@ import time import copy import evdev -from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, ABS_RX, ABS_Y +from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_LEFT, KEY_A from keymapper.dev.injector import is_numlock_on, set_numlock, \ ensure_numlock, KeycodeInjector, is_in_capabilities from keymapper.state import custom_mapping, system_mapping from keymapper.mapping import Mapping, DISABLE_CODE, DISABLE_NAME -from keymapper.config import config, BUTTONS +from keymapper.config import config from keymapper.key import Key from keymapper.dev.macros import parse +from keymapper.dev import utils from tests.test import InputEvent, pending_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ - MAX_ABS, cleanup, read_write_history_pipe + MAX_ABS, cleanup, read_write_history_pipe, InputDevice + + +original_smeab = utils.should_map_event_as_btn class TestInjector(unittest.TestCase): @@ -59,6 +63,8 @@ class TestInjector(unittest.TestCase): evdev.InputDevice.grab = grab_fail_twice def tearDown(self): + utils.should_map_event_as_btn = original_smeab + if self.injector is not None: self.injector.stop_injecting() self.injector = None @@ -211,22 +217,36 @@ class TestInjector(unittest.TestCase): path = self.new_gamepad gamepad_template = copy.deepcopy(fixtures['/dev/input/event30']) fixtures[path] = { - 'name': 'gamepad 2', - 'phys': 'abcd', - 'info': '1234', + 'name': 'gamepad 2', 'phys': 'abcd', 'info': '1234', 'capabilities': gamepad_template['capabilities'] } del fixtures[path]['capabilities'][EV_KEY] device, abs_to_rel = self.injector._prepare_device(path) self.assertNotIn(EV_KEY, device.capabilities()) capabilities = self.injector._modify_capabilities( - {}, - device, - abs_to_rel + {}, device, abs_to_rel ) self.assertIn(EV_KEY, capabilities) self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY]) + """gamepad with a btn_mouse capability existing""" + + path = self.new_gamepad + gamepad_template = copy.deepcopy(fixtures['/dev/input/event30']) + fixtures[path] = { + 'name': 'gamepad 3', 'phys': 'abcd', 'info': '1234', + 'capabilities': gamepad_template['capabilities'] + } + 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) + capabilities = self.injector._modify_capabilities( + {}, device, abs_to_rel + ) + self.assertIn(EV_KEY, capabilities) + self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY]) + self.assertIn(evdev.ecodes.KEY_A, capabilities[EV_KEY]) + """gamepad with existing key capabilities, but not btn_mouse""" path = '/dev/input/event30' @@ -234,11 +254,10 @@ class TestInjector(unittest.TestCase): 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, abs_to_rel ) self.assertIn(EV_KEY, capabilities) + self.assertGreater(len(capabilities), 1) self.assertIn(evdev.ecodes.BTN_MOUSE, capabilities[EV_KEY]) def test_skip_unused_device(self): @@ -296,7 +315,6 @@ class TestInjector(unittest.TestCase): pointer_speed = 80 config.set('gamepad.joystick.pointer_speed', pointer_speed) - # same for ABS, 0 for x, 1 for y rel_x = evdev.ecodes.REL_X rel_y = evdev.ecodes.REL_Y @@ -445,15 +463,21 @@ class TestInjector(unittest.TestCase): numlock_after = is_numlock_on() self.assertEqual(numlock_before, numlock_after) - def test_joysticks_as_buttons(self): - y_up = (EV_ABS, ABS_Y, -MAX_ABS) - y_release = (EV_ABS, ABS_Y, MAX_ABS // 4) + def test_any_funky_event_as_button(self): + # as long as should_map_event_as_btn says it should be a button, + # it will be. + EV_TYPE = 4531 + CODE_1 = 754 + CODE_2 = 4139 - rx_right = (EV_ABS, ABS_RX, MAX_ABS) - rx_release = (EV_ABS, ABS_RX, -MAX_ABS // 4) + w_down = (EV_TYPE, CODE_1, -1) + w_up = (EV_TYPE, CODE_1, 0) - custom_mapping.change(Key(*y_up[:2], -1), 'w') - custom_mapping.change(Key(*rx_right[:2], 1), 'k(d)') + d_down = (EV_TYPE, CODE_2, 1) + d_up = (EV_TYPE, CODE_2, 0) + + custom_mapping.change(Key(*w_down[:2], -1), 'w') + custom_mapping.change(Key(*d_down[:2], 1), 'k(d)') system_mapping.clear() code_w = 71 @@ -463,25 +487,32 @@ class TestInjector(unittest.TestCase): def do_stuff(): if self.injector is not None: + # discard the previous injector self.injector.stop_injecting() time.sleep(0.1) while uinput_write_history_pipe[0].poll(): uinput_write_history_pipe[0].recv() pending_events['gamepad'] = [ - InputEvent(*y_up), - InputEvent(*rx_right), - InputEvent(*y_release), - InputEvent(*rx_release), + InputEvent(*w_down), + InputEvent(*d_down), + InputEvent(*w_up), + InputEvent(*d_up), ] self.injector = KeycodeInjector('gamepad', custom_mapping) + + # 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.start_injecting() uinput_write_history_pipe[0].poll(timeout=1) time.sleep(EVENT_READ_TIMEOUT * 10) return read_write_history_pipe() - """purpose != buttons""" + """no""" history = do_stuff() self.assertEqual(history.count((EV_KEY, code_w, 1)), 0) @@ -489,19 +520,9 @@ class TestInjector(unittest.TestCase): self.assertEqual(history.count((EV_KEY, code_w, 0)), 0) self.assertEqual(history.count((EV_KEY, code_d, 0)), 0) - """left purpose buttons""" + """yes""" - custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) - history = do_stuff() - self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) - self.assertEqual(history.count((EV_KEY, code_d, 1)), 0) - self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) - self.assertEqual(history.count((EV_KEY, code_d, 0)), 0) - - """right purpose buttons""" - - custom_mapping.remove('gamepad.joystick.right_purpose') - config.set('gamepad.joystick.right_purpose', BUTTONS) + utils.should_map_event_as_btn = lambda *args: True history = do_stuff() self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index 5fd2a4bd..d77066d4 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -39,7 +39,7 @@ from gi.repository import Gtk, Gdk from keymapper.state import custom_mapping, system_mapping, XMODMAP_FILENAME from keymapper.paths import CONFIG_PATH, get_preset_path from keymapper.config import config, WHEEL, MOUSE -from keymapper.dev.reader import keycode_reader +from keymapper.dev.reader import keycode_reader, FILTER_THRESHOLD from keymapper.gtk.row import to_string, HOLDING, IDLE from keymapper.dev import permissions from keymapper.key import Key @@ -334,6 +334,7 @@ class TestIntegration(unittest.TestCase): # press down all the keys of a combination for sub_key in key: keycode_reader._pipe[1].send(InputEvent(*sub_key)) + time.sleep(FILTER_THRESHOLD * 2) # make the window consume the keycode time.sleep(0.06) diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 8c3dc82e..1ea5331a 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -23,19 +23,17 @@ import unittest import asyncio import time -from evdev import ecodes -from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_A, ABS_X, \ - EV_REL, REL_X, BTN_TL +from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_A, BTN_TL -from keymapper.dev.keycode_mapper import should_map_event_as_btn, \ - active_macros, handle_keycode, unreleased, subsets, log +from keymapper.dev.keycode_mapper import active_macros, handle_keycode,\ + unreleased, subsets, log from keymapper.state import system_mapping from keymapper.dev.macros import parse -from keymapper.config import config, BUTTONS +from keymapper.config import config from keymapper.mapping import Mapping, DISABLE_CODE from tests.test import InputEvent, UInput, uinput_write_history, \ - cleanup, InputDevice, MAX_ABS + cleanup def wait(func, timeout=1.0): @@ -191,58 +189,6 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(uinput_write_history[2].t, ev_3) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 51, 0)) - def test_should_map_event_as_btn(self): - device = InputDevice('/dev/input/event30') - mapping = Mapping() - - # the function name is so horribly long - do = should_map_event_as_btn - - """D-Pad""" - self.assertTrue(do(device, InputEvent(EV_ABS, ABS_HAT0X, 1), mapping)) - self.assertTrue(do(device, InputEvent(EV_ABS, ABS_HAT0X, -1), mapping)) - - """regular keys""" - - self.assertTrue(do(device, InputEvent(EV_KEY, KEY_A, 1), mapping)) - self.assertFalse(do(device, InputEvent(EV_ABS, ABS_X, 1), mapping)) - self.assertFalse(do(device, InputEvent(EV_REL, REL_X, 1), mapping)) - - """mousepad events""" - - self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_SLOT, 1), mapping)) - self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1), mapping)) - self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_MT_POSITION_X, 1), mapping)) - - """joysticks""" - - self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_RX, 1234), mapping)) - self.assertFalse(do(device, InputEvent(EV_ABS, ecodes.ABS_Y, -1), mapping)) - - mapping.set('gamepad.joystick.left_purpose', BUTTONS) - - event = InputEvent(EV_ABS, ecodes.ABS_RX, MAX_ABS) - self.assertFalse(do(device, event, mapping)) - self.assertEqual(event.value, MAX_ABS) - event = InputEvent(EV_ABS, ecodes.ABS_Y, -MAX_ABS) - self.assertTrue(do(device, event, mapping)) - self.assertEqual(event.value, -1) - event = InputEvent(EV_ABS, ecodes.ABS_X, -MAX_ABS / 4) - self.assertTrue(do(device, event, mapping)) - self.assertEqual(event.value, 0) - - config.set('gamepad.joystick.right_purpose', BUTTONS) - - event = InputEvent(EV_ABS, ecodes.ABS_RX, MAX_ABS) - self.assertTrue(do(device, event, mapping)) - self.assertEqual(event.value, 1) - event = InputEvent(EV_ABS, ecodes.ABS_Y, MAX_ABS) - self.assertTrue(do(device, event, mapping)) - self.assertEqual(event.value, 1) - event = InputEvent(EV_ABS, ecodes.ABS_X, MAX_ABS / 4) - self.assertTrue(do(device, event, mapping)) - self.assertEqual(event.value, 0) - def test_handle_keycode(self): _key_to_code = { ((EV_KEY, 1, 1),): 101, @@ -781,37 +727,8 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(history.count((code_2, 1)), 10) self.assertEqual(history.count((code_2, 0)), 10) - def test_normalize(self): - # -1234 to -1, 5678 to 1, 0 to 0 - - key_1 = (EV_KEY, BTN_TL) - ev_1 = (*key_1, 5678) - ev_2 = (*key_1, 0) - - # doesn't really matter if it makes sense, the A key reports - # negative values now. - key_2 = (EV_KEY, KEY_A) - ev_3 = (*key_2, -1234) - ev_4 = (*key_2, 0) - - _key_to_code = { - ((*key_1, 1),): 41, - ((*key_2, -1),): 42 - } - - uinput = UInput() - handle_keycode(_key_to_code, {}, InputEvent(*ev_1), uinput) - handle_keycode(_key_to_code, {}, InputEvent(*ev_2), uinput) - handle_keycode(_key_to_code, {}, InputEvent(*ev_3), uinput) - handle_keycode(_key_to_code, {}, InputEvent(*ev_4), uinput) - - self.assertEqual(len(uinput_write_history), 4) - self.assertEqual(uinput_write_history[0].t, (EV_KEY, 41, 1)) - self.assertEqual(uinput_write_history[1].t, (EV_KEY, 41, 0)) - self.assertEqual(uinput_write_history[2].t, (EV_KEY, 42, 1)) - self.assertEqual(uinput_write_history[3].t, (EV_KEY, 42, 0)) - def test_filter_trigger_spam(self): + # test_filter_duplicates trigger = (EV_KEY, BTN_TL) _key_to_code = { @@ -823,16 +740,16 @@ class TestKeycodeMapper(unittest.TestCase): """positive""" - for i in range(1, 20): - handle_keycode(_key_to_code, {}, InputEvent(*trigger, i), uinput) + for _ in range(1, 20): + handle_keycode(_key_to_code, {}, InputEvent(*trigger, 1), uinput) handle_keycode(_key_to_code, {}, InputEvent(*trigger, 0), uinput) self.assertEqual(len(uinput_write_history), 2) """negative""" - for i in range(1, 20): - handle_keycode(_key_to_code, {}, InputEvent(*trigger, -i), uinput) + for _ in range(1, 20): + handle_keycode(_key_to_code, {}, InputEvent(*trigger, -1), uinput) handle_keycode(_key_to_code, {}, InputEvent(*trigger, 0), uinput) self.assertEqual(len(uinput_write_history), 4) diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index be4063a2..c7726cf8 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -24,7 +24,7 @@ import time import multiprocessing from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_COMMA, \ - BTN_LEFT, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y + BTN_LEFT, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y, ABS_MISC from keymapper.dev.reader import keycode_reader from keymapper.state import custom_mapping @@ -244,22 +244,25 @@ class TestReader(unittest.TestCase): self.assertEqual(len(keycode_reader._unreleased), 1) def test_prioritizing_2_normalize(self): - # furthermore, 1234 is 1 in the reader, because it probably is some - # sort of continuous trigger or joystick value - pending_events['device 1'] = [ + # a value of 1234 becomes 1 in the reader in order to properly map + # it. Value like that are usually some sort of continuous trigger + # value and normal for some ev_abs events. + custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) + pending_events['gamepad'] = [ InputEvent(EV_ABS, ABS_HAT0X, 1, 1234.0000), - InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0000), # ignored - InputEvent(EV_KEY, KEY_COMMA, 1234, 1235.0010), - InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0020), # ignored - InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0030) # ignored + InputEvent(EV_ABS, ABS_MISC, 1, 1235.0000), # ignored + InputEvent(EV_ABS, ABS_Y, MAX_ABS, 1235.0010), + InputEvent(EV_ABS, ABS_MISC, 1, 1235.0020), # ignored + InputEvent(EV_ABS, ABS_MISC, 1, 1235.0030) # ignored # this time, don't release anything. the combination should # ignore stuff as well. ] - keycode_reader.start_reading('device 1') + keycode_reader.start_reading('gamepad') + time.sleep(0.5) wait(keycode_reader._pipe[0].poll, 0.5) self.assertEqual(keycode_reader.read(), ( (EV_ABS, ABS_HAT0X, 1), - (EV_KEY, KEY_COMMA, 1) + (EV_ABS, ABS_Y, 1) )) self.assertEqual(keycode_reader.read(), None) self.assertEqual(len(keycode_reader._unreleased), 2)