From e617a6eda6c87bce222f77c04587e8f38b6b171b Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 15 Jul 2025 19:55:19 +0000 Subject: [PATCH] Implement axis targets, axis -> button and axis -> relative axis mappings. (#1) Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/joyful/main.go | 7 +- cmd/joyful/threads.go | 6 +- go.mod | 3 + go.sum | 6 + internal/config/devices.go | 18 ++ internal/config/make_rule_targets.go | 26 ++- internal/config/make_rules.go | 38 +++- internal/config/schema.go | 38 ++-- internal/config/variables.go | 13 +- internal/mappingrules/interfaces.go | 28 ++- internal/mappingrules/mapping_rule_axis.go | 5 +- .../mapping_rule_axis_to_button.go | 100 ++++++++-- .../mapping_rule_axis_to_button_test.go | 186 ++++++++++++++++++ .../mapping_rule_axis_to_relaxis.go | 99 ++++++++++ internal/mappingrules/mapping_rule_button.go | 2 +- .../mappingrules/mapping_rule_button_combo.go | 2 +- .../mapping_rule_button_latched.go | 2 +- .../mappingrules/mapping_rule_button_test.go | 55 +++--- .../mappingrules/mapping_rule_mode_select.go | 2 +- internal/mappingrules/math.go | 30 +++ internal/mappingrules/rule_target_axis.go | 116 +++++++---- .../mappingrules/rule_target_axis_test.go | 184 +++++++++++++++++ internal/mappingrules/rule_target_button.go | 6 +- internal/mappingrules/rule_target_relaxis.go | 46 +++++ internal/mappingrules/test_mocks.go | 15 ++ 25 files changed, 903 insertions(+), 130 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_axis_to_button_test.go create mode 100644 internal/mappingrules/mapping_rule_axis_to_relaxis.go create mode 100644 internal/mappingrules/math.go create mode 100644 internal/mappingrules/rule_target_axis_test.go create mode 100644 internal/mappingrules/rule_target_relaxis.go create mode 100644 internal/mappingrules/test_mocks.go diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 9ce1d66..f6b8e5f 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++ } @@ -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/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/go.mod b/go.mod index 2388397..b469a18 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,14 @@ 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 ) 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 539dcc7..4942bf2 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,16 @@ 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= +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= +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/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/make_rule_targets.go b/internal/config/make_rule_targets.go index 36b5e3c..cc6c458 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,28 @@ 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 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) { diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 5e7e9de..6d75d58 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "strings" @@ -41,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: @@ -134,13 +135,44 @@ 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 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, diff --git a/internal/config/schema.go b/internal/config/schema.go index 42bd339..d8edaf1 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 @@ -10,29 +16,33 @@ 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 { - 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"` + 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 a3f3b70..bc10e9b 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -1,9 +1,18 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "time" + + "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) +} + +type TimedEventEmitter interface { + TimerEvent() *evdev.InputEvent + GetOutputDevice() *evdev.InputDevice } // RuleTargets represent either a device input to match on, or an output to produce. @@ -25,4 +34,19 @@ 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 +// 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) +} + +const ( + AxisValueMin = int32(-32768) + AxisValueMax = int32(32767) + NoNextEvent = time.Duration(-1) +) diff --git a/internal/mappingrules/mapping_rule_axis.go b/internal/mappingrules/mapping_rule_axis.go index b190963..7b3e778 100644 --- a/internal/mappingrules/mapping_rule_axis.go +++ b/internal/mappingrules/mapping_rule_axis.go @@ -17,11 +17,12 @@ 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 } - 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_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index f28703b..3e15312 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -4,48 +4,106 @@ import ( "time" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" ) -// TODO: This whole file is still WIP +// MappingRuleAxisToButton represents a rule that converts an axis input into a (potentially repeating) +// button output. 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 + 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 (rule *MappingRuleAxisToButton) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { +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, + repeat: repeatRateMin != 0 && repeatRateMax != 0, + pressed: false, + active: false, + clock: clockwork.NewRealClock(), + } +} + +func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + 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 + rule.active = false + return nil, nil + } + + // If we aren't repeating, we trigger the event immediately + // 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 + } + + // 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)) + rule.active = true + 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 { - rule.lastEvent = time.Now() + // 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) + } + + // If we should not emit another event, + // we just update lastEvent for station keeping + if rule.nextEvent == NoNextEvent { + rule.lastEvent = rule.clock.Now() return nil } - // calculate target time until next event press - // nextEvent := rule.LastEvent + (rule.LastValue) + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.Now() + rule.pressed = true - // TODO: figure out what the condition should be - if false { - // TODO: emit event - rule.lastEvent = time.Now() + // 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) } return nil } + +func (rule *MappingRuleAxisToButton) GetOutputDevice() *evdev.InputDevice { + return rule.Output.Device +} 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..976506c --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -0,0 +1,186 @@ +package mappingrules + +import ( + "testing" + "time" + + "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" + "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() { + + // A valid input should set a nextevent + t.Run("No Repeat", func() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) + + 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) + }) + + 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) + }) + }) + + 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: 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() { + 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 new file mode 100644 index 0000000..731d067 --- /dev/null +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -0,0 +1,99 @@ +package mappingrules + +import ( + "time" + + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" +) + +// TODO: add tests + +// MappingRuleAxisToRelaxis represents a rule that converts an axis input into a (potentially repeating) +// relative axis output. This is most commonly used to generate mouse output events +type MappingRuleAxisToRelaxis struct { + MappingRuleBase + Input *RuleTargetAxis + Output *RuleTargetRelaxis + RepeatRateMin int + RepeatRateMax int + Increment int32 + nextEvent time.Duration + lastEvent time.Time + clock clockwork.Clock +} + +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, + clock: clockwork.NewRealClock(), + } +} + +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 = rule.clock.Now() + return nil + } + + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.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/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_button_test.go b/internal/mappingrules/mapping_rule_button_test.go index df814ad..28fba1b 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,72 +22,64 @@ 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) { +func TestRunnerMappingRuleButtonTests(t *testing.T) { suite.Run(t, new(MappingRuleButtonTests)) } 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/math.go b/internal/mappingrules/math.go new file mode 100644 index 0000000..37de4a2 --- /dev/null +++ b/internal/mappingrules/math.go @@ -0,0 +1,30 @@ +package mappingrules + +import ( + "golang.org/x/exp/constraints" +) + +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 Clamp[T Numeric](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 680c6d5..c0d4b95 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -1,66 +1,87 @@ package mappingrules import ( + "errors" + "fmt" + "github.com/holoplot/go-evdev" ) type RuleTargetAxis struct { DeviceName string - Device *evdev.InputDevice + Device RuleTargetDevice Axis evdev.EvCode Inverted bool DeadzoneStart int32 DeadzoneEnd int32 - Sensitivity float64 + axisSize int32 + deadzoneSize int32 } func NewRuleTargetAxis(device_name string, - device *evdev.InputDevice, + device RuleTargetDevice, 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 { + // 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 { + 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 := 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 + 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 - } - - 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 + axisStrength := target.GetAxisStrength(value) + return LerpInt(AxisValueMin, AxisValueMax, axisStrength) } 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 = Clamp(value, AxisValueMin, AxisValueMax) return &evdev.InputEvent{ Type: evdev.EV_ABS, Code: target.Axis, @@ -68,8 +89,33 @@ 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 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 } + +// TODO: Add tests +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 { + 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 new file mode 100644 index 0000000..28d2fd1 --- /dev/null +++ b/internal/mappingrules/rule_target_axis_test.go @@ -0,0 +1,184 @@ +package mappingrules + +import ( + "errors" + "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 +} + +func (t *RuleTargetAxisTests) SetupTest() { + 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) TearDownTest() { + 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) + + // If Absinfo has an error, we should create a device with permissive bounds + t.call.Unset() + 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) +} + +func (t *RuleTargetAxisTests) TestNormalizeValue() { + // Basic normalization should work + 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.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(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() { + 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 = AxisValueMax + t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) + + testValue = int32(-64000) + expected.Value = AxisValueMin + 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)) +} diff --git a/internal/mappingrules/rule_target_button.go b/internal/mappingrules/rule_target_button.go index 67fe5d6..93534c7 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 { @@ -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 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 +} 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) +}