Add more deadzone specification options. (#9)

Reviewed-on: #9
Co-authored-by: Anna Rose Wiggins <annabunches@gmail.com>
Co-committed-by: Anna Rose Wiggins <annabunches@gmail.com>
This commit is contained in:
Anna Rose Wiggins 2025-07-18 23:10:12 +00:00 committed by Anna Rose Wiggins
parent 5b9dfe0967
commit 97a1acd228
20 changed files with 344 additions and 108 deletions

View file

@ -0,0 +1,7 @@
package config
import "github.com/holoplot/go-evdev"
type Device interface {
AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error)
}

View file

@ -4,11 +4,12 @@ import (
"errors"
"fmt"
"git.annabunches.net/annabunches/joyful/internal/logger"
"git.annabunches.net/annabunches/joyful/internal/mappingrules"
"github.com/holoplot/go-evdev"
)
func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetButton, error) {
func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetButton, error) {
device, ok := devs[targetConfig.Device]
if !ok {
return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device)
@ -27,7 +28,7 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev.
)
}
func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetAxis, error) {
func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetAxis, error) {
device, ok := devs[targetConfig.Device]
if !ok {
return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device)
@ -42,17 +43,22 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In
return nil, err
}
deadzoneStart, deadzoneEnd, err := calculateDeadzones(targetConfig, device, eventCode)
if err != nil {
return nil, err
}
return mappingrules.NewRuleTargetAxis(
targetConfig.Device,
device,
eventCode,
targetConfig.Inverted,
targetConfig.DeadzoneStart,
targetConfig.DeadzoneEnd,
deadzoneStart,
deadzoneEnd,
)
}
func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (*mappingrules.RuleTargetRelaxis, error) {
func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetRelaxis, error) {
device, ok := devs[targetConfig.Device]
if !ok {
return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device)
@ -83,3 +89,61 @@ func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string)
func hasError(_ any, err error) bool {
return err != nil
}
// calculateDeadzones produces the deadzone start and end values in absolute terms
// TODO: on the one hand, this logic feels betten encapsulated in mappingrules. On the other hand,
// passing even more parameters to NewRuleTargetAxis feels terrible
func calculateDeadzones(targetConfig RuleTargetConfig, device Device, axis evdev.EvCode) (int32, int32, error) {
var deadzoneStart, deadzoneEnd int32
deadzoneStart = 0
deadzoneEnd = 0
if targetConfig.DeadzoneStart != 0 || targetConfig.DeadzoneEnd != 0 {
return targetConfig.DeadzoneStart, targetConfig.DeadzoneEnd, nil
}
var min, max int32
absInfoMap, err := device.AbsInfos()
if err != nil {
min = mappingrules.AxisValueMin
max = mappingrules.AxisValueMax
} else {
absInfo := absInfoMap[axis]
min = absInfo.Minimum
max = absInfo.Maximum
}
if targetConfig.DeadzoneCenter < min || targetConfig.DeadzoneCenter > max {
return 0, 0, fmt.Errorf("deadzone_center '%d' is out of bounds", targetConfig.DeadzoneCenter)
}
switch {
case targetConfig.DeadzoneSize != 0:
deadzoneStart = targetConfig.DeadzoneCenter - targetConfig.DeadzoneSize/2
deadzoneEnd = targetConfig.DeadzoneCenter + targetConfig.DeadzoneSize/2
case targetConfig.DeadzoneSizePercent != 0:
deadzoneSize := (max - min) / targetConfig.DeadzoneSizePercent
deadzoneStart = targetConfig.DeadzoneCenter - deadzoneSize/2
deadzoneEnd = targetConfig.DeadzoneCenter + deadzoneSize/2
}
deadzoneStart, deadzoneEnd = clampAndShift(deadzoneStart, deadzoneEnd, min, max)
return deadzoneStart, deadzoneEnd, nil
}
func clampAndShift(start, end, min, max int32) (int32, int32) {
logger.Logf("DEBUG: %d %d %d %d", start, end, min, max)
if start < min {
end += min - start
start = min
logger.Logf("DEBUG: %d %d %d %d", start, end, min, max)
}
if end > max {
start -= end - max
end = max
}
return start, end
}

