From 2f7e11e8a2912c4875ecca6c15bff89dbbcb69c0 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 13:06:24 -0400 Subject: [PATCH 01/11] Implement Axis targets. --- go.mod | 1 + go.sum | 2 + internal/config/make_rule_targets.go | 7 +- internal/mappingrules/math.go | 15 ++++ internal/mappingrules/rule_target_axis.go | 85 +++++++++++++-------- internal/mappingrules/rule_target_button.go | 4 +- 6 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 internal/mappingrules/math.go diff --git a/go.mod b/go.mod index 2388397..9d0599a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/goccy/go-yaml v1.18.0 github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 github.com/stretchr/testify v1.10.0 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b ) require ( diff --git a/go.sum b/go.sum index 539dcc7..cb5a0d8 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 36b5e3c..28afedc 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -24,7 +24,7 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev. device, eventCode, targetConfig.Inverted, - ), nil + ) } func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetAxis, error) { @@ -43,8 +43,9 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In device, eventCode, targetConfig.Inverted, - 0, 0, 0, // TODO: replace these with real values - ), nil + targetConfig.DeadzoneStart, + targetConfig.DeadzoneEnd, + ) } func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { diff --git a/internal/mappingrules/math.go b/internal/mappingrules/math.go new file mode 100644 index 0000000..bcf0487 --- /dev/null +++ b/internal/mappingrules/math.go @@ -0,0 +1,15 @@ +package mappingrules + +import ( + "golang.org/x/exp/constraints" +) + +func AbsInt[T constraints.Integer](value T) T { + return max(value, -value) +} + +// LerpInt linearly interpolates between two integer values using +// a float64 index value +func LerpInt[T constraints.Integer](min, max T, t float64) T { + return T((1-t)*float64(min) + t*float64(max)) +} diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 680c6d5..2780010 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -1,6 +1,9 @@ package mappingrules import ( + "errors" + "fmt" + "github.com/holoplot/go-evdev" ) @@ -11,51 +14,72 @@ type RuleTargetAxis struct { Inverted bool DeadzoneStart int32 DeadzoneEnd int32 - Sensitivity float64 + Sensitivity float64 // TODO: is this even a value that makes sense? + axisSize int32 + deadzoneSize int32 } +const ( + MinAxisValue = int32(-32768) + MaxAxisValue = int32(32767) +) + func NewRuleTargetAxis(device_name string, device *evdev.InputDevice, axis evdev.EvCode, inverted bool, - deadzone_start int32, - deadzone_end int32, - sensitivity float64) *RuleTargetAxis { + deadzoneStart int32, + deadzoneEnd int32) (*RuleTargetAxis, error) { + + info, err := device.AbsInfos() + if err != nil { + return nil, err + } + + if _, ok := info[axis]; !ok { + return nil, fmt.Errorf("device does not support axis %v", axis) + } + + if deadzoneStart > deadzoneEnd { + return nil, errors.New("deadzone_end must be a higher value than deadzone_start") + } + + deadzoneSize := AbsInt(deadzoneEnd - deadzoneStart) + + // Our output range is limited to 16 bits, but we represent values internally with 32 bits. + // As a result, we shouldn't need to worry about integer overruns + axisSize := info[axis].Maximum - info[axis].Minimum - deadzoneSize + + if axisSize == 0 { + return nil, errors.New("axis has size 0") + } return &RuleTargetAxis{ DeviceName: device_name, Device: device, Axis: axis, Inverted: inverted, - DeadzoneStart: deadzone_start, - DeadzoneEnd: deadzone_end, - Sensitivity: sensitivity, - } + DeadzoneStart: deadzoneStart, + DeadzoneEnd: deadzoneEnd, + deadzoneSize: deadzoneSize, + axisSize: axisSize, + }, nil } -// TODO: lots of fixes and decisions to make here. Should we normalize all axes to the same range? -// How do we handle deadzones in light of that? +// NormalizeValue takes a raw input value and converts it to a value suitable for output. +// +// Axis inputs are normalized to the full signed int32 range to match the virtual device's axis +// characteristics. +// +// Typically this function is called after RuleTargetAxis.MatchEvent, which checks whether we are +// in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { - if !target.Inverted { - return value + axisStrength := float64(value-target.deadzoneSize) / float64(target.axisSize) + if target.Inverted { + axisStrength = 1.0 - axisStrength } - - axisRange := target.DeadzoneEnd - target.DeadzoneStart - axisMid := target.DeadzoneEnd - axisRange/2 - delta := value - axisMid - if delta < 0 { - delta = -delta - } - - if value < axisMid { - return axisMid + delta - } else if value > axisMid { - return axisMid - delta - } - - // If we reach here, we're either exactly at the midpoint or something - // strange has happened. Either way, just return the value. - return value + normalizedValue := LerpInt(MinAxisValue, MaxAxisValue, axisStrength) + return normalizedValue } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { @@ -71,5 +95,6 @@ func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.Inpu func (target *RuleTargetAxis) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) bool { return device == target.Device && event.Type == evdev.EV_ABS && - event.Code == target.Axis + event.Code == target.Axis && + (event.Value <= target.DeadzoneStart || event.Value >= target.DeadzoneEnd) } diff --git a/internal/mappingrules/rule_target_button.go b/internal/mappingrules/rule_target_button.go index 67fe5d6..511249c 100644 --- a/internal/mappingrules/rule_target_button.go +++ b/internal/mappingrules/rule_target_button.go @@ -9,13 +9,13 @@ type RuleTargetButton struct { Inverted bool } -func NewRuleTargetButton(device_name string, device *evdev.InputDevice, code evdev.EvCode, inverted bool) *RuleTargetButton { +func NewRuleTargetButton(device_name string, device *evdev.InputDevice, code evdev.EvCode, inverted bool) (*RuleTargetButton, error) { return &RuleTargetButton{ DeviceName: device_name, Device: device, Button: code, Inverted: inverted, - } + }, nil } func (target *RuleTargetButton) NormalizeValue(value int32) int32 { -- 2.47.2 From 8f3b8f4b47c575e2ce229e043e2bb151b01d33e0 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 13:06:32 -0400 Subject: [PATCH 02/11] Clean up some of our tests. --- .../mappingrules/mapping_rule_button_test.go | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/internal/mappingrules/mapping_rule_button_test.go b/internal/mappingrules/mapping_rule_button_test.go index df814ad..8120247 100644 --- a/internal/mappingrules/mapping_rule_button_test.go +++ b/internal/mappingrules/mapping_rule_button_test.go @@ -13,8 +13,7 @@ type MappingRuleButtonTests struct { wrongInputDevice *evdev.InputDevice outputDevice *evdev.InputDevice mode *string - sampleRule *MappingRuleButton - invertedRule *MappingRuleButton + base MappingRuleBase } func (t *MappingRuleButtonTests) SetupTest() { @@ -23,70 +22,62 @@ func (t *MappingRuleButtonTests) SetupTest() { t.outputDevice = &evdev.InputDevice{} mode := "*" t.mode = &mode - - // TODO: implement a constructor function... - t.sampleRule = &MappingRuleButton{ - MappingRuleBase: MappingRuleBase{ - Modes: []string{"*"}, - }, - Input: NewRuleTargetButton("", t.inputDevice, evdev.BTN_TRIGGER, false), - Output: NewRuleTargetButton("", t.outputDevice, evdev.BTN_TRIGGER, false), - } - - t.invertedRule = &MappingRuleButton{ - MappingRuleBase: MappingRuleBase{ - Modes: []string{"*"}, - }, - Output: NewRuleTargetButton("", t.outputDevice, evdev.BTN_TRIGGER, false), - Input: NewRuleTargetButton("", t.inputDevice, evdev.BTN_TRIGGER, true), - } + t.base = NewMappingRuleBase("", []string{}) } func (t *MappingRuleButtonTests) TestMatchEvent() { + inputButton, _ := NewRuleTargetButton("", t.inputDevice, evdev.BTN_TRIGGER, false) + outputButton, _ := NewRuleTargetButton("", t.outputDevice, evdev.BTN_TRIGGER, false) + testRule := NewMappingRuleButton(t.base, inputButton, outputButton) + // A matching input event should produce an output event - correctOutput := &evdev.InputEvent{ + expected := &evdev.InputEvent{ Type: evdev.EV_KEY, Code: evdev.BTN_TRIGGER, Value: 1, } - _, event := t.sampleRule.MatchEvent( + _, event := testRule.MatchEvent( t.inputDevice, &evdev.InputEvent{Code: evdev.BTN_TRIGGER, Value: 1}, t.mode) - t.EqualValues(correctOutput, event) + t.EqualValues(expected, event) // An input event from the wrong device should produce a nil event - _, event = t.sampleRule.MatchEvent( + _, event = testRule.MatchEvent( t.wrongInputDevice, &evdev.InputEvent{Code: evdev.BTN_TRIGGER, Value: 1}, t.mode) t.Nil(event) // An input event from the wrong button should produce a nil event - _, event = t.sampleRule.MatchEvent( + _, event = testRule.MatchEvent( t.inputDevice, &evdev.InputEvent{Code: evdev.BTN_TOP, Value: 1}, t.mode) t.Nil(event) } func (t *MappingRuleButtonTests) TestMatchEventInverted() { + inputButton, _ := NewRuleTargetButton("", t.inputDevice, evdev.BTN_TRIGGER, true) + outputButton, _ := NewRuleTargetButton("", t.outputDevice, evdev.BTN_TRIGGER, false) + testRule := NewMappingRuleButton(t.base, inputButton, outputButton) + // A matching input event should produce an output event - correctOutput := &evdev.InputEvent{ + expected := &evdev.InputEvent{ Type: evdev.EV_KEY, Code: evdev.BTN_TRIGGER, } // Should get the opposite value out that we send in - correctOutput.Value = 0 - _, event := t.invertedRule.MatchEvent( + expected.Value = 0 + _, event := testRule.MatchEvent( t.inputDevice, &evdev.InputEvent{Code: evdev.BTN_TRIGGER, Value: 1}, t.mode) - t.EqualValues(correctOutput, event) + t.EqualValues(expected, event) - correctOutput.Value = 1 - _, event = t.invertedRule.MatchEvent( + expected.Value = 1 + _, event = testRule.MatchEvent( t.inputDevice, &evdev.InputEvent{Code: evdev.BTN_TRIGGER, Value: 0}, t.mode) - t.EqualValues(correctOutput, event) + t.EqualValues(expected, event) } func TestRunnerMatching(t *testing.T) { -- 2.47.2 From 6646044d28342235911b6f8ec05d7069099a1b54 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 16:46:01 -0400 Subject: [PATCH 03/11] Add tests for RuleTargetAxis. --- go.mod | 1 + go.sum | 2 + internal/mappingrules/interfaces.go | 7 + internal/mappingrules/mapping_rule_axis.go | 3 +- .../mappingrules/mapping_rule_button_test.go | 2 +- internal/mappingrules/math.go | 10 ++ internal/mappingrules/rule_target_axis.go | 12 +- .../mappingrules/rule_target_axis_test.go | 136 ++++++++++++++++++ 8 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 internal/mappingrules/rule_target_axis_test.go diff --git a/go.mod b/go.mod index 9d0599a..bbe28fc 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cb5a0d8..079743c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Ta github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= diff --git a/internal/mappingrules/interfaces.go b/internal/mappingrules/interfaces.go index a3f3b70..316ec44 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -26,3 +26,10 @@ type RuleTarget interface { // for most implementations. CreateEvent(int32, *string) *evdev.InputEvent } + +// RuleTargetDevice is an interface abstraction on top of evdev.InputDevice, implementing +// only the methods we need in this package. This is used for testing, and the +// RuleTargetDevice can be safely cast to an *evdev.InputDevice when necessary. +type RuleTargetDevice interface { + AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) +} diff --git a/internal/mappingrules/mapping_rule_axis.go b/internal/mappingrules/mapping_rule_axis.go index b190963..da324e2 100644 --- a/internal/mappingrules/mapping_rule_axis.go +++ b/internal/mappingrules/mapping_rule_axis.go @@ -23,5 +23,6 @@ func (rule *MappingRuleAxis) MatchEvent(device *evdev.InputDevice, event *evdev. return nil, nil } - return rule.Output.Device, rule.Output.CreateEvent(rule.Input.NormalizeValue(event.Value), mode) + // The cast here is safe because the interface is only ever different for unit tests + return rule.Output.Device.(*evdev.InputDevice), rule.Output.CreateEvent(rule.Input.NormalizeValue(event.Value), mode) } diff --git a/internal/mappingrules/mapping_rule_button_test.go b/internal/mappingrules/mapping_rule_button_test.go index 8120247..28fba1b 100644 --- a/internal/mappingrules/mapping_rule_button_test.go +++ b/internal/mappingrules/mapping_rule_button_test.go @@ -80,6 +80,6 @@ func (t *MappingRuleButtonTests) TestMatchEventInverted() { t.EqualValues(expected, event) } -func TestRunnerMatching(t *testing.T) { +func TestRunnerMappingRuleButtonTests(t *testing.T) { suite.Run(t, new(MappingRuleButtonTests)) } diff --git a/internal/mappingrules/math.go b/internal/mappingrules/math.go index bcf0487..bccae51 100644 --- a/internal/mappingrules/math.go +++ b/internal/mappingrules/math.go @@ -13,3 +13,13 @@ func AbsInt[T constraints.Integer](value T) T { func LerpInt[T constraints.Integer](min, max T, t float64) T { return T((1-t)*float64(min) + t*float64(max)) } + +func ClampInt[T constraints.Integer](value, min, max T) T { + if value < min { + value = min + } + if value > max { + value = max + } + return value +} diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 2780010..629eb5c 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -9,12 +9,11 @@ import ( type RuleTargetAxis struct { DeviceName string - Device *evdev.InputDevice + Device RuleTargetDevice Axis evdev.EvCode Inverted bool DeadzoneStart int32 DeadzoneEnd int32 - Sensitivity float64 // TODO: is this even a value that makes sense? axisSize int32 deadzoneSize int32 } @@ -25,7 +24,7 @@ const ( ) func NewRuleTargetAxis(device_name string, - device *evdev.InputDevice, + device RuleTargetDevice, axis evdev.EvCode, inverted bool, deadzoneStart int32, @@ -83,8 +82,7 @@ func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { - // TODO: we can use the axis begin/end to decide whether to emit the event - // TODO: oh no we need center deadzones actually... + value = ClampInt(value, MinAxisValue, MaxAxisValue) return &evdev.InputEvent{ Type: evdev.EV_ABS, Code: target.Axis, @@ -92,9 +90,9 @@ func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.Inpu } } -func (target *RuleTargetAxis) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) bool { +func (target *RuleTargetAxis) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool { return device == target.Device && event.Type == evdev.EV_ABS && event.Code == target.Axis && - (event.Value <= target.DeadzoneStart || event.Value >= target.DeadzoneEnd) + (event.Value < target.DeadzoneStart || event.Value > target.DeadzoneEnd) } diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go new file mode 100644 index 0000000..556ab48 --- /dev/null +++ b/internal/mappingrules/rule_target_axis_test.go @@ -0,0 +1,136 @@ +package mappingrules + +import ( + "testing" + + "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type RuleTargetAxisTests struct { + suite.Suite + mock *InputDeviceMock + call *mock.Call +} + +type InputDeviceMock struct { + mock.Mock +} + +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) +} + +func (t *RuleTargetAxisTests) SetupSuite() { + t.mock = new(InputDeviceMock) + t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ + evdev.ABS_X: { + Minimum: 0, + Maximum: 10000, + }, + evdev.ABS_Y: { + Minimum: -10000, + Maximum: 10000, + }, + }, nil) +} + +func (t *RuleTargetAxisTests) TearDownSuite() { + t.call.Unset() +} + +func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() { + // RuleTargets should get created + ruleTarget, err := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Nil(err) + t.EqualValues(10000, ruleTarget.axisSize) + + ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, 0, 0) + t.Nil(err) + t.EqualValues(20000, ruleTarget.axisSize) + + // Creating a rule with a deadzone should work and reduce the axisSize + ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500) + t.Nil(err) + t.EqualValues(19000, ruleTarget.axisSize) + t.EqualValues(-500, ruleTarget.DeadzoneStart) + t.EqualValues(500, ruleTarget.DeadzoneEnd) + + // Creating a rule with a deadzone should fail if end > start + _, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, 500, -500) + t.NotNil(err) + + // Creating a rule on a non-existent axis should err + _, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Z, false, 0, 0) + t.NotNil(err) +} + +func (t *RuleTargetAxisTests) TestNormalizeValue() { + // Basic normalization should work + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Equal(MaxAxisValue, ruleTarget.NormalizeValue(int32(10000))) + t.Equal(MinAxisValue, 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.Equal(MaxAxisValue, 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.Equal(MaxAxisValue, ruleTarget.NormalizeValue(int32(0))) + t.Equal(MinAxisValue, ruleTarget.NormalizeValue(int32(10000))) +} + +func (t *RuleTargetAxisTests) TestMatchEvent() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500) + validEvent := &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_Y, + Value: 800, + } + deadzoneEvent := &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_Y, + Value: 200, + } + + // An event on the correct device and axis should match + t.True(ruleTarget.MatchEvent(t.mock, validEvent)) + + // A value on the wrong device should not match + t.False(ruleTarget.MatchEvent(&evdev.InputDevice{}, validEvent)) + + // A value in the deadzone should not match + t.False(ruleTarget.MatchEvent(t.mock, deadzoneEvent)) +} + +func (t *RuleTargetAxisTests) TestCreateEvent() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + expected := &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + } + + // Basic event creation + testValue := int32(3928) // Arbitrarily chosen test value + expected.Value = testValue + t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) + + // Validate axis clamping + testValue = int32(64000) + expected.Value = MaxAxisValue + t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) + + testValue = int32(-64000) + expected.Value = MinAxisValue + t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) +} + +func TestRunnerRuleTargetAxisTests(t *testing.T) { + suite.Run(t, new(RuleTargetAxisTests)) +} -- 2.47.2 From 681e1fef705ada65b8074af68a4e2d24dafa7e61 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 18:15:11 -0400 Subject: [PATCH 04/11] Don't fail target creation when AbsInfo errors. --- internal/mappingrules/rule_target_axis.go | 18 +++++++++---- .../mappingrules/rule_target_axis_test.go | 26 ++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 629eb5c..2ea2b58 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -19,8 +19,8 @@ type RuleTargetAxis struct { } const ( - MinAxisValue = int32(-32768) - MaxAxisValue = int32(32767) + AxisValueMin = int32(-32768) + AxisValueMax = int32(32767) ) func NewRuleTargetAxis(device_name string, @@ -31,8 +31,16 @@ func NewRuleTargetAxis(device_name string, deadzoneEnd int32) (*RuleTargetAxis, error) { info, err := device.AbsInfos() + if err != nil { - return nil, err + // If we can't get AbsInfo (for example, we're a virtual device) + // we set the bounds to the maximum allowable + info = map[evdev.EvCode]evdev.AbsInfo{ + axis: { + Minimum: AxisValueMin, + Maximum: AxisValueMax, + }, + } } if _, ok := info[axis]; !ok { @@ -77,12 +85,12 @@ func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { if target.Inverted { axisStrength = 1.0 - axisStrength } - normalizedValue := LerpInt(MinAxisValue, MaxAxisValue, axisStrength) + normalizedValue := LerpInt(AxisValueMin, AxisValueMax, axisStrength) return normalizedValue } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { - value = ClampInt(value, MinAxisValue, MaxAxisValue) + value = ClampInt(value, AxisValueMin, AxisValueMax) return &evdev.InputEvent{ Type: evdev.EV_ABS, Code: target.Axis, diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index 556ab48..15bcd0e 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -1,6 +1,7 @@ package mappingrules import ( + "errors" "testing" "github.com/holoplot/go-evdev" @@ -23,7 +24,7 @@ func (m *InputDeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) { return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1) } -func (t *RuleTargetAxisTests) SetupSuite() { +func (t *RuleTargetAxisTests) SetupTest() { t.mock = new(InputDeviceMock) t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ evdev.ABS_X: { @@ -37,7 +38,7 @@ func (t *RuleTargetAxisTests) SetupSuite() { }, nil) } -func (t *RuleTargetAxisTests) TearDownSuite() { +func (t *RuleTargetAxisTests) TearDownTest() { t.call.Unset() } @@ -65,25 +66,32 @@ func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() { // Creating a rule on a non-existent axis should err _, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Z, false, 0, 0) t.NotNil(err) + + // If Absinfo has an error, we should create a device with permissive bounds + t.call.Unset() + t.mock.On("AbsInfos").Return(nil, errors.New("Test Error")) + ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500) + t.Nil(err) + t.Equal(AxisValueMax-AxisValueMin, ruleTarget.axisSize) } func (t *RuleTargetAxisTests) TestNormalizeValue() { // Basic normalization should work ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) - t.Equal(MaxAxisValue, ruleTarget.NormalizeValue(int32(10000))) - t.Equal(MinAxisValue, ruleTarget.NormalizeValue(int32(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.Equal(MaxAxisValue, ruleTarget.NormalizeValue(int32(10000))) + 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.Equal(MaxAxisValue, ruleTarget.NormalizeValue(int32(0))) - t.Equal(MinAxisValue, ruleTarget.NormalizeValue(int32(10000))) + t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0))) + t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000))) } func (t *RuleTargetAxisTests) TestMatchEvent() { @@ -123,11 +131,11 @@ func (t *RuleTargetAxisTests) TestCreateEvent() { // Validate axis clamping testValue = int32(64000) - expected.Value = MaxAxisValue + expected.Value = AxisValueMax t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) testValue = int32(-64000) - expected.Value = MinAxisValue + expected.Value = AxisValueMin t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) } -- 2.47.2 From a6ad1b609ae0931d0396c9452b9a82684b2a7490 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 19:58:31 -0400 Subject: [PATCH 05/11] Clamp values falling outside of the axis bounds. --- internal/mappingrules/math.go | 9 +++++++-- internal/mappingrules/rule_target_axis.go | 5 +++-- internal/mappingrules/rule_target_axis_test.go | 9 +++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/mappingrules/math.go b/internal/mappingrules/math.go index bccae51..37de4a2 100644 --- a/internal/mappingrules/math.go +++ b/internal/mappingrules/math.go @@ -4,17 +4,22 @@ import ( "golang.org/x/exp/constraints" ) -func AbsInt[T constraints.Integer](value T) T { +type Numeric interface { + constraints.Integer | constraints.Float +} + +func Abs[T Numeric](value T) T { return max(value, -value) } // LerpInt linearly interpolates between two integer values using // a float64 index value func LerpInt[T constraints.Integer](min, max T, t float64) T { + t = Clamp(t, 0.0, 1.0) return T((1-t)*float64(min) + t*float64(max)) } -func ClampInt[T constraints.Integer](value, min, max T) T { +func Clamp[T Numeric](value, min, max T) T { if value < min { value = min } diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 2ea2b58..8701b2c 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -51,7 +51,7 @@ func NewRuleTargetAxis(device_name string, return nil, errors.New("deadzone_end must be a higher value than deadzone_start") } - deadzoneSize := AbsInt(deadzoneEnd - deadzoneStart) + deadzoneSize := Abs(deadzoneEnd - deadzoneStart) // Our output range is limited to 16 bits, but we represent values internally with 32 bits. // As a result, we shouldn't need to worry about integer overruns @@ -82,6 +82,7 @@ func NewRuleTargetAxis(device_name string, // in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { axisStrength := float64(value-target.deadzoneSize) / float64(target.axisSize) + if target.Inverted { axisStrength = 1.0 - axisStrength } @@ -90,7 +91,7 @@ func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { - value = ClampInt(value, AxisValueMin, AxisValueMax) + value = Clamp(value, AxisValueMin, AxisValueMax) return &evdev.InputEvent{ Type: evdev.EV_ABS, Code: target.Axis, diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index 15bcd0e..6d6e632 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -69,8 +69,8 @@ func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() { // If Absinfo has an error, we should create a device with permissive bounds t.call.Unset() - t.mock.On("AbsInfos").Return(nil, errors.New("Test Error")) - ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500) + t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{}, errors.New("Test Error")) + ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) t.Nil(err) t.Equal(AxisValueMax-AxisValueMin, ruleTarget.axisSize) } @@ -92,6 +92,11 @@ func (t *RuleTargetAxisTests) TestNormalizeValue() { 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.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000))) + t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000))) } func (t *RuleTargetAxisTests) TestMatchEvent() { -- 2.47.2 From 47fac539da74437fbe2b8fe2351dbbacb9382144 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Thu, 10 Jul 2025 23:18:34 -0400 Subject: [PATCH 06/11] Implement MappingRuleAxisToButton. --- internal/mappingrules/interfaces.go | 5 ++ .../mapping_rule_axis_to_button.go | 78 ++++++++++++++----- internal/mappingrules/rule_target_axis.go | 25 ++++-- 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/internal/mappingrules/interfaces.go b/internal/mappingrules/interfaces.go index 316ec44..8ea4f39 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -33,3 +33,8 @@ type RuleTarget interface { type RuleTargetDevice interface { AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) } + +const ( + AxisValueMin = int32(-32768) + AxisValueMax = int32(32767) +) diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index f28703b..ec4145c 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -6,45 +6,85 @@ import ( "github.com/holoplot/go-evdev" ) -// TODO: This whole file is still WIP +// MappingRuleAxisToButton represents a rule that converts an axis input into a (potentially repeating) +// button output. +// +// TODO: Add Tests type MappingRuleAxisToButton struct { MappingRuleBase - Input *RuleTargetAxis - Output *RuleTargetButton - RepeatSpeedMin int32 - RepeatSpeedMax int32 - lastValue int32 - lastEvent time.Time + Input *RuleTargetAxis + Output *RuleTargetButton + RepeatRateMin int + RepeatRateMax int + nextEvent time.Duration + lastEvent time.Time + pressed bool +} + +const ( + NoNextEvent = time.Duration(-1) +) + +func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { + return &MappingRuleAxisToButton{ + MappingRuleBase: base, + Input: input, + Output: output, + RepeatRateMin: repeatRateMin, + RepeatRateMax: repeatRateMax, + lastEvent: time.Now(), + nextEvent: NoNextEvent, + pressed: false, + } } func (rule *MappingRuleAxisToButton) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + // TODO: we're using this instead of the RuleTarget's MatchEvent because we need to check inside the deadzone + // We should find a cleaner way to do this... if !rule.MappingRuleBase.modeCheck(mode) || - !rule.Input.MatchEvent(device, event) { + !rule.Input.MatchEventDeviceAndCode(device, event) { return nil, nil } - // set the last value to the normalized input value - rule.lastValue = rule.Input.NormalizeValue(event.Value) + // If we're inside the deadzone, unset the next event + if rule.Input.InDeadZone(event.Value) { + rule.nextEvent = NoNextEvent + return nil, nil + } + + // If we aren't repeating, we trigger the event immediately + if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { + rule.nextEvent = time.Millisecond + return nil, nil + } + + // use the axis value and the repeat rate to set a target time until the next event + strength := 1.0 - rule.Input.GetAxisStrength(event.Value) + rate := int64(LerpInt(rule.RepeatRateMax, rule.RepeatRateMin, strength)) + rule.nextEvent = time.Duration(rate * int64(time.Millisecond)) + return nil, nil } // TimerEvent returns an event when enough time has passed (compared to the last recorded axis value) // to emit an event. func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { - // This is tighter coupling than we'd like, but it will do for now. - // TODO: maybe it would be better to just be more declarative about event types and their inputs and outputs. - if rule.lastValue < rule.Input.DeadzoneStart { + // If we pressed the button last tick, release it + if rule.pressed { + rule.pressed = false + return rule.Output.CreateEvent(0, nil) + } + + // This indicates that we should not emit another event + if rule.nextEvent == -1 { rule.lastEvent = time.Now() return nil } - // calculate target time until next event press - // nextEvent := rule.LastEvent + (rule.LastValue) - - // TODO: figure out what the condition should be - if false { - // TODO: emit event + if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { rule.lastEvent = time.Now() + rule.pressed = true + return rule.Output.CreateEvent(1, nil) } return nil diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 8701b2c..5808e43 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -18,11 +18,6 @@ type RuleTargetAxis struct { deadzoneSize int32 } -const ( - AxisValueMin = int32(-32768) - AxisValueMax = int32(32767) -) - func NewRuleTargetAxis(device_name string, device RuleTargetDevice, axis evdev.EvCode, @@ -81,7 +76,7 @@ func NewRuleTargetAxis(device_name string, // Typically this function is called after RuleTargetAxis.MatchEvent, which checks whether we are // in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { - axisStrength := float64(value-target.deadzoneSize) / float64(target.axisSize) + axisStrength := target.GetAxisStrength(value) if target.Inverted { axisStrength = 1.0 - axisStrength @@ -100,8 +95,22 @@ func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.Inpu } func (target *RuleTargetAxis) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool { + return target.MatchEventDeviceAndCode(device, event) && + !target.InDeadZone(event.Value) +} + +// TODO: Add tests +func (target *RuleTargetAxis) MatchEventDeviceAndCode(device RuleTargetDevice, event *evdev.InputEvent) bool { return device == target.Device && event.Type == evdev.EV_ABS && - event.Code == target.Axis && - (event.Value < target.DeadzoneStart || event.Value > target.DeadzoneEnd) + event.Code == target.Axis +} + +// TODO: Add tests +func (target *RuleTargetAxis) InDeadZone(value int32) bool { + return value >= target.DeadzoneStart && value <= target.DeadzoneEnd +} + +func (target *RuleTargetAxis) GetAxisStrength(value int32) float64 { + return float64(value-target.deadzoneSize) / float64(target.axisSize) } -- 2.47.2 From e93187b8a5d7b5b213706c7a226df8246b629c6a Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 11 Jul 2025 21:20:43 -0400 Subject: [PATCH 07/11] Add tests for AxisToButton rule. (WIP) --- .../mapping_rule_axis_to_button.go | 7 +-- .../mapping_rule_axis_to_button_test.go | 59 +++++++++++++++++++ .../mappingrules/rule_target_axis_test.go | 9 --- internal/mappingrules/test_mocks.go | 15 +++++ 4 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_axis_to_button_test.go create mode 100644 internal/mappingrules/test_mocks.go diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index ec4145c..0873913 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -8,8 +8,6 @@ import ( // MappingRuleAxisToButton represents a rule that converts an axis input into a (potentially repeating) // button output. -// -// TODO: Add Tests type MappingRuleAxisToButton struct { MappingRuleBase Input *RuleTargetAxis @@ -38,9 +36,8 @@ func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, out } } -func (rule *MappingRuleAxisToButton) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { - // TODO: we're using this instead of the RuleTarget's MatchEvent because we need to check inside the deadzone - // We should find a cleaner way to do this... +func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + if !rule.MappingRuleBase.modeCheck(mode) || !rule.Input.MatchEventDeviceAndCode(device, event) { return nil, nil diff --git a/internal/mappingrules/mapping_rule_axis_to_button_test.go b/internal/mappingrules/mapping_rule_axis_to_button_test.go new file mode 100644 index 0000000..a79f64f --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -0,0 +1,59 @@ +package mappingrules + +import ( + "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/suite" +) + +type MappingRuleAxisToButtonTests struct { + suite.Suite + inputDevice *InputDeviceMock + inputRule *RuleTargetAxis + outputDevice *evdev.InputDevice + outputRule *RuleTargetButton + mode *string + base MappingRuleBase +} + +func (t *MappingRuleAxisToButtonTests) SetupTest() { + mode := "*" + t.mode = &mode + t.inputDevice = new(InputDeviceMock) + t.inputDevice.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ + evdev.ABS_X: { + Minimum: 0, + Maximum: 10000, + }, + }, nil) + t.inputRule, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, false, int32(0), int32(1000)) + + t.outputDevice = &evdev.InputDevice{} + t.outputRule, _ = NewRuleTargetButton("test-output", t.outputDevice, evdev.ABS_X, false) + t.base = NewMappingRuleBase("", []string{"*"}) +} + +func (t *MappingRuleAxisToButtonTests) TestMatchEvent() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) + + // A valid input should set a nextevent + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.NotEqual(NoNextEvent, testRule.nextEvent) + + // And a deadzone value should clear it + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 500, + }, t.mode) + t.Equal(NoNextEvent, testRule.nextEvent) + + // TODO: more tests here... check repeats +} + +func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { + // STUB +} diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index 6d6e632..b4bb4c0 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -15,15 +15,6 @@ type RuleTargetAxisTests struct { call *mock.Call } -type InputDeviceMock struct { - mock.Mock -} - -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) -} - func (t *RuleTargetAxisTests) SetupTest() { t.mock = new(InputDeviceMock) t.call = t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ diff --git a/internal/mappingrules/test_mocks.go b/internal/mappingrules/test_mocks.go new file mode 100644 index 0000000..9838731 --- /dev/null +++ b/internal/mappingrules/test_mocks.go @@ -0,0 +1,15 @@ +package mappingrules + +import ( + "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/mock" +) + +type InputDeviceMock struct { + mock.Mock +} + +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) +} -- 2.47.2 From ed2627e1135a9fcc7ba81328b3dfc0ef024c30ae Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Sat, 12 Jul 2025 17:14:57 -0400 Subject: [PATCH 08/11] Implement config generator for AxisToButton. Use RuleTargetDevice interface more broadly. --- internal/config/make_rules.go | 14 +++++++++++--- internal/config/schema.go | 14 ++++++++------ internal/mappingrules/interfaces.go | 4 +++- internal/mappingrules/mapping_rule_axis.go | 2 +- internal/mappingrules/mapping_rule_button.go | 2 +- internal/mappingrules/mapping_rule_button_combo.go | 2 +- .../mappingrules/mapping_rule_button_latched.go | 2 +- internal/mappingrules/mapping_rule_mode_select.go | 2 +- internal/mappingrules/rule_target_button.go | 2 +- 9 files changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 5e7e9de..1b6490c 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "strings" @@ -134,13 +133,22 @@ func makeMappingRuleAxis(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxis(base, input, output), nil } -// STUB func makeMappingRuleAxisToButton(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToButton, error) { - return nil, errors.New("stub: makeMappingRuleAxisToButton") + input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := makeRuleTargetButton(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + + return mappingrules.NewMappingRuleAxisToButton(base, input, output, ruleConfig.RepeatRateMin, ruleConfig.RepeatRateMax), nil } func makeMappingRuleModeSelect(ruleConfig RuleConfig, diff --git a/internal/config/schema.go b/internal/config/schema.go index 42bd339..1482c8c 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -19,12 +19,14 @@ type DeviceConfig struct { } type RuleConfig struct { - Name string `yaml:"name,omitempty"` - Type string `yaml:"type"` - Input RuleTargetConfig `yaml:"input,omitempty"` - Inputs []RuleTargetConfig `yaml:"inputs,omitempty"` - Output RuleTargetConfig `yaml:"output"` - Modes []string `yaml:"modes,omitempty"` + Name string `yaml:"name,omitempty"` + Type string `yaml:"type"` + Input RuleTargetConfig `yaml:"input,omitempty"` + Inputs []RuleTargetConfig `yaml:"inputs,omitempty"` + Output RuleTargetConfig `yaml:"output"` + Modes []string `yaml:"modes,omitempty"` + RepeatRateMin int `yaml:"repeat_rate_min,omitempty"` + RepeatRateMax int `yaml:"repeat_rate_max,omitempty"` } type RuleTargetConfig struct { diff --git a/internal/mappingrules/interfaces.go b/internal/mappingrules/interfaces.go index 8ea4f39..3be58b4 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -3,7 +3,7 @@ package mappingrules import "github.com/holoplot/go-evdev" type MappingRule interface { - MatchEvent(*evdev.InputDevice, *evdev.InputEvent, *string) (*evdev.InputDevice, *evdev.InputEvent) + MatchEvent(RuleTargetDevice, *evdev.InputEvent, *string) (*evdev.InputDevice, *evdev.InputEvent) } // RuleTargets represent either a device input to match on, or an output to produce. @@ -25,6 +25,8 @@ type RuleTarget interface { // Typically int32 is the input event's normalized value. *string is the current mode, but is optional // for most implementations. CreateEvent(int32, *string) *evdev.InputEvent + + MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool } // RuleTargetDevice is an interface abstraction on top of evdev.InputDevice, implementing diff --git a/internal/mappingrules/mapping_rule_axis.go b/internal/mappingrules/mapping_rule_axis.go index da324e2..7b3e778 100644 --- a/internal/mappingrules/mapping_rule_axis.go +++ b/internal/mappingrules/mapping_rule_axis.go @@ -17,7 +17,7 @@ func NewMappingRuleAxis(base MappingRuleBase, input *RuleTargetAxis, output *Rul } } -func (rule *MappingRuleAxis) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { +func (rule *MappingRuleAxis) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { if !rule.MappingRuleBase.modeCheck(mode) || !rule.Input.MatchEvent(device, event) { return nil, nil diff --git a/internal/mappingrules/mapping_rule_button.go b/internal/mappingrules/mapping_rule_button.go index bf47609..a13d5a6 100644 --- a/internal/mappingrules/mapping_rule_button.go +++ b/internal/mappingrules/mapping_rule_button.go @@ -21,7 +21,7 @@ func NewMappingRuleButton( } } -func (rule *MappingRuleButton) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { +func (rule *MappingRuleButton) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { if !rule.MappingRuleBase.modeCheck(mode) { return nil, nil } diff --git a/internal/mappingrules/mapping_rule_button_combo.go b/internal/mappingrules/mapping_rule_button_combo.go index 8bfbda8..4f488ef 100644 --- a/internal/mappingrules/mapping_rule_button_combo.go +++ b/internal/mappingrules/mapping_rule_button_combo.go @@ -23,7 +23,7 @@ func NewMappingRuleButtonCombo( } } -func (rule *MappingRuleButtonCombo) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { +func (rule *MappingRuleButtonCombo) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { if !rule.MappingRuleBase.modeCheck(mode) { return nil, nil } diff --git a/internal/mappingrules/mapping_rule_button_latched.go b/internal/mappingrules/mapping_rule_button_latched.go index 0915550..1204968 100644 --- a/internal/mappingrules/mapping_rule_button_latched.go +++ b/internal/mappingrules/mapping_rule_button_latched.go @@ -22,7 +22,7 @@ func NewMappingRuleButtonLatched( } } -func (rule *MappingRuleButtonLatched) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { +func (rule *MappingRuleButtonLatched) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { if !rule.MappingRuleBase.modeCheck(mode) { return nil, nil } diff --git a/internal/mappingrules/mapping_rule_mode_select.go b/internal/mappingrules/mapping_rule_mode_select.go index c858e10..1bb13fa 100644 --- a/internal/mappingrules/mapping_rule_mode_select.go +++ b/internal/mappingrules/mapping_rule_mode_select.go @@ -22,7 +22,7 @@ func NewMappingRuleModeSelect( } func (rule *MappingRuleModeSelect) MatchEvent( - device *evdev.InputDevice, + device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/rule_target_button.go b/internal/mappingrules/rule_target_button.go index 511249c..93534c7 100644 --- a/internal/mappingrules/rule_target_button.go +++ b/internal/mappingrules/rule_target_button.go @@ -36,7 +36,7 @@ func (target *RuleTargetButton) CreateEvent(value int32, _ *string) *evdev.Input } } -func (target *RuleTargetButton) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent) bool { +func (target *RuleTargetButton) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool { return device == target.Device && event.Type == evdev.EV_KEY && event.Code == target.Button -- 2.47.2 From 8bbb84da85f3a50fc1ccde84867ba76d2ddaf8c5 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Sat, 12 Jul 2025 17:55:44 -0400 Subject: [PATCH 09/11] Add some additional tests for AxisToButton. --- .../mapping_rule_axis_to_button_test.go | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/mappingrules/mapping_rule_axis_to_button_test.go b/internal/mappingrules/mapping_rule_axis_to_button_test.go index a79f64f..5a3193b 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button_test.go +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -1,6 +1,9 @@ package mappingrules import ( + "testing" + "time" + "github.com/holoplot/go-evdev" "github.com/stretchr/testify/suite" ) @@ -51,9 +54,34 @@ func (t *MappingRuleAxisToButtonTests) TestMatchEvent() { }, t.mode) t.Equal(NoNextEvent, testRule.nextEvent) - // TODO: more tests here... check repeats + testRule = NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 10000, + }, t.mode) + t.Equal(time.Duration(250*time.Millisecond), testRule.nextEvent) + + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.True(testRule.nextEvent > time.Duration(700*time.Millisecond)) + + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 5500, + }, t.mode) + t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent) } -func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { - // STUB +// TODO: to add TimerEvent tests we need to use an interface to mock time. +// func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { +// // STUB +// } + +func TestRunnerMappingRuleAxisToButtonTests(t *testing.T) { + suite.Run(t, new(MappingRuleAxisToButtonTests)) } -- 2.47.2 From 0915ea059a641a202abf52d2fb5bb0ba9d10cad8 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 15 Jul 2025 00:46:07 -0400 Subject: [PATCH 10/11] (WIP) Implement axis-to-relaxis repeats; similar to buttons but for discretized relative axis inputs. (i.e. mousewheel) --- cmd/joyful/main.go | 2 +- cmd/joyful/threads.go | 6 +- internal/config/make_rule_targets.go | 19 ++++ internal/config/make_rules.go | 24 +++++ internal/config/schema.go | 11 ++- internal/config/variables.go | 13 +-- internal/mappingrules/interfaces.go | 12 ++- .../mapping_rule_axis_to_button.go | 11 ++- .../mapping_rule_axis_to_relaxis.go | 98 +++++++++++++++++++ internal/mappingrules/rule_target_relaxis.go | 46 +++++++++ 10 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_axis_to_relaxis.go create mode 100644 internal/mappingrules/rule_target_relaxis.go diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 9ce1d66..79495ef 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -75,7 +75,7 @@ func main() { timerCount := 0 for _, rule := range rules { - if timedRule, ok := rule.(*mappingrules.MappingRuleAxisToButton); ok { + if timedRule, ok := rule.(mappingrules.TimedEventEmitter); ok { go timerWatcher(timedRule, eventChannel) timerCount++ } diff --git a/cmd/joyful/threads.go b/cmd/joyful/threads.go index 9f28101..e630881 100644 --- a/cmd/joyful/threads.go +++ b/cmd/joyful/threads.go @@ -9,7 +9,7 @@ import ( ) const ( - TimerCheckIntervalMs = 250 + TimerCheckIntervalMs = 1 DeviceCheckIntervalMs = 1 ) @@ -28,12 +28,12 @@ func eventWatcher(device *evdev.InputDevice, channel chan<- ChannelEvent) { } } -func timerWatcher(rule *mappingrules.MappingRuleAxisToButton, channel chan<- ChannelEvent) { +func timerWatcher(rule mappingrules.TimedEventEmitter, channel chan<- ChannelEvent) { for { event := rule.TimerEvent() if event != nil { channel <- ChannelEvent{ - Device: rule.Output.Device, + Device: rule.GetOutputDevice(), Event: event, Type: ChannelEventTimer, } diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 28afedc..cc6c458 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -48,6 +48,25 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In ) } +func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetRelaxis, error) { + device, ok := devs[targetConfig.Device] + if !ok { + return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) + } + + eventCode, ok := evdev.RELFromString[targetConfig.Axis] + if !ok { + return nil, fmt.Errorf("invalid button code '%s'", targetConfig.Button) + } + + return mappingrules.NewRuleTargetRelaxis( + targetConfig.Device, + device, + eventCode, + targetConfig.Inverted, + ) +} + func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { if ok := validateModes(targetConfig.Modes, allModes); !ok { return nil, errors.New("undefined mode in mode select list") diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 1b6490c..6d75d58 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -40,6 +40,8 @@ func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDev newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base) case RuleTypeAxisToButton: newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base) + case RuleTypeAxisToRelaxis: + newRule, err = makeMappingRuleAxisToRelaxis(ruleConfig, pDevs, vDevs, base) case RuleTypeModeSelect: newRule, err = makeMappingRuleModeSelect(ruleConfig, pDevs, modes, base) default: @@ -151,6 +153,28 @@ func makeMappingRuleAxisToButton(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxisToButton(base, input, output, ruleConfig.RepeatRateMin, ruleConfig.RepeatRateMax), nil } +func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, + pDevs map[string]*evdev.InputDevice, + vDevs map[string]*evdev.InputDevice, + base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToRelaxis, error) { + + input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := makeRuleTargetRelaxis(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + + return mappingrules.NewMappingRuleAxisToRelaxis(base, + input, output, + ruleConfig.RepeatRateMin, + ruleConfig.RepeatRateMax, + ruleConfig.Increment), nil +} + func makeMappingRuleModeSelect(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, modes []string, diff --git a/internal/config/schema.go b/internal/config/schema.go index 1482c8c..b91bb8d 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,5 +1,11 @@ // These types comprise the YAML schema for configuring Joyful. // The config files will be combined and then unmarshalled into this +// +// TODO: currently the types in here aren't especially strong; each one is +// decomposed into a different object based on the Type fields. We should implement +// some sort of delayed unmarshalling technique, for example see ideas at +// https://stackoverflow.com/questions/70635636/unmarshaling-yaml-into-different-struct-based-off-yaml-field +// Then we can be more explicit about the interface here. package config @@ -27,14 +33,15 @@ type RuleConfig struct { Modes []string `yaml:"modes,omitempty"` RepeatRateMin int `yaml:"repeat_rate_min,omitempty"` RepeatRateMax int `yaml:"repeat_rate_max,omitempty"` + Increment int `yaml:"increment,omitempty"` } type RuleTargetConfig struct { Device string `yaml:"device,omitempty"` Button string `yaml:"button,omitempty"` Axis string `yaml:"axis,omitempty"` - DeadzoneStart int32 `yaml:"axis_start,omitempty"` - DeadzoneEnd int32 `yaml:"axis_end,omitempty"` + DeadzoneStart int32 `yaml:"deadzone_start,omitempty"` + DeadzoneEnd int32 `yaml:"deadzone_end,omitempty"` Inverted bool `yaml:"inverted,omitempty"` Modes []string `yaml:"modes,omitempty"` } diff --git a/internal/config/variables.go b/internal/config/variables.go index f352474..c0276c4 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -8,12 +8,13 @@ const ( DeviceTypePhysical = "physical" DeviceTypeVirtual = "virtual" - RuleTypeButton = "button" - RuleTypeButtonCombo = "button-combo" - RuleTypeLatched = "button-latched" - RuleTypeAxis = "axis" - RuleTypeModeSelect = "mode-select" - RuleTypeAxisToButton = "axis-to-button" + RuleTypeButton = "button" + RuleTypeButtonCombo = "button-combo" + RuleTypeLatched = "button-latched" + RuleTypeAxis = "axis" + RuleTypeModeSelect = "mode-select" + RuleTypeAxisToButton = "axis-to-button" + RuleTypeAxisToRelaxis = "axis-to-relaxis" ) var ( diff --git a/internal/mappingrules/interfaces.go b/internal/mappingrules/interfaces.go index 3be58b4..bc10e9b 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -1,11 +1,20 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "time" + + "github.com/holoplot/go-evdev" +) type MappingRule interface { MatchEvent(RuleTargetDevice, *evdev.InputEvent, *string) (*evdev.InputDevice, *evdev.InputEvent) } +type TimedEventEmitter interface { + TimerEvent() *evdev.InputEvent + GetOutputDevice() *evdev.InputDevice +} + // RuleTargets represent either a device input to match on, or an output to produce. // Some RuleTarget types may work via side effects, such as RuleTargetModeSelect. type RuleTarget interface { @@ -39,4 +48,5 @@ type RuleTargetDevice interface { const ( AxisValueMin = int32(-32768) AxisValueMax = int32(32767) + NoNextEvent = time.Duration(-1) ) diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index 0873913..9c4205a 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -19,10 +19,6 @@ type MappingRuleAxisToButton struct { pressed bool } -const ( - NoNextEvent = time.Duration(-1) -) - func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { return &MappingRuleAxisToButton{ MappingRuleBase: base, @@ -50,6 +46,7 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * } // If we aren't repeating, we trigger the event immediately + // TODO: we aren't using pressed correctly; that should be set *and released* in here... if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { rule.nextEvent = time.Millisecond return nil, nil @@ -73,7 +70,7 @@ func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { } // This indicates that we should not emit another event - if rule.nextEvent == -1 { + if rule.nextEvent == NoNextEvent { rule.lastEvent = time.Now() return nil } @@ -86,3 +83,7 @@ func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { return nil } + +func (rule *MappingRuleAxisToButton) GetOutputDevice() *evdev.InputDevice { + return rule.Output.Device +} diff --git a/internal/mappingrules/mapping_rule_axis_to_relaxis.go b/internal/mappingrules/mapping_rule_axis_to_relaxis.go new file mode 100644 index 0000000..07e4b85 --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -0,0 +1,98 @@ +package mappingrules + +import ( + "time" + + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" +) + +// TODO: add tests + +// TODO: deadzones seem to calculate correctly in one direction but not the other when computing axis strength... + +// MappingRuleAxisToRelaxis represents a rule that converts an axis input into a (potentially repeating) +// button output. +type MappingRuleAxisToRelaxis struct { + MappingRuleBase + Input *RuleTargetAxis + Output *RuleTargetRelaxis + RepeatRateMin int + RepeatRateMax int + Increment int32 + nextEvent time.Duration + lastEvent time.Time +} + +func NewMappingRuleAxisToRelaxis( + base MappingRuleBase, + input *RuleTargetAxis, + output *RuleTargetRelaxis, + repeatRateMin, repeatRateMax, increment int) *MappingRuleAxisToRelaxis { + + return &MappingRuleAxisToRelaxis{ + MappingRuleBase: base, + Input: input, + Output: output, + RepeatRateMin: repeatRateMin, + RepeatRateMax: repeatRateMax, + Increment: int32(increment), + lastEvent: time.Now(), + nextEvent: NoNextEvent, + } +} + +func (rule *MappingRuleAxisToRelaxis) MatchEvent( + device RuleTargetDevice, + event *evdev.InputEvent, + mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + + if !rule.MappingRuleBase.modeCheck(mode) || + !rule.Input.MatchEventDeviceAndCode(device, event) { + return nil, nil + } + + defer func() { + logger.Logf("DEBUG: Rule '%s' nextEvent == '%v' with device value '%d'", rule.Name, rule.nextEvent, event.Value) + }() + + // If we're inside the deadzone, unset the next event + if rule.Input.InDeadZone(event.Value) { + rule.nextEvent = NoNextEvent + return nil, nil + } + + // If we aren't repeating, we trigger the event immediately + // TODO: this still needs the pressed parameter... + if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { + rule.nextEvent = time.Millisecond + return nil, nil + } + + // use the axis value and the repeat rate to set a target time until the next event + strength := 1.0 - rule.Input.GetAxisStrength(event.Value) + rate := int64(LerpInt(rule.RepeatRateMax, rule.RepeatRateMin, strength)) + rule.nextEvent = time.Duration(rate * int64(time.Millisecond)) + return nil, nil +} + +// TimerEvent returns an event when enough time has passed (compared to the last recorded axis value) +// to emit an event. +func (rule *MappingRuleAxisToRelaxis) TimerEvent() *evdev.InputEvent { + // This indicates that we should not emit another event + if rule.nextEvent == NoNextEvent { + rule.lastEvent = time.Now() + return nil + } + + if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = time.Now() + return rule.Output.CreateEvent(rule.Increment, nil) + } + + return nil +} + +func (rule *MappingRuleAxisToRelaxis) GetOutputDevice() *evdev.InputDevice { + return rule.Output.Device.(*evdev.InputDevice) +} diff --git a/internal/mappingrules/rule_target_relaxis.go b/internal/mappingrules/rule_target_relaxis.go new file mode 100644 index 0000000..648d7fc --- /dev/null +++ b/internal/mappingrules/rule_target_relaxis.go @@ -0,0 +1,46 @@ +package mappingrules + +import ( + "github.com/holoplot/go-evdev" +) + +type RuleTargetRelaxis struct { + DeviceName string + Device RuleTargetDevice + Axis evdev.EvCode + Inverted bool +} + +func NewRuleTargetRelaxis(device_name string, + device RuleTargetDevice, + axis evdev.EvCode, + inverted bool) (*RuleTargetRelaxis, error) { + + return &RuleTargetRelaxis{ + DeviceName: device_name, + Device: device, + Axis: axis, + Inverted: inverted, + }, nil +} + +// NormalizeValue takes a raw input value and converts it to a value suitable for output. +// +// Relative axes are currently only supported for output. +// TODO: make this have an error return? +func (target *RuleTargetRelaxis) NormalizeValue(value int32) int32 { + return 0 +} + +func (target *RuleTargetRelaxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { + return &evdev.InputEvent{ + Type: evdev.EV_REL, + Code: target.Axis, + Value: value, + } +} + +// Relative axis is only supported for output. +func (target *RuleTargetRelaxis) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent) bool { + return false +} -- 2.47.2 From 58abd4cc349ee41b0fa544154ca220fe9b422939 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 15 Jul 2025 15:27:49 -0400 Subject: [PATCH 11/11] Completed implementation. --- cmd/joyful/main.go | 5 +- go.mod | 1 + go.sum | 2 + internal/config/devices.go | 18 ++ internal/config/schema.go | 13 +- .../mapping_rule_axis_to_button.go | 38 +++- .../mapping_rule_axis_to_button_test.go | 173 ++++++++++++++---- .../mapping_rule_axis_to_relaxis.go | 13 +- internal/mappingrules/rule_target_axis.go | 19 +- .../mappingrules/rule_target_axis_test.go | 44 +++++ 10 files changed, 260 insertions(+), 66 deletions(-) diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 79495ef..f6b8e5f 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -95,7 +95,8 @@ func main() { case ChannelEventInput: switch channelEvent.Event.Type { case evdev.EV_SYN: - // We've received a SYN_REPORT, so now we send all of our pending events + // We've received a SYN_REPORT, so now we send all pending events; since SYN_REPORTs + // might come from multiple input devices, we'll always flush, just to be sure. for _, buffer := range vBuffersByName { buffer.SendEvents() } @@ -114,6 +115,8 @@ func main() { case ChannelEventTimer: // Timer events give us the device and event to use directly vBuffersByDevice[channelEvent.Device].AddEvent(channelEvent.Event) + // If we get a timer event, flush the output device buffer immediately + vBuffersByDevice[channelEvent.Device].SendEvents() } } } diff --git a/go.mod b/go.mod index bbe28fc..b469a18 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/goccy/go-yaml v1.18.0 github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 + github.com/jonboulle/clockwork v0.5.0 github.com/stretchr/testify v1.10.0 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b ) diff --git a/go.sum b/go.sum index 079743c..4942bf2 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Tatx+uUGOhah73jyNUrt7DmfDRXXJ5Xo= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/internal/config/devices.go b/internal/config/devices.go index e4157e2..d904779 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -35,6 +35,7 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice map[evdev.EvType][]evdev.EvCode{ evdev.EV_KEY: makeButtons(int(deviceConfig.Buttons)), evdev.EV_ABS: makeAxes(int(deviceConfig.Axes)), + evdev.EV_REL: makeRelativeAxes(deviceConfig.RelativeAxes), }, ) @@ -116,3 +117,20 @@ func makeAxes(numAxes int) []evdev.EvCode { return axes } + +func makeRelativeAxes(axes []string) []evdev.EvCode { + codes := make([]evdev.EvCode, 0) + + for _, axis := range axes { + code, ok := evdev.RELFromString[axis] + + if !ok { + logger.Logf("Relative axis '%s' invalid. Skipping.", axis) + continue + } + + codes = append(codes, code) + } + + return codes +} diff --git a/internal/config/schema.go b/internal/config/schema.go index b91bb8d..d8edaf1 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -16,12 +16,13 @@ type Config struct { } type DeviceConfig struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - DeviceName string `yaml:"device_name,omitempty"` - Uuid string `yaml:"uuid,omitempty"` - Buttons int `yaml:"buttons,omitempty"` - Axes int `yaml:"axes,omitempty"` + Name string `yaml:"name"` + Type string `yaml:"type"` + DeviceName string `yaml:"device_name,omitempty"` + Uuid string `yaml:"uuid,omitempty"` + Buttons int `yaml:"buttons,omitempty"` + Axes int `yaml:"axes,omitempty"` + RelativeAxes []string `yaml:"rel_axes,omitempty"` } type RuleConfig struct { diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index 9c4205a..3e15312 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -4,6 +4,7 @@ import ( "time" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" ) // MappingRuleAxisToButton represents a rule that converts an axis input into a (potentially repeating) @@ -16,7 +17,10 @@ type MappingRuleAxisToButton struct { RepeatRateMax int nextEvent time.Duration lastEvent time.Time - pressed bool + repeat bool + pressed bool // "pressed" indicates that we've sent the output button signal, but still need to send the button release + active bool // "active" is true whenever the input is not in a deadzone + clock clockwork.Clock } func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { @@ -28,7 +32,10 @@ func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, out RepeatRateMax: repeatRateMax, lastEvent: time.Now(), nextEvent: NoNextEvent, + repeat: repeatRateMin != 0 && repeatRateMax != 0, pressed: false, + active: false, + clock: clockwork.NewRealClock(), } } @@ -42,13 +49,16 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * // If we're inside the deadzone, unset the next event if rule.Input.InDeadZone(event.Value) { rule.nextEvent = NoNextEvent + rule.active = false return nil, nil } // If we aren't repeating, we trigger the event immediately - // TODO: we aren't using pressed correctly; that should be set *and released* in here... - if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { - rule.nextEvent = time.Millisecond + // We also only set this if active == false, so that only one + // event can be emitted per "active" period + if !rule.repeat && !rule.active { + rule.nextEvent = 0 + rule.active = true return nil, nil } @@ -56,6 +66,7 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * strength := 1.0 - rule.Input.GetAxisStrength(event.Value) rate := int64(LerpInt(rule.RepeatRateMax, rule.RepeatRateMin, strength)) rule.nextEvent = time.Duration(rate * int64(time.Millisecond)) + rule.active = true return nil, nil } @@ -63,21 +74,30 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * // TimerEvent returns an event when enough time has passed (compared to the last recorded axis value) // to emit an event. func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { - // If we pressed the button last tick, release it + // If we pressed the button last tick, release it before doing anything else if rule.pressed { rule.pressed = false return rule.Output.CreateEvent(0, nil) } - // This indicates that we should not emit another event + // If we should not emit another event, + // we just update lastEvent for station keeping if rule.nextEvent == NoNextEvent { - rule.lastEvent = time.Now() + rule.lastEvent = rule.clock.Now() return nil } - if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { - rule.lastEvent = time.Now() + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.Now() rule.pressed = true + + // The default case here is to leave nextEvent at whatever + // it has been set to by MatchEvent. Since nextEvent is a delta, + // this will naturally cause the repeat to happen + if !rule.repeat { + rule.nextEvent = NoNextEvent + } + return rule.Output.CreateEvent(1, nil) } diff --git a/internal/mappingrules/mapping_rule_axis_to_button_test.go b/internal/mappingrules/mapping_rule_axis_to_button_test.go index 5a3193b..976506c 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button_test.go +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/suite" ) @@ -36,52 +37,150 @@ func (t *MappingRuleAxisToButtonTests) SetupTest() { } func (t *MappingRuleAxisToButtonTests) TestMatchEvent() { - testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) // A valid input should set a nextevent - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 1001, - }, t.mode) - t.NotEqual(NoNextEvent, testRule.nextEvent) + t.Run("No Repeat", func() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) - // And a deadzone value should clear it - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 500, - }, t.mode) - t.Equal(NoNextEvent, testRule.nextEvent) + t.Run("Valid Input", func() { + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.NotEqual(NoNextEvent, testRule.nextEvent) + }) - testRule = NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 10000, - }, t.mode) - t.Equal(time.Duration(250*time.Millisecond), testRule.nextEvent) + t.Run("Deadzone Input", func() { + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 500, + }, t.mode) + t.Equal(NoNextEvent, testRule.nextEvent) + }) + }) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 1001, - }, t.mode) - t.True(testRule.nextEvent > time.Duration(700*time.Millisecond)) + t.Run("Repeat", func() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 10000, + }, t.mode) + t.Equal(time.Duration(250*time.Millisecond), testRule.nextEvent) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 5500, - }, t.mode) - t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent) + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.True(testRule.nextEvent > time.Duration(700*time.Millisecond)) + + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 5500, + }, t.mode) + t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent) + }) } -// TODO: to add TimerEvent tests we need to use an interface to mock time. -// func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { -// // STUB -// } +func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { + t.Run("No Repeat", func() { + // Get event if called immediately + t.Run("Event is available immediately", func() { + testRule, _ := buildTimerRule(t, 0, 0, 0) + + event := testRule.TimerEvent() + + t.EqualValues(1, event.Value) + t.Equal(true, testRule.pressed) + }) + + // Off event on second call + t.Run("Event emits off on second call", func() { + testRule, _ := buildTimerRule(t, 0, 0, 0) + + testRule.TimerEvent() + event := testRule.TimerEvent() + + t.EqualValues(0, event.Value) + t.Equal(false, testRule.pressed) + }) + + // No further event, even if we wait a while + t.Run("Additional events are not emitted while still active.", func() { + testRule, mockClock := buildTimerRule(t, 0, 0, 0) + + testRule.TimerEvent() + testRule.TimerEvent() + + mockClock.Advance(10 * time.Millisecond) + event := testRule.TimerEvent() + t.Nil(event) + t.Equal(false, testRule.pressed) + }) + }) + + t.Run("Repeat", func() { + t.Run("No event if called immediately", func() { + testRule, _ := buildTimerRule(t, 100, 10, 50*time.Millisecond) + event := testRule.TimerEvent() + t.Nil(event) + }) + + t.Run("No event after 49ms", func() { + testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + mockClock.Advance(49 * time.Millisecond) + + event := testRule.TimerEvent() + + t.Nil(event) + }) + + t.Run("Event after 50ms", func() { + testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + mockClock.Advance(50 * time.Millisecond) + + event := testRule.TimerEvent() + + t.EqualValues(1, event.Value) + t.Equal(true, testRule.pressed) + }) + + t.Run("Additional event at 100ms", func() { + testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + + mockClock.Advance(50 * time.Millisecond) + testRule.TimerEvent() + testRule.TimerEvent() + + mockClock.Advance(50 * time.Millisecond) + event := testRule.TimerEvent() + + t.NotNil(event) + }) + }) +} func TestRunnerMappingRuleAxisToButtonTests(t *testing.T) { suite.Run(t, new(MappingRuleAxisToButtonTests)) } + +// buildTimerRule creates a MappingRuleAxisToButton with a mocked clock +func buildTimerRule(t *MappingRuleAxisToButtonTests, + repeatMin, + repeatMax int, + nextEvent time.Duration) (*MappingRuleAxisToButton, *clockwork.FakeClock) { + + mockClock := clockwork.NewFakeClock() + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, repeatMin, repeatMax) + testRule.clock = mockClock + testRule.lastEvent = testRule.clock.Now() + testRule.nextEvent = nextEvent + if nextEvent != NoNextEvent { + testRule.active = true + } + return testRule, mockClock +} diff --git a/internal/mappingrules/mapping_rule_axis_to_relaxis.go b/internal/mappingrules/mapping_rule_axis_to_relaxis.go index 07e4b85..731d067 100644 --- a/internal/mappingrules/mapping_rule_axis_to_relaxis.go +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -5,14 +5,13 @@ import ( "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" ) // TODO: add tests -// TODO: deadzones seem to calculate correctly in one direction but not the other when computing axis strength... - // MappingRuleAxisToRelaxis represents a rule that converts an axis input into a (potentially repeating) -// button output. +// relative axis output. This is most commonly used to generate mouse output events type MappingRuleAxisToRelaxis struct { MappingRuleBase Input *RuleTargetAxis @@ -22,6 +21,7 @@ type MappingRuleAxisToRelaxis struct { Increment int32 nextEvent time.Duration lastEvent time.Time + clock clockwork.Clock } func NewMappingRuleAxisToRelaxis( @@ -39,6 +39,7 @@ func NewMappingRuleAxisToRelaxis( Increment: int32(increment), lastEvent: time.Now(), nextEvent: NoNextEvent, + clock: clockwork.NewRealClock(), } } @@ -81,12 +82,12 @@ func (rule *MappingRuleAxisToRelaxis) MatchEvent( func (rule *MappingRuleAxisToRelaxis) TimerEvent() *evdev.InputEvent { // This indicates that we should not emit another event if rule.nextEvent == NoNextEvent { - rule.lastEvent = time.Now() + rule.lastEvent = rule.clock.Now() return nil } - if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { - rule.lastEvent = time.Now() + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.Now() return rule.Output.CreateEvent(rule.Increment, nil) } diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 5808e43..c0d4b95 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -77,12 +77,7 @@ func NewRuleTargetAxis(device_name string, // in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { axisStrength := target.GetAxisStrength(value) - - if target.Inverted { - axisStrength = 1.0 - axisStrength - } - normalizedValue := LerpInt(AxisValueMin, AxisValueMax, axisStrength) - return normalizedValue + return LerpInt(AxisValueMin, AxisValueMax, axisStrength) } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { @@ -111,6 +106,16 @@ func (target *RuleTargetAxis) InDeadZone(value int32) bool { return value >= target.DeadzoneStart && value <= target.DeadzoneEnd } +// GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional +// position along the axis' full range. (after factoring in deadzones) +// Calling this function with `value` inside the deadzone range will produce undefined behavior func (target *RuleTargetAxis) GetAxisStrength(value int32) float64 { - return float64(value-target.deadzoneSize) / float64(target.axisSize) + if value > target.DeadzoneEnd { + value -= target.deadzoneSize + } + strength := float64(value) / float64(target.axisSize) + if target.Inverted { + strength = 1.0 - strength + } + return strength } diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index b4bb4c0..28d2fd1 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -135,6 +135,50 @@ func (t *RuleTargetAxisTests) TestCreateEvent() { t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) } +func (t *RuleTargetAxisTests) TestGetAxisStrength() { + t.Run("With no deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Equal(0.0, ruleTarget.GetAxisStrength(0)) + t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) + t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) + }) + + t.Run("With low deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000) + t.InDelta(0.0, ruleTarget.GetAxisStrength(5001), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) + t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("With high deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 5000, 10000) + t.Equal(0.0, ruleTarget.GetAxisStrength(0)) + t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) + t.InDelta(1.0, ruleTarget.GetAxisStrength(4999), 0.01) + }) + + t.Run("Inverted", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0) + t.Equal(1.0, ruleTarget.GetAxisStrength(0)) + t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) + t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("Inverted with low deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 5000) + t.InDelta(1.0, ruleTarget.GetAxisStrength(5001), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) + t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("Inverted with high deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 5000, 10000) + t.InDelta(0.0, ruleTarget.GetAxisStrength(4999), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) + t.Equal(1.0, ruleTarget.GetAxisStrength(0)) + }) +} + func TestRunnerRuleTargetAxisTests(t *testing.T) { suite.Run(t, new(RuleTargetAxisTests)) } -- 2.47.2