Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Input event with origin #550

Merged
merged 37 commits into from
Dec 15, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
fdbe5d4
Update InputEvent to include origin and analog threshold
jonasBoss Nov 20, 2022
4738843
preset save file migration
jonasBoss Nov 20, 2022
df386bd
Merge branch 'beta' into input-event-with-origin
jonasBoss Nov 21, 2022
5b321f0
Use the InputEvent.analog_threshold field instead of value
jonasBoss Nov 22, 2022
fb1d407
Merge branch 'beta' into input-event-with-origin
jonasBoss Nov 24, 2022
56501b8
Make tests start in pycharm again
jonasBoss Nov 24, 2022
2fadc1f
Seperate InputEvent and InputConfiguration
jonasBoss Nov 24, 2022
db652a5
integration tests
jonasBoss Nov 26, 2022
3f6e81d
simplyfied InputEvent
jonasBoss Nov 26, 2022
af96ce1
move find_analog_input_event to InputCombination
jonasBoss Nov 26, 2022
53f2a49
rename InputConfiguration to InputConfig
jonasBoss Nov 26, 2022
b41bcce
move input_configuration.py to configs/input_config.py
jonasBoss Nov 26, 2022
0b88d79
simplified imports
jonasBoss Nov 26, 2022
e76c30b
rename event_combination to input_combination
jonasBoss Nov 26, 2022
705d97b
mypy
jonasBoss Dec 1, 2022
e518a05
use event origin information
jonasBoss Dec 2, 2022
0f724e4
fix reader-service and use md5 hashing
jonasBoss Dec 2, 2022
fc95635
Updated Mapping handlers
jonasBoss Dec 2, 2022
9da39c6
Updated Tests
jonasBoss Dec 2, 2022
2f168a1
Merge branch 'beta' into input-event-with-origin
jonasBoss Dec 6, 2022
11dce4c
fix test_injector
jonasBoss Dec 6, 2022
b7ca337
mypy
jonasBoss Dec 6, 2022
d67dd32
integration tests
jonasBoss Dec 7, 2022
c3b30cf
added unit tests
jonasBoss Dec 9, 2022
e5f0159
usage.md
jonasBoss Dec 10, 2022
9af4a59
refactor injector grab deviece logic
jonasBoss Dec 11, 2022
6c1d8f9
InputConfig docstring
jonasBoss Dec 11, 2022
6067285
refactor migrations._input_combination_from_string
jonasBoss Dec 11, 2022
f94e5e7
amend
jonasBoss Dec 11, 2022
61fe702
refactor temprary preset migration
jonasBoss Dec 11, 2022
5943fd0
made notify_callbacks private
jonasBoss Dec 12, 2022
925ffad
constrain origin to lowercase
jonasBoss Dec 12, 2022
7639422
tabs 'n spaces
jonasBoss Dec 12, 2022
7010955
mypy
jonasBoss Dec 12, 2022
aa3d868
mypy
jonasBoss Dec 13, 2022
699a6f8
Signal no longer inherits Message protocol
jonasBoss Dec 13, 2022
a1d1641
rename origin to origin_hash
jonasBoss Dec 14, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
use event origin information
  • Loading branch information
jonasBoss committed Dec 2, 2022
commit e518a057f22067bb204e1c5bbc245a0807f8e4c4
12 changes: 11 additions & 1 deletion inputremapper/configs/input_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from __future__ import annotations

import itertools
from typing import Tuple, Iterable, Union, List, Dict, Optional
from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable

from evdev import ecodes
from pydantic import BaseModel, root_validator, validator
Expand Down Expand Up @@ -51,6 +51,16 @@ class InputConfig(BaseModel):
origin: Optional[int] = None
analog_threshold: Optional[int] = None

@property
def input_match_hash(self) -> Hashable:
"""a Hashable object which is intended to match the InputConfig with a
InputEvent.

InputConfig itself is hashable, but can not be used to match InputEvent's
because its hash includes the analog_threshold
"""
return self.type, self.code, self.origin