View file

@ -4,12 +4,24 @@ import (
"testing"
"github.com/holoplot/go-evdev"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
type MakeRuleTargetsTests struct {
suite.Suite
devs map[string]*evdev.InputDevice
devs map[string]Device
deviceMock *DeviceMock
config RuleTargetConfig
}
type DeviceMock struct {
mock.Mock
}
func (m *DeviceMock) AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) {
args := m.Called()
return args.Get(0).(map[evdev.EvCode]evdev.AbsInfo), args.Error(1)
}
func TestRunnerMakeRuleTargets(t *testing.T) {
@ -17,131 +29,216 @@ func TestRunnerMakeRuleTargets(t *testing.T) {
}
func (t *MakeRuleTargetsTests) SetupSuite() {
t.devs = map[string]*evdev.InputDevice{
"test": {},
t.deviceMock = new(DeviceMock)
t.deviceMock.On("AbsInfos").Return(
map[evdev.EvCode]evdev.AbsInfo{
evdev.ABS_X: {
Minimum: 0,
Maximum: 10000,
},
evdev.ABS_Y: {
Minimum: 0,
Maximum: 10000,
},
}, nil,
)
t.devs = map[string]Device{
"test": t.deviceMock,
}
}
func (t *MakeRuleTargetsTests) SetupSubTest() {
t.config = RuleTargetConfig{
Device: "test",
}
}
func (t *MakeRuleTargetsTests) TestMakeRuleTargetButton() {
config := RuleTargetConfig{
Device: "test",
}
t.Run("Standard keycode", func() {
config.Button = "BTN_TRIGGER"
rule, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "BTN_TRIGGER"
rule, err := makeRuleTargetButton(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.BTN_TRIGGER, rule.Button)
})
t.Run("Hex code", func() {
config.Button = "0x2fd"
rule, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "0x2fd"
rule, err := makeRuleTargetButton(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.EvCode(0x2fd), rule.Button)
})
t.Run("Index", func() {
config.Button = "3"
rule, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "3"
rule, err := makeRuleTargetButton(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.BTN_TOP, rule.Button)
})
t.Run("Index too high", func() {
config.Button = "74"
_, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "74"
_, err := makeRuleTargetButton(t.config, t.devs)
t.NotNil(err)
})
t.Run("Un-prefixed keycode", func() {
config.Button = "pinkie"
rule, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "pinkie"
rule, err := makeRuleTargetButton(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.BTN_PINKIE, rule.Button)
})
t.Run("Invalid keycode", func() {
config.Button = "foo"
_, err := makeRuleTargetButton(config, t.devs)
t.config.Button = "foo"
_, err := makeRuleTargetButton(t.config, t.devs)
t.NotNil(err)
})
}
func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() {
config := RuleTargetConfig{
Device: "test",
}
t.Run("Standard keycode", func() {
config.Axis = "ABS_X"
rule, err := makeRuleTargetAxis(config, t.devs)
t.Run("Standard code", func() {
t.config.Axis = "ABS_X"
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.ABS_X, rule.Axis)
})
t.Run("Hex keycode", func() {
config.Axis = "0x01"
rule, err := makeRuleTargetAxis(config, t.devs)
t.Run("Hex code", func() {
t.config.Axis = "0x01"
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.ABS_Y, rule.Axis)
})
t.Run("Un-prefixed keycode", func() {
config.Axis = "x"
rule, err := makeRuleTargetAxis(config, t.devs)
t.Run("Un-prefixed code", func() {
t.config.Axis = "x"
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.ABS_X, rule.Axis)
})
t.Run("Invalid keycode", func() {
config.Axis = "foo"
_, err := makeRuleTargetAxis(config, t.devs)
t.Run("Invalid code", func() {
t.config.Axis = "foo"
_, err := makeRuleTargetAxis(t.config, t.devs)
t.NotNil(err)
})
t.Run("Invalid deadzone", func() {
config.DeadzoneEnd = 100
config.DeadzoneStart = 1000
_, err := makeRuleTargetAxis(config, t.devs)
t.config.Axis = "x"
t.config.DeadzoneEnd = 100
t.config.DeadzoneStart = 1000
_, err := makeRuleTargetAxis(t.config, t.devs)
t.NotNil(err)
})
t.Run("Deadzone center/size", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 5000
t.config.DeadzoneSize = 1000
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(4500, rule.DeadzoneStart)
t.EqualValues(5500, rule.DeadzoneEnd)
})
t.Run("Deadzone center/size lower boundary", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 0
t.config.DeadzoneSize = 500
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(0, rule.DeadzoneStart)
t.EqualValues(500, rule.DeadzoneEnd)
})
t.Run("Deadzone center/size upper boundary", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 10000
t.config.DeadzoneSize = 500
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(9500, rule.DeadzoneStart)
t.EqualValues(10000, rule.DeadzoneEnd)
})
t.Run("Deadzone center/size invalid center", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 20000
t.config.DeadzoneSize = 500
_, err := makeRuleTargetAxis(t.config, t.devs)
t.NotNil(err)
})
t.Run("Deadzone center/percent", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 5000
t.config.DeadzoneSizePercent = 10
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(4500, rule.DeadzoneStart)
t.EqualValues(5500, rule.DeadzoneEnd)
})
t.Run("Deadzone center/percent lower boundary", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 0
t.config.DeadzoneSizePercent = 10
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(0, rule.DeadzoneStart)
t.EqualValues(1000, rule.DeadzoneEnd)
})
t.Run("Deadzone center/percent upper boundary", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 10000
t.config.DeadzoneSizePercent = 10
rule, err := makeRuleTargetAxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(9000, rule.DeadzoneStart)
t.EqualValues(10000, rule.DeadzoneEnd)
})
t.Run("Deadzone center/percent invalid center", func() {
t.config.Axis = "x"
t.config.DeadzoneCenter = 20000
t.config.DeadzoneSizePercent = 10
_, err := makeRuleTargetAxis(t.config, t.devs)
t.NotNil(err)
})
}
func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() {
config := RuleTargetConfig{
Device: "test",
}
t.Run("Standard keycode", func() {
config.Axis = "REL_WHEEL"
rule, err := makeRuleTargetRelaxis(config, t.devs)
t.config.Axis = "REL_WHEEL"
rule, err := makeRuleTargetRelaxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.REL_WHEEL, rule.Axis)
})
t.Run("Hex keycode", func() {
config.Axis = "0x00"
rule, err := makeRuleTargetRelaxis(config, t.devs)
t.config.Axis = "0x00"
rule, err := makeRuleTargetRelaxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.REL_X, rule.Axis)
})
t.Run("Un-prefixed keycode", func() {
config.Axis = "wheel"
rule, err := makeRuleTargetRelaxis(config, t.devs)
t.config.Axis = "wheel"
rule, err := makeRuleTargetRelaxis(t.config, t.devs)
t.Nil(err)
t.EqualValues(evdev.REL_WHEEL, rule.Axis)
})
t.Run("Invalid keycode", func() {
config.Axis = "foo"
_, err := makeRuleTargetRelaxis(config, t.devs)
t.config.Axis = "foo"
_, err := makeRuleTargetRelaxis(t.config, t.devs)
t.NotNil(err)
})
t.Run("Incorrect axis type", func() {
config.Axis = "ABS_X"
_, err := makeRuleTargetRelaxis(config, t.devs)
t.config.Axis = "ABS_X"
_, err := makeRuleTargetRelaxis(t.config, t.devs)
t.NotNil(err)
})
}

