input-remapper/inputremapper/injection/event_reader.py
jonasBoss 7c561dde89
fix #666 (#667)
* fix #666

increase delay in the EventReader after notifying listeners

* update debug level for Context._creat_callbacks message
2023-03-26 16:47:17 +02:00

208 lines
7.1 KiB
Python

# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <proxima@sezanzeb.de>
#
# This file is part of input-remapper.
#
# input-remapper 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.
#
# input-remapper 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 input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Because multiple calls to async_read_loop won't work."""
import asyncio
import os
import traceback
from typing import AsyncIterator, Protocol, Set, List
import evdev
from inputremapper.utils import get_device_hash, DeviceHash
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
class Context(Protocol):
listeners: Set[EventListener]
def reset(self):
...
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
...
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput:
...
class EventReader:
"""Reads input events from a single device and distributes them.
There is one EventReader object for each source, which tells multiple
mapping_handlers that a new event is ready so that they can inject all sorts of
funny things.
Other devnodes may be present for the hardware device, in which case this
needs to be created multiple times.
"""
def __init__(
self,
context: Context,
source: evdev.InputDevice,
stop_event: asyncio.Event,
) -> None:
"""Initialize all mapping_handlers
Parameters
----------
source
where to read keycodes from
"""
self._device_hash = get_device_hash(source)
self._source = source
self.context = context
self.stop_event = stop_event
def stop(self):
"""Stop the reader."""
self.stop_event.set()
async def read_loop(self) -> AsyncIterator[evdev.InputEvent]:
stop_task = asyncio.Task(self.stop_event.wait())
loop = asyncio.get_running_loop()
events_ready = asyncio.Event()
loop.add_reader(self._source.fileno(), events_ready.set)
while True:
_, pending = await asyncio.wait(
{stop_task, asyncio.Task(events_ready.wait())},
return_when=asyncio.FIRST_COMPLETED,
)
fd_broken = os.stat(self._source.fileno()).st_nlink == 0
if fd_broken:
# happens when the device is unplugged while reading, causing 100% cpu
# usage because events_ready.set is called repeatedly forever,
# while read_loop will hang at self._source.read_one().
logger.error("fd broke, was the device unplugged?")
if stop_task.done() or fd_broken:
for task in pending:
task.cancel()
loop.remove_reader(self._source.fileno())
logger.debug("read loop stopped")
return
events_ready.clear()
while event := self._source.read_one():
yield event
def send_to_handlers(self, event: InputEvent) -> bool:
"""Send the event to the NotifyCallbacks.
Return if anyone took care of the event.
"""
if event.type == evdev.ecodes.EV_MSC:
return False
if event.type == evdev.ecodes.EV_SYN:
return False
handled = False
notify_callbacks = self.context.get_notify_callbacks(event)
if notify_callbacks:
for notify_callback in notify_callbacks:
handled = notify_callback(event, source=self._source) | handled
return handled
async def send_to_listeners(self, event: InputEvent) -> None:
"""Send the event to listeners."""
if event.type == evdev.ecodes.EV_MSC:
return
if event.type == evdev.ecodes.EV_SYN:
return
for listener in self.context.listeners.copy():
# use a copy, since the listeners might remove themselves form the set
# fire and forget, run them in parallel and don't wait for them, since
# a listener might be blocking forever while waiting for more events.
asyncio.ensure_future(listener(event))
# Running macros have priority, give them a head-start for processing the
# event. If if_single injects a modifier, this modifier should be active
# before the next handler injects an "a" or something, so that it is
# possible to capitalize it via if_single.
# 1. Event from keyboard arrives (e.g. an "a")
# 2. the listener for if_single is called
# 3. if_single decides runs then (e.g. injects shift_L)
# 4. The original event is forwarded (or whatever it is supposed to do)
# 5. Capitalized "A" is injected.
# So make sure to call the listeners before notifying the handlers.
for _ in range(5):
await asyncio.sleep(0)
def forward(self, event: InputEvent) -> None:
"""Forward an event, which injects it unmodified."""
forward_to = self.context.get_forward_uinput(self._device_hash)
if event.type == evdev.ecodes.EV_KEY:
logger.write(event, forward_to)
forward_to.write(*event.event_tuple)
async def handle(self, event: InputEvent) -> None:
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
# button-hold event. Environments (gnome, etc.) create them on
# their own for the injection-fake-device if the release event
# won't appear, no need to forward or map them.
return
await self.send_to_listeners(event)
if not self.send_to_handlers(event):
# no handler took care of it, forward it
self.forward(event)
async def run(self):
"""Start doing things.
Can be stopped by stopping the asyncio loop or by setting the stop_event.
This loop reads events from a single device only.
"""
logger.debug(
"Starting to listen for events from %s, fd %s",
self._source.path,
self._source.fd,
)
async for event in self.read_loop():
try:
await self.handle(
InputEvent.from_event(event, origin_hash=self._device_hash)
)
except Exception as e:
logger.error("Handling event %s failed: %s", event, e)
traceback.print_exception(e)
self.context.reset()
logger.info("read loop for %s stopped", self._source.path)