Add support for combining 2 axes into one virtual axis. (#11)

Reviewed-on: #11
This commit is contained in:
Anna Rose Wiggins 2025-07-28 17:45:16 +00:00
parent 7b520af24a
commit 3196d4ea22
14 changed files with 321 additions and 61 deletions

View file

@ -66,27 +66,20 @@ rules:
# Vertical thrust is on the VPC "paddles" in the main flight mode
- type: axis
name: translation up
- type: axis-combined
name: translation vertical
modes:
- main
input:
device: right-stick
axis: ABS_THROTTLE
output:
device: primary
axis: ABS_THROTTLE
- type: axis
name: translation down
modes:
- main
input:
input_lower:
device: left-stick
axis: ABS_THROTTLE
axis: Throttle
inverted: true
input_upper:
device: right-stick
axis: Throttle
output:
device: primary
axis: ABS_RUDDER
axis: RZ
# By default, the left thumbstick controls tractor beam via mousewheel
- type: axis-to-relaxis
@ -125,30 +118,15 @@ rules:
# In Mining mode, we move vertical thrust to the left thumbstick
# and remap the right paddle to be mining laser power
- type: axis
name: translation up alternate
name: translation up/down alternate
modes:
- mining
input:
device: left-stick
axis: RY
deadzone_start: 29250
deadzone_end: 64000
output:
device: primary
axis: ABS_THROTTLE
- type: axis
name: translation down alternate
modes:
- mining
input:
device: left-stick
axis: RY
deadzone_start: 0
deadzone_end: 30500
output:
device: primary
axis: ABS_RUDDER
axis: RZ
- type: axis
name: mining laser
@ -156,7 +134,7 @@ rules:
- mining
input:
device: right-stick
axis: ABS_THROTTLE
axis: Throttle
output:
device: primary
axis: RZ
axis: Throttle

View file

@ -6,7 +6,7 @@ devices:
- name: secondary
type: virtual
num_buttons: 74
num_axes: 2
num_axes: 3
- name: mouse
type: virtual
num_buttons: 0

View file

@ -1,7 +1,10 @@
## multi-file configuration example
This directory demonstrates how to split your configuration across multiple files.
Note that we re-define the top-level `rules` element in two different files; this is by design.
Note that we re-define the top-level `rules` element in two different files; this provides a way to organize
your rules however you like.
It also serves as a real-world example demonstrating many of the available features of the system.
It is copied from the author's actual mappings for Star Citizen.
It is copied from the author's actual mappings for Star Citizen, using dual Virpil Constellation Alpha joysticks,
CH Products pedals, and a custom-built button panel. (see https://git.annabunches.net/anna/hardware-projects/src/branch/main/flight-panel-2021-11 for
implementation details of the button panel)

View file

@ -54,6 +54,19 @@ rules:
device: main
axis: ABS_Y
# Create a single merged output axis from 2 input axes
- type: axis-combined
input_lower:
device: flightstick
axis: X
inverted: true # the lower half of the axis will often need to be inverted
input_uppper:
device: flightstick
axis: RX
output:
device: main
axis: RZ
# Straightforward button mapping
- type: button
input:

View file

@ -32,6 +32,7 @@ All `rules` must have a `type` parameter. Valid values for this parameter are:
* `button-combo` - multiple input buttons mapped to a single output. The output event will trigger when all the input conditions are met.
* `button-latched` - a single button mapped to a single output, but each time the input is pressed, the output will toggle.
* `axis` - a simple axis mapping
* `axis-combined` - a mapping that combines 2 input axes into a single output axis.
* `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value
* `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events.

View file

@ -49,6 +49,8 @@ func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice,
newRule, err = makeMappingRuleLatched(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxis:
newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisCombined:
newRule, err = makeMappingRuleAxisCombined(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisToButton:
newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base)
case RuleTypeAxisToRelaxis:
@ -146,6 +148,29 @@ func makeMappingRuleAxis(ruleConfig RuleConfig,
return mappingrules.NewMappingRuleAxis(base, input, output), nil
}
func makeMappingRuleAxisCombined(ruleConfig RuleConfig,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisCombined, error) {
inputLower, err := makeRuleTargetAxis(ruleConfig.InputLower, pDevs)
if err != nil {
return nil, err
}
inputUpper, err := makeRuleTargetAxis(ruleConfig.InputUpper, pDevs)
if err != nil {
return nil, err
}
output, err := makeRuleTargetAxis(ruleConfig.Output, vDevs)
if err != nil {
return nil, err
}
return mappingrules.NewMappingRuleAxisCombined(base, inputLower, inputUpper, output), nil
}
func makeMappingRuleAxisToButton(ruleConfig RuleConfig,
pDevs map[string]Device,
vDevs map[string]Device,

View file

@ -32,6 +32,8 @@ type RuleConfig struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type"`
Input RuleTargetConfig `yaml:"input,omitempty"`
InputLower RuleTargetConfig `yaml:"input_lower,omitempty"`
InputUpper RuleTargetConfig `yaml:"input_upper,omitempty"`
Inputs []RuleTargetConfig `yaml:"inputs,omitempty"`
Output RuleTargetConfig `yaml:"output"`
Modes []string `yaml:"modes,omitempty"`

View file

@ -12,6 +12,7 @@ const (
RuleTypeButtonCombo = "button-combo"
RuleTypeLatched = "button-latched"
RuleTypeAxis = "axis"
RuleTypeAxisCombined = "axis-combined"
RuleTypeModeSelect = "mode-select"
RuleTypeAxisToButton = "axis-to-button"
RuleTypeAxisToRelaxis = "axis-to-relaxis"

View file

@ -0,0 +1,51 @@
package mappingrules
import (
"git.annabunches.net/annabunches/joyful/internal/logger"
"github.com/holoplot/go-evdev"
)
type MappingRuleAxisCombined struct {
MappingRuleBase
InputLower *RuleTargetAxis
InputUpper *RuleTargetAxis
Output *RuleTargetAxis
}
func NewMappingRuleAxisCombined(base MappingRuleBase, inputLower *RuleTargetAxis, inputUpper *RuleTargetAxis, output *RuleTargetAxis) *MappingRuleAxisCombined {
inputLower.OutputMax = 0
inputUpper.OutputMin = 0
return &MappingRuleAxisCombined{
MappingRuleBase: base,
InputLower: inputLower,
InputUpper: inputUpper,
Output: output,
}
}
func (rule *MappingRuleAxisCombined) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) {
if !rule.MappingRuleBase.modeCheck(mode) ||
!(rule.InputLower.MatchEvent(device, event) ||
rule.InputUpper.MatchEvent(device, event)) {
return nil, nil
}
// Since lower and upper are guaranteed to have opposite signs,
// we can just sum them.
var value int32
value += getValueFromAbs(rule.InputLower)
value += getValueFromAbs(rule.InputUpper)
return rule.Output.Device.(*evdev.InputDevice), rule.Output.CreateEvent(value, mode)
}
func getValueFromAbs(ruleTarget *RuleTargetAxis) int32 {
absInfo, err := ruleTarget.Device.AbsInfos()
if err != nil {
logger.LogErrorf(err, "WARNING: Couldn't get axis data for device '%s'", ruleTarget.DeviceName)
return 0
}
return ruleTarget.NormalizeValue(absInfo[ruleTarget.Axis].Value)
}

View file

@ -0,0 +1,142 @@
package mappingrules
import (
"fmt"
"testing"
"github.com/holoplot/go-evdev"
"github.com/stretchr/testify/suite"
)
// TODO: revisit all of this after adding new functionality to
// RuleTargetAxis
type MappingRuleAxisCombinedTests struct {
suite.Suite
inputDevice *InputDeviceMock
outputDevice *evdev.InputDevice
inputTargetLower *RuleTargetAxis
inputTargetUpper *RuleTargetAxis
outputTarget *RuleTargetAxis
base MappingRuleBase
mode *string
}
func TestRunnerMappingRuleAxisCombined(t *testing.T) {
suite.Run(t, new(MappingRuleAxisCombinedTests))
}
func (t *MappingRuleAxisCombinedTests) SetupTest() {
mode := "*"
t.mode = &mode
t.inputDevice = NewInputDeviceMock()
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000},
}, nil)
t.inputTargetLower, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, true, 0, 0)
t.inputTargetUpper, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_Y, false, 0, 0)
t.outputDevice = &evdev.InputDevice{}
t.outputTarget, _ = NewRuleTargetAxis("test-output", t.outputDevice, evdev.ABS_X, false, 0, 0)
t.base = NewMappingRuleBase("", []string{"*"})
// We clear the AbsInfo call here so it can be cleanly set by the (sub-)tests
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TearDownTest() {
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TearDownSubTest() {
t.inputDevice.Reset()
}
func (t *MappingRuleAxisCombinedTests) TestNewMappingRuleAxisCombined() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000},
}, nil)
rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget)
t.EqualValues(0, rule.InputLower.OutputMax)
t.EqualValues(0, rule.InputUpper.OutputMin)
}
func (t *MappingRuleAxisCombinedTests) TestMatchEvent() {
rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget)
t.Run("Lower Input", func() {
testCases := []struct{ in, out int32 }{
{10000, AxisValueMin},
{0, 0},
{5000, AxisValueMin / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d->%d", testCase.in, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: testCase.in},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: 0},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_X, Value: testCase.in}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
t.Run("Upper Input", func() {
testCases := []struct{ in, out int32 }{
{10000, AxisValueMax},
{0, 0},
{5000, AxisValueMax / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d->%d", testCase.in, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: 0},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: testCase.in},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_Y, Value: testCase.in}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
t.Run("Combined Inputs", func() {
testCases := []struct{ x, y, out int32 }{
{0, 0, 0},
{5000, 5000, 0},
{10000, 10000, 0},
{5000, 10000, AxisValueMax / 2},
}
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%d,%d->%d", testCase.x, testCase.y, testCase.out), func() {
t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {Minimum: 0, Maximum: 10000, Value: testCase.x},
evdev.ABS_Y: {Minimum: 0, Maximum: 10000, Value: testCase.y},
}, nil)
device, event := rule.MatchEvent(t.inputDevice, &evdev.InputEvent{Type: evdev.EV_ABS, Code: evdev.ABS_Y, Value: testCase.y}, t.mode)
t.Equal(t.outputDevice, device)
t.InDelta(testCase.out, event.Value, 1)
})
}
})
// TODO: add tests for exception cases
}

View file

@ -14,6 +14,8 @@ type RuleTargetAxis struct {
Inverted bool
DeadzoneStart int32
DeadzoneEnd int32
OutputMin int32
OutputMax int32
axisSize int32
deadzoneSize int32
}
@ -61,6 +63,8 @@ func NewRuleTargetAxis(device_name string,
Device: device,
Axis: axis,
Inverted: inverted,
OutputMin: AxisValueMin,
OutputMax: AxisValueMax,
DeadzoneStart: deadzoneStart,
DeadzoneEnd: deadzoneEnd,
deadzoneSize: deadzoneSize,
@ -77,7 +81,7 @@ func NewRuleTargetAxis(device_name string,
// in the deadzone, among other things.
func (target *RuleTargetAxis) NormalizeValue(value int32) int32 {
axisStrength := target.GetAxisStrength(value)
return LerpInt(AxisValueMin, AxisValueMax, axisStrength)
return LerpInt(target.OutputMin, target.OutputMax, axisStrength)
}
func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent {
@ -103,7 +107,7 @@ func (target *RuleTargetAxis) MatchEventDeviceAndCode(device Device, event *evde
// TODO: Add tests
func (target *RuleTargetAxis) InDeadZone(value int32) bool {
return value >= target.DeadzoneStart && value <= target.DeadzoneEnd
return target.deadzoneSize > 0 && value >= target.DeadzoneStart && value <= target.DeadzoneEnd
}
// GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional

View file

@ -15,6 +15,10 @@ type RuleTargetAxisTests struct {
call *mock.Call
}
func TestRunnerRuleTargetAxisTests(t *testing.T) {
suite.Run(t, new(RuleTargetAxisTests))
}
func (t *RuleTargetAxisTests) SetupTest() {
t.mock = new(InputDeviceMock)
t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{
@ -68,26 +72,41 @@ func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() {
func (t *RuleTargetAxisTests) TestNormalizeValue() {
// Basic normalization should work
t.Run("Simple normalization", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0)))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000)))
})
// Normalization with a deadzone should work
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000)
t.Run("With Deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500)))
})
// Normalization on an inverted axis should work
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0)
t.Run("Inverted", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0)))
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000)))
})
// Normalization past the stated axis bounds should clamp
ruleTarget, _ = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Run("Out of bounds", func() { // Normalization past the stated axis bounds should clamp
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000)))
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000)))
})
t.Run("With partial output range", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
ruleTarget.OutputMin = 0
ruleTarget.OutputMax = AxisValueMax
t.EqualValues(0, ruleTarget.NormalizeValue(int32(0)))
t.EqualValues(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.InDelta(AxisValueMax/2, ruleTarget.NormalizeValue(int32(5000)), 10.0)
})
}
func (t *RuleTargetAxisTests) TestMatchEvent() {
@ -178,7 +197,3 @@ func (t *RuleTargetAxisTests) TestGetAxisStrength() {
t.Equal(1.0, ruleTarget.GetAxisStrength(0))
})
}
func TestRunnerRuleTargetAxisTests(t *testing.T) {
suite.Run(t, new(RuleTargetAxisTests))
}