View file

@ -10,14 +10,25 @@ import (
)
// TODO: At some point it would *very likely* make sense to map each rule to all of the physical devices that can
// trigger it, and return that instead. Something like a map[*evdev.InputDevice][]mappingrule.MappingRule.
// trigger it, and return that instead. Something like a map[Device][]mappingrule.MappingRule.
// This would speed up rule matching by only checking relevant rules for a given input event.
// We could take this further and make it a map[<struct of *inputdevice, type, and code>][]rule
// For very large rule-bases this may be helpful for staying performant.
func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule {
func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, vInputDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule {
rules := make([]mappingrules.MappingRule, 0)
modes := parser.GetModes()
// Golang can't inspect the concrete map type to determine interface conformance,
// so we handle that here.
pDevs := make(map[string]Device)
for name, dev := range pInputDevs {
pDevs[name] = dev
}
vDevs := make(map[string]Device)
for name, dev := range vInputDevs {
vDevs[name] = dev
}
for _, ruleConfig := range parser.config.Rules {
var newRule mappingrules.MappingRule
var err error
@ -60,8 +71,8 @@ func (parser *ConfigParser) BuildRules(pDevs map[string]*evdev.InputDevice, vDev
}
func makeMappingRuleButton(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButton, error) {
input, err := makeRuleTargetButton(ruleConfig.Input, pDevs)
@ -78,8 +89,8 @@ func makeMappingRuleButton(ruleConfig RuleConfig,
}
func makeMappingRuleCombo(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonCombo, error) {
inputs := make([]*mappingrules.RuleTargetButton, 0)
@ -100,8 +111,8 @@ func makeMappingRuleCombo(ruleConfig RuleConfig,
}
func makeMappingRuleLatched(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonLatched, error) {
input, err := makeRuleTargetButton(ruleConfig.Input, pDevs)
@ -118,8 +129,8 @@ func makeMappingRuleLatched(ruleConfig RuleConfig,
}
func makeMappingRuleAxis(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxis, error) {
input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs)
@ -136,8 +147,8 @@ func makeMappingRuleAxis(ruleConfig RuleConfig,
}
func makeMappingRuleAxisToButton(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToButton, error) {
input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs)
@ -154,8 +165,8 @@ func makeMappingRuleAxisToButton(ruleConfig RuleConfig,
}
func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
vDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
vDevs map[string]Device,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToRelaxis, error) {
input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs)
@ -176,7 +187,7 @@ func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig,
}
func makeMappingRuleModeSelect(ruleConfig RuleConfig,
pDevs map[string]*evdev.InputDevice,
pDevs map[string]Device,
modes []string,
base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleModeSelect, error) {

View file

@ -41,11 +41,14 @@ type RuleConfig struct {
}
type RuleTargetConfig struct {
Device string `yaml:"device,omitempty"`
Button string `yaml:"button,omitempty"`
Axis string `yaml:"axis,omitempty"`
DeadzoneStart int32 `yaml:"deadzone_start,omitempty"`
DeadzoneEnd int32 `yaml:"deadzone_end,omitempty"`
Inverted bool `yaml:"inverted,omitempty"`
Modes []string `yaml:"modes,omitempty"`
Device string `yaml:"device,omitempty"`
Button string `yaml:"button,omitempty"`
Axis string `yaml:"axis,omitempty"`
DeadzoneCenter int32 `yaml:"deadzone_center,omitempty"`
DeadzoneSize int32 `yaml:"deadzone_size,omitempty"`
DeadzoneSizePercent int32 `yaml:"deadzone_size_percent,omitempty"`
DeadzoneStart int32 `yaml:"deadzone_start,omitempty"`
DeadzoneEnd int32 `yaml:"deadzone_end,omitempty"`
Inverted bool `yaml:"inverted,omitempty"`
Modes []string `yaml:"modes,omitempty"`
}