@property
def defines_analog_input(self) -> bool:
"""Whether this defines an analog input"""
Expand Down
2 changes: 1 addition & 1 deletion inputremapper/configs/preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def __init__(

def __iter__(self) -> Iterator[MappingModel]:
"""Iterate over Mapping objects."""
return iter(self._mappings.values())
return iter(self._mappings.copy().values())

def __len__(self) -> int:
return len(self._mappings)
Expand Down
15 changes: 9 additions & 6 deletions inputremapper/injection/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@

"""Stores injection-process wide information."""
from collections import defaultdict
from typing import List, Dict, Tuple, Set
from typing import List, Dict, Tuple, Set, Hashable

from inputremapper.configs.preset import Preset
from inputremapper.injection.mapping_handlers.mapping_handler import (
InputEventHandler,
EventListener,
NotifyCallback,
)
from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings
from inputremapper.injection.mapping_handlers.mapping_parser import (
parse_mappings,
EventPipelines,
)
from inputremapper.input_event import InputEvent


Expand Down Expand Up @@ -61,8 +64,8 @@ class Context:
"""

listeners: Set[EventListener]
notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
_handlers: Dict[InputEvent, Set[InputEventHandler]]
notify_callbacks: Dict[Hashable, List[NotifyCallback]]
_handlers: EventPipelines

def __init__(self, preset: Preset):
self.listeners = set()
Expand All @@ -79,7 +82,7 @@ def reset(self) -> None:

def _create_callbacks(self) -> None:
"""Add the notify method from all _handlers to self.callbacks."""
for event, handler_list in self._handlers.items():
self.notify_callbacks[event.type_and_code].extend(
for input_config, handler_list in self._handlers.items():
self.notify_callbacks[input_config.input_match_hash].extend(
handler.notify for handler in handler_list
)
10 changes: 6 additions & 4 deletions inputremapper/injection/event_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@

import asyncio
import os
from typing import AsyncIterator, Protocol, Set, Dict, Tuple, List
from typing import AsyncIterator, Protocol, Set, Dict, List, Hashable

import evdev

from inputremapper.utils import get_device_hash
from inputremapper.injection.mapping_handlers.mapping_handler import (
EventListener,
NotifyCallback,
Expand All @@ -36,7 +37,7 @@

class Context(Protocol):
listeners: Set[EventListener]
notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]]
notify_callbacks: Dict[Hashable, List[NotifyCallback]]

def reset(self):
...
Expand Down Expand Up @@ -71,6 +72,7 @@ def __init__(
Should be an UInput with capabilities that work for all forwarded
events, so ideally they should be copied from source.
"""
self._device_hash = get_device_hash(source)
self._source = source
self._forward_to = forward_to
self.context = context
Expand Down Expand Up @@ -119,7 +121,7 @@ def send_to_handlers(self, event: InputEvent) -> bool:
return False

results = set()
notify_callbacks = self.context.notify_callbacks.get(event.type_and_code)
notify_callbacks = self.context.notify_callbacks.get(event.input_match_hash)
if notify_callbacks:
for notify_callback in notify_callbacks:
results.add(
Expand Down Expand Up @@ -191,7 +193,7 @@ async def run(self):
self._source.fd,
)
async for event in self.read_loop():
await self.handle(InputEvent.from_event(event))
await self.handle(InputEvent.from_event(event, origin=self._device_hash))

self.context.reset()
logger.info("read loop for %s stopped", self._source.path)
81 changes: 55 additions & 26 deletions inputremapper/injection/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

import evdev

from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.preset import Preset
from inputremapper.groups import (
_Group,
Expand All @@ -44,6 +44,7 @@
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
from inputremapper.logger import logger
from inputremapper.utils import get_device_hash

CapabilitiesDict = Dict[int, List[int]]
GroupSources = List[evdev.InputDevice]
Expand Down Expand Up @@ -188,7 +189,23 @@ def stop_injecting(self) -> None:

"""Process internal stuff."""

def _grab_devices(self) -> GroupSources:
@staticmethod
def _find_input_device(
devices: List[evdev.InputDevice], input_config: InputConfig
) -> Optional[evdev.InputDevice]:
devices_by_hash = {get_device_hash: device for device in devices}

def has_type_and_code(device, type, code) -> bool:
return code in device.capabilities(absinfo=False).get(type, [])

if device := devices_by_hash.get(input_config.origin):
if has_type_and_code(device, *input_config.type_and_code):
return device

"""Fallback logic"""
logger.warning(
f"Can not find correct device for {input_config.origin} " f"trying fallback"
)
ranking = [
DeviceType.KEYBOARD,
DeviceType.GAMEPAD,
Expand All @@ -198,6 +215,23 @@ def _grab_devices(self) -> GroupSources:
DeviceType.CAMERA,
DeviceType.UNKNOWN,
]
candidates: List[evdev.InputDevice] = [
device
for device in devices
if input_config.code
in device.capabilities(absinfo=False).get(input_config.type, [])
]
if len(candidates) > 1:
# there is more than on input device which can be used for this
# event we choose only one determined by the ranking
return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0]
elif len(candidates) == 1:
return candidates.pop()

logger.error(f"Could not find input for {input_config}")
return None

def _grab_devices(self) -> GroupSources:

# all devices in this group
devices: List[evdev.InputDevice] = []
Expand All @@ -213,25 +247,20 @@ def _grab_devices(self) -> GroupSources:
needed_devices = {}

for mapping in self.preset:
for event in mapping.input_combination:
candidates: List[evdev.InputDevice] = [
device
for device in devices
if event.code
in device.capabilities(absinfo=False).get(event.type, [])
]
if len(candidates) > 1:
# there is more than on input device which can be used for this
# event we choose only one determined by the ranking
device = sorted(
candidates, key=lambda d: ranking.index(classify(d))
)[0]
elif len(candidates) == 1:
device = candidates.pop()
else:
logger.error("Could not find input for %s in %s", event, mapping)
continue
needed_devices[device.path] = device
update_configs = {}
for input_config in mapping.input_combination:
if device := self._find_input_device(devices, input_config):
needed_devices[device.path] = device
if input_config.origin is None:
update_configs[input_config] = input_config.modify(
origin=get_device_hash(device)
)

if update_configs:
combination = list(mapping.input_combination)
for old, new in update_configs.items():
combination[combination.index(old)] = new
mapping.input_combination = combination
sezanzeb marked this conversation as resolved.
Show resolved Hide resolved

grabbed_devices = []
for device in needed_devices.values():
Expand Down Expand Up @@ -324,16 +353,16 @@ def run(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

# grab devices as early as possible. If events appear that won't get
# released anymore before the grab they appear to be held down forever
# This also updates the mappings with origin information for each input_config
sources = self._grab_devices()

# 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
# forever
sources = self._grab_devices()

if len(sources) == 0:
# maybe the preset was empty or something
logger.error("Did not grab any device")
Expand Down
12 changes: 6 additions & 6 deletions inputremapper/injection/mapping_handlers/mapping_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from evdev.ecodes import EV_KEY, EV_ABS, EV_REL

from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME
Expand Down Expand Up @@ -56,7 +56,7 @@
from inputremapper.logger import logger
from inputremapper.utils import get_evdev_constant_name

EventPipelines = Dict[InputEvent, Set[InputEventHandler]]
EventPipelines = Dict[InputConfig, Set[InputEventHandler]]

mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = {
# all available mapping_handlers
Expand Down Expand Up @@ -130,14 +130,14 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
event_pipelines: EventPipelines = defaultdict(set)
for handler in handlers:
assert handler.input_configs
for event in handler.input_configs:
for input_config in handler.input_configs:
logger.debug(
"event-pipeline with entry point: %s %s",
get_evdev_constant_name(*event.type_and_code),
event.type_and_code,
get_evdev_constant_name(*input_config.type_and_code),
input_config.input_match_hash,
)
logger.debug_mapping_handler(handler)
event_pipelines[event].add(handler)
event_pipelines[input_config].add(handler)

return event_pipelines

Expand Down
22 changes: 19 additions & 3 deletions inputremapper/input_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import enum
from dataclasses import dataclass
from typing import Tuple, Optional
from typing import Tuple, Optional, Hashable

import evdev
from evdev import ecodes
Expand Down Expand Up @@ -63,11 +63,27 @@ def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]):
return self.event_tuple == other
raise TypeError(f"cannot compare {type(other)} with InputEvent")

@property
def input_match_hash(self) -> Hashable:
jonasBoss marked this conversation as resolved.
Show resolved Hide resolved
"""a Hashable object which is intended to match the InputEvent with a
InputConfig.
"""
return self.type, self.code, self.origin

@classmethod
def from_event(cls, event: evdev.InputEvent) -> InputEvent:
def from_event(
cls, event: evdev.InputEvent, origin: Optional[int] = None
) -> InputEvent:
"""Create a InputEvent from another InputEvent or evdev.InputEvent."""
try:
return cls(event.sec, event.usec, event.type, event.code, event.value)
return cls(
event.sec,
event.usec,
event.type,
event.code,
event.value,
origin=origin,
)
except AttributeError as exception:
raise TypeError(
f"Failed to create InputEvent from {event = }"
Expand Down
6 changes: 5 additions & 1 deletion inputremapper/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"""Utility functions."""

import sys
from typing import Optional
from typing import Optional, Hashable

import evdev

Expand All @@ -30,6 +30,10 @@ def is_service() -> bool:
return sys.argv[0].endswith("input-remapper-service")


def get_device_hash(device: evdev.InputDevice) -> int:
return hash(str(device.capabilities(absinfo=False)) + device.name)


def get_evdev_constant_name(type_: int, code: int, *_) -> Optional[str]:
"""Handy function to get the evdev constant name."""
# this is more readable than
Expand Down