View file

@ -7,9 +7,32 @@ import (
type InputDeviceMock struct {
mock.Mock
calls []*mock.Call
}
func NewInputDeviceMock() *InputDeviceMock {
m := new(InputDeviceMock)
m.calls = make([]*mock.Call, 0, 10)
return m
}
func (m *InputDeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) {
args := m.Called()
return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1)
}
// TODO: this would make a great library class functioning as a slight improvement on testify's
// mocks for instances where we want to redefine behavior more often...
// (alternately, this is possibly an anti-pattern in Go, in which case find a cleaner way to do this and remove)
func (m *InputDeviceMock) Stub(method string) *mock.Call {
call := m.On(method)
m.calls = append(m.calls, call)
return call
}
func (m *InputDeviceMock) Reset() {
for _, call := range m.calls {
call.Unset()
}
m.calls = make([]*mock.Call, 0, 10)
}

View file

@ -41,9 +41,11 @@ Configuration can be fairly complicated and repetitive. If anyone wants to creat
After building (see below) and writing your configuration (see above), just run `joyful`. You can use `joyful --config <directory>` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory.
Pressing `<enter>` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program.
## Technical details
Joyful is written in golang, and uses evdev/uinput to manage devices.
Joyful is written in golang, and uses evdev/uinput to manage devices. See `cmd/joyful/main.go` for the program's entry point.
### Build & Install