chore(repo): baseline du fork input-remapper (upstream v2.2.1)

Le dépôt ne contenait que README.md ; ajout de l'intégralité du code
fonctionnel du fork comme socle stable de la branche de release.
L'état runtime d'orchestration (.ideai/) est exclu du suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View File

@ -0,0 +1,86 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import asyncio
import unittest
from typing import Iterable
import evdev
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from tests.lib.cleanup import cleanup
from tests.lib.logger import logger
from tests.lib.fixtures import Fixture
class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase):
"""Test the event pipeline form event_reader to UInput."""
def setUp(self):
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.mapping_parser = MappingParser(self.global_uinputs)
self.global_uinputs.is_service = True
self.global_uinputs.prepare_all()
self.forward_uinput = evdev.UInput(name="test-forward-uinput")
self.stop_event = asyncio.Event()
def tearDown(self) -> None:
cleanup()
async def asyncTearDown(self) -> None:
logger.info("setting stop_event for the reader")
self.stop_event.set()
await asyncio.sleep(0.5)
@staticmethod
async def send_events(events: Iterable[InputEvent], event_reader: EventReader):
for event in events:
logger.info("sending into event_pipeline: %s", event)
await event_reader.handle(event)
def create_event_reader(
self,
preset: Preset,
source: Fixture,
) -> EventReader:
"""Create and start an EventReader."""
context = Context(
preset,
source_devices={},
forward_devices={source.get_device_hash(): self.forward_uinput},
mapping_parser=self.mapping_parser,
)
reader = EventReader(
context,
evdev.InputDevice(source.path),
self.stop_event,
)
asyncio.ensure_future(reader.run())
return reader
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,145 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import asyncio
import unittest
from evdev.ecodes import (
EV_ABS,
ABS_X,
ABS_Y,
)
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToAbs(EventPipelineTestBase):
async def test_abs_to_abs(self):
gain = 0.5
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = ABS_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x),
InputEvent.abs(ABS_Y, y),
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
],
)
async def test_abs_to_abs_with_input_switch(self):
gain = 0.5
input_combination = InputCombination(
(
InputConfig(type=EV_ABS, code=0),
InputConfig(type=EV_ABS, code=1, analog_threshold=10),
)
)
# left x to mouse x
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x // 5), # will not map
InputEvent.abs(ABS_X, -x), # will map later
# switch axis on sends initial position (previous event)
InputEvent.abs(ABS_Y, y),
InputEvent.abs(ABS_X, x), # normally mapped
InputEvent.abs(ABS_Y, y // 15), # off, re-centers axis
InputEvent.abs(ABS_X, -x // 5), # will not map
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, 0)),
],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,225 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import unittest
from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Z
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.logger import logger
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToBtn(EventPipelineTestBase):
async def test_abs_trigger_threshold_simple(self):
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)]
),
output_symbol="a",
)
preset = Preset()
preset.add(mapping_1)
a_code = keyboard_layout.get("a")
event_reader = self.create_event_reader(preset, fixtures.gamepad)
# 50%, trigger a
await self.send_events(
[InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2)],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(len(keyboard_history), 1)
self.assertEqual(keyboard_history[0], (EV_KEY, a_code, 1))
self.assertNotIn((EV_KEY, a_code, 0), keyboard_history)
async def test_abs_z(self):
# Shoulder triggers (ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE). Their center point
# is equal to the fully released point. They only have one direction.
# Triggers go from 0 to whatever value, so use the appropriate fixture.
# Yes this test setup is stupid, it should have different absinfos depending
# on the event.
fixture = fixtures.gamepad_abs_0_to_256
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=30)]
),
output_symbol="a",
)
# This mapping is impossible. There is no negative direction.
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=-30)]
),
output_symbol="b",
)
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a_code = keyboard_layout.get("a")
b_code = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixture)
max_abs = fixture.max_abs
# 50%, trigger a
await self.send_events(
[InputEvent.abs(ABS_Z, max_abs // 2)],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history, [(EV_KEY, a_code, 1)])
# Lets slowly move it all the way into the other direction
assert fixture.min_abs == 0 # just for clarification here
await self.send_events(
[
InputEvent.abs(ABS_Z, max_abs // 3),
InputEvent.abs(ABS_Z, max_abs // 5),
InputEvent.abs(ABS_Z, max_abs // 10),
InputEvent.abs(ABS_Z, 0),
],
event_reader,
)
# The negative mapping (mapping_2, to b) was not triggered. It just released
# the a.
self.assertEqual(
keyboard_history,
[
(EV_KEY, a_code, 1),
(EV_KEY, a_code, 0),
],
)
async def test_abs_trigger_threshold(self):
"""Test that different activation points for abs_to_btn work correctly."""
forwarded_history = self.forward_uinput.write_history
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)]
),
output_symbol="a",
)
# at 70% map to b
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=70)]
),
output_symbol="b",
)
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixtures.gamepad)
logger.info("do nothing, then trigger a")
await self.send_events(
[
# -10%, do nothing
InputEvent.abs(ABS_X, fixtures.gamepad.min_abs // 10),
# 0%, do noting
InputEvent.abs(ABS_X, 0),
# 10%, do nothing
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 10),
# 50%, trigger a
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2),
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1)
self.assertNotIn((EV_KEY, a, 0), keyboard_history)
self.assertNotIn((EV_KEY, b, 1), keyboard_history)
# the negative movements are not mapped, so the one event at -10% and its
# release should be forwarded instead
self.assertEqual(
forwarded_history,
[
(EV_ABS, ABS_X, fixtures.gamepad.min_abs // 10),
(EV_ABS, ABS_X, 0),
],
)
logger.info("trigger b, then release b")
await self.send_events(
[
# 80%, trigger b
InputEvent.abs(ABS_X, int(fixtures.gamepad.max_abs * 0.8)),
# 50%, release b
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2),
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(
keyboard_history,
[
(EV_KEY, a, 1),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
],
)
# 0% release a
await event_reader.handle(InputEvent.abs(ABS_X, 0))
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(
keyboard_history,
[
(EV_KEY, a, 1),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
(EV_KEY, a, 0),
],
)
# This didn't change. ABS_X of 0 should not be forwarded, because the joystick
# came from the mapped direction to 0. Instead, it maps to release a.
self.assertEqual(len(forwarded_history), 2)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import asyncio
import unittest
from evdev.ecodes import (
EV_ABS,
EV_REL,
ABS_X,
ABS_Y,
REL_X,
REL_Y,
REL_HWHEEL,
REL_WHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToRel(EventPipelineTestBase):
async def test_abs_to_rel(self):
"""Map gamepad EV_ABS events to EV_REL events."""
rel_rate = 60 # rate [Hz] at which events are produced
gain = 0.5 # halve the speed of the rel axis
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_X,
"rel_rate": rel_rate,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# left y to mouse y
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = REL_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
# set input axis to 100% in order to move
# (gain * REL_XY_SCALING) pixel per event
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x),
InputEvent.abs(ABS_Y, -y),
],
event_reader,
)
# wait a bit more for it to sum up
sleep = 0.5
await asyncio.sleep(sleep)
# stop it
await self.send_events(
[
InputEvent.abs(ABS_X, 0),
InputEvent.abs(ABS_Y, 0),
],
event_reader,
)
mouse_history = self.global_uinputs.get_uinput("mouse").write_history
if mouse_history[0].type == EV_ABS:
raise AssertionError(
"The injector probably just forwarded them unchanged"
# possibly in addition to writing mouse events
)
# This varies quite a lot depending on the machines performance.
# Face it, python is a bad choice for this.
self.assertAlmostEqual(len(mouse_history), rel_rate * sleep * 2, delta=10)
# those may be in arbitrary order
expected_value = -gain * REL_XY_SCALING * (rel_rate / DEFAULT_REL_RATE)
count_x = mouse_history.count((EV_REL, REL_X, expected_value))
count_y = mouse_history.count((EV_REL, REL_Y, expected_value))
self.assertGreater(count_x, 1)
self.assertGreater(count_y, 1)
# only those two types of events were written
self.assertEqual(len(mouse_history), count_x + count_y)
async def test_abs_to_wheel_hi_res_quirk(self):
"""When mapping to wheel events we always expect to see both,
REL_WHEEL and REL_WHEEL_HI_RES events with an accumulative value ratio of 1/120
"""
rel_rate = 60 # rate [Hz] at which events are produced
gain = 1
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_WHEEL,
"rel_rate": rel_rate,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# left y to mouse y
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = REL_HWHEEL_HI_RES
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
# set input axis to 100% in order to move
# speed*gain*rate=1*0.5*60 pixel per second
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, x),
InputEvent.abs(ABS_Y, -y),
],
event_reader,
)
# wait a bit more for it to sum up
sleep = 0.8
await asyncio.sleep(sleep)
# stop it
await self.send_events(
[
InputEvent.abs(ABS_X, 0),
InputEvent.abs(ABS_Y, 0),
],
event_reader,
)
m_history = self.global_uinputs.get_uinput("mouse").write_history
rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL])
rel_wheel_hi_res = sum(
[event.value for event in m_history if event.code == REL_WHEEL_HI_RES]
)
rel_hwheel = sum(
[event.value for event in m_history if event.code == REL_HWHEEL]
)
rel_hwheel_hi_res = sum(
[event.value for event in m_history if event.code == REL_HWHEEL_HI_RES]
)
self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0)
self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import dataclasses
import functools
import itertools
import unittest
from typing import Iterable, List
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from tests.lib.test_setup import test_setup
@test_setup
class TestAxisTransformation(unittest.TestCase):
@dataclasses.dataclass
class InitArgs:
max_: int
min_: int
deadzone: float
gain: float
expo: float
def values(self):
return self.__dict__.values()
def get_init_args(
self,
max_=(255, 1000, 2**15),
min_=(50, 0, -255),
deadzone=(0, 0.5),
gain=(0.5, 1, 2),
expo=(-0.9, 0, 0.3),
) -> Iterable[InitArgs]:
for args in itertools.product(max_, min_, deadzone, gain, expo):
yield self.InitArgs(*args)
@staticmethod
def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]:
"""Scale values between -1 and 1 up, such that they are between min and max."""
half_range = (max_ - min_) / 2
return [float_x * half_range + min_ + half_range for float_x in x]
def test_scale_to_range(self):
"""Make sure scale_to_range will actually return the min and max values
(avoid "off by one" errors)"""
max_ = (255, 1000, 2**15)
min_ = (50, 0, -255)
for x1, x2 in itertools.product(min_, max_):
scaled = self.scale_to_range(x1, x2, (-1, 1))
self.assertEqual(scaled, [x1, x2])
def test_expo_symmetry(self):
"""Test that the transformation is symmetric for expo parameter
x = f(g(x)), if f._expo == - g._expo
with the following constraints:
min = -1, max = 1
gain = 1
deadzone = 0
we can remove the constraints for min, max and gain,
by scaling the values appropriately after each transformation
"""
for init_args in self.get_init_args(deadzone=(0,)):
f = Transformation(*init_args.values())
init_args.expo = -init_args.expo
g = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range,
init_args.min_,
init_args.max_,
)
for x in scale():
y1 = g(x)
y1 = y1 / init_args.gain # remove the gain
y1 = scale((y1,))[0] # remove the min/max constraint
y2 = f(y1)
y2 = y2 / init_args.gain # remove the gain
y2 = scale((y2,))[0] # remove the min/max constraint
self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}")
def test_origin_symmetry(self):
"""Test that the transformation is symmetric to the origin_hash
f(x) = - f(-x)
within the constraints: min = -max
"""
for init_args in self.get_init_args():
init_args.min_ = -init_args.max_
f = Transformation(*init_args.values())
for x in self.scale_to_range(init_args.min_, init_args.max_):
self.assertAlmostEqual(
f(x),
-f(-x),
msg=f"test origin_hash symmetry at {x=} for {init_args}",
)
def test_gain(self):
"""Test that f(max) = gain and f(min) = -gain."""
for init_args in self.get_init_args():
f = Transformation(*init_args.values())
self.assertAlmostEqual(
f(init_args.max_),
init_args.gain,
msg=f"test gain for {init_args}",
)
self.assertAlmostEqual(
f(init_args.min_),
-init_args.gain,
msg=f"test gain for {init_args}",
)
def test_deadzone(self):
"""Test the Transfomation returns exactly 0 in the range of the deadzone."""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
for x in self.scale_to_range(
init_args.min_,
init_args.max_,
x=(
init_args.deadzone * 0.999,
-init_args.deadzone * 0.999,
0.3 * init_args.deadzone,
0,
),
):
self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}")
def test_continuity_near_deadzone(self):
"""Test that the Transfomation is continues (no sudden jump) next to the
deadzone"""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range,
init_args.min_,
init_args.max_,
)
x = (
init_args.deadzone * 1.00001,
init_args.deadzone * 1.001,
-init_args.deadzone * 1.00001,
-init_args.deadzone * 1.001,
)
scaled_x = scale(x=x)
p1 = (x[0], f(scaled_x[0])) # first point right of deadzone
p2 = (x[1], f(scaled_x[1])) # second point right of deadzone
# calculate a linear function y = m * x + b from p1 and p2
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
# the zero intersection of that function must be close to the
# edge of the deadzone
self.assertAlmostEqual(
-b / m,
init_args.deadzone,
places=5,
msg=f"test continuity at {init_args.deadzone} for {init_args}",
)
# same thing on the other side
p1 = (x[2], f(scaled_x[2]))
p2 = (x[3], f(scaled_x[3]))
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
self.assertAlmostEqual(
-b / m,
-init_args.deadzone,
places=5,
msg=f"test continuity at {- init_args.deadzone} for {init_args}",
)
def test_expo_out_of_range(self):
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3)
self.assertRaises(ValueError, f, 0)
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3)
self.assertRaises(ValueError, f, 0)
def test_returns_one_for_range_between_minus_and_plus_one(self):
for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)):
f = Transformation(*init_args.values())
self.assertEqual(f(1), 1)
self.assertEqual(f(-1), -1)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,597 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
"""See TestEventPipeline for more tests."""
import asyncio
import unittest
from unittest.mock import MagicMock
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
ABS_X,
REL_X,
BTN_LEFT,
BTN_RIGHT,
KEY_A,
REL_Y,
REL_WHEEL,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE, KnownUinput
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
)
from inputremapper.injection.mapping_handlers.combination_handler import (
CombinationHandler,
)
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
)
from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler
from inputremapper.input_event import InputEvent, EventActions
from tests.lib.cleanup import cleanup
from tests.lib.fixtures import fixtures
from tests.lib.patches import InputDevice
from tests.lib.test_setup import test_setup
class BaseTests:
"""implements test that should pass on most mapping handlers
in special cases override specific tests.
"""
handler: MappingHandler
def setUp(self):
raise NotImplementedError
def tearDown(self) -> None:
cleanup()
def test_reset(self):
mock = MagicMock()
self.handler.set_sub_handler(mock)
self.handler.reset()
mock.reset.assert_called()
@test_setup
class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
(
InputConfig(type=2, code=5),
InputConfig(type=1, code=3),
)
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AxisSwitchHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_type=2,
output_code=1,
),
MagicMock(),
self.global_uinputs,
)
@test_setup
class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
[InputConfig(type=3, code=5, analog_threshold=10)]
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToBtnHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
global_uinputs=self.global_uinputs,
)
@test_setup
class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToAbsHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="gamepad",
output_type=EV_ABS,
output_code=ABS_X,
),
global_uinputs=self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs),
source=InputDevice("/dev/input/event15"),
)
self.handler.reset()
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.foo_device_2_gamepad.max_abs)),
InputEvent.from_tuple((3, 0, 0)),
],
)
@test_setup
class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToAbsHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="gamepad",
output_type=EV_ABS,
output_code=ABS_X,
),
self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 123),
source=InputDevice("/dev/input/event15"),
)
self.handler.reset()
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(len(history), 2)
# something large, doesn't matter
self.assertGreater(history[0].value, fixtures.foo_device_2_gamepad.max_abs / 10)
# 0, because of the reset
self.assertEqual(history[1].value, 0)
async def test_rate_changes(self):
expected_rate = 100
# delta in usec
delta = 1000000 / expected_rate
self.handler.notify(
InputEvent(0, delta, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.handler.notify(
InputEvent(0, delta * 2, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.assertEqual(self.handler._observed_rate, expected_rate)
async def test_rate_stays(self):
# if two timestamps are equal, the rate stays at its previous value,
# in this case the default
self.handler.notify(
InputEvent(0, 50, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.handler.notify(
InputEvent(0, 50, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE)
@test_setup
class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToRelHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
),
self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs),
source=InputDevice("/dev/input/event15"),
)
await asyncio.sleep(0.2)
self.handler.reset()
await asyncio.sleep(0.05)
count = self.global_uinputs.get_uinput("mouse").write_count
self.assertGreater(count, 6) # count should be 60*0.2 = 12
await asyncio.sleep(0.2)
self.assertEqual(count, self.global_uinputs.get_uinput("mouse").write_count)
@test_setup
class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
handler: CombinationHandler
def setUp(self):
mouse = fixtures.foo_device_2_mouse
self.mouse_hash = mouse.get_device_hash()
keyboard = fixtures.foo_device_2_keyboard
self.keyboard_hash = keyboard.get_device_hash()
gamepad = fixtures.gamepad
self.gamepad_hash = gamepad.get_device_hash()
input_combination = InputCombination(
(
InputConfig(
type=EV_REL,
code=5,
analog_threshold=10,
origin_hash=self.mouse_hash,
),
InputConfig(
type=EV_KEY,
code=3,
origin_hash=self.keyboard_hash,
),
InputConfig(
type=EV_KEY,
code=4,
origin_hash=self.gamepad_hash,
),
)
)
self.input_combination = input_combination
self.context_mock = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = CombinationHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
release_combination_keys=True,
),
self.context_mock,
global_uinputs=self.global_uinputs,
)
sub_handler_mock = MagicMock(MappingHandler)
self.handler.set_sub_handler(sub_handler_mock)
# insert our own test-uinput to see what is being written to it
self.uinputs = {
self.mouse_hash: evdev.UInput(),
self.keyboard_hash: evdev.UInput(),
self.gamepad_hash: evdev.UInput(),
}
self.context_mock.get_forward_uinput = lambda origin_hash: self.uinputs[
origin_hash
]
def test_forward_correctly(self):
# In the past, if a mapping has inputs from two different sub devices, it
# always failed to send the release events to the correct one.
# Nowadays, self._context.get_forward_uinput(origin_hash) is used to
# release them correctly.
# 1. trigger the combination
self.handler.notify(
InputEvent.rel(
code=self.input_combination[0].code,
value=1,
origin_hash=self.input_combination[0].origin_hash,
),
source=fixtures.foo_device_2_mouse,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[1].code,
value=1,
origin_hash=self.input_combination[1].origin_hash,
),
source=fixtures.foo_device_2_keyboard,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[2].code,
value=1,
origin_hash=self.input_combination[2].origin_hash,
),
source=fixtures.gamepad,
)
# 2. expect release events to be written to the correct devices, as indicated
# by the origin_hash of the InputConfigs
self.assertListEqual(
self.uinputs[self.mouse_hash].write_history,
[InputEvent.rel(self.input_combination[0].code, 0)],
)
self.assertListEqual(
self.uinputs[self.keyboard_hash].write_history,
[InputEvent.key(self.input_combination[1].code, 0)],
)
# We do not expect a release event for this, because there was no key-down
# event when the combination triggered.
# self.assertListEqual(
# self.uinputs[self.gamepad_hash].write_history,
# [InputEvent.key(self.input_combination[2].code, 0)],
# )
def test_no_forwards(self):
# if a combination is not triggered, nothing is released
# 1. inject any two events
self.handler.notify(
InputEvent.rel(
code=self.input_combination[0].code,
value=1,
origin_hash=self.input_combination[0].origin_hash,
),
source=fixtures.foo_device_2_mouse,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[1].code,
value=1,
origin_hash=self.input_combination[1].origin_hash,
),
source=fixtures.foo_device_2_keyboard,
)
# 2. expect no release events to be written
self.assertListEqual(self.uinputs[self.mouse_hash].write_history, [])
self.assertListEqual(self.uinputs[self.keyboard_hash].write_history, [])
@test_setup
class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.mock1 = MagicMock()
self.mock2 = MagicMock()
self.mock3 = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = HierarchyHandler(
[self.mock1, self.mock2, self.mock3],
InputConfig(type=EV_KEY, code=KEY_A),
self.global_uinputs,
)
def test_reset(self):
self.handler.reset()
self.mock1.reset.assert_called()
self.mock2.reset.assert_called()
self.mock3.reset.assert_called()
@test_setup
class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
(
InputConfig(type=2, code=0, analog_threshold=10),
InputConfig(type=1, code=3),
)
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = KeyHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
self.global_uinputs,
)
def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(history[0], InputEvent.key(BTN_LEFT, 1))
self.assertEqual(len(history), 1)
self.handler.reset()
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0))
self.assertEqual(len(history), 2)
@test_setup
class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.input_combination = InputCombination(
(
InputConfig(type=2, code=0, analog_threshold=10),
InputConfig(type=1, code=3),
)
)
self.context_mock = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.set_handler(KnownUinput.KEYBOARD, "key(a)")
def set_handler(self, target_uinput: KnownUinput, macro: str):
self.handler = MacroHandler(
self.input_combination,
Mapping(
input_combination=self.input_combination.to_config(),
target_uinput=target_uinput,
output_symbol=macro,
),
context=self.context_mock,
global_uinputs=self.global_uinputs,
)
async def test_reset(self):
self.set_handler(KnownUinput.MOUSE, "hold_keys(BTN_LEFT, BTN_RIGHT)")
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history
self.assertIn(InputEvent.key(BTN_LEFT, 1), history)
self.assertIn(InputEvent.key(BTN_RIGHT, 1), history)
self.assertEqual(len(history), 2)
self.handler.reset()
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history
self.assertIn(InputEvent.key(BTN_LEFT, 0), history[-2:])
self.assertIn(InputEvent.key(BTN_RIGHT, 0), history[-2:])
self.assertEqual(len(history), 4)
async def test_reset_output(self):
self.set_handler(KnownUinput.KEYBOARD, "key_down(a)")
history = self.global_uinputs.get_uinput(KnownUinput.KEYBOARD).write_history
self.handler.reset()
await asyncio.sleep(0.1)
self.assertEqual(len(history), 0)
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
await asyncio.sleep(0.1)
self.assertEqual(len(history), 1)
self.assertIn(InputEvent.key(KEY_A, 1), history)
self.handler.reset()
await asyncio.sleep(0.1)
self.assertEqual(len(history), 2)
self.assertIn(InputEvent.key(KEY_A, 0), history)
@test_setup
class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
[InputConfig(type=2, code=0, analog_threshold=10)]
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToBtnHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
self.global_uinputs,
)
@test_setup
class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase):
handler: RelToRelHandler
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToRelHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
output_type=EV_REL,
output_code=REL_Y,
output_value=20,
target_uinput="mouse",
),
self.global_uinputs,
)
def test_should_map(self):
self.assertTrue(
self.handler._should_map(
InputEvent(
0,
0,
EV_REL,
REL_X,
0,
)
)
)
self.assertFalse(
self.handler._should_map(
InputEvent(
0,
0,
EV_REL,
REL_WHEEL,
1,
)
)
)
def test_reset(self):
# nothing special has to happen here
pass

