From a05dc9126d93ac52308d173e1366d701014ad895 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Wed, 16 Jul 2025 23:27:29 +0000 Subject: [PATCH] Add support for multiple keycode formats. (#3) Additionally: - Increases maximum supported buttons per output device to 74. - Updates documentation. Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/3 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- docs/{examples => }/readme.md | 15 ++- internal/config/devices.go | 6 +- internal/config/make_rule_targets.go | 96 ++++++++++++-- internal/config/make_rule_targets_test.go | 147 ++++++++++++++++++++++ internal/config/variables.go | 24 ++++ 5 files changed, 271 insertions(+), 17 deletions(-) rename docs/{examples => }/readme.md (68%) create mode 100644 internal/config/make_rule_targets_test.go diff --git a/docs/examples/readme.md b/docs/readme.md similarity index 68% rename from docs/examples/readme.md rename to docs/readme.md index a38bd89..799f6b5 100644 --- a/docs/examples/readme.md +++ b/docs/readme.md @@ -15,7 +15,7 @@ Each entry in `devices` must have a couple of parameters: `virtual` devices must additionally define these parameters: -* `buttons` - a number between 0 and 80. Linux may not recognize buttons greater than 56. +* `buttons` - a number between 0 and 74. Linux may not recognize buttons greater than 56. * `axes` - a number between 0 and 8. Virtual devices can also define a `relative_axes` parameter; this must be a list of `REL_` event keycodes, and can be useful for a simulated mouse device. Some environments will only register mouse events if the device *only* supports mouse-like events, so it can be useful to isolate your `relative_axes` to their own virtual device. @@ -35,13 +35,20 @@ Configuration options for each rule type vary. See for ### Keycodes -Currently, there is only one way to specify a button or axis: using evdev's Keycodes. These look like `ABS_X` for axes and `BTN_TRIGGER` -for buttons. See for a full list of these codes, but note that Joyful's virtual devices currently only uses a subset. Specifically, the axes from `ABS_X` to `ABS_RUDDER`, and the buttons from `BTN_JOYSTICK` to `BTN_DEAD`, as well as all of the `BTN_TRIGGER_HAPPY*` codes. +Keycodes are the values that identify buttons and axes. There are several ways to configure keycodes. All of them are case-insensitive. + +Ways to specify keycodes are: + +* Using evdev's Keycodes. This is the best way to be absolutely certain about which axis you're referencing. You can specify these keycodes in two forms: + * Using the code's identifier from . e.g., `ABS_X`, `REL_WHEEL`, `BTN_TRIGGER`. + * Alternately, you can omit the `ABS_` type prefix, and Joyful will automatically add it from context. So for a button input, you can simply specify `button: trigger` instead of `BTN_TRIGGER`. +* You can use the hexadecimal value of the keycode directly, via `"0x"`. This can be useful if you want to force a specific numeric value that isn't represented by a Linux keycode directly. Note however that not all keycodes will work. Only the first 8 axes are available, and see for a list of valid button outputs. This is most useful with input configurations. **Note: You must use quotation marks around the hex value to prevent the yaml parser from automatically converting it to decimal.** +* For buttons, you can specify the button number, as in `button: 3`. There are 74 buttons available, and the first button is button number `0`. As a result, valid values are 0-73. Note that buttons 12-14 and buttons 55-73 may not work in all Linux-native games. For input, you can figure out what keycodes your device is emitting by running the Linux utility `evtest`. `evtest` works well with `grep`, so if you just want to see button inputs, you can do: ``` -evtest | grep KEY_ +evtest | grep BTN_ ``` The authors of this tool recognize that this is currently a pain in the ass. Easier ways to represent keycodes (as well as outputting additional keycodes) is planned for the future. diff --git a/internal/config/devices.go b/internal/config/devices.go index d904779..2fb0e50 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -82,9 +82,9 @@ func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevi } func makeButtons(numButtons int) []evdev.EvCode { - if numButtons > 56 { - numButtons = 56 - logger.Log("Limiting virtual device buttons to 56") + if numButtons > VirtualDeviceMaxButtons { + numButtons = VirtualDeviceMaxButtons + logger.Logf("Limiting virtual device buttons to %d", VirtualDeviceMaxButtons) } buttons := make([]evdev.EvCode, numButtons) diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index cc6c458..d46c83c 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -3,6 +3,8 @@ package config import ( "errors" "fmt" + "strconv" + "strings" "git.annabunches.net/annabunches/joyful/internal/mappingrules" "github.com/holoplot/go-evdev" @@ -14,9 +16,39 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]*evdev. return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) } - eventCode, ok := evdev.KEYFromString[targetConfig.Button] - if !ok { - return nil, fmt.Errorf("invalid button code '%s'", targetConfig.Button) + var eventCode evdev.EvCode + buttonConfig := strings.ToUpper(targetConfig.Button) + switch { + case strings.HasPrefix(buttonConfig, "BTN_"): + eventCode, ok = evdev.KEYFromString[buttonConfig] + if !ok { + return nil, fmt.Errorf("invalid button specification '%s'", buttonConfig) + } + + case strings.HasPrefix(buttonConfig, "0X"): + codeInt, err := strconv.ParseUint(buttonConfig[2:], 16, 0) + if err != nil { + return nil, err + } + eventCode = evdev.EvCode(codeInt) + + case !hasError(strconv.Atoi(buttonConfig)): + index, err := strconv.Atoi(buttonConfig) + if err != nil { + return nil, err + } + + if index >= len(ButtonFromIndex) { + return nil, fmt.Errorf("button index '%d' out of bounds", index) + } + + eventCode = ButtonFromIndex[index] + + default: + eventCode, ok = evdev.KEYFromString["BTN_"+buttonConfig] + if !ok { + return nil, fmt.Errorf("invalid button specification '%s'", buttonConfig) + } } return mappingrules.NewRuleTargetButton( @@ -33,9 +65,31 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]*evdev.In return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) } - eventCode, ok := evdev.ABSFromString[targetConfig.Axis] - if !ok { - return nil, fmt.Errorf("invalid button code '%s'", targetConfig.Button) + if targetConfig.DeadzoneEnd < targetConfig.DeadzoneStart { + return nil, errors.New("deadzone_end must be greater than deadzone_start") + } + + var eventCode evdev.EvCode + axisConfig := strings.ToUpper(targetConfig.Axis) + switch { + case strings.HasPrefix(axisConfig, "ABS_"): + eventCode, ok = evdev.ABSFromString[axisConfig] + if !ok { + return nil, fmt.Errorf("invalid axis code '%s'", axisConfig) + } + + case strings.HasPrefix(axisConfig, "0X"): + codeInt, err := strconv.ParseUint(axisConfig[2:], 16, 32) + if err != nil { + return nil, err + } + eventCode = evdev.EvCode(codeInt) + + default: + eventCode, ok = evdev.ABSFromString["ABS_"+axisConfig] + if !ok { + return nil, fmt.Errorf("invalid axis code '%s'", axisConfig) + } } return mappingrules.NewRuleTargetAxis( @@ -54,11 +108,28 @@ func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]*evdev 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) - } + var eventCode evdev.EvCode + axisConfig := strings.ToUpper(targetConfig.Axis) + switch { + case strings.HasPrefix(axisConfig, "REL_"): + eventCode, ok = evdev.RELFromString[axisConfig] + if !ok { + return nil, fmt.Errorf("invalid axis code '%s'", axisConfig) + } + case strings.HasPrefix(axisConfig, "0X"): + codeInt, err := strconv.ParseUint(axisConfig[2:], 16, 32) + if err != nil { + return nil, err + } + eventCode = evdev.EvCode(codeInt) + + default: + eventCode, ok = evdev.RELFromString["REL_"+axisConfig] + if !ok { + return nil, fmt.Errorf("invalid axis code '%s'", axisConfig) + } + } return mappingrules.NewRuleTargetRelaxis( targetConfig.Device, device, @@ -74,3 +145,8 @@ func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) return mappingrules.NewRuleTargetModeSelect(targetConfig.Modes) } + +// hasError exists solely to switch on errors in case statements +func hasError(_ any, err error) bool { + return err != nil +} diff --git a/internal/config/make_rule_targets_test.go b/internal/config/make_rule_targets_test.go new file mode 100644 index 0000000..770a4fe --- /dev/null +++ b/internal/config/make_rule_targets_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "testing" + + "github.com/holoplot/go-evdev" + "github.com/stretchr/testify/suite" +) + +type MakeRuleTargetsTests struct { + suite.Suite + devs map[string]*evdev.InputDevice +} + +func (t *MakeRuleTargetsTests) SetupSuite() { + t.devs = map[string]*evdev.InputDevice{ + "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.Nil(err) + t.EqualValues(evdev.BTN_TRIGGER, rule.Button) + }) + + t.Run("Hex code", func() { + config.Button = "0x2fd" + rule, err := makeRuleTargetButton(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.Nil(err) + t.EqualValues(evdev.BTN_TOP, rule.Button) + }) + + t.Run("Index too high", func() { + config.Button = "74" + _, err := makeRuleTargetButton(config, t.devs) + t.NotNil(err) + }) + + t.Run("Un-prefixed keycode", func() { + config.Button = "pinkie" + rule, err := makeRuleTargetButton(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.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.Nil(err) + t.EqualValues(evdev.ABS_X, rule.Axis) + }) + + t.Run("Hex keycode", func() { + config.Axis = "0x01" + rule, err := makeRuleTargetAxis(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.Nil(err) + t.EqualValues(evdev.ABS_X, rule.Axis) + }) + + t.Run("Invalid keycode", func() { + config.Axis = "foo" + _, err := makeRuleTargetAxis(config, t.devs) + t.NotNil(err) + }) + + t.Run("Invalid deadzone", func() { + config.DeadzoneEnd = 100 + config.DeadzoneStart = 1000 + _, err := makeRuleTargetAxis(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.Nil(err) + t.EqualValues(evdev.REL_WHEEL, rule.Axis) + }) + + t.Run("Hex keycode", func() { + config.Axis = "0x00" + rule, err := makeRuleTargetRelaxis(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.Nil(err) + t.EqualValues(evdev.REL_WHEEL, rule.Axis) + }) + + t.Run("Invalid keycode", func() { + config.Axis = "foo" + _, err := makeRuleTargetRelaxis(config, t.devs) + t.NotNil(err) + }) + + t.Run("Incorrect axis type", func() { + config.Axis = "ABS_X" + _, err := makeRuleTargetRelaxis(config, t.devs) + t.NotNil(err) + }) +} + +func TestRunnerMakeRuleTargets(t *testing.T) { + suite.Run(t, new(MakeRuleTargetsTests)) +} diff --git a/internal/config/variables.go b/internal/config/variables.go index c0276c4..f954372 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -15,6 +15,8 @@ const ( RuleTypeModeSelect = "mode-select" RuleTypeAxisToButton = "axis-to-button" RuleTypeAxisToRelaxis = "axis-to-relaxis" + + VirtualDeviceMaxButtons = 74 ) var ( @@ -31,6 +33,10 @@ var ( evdev.BTN_BASE4, evdev.BTN_BASE5, evdev.BTN_BASE6, + evdev.EvCode(0x12c), // decimal 300 + evdev.EvCode(0x12d), // decimal 301 + evdev.EvCode(0x12e), // decimal 302 + evdev.BTN_DEAD, evdev.BTN_TRIGGER_HAPPY1, evdev.BTN_TRIGGER_HAPPY2, evdev.BTN_TRIGGER_HAPPY3, @@ -71,5 +77,23 @@ var ( evdev.BTN_TRIGGER_HAPPY38, evdev.BTN_TRIGGER_HAPPY39, evdev.BTN_TRIGGER_HAPPY40, + evdev.EvCode(0x2e8), + evdev.EvCode(0x2e9), + evdev.EvCode(0x2f0), + evdev.EvCode(0x2f1), + evdev.EvCode(0x2f2), + evdev.EvCode(0x2f3), + evdev.EvCode(0x2f4), + evdev.EvCode(0x2f5), + evdev.EvCode(0x2f6), + evdev.EvCode(0x2f7), + evdev.EvCode(0x2f8), + evdev.EvCode(0x2f9), + evdev.EvCode(0x2fa), + evdev.EvCode(0x2fb), + evdev.EvCode(0x2fc), + evdev.EvCode(0x2fd), + evdev.EvCode(0x2fe), + evdev.EvCode(0x2ff), } )