diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py index 3d1694d3f..e8a4d8312 100644 --- a/inputremapper/injection/context.py +++ b/inputremapper/injection/context.py @@ -20,6 +20,7 @@ """Stores injection-process wide information.""" +import asyncio from typing import Awaitable, List, Dict, Tuple, Protocol, Set import evdev @@ -90,6 +91,11 @@ def __init__(self, preset: Preset): self._create_callbacks() + def reset(self) -> None: + """call the reset method for each handler in the context""" + for handlers in self._handlers.values(): + [handler.reset() for handler in handlers] + def _create_callbacks(self) -> None: """add the notify method from all _handlers to self.callbacks""" for event, handler_list in self._handlers.items(): diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py index d5fa86d01..65a701696 100644 --- a/inputremapper/injection/event_reader.py +++ b/inputremapper/injection/event_reader.py @@ -23,10 +23,38 @@ import asyncio import evdev from inputremapper.logger import logger -from inputremapper.input_event import InputEvent +from inputremapper.input_event import InputEvent, EventActions from inputremapper.injection.context import Context +class _ReadLoop: + def __init__(self, device: evdev.InputDevice, stop_event: asyncio.Event): + self.iterator = device.async_read_loop().__aiter__() + self.stop_event = stop_event + self.wait_for_stop = asyncio.Task(stop_event.wait()) + + def __aiter__(self): + return self + + def __anext__(self): + if self.stop_event.is_set(): + raise StopAsyncIteration + + return self.future() + + async def future(self): + ev_task = asyncio.Task(self.iterator.__anext__()) + stop_task = self.wait_for_stop + done, pending = await asyncio.wait( + {ev_task, stop_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if stop_task in done: + raise StopAsyncIteration + + return done.pop().result() + + class EventReader: """Reads input events from a single device and distributes them. @@ -43,6 +71,7 @@ def __init__( context: Context, source: evdev.InputDevice, forward_to: evdev.UInput, + stop_event: asyncio.Event, ) -> None: """Initialize all mapping_handlers @@ -58,6 +87,7 @@ def __init__( self._source = source self._forward_to = forward_to self.context = context + self.stop_event = stop_event def send_to_handlers(self, event: InputEvent) -> bool: """Send the event to callback.""" @@ -132,9 +162,10 @@ async def run(self): self._source.fd, ) - async for event in self._source.async_read_loop(): + async for event in _ReadLoop(self._source, self.stop_event): await self.handle(InputEvent.from_event(event)) + self.context.reset() # This happens all the time in tests because the async_read_loop stops when # there is nothing to read anymore. Otherwise tests would block. logger.error('The async_read_loop for "%s" stopped early', self._source.path) diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 713867074..691c3063f 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -95,6 +95,7 @@ class Injector(multiprocessing.Process): _state: int _msg_pipe: multiprocessing.Pipe _consumer_controls: List[EventReader] + _stop_event: asyncio.Event regrab_timeout = 0.2 @@ -118,6 +119,7 @@ def __init__(self, group: _Group, preset: Preset) -> None: self.context = None # only needed inside the injection process self._consumer_controls = [] + self._stop_event = None super().__init__(name=group) @@ -262,6 +264,11 @@ async def _msg_listener(self) -> None: msg = self._msg_pipe[0].recv() if msg == CLOSE: logger.debug("Received close signal") + self._stop_event.set() + # give the event pipeline some time to reset devices + # before shutting the loop down + await asyncio.sleep(0.1) + # stop the event loop and cause the process to reach its end # cleanly. Using .terminate prevents coverage from working. loop.stop() @@ -306,6 +313,7 @@ def run(self) -> None: # create this within the process after the event loop creation, # so that the macros use the correct loop self.context = Context(self.preset) + self._stop_event = asyncio.Event() # grab devices as early as possible. If events appear that won't get # released anymore before the grab they appear to be held down @@ -349,7 +357,9 @@ def run(self) -> None: raise e # actually doing things - consumer_control = EventReader(self.context, source, forward_to) + consumer_control = EventReader( + self.context, source, forward_to, self._stop_event + ) coroutines.append(consumer_control.run()) self._consumer_controls.append(consumer_control) diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py index 7b184e100..3807f1e73 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -85,7 +85,7 @@ def notify( self, event: InputEvent, source: evdev.InputDevice, - forward: evdev.UInput = None, + forward: evdev.UInput, supress: bool = False, ) -> bool: if event.type_and_code != self._input_event.type_and_code: @@ -121,3 +121,7 @@ def notify( forward=forward, supress=supress, ) + + def reset(self) -> None: + self._active = False + self._sub_handler.reset() diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py index 9e79ebdd0..555a1c5c6 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -188,6 +188,9 @@ def notify( asyncio.ensure_future(self._run()) return True + def reset(self) -> None: + self._stop = True + @staticmethod def _calc_qubic(x: float, k: float) -> float: """ diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py index 3547afc9f..d90a82784 100644 --- a/inputremapper/injection/mapping_handlers/axis_switch_handler.py +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -128,6 +128,11 @@ def notify( return False + def reset(self) -> None: + self._last_value = 0 + self._active = False + self._sub_handler.reset() + def needs_wrapping(self) -> bool: return True diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py index acad07a00..b604fcc3f 100644 --- a/inputremapper/injection/mapping_handlers/combination_handler.py +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -113,6 +113,12 @@ def notify( self._output_state = bool(event.value) return self._sub_handler.notify(event, source, forward, supress) + def reset(self) -> None: + self._sub_handler.reset() + for key in self._key_map: + self._key_map[key] = False + self._output_state = False + def get_active(self) -> bool: """return if all keys in the keymap are set to True""" return False not in self._key_map.values() diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py index e42976ff2..fe658870f 100644 --- a/inputremapper/injection/mapping_handlers/hierarchy_handler.py +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -79,6 +79,10 @@ def notify( handler.notify(event, source, forward, supress=True) return success + def reset(self) -> None: + for sub_handler in self.handlers: + sub_handler.reset() + def wrap_with(self) -> Dict[EventCombination, HandlerEnums]: if self._input_event.type == EV_ABS and self._input_event.value != 0: return {EventCombination(self._input_event): HandlerEnums.abs2btn} diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py index 035aa3f4b..c93a39d53 100644 --- a/inputremapper/injection/mapping_handlers/key_handler.py +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -78,6 +78,13 @@ def notify(self, event: InputEvent, *_, **__) -> bool: except exceptions.Error: return False + def reset(self) -> None: + logger.debug("resetting key_handler") + if self._active: + event_tuple = (*self._maps_to, 0) + global_uinputs.write(event_tuple, self.mapping.target_uinput) + self._active = False + def needs_wrapping(self) -> bool: return True diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py index d3d6f1f1f..b5c789396 100644 --- a/inputremapper/injection/mapping_handlers/macro_handler.py +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -88,6 +88,11 @@ def f(ev_type, code, value) -> None: return True + def reset(self) -> None: + self._active = False + if self._macro.is_holding(): + self._macro.release_trigger() + def needs_wrapping(self) -> bool: return True diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py index 23fa762e6..3bffbb3d4 100644 --- a/inputremapper/injection/mapping_handlers/mapping_handler.py +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -97,6 +97,10 @@ def notify( ) -> bool: ... + def reset(self) -> None: + """reset the state of the handler e.g. release any buttons""" + ... + class HandlerEnums(enum.Enum): # converting to btn diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py index ae13c55ba..5981e8e5d 100644 --- a/inputremapper/injection/mapping_handlers/null_handler.py +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -55,3 +55,6 @@ def notify( supress: bool = False, ) -> bool: return True + + def reset(self) -> None: + pass diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py index e422447bf..55fa2caeb 100644 --- a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -107,7 +107,7 @@ def notify( # the axis is below the threshold and the stage_release function is running event = event.modify(value=0, action=EventActions.as_key) logger.debug_key(event.event_tuple, "sending to sub_handler") - self._abort_release = True # abort the stage release + self._abort_release = True self._active = False return self._sub_handler.notify(event, source, forward, supress) else: @@ -123,3 +123,10 @@ def notify( asyncio.ensure_future(self._stage_release(source, forward, supress)) self._active = True return self._sub_handler.notify(event, source, forward, supress) + + def reset(self) -> None: + if self._active: + self._abort_release = True + + self._active = False + self._sub_handler.reset() diff --git a/tests/unit/test_event_pipeline.py b/tests/unit/test_event_pipeline.py index e3391d1c5..feb8ba96a 100644 --- a/tests/unit/test_event_pipeline.py +++ b/tests/unit/test_event_pipeline.py @@ -80,6 +80,7 @@ def setUp(self): # print("in setup") # global_uinputs.prepare_all() self.forward_uinput = evdev.UInput() + self.stop_event = asyncio.Event() def tearDown(self) -> None: cleanup() @@ -94,10 +95,11 @@ def get_event_reader( self, preset: Preset, source: evdev.InputDevice ) -> EventReader: context = Context(preset) - return EventReader(context, source, self.forward_uinput) + return EventReader(context, source, self.forward_uinput, self.stop_event) async def test_any_event_as_button(self): - """as long as there is an event handler and a mapping we should be able to map anything to a button""" + """as long as there is an event handler and a mapping we should be able + to map anything to a button""" w_down = ( EV_ABS, @@ -194,6 +196,57 @@ async def test_any_event_as_button(self): self.assertEqual(history.count((EV_KEY, code_a, 0)), 1) self.assertEqual(history.count((EV_KEY, code_s, 0)), 1) + async def test_reset_releases_keys(self): + """make sure that macros and keys are releases when the stop event is set""" + preset = Preset() + preset.add(get_key_mapping(combination="1,1,1", output_symbol="hold(a)")) + preset.add(get_key_mapping(combination="1,2,1", output_symbol="b")) + preset.add( + get_key_mapping(combination="1,3,1", output_symbol="modify(c,hold(d))") + ) + event_reader = self.get_event_reader(preset, InputDevice("/dev/input/event10")) + + a = system_mapping.get("a") + b = system_mapping.get("b") + c = system_mapping.get("c") + d = system_mapping.get("d") + + await self.send_events( + [ + InputEvent.from_tuple((1, 1, 1)), + InputEvent.from_tuple((1, 2, 1)), + InputEvent.from_tuple((1, 3, 1)), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(len(fw_history), 0) + # a down, b down, c down, d down + self.assertEqual(len(kb_history), 4) + + event_reader.context.reset() + await asyncio.sleep(0.1) + + fw_history = convert_to_internal_events(self.forward_uinput.write_history) + kb_history = convert_to_internal_events( + global_uinputs.get_uinput("keyboard").write_history + ) + + self.assertEqual(len(fw_history), 0) + # all a, b, c, d down+up + self.assertEqual(len(kb_history), 8) + kb_history = kb_history[-4:] + self.assertIn((1, a, 0), kb_history) + self.assertIn((1, b, 0), kb_history) + self.assertIn((1, c, 0), kb_history) + self.assertIn((1, d, 0), kb_history) + async def test_abs_to_rel(self): """map gamepad EV_ABS events to EV_REL events""" diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py index 90282a329..a18ae58fd 100644 --- a/tests/unit/test_event_reader.py +++ b/tests/unit/test_event_reader.py @@ -52,6 +52,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase): def setUp(self): self.gamepad_source = evdev.InputDevice("/dev/input/event30") + self.stop_event = asyncio.Event() self.preset = Preset() def tearDown(self): @@ -62,7 +63,7 @@ def setup(self, source, mapping): forward_to = evdev.UInput() context = Context(mapping) context.uinput = evdev.UInput() - consumer_control = EventReader(context, source, forward_to) + consumer_control = EventReader(context, source, forward_to, self.stop_event) # for consumer in consumer_control._consumers: # consumer._abs_range = (-10, 10) asyncio.ensure_future(consumer_control.run()) diff --git a/tests/unit/test_macros.py b/tests/unit/test_macros.py index 6fe772fbe..ba93f302e 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -1377,7 +1377,7 @@ async def test_if_tap_none(self): self.assertListEqual(self.result, []) # second param none - macro = parse("if_tap(key(y), , 50)", self.context) + macro = parse("if_tap(key(y), , 50)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 1) y = system_mapping.get("y") macro.press_trigger()