View File

@ -0,0 +1,239 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import asyncio
import unittest
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
ABS_X,
ABS_Y,
REL_X,
REL_Y,
KEY_A,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToAbs(EventPipelineTestBase):
def setUp(self):
self.timestamp = 0
super().setUp()
def next_usec_time(self):
self.timestamp += 1000000 / DEFAULT_REL_RATE
return self.timestamp
async def test_rel_to_abs(self):
# first mapping
# left mouse x to abs x
gain = 0.5
cutoff = 2
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"release_timeout": 0.5,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# second mapping
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_Y)])
mapping_config["input_combination"] = input_combination.to_config()
mapping_config["output_code"] = ABS_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
next_time = self.next_usec_time()
await self.send_events(
[
InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING * cutoff)),
InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING * cutoff)),
],
event_reader,
)
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
],
)
# send more events, then wait until the release timeout
next_time = self.next_usec_time()
await self.send_events(
[
InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING)),
InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING)),
],
event_reader,
)
await asyncio.sleep(0.7)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 4)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 4)),
InputEvent.from_tuple((3, 0, 0)),
InputEvent.from_tuple((3, 1, 0)),
],
)
async def test_rel_to_abs_reset_multiple(self):
# Recenters correctly when triggering the mapping a second time.
# Could only be reproduced if a key input is part of the combination, that is
# released and pressed again.
# left mouse x to abs x
gain = 0.5
cutoff = 2
input_combination = InputCombination(
[
InputConfig(type=EV_KEY, code=KEY_A),
InputConfig(type=EV_REL, code=REL_X),
]
)
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"release_timeout": 0.1,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
for _ in range(3):
next_time = self.next_usec_time()
value = int(REL_XY_SCALING * cutoff)
await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 1))
await event_reader.handle(InputEvent(0, next_time, EV_REL, REL_X, value))
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertIn(
InputEvent.from_tuple((3, 0, 0)),
history,
)
await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 0))
await asyncio.sleep(0.05)
self.global_uinputs.get_uinput("gamepad").write_history = []
async def test_rel_to_abs_with_input_switch(self):
# use 0 everywhere, because that will cause the handler to not update the rate,
# and we are able to test things without worrying about that at all
timestamp = 0
gain = 0.5
cutoff = 1
input_combination = InputCombination(
(
InputConfig(type=EV_REL, code=REL_X),
InputConfig(type=EV_REL, code=REL_Y, analog_threshold=10),
)
)
# left mouse x to x
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
# if the cutoff is higher, the test sends higher values to overcome the cutoff
await self.send_events(
[
# will not map
InputEvent(0, timestamp, EV_REL, REL_X, -REL_XY_SCALING / 4 * cutoff),
# switch axis on
InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 5 * cutoff),
# normally mapped
InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING * cutoff),
# off, re-centers axis
InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 20 * cutoff),
# will not map
InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING / 2 * cutoff),
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, 0)),
],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,180 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import asyncio
import unittest
from evdev.ecodes import (
EV_KEY,
EV_REL,
REL_X,
REL_HWHEEL,
REL_WHEEL,
)
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToBtn(EventPipelineTestBase):
async def test_rel_to_btn(self):
"""Rel axis mapped to buttons are automatically released if no new rel event arrives."""
# map those two to stuff
w_up = (EV_REL, REL_WHEEL, -1)
hw_right = (EV_REL, REL_HWHEEL, 1)
# should be forwarded and present in the capabilities
hw_left = (EV_REL, REL_HWHEEL, -1)
keyboard_layout.clear()
code_b = 91
code_c = 92
keyboard_layout._set("b", code_b)
keyboard_layout._set("c", code_c)
# set a high release timeout to make sure the tests pass
release_timeout = 0.2
mapping_1 = Mapping.from_combination(
InputCombination(InputCombination.from_tuples(hw_right)), "keyboard", "k(b)"
)
mapping_2 = Mapping.from_combination(
InputCombination(InputCombination.from_tuples(w_up)), "keyboard", "c"
)
mapping_1.release_timeout = release_timeout
mapping_2.release_timeout = release_timeout
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse)
await self.send_events(
[InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5,
event_reader,
)
# wait less than the release timeout and send more events
await asyncio.sleep(release_timeout / 5)
await self.send_events(
[InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5
+ [InputEvent.from_tuple(hw_left)]
* 3, # one event will release hw_right, the others are forwarded
event_reader,
)
# wait more than the release_timeout to make sure all handlers finish
await asyncio.sleep(release_timeout * 1.2)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
forwarded_history = self.forward_uinput.write_history
self.assertEqual(keyboard_history.count((EV_KEY, code_b, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_c, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_b, 0)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_c, 0)), 1)
# the unmapped wheel direction
self.assertEqual(forwarded_history.count(hw_left), 2)
# the unmapped wheel won't get a debounced release command, it's
# forwarded as is
self.assertNotIn((EV_REL, REL_HWHEEL, 0), forwarded_history)
async def test_rel_trigger_threshold(self):
"""Test that different activation points for rel_to_btn work correctly."""
# at 5 map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_REL, code=REL_X, analog_threshold=5)]
),
output_symbol="a",
)
# at 15 map to b
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_REL, code=REL_X, analog_threshold=15)]
),
output_symbol="b",
)
release_timeout = 0.2 # give some time to do assertions before the release
mapping_1.release_timeout = release_timeout
mapping_2.release_timeout = release_timeout
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse)
await self.send_events(
[
InputEvent.rel(REL_X, -5), # forward
InputEvent.rel(REL_X, 0), # forward
InputEvent.rel(REL_X, 3), # forward
InputEvent.rel(REL_X, 10), # trigger a
],
event_reader,
)
await asyncio.sleep(release_timeout * 1.5) # release a
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history, [(EV_KEY, a, 1), (EV_KEY, a, 0)])
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1)
self.assertNotIn((EV_KEY, b, 1), keyboard_history)
await self.send_events(
[
InputEvent.rel(REL_X, 10), # trigger a
InputEvent.rel(REL_X, 20), # trigger b
InputEvent.rel(REL_X, 10), # release b
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 2)
self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1)
await asyncio.sleep(release_timeout * 1.5) # release a
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
forwarded_history = self.forward_uinput.write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 2)
self.assertEqual(
forwarded_history,
[(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,219 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# 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/>.
import unittest
from evdev.ecodes import (
EV_REL,
REL_X,
REL_Y,
REL_HWHEEL,
REL_WHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToRel(EventPipelineTestBase):
async def _test(self, input_code, input_value, output_code, output_value, gain=1):
preset = Preset()
input_config = InputConfig(type=EV_REL, code=input_code)
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=output_code,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[InputEvent(0, 0, EV_REL, input_code, input_value)],
event_reader,
)
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(len(history), 1)
self.assertEqual(
history[0],
InputEvent(0, 0, EV_REL, output_code, output_value),
)
async def test_wheel_to_y(self):
await self._test(
input_code=REL_WHEEL,
input_value=2 * WHEEL_SCALING,
output_code=REL_Y,
output_value=2 * REL_XY_SCALING,
)
async def test_hi_res_wheel_to_y(self):
await self._test(
input_code=REL_WHEEL_HI_RES,
input_value=3 * WHEEL_HI_RES_SCALING,
output_code=REL_Y,
output_value=3 * REL_XY_SCALING,
)
async def test_x_to_hwheel(self):
# injects both hi_res and regular wheel events at the same time
input_code = REL_X
input_value = 100
output_code = REL_HWHEEL
gain = 2
output_value = int(input_value / REL_XY_SCALING * WHEEL_SCALING * gain)
output_value_hi_res = int(
input_value / REL_XY_SCALING * WHEEL_HI_RES_SCALING * gain
)
preset = Preset()
input_config = InputConfig(type=EV_REL, code=input_code)
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=output_code,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[InputEvent(0, 0, EV_REL, input_code, input_value)],
event_reader,
)
history = self.global_uinputs.get_uinput("mouse").write_history
# injects both REL_WHEEL and REL_WHEEL_HI_RES events
self.assertEqual(len(history), 2)
self.assertEqual(
history[0],
InputEvent(
0,
0,
EV_REL,
REL_HWHEEL,
output_value,
),
)
self.assertEqual(
history[1],
InputEvent(
0,
0,
EV_REL,
REL_HWHEEL_HI_RES,
output_value_hi_res,
),
)
async def test_remainder(self):
preset = Preset()
history = self.global_uinputs.get_uinput("mouse").write_history
# REL_WHEEL_HI_RES to REL_Y
input_config = InputConfig(type=EV_REL, code=REL_WHEEL_HI_RES)
gain = 0.01
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
events_until_one_rel_y_written = int(
WHEEL_HI_RES_SCALING / REL_XY_SCALING / gain
)
# due to the low gain and low input value, it needs to be sent many times
# until one REL_Y event is written
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)]
* (events_until_one_rel_y_written - 1),
event_reader,
)
self.assertEqual(len(history), 0)
# write the final event that causes the input to accumulate to 1
# plus one extra event because of floating-point math
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)],
event_reader,
)
self.assertEqual(len(history), 1)
self.assertEqual(
history[0],
InputEvent(0, 0, EV_REL, REL_Y, 1),
)
# repeat it one more time to see if the remainder is reset correctly
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)]
* (events_until_one_rel_y_written - 1),
event_reader,
)
self.assertEqual(len(history), 1)
# the event that causes the second REL_Y to be written
# this should never need the one extra if the remainder is reset correctly
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)],
event_reader,
)
self.assertEqual(len(history), 2)
self.assertEqual(
history[1],
InputEvent(0, 0, EV_REL, REL_Y, 1),
)
if __name__ == "__main__":
unittest.main()