Add support for multiple keycode formats. #3

Merged
anna merged 4 commits from keycodes into main 2025-07-16 23:27:29 +00:00
5 changed files with 271 additions and 17 deletions

View file

@ -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 <examples/ruletypes.yml> 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 <https://github.com/holoplot/go-evdev/blob/master/codes.go> 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 <https://github.com/holoplot/go-evdev/blob/master/codes.go>. 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<hex value>"`. 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 <internal/config/variables.go> 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.

View file

@ -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)

View file

@ -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
}

View file

@ -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))
}

View file

@ -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),
}
)