From bc0818a8e57193c2d0c3f1335380230e5c6a2371 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Fri, 1 Jan 2021 22:20:33 +0100 Subject: [PATCH] mapping joysticks like a d-pad --- data/key-mapper.glade | 2 + keymapper/config.py | 1 + keymapper/dev/ev_abs_mapper.py | 18 ++----- keymapper/dev/injector.py | 23 ++++---- keymapper/dev/keycode_mapper.py | 53 ++++++++++++------ keymapper/dev/reader.py | 34 ++++++------ keymapper/dev/utils.py | 50 +++++++++++++++++ keymapper/gtk/row.py | 14 ++++- keymapper/gtk/window.py | 1 - keymapper/key.py | 2 +- keymapper/mapping.py | 2 + readme/coverage.svg | 4 +- readme/development.md | 3 +- tests/test.py | 36 +++++++++++-- tests/testcases/test_daemon.py | 11 ++-- tests/testcases/test_ev_abs_mapper.py | 5 ++ tests/testcases/test_getdevices.py | 1 + tests/testcases/test_injector.py | 74 +++++++++++++++++++++++--- tests/testcases/test_integration.py | 10 ++-- tests/testcases/test_keycode_mapper.py | 62 +++++++++++++++++---- tests/testcases/test_reader.py | 29 +++++++++- 21 files changed, 348 insertions(+), 87 deletions(-) create mode 100644 keymapper/dev/utils.py diff --git a/data/key-mapper.glade b/data/key-mapper.glade index cff5e825..43d3a6dc 100644 --- a/data/key-mapper.glade +++ b/data/key-mapper.glade @@ -645,6 +645,7 @@ Mouse Wheel + Buttons @@ -687,6 +688,7 @@ Mouse Wheel + Buttons diff --git a/keymapper/config.py b/keymapper/config.py index 94eae4fb..f56e3458 100644 --- a/keymapper/config.py +++ b/keymapper/config.py @@ -33,6 +33,7 @@ from keymapper.logger import logger MOUSE = 'mouse' WHEEL = 'wheel' +BUTTONS = 'buttons' INITIAL_CONFIG = { 'autoload': {}, diff --git a/keymapper/dev/ev_abs_mapper.py b/keymapper/dev/ev_abs_mapper.py index bac3f762..1efea74a 100644 --- a/keymapper/dev/ev_abs_mapper.py +++ b/keymapper/dev/ev_abs_mapper.py @@ -26,10 +26,11 @@ import asyncio import time import evdev -from evdev.ecodes import EV_ABS, EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL +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 # other events for ABS include buttons @@ -117,20 +118,9 @@ async def ev_abs_mapper(abs_state, input_device, keymapper_device, mapping): mapping : Mapping the mapping object that configures the current injection """ - # since input_device.absinfo(EV_ABS).max is too new for ubuntu, - # figure out the max value via the capabilities - absinfos = [ - entry[1] for entry in - input_device.capabilities(absinfo=True)[EV_ABS] - if isinstance(entry, tuple) and isinstance(entry[1], evdev.AbsInfo) - ] + max_value = max_abs(input_device) - if len(absinfos) == 0: - return - - max_value = absinfos[0].max - - if max_value == 0: + if max_value == 0 or max_value is None: return max_speed = ((max_value ** 2) * 2) ** 0.5 diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 3f946fd5..77685fee 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -162,6 +162,11 @@ class KeycodeInjector: continue for permutation in key.get_permutations(): + if permutation.keys[-1][-1] not in [-1, 1]: + logger.error( + 'Expected values to be -1 or 1 at this point: %s', + permutation.keys + ) key_to_code[permutation.keys] = target_code return key_to_code @@ -447,6 +452,15 @@ class KeycodeInjector: ) async for event in source.async_read_loop(): + if should_map_event_as_btn(source, event, self.mapping): + handle_keycode( + self._key_to_code, + macros, + event, + uinput + ) + continue + if abs_to_rel and event.type == EV_ABS and event.code in JOYSTICK: if event.code == evdev.ecodes.ABS_X: self.abs_state[0] = event.value @@ -458,15 +472,6 @@ class KeycodeInjector: self.abs_state[3] = event.value continue - if should_map_event_as_btn(event.type, event.code): - handle_keycode( - self._key_to_code, - macros, - event, - uinput - ) - continue - # forward the rest uinput.write(event.type, event.code, event.value) # this already includes SYN events, so need to syn here again diff --git a/keymapper/dev/keycode_mapper.py b/keymapper/dev/keycode_mapper.py index 931d6f14..7c06ee2d 100644 --- a/keymapper/dev/keycode_mapper.py +++ b/keymapper/dev/keycode_mapper.py @@ -24,13 +24,16 @@ import itertools import asyncio +import math -from evdev.ecodes import EV_KEY, EV_ABS +from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY 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 @@ -55,25 +58,44 @@ active_macros = {} unreleased = {} -def should_map_event_as_btn(ev_type, code): +# 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. - - Parameters - ---------- - ev_type : int - one of evdev.events - code : int - linux keycode """ - if ev_type == EV_KEY: + if event.type == EV_KEY: return True - if ev_type == EV_ABS: - is_mousepad = 47 <= code <= 61 - if not is_mousepad and code not in JOYSTICK: + 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 @@ -217,8 +239,9 @@ def handle_keycode(key_to_code, macros, event, uinput): else: log(key, 'releasing %s', target_code) write(uinput, (target_type, target_code, 0)) - else: - # disabled keys can still be used in combinations btw + elif event.type != EV_ABS: + # ABS events might be spammed like crazy every time the position + # slightly changes log(key, 'unexpected key up') # everything that can be released is released now diff --git a/keymapper/dev/reader.py b/keymapper/dev/reader.py index c097cd30..41ae5be0 100644 --- a/keymapper/dev/reader.py +++ b/keymapper/dev/reader.py @@ -25,13 +25,15 @@ import sys import select import multiprocessing +import threading import evdev -from evdev.events import EV_KEY, EV_ABS +from evdev.ecodes import EV_KEY, EV_ABS 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 @@ -128,13 +130,13 @@ class _KeycodeReader: pipe = multiprocessing.Pipe() self._pipe = pipe - self._process = multiprocessing.Process(target=self._read_worker) + self._process = threading.Thread(target=self._read_worker) self._process.start() - def _consume_event(self, event): + def _consume_event(self, event, device): """Write the event code into the pipe if it is a key-down press.""" # value: 1 for down, 0 for up, 2 for hold. - if self._pipe[1].closed: + if self._pipe is None or self._pipe[1].closed: logger.debug('Pipe closed, reader stops.') sys.exit(0) @@ -153,20 +155,22 @@ class _KeycodeReader: # which breaks the current workflow. return - if not should_map_event_as_btn(event.type, event.code): + if not should_map_event_as_btn(device, event, custom_mapping): return - logger.spam( - 'got (%s, %s, %s)', - event.type, - event.code, - event.value - ) + if not (event.value == 0 and event.type == EV_ABS): + # avoid gamepad trigger spam + logger.spam( + 'got (%s, %s, %s)', + event.type, + event.code, + event.value + ) self._pipe[1].send(event) def _read_worker(self): - """Process that reads keycodes and buffers them into a pipe.""" - # using a process that blocks instead of read_one made it easier + """Thread that reads keycodes and buffers them into a pipe.""" + # using a thread that blocks instead of read_one made it easier # to debug via the logs, because the UI was not polling properly # at some point which caused logs for events not to be written. rlist = {device.fd: device for device in self.virtual_devices} @@ -175,7 +179,7 @@ class _KeycodeReader: while True: ready = select.select(rlist, [], [])[0] for fd in ready: - readable = rlist[fd] + readable = rlist[fd] # a device or a pipe if isinstance(readable, multiprocessing.connection.Connection): msg = readable.recv() if msg == CLOSE: @@ -185,7 +189,7 @@ class _KeycodeReader: try: for event in rlist[fd].read(): - self._consume_event(event) + self._consume_event(event, readable) except OSError: logger.debug( 'Device "%s" disappeared from the reader', diff --git a/keymapper/dev/utils.py b/keymapper/dev/utils.py new file mode 100644 index 00000000..12b01204 --- /dev/null +++ b/keymapper/dev/utils.py @@ -0,0 +1,50 @@ +#!/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 for all other modules in keymapper.dev""" + + +import evdev +from evdev.ecodes import EV_ABS + + +def max_abs(device): + """Figure out the maximum value of EV_ABS events of that device. + + Like joystick movements or triggers. + """ + # since input_device.absinfo(EV_ABS).max is too new for (some?) ubuntus, + # figure out the max value via the capabilities + capabilities = device.capabilities(absinfo=True) + + if EV_ABS not in capabilities: + return None + + absinfos = [ + entry[1] for entry in + capabilities[EV_ABS] + if isinstance(entry, tuple) and isinstance(entry[1], evdev.AbsInfo) + ] + + if len(absinfos) == 0: + return None + + return absinfos[0].max diff --git a/keymapper/gtk/row.py b/keymapper/gtk/row.py index 657d14c3..e5416df7 100644 --- a/keymapper/gtk/row.py +++ b/keymapper/gtk/row.py @@ -62,6 +62,7 @@ def to_string(key): if ev_type != evdev.ecodes.EV_KEY: direction = { + # D-Pad (evdev.ecodes.ABS_HAT0X, -1): 'L', (evdev.ecodes.ABS_HAT0X, 1): 'R', (evdev.ecodes.ABS_HAT0Y, -1): 'U', @@ -74,6 +75,15 @@ def to_string(key): (evdev.ecodes.ABS_HAT2X, 1): 'R', (evdev.ecodes.ABS_HAT2Y, -1): 'U', (evdev.ecodes.ABS_HAT2Y, 1): 'D', + # joystick + (evdev.ecodes.ABS_X, 1): 'R', + (evdev.ecodes.ABS_X, -1): 'L', + (evdev.ecodes.ABS_Y, 1): 'D', + (evdev.ecodes.ABS_Y, -1): 'U', + (evdev.ecodes.ABS_RX, 1): 'R', + (evdev.ecodes.ABS_RX, -1): 'L', + (evdev.ecodes.ABS_RY, 1): 'D', + (evdev.ecodes.ABS_RY, -1): 'U', }.get((code, value)) if direction is not None: key_name += f' {direction}' @@ -115,13 +125,15 @@ class Row(Gtk.ListBoxRow): def release(self): """Tell the row that no keys are currently pressed down.""" - if self.state == HOLDING: + if self.state == HOLDING and self.get_key() is not None: # A key was pressed and then released. # Switch to the character. idle_add this so that the # keycode event won't write into the character input as well. window = self.window.window GLib.idle_add(lambda: window.set_focus(self.character_input)) + self.state = IDLE + def get_key(self): """Get the Key object from the left column. diff --git a/keymapper/gtk/window.py b/keymapper/gtk/window.py index d0232c57..ab025136 100755 --- a/keymapper/gtk/window.py +++ b/keymapper/gtk/window.py @@ -170,7 +170,6 @@ class Window: self.get('gamepad_config').hide() self.populate_devices() - self.select_newest_preset() self.timeouts = [ diff --git a/keymapper/key.py b/keymapper/key.py index 1ff8cbdc..9755701a 100644 --- a/keymapper/key.py +++ b/keymapper/key.py @@ -32,7 +32,7 @@ def verify(key): if not isinstance(key, tuple) or len(key) != 3: raise ValueError(f'Expected key to be a 3-tuple, but got {key}') if sum([not isinstance(value, int) for value in key]) != 0: - raise ValueError(f'Can only use numbers, but got {key}') + raise ValueError(f'Can only use integers, but got {key}') # having shift in combinations modifies the configured output, diff --git a/keymapper/mapping.py b/keymapper/mapping.py index a1fbc52b..52d17ea9 100644 --- a/keymapper/mapping.py +++ b/keymapper/mapping.py @@ -249,3 +249,5 @@ class Mapping(ConfigBase): existing = self._mapping.get(permutation) if existing is not None: return existing + + return None diff --git a/readme/coverage.svg b/readme/coverage.svg index c6421901..d6e67f4f 100644 --- a/readme/coverage.svg +++ b/readme/coverage.svg @@ -17,7 +17,7 @@ coverage - 91% - 91% + 92% + 92% \ No newline at end of file diff --git a/readme/development.md b/readme/development.md index ec412f42..6188561b 100644 --- a/readme/development.md +++ b/readme/development.md @@ -27,7 +27,8 @@ requests. - [x] start the daemon in such a way to not require usermod - [x] mapping a combined button press to a key - [x] add "disable" as mapping option -- [ ] mapping joystick directions as buttons, making it act like a D-Pad +- [x] mapping joystick directions as buttons, making it act like a D-Pad +- [ ] mapping mouse wheel events to buttons - [ ] automatically load presets when devices get plugged in after login (udev) - [ ] configure locale for preset to provide a different set of possible keys - [ ] user-friendly way to map btn_left diff --git a/tests/test.py b/tests/test.py index 37bf420f..e8903386 100644 --- a/tests/test.py +++ b/tests/test.py @@ -71,6 +71,16 @@ uinput_write_history = [] uinput_write_history_pipe = multiprocessing.Pipe() pending_events = {} + +def read_write_history_pipe(): + """convert the write history from the pipe 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)) + return history + + # key-mapper is only interested in devices that have EV_KEY, add some # random other stuff to test that they are ignored. phys_1 = 'usb-0000:03:00.0-1/input2' @@ -197,6 +207,9 @@ class InputEvent: self.sec = int(timestamp) self.usec = timestamp % 1 * 1000000 + def __str__(self): + return f'InputEvent{self.t}' + def patch_paths(): from keymapper import paths @@ -222,6 +235,9 @@ def patch_select(): if len(pending_events.get(thing, [])) > 0: ret.append(thing) + # avoid a fast iterating infinite loop in the reader + time.sleep(0.01) + return [ret, [], []] select.select = new_select @@ -242,6 +258,13 @@ class InputDevice: self.name = fixtures[path]['name'] self.fd = self.name + def log(self, key, msg): + print( + f'\033[90m' # color + f'{msg} "{self.name}" "{self.phys}" {key}' + '\033[0m' # end style + ) + def absinfo(self, *args): raise Exception('Ubuntus version of evdev doesn\'t support .absinfo') @@ -264,6 +287,7 @@ class InputDevice: return None event = pending_events[self.name].pop(0) + self.log(event, 'read_one') return event def read_loop(self): @@ -272,7 +296,9 @@ class InputDevice: return while len(pending_events[self.name]) > 0: - yield pending_events[self.name].pop(0) + result = pending_events[self.name].pop(0) + self.log(result, 'read_loop') + yield result time.sleep(EVENT_READ_TIMEOUT) async def async_read_loop(self): @@ -281,7 +307,9 @@ class InputDevice: return while len(pending_events[self.name]) > 0: - yield pending_events[self.name].pop(0) + result = pending_events[self.name].pop(0) + self.log(result, 'async_read_loop') + yield result await asyncio.sleep(0.01) def capabilities(self, absinfo=True): @@ -375,6 +403,9 @@ def cleanup(): task.cancel() os.system('pkill -f key-mapper-service') + + time.sleep(0.05) + if os.path.exists(tmp): shutil.rmtree(tmp) @@ -436,7 +467,6 @@ def main(): print() unittest.TextTestResult.startTest = start_test - unittest.TextTestRunner(verbosity=2).run(testsuite) diff --git a/tests/testcases/test_daemon.py b/tests/testcases/test_daemon.py index 196b4890..f9dc5557 100644 --- a/tests/testcases/test_daemon.py +++ b/tests/testcases/test_daemon.py @@ -148,6 +148,7 @@ class TestDaemon(unittest.TestCase): self.daemon = Daemon() preset_path = get_preset_path(device, preset) + self.assertFalse(uinput_write_history_pipe[0].poll()) self.daemon.start_injecting(device, preset_path) self.assertTrue(self.daemon.is_injecting(device)) @@ -161,6 +162,13 @@ class TestDaemon(unittest.TestCase): self.daemon.stop_injecting(device) self.assertFalse(self.daemon.is_injecting(device)) + time.sleep(0.2) + try: + self.assertFalse(uinput_write_history_pipe[0].poll()) + except AssertionError: + print(uinput_write_history_pipe[0].recv()) + raise + """injection 2""" # -1234 will be normalized to -1 by the injector @@ -168,9 +176,6 @@ class TestDaemon(unittest.TestCase): InputEvent(*ev_2, -1234) ] - time.sleep(0.2) - self.assertFalse(uinput_write_history_pipe[0].poll()) - path = get_preset_path(device, preset) self.daemon.start_injecting(device, path) diff --git a/tests/testcases/test_ev_abs_mapper.py b/tests/testcases/test_ev_abs_mapper.py index d950b3c8..26d06408 100644 --- a/tests/testcases/test_ev_abs_mapper.py +++ b/tests/testcases/test_ev_abs_mapper.py @@ -27,6 +27,7 @@ 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, \ @@ -59,6 +60,10 @@ 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 e46e66fa..41cb6e17 100644 --- a/tests/testcases/test_getdevices.py +++ b/tests/testcases/test_getdevices.py @@ -118,5 +118,6 @@ class TestGetDevices(unittest.TestCase): EV_KEY: [evdev.ecodes.ABS_X] # intentionally ABS_X (0) on EV_KEY })) + if __name__ == "__main__": unittest.main() diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index 07d508ce..e8a328c7 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -24,19 +24,19 @@ import time import copy import evdev -from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X +from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, ABS_RX, ABS_Y 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 +from keymapper.config import config, BUTTONS from keymapper.key import Key from keymapper.dev.macros import parse from tests.test import InputEvent, pending_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ - MAX_ABS, cleanup + MAX_ABS, cleanup, read_write_history_pipe class TestInjector(unittest.TestCase): @@ -393,10 +393,7 @@ class TestInjector(unittest.TestCase): self.injector._msg_pipe[1].send(1234) # 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() # 1 event before the combination was triggered (+1 for release) # 4 events for the macro @@ -448,6 +445,69 @@ 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) + + rx_right = (EV_ABS, ABS_RX, MAX_ABS) + rx_release = (EV_ABS, ABS_RX, -MAX_ABS // 4) + + custom_mapping.change(Key(*y_up[:2], -1), 'w') + custom_mapping.change(Key(*rx_right[:2], 1), 'k(d)') + + system_mapping.clear() + code_w = 71 + code_d = 74 + system_mapping._set('w', code_w) + system_mapping._set('d', code_d) + + def do_stuff(): + if self.injector is not None: + 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), + ] + + self.injector = KeycodeInjector('gamepad', custom_mapping) + 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""" + + history = do_stuff() + self.assertEqual(history.count((EV_KEY, code_w, 1)), 0) + self.assertEqual(history.count((EV_KEY, code_d, 1)), 0) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 0) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 0) + + """left purpose buttons""" + + 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) + history = do_stuff() + self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) + def test_store_permutations_for_macros(self): mapping = Mapping() ev_1 = (EV_KEY, 41, 1) diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index 49831114..5fd2a4bd 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -231,9 +231,11 @@ class TestIntegration(unittest.TestCase): self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_9, 1)), '9') self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_SEMICOLON, 1)), 'SEMICOLON') self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1)), 'ABS_HAT0X L') - self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1)), 'ABS_HAT0X R') + self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1)), 'ABS_HAT0Y U') self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.BTN_A, 1)), 'BTN_A') self.assertEqual(to_string(Key(EV_KEY, 1234, 1)), 'unknown') + self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_X, 1)), 'ABS_X R') + self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_RY, 1)), 'ABS_RY D') # combinations self.assertEqual(to_string(Key( @@ -334,7 +336,7 @@ class TestIntegration(unittest.TestCase): keycode_reader._pipe[1].send(InputEvent(*sub_key)) # make the window consume the keycode - time.sleep(0.05) + time.sleep(0.06) gtk_iteration() # holding down @@ -347,7 +349,7 @@ class TestIntegration(unittest.TestCase): keycode_reader._pipe[1].send(InputEvent(*sub_key[:2], 0)) # make the window consume the keycode - time.sleep(0.05) + time.sleep(0.06) gtk_iteration() # released @@ -367,6 +369,8 @@ class TestIntegration(unittest.TestCase): css_classes = row.get_style_context().list_classes() self.assertNotIn('changed', css_classes) self.assertEqual(row.state, IDLE) + # it won't switch the focus to the character input + self.assertTrue(row.keycode_input.is_focus()) return row if char and code_first: diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 0aee8245..8c3dc82e 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -31,11 +31,11 @@ from keymapper.dev.keycode_mapper import should_map_event_as_btn, \ active_macros, handle_keycode, unreleased, subsets, log from keymapper.state import system_mapping from keymapper.dev.macros import parse -from keymapper.config import config +from keymapper.config import config, BUTTONS from keymapper.mapping import Mapping, DISABLE_CODE from tests.test import InputEvent, UInput, uinput_write_history, \ - cleanup + cleanup, InputDevice, MAX_ABS def wait(func, timeout=1.0): @@ -192,19 +192,61 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(uinput_write_history[3].t, (EV_KEY, 51, 0)) def test_should_map_event_as_btn(self): - self.assertTrue(should_map_event_as_btn(EV_ABS, ABS_HAT0X)) - self.assertTrue(should_map_event_as_btn(EV_KEY, KEY_A)) - self.assertFalse(should_map_event_as_btn(EV_ABS, ABS_X)) - self.assertFalse(should_map_event_as_btn(EV_REL, REL_X)) + device = InputDevice('/dev/input/event30') + mapping = Mapping() - self.assertFalse(should_map_event_as_btn(EV_ABS, ecodes.ABS_MT_SLOT)) - self.assertFalse(should_map_event_as_btn(EV_ABS, ecodes.ABS_MT_TOOL_Y)) - self.assertFalse(should_map_event_as_btn(EV_ABS, ecodes.ABS_MT_POSITION_X)) + # 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, - ((EV_KEY, 2, 1),): 102 + ((EV_KEY, 2, 1),): 102 } uinput = UInput() diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index 627f4681..be4063a2 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -24,11 +24,14 @@ 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 + BTN_LEFT, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y from keymapper.dev.reader import keycode_reader +from keymapper.state import custom_mapping +from keymapper.config import BUTTONS, MOUSE -from tests.test import InputEvent, pending_events, EVENT_READ_TIMEOUT, cleanup +from tests.test import InputEvent, pending_events, EVENT_READ_TIMEOUT, \ + cleanup, MAX_ABS CODE_1 = 100 @@ -88,6 +91,28 @@ class TestReader(unittest.TestCase): self.assertEqual(keycode_reader.read(), None) self.assertEqual(len(keycode_reader._unreleased), 3) + def test_reads_joysticks(self): + # if their purpose is "buttons" + custom_mapping.set('gamepad.joystick.left_purpose', BUTTONS) + pending_events['gamepad'] = [ + InputEvent(EV_ABS, ABS_Y, MAX_ABS) + ] + keycode_reader.start_reading('gamepad') + wait(keycode_reader._pipe[0].poll, 0.5) + self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_Y, 1)) + self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) + + keycode_reader._unreleased = {} + custom_mapping.set('gamepad.joystick.left_purpose', MOUSE) + pending_events['gamepad'] = [ + InputEvent(EV_ABS, ABS_Y, MAX_ABS) + ] + keycode_reader.start_reading('gamepad') + time.sleep(0.1) + self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 0) + def test_ignore_btn_left(self): # click events are ignored because overwriting them would render the # mouse useless, but a mouse is needed to stop the injection