From 7f104f054a105c1c613d6c1d58f14c9c4b5b05f3 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 1 Aug 2025 13:37:06 -0400 Subject: [PATCH 01/12] Add makefile and update install documentation. --- Makefile | 18 ++++++++++++++++++ readme.md | 42 +++++++++++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ea3da7a --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +DESTDIR=/ +PREFIX=${HOME}/ +BINDIR=bin/ + +default: + go build -o build/ ./... + +install: + mkdir -p ${DESTDIR}/${PREFIX}/${BINDIR} + cp build/evinfo ${DESTDIR}/${PREFIX}/${BINDIR}/ + cp build/joyful ${DESTDIR}/${PREFIX}/${BINDIR}/ + +uninstall: + rm ${DESTDIR}/${PREFIX}/${BINDIR}/evinfo + rm ${DESTDIR}/${PREFIX}/${BINDIR}/joyful + +clean: + rm -rf build/ \ No newline at end of file diff --git a/readme.md b/readme.md index 5c94306..eb432f6 100644 --- a/readme.md +++ b/readme.md @@ -32,7 +32,7 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * Sensitivity Curves. * Packaged builds for Arch and possibly other distributions. -## Configuration +## Configure Configuration is handled via YAML files in `~/.config/joyful/`. Joyful will read every yaml file in this directory and combine them, so you can split your configuration up however you like. @@ -40,26 +40,50 @@ A configuration guide and examples can be found in the `docs/` directory. Configuration can be fairly complicated and repetitive. If anyone wants to create a graphical interface to configure Joyful, we would love to link to it here. -## Usage +## Install -After building (see below) and writing your configuration (see above), just run `joyful`. You can use `joyful --config ` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory. +If you are on Arch or an Arch-based distro, you can get the latest Joyful release from the AUR: -Pressing `` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program. +``` +yay -S joyful +``` -## Build & Install +### Manual Install + +To build joyful manually, first use your distribution's package manager to install the following dependencies: -To build joyful, first use your distribution's package manager to install the following packages: * `go` +* `make` * `alsa-lib` - this may be `libasound2-dev` or `libasound2-devel` depending on your distribution * `espeak-ng` - if you want text-to-speech to announce mode changes -Then, run: +Then, clone this repository, e.g.: ``` -go build -o build/ ./... +git clone https://git.annabunches.net/anna/joyful.git +cd joyful ``` -Finally, copy the files in the `build/` directory to somewhere in your `$PATH`. (details depend on your setup, but typically somewhere like `/usr/local/bin` or `~/bin`) +Then, to build and install, run: + +``` +make +make install +``` + +By default this will install into `~/bin`. If you want to install Joyful system-wide, you can instead do: + +``` +make +sudo make PREFIX=/usr/local install +``` + + +## Usage + +After installing Joyful and writing your configuration (see above), run `joyful`. You can use `joyful --config ` to specify different configuration profiles; just put all the YAML files for a given profile in a unique directory. + +Pressing `` in the running terminal window will reload the `rules` section of your config files, so you can make changes to your rules without restarting the application. Applying any changes to `devices` or `modes` requires exiting and re-launching the program. ## Technical details From 9652df93660411e826ef3ccad546ab184b02fd9d Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 1 Aug 2025 13:49:03 -0400 Subject: [PATCH 02/12] Add device locking with a flag to disable for testing. --- cmd/joyful/main.go | 8 +++++--- internal/config/devices.go | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 17482bf..41eaae5 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -52,8 +52,8 @@ func getVirtualDevices(buffers map[string]*virtualdevice.EventBuffer) map[string return devices } -func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDevice { - pDeviceMap := config.ConnectPhysicalDevices() +func initPhysicalDevices(config *config.ConfigParser, lock bool) map[string]*evdev.InputDevice { + pDeviceMap := config.ConnectPhysicalDevices(lock) if len(pDeviceMap) == 0 { logger.Log("Warning: no physical devices found in configuration. No rules will work.") } @@ -63,7 +63,9 @@ func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDev func main() { // parse command-line var configFlag string + var noLockFlag bool flag.BoolVarP(&logger.IsDebugMode, "debug", "d", false, "Output very verbose debug messages.") + flag.BoolVar(&noLockFlag, "no-lock", false, "Disable locking the physical devices for exclusive reading.") flag.StringVarP(&configFlag, "config", "c", "~/.config/joyful", "Directory to read configuration from.") ttsOps := addTTSFlags() flag.Parse() @@ -80,7 +82,7 @@ func main() { vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) // Initialize physical devices - pDevices := initPhysicalDevices(config) + pDevices := initPhysicalDevices(config, !noLockFlag) // Load the rules rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) diff --git a/internal/config/devices.go b/internal/config/devices.go index 5da9849..d77147d 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -66,7 +66,7 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice // This function assumes you have already called Parse() on the config directory. // // This function should only be called once. -func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevice { +func (parser *ConfigParser) ConnectPhysicalDevices(lock bool) map[string]*evdev.InputDevice { deviceMap := make(map[string]*evdev.InputDevice) for _, deviceConfig := range parser.config.Devices { @@ -80,7 +80,12 @@ func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevi continue } - // TODO: grab exclusive access to device (add config option) + if lock { + err := device.Grab() + if err != nil { + logger.LogError(err, "Failed to grab device for exclusive access") + } + } logger.Log(fmt.Sprintf("Connected to '%s' as '%s'", deviceConfig.DeviceName, deviceConfig.Name)) deviceMap[deviceConfig.Name] = device From 3bbffa9325bcce69446ac68f48d65bc9efd9f39e Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 1 Aug 2025 14:05:58 -0400 Subject: [PATCH 03/12] Add test command to makefile. --- Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ea3da7a..6980ef2 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,16 @@ DESTDIR=/ PREFIX=${HOME}/ BINDIR=bin/ -default: +.PHONY: default build test install uninstall clean + +default: build + +build: go build -o build/ ./... +test: + go test ./... + install: mkdir -p ${DESTDIR}/${PREFIX}/${BINDIR} cp build/evinfo ${DESTDIR}/${PREFIX}/${BINDIR}/ From 61fe5208e6e4cd388d539b3a6c6ed91128f64bc5 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 1 Aug 2025 15:15:25 -0400 Subject: [PATCH 04/12] On second thought, makefile turns out to be a poor fit for golang. --- Makefile | 25 ------------------------- readme.md | 10 +++++----- 2 files changed, 5 insertions(+), 30 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index 6980ef2..0000000 --- a/Makefile +++ /dev/null @@ -1,25 +0,0 @@ -DESTDIR=/ -PREFIX=${HOME}/ -BINDIR=bin/ - -.PHONY: default build test install uninstall clean - -default: build - -build: - go build -o build/ ./... - -test: - go test ./... - -install: - mkdir -p ${DESTDIR}/${PREFIX}/${BINDIR} - cp build/evinfo ${DESTDIR}/${PREFIX}/${BINDIR}/ - cp build/joyful ${DESTDIR}/${PREFIX}/${BINDIR}/ - -uninstall: - rm ${DESTDIR}/${PREFIX}/${BINDIR}/evinfo - rm ${DESTDIR}/${PREFIX}/${BINDIR}/joyful - -clean: - rm -rf build/ \ No newline at end of file diff --git a/readme.md b/readme.md index eb432f6..164e2ac 100644 --- a/readme.md +++ b/readme.md @@ -67,15 +67,15 @@ cd joyful Then, to build and install, run: ``` -make -make install +go build -o build/ ./... +cp build/* ~/bin/ ``` -By default this will install into `~/bin`. If you want to install Joyful system-wide, you can instead do: +If you want to install Joyful system-wide, you can instead do: ``` -make -sudo make PREFIX=/usr/local install +go build -o build/ ./... +sudo cp build/* /usr/local/bin/ ``` From 838449000c1f696634cc6448e80433afa8553f71 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Mon, 4 Aug 2025 19:55:56 +0000 Subject: [PATCH 05/12] Support keyboard buttons and add presets. (#14) Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/14 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/joyful/main.go | 8 +- docs/examples/gamepad/devices.yml | 14 +- docs/examples/gamepad/readme.md | 2 +- docs/examples/multiple_files/devices.yml | 11 +- docs/readme.md | 10 +- internal/config/codes.go | 12 +- internal/config/codes_test.go | 192 ++++++++------- internal/config/devices.go | 31 ++- internal/config/make_rule_targets.go | 6 +- internal/config/schema.go | 43 ++++ internal/config/variables.go | 284 +++++++++++++++++++++++ readme.md | 12 +- 12 files changed, 492 insertions(+), 133 deletions(-) diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 41eaae5..17482bf 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -52,8 +52,8 @@ func getVirtualDevices(buffers map[string]*virtualdevice.EventBuffer) map[string return devices } -func initPhysicalDevices(config *config.ConfigParser, lock bool) map[string]*evdev.InputDevice { - pDeviceMap := config.ConnectPhysicalDevices(lock) +func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDevice { + pDeviceMap := config.ConnectPhysicalDevices() if len(pDeviceMap) == 0 { logger.Log("Warning: no physical devices found in configuration. No rules will work.") } @@ -63,9 +63,7 @@ func initPhysicalDevices(config *config.ConfigParser, lock bool) map[string]*evd func main() { // parse command-line var configFlag string - var noLockFlag bool flag.BoolVarP(&logger.IsDebugMode, "debug", "d", false, "Output very verbose debug messages.") - flag.BoolVar(&noLockFlag, "no-lock", false, "Disable locking the physical devices for exclusive reading.") flag.StringVarP(&configFlag, "config", "c", "~/.config/joyful", "Directory to read configuration from.") ttsOps := addTTSFlags() flag.Parse() @@ -82,7 +80,7 @@ func main() { vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) // Initialize physical devices - pDevices := initPhysicalDevices(config, !noLockFlag) + pDevices := initPhysicalDevices(config) // Load the rules rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) diff --git a/docs/examples/gamepad/devices.yml b/docs/examples/gamepad/devices.yml index 4064f25..b378800 100644 --- a/docs/examples/gamepad/devices.yml +++ b/docs/examples/gamepad/devices.yml @@ -1,19 +1,7 @@ devices: - name: primary type: virtual - num_axes: 6 - buttons: - - BTN_EAST - - BTN_SOUTH - - BTN_NORTH - - BTN_WEST - - BTN_TL - - BTN_TR - - BTN_SELECT - - BTN_START - - BTN_MODE - - BTN_THUMBL - - BTN_THUMBR + preset: gamepad - name: right-stick type: physical device_name: VIRPIL Controls 20220407 R-VPC Stick MT-50CM2 diff --git a/docs/examples/gamepad/readme.md b/docs/examples/gamepad/readme.md index 81941b1..9c87c8d 100644 --- a/docs/examples/gamepad/readme.md +++ b/docs/examples/gamepad/readme.md @@ -1,5 +1,5 @@ ## joystick -> gamepad mapping -This is an incomplete example for mapping dual flightsticks (Virpil Constellation Alphas) to gamepad outputs, to support dual-joystick play in games that expect a console-style gamepad. This has been tested on Steam, and it successfully recognizes this as a gamepad. +This is an incomplete example for mapping dual flightsticks (Virpil Constellation Alphas) to gamepad outputs, to support dual-joystick play in games that expect a console-style gamepad. This has been tested on Outer Wilds running in Steam. Not every possible input is mapped here, this is just a somewhat minimal example. \ No newline at end of file diff --git a/docs/examples/multiple_files/devices.yml b/docs/examples/multiple_files/devices.yml index 156e132..391e4c8 100644 --- a/docs/examples/multiple_files/devices.yml +++ b/docs/examples/multiple_files/devices.yml @@ -1,18 +1,13 @@ devices: - name: primary type: virtual - num_buttons: 74 - num_axes: 8 + preset: joystick - name: secondary type: virtual - num_buttons: 74 - num_axes: 3 + preset: joystick - name: mouse type: virtual - num_buttons: 0 - num_axes: 0 - rel_axes: - - REL_WHEEL + preset: mouse - name: right-stick type: physical device_name: VIRPIL Controls 20220407 R-VPC Stick MT-50CM2 diff --git a/docs/readme.md b/docs/readme.md index 84a8c74..4dfe497 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -2,7 +2,7 @@ Configuration is divided into three sections: `devices`, `modes`, and `rules`. Each yaml file can have any number of these sections; joyful will combine the configuration from all files at runtime. -### Device configuration +## Device configuration Each entry in `devices` must have a couple of parameters: @@ -15,16 +15,18 @@ Each entry in `devices` must have a couple of parameters: `virtual` devices can additionally define these parameters: +* `preset` - Can be `joystick`, `gamepad`, `mouse`, or `keyboard`, and will configure the virtual device to look like and emit an appropriate set of outputs based on the name. For exactly which axes and buttons are defined for each type, see the `Capabilities` values in [internal/config/variables.go](internal/config/variables.go). * `buttons` or `num_buttons` - Either a list of explicit buttons or a number of buttons to create. (max 74 buttons) Linux-native games may not recognize all buttons created by Joyful. * `axes` or `num_axes` - An explicit list of `ABS_` axes or a number to create. * `relative_axes` or `num_relative_axes` - As above, but for `REL_` axes. A couple of additional notes on virtual devices: -* For all 3 of the above options, an explicit list will override the `num_` parameters if both are present. -* 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 and explicitly define the axes. +* Users are encouraged to use the `preset` options whenever possible. They have the highest probability of working the way you expect. If you need to output to multiple types of device, the best approach is to create multiple virtual devices. +* For all 3 of the above options, there is a priority order. If you specify a `preset`, it will be used ignoring any other settings. An explicit list will override the corresponding `num_` parameter. +* Some environments/applications are prescriptive about what combinations make sense; for example, they will only register mouse events if the device *only* supports mouse-like events. The `presets` attempt to take this into account. If you are defining capabilities manually and attempt to mix and match button codes, you may also run into this problem. -### Rules configuration +## Rules configuration All `rules` must have a `type` parameter. Valid values for this parameter are: diff --git a/internal/config/codes.go b/internal/config/codes.go index fc819cb..c879feb 100644 --- a/internal/config/codes.go +++ b/internal/config/codes.go @@ -8,13 +8,23 @@ import ( "github.com/holoplot/go-evdev" ) +func parseCodeButton(code string) (evdev.EvCode, error) { + prefix := CodePrefixButton + + if strings.HasPrefix(code, CodePrefixKey+"_") { + prefix = CodePrefixKey + } + + return parseCode(code, prefix) +} + func parseCode(code, prefix string) (evdev.EvCode, error) { code = strings.ToUpper(code) var codeLookup map[string]evdev.EvCode switch prefix { - case CodePrefixButton: + case CodePrefixButton, CodePrefixKey: codeLookup = evdev.KEYFromString case CodePrefixAxis: codeLookup = evdev.ABSFromString diff --git a/internal/config/codes_test.go b/internal/config/codes_test.go index cf1741c..6e80291 100644 --- a/internal/config/codes_test.go +++ b/internal/config/codes_test.go @@ -16,7 +16,7 @@ func TestRunnerEventCodeParserTests(t *testing.T) { suite.Run(t, new(EventCodeParserTests)) } -func parseCodeTestCase(t *EventCodeParserTests, in string, out int, prefix string) { +func parseCodeTestCase(t *EventCodeParserTests, in string, out evdev.EvCode, prefix string) { t.Run(fmt.Sprintf("%s: %s", prefix, in), func() { code, err := parseCode(in, prefix) t.Nil(err) @@ -24,95 +24,119 @@ func parseCodeTestCase(t *EventCodeParserTests, in string, out int, prefix strin }) } -func (t *EventCodeParserTests) TestParseCodeABS() { +func (t *EventCodeParserTests) TestParseCodeButton() { testCases := []struct { in string - out int + out evdev.EvCode }{ - {"ABS_X", evdev.ABS_X}, - {"ABS_Y", evdev.ABS_Y}, - {"ABS_Z", evdev.ABS_Z}, - {"ABS_RX", evdev.ABS_RX}, - {"ABS_RY", evdev.ABS_RY}, - {"ABS_RZ", evdev.ABS_RZ}, - {"ABS_THROTTLE", evdev.ABS_THROTTLE}, - {"ABS_RUDDER", evdev.ABS_RUDDER}, - {"x", evdev.ABS_X}, - {"y", evdev.ABS_Y}, - {"z", evdev.ABS_Z}, - {"throttle", evdev.ABS_THROTTLE}, - {"rudder", evdev.ABS_RUDDER}, - {"0x0", evdev.ABS_X}, - {"0x1", evdev.ABS_Y}, - {"0x2", evdev.ABS_Z}, + {"BTN_A", evdev.BTN_A}, + {"A", evdev.BTN_A}, + {"BTN_TRIGGER_HAPPY", evdev.BTN_TRIGGER_HAPPY}, + {"KEY_A", evdev.KEY_A}, + {"KEY_ESC", evdev.KEY_ESC}, } for _, testCase := range testCases { - parseCodeTestCase(t, testCase.in, testCase.out, "ABS") - } -} - -func (t *EventCodeParserTests) TestParseCodeREL() { - testCases := []struct { - in string - out int - }{ - {"REL_X", evdev.REL_X}, - {"REL_Y", evdev.REL_Y}, - {"REL_Z", evdev.REL_Z}, - {"REL_RX", evdev.REL_RX}, - {"REL_RY", evdev.REL_RY}, - {"REL_RZ", evdev.REL_RZ}, - {"REL_WHEEL", evdev.REL_WHEEL}, - {"REL_HWHEEL", evdev.REL_HWHEEL}, - {"REL_MISC", evdev.REL_MISC}, - {"x", evdev.REL_X}, - {"y", evdev.REL_Y}, - {"wheel", evdev.REL_WHEEL}, - {"0x0", evdev.REL_X}, - {"0x1", evdev.REL_Y}, - {"0x2", evdev.REL_Z}, - } - - for _, testCase := range testCases { - parseCodeTestCase(t, testCase.in, testCase.out, "REL") - } -} - -func (t *EventCodeParserTests) TestParseCodeBTN() { - testCases := []struct { - in string - out int - }{ - {"BTN_TRIGGER", evdev.BTN_TRIGGER}, - {"trigger", evdev.BTN_TRIGGER}, - {"0", evdev.BTN_TRIGGER}, - {"0x120", evdev.BTN_TRIGGER}, - } - - for _, testCase := range testCases { - parseCodeTestCase(t, testCase.in, testCase.out, "BTN") - } -} - -func (t *EventCodeParserTests) TestParseCodeInvalid() { - testCases := []struct { - in string - prefix string - }{ - {"badbutton", "BTN"}, - {"ABS_X", "BTN"}, - {"!@#$%^&*(){}-_", "BTN"}, - {"REL_X", "ABS"}, - {"ABS_W", "ABS"}, - {"0", "ABS"}, - {"0xg", "ABS"}, - } - - for _, testCase := range testCases { - t.Run(fmt.Sprintf("%s - '%s'", testCase.prefix, testCase.in), func() { - _, err := parseCode(testCase.in, testCase.prefix) - t.NotNil(err) + t.Run(testCase.in, func() { + code, err := parseCodeButton(testCase.in) + t.Nil(err) + t.EqualValues(code, testCase.out) }) } } + +func (t *EventCodeParserTests) TestParseCode() { + + t.Run("ABS", func() { + testCases := []struct { + in string + out evdev.EvCode + }{ + {"ABS_X", evdev.ABS_X}, + {"ABS_Y", evdev.ABS_Y}, + {"ABS_Z", evdev.ABS_Z}, + {"ABS_RX", evdev.ABS_RX}, + {"ABS_RY", evdev.ABS_RY}, + {"ABS_RZ", evdev.ABS_RZ}, + {"ABS_THROTTLE", evdev.ABS_THROTTLE}, + {"ABS_RUDDER", evdev.ABS_RUDDER}, + {"x", evdev.ABS_X}, + {"y", evdev.ABS_Y}, + {"z", evdev.ABS_Z}, + {"throttle", evdev.ABS_THROTTLE}, + {"rudder", evdev.ABS_RUDDER}, + {"0x0", evdev.ABS_X}, + {"0x1", evdev.ABS_Y}, + {"0x2", evdev.ABS_Z}, + } + + for _, testCase := range testCases { + parseCodeTestCase(t, testCase.in, testCase.out, "ABS") + } + }) + + t.Run("REL", func() { + testCases := []struct { + in string + out evdev.EvCode + }{ + {"REL_X", evdev.REL_X}, + {"REL_Y", evdev.REL_Y}, + {"REL_Z", evdev.REL_Z}, + {"REL_RX", evdev.REL_RX}, + {"REL_RY", evdev.REL_RY}, + {"REL_RZ", evdev.REL_RZ}, + {"REL_WHEEL", evdev.REL_WHEEL}, + {"REL_HWHEEL", evdev.REL_HWHEEL}, + {"REL_MISC", evdev.REL_MISC}, + {"x", evdev.REL_X}, + {"y", evdev.REL_Y}, + {"wheel", evdev.REL_WHEEL}, + {"0x0", evdev.REL_X}, + {"0x1", evdev.REL_Y}, + {"0x2", evdev.REL_Z}, + } + + for _, testCase := range testCases { + parseCodeTestCase(t, testCase.in, testCase.out, "REL") + } + }) + + t.Run("BTN", func() { + testCases := []struct { + in string + out evdev.EvCode + }{ + {"BTN_TRIGGER", evdev.BTN_TRIGGER}, + {"trigger", evdev.BTN_TRIGGER}, + {"0", evdev.BTN_TRIGGER}, + {"0x120", evdev.BTN_TRIGGER}, + } + + for _, testCase := range testCases { + parseCodeTestCase(t, testCase.in, testCase.out, "BTN") + } + }) + + t.Run("Invalid", func() { + testCases := []struct { + in string + prefix string + }{ + {"badbutton", "BTN"}, + {"ABS_X", "BTN"}, + {"!@#$%^&*(){}-_", "BTN"}, + {"REL_X", "ABS"}, + {"ABS_W", "ABS"}, + {"0", "ABS"}, + {"0xg", "ABS"}, + } + + for _, testCase := range testCases { + t.Run(fmt.Sprintf("%s - '%s'", testCase.prefix, testCase.in), func() { + _, err := parseCode(testCase.in, testCase.prefix) + t.NotNil(err) + }) + } + }) +} diff --git a/internal/config/devices.go b/internal/config/devices.go index d77147d..f878fde 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -23,10 +23,25 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice } name := fmt.Sprintf("joyful-%s", deviceConfig.Name) - capabilities := map[evdev.EvType][]evdev.EvCode{ - evdev.EV_KEY: makeButtons(deviceConfig.NumButtons, deviceConfig.Buttons), - evdev.EV_ABS: makeAxes(deviceConfig.NumAxes, deviceConfig.Axes), - evdev.EV_REL: makeRelativeAxes(deviceConfig.NumRelativeAxes, deviceConfig.RelativeAxes), + + var capabilities map[evdev.EvType][]evdev.EvCode + + // todo: add tests for presets + switch deviceConfig.Preset { + case DevicePresetGamepad: + capabilities = CapabilitiesPresetGamepad + case DevicePresetKeyboard: + capabilities = CapabilitiesPresetKeyboard + case DevicePresetJoystick: + capabilities = CapabilitiesPresetJoystick + case DevicePresetMouse: + capabilities = CapabilitiesPresetMouse + default: + capabilities = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_KEY: makeButtons(deviceConfig.NumButtons, deviceConfig.Buttons), + evdev.EV_ABS: makeAxes(deviceConfig.NumAxes, deviceConfig.Axes), + evdev.EV_REL: makeRelativeAxes(deviceConfig.NumRelativeAxes, deviceConfig.RelativeAxes), + } } device, err := evdev.CreateDevice( @@ -60,13 +75,12 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice } // ConnectPhysicalDevices will create InputDevices corresponding to any registered -// devices with type = physical. It will also attempt to acquire exclusive access -// to those devices, to prevent the same inputs from being read on multiple devices. +// devices with type = physical. // // This function assumes you have already called Parse() on the config directory. // // This function should only be called once. -func (parser *ConfigParser) ConnectPhysicalDevices(lock bool) map[string]*evdev.InputDevice { +func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevice { deviceMap := make(map[string]*evdev.InputDevice) for _, deviceConfig := range parser.config.Devices { @@ -80,7 +94,8 @@ func (parser *ConfigParser) ConnectPhysicalDevices(lock bool) map[string]*evdev. continue } - if lock { + if deviceConfig.Lock { + logger.LogDebugf("Locking device '%s'", deviceConfig.DeviceName) err := device.Grab() if err != nil { logger.LogError(err, "Failed to grab device for exclusive access") diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 47d28e5..7e8c2eb 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -14,7 +14,7 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]Device) return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) } - eventCode, err := parseCode(targetConfig.Button, "BTN") + eventCode, err := parseCodeButton(targetConfig.Button) if err != nil { return nil, err } @@ -37,7 +37,7 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]Device) ( return nil, errors.New("deadzone_end must be greater than deadzone_start") } - eventCode, err := parseCode(targetConfig.Axis, "ABS") + eventCode, err := parseCode(targetConfig.Axis, CodePrefixAxis) if err != nil { return nil, err } @@ -63,7 +63,7 @@ func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]Device return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) } - eventCode, err := parseCode(targetConfig.Axis, "REL") + eventCode, err := parseCode(targetConfig.Axis, CodePrefixRelaxis) if err != nil { return nil, err } diff --git a/internal/config/schema.go b/internal/config/schema.go index afb4940..b4675e0 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -20,12 +20,14 @@ type DeviceConfig struct { Type string `yaml:"type"` DeviceName string `yaml:"device_name,omitempty"` Uuid string `yaml:"uuid,omitempty"` + Preset string `yaml:"preset,omitempty"` NumButtons int `yaml:"num_buttons,omitempty"` NumAxes int `yaml:"num_axes,omitempty"` NumRelativeAxes int `yaml:"num_rel_axes"` Buttons []string `yaml:"buttons,omitempty"` Axes []string `yaml:"axes,omitempty"` RelativeAxes []string `yaml:"rel_axes,omitempty"` + Lock bool `yaml:"lock,omitempty"` } type RuleConfig struct { @@ -54,3 +56,44 @@ type RuleTargetConfig struct { Inverted bool `yaml:"inverted,omitempty"` Modes []string `yaml:"modes,omitempty"` } + +// TODO: custom yaml unmarshaling is obtuse; do we really need to do all of this work +// just to set a single default value? +func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { + var raw struct { + Name string + Type string + DeviceName string `yaml:"device_name"` + Uuid string + Preset string + NumButtons int `yaml:"num_buttons"` + NumAxes int `yaml:"num_axes"` + NumRelativeAxes int `yaml:"num_rel_axes"` + Buttons []string + Axes []string + RelativeAxes []string `yaml:"relative_axes"` + Lock bool `yaml:"lock,omitempty"` + } + raw.Lock = true + + err := unmarshal(&raw) + if err != nil { + return err + } + + *dc = DeviceConfig{ + Name: raw.Name, + Type: raw.Type, + DeviceName: raw.DeviceName, + Uuid: raw.Uuid, + Preset: raw.Preset, + NumButtons: raw.NumButtons, + NumAxes: raw.NumAxes, + NumRelativeAxes: raw.NumRelativeAxes, + Buttons: raw.Buttons, + Axes: raw.Axes, + RelativeAxes: raw.RelativeAxes, + Lock: raw.Lock, + } + return nil +} diff --git a/internal/config/variables.go b/internal/config/variables.go index 9c126bc..e4e0bf0 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -8,6 +8,11 @@ const ( DeviceTypePhysical = "physical" DeviceTypeVirtual = "virtual" + DevicePresetKeyboard = "keyboard" + DevicePresetGamepad = "gamepad" + DevicePresetJoystick = "joystick" + DevicePresetMouse = "mouse" + RuleTypeButton = "button" RuleTypeButtonCombo = "button-combo" RuleTypeLatched = "button-latched" @@ -18,6 +23,7 @@ const ( RuleTypeAxisToRelaxis = "axis-to-relaxis" CodePrefixButton = "BTN" + CodePrefixKey = "KEY" CodePrefixAxis = "ABS" CodePrefixRelaxis = "REL" @@ -102,3 +108,281 @@ var ( evdev.EvCode(0x2ff), } ) + +// Device Presets +var ( + CapabilitiesPresetGamepad = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_ABS: { + evdev.ABS_X, + evdev.ABS_Y, + evdev.ABS_Z, + evdev.ABS_RX, + evdev.ABS_RY, + evdev.ABS_RZ, + evdev.ABS_HAT0X, + evdev.ABS_HAT0Y, + }, + evdev.EV_KEY: { + evdev.BTN_NORTH, // Xbox 'X', Playstation 'Square' + evdev.BTN_SOUTH, // Xbox 'A', Plastation 'X' + evdev.BTN_WEST, // Xbox 'Y', Playstation 'Triangle' + evdev.BTN_EAST, // Xbox 'B', Playstation 'O' + evdev.BTN_THUMBL, + evdev.BTN_THUMBR, + evdev.BTN_TL, + evdev.BTN_TR, + evdev.BTN_SELECT, + evdev.BTN_START, + evdev.BTN_MODE, + }, + } + + CapabilitiesPresetJoystick = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_ABS: { + evdev.ABS_X, + evdev.ABS_Y, + evdev.ABS_Z, + evdev.ABS_RX, + evdev.ABS_RY, + evdev.ABS_RZ, + evdev.ABS_THROTTLE, // Also called "Slider" or "Slider1" + evdev.ABS_RUDDER, // Also called "Dial", "Slider2", or "RSlider" + }, + evdev.EV_KEY: { + evdev.BTN_TRIGGER, + evdev.BTN_THUMB, + evdev.BTN_THUMB2, + evdev.BTN_TOP, + evdev.BTN_TOP2, + evdev.BTN_PINKIE, + evdev.BTN_BASE, + evdev.BTN_BASE2, + evdev.BTN_BASE3, + 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, + evdev.BTN_TRIGGER_HAPPY4, + evdev.BTN_TRIGGER_HAPPY5, + evdev.BTN_TRIGGER_HAPPY6, + evdev.BTN_TRIGGER_HAPPY7, + evdev.BTN_TRIGGER_HAPPY8, + evdev.BTN_TRIGGER_HAPPY9, + evdev.BTN_TRIGGER_HAPPY10, + evdev.BTN_TRIGGER_HAPPY11, + evdev.BTN_TRIGGER_HAPPY12, + evdev.BTN_TRIGGER_HAPPY13, + evdev.BTN_TRIGGER_HAPPY14, + evdev.BTN_TRIGGER_HAPPY15, + evdev.BTN_TRIGGER_HAPPY16, + evdev.BTN_TRIGGER_HAPPY17, + evdev.BTN_TRIGGER_HAPPY18, + evdev.BTN_TRIGGER_HAPPY19, + evdev.BTN_TRIGGER_HAPPY20, + evdev.BTN_TRIGGER_HAPPY21, + evdev.BTN_TRIGGER_HAPPY22, + evdev.BTN_TRIGGER_HAPPY23, + evdev.BTN_TRIGGER_HAPPY24, + evdev.BTN_TRIGGER_HAPPY25, + evdev.BTN_TRIGGER_HAPPY26, + evdev.BTN_TRIGGER_HAPPY27, + evdev.BTN_TRIGGER_HAPPY28, + evdev.BTN_TRIGGER_HAPPY29, + evdev.BTN_TRIGGER_HAPPY30, + evdev.BTN_TRIGGER_HAPPY31, + evdev.BTN_TRIGGER_HAPPY32, + evdev.BTN_TRIGGER_HAPPY33, + evdev.BTN_TRIGGER_HAPPY34, + evdev.BTN_TRIGGER_HAPPY35, + evdev.BTN_TRIGGER_HAPPY36, + evdev.BTN_TRIGGER_HAPPY37, + 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), + }, + } + + CapabilitiesPresetKeyboard = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_KEY: { + evdev.KEY_ESC, + evdev.KEY_1, + evdev.KEY_2, + evdev.KEY_3, + evdev.KEY_4, + evdev.KEY_5, + evdev.KEY_6, + evdev.KEY_7, + evdev.KEY_8, + evdev.KEY_9, + evdev.KEY_0, + evdev.KEY_MINUS, + evdev.KEY_EQUAL, + evdev.KEY_BACKSPACE, + evdev.KEY_TAB, + evdev.KEY_Q, + evdev.KEY_W, + evdev.KEY_E, + evdev.KEY_R, + evdev.KEY_T, + evdev.KEY_Y, + evdev.KEY_U, + evdev.KEY_I, + evdev.KEY_O, + evdev.KEY_P, + evdev.KEY_LEFTBRACE, + evdev.KEY_RIGHTBRACE, + evdev.KEY_ENTER, + evdev.KEY_LEFTCTRL, + evdev.KEY_A, + evdev.KEY_S, + evdev.KEY_D, + evdev.KEY_F, + evdev.KEY_G, + evdev.KEY_H, + evdev.KEY_J, + evdev.KEY_K, + evdev.KEY_L, + evdev.KEY_SEMICOLON, + evdev.KEY_APOSTROPHE, + evdev.KEY_GRAVE, + evdev.KEY_LEFTSHIFT, + evdev.KEY_BACKSLASH, + evdev.KEY_Z, + evdev.KEY_X, + evdev.KEY_C, + evdev.KEY_V, + evdev.KEY_B, + evdev.KEY_N, + evdev.KEY_M, + evdev.KEY_COMMA, + evdev.KEY_DOT, + evdev.KEY_SLASH, + evdev.KEY_RIGHTSHIFT, + evdev.KEY_KPASTERISK, + evdev.KEY_LEFTALT, + evdev.KEY_SPACE, + evdev.KEY_CAPSLOCK, + evdev.KEY_F1, + evdev.KEY_F2, + evdev.KEY_F3, + evdev.KEY_F4, + evdev.KEY_F5, + evdev.KEY_F6, + evdev.KEY_F7, + evdev.KEY_F8, + evdev.KEY_F9, + evdev.KEY_F10, + evdev.KEY_NUMLOCK, + evdev.KEY_SCROLLLOCK, + evdev.KEY_KP7, + evdev.KEY_KP8, + evdev.KEY_KP9, + evdev.KEY_KPMINUS, + evdev.KEY_KP4, + evdev.KEY_KP5, + evdev.KEY_KP6, + evdev.KEY_KPPLUS, + evdev.KEY_KP1, + evdev.KEY_KP2, + evdev.KEY_KP3, + evdev.KEY_KP0, + evdev.KEY_KPDOT, + evdev.KEY_ZENKAKUHANKAKU, + evdev.KEY_102ND, + evdev.KEY_F11, + evdev.KEY_F12, + evdev.KEY_RO, + evdev.KEY_KATAKANA, + evdev.KEY_HIRAGANA, + evdev.KEY_HENKAN, + evdev.KEY_KATAKANAHIRAGANA, + evdev.KEY_MUHENKAN, + evdev.KEY_KPJPCOMMA, + evdev.KEY_KPENTER, + evdev.KEY_RIGHTCTRL, + evdev.KEY_KPSLASH, + evdev.KEY_SYSRQ, + evdev.KEY_RIGHTALT, + evdev.KEY_LINEFEED, + evdev.KEY_HOME, + evdev.KEY_UP, + evdev.KEY_PAGEUP, + evdev.KEY_LEFT, + evdev.KEY_RIGHT, + evdev.KEY_END, + evdev.KEY_DOWN, + evdev.KEY_PAGEDOWN, + evdev.KEY_INSERT, + evdev.KEY_DELETE, + evdev.KEY_MACRO, + evdev.KEY_MUTE, + evdev.KEY_VOLUMEDOWN, + evdev.KEY_VOLUMEUP, + evdev.KEY_KPEQUAL, + evdev.KEY_KPPLUSMINUS, + evdev.KEY_PAUSE, + evdev.KEY_SCALE, + evdev.KEY_KPCOMMA, + evdev.KEY_HANGEUL, + evdev.KEY_HANJA, + evdev.KEY_YEN, + evdev.KEY_LEFTMETA, + evdev.KEY_RIGHTMETA, + evdev.KEY_COMPOSE, + evdev.KEY_F13, + evdev.KEY_F14, + evdev.KEY_F15, + evdev.KEY_F16, + evdev.KEY_F17, + evdev.KEY_F18, + evdev.KEY_F19, + evdev.KEY_F20, + evdev.KEY_F21, + evdev.KEY_F22, + evdev.KEY_F23, + evdev.KEY_F24, + }, + } + + CapabilitiesPresetMouse = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_REL: { + evdev.REL_X, + evdev.REL_Y, + evdev.REL_WHEEL, + evdev.REL_HWHEEL, + }, + evdev.EV_KEY: { + evdev.BTN_LEFT, + evdev.BTN_MIDDLE, + evdev.BTN_RIGHT, + evdev.BTN_SIDE, + evdev.BTN_EXTRA, + evdev.BTN_FORWARD, + evdev.BTN_BACK, + }, + } +) diff --git a/readme.md b/readme.md index 164e2ac..937bf13 100644 --- a/readme.md +++ b/readme.md @@ -18,6 +18,7 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * "Combined" axis mapping: map two physical axes to one virtual axis. * Axis -> button mapping with optional "proportional" repeat speed (i.e. repeat faster as the axis is engaged further) * Axis -> Relative Axis mapping, for converting a joystick axis to mouse movement and scrollwheel events. +* Define keyboard, mouse, and gamepad outputs in addition to joysticks. * Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. * Define multiple modes with per-mode behavior. * Text-to-speech engine that announces the current mode when it changes. @@ -26,10 +27,9 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * Macros - have a single input produce a sequence of button presses with configurable pauses. * Sequence combos - Button1, Button2, Button3 -> VirtualButtonA -* Output keyboard button presses * Hat support * HIDRAW support for more button options. -* Sensitivity Curves. +* Sensitivity Curves? * Packaged builds for Arch and possibly other distributions. ## Configure @@ -87,10 +87,10 @@ Pressing `` in the running terminal window will reload the `rules` sectio ## Technical details -Joyful is written in golang, and uses `evdev`/`uinput` to manage devices, `piper` and `oto` for TTS. See [cmd/joyful/main.go](cmd/joyful/main.go) for the program's entry point. +Joyful is written in golang, and uses `evdev`/`uinput` to manage devices and `espeak-ng` for TTS. See [cmd/joyful/main.go](cmd/joyful/main.go) for the program's entry point. + +This was originally going to be a Rust project, but the author's Rust skills weren't quite up to the task yet. Please look forward to the inevitable Rust rewrite. ### Contributing -Send patches and questions to [annabunches@gmail.com](mailto:annabunches@gmail.com). Make sure the subject of your email starts with `[Joyful]`. - -If enough people show an interest in contributing, I'll consider mirroring the repository on Github. \ No newline at end of file +Issues and pull requests should be made on the [Codeberg mirror](https://codeberg.org/annabunches/joyful). \ No newline at end of file From 890c19f1dc2a1901ce3887c9ea1a3b272ce9da2a Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Mon, 4 Aug 2025 16:11:53 -0400 Subject: [PATCH 06/12] Attempt to clarify some documentation. --- docs/readme.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/readme.md b/docs/readme.md index 4dfe497..773451e 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -4,18 +4,19 @@ Configuration is divided into three sections: `devices`, `modes`, and `rules`. E ## Device configuration -Each entry in `devices` must have a couple of parameters: +Each entry in `devices` must have these parameters: * `name` - This is an identifier that your rules will use to refer to the device. It is recommended to avoid spaces or special characters. -* `type` - Should be `physical` for an input device, and `virtual` for an output device. +* `type` - 'physical' for an input device, 'virtual' for an output device. -`physical` devices must additionally define these parameters: +`physical` devices have these additional parameters: -* `device_name` - The name of the device as reported by the included `evinfo` command. If your device name ends with a space, use quotation marks (`""`) around the name. +* `device_name` (required) - The name of the device as reported by the included `evinfo` command. If your device name ends with a space, use quotation marks (`""`) around the name. +* `lock` - If set to 'true', the device will be locked for exclusive access. This means that your game will not see any events from the device, so you'll need to make sure you map every button you want to use. Setting this to 'false' might be useful if you're just mapping a few joystick buttons to keyboard buttons. This value defaults to 'true'. -`virtual` devices can additionally define these parameters: +`virtual` devices have these additional parameters: -* `preset` - Can be `joystick`, `gamepad`, `mouse`, or `keyboard`, and will configure the virtual device to look like and emit an appropriate set of outputs based on the name. For exactly which axes and buttons are defined for each type, see the `Capabilities` values in [internal/config/variables.go](internal/config/variables.go). +* `preset` - Can be 'joystick', 'gamepad', 'mouse', or 'keyboard', and will configure the virtual device to look like and emit an appropriate set of outputs based on the name. For exactly which axes and buttons are defined for each type, see the `Capabilities` values in [internal/config/variables.go](internal/config/variables.go). * `buttons` or `num_buttons` - Either a list of explicit buttons or a number of buttons to create. (max 74 buttons) Linux-native games may not recognize all buttons created by Joyful. * `axes` or `num_axes` - An explicit list of `ABS_` axes or a number to create. * `relative_axes` or `num_relative_axes` - As above, but for `REL_` axes. From 329058b4b5b68f54c40eaa6e4eaf6a9d98141714 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 5 Aug 2025 20:02:45 +0000 Subject: [PATCH 07/12] Support specifying physical devices via device file instead of device name. (#15) Fixes https://codeberg.org/annabunches/joyful/issues/2 Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/15 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/evinfo/main.go | 35 +++++++++++++++++++++++++++++++++-- docs/readme.md | 12 +++++++++++- internal/config/devices.go | 19 +++++++++++++++---- internal/config/schema.go | 6 +++--- readme.md | 2 ++ 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/cmd/evinfo/main.go b/cmd/evinfo/main.go index d9e600b..c2cc8f0 100644 --- a/cmd/evinfo/main.go +++ b/cmd/evinfo/main.go @@ -43,6 +43,32 @@ func printDevice(devPath evdev.InputPath) { return } + // Get metadata + // metadata := struct { + // uuid string + // vendor string + // product string + // version string + // }{} + + // uuid, err := device.UniqueID() + // if err != nil { + // metadata.uuid = "unknown" + // } else { + // metadata.uuid = uuid + // } + + // inputId, err := device.InputID() + // if err != nil { + // metadata.vendor = "unknown" + // metadata.product = "unknown" + // metadata.version = "unknown" + // } else { + // metadata.vendor = "0x" + strconv.FormatUint(uint64(inputId.Vendor), 16) + // metadata.product = "0x" + strconv.FormatUint(uint64(inputId.Product), 16) + // metadata.version = strconv.FormatUint(uint64(inputId.Version), 10) + // } + // Get axis info var axisOutputs []string absInfos, err := device.AbsInfos() @@ -56,8 +82,13 @@ func printDevice(devPath evdev.InputPath) { } } + // Print everything fmt.Printf("%s:\n", devPath.Path) - fmt.Printf("\tName: '%s'\n", devPath.Name) + fmt.Printf("\tName:\t\t'%s'\n", devPath.Name) + // fmt.Printf("\tUUID:\t\t'%s'\n", metadata.uuid) + // fmt.Printf("\tVendor:\t\t'%s'\n", metadata.vendor) + // fmt.Printf("\tProduct:\t'%s'\n", metadata.product) + // fmt.Printf("\tVersion:\t'%s'\n", metadata.version) if len(axisOutputs) > 0 { fmt.Println("\tAxes:") for _, str := range axisOutputs { @@ -76,7 +107,7 @@ func printDeviceQuiet(devPath evdev.InputPath) { return } - fmt.Printf("'%s'\n", devPath.Name) + fmt.Printf("'%s': '%s'\n", devPath.Path, devPath.Name) } // TODO: it would be nice to be able to specify a device by name or device file and get axis info diff --git a/docs/readme.md b/docs/readme.md index 773451e..f6e7f37 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -9,11 +9,21 @@ Each entry in `devices` must have these parameters: * `name` - This is an identifier that your rules will use to refer to the device. It is recommended to avoid spaces or special characters. * `type` - 'physical' for an input device, 'virtual' for an output device. +### Physical Devices + `physical` devices have these additional parameters: -* `device_name` (required) - The name of the device as reported by the included `evinfo` command. If your device name ends with a space, use quotation marks (`""`) around the name. +* `device_name` - The name of the device as reported by the included `evinfo` command. If your device name ends with a space, use quotation marks (`""`) around the name. +* `device_path` - If you have multiple devices that report the same name, you can use `device_path` instead of `device_name`. Setting this will cause the device to be opened directly via the device file. + * It is recommended to use the `by-path` symlinks, e.g., `/dev/input/by-path/pci-0000:0d:00.0-usbv2-0:3:1.0-event-joystick`. + * Note that this method may be slightly unreliable since these identifiers may change if they are plugged into different USB ports or in the rare case that the USB topology changes (e.g., you add a new USB hub). + * On the other hand, this method causes the device to be opened considerably faster, lowering Joyful's startup time substantially. If this is important to you this method may be preferable. * `lock` - If set to 'true', the device will be locked for exclusive access. This means that your game will not see any events from the device, so you'll need to make sure you map every button you want to use. Setting this to 'false' might be useful if you're just mapping a few joystick buttons to keyboard buttons. This value defaults to 'true'. +`device_path` is given higher priority than `device_name`; if both are specified, `device_path` will be used. + +### Virtual Devices + `virtual` devices have these additional parameters: * `preset` - Can be 'joystick', 'gamepad', 'mouse', or 'keyboard', and will configure the virtual device to look like and emit an appropriate set of outputs based on the name. For exactly which axes and buttons are defined for each type, see the `Capabilities` values in [internal/config/variables.go](internal/config/variables.go). diff --git a/internal/config/devices.go b/internal/config/devices.go index f878fde..9802bff 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -88,21 +88,32 @@ func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevi continue } - device, err := evdev.OpenByName(deviceConfig.DeviceName) + var infoName string + var device *evdev.InputDevice + var err error + + if deviceConfig.DevicePath != "" { + infoName = deviceConfig.DevicePath + device, err = evdev.Open(deviceConfig.DevicePath) + } else { + infoName = deviceConfig.DeviceName + device, err = evdev.OpenByName(deviceConfig.DeviceName) + } + if err != nil { - logger.LogError(err, "Failed to open physical device, skipping. Confirm the device name with 'evlist'. Watch out for spaces.") + logger.LogError(err, "Failed to open physical device, skipping. Confirm the device name or path with 'evinfo'") continue } if deviceConfig.Lock { - logger.LogDebugf("Locking device '%s'", deviceConfig.DeviceName) + logger.LogDebugf("Locking device '%s'", infoName) err := device.Grab() if err != nil { logger.LogError(err, "Failed to grab device for exclusive access") } } - logger.Log(fmt.Sprintf("Connected to '%s' as '%s'", deviceConfig.DeviceName, deviceConfig.Name)) + logger.Log(fmt.Sprintf("Connected to '%s' as '%s'", infoName, deviceConfig.Name)) deviceMap[deviceConfig.Name] = device } diff --git a/internal/config/schema.go b/internal/config/schema.go index b4675e0..1ea3527 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -19,7 +19,7 @@ type DeviceConfig struct { Name string `yaml:"name"` Type string `yaml:"type"` DeviceName string `yaml:"device_name,omitempty"` - Uuid string `yaml:"uuid,omitempty"` + DevicePath string `yaml:"device_path,omitempty"` Preset string `yaml:"preset,omitempty"` NumButtons int `yaml:"num_buttons,omitempty"` NumAxes int `yaml:"num_axes,omitempty"` @@ -64,7 +64,7 @@ func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) er Name string Type string DeviceName string `yaml:"device_name"` - Uuid string + DevicePath string `yaml:"device_path"` Preset string NumButtons int `yaml:"num_buttons"` NumAxes int `yaml:"num_axes"` @@ -85,7 +85,7 @@ func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) er Name: raw.Name, Type: raw.Type, DeviceName: raw.DeviceName, - Uuid: raw.Uuid, + DevicePath: raw.DevicePath, Preset: raw.Preset, NumButtons: raw.NumButtons, NumAxes: raw.NumAxes, diff --git a/readme.md b/readme.md index 937bf13..5872c24 100644 --- a/readme.md +++ b/readme.md @@ -48,6 +48,8 @@ If you are on Arch or an Arch-based distro, you can get the latest Joyful releas yay -S joyful ``` +You may also need to add the user(s) who will be running joyful to the `input` group. + ### Manual Install To build joyful manually, first use your distribution's package manager to install the following dependencies: From 7a9a2ba9e279a4d1572a1a5dfaae9350e6dcaef3 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 5 Aug 2025 16:07:29 -0400 Subject: [PATCH 08/12] Update feature list. --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 5872c24..f9c0e88 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * Hat support * HIDRAW support for more button options. * Sensitivity Curves? -* Packaged builds for Arch and possibly other distributions. +* Packaged builds non-Arch distributions. ## Configure From 1a7b288083601e57746cf3c9f7e752c983c09da3 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 8 Aug 2025 11:58:25 -0400 Subject: [PATCH 09/12] Convenience ignore for rust experimentation. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d163863..dd955ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -build/ \ No newline at end of file +build/ +target/ \ No newline at end of file From d9babf5dc0465ca5f13da1ee4e898c89483688f9 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Sat, 9 Aug 2025 16:33:46 +0000 Subject: [PATCH 10/12] Improve config yaml schema (#16) Leverages custom unmarshaling to be more declarative for our config specification. Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/16 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/joyful/main.go | 28 +- internal/config/devices.go | 18 +- internal/config/make_rule_targets.go | 11 +- internal/config/make_rule_targets_test.go | 229 ++++++++-------- internal/config/make_rules.go | 43 +-- internal/config/schema.go | 264 ++++++++++++++----- internal/config/variables.go | 4 +- internal/mappingrules/rule_target_relaxis.go | 5 +- 8 files changed, 364 insertions(+), 238 deletions(-) diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 17482bf..f6cf6de 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -28,8 +28,11 @@ func readConfig(configDir string) *config.ConfigParser { return parser } -func initVirtualBuffers(config *config.ConfigParser) (map[string]*virtualdevice.EventBuffer, map[*evdev.InputDevice]*virtualdevice.EventBuffer) { - vDevices := config.CreateVirtualDevices() +func initVirtualBuffers(config *config.ConfigParser) (map[string]*evdev.InputDevice, + map[string]*virtualdevice.EventBuffer, + map[*evdev.InputDevice]*virtualdevice.EventBuffer) { + + vDevices := config.InitVirtualDevices() if len(vDevices) == 0 { logger.Log("Warning: no virtual devices found in configuration. No rules will work.") } @@ -40,20 +43,11 @@ func initVirtualBuffers(config *config.ConfigParser) (map[string]*virtualdevice. vBuffersByName[name] = virtualdevice.NewEventBuffer(device) vBuffersByDevice[device] = vBuffersByName[name] } - return vBuffersByName, vBuffersByDevice -} - -// Extracts the evdev devices from a list of virtual buffers and returns them. -func getVirtualDevices(buffers map[string]*virtualdevice.EventBuffer) map[string]*evdev.InputDevice { - devices := make(map[string]*evdev.InputDevice) - for name, buffer := range buffers { - devices[name] = buffer.Device.(*evdev.InputDevice) - } - return devices + return vDevices, vBuffersByName, vBuffersByDevice } func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDevice { - pDeviceMap := config.ConnectPhysicalDevices() + pDeviceMap := config.InitPhysicalDevices() if len(pDeviceMap) == 0 { logger.Log("Warning: no physical devices found in configuration. No rules will work.") } @@ -77,13 +71,13 @@ func main() { logger.LogIfError(err, "Failed to initialize TTS") // Initialize virtual devices with event buffers - vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) + vDevicesByName, vBuffersByName, vBuffersByDevice := initVirtualBuffers(config) // Initialize physical devices pDevices := initPhysicalDevices(config) // Load the rules - rules, eventChannel, cancel, wg := loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) + rules, eventChannel, cancel, wg := loadRules(config, pDevices, vDevicesByName) // initialize the mode variable mode := config.GetModes()[0] @@ -139,7 +133,7 @@ func main() { wg.Wait() fmt.Println("Listeners exited. Parsing config.") config := readConfig(configDir) // reload the config - rules, eventChannel, cancel, wg = loadRules(config, pDevices, getVirtualDevices(vBuffersByName)) + rules, eventChannel, cancel, wg = loadRules(config, pDevices, vDevicesByName) fmt.Println("Config re-loaded. Only rule changes applied. Device and Mode changes require restart.") } @@ -159,7 +153,7 @@ func loadRules( ctx, cancel := context.WithCancel(context.Background()) // Initialize rules - rules := config.BuildRules(pDevices, vDevices) + rules := config.InitRules(pDevices, vDevices) logger.Logf("Created %d mapping rules.", len(rules)) // start listening for events on devices and timers diff --git a/internal/config/devices.go b/internal/config/devices.go index 9802bff..d933ed7 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -8,13 +8,13 @@ import ( "github.com/holoplot/go-evdev" ) -// CreateVirtualDevices will register any configured devices with type = virtual +// InitVirtualDevices will register any configured devices with type = virtual // using /dev/uinput, and return a map of those devices. // -// This function assumes you have already called Parse() on the config directory. +// This function assumes Parse() has been called. // -// This function should only be called once, unless you want to create duplicate devices for some reason. -func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice { +// This function should only be called once, unless we want to create duplicate devices for some reason. +func (parser *ConfigParser) InitVirtualDevices() map[string]*evdev.InputDevice { deviceMap := make(map[string]*evdev.InputDevice) for _, deviceConfig := range parser.config.Devices { @@ -22,6 +22,8 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice continue } + deviceConfig := deviceConfig.Config.(DeviceConfigVirtual) + name := fmt.Sprintf("joyful-%s", deviceConfig.Name) var capabilities map[evdev.EvType][]evdev.EvCode @@ -74,13 +76,13 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice return deviceMap } -// ConnectPhysicalDevices will create InputDevices corresponding to any registered +// InitPhysicalDevices will create InputDevices corresponding to any registered // devices with type = physical. // -// This function assumes you have already called Parse() on the config directory. +// This function assumes Parse() has been called. // // This function should only be called once. -func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevice { +func (parser *ConfigParser) InitPhysicalDevices() map[string]*evdev.InputDevice { deviceMap := make(map[string]*evdev.InputDevice) for _, deviceConfig := range parser.config.Devices { @@ -88,6 +90,8 @@ func (parser *ConfigParser) ConnectPhysicalDevices() map[string]*evdev.InputDevi continue } + deviceConfig := deviceConfig.Config.(DeviceConfigPhysical) + var infoName string var device *evdev.InputDevice var err error diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go index 7e8c2eb..203a015 100644 --- a/internal/config/make_rule_targets.go +++ b/internal/config/make_rule_targets.go @@ -8,7 +8,7 @@ import ( "github.com/holoplot/go-evdev" ) -func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetButton, error) { +func makeRuleTargetButton(targetConfig RuleTargetConfigButton, 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 +27,7 @@ func makeRuleTargetButton(targetConfig RuleTargetConfig, devs map[string]Device) ) } -func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetAxis, error) { +func makeRuleTargetAxis(targetConfig RuleTargetConfigAxis, devs map[string]Device) (*mappingrules.RuleTargetAxis, error) { device, ok := devs[targetConfig.Device] if !ok { return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) @@ -57,7 +57,7 @@ func makeRuleTargetAxis(targetConfig RuleTargetConfig, devs map[string]Device) ( ) } -func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]Device) (*mappingrules.RuleTargetRelaxis, error) { +func makeRuleTargetRelaxis(targetConfig RuleTargetConfigRelaxis, devs map[string]Device) (*mappingrules.RuleTargetRelaxis, error) { device, ok := devs[targetConfig.Device] if !ok { return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) @@ -72,11 +72,10 @@ func makeRuleTargetRelaxis(targetConfig RuleTargetConfig, devs map[string]Device targetConfig.Device, device, eventCode, - targetConfig.Inverted, ) } -func makeRuleTargetModeSelect(targetConfig RuleTargetConfig, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { +func makeRuleTargetModeSelect(targetConfig RuleTargetConfigModeSelect, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { if ok := validateModes(targetConfig.Modes, allModes); !ok { return nil, errors.New("undefined mode in mode select list") } @@ -92,7 +91,7 @@ func hasError(_ any, err error) bool { // 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) { +func calculateDeadzones(targetConfig RuleTargetConfigAxis, device Device, axis evdev.EvCode) (int32, int32, error) { var deadzoneStart, deadzoneEnd int32 deadzoneStart = 0 diff --git a/internal/config/make_rule_targets_test.go b/internal/config/make_rule_targets_test.go index 6e71fa6..7ee8fb8 100644 --- a/internal/config/make_rule_targets_test.go +++ b/internal/config/make_rule_targets_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "testing" "github.com/holoplot/go-evdev" @@ -12,7 +13,6 @@ type MakeRuleTargetsTests struct { suite.Suite devs map[string]Device deviceMock *DeviceMock - config RuleTargetConfig } type DeviceMock struct { @@ -47,198 +47,197 @@ func (t *MakeRuleTargetsTests) SetupSuite() { } } -func (t *MakeRuleTargetsTests) SetupSubTest() { - t.config = RuleTargetConfig{ - Device: "test", - } -} - func (t *MakeRuleTargetsTests) TestMakeRuleTargetButton() { + config := RuleTargetConfigButton{Device: "test"} + t.Run("Standard keycode", func() { - t.config.Button = "BTN_TRIGGER" - rule, err := makeRuleTargetButton(t.config, t.devs) + 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() { - t.config.Button = "0x2fd" - rule, err := makeRuleTargetButton(t.config, t.devs) + config.Button = "0x2fd" + rule, err := makeRuleTargetButton(config, t.devs) t.Nil(err) t.EqualValues(evdev.EvCode(0x2fd), rule.Button) }) t.Run("Index", func() { - t.config.Button = "3" - rule, err := makeRuleTargetButton(t.config, t.devs) + 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() { - t.config.Button = "74" - _, err := makeRuleTargetButton(t.config, t.devs) + config.Button = "74" + _, err := makeRuleTargetButton(config, t.devs) t.NotNil(err) }) t.Run("Un-prefixed keycode", func() { - t.config.Button = "pinkie" - rule, err := makeRuleTargetButton(t.config, t.devs) + config.Button = "pinkie" + rule, err := makeRuleTargetButton(config, t.devs) t.Nil(err) t.EqualValues(evdev.BTN_PINKIE, rule.Button) }) t.Run("Invalid keycode", func() { - t.config.Button = "foo" - _, err := makeRuleTargetButton(t.config, t.devs) + config.Button = "foo" + _, err := makeRuleTargetButton(config, t.devs) t.NotNil(err) }) } func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { - 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) - }) + codeTestCases := []struct { + input string + output evdev.EvCode + }{ + {"ABS_X", evdev.ABS_X}, + {"0x01", evdev.ABS_Y}, + {"x", evdev.ABS_X}, + } - 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) - }) + for _, tc := range codeTestCases { + t.Run(fmt.Sprintf("KeyCode %s", tc.input), func() { + config := RuleTargetConfigAxis{Device: "test"} + config.Axis = tc.input + rule, err := makeRuleTargetAxis(config, t.devs) + t.Nil(err) + t.EqualValues(tc.output, rule.Axis) - 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 code", func() { - t.config.Axis = "foo" - _, err := makeRuleTargetAxis(t.config, t.devs) + config := RuleTargetConfigAxis{Device: "test"} + config.Axis = "foo" + _, err := makeRuleTargetAxis(config, t.devs) t.NotNil(err) }) t.Run("Invalid deadzone", func() { - t.config.Axis = "x" - t.config.DeadzoneEnd = 100 - t.config.DeadzoneStart = 1000 - _, err := makeRuleTargetAxis(t.config, t.devs) + config := RuleTargetConfigAxis{Device: "test"} + config.Axis = "x" + config.DeadzoneEnd = 100 + config.DeadzoneStart = 1000 + _, err := makeRuleTargetAxis(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) - }) + relDeadzoneTestCases := []struct { + inCenter int32 + inSize int32 + outStart int32 + outEnd int32 + }{ + {5000, 1000, 4500, 5500}, + {0, 500, 0, 500}, + {10000, 500, 9500, 10000}, + } - 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) - }) + for _, tc := range relDeadzoneTestCases { + t.Run(fmt.Sprintf("Relative Deadzone %d +- %d", tc.inCenter, tc.inSize), func() { + config := RuleTargetConfigAxis{ + Device: "test", + Axis: "x", + DeadzoneCenter: tc.inCenter, + DeadzoneSize: tc.inSize, + } + rule, err := makeRuleTargetAxis(config, t.devs) - 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.Nil(err) + t.Equal(tc.outStart, rule.DeadzoneStart) + t.Equal(tc.outEnd, 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) + config := RuleTargetConfigAxis{ + Device: "test", + Axis: "x", + DeadzoneCenter: 20000, + DeadzoneSize: 500, + } + _, err := makeRuleTargetAxis(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) - }) + relDeadzonePercentTestCases := []struct { + inCenter int32 + inSizePercent int32 + outStart int32 + outEnd int32 + }{ + {5000, 10, 4500, 5500}, + {0, 10, 0, 1000}, + {10000, 10, 9000, 10000}, + } - 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) - }) + for _, tc := range relDeadzonePercentTestCases { + t.Run(fmt.Sprintf("Relative percent deadzone %d +- %d%%", tc.inCenter, tc.inSizePercent), func() { + config := RuleTargetConfigAxis{ + Device: "test", + Axis: "x", + DeadzoneCenter: tc.inCenter, + DeadzoneSizePercent: tc.inSizePercent, + } + rule, err := makeRuleTargetAxis(config, t.devs) - 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.Nil(err) + t.Equal(tc.outStart, rule.DeadzoneStart) + t.Equal(tc.outEnd, 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) + config := RuleTargetConfigAxis{ + Device: "test", + Axis: "x", + DeadzoneCenter: 20000, + DeadzoneSizePercent: 10, + } + _, err := makeRuleTargetAxis(config, t.devs) t.NotNil(err) }) } func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() { + config := RuleTargetConfigRelaxis{Device: "test"} + t.Run("Standard keycode", func() { - t.config.Axis = "REL_WHEEL" - rule, err := makeRuleTargetRelaxis(t.config, t.devs) + 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() { - t.config.Axis = "0x00" - rule, err := makeRuleTargetRelaxis(t.config, t.devs) + 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() { - t.config.Axis = "wheel" - rule, err := makeRuleTargetRelaxis(t.config, t.devs) + config.Axis = "wheel" + rule, err := makeRuleTargetRelaxis(config, t.devs) t.Nil(err) t.EqualValues(evdev.REL_WHEEL, rule.Axis) }) t.Run("Invalid keycode", func() { - t.config.Axis = "foo" - _, err := makeRuleTargetRelaxis(t.config, t.devs) + config.Axis = "foo" + _, err := makeRuleTargetRelaxis(config, t.devs) t.NotNil(err) }) t.Run("Incorrect axis type", func() { - t.config.Axis = "ABS_X" - _, err := makeRuleTargetRelaxis(t.config, t.devs) + config.Axis = "ABS_X" + _, err := makeRuleTargetRelaxis(config, t.devs) t.NotNil(err) }) } diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go index 647987c..9baf9d7 100644 --- a/internal/config/make_rules.go +++ b/internal/config/make_rules.go @@ -14,7 +14,7 @@ import ( // 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[][]rule // For very large rule-bases this may be helpful for staying performant. -func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, vInputDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule { +func (parser *ConfigParser) InitRules(pInputDevs map[string]*evdev.InputDevice, vInputDevs map[string]*evdev.InputDevice) []mappingrules.MappingRule { rules := make([]mappingrules.MappingRule, 0) modes := parser.GetModes() @@ -42,21 +42,21 @@ func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, switch strings.ToLower(ruleConfig.Type) { case RuleTypeButton: - newRule, err = makeMappingRuleButton(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleButton(ruleConfig.Config.(RuleConfigButton), pDevs, vDevs, base) case RuleTypeButtonCombo: - newRule, err = makeMappingRuleCombo(ruleConfig, pDevs, vDevs, base) - case RuleTypeLatched: - newRule, err = makeMappingRuleLatched(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleCombo(ruleConfig.Config.(RuleConfigButtonCombo), pDevs, vDevs, base) + case RuleTypeButtonLatched: + newRule, err = makeMappingRuleLatched(ruleConfig.Config.(RuleConfigButtonLatched), pDevs, vDevs, base) case RuleTypeAxis: - newRule, err = makeMappingRuleAxis(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleAxis(ruleConfig.Config.(RuleConfigAxis), pDevs, vDevs, base) case RuleTypeAxisCombined: - newRule, err = makeMappingRuleAxisCombined(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleAxisCombined(ruleConfig.Config.(RuleConfigAxisCombined), pDevs, vDevs, base) case RuleTypeAxisToButton: - newRule, err = makeMappingRuleAxisToButton(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleAxisToButton(ruleConfig.Config.(RuleConfigAxisToButton), pDevs, vDevs, base) case RuleTypeAxisToRelaxis: - newRule, err = makeMappingRuleAxisToRelaxis(ruleConfig, pDevs, vDevs, base) + newRule, err = makeMappingRuleAxisToRelaxis(ruleConfig.Config.(RuleConfigAxisToRelaxis), pDevs, vDevs, base) case RuleTypeModeSelect: - newRule, err = makeMappingRuleModeSelect(ruleConfig, pDevs, modes, base) + newRule, err = makeMappingRuleModeSelect(ruleConfig.Config.(RuleConfigModeSelect), pDevs, modes, base) default: err = fmt.Errorf("bad rule type '%s' for rule '%s'", ruleConfig.Type, ruleConfig.Name) } @@ -72,7 +72,14 @@ func (parser *ConfigParser) BuildRules(pInputDevs map[string]*evdev.InputDevice, return rules } -func makeMappingRuleButton(ruleConfig RuleConfig, +// TODO: how much of these functions could we fold into the unmarshaling logic itself? The main problem +// is that we don't have access to the device maps in those functions... could we set device names +// as stand-ins and do a post-processing pass that *just* handles device linking and possibly mode +// checking? +// +// In other words - can we unmarshal the config directly into our target structs and remove most of +// this library? +func makeMappingRuleButton(ruleConfig RuleConfigButton, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButton, error) { @@ -90,7 +97,7 @@ func makeMappingRuleButton(ruleConfig RuleConfig, return mappingrules.NewMappingRuleButton(base, input, output), nil } -func makeMappingRuleCombo(ruleConfig RuleConfig, +func makeMappingRuleCombo(ruleConfig RuleConfigButtonCombo, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonCombo, error) { @@ -112,7 +119,7 @@ func makeMappingRuleCombo(ruleConfig RuleConfig, return mappingrules.NewMappingRuleButtonCombo(base, inputs, output), nil } -func makeMappingRuleLatched(ruleConfig RuleConfig, +func makeMappingRuleLatched(ruleConfig RuleConfigButtonLatched, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonLatched, error) { @@ -130,7 +137,7 @@ func makeMappingRuleLatched(ruleConfig RuleConfig, return mappingrules.NewMappingRuleButtonLatched(base, input, output), nil } -func makeMappingRuleAxis(ruleConfig RuleConfig, +func makeMappingRuleAxis(ruleConfig RuleConfigAxis, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxis, error) { @@ -148,7 +155,7 @@ func makeMappingRuleAxis(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxis(base, input, output), nil } -func makeMappingRuleAxisCombined(ruleConfig RuleConfig, +func makeMappingRuleAxisCombined(ruleConfig RuleConfigAxisCombined, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisCombined, error) { @@ -171,7 +178,7 @@ func makeMappingRuleAxisCombined(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxisCombined(base, inputLower, inputUpper, output), nil } -func makeMappingRuleAxisToButton(ruleConfig RuleConfig, +func makeMappingRuleAxisToButton(ruleConfig RuleConfigAxisToButton, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToButton, error) { @@ -189,7 +196,7 @@ func makeMappingRuleAxisToButton(ruleConfig RuleConfig, return mappingrules.NewMappingRuleAxisToButton(base, input, output, ruleConfig.RepeatRateMin, ruleConfig.RepeatRateMax), nil } -func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, +func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfigAxisToRelaxis, pDevs map[string]Device, vDevs map[string]Device, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToRelaxis, error) { @@ -211,7 +218,7 @@ func makeMappingRuleAxisToRelaxis(ruleConfig RuleConfig, ruleConfig.Increment), nil } -func makeMappingRuleModeSelect(ruleConfig RuleConfig, +func makeMappingRuleModeSelect(ruleConfig RuleConfigModeSelect, pDevs map[string]Device, modes []string, base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleModeSelect, error) { diff --git a/internal/config/schema.go b/internal/config/schema.go index 1ea3527..ad91f28 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,79 +1,213 @@ // 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 +import ( + "fmt" +) + type Config struct { - Devices []DeviceConfig `yaml:"devices"` - Modes []string `yaml:"modes,omitempty"` - Rules []RuleConfig `yaml:"rules"` + Devices []DeviceConfig + Modes []string + Rules []RuleConfig } +// These top-level structs use custom unmarshaling to unpack each available sub-type type DeviceConfig struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - DeviceName string `yaml:"device_name,omitempty"` - DevicePath string `yaml:"device_path,omitempty"` - Preset string `yaml:"preset,omitempty"` - NumButtons int `yaml:"num_buttons,omitempty"` - NumAxes int `yaml:"num_axes,omitempty"` - NumRelativeAxes int `yaml:"num_rel_axes"` - Buttons []string `yaml:"buttons,omitempty"` - Axes []string `yaml:"axes,omitempty"` - RelativeAxes []string `yaml:"rel_axes,omitempty"` - Lock bool `yaml:"lock,omitempty"` + Type string + Config interface{} } type RuleConfig struct { - Name string `yaml:"name,omitempty"` - Type string `yaml:"type"` - Input RuleTargetConfig `yaml:"input,omitempty"` - InputLower RuleTargetConfig `yaml:"input_lower,omitempty"` - InputUpper RuleTargetConfig `yaml:"input_upper,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 string + Name string + Modes []string + Config interface{} } -type RuleTargetConfig struct { - 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"` +type DeviceConfigPhysical struct { + Name string + DeviceName string `yaml:"device_name,omitempty"` + DevicePath string `yaml:"device_path,omitempty"` + Lock bool +} + +// TODO: configure custom unmarshaling so we can overload Buttons, Axes, and RelativeAxes... +type DeviceConfigVirtual struct { + Name string + Preset string + NumButtons int `yaml:"num_buttons,omitempty"` + NumAxes int `yaml:"num_axes,omitempty"` + NumRelativeAxes int `yaml:"num_rel_axes"` + Buttons []string + Axes []string + RelativeAxes []string `yaml:"rel_axes,omitempty"` +} + +type RuleConfigButton struct { + Input RuleTargetConfigButton + Output RuleTargetConfigButton +} + +type RuleConfigButtonCombo struct { + Inputs []RuleTargetConfigButton + Output RuleTargetConfigButton +} + +type RuleConfigButtonLatched struct { + Input RuleTargetConfigButton + Output RuleTargetConfigButton +} + +type RuleConfigAxis struct { + Input RuleTargetConfigAxis + Output RuleTargetConfigAxis +} + +type RuleConfigAxisCombined struct { + InputLower RuleTargetConfigAxis `yaml:"input_lower,omitempty"` + InputUpper RuleTargetConfigAxis `yaml:"input_upper,omitempty"` + Output RuleTargetConfigAxis +} + +type RuleConfigAxisToButton struct { + RepeatRateMin int `yaml:"repeat_rate_min,omitempty"` + RepeatRateMax int `yaml:"repeat_rate_max,omitempty"` + Input RuleTargetConfigAxis + Output RuleTargetConfigButton +} + +type RuleConfigAxisToRelaxis struct { + RepeatRateMin int `yaml:"repeat_rate_min"` + RepeatRateMax int `yaml:"repeat_rate_max"` + Increment int + Input RuleTargetConfigAxis + Output RuleTargetConfigRelaxis +} + +type RuleConfigModeSelect struct { + Input RuleTargetConfigButton + Output RuleTargetConfigModeSelect +} + +type RuleTargetConfigButton struct { + Device string + Button string + Inverted bool +} + +type RuleTargetConfigAxis struct { + Device string + Axis string + 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 +} + +type RuleTargetConfigRelaxis struct { + Device string + Axis string +} + +type RuleTargetConfigModeSelect struct { + Modes []string +} + +func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { + metaConfig := &struct { + Type string + }{} + err := unmarshal(metaConfig) + if err != nil { + return err + } + dc.Type = metaConfig.Type + + err = nil + switch metaConfig.Type { + case DeviceTypePhysical: + config := DeviceConfigPhysical{} + err = unmarshal(&config) + dc.Config = config + case DeviceTypeVirtual: + config := DeviceConfigVirtual{} + err = unmarshal(&config) + dc.Config = config + default: + err = fmt.Errorf("invalid device type '%s'", dc.Type) + } + return err +} + +func (dc *RuleConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { + metaConfig := &struct { + Type string + Name string + Modes []string + }{} + err := unmarshal(metaConfig) + if err != nil { + return err + } + dc.Type = metaConfig.Type + dc.Name = metaConfig.Name + dc.Modes = metaConfig.Modes + + switch dc.Type { + case RuleTypeButton: + config := RuleConfigButton{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeButtonCombo: + config := RuleConfigButtonCombo{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeButtonLatched: + config := RuleConfigButtonLatched{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxis: + config := RuleConfigAxis{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisCombined: + config := RuleConfigAxisCombined{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisToButton: + config := RuleConfigAxisToButton{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisToRelaxis: + config := RuleConfigAxisToRelaxis{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeModeSelect: + config := RuleConfigModeSelect{} + err = unmarshal(&config) + dc.Config = config + default: + err = fmt.Errorf("invalid rule type '%s'", dc.Type) + } + + return err } // TODO: custom yaml unmarshaling is obtuse; do we really need to do all of this work // just to set a single default value? -func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { +func (dc *DeviceConfigPhysical) UnmarshalYAML(unmarshal func(data interface{}) error) error { var raw struct { - Name string - Type string - DeviceName string `yaml:"device_name"` - DevicePath string `yaml:"device_path"` - Preset string - NumButtons int `yaml:"num_buttons"` - NumAxes int `yaml:"num_axes"` - NumRelativeAxes int `yaml:"num_rel_axes"` - Buttons []string - Axes []string - RelativeAxes []string `yaml:"relative_axes"` - Lock bool `yaml:"lock,omitempty"` + Name string + DeviceName string `yaml:"device_name"` + DevicePath string `yaml:"device_path"` + Lock bool `yaml:"lock,omitempty"` } + + // Set non-standard defaults raw.Lock = true err := unmarshal(&raw) @@ -81,19 +215,11 @@ func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) er return err } - *dc = DeviceConfig{ - Name: raw.Name, - Type: raw.Type, - DeviceName: raw.DeviceName, - DevicePath: raw.DevicePath, - Preset: raw.Preset, - NumButtons: raw.NumButtons, - NumAxes: raw.NumAxes, - NumRelativeAxes: raw.NumRelativeAxes, - Buttons: raw.Buttons, - Axes: raw.Axes, - RelativeAxes: raw.RelativeAxes, - Lock: raw.Lock, + *dc = DeviceConfigPhysical{ + Name: raw.Name, + DeviceName: raw.DeviceName, + DevicePath: raw.DevicePath, + Lock: raw.Lock, } return nil } diff --git a/internal/config/variables.go b/internal/config/variables.go index e4e0bf0..6e62977 100644 --- a/internal/config/variables.go +++ b/internal/config/variables.go @@ -15,12 +15,12 @@ const ( RuleTypeButton = "button" RuleTypeButtonCombo = "button-combo" - RuleTypeLatched = "button-latched" + RuleTypeButtonLatched = "button-latched" RuleTypeAxis = "axis" RuleTypeAxisCombined = "axis-combined" - RuleTypeModeSelect = "mode-select" RuleTypeAxisToButton = "axis-to-button" RuleTypeAxisToRelaxis = "axis-to-relaxis" + RuleTypeModeSelect = "mode-select" CodePrefixButton = "BTN" CodePrefixKey = "KEY" diff --git a/internal/mappingrules/rule_target_relaxis.go b/internal/mappingrules/rule_target_relaxis.go index 8de8c0b..1942c4b 100644 --- a/internal/mappingrules/rule_target_relaxis.go +++ b/internal/mappingrules/rule_target_relaxis.go @@ -8,19 +8,16 @@ type RuleTargetRelaxis struct { DeviceName string Device Device Axis evdev.EvCode - Inverted bool } func NewRuleTargetRelaxis(device_name string, device Device, - axis evdev.EvCode, - inverted bool) (*RuleTargetRelaxis, error) { + axis evdev.EvCode) (*RuleTargetRelaxis, error) { return &RuleTargetRelaxis{ DeviceName: device_name, Device: device, Axis: axis, - Inverted: inverted, }, nil } From 8d2b15a7c8223af91dc190aa3aa9c8a5e7fe494a Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 12 Aug 2025 00:57:11 +0000 Subject: [PATCH 11/12] Move initialization code closer to the appropriate structs. (#17) Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/17 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/evinfo/main.go | 5 +- cmd/joyful/config.go | 147 +++++++++++ cmd/joyful/main.go | 105 ++------ internal/config/configparser.go | 77 ------ internal/config/devices.go | 221 ---------------- internal/config/interfaces.go | 7 - internal/config/make_rule_targets.go | 145 ----------- internal/config/make_rules.go | 237 ------------------ internal/config/modes.go | 19 -- internal/configparser/configparser.go | 67 +++++ internal/{config => configparser}/schema.go | 2 +- internal/configparser/variables.go | 15 ++ internal/{config => eventcodes}/codes.go | 13 +- internal/{config => eventcodes}/codes_test.go | 8 +- internal/eventcodes/variables.go | 90 +++++++ .../init_rule_targets_test.go} | 59 ++--- internal/mappingrules/init_rules.go | 79 ++++++ internal/mappingrules/mapping_rule_axis.go | 23 +- .../mapping_rule_axis_combined.go | 24 +- .../mapping_rule_axis_combined_test.go | 17 +- .../mapping_rule_axis_to_button.go | 25 +- .../mapping_rule_axis_to_button_test.go | 77 +++--- .../mapping_rule_axis_to_relaxis.go | 28 ++- internal/mappingrules/mapping_rule_button.go | 25 +- .../mappingrules/mapping_rule_button_combo.go | 29 ++- .../mapping_rule_button_latched.go | 25 +- .../mappingrules/mapping_rule_button_test.go | 12 +- .../mappingrules/mapping_rule_mode_select.go | 26 +- internal/mappingrules/math.go | 13 + internal/mappingrules/rule_target_axis.go | 73 ++++++ internal/mappingrules/rule_target_button.go | 27 +- .../mappingrules/rule_target_modeselect.go | 9 + internal/mappingrules/rule_target_relaxis.go | 26 +- internal/mappingrules/variables.go | 12 + internal/virtualdevice/cleanup.go | 35 --- internal/virtualdevice/eventbuffer.go | 8 +- internal/virtualdevice/eventbuffer_test.go | 105 ++++---- internal/virtualdevice/init.go | 165 ++++++++++++ .../init_test.go} | 14 +- .../{config => virtualdevice}/variables.go | 102 +------- 40 files changed, 1087 insertions(+), 1109 deletions(-) create mode 100644 cmd/joyful/config.go delete mode 100644 internal/config/configparser.go delete mode 100644 internal/config/devices.go delete mode 100644 internal/config/interfaces.go delete mode 100644 internal/config/make_rule_targets.go delete mode 100644 internal/config/make_rules.go delete mode 100644 internal/config/modes.go create mode 100644 internal/configparser/configparser.go rename internal/{config => configparser}/schema.go (99%) create mode 100644 internal/configparser/variables.go rename internal/{config => eventcodes}/codes.go (81%) rename internal/{config => eventcodes}/codes_test.go (94%) create mode 100644 internal/eventcodes/variables.go rename internal/{config/make_rule_targets_test.go => mappingrules/init_rule_targets_test.go} (71%) create mode 100644 internal/mappingrules/init_rules.go create mode 100644 internal/mappingrules/variables.go delete mode 100644 internal/virtualdevice/cleanup.go create mode 100644 internal/virtualdevice/init.go rename internal/{config/devices_test.go => virtualdevice/init_test.go} (91%) rename internal/{config => virtualdevice}/variables.go (71%) diff --git a/cmd/evinfo/main.go b/cmd/evinfo/main.go index c2cc8f0..12a0ecb 100644 --- a/cmd/evinfo/main.go +++ b/cmd/evinfo/main.go @@ -5,7 +5,8 @@ import ( "slices" // TODO: using config here feels like bad coupling... ButtonFromIndex might need a refactor / move - "git.annabunches.net/annabunches/joyful/internal/config" + + "git.annabunches.net/annabunches/joyful/internal/eventcodes" "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" flag "github.com/spf13/pflag" @@ -20,7 +21,7 @@ func isJoystickLike(device *evdev.InputDevice) bool { if slices.Contains(types, evdev.EV_KEY) { buttons := device.CapableEvents(evdev.EV_KEY) - for _, code := range config.ButtonFromIndex { + for _, code := range eventcodes.ButtonFromIndex { if slices.Contains(buttons, code) { return true } diff --git a/cmd/joyful/config.go b/cmd/joyful/config.go new file mode 100644 index 0000000..2b43380 --- /dev/null +++ b/cmd/joyful/config.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "strings" + "sync" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/logger" + "git.annabunches.net/annabunches/joyful/internal/mappingrules" + "git.annabunches.net/annabunches/joyful/internal/virtualdevice" + "github.com/holoplot/go-evdev" +) + +func initPhysicalDevices(conf *configparser.Config) map[string]*evdev.InputDevice { + pDeviceMap := make(map[string]*evdev.InputDevice) + + for _, devConfig := range conf.Devices { + if strings.ToLower(devConfig.Type) != configparser.DeviceTypePhysical { + continue + } + + innerConfig := devConfig.Config.(configparser.DeviceConfigPhysical) + name, device, err := initPhysicalDevice(innerConfig) + if err != nil { + logger.LogError(err, "Failed to initialize physical device") + continue + } + + pDeviceMap[name] = device + + displayName := innerConfig.DeviceName + if innerConfig.DevicePath != "" { + displayName = innerConfig.DevicePath + } + logger.Logf("Connected to '%s' as '%s'", displayName, name) + } + + if len(pDeviceMap) == 0 { + logger.Log("Warning: no physical devices found in configuration. No rules will work.") + } + return pDeviceMap +} + +func initPhysicalDevice(config configparser.DeviceConfigPhysical) (string, *evdev.InputDevice, error) { + name := config.Name + var device *evdev.InputDevice + var err error + + if config.DevicePath != "" { + device, err = evdev.Open(config.DevicePath) + } else { + device, err = evdev.OpenByName(config.DeviceName) + } + + if config.Lock && err == nil { + grabErr := device.Grab() + logger.LogIfError(grabErr, "Failed to lock device for exclusive access") + } + + return name, device, err +} + +// TODO: juggling all these maps is a pain. Is there a better solution here? +func initVirtualBuffers(config *configparser.Config) (map[string]*evdev.InputDevice, + map[string]*virtualdevice.EventBuffer, + map[*evdev.InputDevice]*virtualdevice.EventBuffer) { + + vDevicesByName := make(map[string]*evdev.InputDevice) + vBuffersByName := make(map[string]*virtualdevice.EventBuffer) + vBuffersByDevice := make(map[*evdev.InputDevice]*virtualdevice.EventBuffer) + + for _, devConfig := range config.Devices { + if strings.ToLower(devConfig.Type) != configparser.DeviceTypeVirtual { + continue + } + + vConfig := devConfig.Config.(configparser.DeviceConfigVirtual) + buffer, err := virtualdevice.NewEventBuffer(vConfig) + if err != nil { + logger.LogError(err, "Failed to create virtual device, skipping") + continue + } + vDevicesByName[buffer.Name] = buffer.Device.(*evdev.InputDevice) + vBuffersByName[buffer.Name] = buffer + vBuffersByDevice[buffer.Device.(*evdev.InputDevice)] = buffer + } + + if len(vDevicesByName) == 0 { + logger.Log("Warning: no virtual devices found in configuration. No rules will work.") + } + + return vDevicesByName, vBuffersByName, vBuffersByDevice +} + +// 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[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[][]rule +// For very large rule-bases this may be helpful for staying performant. +func loadRules( + config *configparser.Config, + pDevices map[string]*evdev.InputDevice, + vDevices map[string]*evdev.InputDevice, + modes []string) ([]mappingrules.MappingRule, <-chan ChannelEvent, func(), *sync.WaitGroup) { + + var wg sync.WaitGroup + eventChannel := make(chan ChannelEvent, 1000) + ctx, cancel := context.WithCancel(context.Background()) + + // Setup device mapping for the mappingrules package + pDevs := mappingrules.ConvertDeviceMap(pDevices) + vDevs := mappingrules.ConvertDeviceMap(vDevices) + + // Initialize rules + rules := make([]mappingrules.MappingRule, 0) + for _, ruleConfig := range config.Rules { + newRule, err := mappingrules.NewRule(ruleConfig, pDevs, vDevs, modes) + if err != nil { + logger.LogError(err, "Failed to create rule, skipping") + continue + } + rules = append(rules, newRule) + } + + logger.Logf("Created %d mapping rules.", len(rules)) + + // start listening for events on devices and timers + for _, device := range pDevices { + wg.Add(1) + go eventWatcher(device, eventChannel, ctx, &wg) + } + + timerCount := 0 + for _, rule := range rules { + if timedRule, ok := rule.(mappingrules.TimedEventEmitter); ok { + wg.Add(1) + go timerWatcher(timedRule, eventChannel, ctx, &wg) + timerCount++ + } + } + logger.Logf("Registered %d timers.", timerCount) + + go consoleWatcher(eventChannel) + + return rules, eventChannel, cancel, &wg +} diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index f6cf6de..bcdeccc 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -1,19 +1,15 @@ package main import ( - "context" "fmt" "os" "strings" - "sync" "github.com/holoplot/go-evdev" flag "github.com/spf13/pflag" - "git.annabunches.net/annabunches/joyful/internal/config" + "git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/logger" - "git.annabunches.net/annabunches/joyful/internal/mappingrules" - "git.annabunches.net/annabunches/joyful/internal/virtualdevice" ) func getConfigDir(dir string) string { @@ -21,39 +17,6 @@ func getConfigDir(dir string) string { return os.ExpandEnv(configDir) } -func readConfig(configDir string) *config.ConfigParser { - parser := &config.ConfigParser{} - err := parser.Parse(configDir) - logger.FatalIfError(err, "Failed to parse config") - return parser -} - -func initVirtualBuffers(config *config.ConfigParser) (map[string]*evdev.InputDevice, - map[string]*virtualdevice.EventBuffer, - map[*evdev.InputDevice]*virtualdevice.EventBuffer) { - - vDevices := config.InitVirtualDevices() - if len(vDevices) == 0 { - logger.Log("Warning: no virtual devices found in configuration. No rules will work.") - } - - vBuffersByName := make(map[string]*virtualdevice.EventBuffer) - vBuffersByDevice := make(map[*evdev.InputDevice]*virtualdevice.EventBuffer) - for name, device := range vDevices { - vBuffersByName[name] = virtualdevice.NewEventBuffer(device) - vBuffersByDevice[device] = vBuffersByName[name] - } - return vDevices, vBuffersByName, vBuffersByDevice -} - -func initPhysicalDevices(config *config.ConfigParser) map[string]*evdev.InputDevice { - pDeviceMap := config.InitPhysicalDevices() - if len(pDeviceMap) == 0 { - logger.Log("Warning: no physical devices found in configuration. No rules will work.") - } - return pDeviceMap -} - func main() { // parse command-line var configFlag string @@ -64,7 +27,8 @@ func main() { // parse configs configDir := getConfigDir(configFlag) - config := readConfig(configDir) + config, err := configparser.ParseConfig(configDir) + logger.FatalIfError(err, "Failed to parse configuration") // initialize TTS tts, err := newTTS(ttsOps) @@ -76,20 +40,26 @@ func main() { // Initialize physical devices pDevices := initPhysicalDevices(config) - // Load the rules - rules, eventChannel, cancel, wg := loadRules(config, pDevices, vDevicesByName) + // initialize the mode variables + var mode string + modes := config.Modes + if len(modes) == 0 { + mode = "*" + } else { + mode = config.Modes[0] + } - // initialize the mode variable - mode := config.GetModes()[0] + // Load the rules + rules, eventChannel, cancel, wg := loadRules(config, pDevices, vDevicesByName, modes) // initialize TTS phrases for modes - for _, m := range config.GetModes() { + for _, m := range modes { tts.AddMessage(m) logger.LogDebugf("Added TTS message '%s'", m) } fmt.Println("Joyful Running! Press Ctrl+C to quit. Press Enter to reload rules.") - if len(config.GetModes()) > 1 { + if len(modes) > 0 { logger.Logf("Initial mode set to '%s'", mode) } @@ -127,13 +97,18 @@ func main() { case ChannelEventReload: // stop existing channels + config, err := configparser.ParseConfig(configDir) // reload the config + if err != nil { + logger.LogError(err, "Failed to parse config, no changes made") + continue + } + fmt.Println("Reloading rules.") cancel() fmt.Println("Waiting for existing listeners to exit. Provide input from each of your devices.") wg.Wait() - fmt.Println("Listeners exited. Parsing config.") - config := readConfig(configDir) // reload the config - rules, eventChannel, cancel, wg = loadRules(config, pDevices, vDevicesByName) + fmt.Println("Listeners exited. Loading new rules.") + rules, eventChannel, cancel, wg = loadRules(config, pDevices, vDevicesByName, modes) fmt.Println("Config re-loaded. Only rule changes applied. Device and Mode changes require restart.") } @@ -142,37 +117,3 @@ func main() { } } } - -func loadRules( - config *config.ConfigParser, - pDevices map[string]*evdev.InputDevice, - vDevices map[string]*evdev.InputDevice) ([]mappingrules.MappingRule, <-chan ChannelEvent, func(), *sync.WaitGroup) { - - var wg sync.WaitGroup - eventChannel := make(chan ChannelEvent, 1000) - ctx, cancel := context.WithCancel(context.Background()) - - // Initialize rules - rules := config.InitRules(pDevices, vDevices) - logger.Logf("Created %d mapping rules.", len(rules)) - - // start listening for events on devices and timers - for _, device := range pDevices { - wg.Add(1) - go eventWatcher(device, eventChannel, ctx, &wg) - } - - timerCount := 0 - for _, rule := range rules { - if timedRule, ok := rule.(mappingrules.TimedEventEmitter); ok { - wg.Add(1) - go timerWatcher(timedRule, eventChannel, ctx, &wg) - timerCount++ - } - } - logger.Logf("Registered %d timers.", timerCount) - - go consoleWatcher(eventChannel) - - return rules, eventChannel, cancel, &wg -} diff --git a/internal/config/configparser.go b/internal/config/configparser.go deleted file mode 100644 index 564c00d..0000000 --- a/internal/config/configparser.go +++ /dev/null @@ -1,77 +0,0 @@ -// The ConfigParser is the main structure you'll interact with when using this package. -// -// Example usage: -// config := &config.ConfigParser{} -// config.Parse() -// virtualDevices := config.CreateVirtualDevices() -// physicalDevices := config.ConnectVirtualDevices() -// modes := config.GetModes() -// rules := config.BuildRules(physicalDevices, virtualDevices, modes) -// -// nb: there are methods defined on ConfigParser in other files in this package! - -package config - -import ( - "errors" - "os" - "path/filepath" - "strings" - - "git.annabunches.net/annabunches/joyful/internal/logger" - "github.com/goccy/go-yaml" -) - -type ConfigParser struct { - config Config -} - -// Parse all the config files and store the config data for further use -func (parser *ConfigParser) Parse(directory string) error { - parser.config = Config{} - - // Find the config files in the directory - dirEntries, err := os.ReadDir(directory) - if err != nil { - err = os.Mkdir(directory, 0755) - if err != nil { - return errors.New("Failed to create config directory at " + directory) - } - } - - // Open each yaml file and add its contents to the global config - for _, file := range dirEntries { - name := file.Name() - if file.IsDir() || !(strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")) { - continue - } - - filePath := filepath.Join(directory, name) - if strings.HasSuffix(filePath, ".yaml") || strings.HasSuffix(filePath, ".yml") { - data, err := os.ReadFile(filePath) - if err != nil { - logger.LogError(err, "Error while opening config file") - continue - } - newConfig := Config{} - err = yaml.Unmarshal(data, &newConfig) - logger.LogIfError(err, "Error parsing YAML") - parser.config.Rules = append(parser.config.Rules, newConfig.Rules...) - parser.config.Devices = append(parser.config.Devices, newConfig.Devices...) - parser.config.Modes = append(parser.config.Modes, newConfig.Modes...) - } - } - - if len(parser.config.Devices) == 0 { - return errors.New("Found no devices in configuration. Please add configuration at " + directory) - } - - return nil -} - -func (parser *ConfigParser) GetModes() []string { - if len(parser.config.Modes) == 0 { - return []string{"*"} - } - return parser.config.Modes -} diff --git a/internal/config/devices.go b/internal/config/devices.go deleted file mode 100644 index d933ed7..0000000 --- a/internal/config/devices.go +++ /dev/null @@ -1,221 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - "git.annabunches.net/annabunches/joyful/internal/logger" - "github.com/holoplot/go-evdev" -) - -// InitVirtualDevices will register any configured devices with type = virtual -// using /dev/uinput, and return a map of those devices. -// -// This function assumes Parse() has been called. -// -// This function should only be called once, unless we want to create duplicate devices for some reason. -func (parser *ConfigParser) InitVirtualDevices() map[string]*evdev.InputDevice { - deviceMap := make(map[string]*evdev.InputDevice) - - for _, deviceConfig := range parser.config.Devices { - if strings.ToLower(deviceConfig.Type) != DeviceTypeVirtual { - continue - } - - deviceConfig := deviceConfig.Config.(DeviceConfigVirtual) - - name := fmt.Sprintf("joyful-%s", deviceConfig.Name) - - var capabilities map[evdev.EvType][]evdev.EvCode - - // todo: add tests for presets - switch deviceConfig.Preset { - case DevicePresetGamepad: - capabilities = CapabilitiesPresetGamepad - case DevicePresetKeyboard: - capabilities = CapabilitiesPresetKeyboard - case DevicePresetJoystick: - capabilities = CapabilitiesPresetJoystick - case DevicePresetMouse: - capabilities = CapabilitiesPresetMouse - default: - capabilities = map[evdev.EvType][]evdev.EvCode{ - evdev.EV_KEY: makeButtons(deviceConfig.NumButtons, deviceConfig.Buttons), - evdev.EV_ABS: makeAxes(deviceConfig.NumAxes, deviceConfig.Axes), - evdev.EV_REL: makeRelativeAxes(deviceConfig.NumRelativeAxes, deviceConfig.RelativeAxes), - } - } - - device, err := evdev.CreateDevice( - name, - // TODO: who knows what these should actually be - evdev.InputID{ - BusType: 0x03, - Vendor: 0x4711, - Product: 0x0816, - Version: 1, - }, - capabilities, - ) - - if err != nil { - logger.LogIfError(err, "Failed to create virtual device") - continue - } - - deviceMap[deviceConfig.Name] = device - logger.Log(fmt.Sprintf( - "Created virtual device '%s' with %d buttons, %d axes, and %d relative axes", - name, - len(capabilities[evdev.EV_KEY]), - len(capabilities[evdev.EV_ABS]), - len(capabilities[evdev.EV_REL]), - )) - } - - return deviceMap -} - -// InitPhysicalDevices will create InputDevices corresponding to any registered -// devices with type = physical. -// -// This function assumes Parse() has been called. -// -// This function should only be called once. -func (parser *ConfigParser) InitPhysicalDevices() map[string]*evdev.InputDevice { - deviceMap := make(map[string]*evdev.InputDevice) - - for _, deviceConfig := range parser.config.Devices { - if strings.ToLower(deviceConfig.Type) != DeviceTypePhysical { - continue - } - - deviceConfig := deviceConfig.Config.(DeviceConfigPhysical) - - var infoName string - var device *evdev.InputDevice - var err error - - if deviceConfig.DevicePath != "" { - infoName = deviceConfig.DevicePath - device, err = evdev.Open(deviceConfig.DevicePath) - } else { - infoName = deviceConfig.DeviceName - device, err = evdev.OpenByName(deviceConfig.DeviceName) - } - - if err != nil { - logger.LogError(err, "Failed to open physical device, skipping. Confirm the device name or path with 'evinfo'") - continue - } - - if deviceConfig.Lock { - logger.LogDebugf("Locking device '%s'", infoName) - err := device.Grab() - if err != nil { - logger.LogError(err, "Failed to grab device for exclusive access") - } - } - - logger.Log(fmt.Sprintf("Connected to '%s' as '%s'", infoName, deviceConfig.Name)) - deviceMap[deviceConfig.Name] = device - } - - return deviceMap -} - -// TODO: these functions have a lot of duplication; we need to figure out how to refactor it cleanly -// without losing logging context... -func makeButtons(numButtons int, buttonList []string) []evdev.EvCode { - if numButtons > 0 && len(buttonList) > 0 { - logger.Log("'num_buttons' and 'buttons' both specified, ignoring 'num_buttons'") - } - - if numButtons > VirtualDeviceMaxButtons { - numButtons = VirtualDeviceMaxButtons - logger.Logf("Limiting virtual device buttons to %d", VirtualDeviceMaxButtons) - } - - if len(buttonList) > 0 { - buttons := make([]evdev.EvCode, 0, len(buttonList)) - for _, codeStr := range buttonList { - code, err := parseCode(codeStr, "BTN") - if err != nil { - logger.LogError(err, "Failed to create button, skipping") - continue - } - buttons = append(buttons, code) - } - return buttons - } - - buttons := make([]evdev.EvCode, numButtons) - - for i := 0; i < numButtons; i++ { - buttons[i] = ButtonFromIndex[i] - } - - return buttons -} - -func makeAxes(numAxes int, axisList []string) []evdev.EvCode { - if numAxes > 0 && len(axisList) > 0 { - logger.Log("'num_axes' and 'axes' both specified, ignoring 'num_axes'") - } - - if len(axisList) > 0 { - axes := make([]evdev.EvCode, 0, len(axisList)) - for _, codeStr := range axisList { - code, err := parseCode(codeStr, "ABS") - if err != nil { - logger.LogError(err, "Failed to create axis, skipping") - continue - } - axes = append(axes, code) - } - return axes - } - - if numAxes > 8 { - numAxes = 8 - logger.Log("Limiting virtual device axes to 8") - } - - axes := make([]evdev.EvCode, numAxes) - for i := 0; i < numAxes; i++ { - axes[i] = evdev.EvCode(i) - } - - return axes -} - -func makeRelativeAxes(numAxes int, axisList []string) []evdev.EvCode { - if numAxes > 0 && len(axisList) > 0 { - logger.Log("'num_rel_axes' and 'rel_axes' both specified, ignoring 'num_rel_axes'") - } - - if len(axisList) > 0 { - axes := make([]evdev.EvCode, 0, len(axisList)) - for _, codeStr := range axisList { - code, err := parseCode(codeStr, "REL") - if err != nil { - logger.LogError(err, "Failed to create axis, skipping") - continue - } - axes = append(axes, code) - } - return axes - } - - if numAxes > 10 { - numAxes = 10 - logger.Log("Limiting virtual device relative axes to 10") - } - - axes := make([]evdev.EvCode, numAxes) - for i := 0; i < numAxes; i++ { - axes[i] = evdev.EvCode(i) - } - - return axes -} diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go deleted file mode 100644 index 0b9fa42..0000000 --- a/internal/config/interfaces.go +++ /dev/null @@ -1,7 +0,0 @@ -package config - -import "github.com/holoplot/go-evdev" - -type Device interface { - AbsInfos() (map[evdev.EvCode]evdev.AbsInfo, error) -} diff --git a/internal/config/make_rule_targets.go b/internal/config/make_rule_targets.go deleted file mode 100644 index 203a015..0000000 --- a/internal/config/make_rule_targets.go +++ /dev/null @@ -1,145 +0,0 @@ -package config - -import ( - "errors" - "fmt" - - "git.annabunches.net/annabunches/joyful/internal/mappingrules" - "github.com/holoplot/go-evdev" -) - -func makeRuleTargetButton(targetConfig RuleTargetConfigButton, devs map[string]Device) (*mappingrules.RuleTargetButton, error) { - device, ok := devs[targetConfig.Device] - if !ok { - return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) - } - - eventCode, err := parseCodeButton(targetConfig.Button) - if err != nil { - return nil, err - } - - return mappingrules.NewRuleTargetButton( - targetConfig.Device, - device, - eventCode, - targetConfig.Inverted, - ) -} - -func makeRuleTargetAxis(targetConfig RuleTargetConfigAxis, devs map[string]Device) (*mappingrules.RuleTargetAxis, error) { - device, ok := devs[targetConfig.Device] - if !ok { - return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) - } - - if targetConfig.DeadzoneEnd < targetConfig.DeadzoneStart { - return nil, errors.New("deadzone_end must be greater than deadzone_start") - } - - eventCode, err := parseCode(targetConfig.Axis, CodePrefixAxis) - if err != nil { - 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, - deadzoneStart, - deadzoneEnd, - ) -} - -func makeRuleTargetRelaxis(targetConfig RuleTargetConfigRelaxis, devs map[string]Device) (*mappingrules.RuleTargetRelaxis, error) { - device, ok := devs[targetConfig.Device] - if !ok { - return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) - } - - eventCode, err := parseCode(targetConfig.Axis, CodePrefixRelaxis) - if err != nil { - return nil, err - } - - return mappingrules.NewRuleTargetRelaxis( - targetConfig.Device, - device, - eventCode, - ) -} - -func makeRuleTargetModeSelect(targetConfig RuleTargetConfigModeSelect, allModes []string) (*mappingrules.RuleTargetModeSelect, error) { - if ok := validateModes(targetConfig.Modes, allModes); !ok { - return nil, errors.New("undefined mode in mode select list") - } - - return mappingrules.NewRuleTargetModeSelect(targetConfig.Modes) -} - -// hasError exists solely to switch on errors in case statements -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 RuleTargetConfigAxis, 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) { - if start < min { - end += min - start - start = min - } - if end > max { - start -= end - max - end = max - } - - return start, end -} diff --git a/internal/config/make_rules.go b/internal/config/make_rules.go deleted file mode 100644 index 9baf9d7..0000000 --- a/internal/config/make_rules.go +++ /dev/null @@ -1,237 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - "git.annabunches.net/annabunches/joyful/internal/logger" - "git.annabunches.net/annabunches/joyful/internal/mappingrules" - "github.com/holoplot/go-evdev" -) - -// 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[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[][]rule -// For very large rule-bases this may be helpful for staying performant. -func (parser *ConfigParser) InitRules(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 - - if ok := validateModes(ruleConfig.Modes, modes); !ok { - logger.Logf("Skipping rule '%s', mode list specifies undefined mode.", ruleConfig.Name) - continue - } - - base := mappingrules.NewMappingRuleBase(ruleConfig.Name, ruleConfig.Modes) - - switch strings.ToLower(ruleConfig.Type) { - case RuleTypeButton: - newRule, err = makeMappingRuleButton(ruleConfig.Config.(RuleConfigButton), pDevs, vDevs, base) - case RuleTypeButtonCombo: - newRule, err = makeMappingRuleCombo(ruleConfig.Config.(RuleConfigButtonCombo), pDevs, vDevs, base) - case RuleTypeButtonLatched: - newRule, err = makeMappingRuleLatched(ruleConfig.Config.(RuleConfigButtonLatched), pDevs, vDevs, base) - case RuleTypeAxis: - newRule, err = makeMappingRuleAxis(ruleConfig.Config.(RuleConfigAxis), pDevs, vDevs, base) - case RuleTypeAxisCombined: - newRule, err = makeMappingRuleAxisCombined(ruleConfig.Config.(RuleConfigAxisCombined), pDevs, vDevs, base) - case RuleTypeAxisToButton: - newRule, err = makeMappingRuleAxisToButton(ruleConfig.Config.(RuleConfigAxisToButton), pDevs, vDevs, base) - case RuleTypeAxisToRelaxis: - newRule, err = makeMappingRuleAxisToRelaxis(ruleConfig.Config.(RuleConfigAxisToRelaxis), pDevs, vDevs, base) - case RuleTypeModeSelect: - newRule, err = makeMappingRuleModeSelect(ruleConfig.Config.(RuleConfigModeSelect), pDevs, modes, base) - default: - err = fmt.Errorf("bad rule type '%s' for rule '%s'", ruleConfig.Type, ruleConfig.Name) - } - - if err != nil { - logger.LogErrorf(err, "Failed to build rule '%s'", ruleConfig.Name) - continue - } - - rules = append(rules, newRule) - } - - return rules -} - -// TODO: how much of these functions could we fold into the unmarshaling logic itself? The main problem -// is that we don't have access to the device maps in those functions... could we set device names -// as stand-ins and do a post-processing pass that *just* handles device linking and possibly mode -// checking? -// -// In other words - can we unmarshal the config directly into our target structs and remove most of -// this library? -func makeMappingRuleButton(ruleConfig RuleConfigButton, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButton, error) { - - input, err := makeRuleTargetButton(ruleConfig.Input, pDevs) - if err != nil { - return nil, err - } - - output, err := makeRuleTargetButton(ruleConfig.Output, vDevs) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleButton(base, input, output), nil -} - -func makeMappingRuleCombo(ruleConfig RuleConfigButtonCombo, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonCombo, error) { - - inputs := make([]*mappingrules.RuleTargetButton, 0) - for _, inputConfig := range ruleConfig.Inputs { - input, err := makeRuleTargetButton(inputConfig, pDevs) - if err != nil { - return nil, err - } - inputs = append(inputs, input) - } - - output, err := makeRuleTargetButton(ruleConfig.Output, vDevs) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleButtonCombo(base, inputs, output), nil -} - -func makeMappingRuleLatched(ruleConfig RuleConfigButtonLatched, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleButtonLatched, error) { - - input, err := makeRuleTargetButton(ruleConfig.Input, pDevs) - if err != nil { - return nil, err - } - - output, err := makeRuleTargetButton(ruleConfig.Output, vDevs) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleButtonLatched(base, input, output), nil -} - -func makeMappingRuleAxis(ruleConfig RuleConfigAxis, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxis, error) { - - input, err := makeRuleTargetAxis(ruleConfig.Input, pDevs) - if err != nil { - return nil, err - } - - output, err := makeRuleTargetAxis(ruleConfig.Output, vDevs) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleAxis(base, input, output), nil -} - -func makeMappingRuleAxisCombined(ruleConfig RuleConfigAxisCombined, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisCombined, error) { - - inputLower, err := makeRuleTargetAxis(ruleConfig.InputLower, pDevs) - if err != nil { - return nil, err - } - - inputUpper, err := makeRuleTargetAxis(ruleConfig.InputUpper, pDevs) - if err != nil { - return nil, err - } - - output, err := makeRuleTargetAxis(ruleConfig.Output, vDevs) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleAxisCombined(base, inputLower, inputUpper, output), nil -} - -func makeMappingRuleAxisToButton(ruleConfig RuleConfigAxisToButton, - pDevs map[string]Device, - vDevs map[string]Device, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleAxisToButton, error) { - - 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 RuleConfigAxisToRelaxis, - pDevs map[string]Device, - vDevs map[string]Device, - 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 RuleConfigModeSelect, - pDevs map[string]Device, - modes []string, - base mappingrules.MappingRuleBase) (*mappingrules.MappingRuleModeSelect, error) { - - input, err := makeRuleTargetButton(ruleConfig.Input, pDevs) - if err != nil { - return nil, err - } - - output, err := makeRuleTargetModeSelect(ruleConfig.Output, modes) - if err != nil { - return nil, err - } - - return mappingrules.NewMappingRuleModeSelect(base, input, output), nil -} diff --git a/internal/config/modes.go b/internal/config/modes.go deleted file mode 100644 index ad3dee2..0000000 --- a/internal/config/modes.go +++ /dev/null @@ -1,19 +0,0 @@ -package config - -import "slices" - -// validateModes checks the provided modes against a larger subset of modes (usually all defined ones) -// and returns false if any of the modes are not defined. -func validateModes(modes []string, allModes []string) bool { - if len(modes) == 0 { - return true - } - - for _, mode := range modes { - if !slices.Contains(allModes, mode) { - return false - } - } - - return true -} diff --git a/internal/configparser/configparser.go b/internal/configparser/configparser.go new file mode 100644 index 0000000..3daa217 --- /dev/null +++ b/internal/configparser/configparser.go @@ -0,0 +1,67 @@ +package configparser + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/goccy/go-yaml" +) + +func ParseConfig(directory string) (*Config, error) { + config := new(Config) + + configFiles, err := getConfigFilePaths(directory) + if err != nil { + return nil, err + } + + // Open each yaml file and add its contents to the global config + for _, filePath := range configFiles { + data, err := os.ReadFile(filePath) + if err != nil { + logger.LogError(err, "Error while opening config file") + continue + } + + newConfig := Config{} + err = yaml.Unmarshal(data, &newConfig) + logger.LogIfError(err, "Error parsing YAML") + config.Rules = append(config.Rules, newConfig.Rules...) + config.Devices = append(config.Devices, newConfig.Devices...) + config.Modes = append(config.Modes, newConfig.Modes...) + } + + if len(config.Devices) == 0 { + return nil, errors.New("Found no devices in configuration. Please add configuration at " + directory) + } + + return config, nil +} + +func getConfigFilePaths(directory string) ([]string, error) { + paths := make([]string, 0) + + dirEntries, err := os.ReadDir(directory) + if err != nil { + err = os.Mkdir(directory, 0755) + if err != nil { + return nil, errors.New("failed to create config directory at " + directory) + } else { + return nil, errors.New("no config files found at " + directory) + } + } + + for _, file := range dirEntries { + name := strings.ToLower(file.Name()) + if file.IsDir() || !(strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")) { + continue + } + + paths = append(paths, filepath.Join(directory, file.Name())) + } + + return paths, nil +} diff --git a/internal/config/schema.go b/internal/configparser/schema.go similarity index 99% rename from internal/config/schema.go rename to internal/configparser/schema.go index ad91f28..8b70521 100644 --- a/internal/config/schema.go +++ b/internal/configparser/schema.go @@ -1,7 +1,7 @@ // These types comprise the YAML schema for configuring Joyful. // The config files will be combined and then unmarshalled into this -package config +package configparser import ( "fmt" diff --git a/internal/configparser/variables.go b/internal/configparser/variables.go new file mode 100644 index 0000000..77e2b9c --- /dev/null +++ b/internal/configparser/variables.go @@ -0,0 +1,15 @@ +package configparser + +const ( + DeviceTypePhysical = "physical" + DeviceTypeVirtual = "virtual" + + RuleTypeButton = "button" + RuleTypeButtonCombo = "button-combo" + RuleTypeButtonLatched = "button-latched" + RuleTypeAxis = "axis" + RuleTypeAxisCombined = "axis-combined" + RuleTypeAxisToButton = "axis-to-button" + RuleTypeAxisToRelaxis = "axis-to-relaxis" + RuleTypeModeSelect = "mode-select" +) diff --git a/internal/config/codes.go b/internal/eventcodes/codes.go similarity index 81% rename from internal/config/codes.go rename to internal/eventcodes/codes.go index c879feb..a7515a8 100644 --- a/internal/config/codes.go +++ b/internal/eventcodes/codes.go @@ -1,4 +1,4 @@ -package config +package eventcodes import ( "fmt" @@ -8,17 +8,17 @@ import ( "github.com/holoplot/go-evdev" ) -func parseCodeButton(code string) (evdev.EvCode, error) { +func ParseCodeButton(code string) (evdev.EvCode, error) { prefix := CodePrefixButton if strings.HasPrefix(code, CodePrefixKey+"_") { prefix = CodePrefixKey } - return parseCode(code, prefix) + return ParseCode(code, prefix) } -func parseCode(code, prefix string) (evdev.EvCode, error) { +func ParseCode(code, prefix string) (evdev.EvCode, error) { code = strings.ToUpper(code) var codeLookup map[string]evdev.EvCode @@ -70,3 +70,8 @@ func parseCode(code, prefix string) (evdev.EvCode, error) { return eventCode, nil } } + +// hasError exists solely to switch on errors in conditional and case statements +func hasError(_ any, err error) bool { + return err != nil +} diff --git a/internal/config/codes_test.go b/internal/eventcodes/codes_test.go similarity index 94% rename from internal/config/codes_test.go rename to internal/eventcodes/codes_test.go index 6e80291..4d72526 100644 --- a/internal/config/codes_test.go +++ b/internal/eventcodes/codes_test.go @@ -1,4 +1,4 @@ -package config +package eventcodes import ( "fmt" @@ -18,7 +18,7 @@ func TestRunnerEventCodeParserTests(t *testing.T) { func parseCodeTestCase(t *EventCodeParserTests, in string, out evdev.EvCode, prefix string) { t.Run(fmt.Sprintf("%s: %s", prefix, in), func() { - code, err := parseCode(in, prefix) + code, err := ParseCode(in, prefix) t.Nil(err) t.EqualValues(out, code) }) @@ -38,7 +38,7 @@ func (t *EventCodeParserTests) TestParseCodeButton() { for _, testCase := range testCases { t.Run(testCase.in, func() { - code, err := parseCodeButton(testCase.in) + code, err := ParseCodeButton(testCase.in) t.Nil(err) t.EqualValues(code, testCase.out) }) @@ -134,7 +134,7 @@ func (t *EventCodeParserTests) TestParseCode() { for _, testCase := range testCases { t.Run(fmt.Sprintf("%s - '%s'", testCase.prefix, testCase.in), func() { - _, err := parseCode(testCase.in, testCase.prefix) + _, err := ParseCode(testCase.in, testCase.prefix) t.NotNil(err) }) } diff --git a/internal/eventcodes/variables.go b/internal/eventcodes/variables.go new file mode 100644 index 0000000..d63b92d --- /dev/null +++ b/internal/eventcodes/variables.go @@ -0,0 +1,90 @@ +package eventcodes + +import "github.com/holoplot/go-evdev" + +const ( + CodePrefixButton = "BTN" + CodePrefixKey = "KEY" + CodePrefixAxis = "ABS" + CodePrefixRelaxis = "REL" +) + +var ( + // Map joystick buttons to integer indices + ButtonFromIndex = []evdev.EvCode{ + evdev.BTN_TRIGGER, + evdev.BTN_THUMB, + evdev.BTN_THUMB2, + evdev.BTN_TOP, + evdev.BTN_TOP2, + evdev.BTN_PINKIE, + evdev.BTN_BASE, + evdev.BTN_BASE2, + evdev.BTN_BASE3, + 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, + evdev.BTN_TRIGGER_HAPPY4, + evdev.BTN_TRIGGER_HAPPY5, + evdev.BTN_TRIGGER_HAPPY6, + evdev.BTN_TRIGGER_HAPPY7, + evdev.BTN_TRIGGER_HAPPY8, + evdev.BTN_TRIGGER_HAPPY9, + evdev.BTN_TRIGGER_HAPPY10, + evdev.BTN_TRIGGER_HAPPY11, + evdev.BTN_TRIGGER_HAPPY12, + evdev.BTN_TRIGGER_HAPPY13, + evdev.BTN_TRIGGER_HAPPY14, + evdev.BTN_TRIGGER_HAPPY15, + evdev.BTN_TRIGGER_HAPPY16, + evdev.BTN_TRIGGER_HAPPY17, + evdev.BTN_TRIGGER_HAPPY18, + evdev.BTN_TRIGGER_HAPPY19, + evdev.BTN_TRIGGER_HAPPY20, + evdev.BTN_TRIGGER_HAPPY21, + evdev.BTN_TRIGGER_HAPPY22, + evdev.BTN_TRIGGER_HAPPY23, + evdev.BTN_TRIGGER_HAPPY24, + evdev.BTN_TRIGGER_HAPPY25, + evdev.BTN_TRIGGER_HAPPY26, + evdev.BTN_TRIGGER_HAPPY27, + evdev.BTN_TRIGGER_HAPPY28, + evdev.BTN_TRIGGER_HAPPY29, + evdev.BTN_TRIGGER_HAPPY30, + evdev.BTN_TRIGGER_HAPPY31, + evdev.BTN_TRIGGER_HAPPY32, + evdev.BTN_TRIGGER_HAPPY33, + evdev.BTN_TRIGGER_HAPPY34, + evdev.BTN_TRIGGER_HAPPY35, + evdev.BTN_TRIGGER_HAPPY36, + evdev.BTN_TRIGGER_HAPPY37, + 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), + } +) diff --git a/internal/config/make_rule_targets_test.go b/internal/mappingrules/init_rule_targets_test.go similarity index 71% rename from internal/config/make_rule_targets_test.go rename to internal/mappingrules/init_rule_targets_test.go index 7ee8fb8..168b02d 100644 --- a/internal/config/make_rule_targets_test.go +++ b/internal/mappingrules/init_rule_targets_test.go @@ -1,9 +1,12 @@ -package config +// TODO: these tests should live with their rule_target_* counterparts + +package mappingrules import ( "fmt" "testing" + "git.annabunches.net/annabunches/joyful/internal/configparser" "github.com/holoplot/go-evdev" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -48,45 +51,45 @@ func (t *MakeRuleTargetsTests) SetupSuite() { } func (t *MakeRuleTargetsTests) TestMakeRuleTargetButton() { - config := RuleTargetConfigButton{Device: "test"} + config := configparser.RuleTargetConfigButton{Device: "test"} t.Run("Standard keycode", func() { config.Button = "BTN_TRIGGER" - rule, err := makeRuleTargetButton(config, t.devs) + rule, err := NewRuleTargetButtonFromConfig(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) + rule, err := NewRuleTargetButtonFromConfig(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) + rule, err := NewRuleTargetButtonFromConfig(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) + _, err := NewRuleTargetButtonFromConfig(config, t.devs) t.NotNil(err) }) t.Run("Un-prefixed keycode", func() { config.Button = "pinkie" - rule, err := makeRuleTargetButton(config, t.devs) + rule, err := NewRuleTargetButtonFromConfig(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) + _, err := NewRuleTargetButtonFromConfig(config, t.devs) t.NotNil(err) }) } @@ -103,9 +106,9 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { for _, tc := range codeTestCases { t.Run(fmt.Sprintf("KeyCode %s", tc.input), func() { - config := RuleTargetConfigAxis{Device: "test"} + config := configparser.RuleTargetConfigAxis{Device: "test"} config.Axis = tc.input - rule, err := makeRuleTargetAxis(config, t.devs) + rule, err := NewRuleTargetAxisFromConfig(config, t.devs) t.Nil(err) t.EqualValues(tc.output, rule.Axis) @@ -113,18 +116,18 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { } t.Run("Invalid code", func() { - config := RuleTargetConfigAxis{Device: "test"} + config := configparser.RuleTargetConfigAxis{Device: "test"} config.Axis = "foo" - _, err := makeRuleTargetAxis(config, t.devs) + _, err := NewRuleTargetAxisFromConfig(config, t.devs) t.NotNil(err) }) t.Run("Invalid deadzone", func() { - config := RuleTargetConfigAxis{Device: "test"} + config := configparser.RuleTargetConfigAxis{Device: "test"} config.Axis = "x" config.DeadzoneEnd = 100 config.DeadzoneStart = 1000 - _, err := makeRuleTargetAxis(config, t.devs) + _, err := NewRuleTargetAxisFromConfig(config, t.devs) t.NotNil(err) }) @@ -141,13 +144,13 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { for _, tc := range relDeadzoneTestCases { t.Run(fmt.Sprintf("Relative Deadzone %d +- %d", tc.inCenter, tc.inSize), func() { - config := RuleTargetConfigAxis{ + config := configparser.RuleTargetConfigAxis{ Device: "test", Axis: "x", DeadzoneCenter: tc.inCenter, DeadzoneSize: tc.inSize, } - rule, err := makeRuleTargetAxis(config, t.devs) + rule, err := NewRuleTargetAxisFromConfig(config, t.devs) t.Nil(err) t.Equal(tc.outStart, rule.DeadzoneStart) @@ -156,13 +159,13 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { } t.Run("Deadzone center/size invalid center", func() { - config := RuleTargetConfigAxis{ + config := configparser.RuleTargetConfigAxis{ Device: "test", Axis: "x", DeadzoneCenter: 20000, DeadzoneSize: 500, } - _, err := makeRuleTargetAxis(config, t.devs) + _, err := NewRuleTargetAxisFromConfig(config, t.devs) t.NotNil(err) }) @@ -179,13 +182,13 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { for _, tc := range relDeadzonePercentTestCases { t.Run(fmt.Sprintf("Relative percent deadzone %d +- %d%%", tc.inCenter, tc.inSizePercent), func() { - config := RuleTargetConfigAxis{ + config := configparser.RuleTargetConfigAxis{ Device: "test", Axis: "x", DeadzoneCenter: tc.inCenter, DeadzoneSizePercent: tc.inSizePercent, } - rule, err := makeRuleTargetAxis(config, t.devs) + rule, err := NewRuleTargetAxisFromConfig(config, t.devs) t.Nil(err) t.Equal(tc.outStart, rule.DeadzoneStart) @@ -194,50 +197,50 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() { } t.Run("Deadzone center/percent invalid center", func() { - config := RuleTargetConfigAxis{ + config := configparser.RuleTargetConfigAxis{ Device: "test", Axis: "x", DeadzoneCenter: 20000, DeadzoneSizePercent: 10, } - _, err := makeRuleTargetAxis(config, t.devs) + _, err := NewRuleTargetAxisFromConfig(config, t.devs) t.NotNil(err) }) } func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() { - config := RuleTargetConfigRelaxis{Device: "test"} + config := configparser.RuleTargetConfigRelaxis{Device: "test"} t.Run("Standard keycode", func() { config.Axis = "REL_WHEEL" - rule, err := makeRuleTargetRelaxis(config, t.devs) + rule, err := NewRuleTargetRelaxisFromConfig(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) + rule, err := NewRuleTargetRelaxisFromConfig(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) + rule, err := NewRuleTargetRelaxisFromConfig(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) + _, err := NewRuleTargetRelaxisFromConfig(config, t.devs) t.NotNil(err) }) t.Run("Incorrect axis type", func() { config.Axis = "ABS_X" - _, err := makeRuleTargetRelaxis(config, t.devs) + _, err := NewRuleTargetRelaxisFromConfig(config, t.devs) t.NotNil(err) }) } diff --git a/internal/mappingrules/init_rules.go b/internal/mappingrules/init_rules.go new file mode 100644 index 0000000..7ea0ea4 --- /dev/null +++ b/internal/mappingrules/init_rules.go @@ -0,0 +1,79 @@ +package mappingrules + +import ( + "errors" + "fmt" + "slices" + "strings" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" +) + +func ConvertDeviceMap(inputDevs map[string]*evdev.InputDevice) map[string]Device { + // Golang can't inspect the concrete map type to determine interface conformance, + // so we handle that here. + devices := make(map[string]Device) + for name, dev := range inputDevs { + devices[name] = dev + } + return devices +} + +// NewRule parses a RuleConfig struct and creates and returns the appropriate rule type. +// You can remap a map[string]*evdev.InputDevice to our interface type with ConvertDeviceMap +func NewRule(config configparser.RuleConfig, pDevs map[string]Device, vDevs map[string]Device, modes []string) (MappingRule, error) { + var newRule MappingRule + var err error + + if !validateModes(config.Modes, modes) { + return nil, errors.New("mode list specifies undefined mode") + } + + base := NewMappingRuleBase(config.Name, config.Modes) + + switch strings.ToLower(config.Type) { + case RuleTypeButton: + newRule, err = NewMappingRuleButton(config.Config.(configparser.RuleConfigButton), pDevs, vDevs, base) + case RuleTypeButtonCombo: + newRule, err = NewMappingRuleButtonCombo(config.Config.(configparser.RuleConfigButtonCombo), pDevs, vDevs, base) + case RuleTypeButtonLatched: + newRule, err = NewMappingRuleButtonLatched(config.Config.(configparser.RuleConfigButtonLatched), pDevs, vDevs, base) + case RuleTypeAxis: + newRule, err = NewMappingRuleAxis(config.Config.(configparser.RuleConfigAxis), pDevs, vDevs, base) + case RuleTypeAxisCombined: + newRule, err = NewMappingRuleAxisCombined(config.Config.(configparser.RuleConfigAxisCombined), pDevs, vDevs, base) + case RuleTypeAxisToButton: + newRule, err = NewMappingRuleAxisToButton(config.Config.(configparser.RuleConfigAxisToButton), pDevs, vDevs, base) + case RuleTypeAxisToRelaxis: + newRule, err = NewMappingRuleAxisToRelaxis(config.Config.(configparser.RuleConfigAxisToRelaxis), pDevs, vDevs, base) + case RuleTypeModeSelect: + newRule, err = NewMappingRuleModeSelect(config.Config.(configparser.RuleConfigModeSelect), pDevs, modes, base) + default: + err = fmt.Errorf("bad rule type '%s' for rule '%s'", config.Type, config.Name) + } + + if err != nil { + logger.LogErrorf(err, "Failed to build rule '%s'", config.Name) + return nil, err + } + + return newRule, nil +} + +// validateModes checks the provided modes against a larger subset of modes (usually all defined ones) +// and returns false if any of the modes are not defined. +func validateModes(modes []string, allModes []string) bool { + if len(modes) == 0 { + return true + } + + for _, mode := range modes { + if !slices.Contains(allModes, mode) { + return false + } + } + + return true +} diff --git a/internal/mappingrules/mapping_rule_axis.go b/internal/mappingrules/mapping_rule_axis.go index a2ab41d..a4d1ed1 100644 --- a/internal/mappingrules/mapping_rule_axis.go +++ b/internal/mappingrules/mapping_rule_axis.go @@ -1,6 +1,9 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" + "github.com/holoplot/go-evdev" +) // A Simple Mapping Rule can map a button to a button or an axis to an axis. type MappingRuleAxis struct { @@ -9,12 +12,26 @@ type MappingRuleAxis struct { Output *RuleTargetAxis } -func NewMappingRuleAxis(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetAxis) *MappingRuleAxis { +func NewMappingRuleAxis(ruleConfig configparser.RuleConfigAxis, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleAxis, error) { + + input, err := NewRuleTargetAxisFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetAxisFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + return &MappingRuleAxis{ MappingRuleBase: base, Input: input, Output: output, - } + }, nil } func (rule *MappingRuleAxis) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_axis_combined.go b/internal/mappingrules/mapping_rule_axis_combined.go index 36562b8..62ce542 100644 --- a/internal/mappingrules/mapping_rule_axis_combined.go +++ b/internal/mappingrules/mapping_rule_axis_combined.go @@ -1,6 +1,7 @@ package mappingrules import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" ) @@ -12,7 +13,26 @@ type MappingRuleAxisCombined struct { Output *RuleTargetAxis } -func NewMappingRuleAxisCombined(base MappingRuleBase, inputLower *RuleTargetAxis, inputUpper *RuleTargetAxis, output *RuleTargetAxis) *MappingRuleAxisCombined { +func NewMappingRuleAxisCombined(ruleConfig configparser.RuleConfigAxisCombined, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleAxisCombined, error) { + + inputLower, err := NewRuleTargetAxisFromConfig(ruleConfig.InputLower, pDevs) + if err != nil { + return nil, err + } + + inputUpper, err := NewRuleTargetAxisFromConfig(ruleConfig.InputUpper, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetAxisFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + inputLower.OutputMax = 0 inputUpper.OutputMin = 0 return &MappingRuleAxisCombined{ @@ -20,7 +40,7 @@ func NewMappingRuleAxisCombined(base MappingRuleBase, inputLower *RuleTargetAxis InputLower: inputLower, InputUpper: inputUpper, Output: output, - } + }, nil } func (rule *MappingRuleAxisCombined) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_axis_combined_test.go b/internal/mappingrules/mapping_rule_axis_combined_test.go index 631d7a0..c514ed7 100644 --- a/internal/mappingrules/mapping_rule_axis_combined_test.go +++ b/internal/mappingrules/mapping_rule_axis_combined_test.go @@ -38,7 +38,9 @@ func (t *MappingRuleAxisCombinedTests) SetupTest() { }, nil) t.inputTargetLower, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, true, 0, 0) + t.inputTargetLower.OutputMax = 0 t.inputTargetUpper, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_Y, false, 0, 0) + t.inputTargetUpper.OutputMin = 0 t.outputDevice = &evdev.InputDevice{} t.outputTarget, _ = NewRuleTargetAxis("test-output", t.outputDevice, evdev.ABS_X, false, 0, 0) @@ -57,19 +59,30 @@ func (t *MappingRuleAxisCombinedTests) TearDownSubTest() { t.inputDevice.Reset() } +// TODO: this test sucks func (t *MappingRuleAxisCombinedTests) TestNewMappingRuleAxisCombined() { t.inputDevice.Stub("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{ evdev.ABS_X: {Minimum: 0, Maximum: 10000}, evdev.ABS_Y: {Minimum: 0, Maximum: 10000}, }, nil) - rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget) + rule := &MappingRuleAxisCombined{ + MappingRuleBase: t.base, + InputLower: t.inputTargetLower, + InputUpper: t.inputTargetUpper, + Output: t.outputTarget, + } t.EqualValues(0, rule.InputLower.OutputMax) t.EqualValues(0, rule.InputUpper.OutputMin) } func (t *MappingRuleAxisCombinedTests) TestMatchEvent() { - rule := NewMappingRuleAxisCombined(t.base, t.inputTargetLower, t.inputTargetUpper, t.outputTarget) + rule := &MappingRuleAxisCombined{ + MappingRuleBase: t.base, + InputLower: t.inputTargetLower, + InputUpper: t.inputTargetUpper, + Output: t.outputTarget, + } t.Run("Lower Input", func() { testCases := []struct{ in, out int32 }{ diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index 3356dbe..82862ee 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -3,6 +3,7 @@ package mappingrules import ( "time" + "git.annabunches.net/annabunches/joyful/internal/configparser" "github.com/holoplot/go-evdev" "github.com/jonboulle/clockwork" ) @@ -23,20 +24,34 @@ type MappingRuleAxisToButton struct { clock clockwork.Clock } -func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { +func NewMappingRuleAxisToButton(ruleConfig configparser.RuleConfigAxisToButton, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleAxisToButton, error) { + + input, err := NewRuleTargetAxisFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetButtonFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + return &MappingRuleAxisToButton{ MappingRuleBase: base, Input: input, Output: output, - RepeatRateMin: repeatRateMin, - RepeatRateMax: repeatRateMax, + RepeatRateMin: ruleConfig.RepeatRateMin, + RepeatRateMax: ruleConfig.RepeatRateMax, lastEvent: time.Now(), nextEvent: NoNextEvent, - repeat: repeatRateMin != 0 && repeatRateMax != 0, + repeat: ruleConfig.RepeatRateMin != 0 && ruleConfig.RepeatRateMax != 0, pressed: false, active: false, clock: clockwork.NewRealClock(), - } + }, nil } func (rule *MappingRuleAxisToButton) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_axis_to_button_test.go b/internal/mappingrules/mapping_rule_axis_to_button_test.go index 976506c..0da086a 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button_test.go +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -19,6 +19,44 @@ type MappingRuleAxisToButtonTests struct { base MappingRuleBase } +func TestRunnerMappingRuleAxisToButtonTests(t *testing.T) { + suite.Run(t, new(MappingRuleAxisToButtonTests)) +} + +// buildTimerRule creates a MappingRuleAxisToButton with a mocked clock +func (t *MappingRuleAxisToButtonTests) buildTimerRule( + repeatMin, + repeatMax int, + nextEvent time.Duration) (*MappingRuleAxisToButton, *clockwork.FakeClock) { + + mockClock := clockwork.NewFakeClock() + testRule := t.buildRule(repeatMin, repeatMax) + testRule.clock = mockClock + testRule.lastEvent = testRule.clock.Now() + testRule.nextEvent = nextEvent + if nextEvent != NoNextEvent { + testRule.active = true + } + return testRule, mockClock +} + +// Todo: don't love this repeated logic... +func (t *MappingRuleAxisToButtonTests) buildRule(repeatMin, repeatMax int) *MappingRuleAxisToButton { + return &MappingRuleAxisToButton{ + MappingRuleBase: t.base, + Input: t.inputRule, + Output: t.outputRule, + RepeatRateMin: repeatMin, + RepeatRateMax: repeatMax, + lastEvent: time.Now(), + nextEvent: NoNextEvent, + repeat: repeatMin != 0 && repeatMax != 0, + pressed: false, + active: false, + clock: clockwork.NewRealClock(), + } +} + func (t *MappingRuleAxisToButtonTests) SetupTest() { mode := "*" t.mode = &mode @@ -40,7 +78,7 @@ 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) + testRule := t.buildRule(0, 0) t.Run("Valid Input", func() { testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ @@ -62,7 +100,7 @@ func (t *MappingRuleAxisToButtonTests) TestMatchEvent() { }) t.Run("Repeat", func() { - testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) + testRule := t.buildRule(750, 250) testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ Type: evdev.EV_ABS, Code: evdev.ABS_X, @@ -90,7 +128,7 @@ 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) + testRule, _ := t.buildTimerRule(0, 0, 0) event := testRule.TimerEvent() @@ -100,7 +138,7 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { // Off event on second call t.Run("Event emits off on second call", func() { - testRule, _ := buildTimerRule(t, 0, 0, 0) + testRule, _ := t.buildTimerRule(0, 0, 0) testRule.TimerEvent() event := testRule.TimerEvent() @@ -111,7 +149,7 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { // 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, mockClock := t.buildTimerRule(0, 0, 0) testRule.TimerEvent() testRule.TimerEvent() @@ -125,13 +163,13 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { t.Run("Repeat", func() { t.Run("No event if called immediately", func() { - testRule, _ := buildTimerRule(t, 100, 10, 50*time.Millisecond) + testRule, _ := t.buildTimerRule(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) + testRule, mockClock := t.buildTimerRule(100, 10, 50*time.Millisecond) mockClock.Advance(49 * time.Millisecond) event := testRule.TimerEvent() @@ -140,7 +178,7 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { }) t.Run("Event after 50ms", func() { - testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + testRule, mockClock := t.buildTimerRule(100, 10, 50*time.Millisecond) mockClock.Advance(50 * time.Millisecond) event := testRule.TimerEvent() @@ -150,7 +188,7 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { }) t.Run("Additional event at 100ms", func() { - testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + testRule, mockClock := t.buildTimerRule(100, 10, 50*time.Millisecond) mockClock.Advance(50 * time.Millisecond) testRule.TimerEvent() @@ -163,24 +201,3 @@ func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { }) }) } - -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 153b992..a6b418e 100644 --- a/internal/mappingrules/mapping_rule_axis_to_relaxis.go +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -3,6 +3,7 @@ package mappingrules import ( "time" + "git.annabunches.net/annabunches/joyful/internal/configparser" "github.com/holoplot/go-evdev" "github.com/jonboulle/clockwork" ) @@ -23,23 +24,32 @@ type MappingRuleAxisToRelaxis struct { clock clockwork.Clock } -func NewMappingRuleAxisToRelaxis( - base MappingRuleBase, - input *RuleTargetAxis, - output *RuleTargetRelaxis, - repeatRateMin, repeatRateMax, increment int) *MappingRuleAxisToRelaxis { +func NewMappingRuleAxisToRelaxis(ruleConfig configparser.RuleConfigAxisToRelaxis, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleAxisToRelaxis, error) { + + input, err := NewRuleTargetAxisFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetRelaxisFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } return &MappingRuleAxisToRelaxis{ MappingRuleBase: base, Input: input, Output: output, - RepeatRateMin: repeatRateMin, - RepeatRateMax: repeatRateMax, - Increment: int32(increment), + RepeatRateMin: ruleConfig.RepeatRateMin, + RepeatRateMax: ruleConfig.RepeatRateMax, + Increment: int32(ruleConfig.Increment), lastEvent: time.Now(), nextEvent: NoNextEvent, clock: clockwork.NewRealClock(), - } + }, nil } func (rule *MappingRuleAxisToRelaxis) MatchEvent( diff --git a/internal/mappingrules/mapping_rule_button.go b/internal/mappingrules/mapping_rule_button.go index 69a7cfe..3b7befa 100644 --- a/internal/mappingrules/mapping_rule_button.go +++ b/internal/mappingrules/mapping_rule_button.go @@ -1,6 +1,9 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" + "github.com/holoplot/go-evdev" +) // A Simple Mapping Rule can map a button to a button or an axis to an axis. type MappingRuleButton struct { @@ -9,16 +12,26 @@ type MappingRuleButton struct { Output *RuleTargetButton } -func NewMappingRuleButton( - base MappingRuleBase, - input *RuleTargetButton, - output *RuleTargetButton) *MappingRuleButton { +func NewMappingRuleButton(ruleConfig configparser.RuleConfigButton, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleButton, error) { + + input, err := NewRuleTargetButtonFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetButtonFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } return &MappingRuleButton{ MappingRuleBase: base, Input: input, Output: output, - } + }, nil } func (rule *MappingRuleButton) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_button_combo.go b/internal/mappingrules/mapping_rule_button_combo.go index a7b7c23..12c8ef3 100644 --- a/internal/mappingrules/mapping_rule_button_combo.go +++ b/internal/mappingrules/mapping_rule_button_combo.go @@ -1,6 +1,9 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" + "github.com/holoplot/go-evdev" +) // A Combo Mapping Rule can require multiple physical button presses for a single output button type MappingRuleButtonCombo struct { @@ -10,17 +13,31 @@ type MappingRuleButtonCombo struct { State int } -func NewMappingRuleButtonCombo( - base MappingRuleBase, - inputs []*RuleTargetButton, - output *RuleTargetButton) *MappingRuleButtonCombo { +func NewMappingRuleButtonCombo(ruleConfig configparser.RuleConfigButtonCombo, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleButtonCombo, error) { + + inputs := make([]*RuleTargetButton, 0) + for _, inputConfig := range ruleConfig.Inputs { + input, err := NewRuleTargetButtonFromConfig(inputConfig, pDevs) + if err != nil { + return nil, err + } + inputs = append(inputs, input) + } + + output, err := NewRuleTargetButtonFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } return &MappingRuleButtonCombo{ MappingRuleBase: base, Inputs: inputs, Output: output, State: 0, - } + }, nil } func (rule *MappingRuleButtonCombo) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_button_latched.go b/internal/mappingrules/mapping_rule_button_latched.go index d8e5bec..4536ca9 100644 --- a/internal/mappingrules/mapping_rule_button_latched.go +++ b/internal/mappingrules/mapping_rule_button_latched.go @@ -1,6 +1,9 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" + "github.com/holoplot/go-evdev" +) type MappingRuleButtonLatched struct { MappingRuleBase @@ -9,17 +12,27 @@ type MappingRuleButtonLatched struct { State bool } -func NewMappingRuleButtonLatched( - base MappingRuleBase, - input *RuleTargetButton, - output *RuleTargetButton) *MappingRuleButtonLatched { +func NewMappingRuleButtonLatched(ruleConfig configparser.RuleConfigButtonLatched, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleButtonLatched, error) { + + input, err := NewRuleTargetButtonFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetButtonFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } return &MappingRuleButtonLatched{ MappingRuleBase: base, Input: input, Output: output, State: false, - } + }, nil } func (rule *MappingRuleButtonLatched) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { diff --git a/internal/mappingrules/mapping_rule_button_test.go b/internal/mappingrules/mapping_rule_button_test.go index 28fba1b..740c1ce 100644 --- a/internal/mappingrules/mapping_rule_button_test.go +++ b/internal/mappingrules/mapping_rule_button_test.go @@ -28,7 +28,11 @@ func (t *MappingRuleButtonTests) SetupTest() { 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) + testRule := &MappingRuleButton{ + MappingRuleBase: t.base, + Input: inputButton, + Output: outputButton, + } // A matching input event should produce an output event expected := &evdev.InputEvent{ @@ -58,7 +62,11 @@ func (t *MappingRuleButtonTests) TestMatchEvent() { 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) + testRule := &MappingRuleButton{ + MappingRuleBase: t.base, + Input: inputButton, + Output: outputButton, + } // A matching input event should produce an output event expected := &evdev.InputEvent{ diff --git a/internal/mappingrules/mapping_rule_mode_select.go b/internal/mappingrules/mapping_rule_mode_select.go index 69afd0b..23a0757 100644 --- a/internal/mappingrules/mapping_rule_mode_select.go +++ b/internal/mappingrules/mapping_rule_mode_select.go @@ -1,6 +1,9 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "git.annabunches.net/annabunches/joyful/internal/configparser" + "github.com/holoplot/go-evdev" +) type MappingRuleModeSelect struct { MappingRuleBase @@ -8,17 +11,26 @@ type MappingRuleModeSelect struct { Output *RuleTargetModeSelect } -func NewMappingRuleModeSelect( - base MappingRuleBase, - input *RuleTargetButton, - output *RuleTargetModeSelect, -) *MappingRuleModeSelect { +func NewMappingRuleModeSelect(ruleConfig configparser.RuleConfigModeSelect, + pDevs map[string]Device, + modes []string, + base MappingRuleBase) (*MappingRuleModeSelect, error) { + + input, err := NewRuleTargetButtonFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetModeSelectFromConfig(ruleConfig.Output, modes) + if err != nil { + return nil, err + } return &MappingRuleModeSelect{ MappingRuleBase: base, Input: input, Output: output, - } + }, nil } func (rule *MappingRuleModeSelect) MatchEvent( diff --git a/internal/mappingrules/math.go b/internal/mappingrules/math.go index 37de4a2..6d036df 100644 --- a/internal/mappingrules/math.go +++ b/internal/mappingrules/math.go @@ -28,3 +28,16 @@ func Clamp[T Numeric](value, min, max T) T { } return value } + +func clampAndShift(start, end, min, max int32) (int32, int32) { + if start < min { + end += min - start + start = min + } + if end > max { + start -= end - max + end = max + } + + return start, end +} diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index fece9b8..1d92d37 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/eventcodes" "github.com/holoplot/go-evdev" ) @@ -20,6 +22,77 @@ type RuleTargetAxis struct { deadzoneSize int32 } +func NewRuleTargetAxisFromConfig(targetConfig configparser.RuleTargetConfigAxis, devs map[string]Device) (*RuleTargetAxis, error) { + device, ok := devs[targetConfig.Device] + if !ok { + return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) + } + + if targetConfig.DeadzoneEnd < targetConfig.DeadzoneStart { + return nil, errors.New("deadzone_end must be greater than deadzone_start") + } + + eventCode, err := eventcodes.ParseCode(targetConfig.Axis, eventcodes.CodePrefixAxis) + if err != nil { + return nil, err + } + + deadzoneStart, deadzoneEnd, err := calculateDeadzones(targetConfig, device, eventCode) + if err != nil { + return nil, err + } + + return NewRuleTargetAxis( + targetConfig.Device, + device, + eventCode, + targetConfig.Inverted, + deadzoneStart, + deadzoneEnd, + ) +} + +// calculateDeadzones produces the deadzone start and end values in absolute terms +func calculateDeadzones(targetConfig configparser.RuleTargetConfigAxis, 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 = AxisValueMin + max = 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 NewRuleTargetAxis(device_name string, device Device, axis evdev.EvCode, diff --git a/internal/mappingrules/rule_target_button.go b/internal/mappingrules/rule_target_button.go index 68fd252..316e7c5 100644 --- a/internal/mappingrules/rule_target_button.go +++ b/internal/mappingrules/rule_target_button.go @@ -1,6 +1,12 @@ package mappingrules -import "github.com/holoplot/go-evdev" +import ( + "fmt" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/eventcodes" + "github.com/holoplot/go-evdev" +) type RuleTargetButton struct { DeviceName string @@ -9,6 +15,25 @@ type RuleTargetButton struct { Inverted bool } +func NewRuleTargetButtonFromConfig(targetConfig configparser.RuleTargetConfigButton, devs map[string]Device) (*RuleTargetButton, error) { + device, ok := devs[targetConfig.Device] + if !ok { + return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) + } + + eventCode, err := eventcodes.ParseCodeButton(targetConfig.Button) + if err != nil { + return nil, err + } + + return NewRuleTargetButton( + targetConfig.Device, + device, + eventCode, + targetConfig.Inverted, + ) +} + func NewRuleTargetButton(device_name string, device Device, code evdev.EvCode, inverted bool) (*RuleTargetButton, error) { return &RuleTargetButton{ DeviceName: device_name, diff --git a/internal/mappingrules/rule_target_modeselect.go b/internal/mappingrules/rule_target_modeselect.go index 55c8f46..0235700 100644 --- a/internal/mappingrules/rule_target_modeselect.go +++ b/internal/mappingrules/rule_target_modeselect.go @@ -4,6 +4,7 @@ import ( "errors" "slices" + "git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" ) @@ -12,6 +13,14 @@ type RuleTargetModeSelect struct { Modes []string } +func NewRuleTargetModeSelectFromConfig(targetConfig configparser.RuleTargetConfigModeSelect, allModes []string) (*RuleTargetModeSelect, error) { + if ok := validateModes(targetConfig.Modes, allModes); !ok { + return nil, errors.New("undefined mode in mode select list") + } + + return NewRuleTargetModeSelect(targetConfig.Modes) +} + func NewRuleTargetModeSelect(modes []string) (*RuleTargetModeSelect, error) { if len(modes) == 0 { return nil, errors.New("cannot create RuleTargetModeSelect: mode list is empty") diff --git a/internal/mappingrules/rule_target_relaxis.go b/internal/mappingrules/rule_target_relaxis.go index 1942c4b..6b79812 100644 --- a/internal/mappingrules/rule_target_relaxis.go +++ b/internal/mappingrules/rule_target_relaxis.go @@ -1,6 +1,10 @@ package mappingrules import ( + "fmt" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/eventcodes" "github.com/holoplot/go-evdev" ) @@ -10,12 +14,30 @@ type RuleTargetRelaxis struct { Axis evdev.EvCode } -func NewRuleTargetRelaxis(device_name string, +func NewRuleTargetRelaxisFromConfig(targetConfig configparser.RuleTargetConfigRelaxis, devs map[string]Device) (*RuleTargetRelaxis, error) { + device, ok := devs[targetConfig.Device] + if !ok { + return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) + } + + eventCode, err := eventcodes.ParseCode(targetConfig.Axis, eventcodes.CodePrefixRelaxis) + if err != nil { + return nil, err + } + + return NewRuleTargetRelaxis( + targetConfig.Device, + device, + eventCode, + ) +} + +func NewRuleTargetRelaxis(deviceName string, device Device, axis evdev.EvCode) (*RuleTargetRelaxis, error) { return &RuleTargetRelaxis{ - DeviceName: device_name, + DeviceName: deviceName, Device: device, Axis: axis, }, nil diff --git a/internal/mappingrules/variables.go b/internal/mappingrules/variables.go new file mode 100644 index 0000000..d9a171b --- /dev/null +++ b/internal/mappingrules/variables.go @@ -0,0 +1,12 @@ +package mappingrules + +const ( + RuleTypeButton = "button" + RuleTypeButtonCombo = "button-combo" + RuleTypeButtonLatched = "button-latched" + RuleTypeAxis = "axis" + RuleTypeAxisCombined = "axis-combined" + RuleTypeAxisToButton = "axis-to-button" + RuleTypeAxisToRelaxis = "axis-to-relaxis" + RuleTypeModeSelect = "mode-select" +) diff --git a/internal/virtualdevice/cleanup.go b/internal/virtualdevice/cleanup.go deleted file mode 100644 index 9839f6b..0000000 --- a/internal/virtualdevice/cleanup.go +++ /dev/null @@ -1,35 +0,0 @@ -// Functions for cleaning up stale virtual devices - -package virtualdevice - -import ( - "fmt" - "strings" - - "github.com/holoplot/go-evdev" -) - -func CleanupStaleVirtualDevices() { - devices, err := evdev.ListDevicePaths() - if err != nil { - fmt.Printf("Couldn't list devices while running cleanup: %s\n", err.Error()) - return - } - - for _, devicePath := range devices { - if strings.HasPrefix(devicePath.Name, "joyful-joystick") { - device, err := evdev.Open(devicePath.Path) - if err != nil { - fmt.Printf("Failed to open existing joyful device at '%s': %s\n", devicePath.Path, err.Error()) - continue - } - - err = evdev.DestroyDevice(device) - if err != nil { - fmt.Printf("Failed to destroy existing joyful device '%s' at '%s': %s\n", devicePath.Name, devicePath.Path, err.Error()) - } else { - fmt.Printf("Destroyed stale joyful device '%s'\n", devicePath.Path) - } - } - } -} diff --git a/internal/virtualdevice/eventbuffer.go b/internal/virtualdevice/eventbuffer.go index 9a46341..5364a5d 100644 --- a/internal/virtualdevice/eventbuffer.go +++ b/internal/virtualdevice/eventbuffer.go @@ -11,13 +11,7 @@ import ( type EventBuffer struct { events []*evdev.InputEvent Device VirtualDevice -} - -func NewEventBuffer(device VirtualDevice) *EventBuffer { - return &EventBuffer{ - events: make([]*evdev.InputEvent, 0, 100), - Device: device, - } + Name string } func (buffer *EventBuffer) AddEvent(event *evdev.InputEvent) { diff --git a/internal/virtualdevice/eventbuffer_test.go b/internal/virtualdevice/eventbuffer_test.go index 515de5f..df8c7ff 100644 --- a/internal/virtualdevice/eventbuffer_test.go +++ b/internal/virtualdevice/eventbuffer_test.go @@ -11,10 +11,11 @@ import ( type EventBufferTests struct { suite.Suite - device *VirtualDeviceMock - writeOneCall *mock.Call + device *VirtualDeviceMock + buffer *EventBuffer } +// Mocks type VirtualDeviceMock struct { mock.Mock } @@ -24,65 +25,65 @@ func (m *VirtualDeviceMock) WriteOne(event *evdev.InputEvent) error { return args.Error(0) } +// Setup func TestRunnerEventBufferTests(t *testing.T) { suite.Run(t, new(EventBufferTests)) } -func (t *EventBufferTests) SetupTest() { - t.device = new(VirtualDeviceMock) -} - func (t *EventBufferTests) SetupSubTest() { t.device = new(VirtualDeviceMock) - t.writeOneCall = t.device.On("WriteOne").Return(nil) -} - -func (t *EventBufferTests) TearDownSubTest() { - t.writeOneCall.Unset() + t.buffer = &EventBuffer{Device: t.device} } +// Tests func (t *EventBufferTests) TestNewEventBuffer() { - buffer := NewEventBuffer(t.device) - t.Equal(t.device, buffer.Device) - t.Len(buffer.events, 0) + t.Equal(t.device, t.buffer.Device) + t.Len(t.buffer.events, 0) } -func (t *EventBufferTests) TestEventBufferAddEvent() { - buffer := NewEventBuffer(t.device) - buffer.AddEvent(&evdev.InputEvent{}) - buffer.AddEvent(&evdev.InputEvent{}) - buffer.AddEvent(&evdev.InputEvent{}) - t.Len(buffer.events, 3) -} - -func (t *EventBufferTests) TestEventBufferSendEvents() { - t.Run("3 Events", func() { - buffer := NewEventBuffer(t.device) - buffer.AddEvent(&evdev.InputEvent{}) - buffer.AddEvent(&evdev.InputEvent{}) - buffer.AddEvent(&evdev.InputEvent{}) - errs := buffer.SendEvents() - - t.Len(errs, 0) - t.device.AssertNumberOfCalls(t.T(), "WriteOne", 4) - }) - - t.Run("No Events", func() { - buffer := NewEventBuffer(t.device) - errs := buffer.SendEvents() - - t.Len(errs, 0) - t.device.AssertNumberOfCalls(t.T(), "WriteOne", 0) - }) - - t.Run("Bad Event", func() { - t.writeOneCall.Unset() - t.writeOneCall = t.device.On("WriteOne").Return(errors.New("Fail")) - - buffer := NewEventBuffer(t.device) - buffer.AddEvent(&evdev.InputEvent{}) - errs := buffer.SendEvents() - t.Len(errs, 2) - }) - +func (t *EventBufferTests) TestEventBuffer() { + + t.Run("AddEvent", func() { + t.buffer.AddEvent(&evdev.InputEvent{}) + t.buffer.AddEvent(&evdev.InputEvent{}) + t.buffer.AddEvent(&evdev.InputEvent{}) + t.Len(t.buffer.events, 3) + }) + + t.Run("SendEvents", func() { + t.Run("3 Events", func() { + writeOneCall := t.device.On("WriteOne").Return(nil) + + t.buffer.AddEvent(&evdev.InputEvent{}) + t.buffer.AddEvent(&evdev.InputEvent{}) + t.buffer.AddEvent(&evdev.InputEvent{}) + errs := t.buffer.SendEvents() + + t.Len(errs, 0) + t.device.AssertNumberOfCalls(t.T(), "WriteOne", 4) + + writeOneCall.Unset() + }) + + t.Run("No Events", func() { + writeOneCall := t.device.On("WriteOne").Return(nil) + + errs := t.buffer.SendEvents() + + t.Len(errs, 0) + t.device.AssertNumberOfCalls(t.T(), "WriteOne", 0) + + writeOneCall.Unset() + }) + + t.Run("Bad Event", func() { + writeOneCall := t.device.On("WriteOne").Return(errors.New("Fail")) + + t.buffer.AddEvent(&evdev.InputEvent{}) + errs := t.buffer.SendEvents() + t.Len(errs, 2) + + writeOneCall.Unset() + }) + }) } diff --git a/internal/virtualdevice/init.go b/internal/virtualdevice/init.go new file mode 100644 index 0000000..14f1c04 --- /dev/null +++ b/internal/virtualdevice/init.go @@ -0,0 +1,165 @@ +package virtualdevice + +import ( + "fmt" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/eventcodes" + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" +) + +// NewEventBuffer takes a virtual device config specification, creates the underlying +// evdev.InputDevice, and wraps it in a buffered event emitter. +func NewEventBuffer(config configparser.DeviceConfigVirtual) (*EventBuffer, error) { + deviceMap := make(map[string]*evdev.InputDevice) + + name := fmt.Sprintf("joyful-%s", config.Name) + + var capabilities map[evdev.EvType][]evdev.EvCode + + // todo: add tests for presets + switch config.Preset { + case DevicePresetGamepad: + capabilities = CapabilitiesPresetGamepad + case DevicePresetKeyboard: + capabilities = CapabilitiesPresetKeyboard + case DevicePresetJoystick: + capabilities = CapabilitiesPresetJoystick + case DevicePresetMouse: + capabilities = CapabilitiesPresetMouse + default: + capabilities = map[evdev.EvType][]evdev.EvCode{ + evdev.EV_KEY: makeButtons(config.NumButtons, config.Buttons), + evdev.EV_ABS: makeAxes(config.NumAxes, config.Axes), + evdev.EV_REL: makeRelativeAxes(config.NumRelativeAxes, config.RelativeAxes), + } + } + + device, err := evdev.CreateDevice( + name, + // TODO: placeholders. Who knows what these should actually be... + evdev.InputID{ + BusType: 0x03, + Vendor: 0x4711, + Product: 0x0816, + Version: 1, + }, + capabilities, + ) + + if err != nil { + return nil, err + } + + deviceMap[config.Name] = device + logger.Log(fmt.Sprintf( + "Created virtual device '%s' with %d buttons, %d axes, and %d relative axes", + name, + len(capabilities[evdev.EV_KEY]), + len(capabilities[evdev.EV_ABS]), + len(capabilities[evdev.EV_REL]), + )) + + return &EventBuffer{ + events: make([]*evdev.InputEvent, 0, 100), + Device: device, + Name: config.Name, + }, nil +} + +// TODO: these functions have a lot of duplication; we need to figure out how to refactor it cleanly +// without losing logging context... +func makeButtons(numButtons int, buttonList []string) []evdev.EvCode { + if numButtons > 0 && len(buttonList) > 0 { + logger.Log("'num_buttons' and 'buttons' both specified, ignoring 'num_buttons'") + } + + if numButtons > VirtualDeviceMaxButtons { + numButtons = VirtualDeviceMaxButtons + logger.Logf("Limiting virtual device buttons to %d", VirtualDeviceMaxButtons) + } + + if len(buttonList) > 0 { + buttons := make([]evdev.EvCode, 0, len(buttonList)) + for _, codeStr := range buttonList { + code, err := eventcodes.ParseCode(codeStr, "BTN") + if err != nil { + logger.LogError(err, "Failed to create button, skipping") + continue + } + buttons = append(buttons, code) + } + return buttons + } + + buttons := make([]evdev.EvCode, numButtons) + + for i := 0; i < numButtons; i++ { + buttons[i] = eventcodes.ButtonFromIndex[i] + } + + return buttons +} + +func makeAxes(numAxes int, axisList []string) []evdev.EvCode { + if numAxes > 0 && len(axisList) > 0 { + logger.Log("'num_axes' and 'axes' both specified, ignoring 'num_axes'") + } + + if len(axisList) > 0 { + axes := make([]evdev.EvCode, 0, len(axisList)) + for _, codeStr := range axisList { + code, err := eventcodes.ParseCode(codeStr, "ABS") + if err != nil { + logger.LogError(err, "Failed to create axis, skipping") + continue + } + axes = append(axes, code) + } + return axes + } + + if numAxes > 8 { + numAxes = 8 + logger.Log("Limiting virtual device axes to 8") + } + + axes := make([]evdev.EvCode, numAxes) + for i := 0; i < numAxes; i++ { + axes[i] = evdev.EvCode(i) + } + + return axes +} + +func makeRelativeAxes(numAxes int, axisList []string) []evdev.EvCode { + if numAxes > 0 && len(axisList) > 0 { + logger.Log("'num_rel_axes' and 'rel_axes' both specified, ignoring 'num_rel_axes'") + } + + if len(axisList) > 0 { + axes := make([]evdev.EvCode, 0, len(axisList)) + for _, codeStr := range axisList { + code, err := eventcodes.ParseCode(codeStr, "REL") + if err != nil { + logger.LogError(err, "Failed to create axis, skipping") + continue + } + axes = append(axes, code) + } + return axes + } + + if numAxes > 10 { + numAxes = 10 + logger.Log("Limiting virtual device relative axes to 10") + } + + axes := make([]evdev.EvCode, numAxes) + for i := 0; i < numAxes; i++ { + axes[i] = evdev.EvCode(i) + } + + return axes +} diff --git a/internal/config/devices_test.go b/internal/virtualdevice/init_test.go similarity index 91% rename from internal/config/devices_test.go rename to internal/virtualdevice/init_test.go index ad3b624..a6e631c 100644 --- a/internal/config/devices_test.go +++ b/internal/virtualdevice/init_test.go @@ -1,4 +1,4 @@ -package config +package virtualdevice import ( "testing" @@ -7,15 +7,15 @@ import ( "github.com/stretchr/testify/suite" ) -type DevicesConfigTests struct { +type InitTests struct { suite.Suite } -func TestRunnerDevicesConfig(t *testing.T) { - suite.Run(t, new(DevicesConfigTests)) +func TestRunnerInit(t *testing.T) { + suite.Run(t, new(InitTests)) } -func (t *DevicesConfigTests) TestMakeButtons() { +func (t *InitTests) TestMakeButtons() { t.Run("Maximum buttons", func() { buttons := makeButtons(VirtualDeviceMaxButtons, []string{}) t.Equal(VirtualDeviceMaxButtons, len(buttons)) @@ -44,7 +44,7 @@ func (t *DevicesConfigTests) TestMakeButtons() { }) } -func (t *DevicesConfigTests) TestMakeAxes() { +func (t *InitTests) TestMakeAxes() { t.Run("8 axes", func() { axes := makeAxes(8, []string{}) t.Equal(8, len(axes)) @@ -81,7 +81,7 @@ func (t *DevicesConfigTests) TestMakeAxes() { }) } -func (t *DevicesConfigTests) TestMakeRelativeAxes() { +func (t *InitTests) TestMakeRelativeAxes() { t.Run("10 axes", func() { axes := makeRelativeAxes(10, []string{}) t.Equal(10, len(axes)) diff --git a/internal/config/variables.go b/internal/virtualdevice/variables.go similarity index 71% rename from internal/config/variables.go rename to internal/virtualdevice/variables.go index 6e62977..11adb46 100644 --- a/internal/config/variables.go +++ b/internal/virtualdevice/variables.go @@ -1,114 +1,16 @@ -package config +package virtualdevice -import ( - "github.com/holoplot/go-evdev" -) +import "github.com/holoplot/go-evdev" const ( - DeviceTypePhysical = "physical" - DeviceTypeVirtual = "virtual" - DevicePresetKeyboard = "keyboard" DevicePresetGamepad = "gamepad" DevicePresetJoystick = "joystick" DevicePresetMouse = "mouse" - RuleTypeButton = "button" - RuleTypeButtonCombo = "button-combo" - RuleTypeButtonLatched = "button-latched" - RuleTypeAxis = "axis" - RuleTypeAxisCombined = "axis-combined" - RuleTypeAxisToButton = "axis-to-button" - RuleTypeAxisToRelaxis = "axis-to-relaxis" - RuleTypeModeSelect = "mode-select" - - CodePrefixButton = "BTN" - CodePrefixKey = "KEY" - CodePrefixAxis = "ABS" - CodePrefixRelaxis = "REL" - VirtualDeviceMaxButtons = 74 ) -var ( - ButtonFromIndex = []evdev.EvCode{ - evdev.BTN_TRIGGER, - evdev.BTN_THUMB, - evdev.BTN_THUMB2, - evdev.BTN_TOP, - evdev.BTN_TOP2, - evdev.BTN_PINKIE, - evdev.BTN_BASE, - evdev.BTN_BASE2, - evdev.BTN_BASE3, - 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, - evdev.BTN_TRIGGER_HAPPY4, - evdev.BTN_TRIGGER_HAPPY5, - evdev.BTN_TRIGGER_HAPPY6, - evdev.BTN_TRIGGER_HAPPY7, - evdev.BTN_TRIGGER_HAPPY8, - evdev.BTN_TRIGGER_HAPPY9, - evdev.BTN_TRIGGER_HAPPY10, - evdev.BTN_TRIGGER_HAPPY11, - evdev.BTN_TRIGGER_HAPPY12, - evdev.BTN_TRIGGER_HAPPY13, - evdev.BTN_TRIGGER_HAPPY14, - evdev.BTN_TRIGGER_HAPPY15, - evdev.BTN_TRIGGER_HAPPY16, - evdev.BTN_TRIGGER_HAPPY17, - evdev.BTN_TRIGGER_HAPPY18, - evdev.BTN_TRIGGER_HAPPY19, - evdev.BTN_TRIGGER_HAPPY20, - evdev.BTN_TRIGGER_HAPPY21, - evdev.BTN_TRIGGER_HAPPY22, - evdev.BTN_TRIGGER_HAPPY23, - evdev.BTN_TRIGGER_HAPPY24, - evdev.BTN_TRIGGER_HAPPY25, - evdev.BTN_TRIGGER_HAPPY26, - evdev.BTN_TRIGGER_HAPPY27, - evdev.BTN_TRIGGER_HAPPY28, - evdev.BTN_TRIGGER_HAPPY29, - evdev.BTN_TRIGGER_HAPPY30, - evdev.BTN_TRIGGER_HAPPY31, - evdev.BTN_TRIGGER_HAPPY32, - evdev.BTN_TRIGGER_HAPPY33, - evdev.BTN_TRIGGER_HAPPY34, - evdev.BTN_TRIGGER_HAPPY35, - evdev.BTN_TRIGGER_HAPPY36, - evdev.BTN_TRIGGER_HAPPY37, - 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), - } -) - // Device Presets var ( CapabilitiesPresetGamepad = map[evdev.EvType][]evdev.EvCode{ From 8a903e0703aeec7ff903ed6f8b8e8701393d5c49 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Fri, 5 Sep 2025 21:17:55 +0000 Subject: [PATCH 12/12] Make enum values typed strings (#18) This also moves validation into the parsing process and refactors a bunch of code related to the config. Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/18 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- cmd/joyful/config.go | 5 +- internal/configparser/deviceconfig.go | 31 ++++ internal/configparser/deviceconfigphysical.go | 35 +++++ internal/configparser/devicetype.go | 40 ++++++ internal/configparser/ruleconfig.go | 60 ++++++++ internal/configparser/ruletype.go | 53 +++++++ internal/configparser/schema.go | 134 +----------------- internal/configparser/variables.go | 15 -- internal/mappingrules/init_rules.go | 20 +-- internal/mappingrules/variables.go | 12 -- 10 files changed, 232 insertions(+), 173 deletions(-) create mode 100644 internal/configparser/deviceconfig.go create mode 100644 internal/configparser/deviceconfigphysical.go create mode 100644 internal/configparser/devicetype.go create mode 100644 internal/configparser/ruleconfig.go create mode 100644 internal/configparser/ruletype.go delete mode 100644 internal/configparser/variables.go delete mode 100644 internal/mappingrules/variables.go diff --git a/cmd/joyful/config.go b/cmd/joyful/config.go index 2b43380..64d6b2d 100644 --- a/cmd/joyful/config.go +++ b/cmd/joyful/config.go @@ -2,7 +2,6 @@ package main import ( "context" - "strings" "sync" "git.annabunches.net/annabunches/joyful/internal/configparser" @@ -16,7 +15,7 @@ func initPhysicalDevices(conf *configparser.Config) map[string]*evdev.InputDevic pDeviceMap := make(map[string]*evdev.InputDevice) for _, devConfig := range conf.Devices { - if strings.ToLower(devConfig.Type) != configparser.DeviceTypePhysical { + if devConfig.Type != configparser.DeviceTypePhysical { continue } @@ -71,7 +70,7 @@ func initVirtualBuffers(config *configparser.Config) (map[string]*evdev.InputDev vBuffersByDevice := make(map[*evdev.InputDevice]*virtualdevice.EventBuffer) for _, devConfig := range config.Devices { - if strings.ToLower(devConfig.Type) != configparser.DeviceTypeVirtual { + if devConfig.Type != configparser.DeviceTypeVirtual { continue } diff --git a/internal/configparser/deviceconfig.go b/internal/configparser/deviceconfig.go new file mode 100644 index 0000000..eafd8ca --- /dev/null +++ b/internal/configparser/deviceconfig.go @@ -0,0 +1,31 @@ +package configparser + +// These top-level structs use custom unmarshaling to unpack each available sub-type +type DeviceConfig struct { + Type DeviceType + Config interface{} +} + +func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { + metaConfig := &struct { + Type DeviceType + }{} + err := unmarshal(metaConfig) + if err != nil { + return err + } + dc.Type = metaConfig.Type + + err = nil + switch metaConfig.Type { + case DeviceTypePhysical: + config := DeviceConfigPhysical{} + err = unmarshal(&config) + dc.Config = config + case DeviceTypeVirtual: + config := DeviceConfigVirtual{} + err = unmarshal(&config) + dc.Config = config + } + return err +} diff --git a/internal/configparser/deviceconfigphysical.go b/internal/configparser/deviceconfigphysical.go new file mode 100644 index 0000000..ecb5255 --- /dev/null +++ b/internal/configparser/deviceconfigphysical.go @@ -0,0 +1,35 @@ +package configparser + +type DeviceConfigPhysical struct { + Name string + DeviceName string `yaml:"device_name,omitempty"` + DevicePath string `yaml:"device_path,omitempty"` + Lock bool +} + +// TODO: custom yaml unmarshaling is obtuse; do we really need to do all of this work +// just to set a single default value? +func (dc *DeviceConfigPhysical) UnmarshalYAML(unmarshal func(data interface{}) error) error { + var raw struct { + Name string + DeviceName string `yaml:"device_name"` + DevicePath string `yaml:"device_path"` + Lock bool `yaml:"lock,omitempty"` + } + + // Set non-standard defaults + raw.Lock = true + + err := unmarshal(&raw) + if err != nil { + return err + } + + *dc = DeviceConfigPhysical{ + Name: raw.Name, + DeviceName: raw.DeviceName, + DevicePath: raw.DevicePath, + Lock: raw.Lock, + } + return nil +} diff --git a/internal/configparser/devicetype.go b/internal/configparser/devicetype.go new file mode 100644 index 0000000..7640304 --- /dev/null +++ b/internal/configparser/devicetype.go @@ -0,0 +1,40 @@ +package configparser + +import ( + "fmt" + "strings" +) + +type DeviceType string + +const ( + DeviceTypeNone DeviceType = "" + DeviceTypePhysical DeviceType = "physical" + DeviceTypeVirtual DeviceType = "virtual" +) + +var ( + deviceTypeMap = map[string]DeviceType{ + "physical": DeviceTypePhysical, + "virtual": DeviceTypeVirtual, + } +) + +func ParseDeviceType(in string) (DeviceType, error) { + deviceType, ok := deviceTypeMap[strings.ToLower(in)] + if !ok { + return DeviceTypeNone, fmt.Errorf("invalid rule type '%s'", in) + } + return deviceType, nil +} + +func (rt *DeviceType) UnmarshalYAML(unmarshal func(data interface{}) error) error { + var raw string + err := unmarshal(&raw) + if err != nil { + return err + } + + *rt, err = ParseDeviceType(raw) + return err +} diff --git a/internal/configparser/ruleconfig.go b/internal/configparser/ruleconfig.go new file mode 100644 index 0000000..b41e339 --- /dev/null +++ b/internal/configparser/ruleconfig.go @@ -0,0 +1,60 @@ +package configparser + +type RuleConfig struct { + Type RuleType + Name string + Modes []string + Config interface{} +} + +func (dc *RuleConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { + metaConfig := &struct { + Type RuleType + Name string + Modes []string + }{} + err := unmarshal(metaConfig) + if err != nil { + return err + } + dc.Type = metaConfig.Type + dc.Name = metaConfig.Name + dc.Modes = metaConfig.Modes + + switch dc.Type { + case RuleTypeButton: + config := RuleConfigButton{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeButtonCombo: + config := RuleConfigButtonCombo{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeButtonLatched: + config := RuleConfigButtonLatched{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxis: + config := RuleConfigAxis{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisCombined: + config := RuleConfigAxisCombined{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisToButton: + config := RuleConfigAxisToButton{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeAxisToRelaxis: + config := RuleConfigAxisToRelaxis{} + err = unmarshal(&config) + dc.Config = config + case RuleTypeModeSelect: + config := RuleConfigModeSelect{} + err = unmarshal(&config) + dc.Config = config + } + + return err +} diff --git a/internal/configparser/ruletype.go b/internal/configparser/ruletype.go new file mode 100644 index 0000000..7f43001 --- /dev/null +++ b/internal/configparser/ruletype.go @@ -0,0 +1,53 @@ +package configparser + +import ( + "fmt" + "strings" +) + +// TODO: maybe these want to live somewhere other than configparser? +type RuleType string + +const ( + RuleTypeNone RuleType = "" + RuleTypeButton RuleType = "button" + RuleTypeButtonCombo RuleType = "button-combo" + RuleTypeButtonLatched RuleType = "button-latched" + RuleTypeAxis RuleType = "axis" + RuleTypeAxisCombined RuleType = "axis-combined" + RuleTypeAxisToButton RuleType = "axis-to-button" + RuleTypeAxisToRelaxis RuleType = "axis-to-relaxis" + RuleTypeModeSelect RuleType = "mode-select" +) + +var ( + ruleTypeMap = map[string]RuleType{ + "button": RuleTypeButton, + "button-combo": RuleTypeButtonCombo, + "button-latched": RuleTypeButtonLatched, + "axis": RuleTypeAxis, + "axis-combined": RuleTypeAxisCombined, + "axis-to-button": RuleTypeAxisToButton, + "axis-to-relaxis": RuleTypeAxisToRelaxis, + "mode-select": RuleTypeModeSelect, + } +) + +func ParseRuleType(in string) (RuleType, error) { + ruleType, ok := ruleTypeMap[strings.ToLower(in)] + if !ok { + return RuleTypeNone, fmt.Errorf("invalid rule type '%s'", in) + } + return ruleType, nil +} + +func (rt *RuleType) UnmarshalYAML(unmarshal func(data interface{}) error) error { + var raw string + err := unmarshal(&raw) + if err != nil { + return err + } + + *rt, err = ParseRuleType(raw) + return err +} diff --git a/internal/configparser/schema.go b/internal/configparser/schema.go index 8b70521..942f873 100644 --- a/internal/configparser/schema.go +++ b/internal/configparser/schema.go @@ -1,38 +1,13 @@ -// These types comprise the YAML schema for configuring Joyful. -// The config files will be combined and then unmarshalled into this +// These types comprise the YAML schema that doesn't need custom unmarshalling. package configparser -import ( - "fmt" -) - type Config struct { Devices []DeviceConfig Modes []string Rules []RuleConfig } -// These top-level structs use custom unmarshaling to unpack each available sub-type -type DeviceConfig struct { - Type string - Config interface{} -} - -type RuleConfig struct { - Type string - Name string - Modes []string - Config interface{} -} - -type DeviceConfigPhysical struct { - Name string - DeviceName string `yaml:"device_name,omitempty"` - DevicePath string `yaml:"device_path,omitempty"` - Lock bool -} - // TODO: configure custom unmarshaling so we can overload Buttons, Axes, and RelativeAxes... type DeviceConfigVirtual struct { Name string @@ -116,110 +91,3 @@ type RuleTargetConfigRelaxis struct { type RuleTargetConfigModeSelect struct { Modes []string } - -func (dc *DeviceConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { - metaConfig := &struct { - Type string - }{} - err := unmarshal(metaConfig) - if err != nil { - return err - } - dc.Type = metaConfig.Type - - err = nil - switch metaConfig.Type { - case DeviceTypePhysical: - config := DeviceConfigPhysical{} - err = unmarshal(&config) - dc.Config = config - case DeviceTypeVirtual: - config := DeviceConfigVirtual{} - err = unmarshal(&config) - dc.Config = config - default: - err = fmt.Errorf("invalid device type '%s'", dc.Type) - } - return err -} - -func (dc *RuleConfig) UnmarshalYAML(unmarshal func(data interface{}) error) error { - metaConfig := &struct { - Type string - Name string - Modes []string - }{} - err := unmarshal(metaConfig) - if err != nil { - return err - } - dc.Type = metaConfig.Type - dc.Name = metaConfig.Name - dc.Modes = metaConfig.Modes - - switch dc.Type { - case RuleTypeButton: - config := RuleConfigButton{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeButtonCombo: - config := RuleConfigButtonCombo{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeButtonLatched: - config := RuleConfigButtonLatched{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeAxis: - config := RuleConfigAxis{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeAxisCombined: - config := RuleConfigAxisCombined{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeAxisToButton: - config := RuleConfigAxisToButton{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeAxisToRelaxis: - config := RuleConfigAxisToRelaxis{} - err = unmarshal(&config) - dc.Config = config - case RuleTypeModeSelect: - config := RuleConfigModeSelect{} - err = unmarshal(&config) - dc.Config = config - default: - err = fmt.Errorf("invalid rule type '%s'", dc.Type) - } - - return err -} - -// TODO: custom yaml unmarshaling is obtuse; do we really need to do all of this work -// just to set a single default value? -func (dc *DeviceConfigPhysical) UnmarshalYAML(unmarshal func(data interface{}) error) error { - var raw struct { - Name string - DeviceName string `yaml:"device_name"` - DevicePath string `yaml:"device_path"` - Lock bool `yaml:"lock,omitempty"` - } - - // Set non-standard defaults - raw.Lock = true - - err := unmarshal(&raw) - if err != nil { - return err - } - - *dc = DeviceConfigPhysical{ - Name: raw.Name, - DeviceName: raw.DeviceName, - DevicePath: raw.DevicePath, - Lock: raw.Lock, - } - return nil -} diff --git a/internal/configparser/variables.go b/internal/configparser/variables.go deleted file mode 100644 index 77e2b9c..0000000 --- a/internal/configparser/variables.go +++ /dev/null @@ -1,15 +0,0 @@ -package configparser - -const ( - DeviceTypePhysical = "physical" - DeviceTypeVirtual = "virtual" - - RuleTypeButton = "button" - RuleTypeButtonCombo = "button-combo" - RuleTypeButtonLatched = "button-latched" - RuleTypeAxis = "axis" - RuleTypeAxisCombined = "axis-combined" - RuleTypeAxisToButton = "axis-to-button" - RuleTypeAxisToRelaxis = "axis-to-relaxis" - RuleTypeModeSelect = "mode-select" -) diff --git a/internal/mappingrules/init_rules.go b/internal/mappingrules/init_rules.go index 7ea0ea4..f621875 100644 --- a/internal/mappingrules/init_rules.go +++ b/internal/mappingrules/init_rules.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "slices" - "strings" "git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/logger" @@ -33,24 +32,25 @@ func NewRule(config configparser.RuleConfig, pDevs map[string]Device, vDevs map[ base := NewMappingRuleBase(config.Name, config.Modes) - switch strings.ToLower(config.Type) { - case RuleTypeButton: + switch config.Type { + case configparser.RuleTypeButton: newRule, err = NewMappingRuleButton(config.Config.(configparser.RuleConfigButton), pDevs, vDevs, base) - case RuleTypeButtonCombo: + case configparser.RuleTypeButtonCombo: newRule, err = NewMappingRuleButtonCombo(config.Config.(configparser.RuleConfigButtonCombo), pDevs, vDevs, base) - case RuleTypeButtonLatched: + case configparser.RuleTypeButtonLatched: newRule, err = NewMappingRuleButtonLatched(config.Config.(configparser.RuleConfigButtonLatched), pDevs, vDevs, base) - case RuleTypeAxis: + case configparser.RuleTypeAxis: newRule, err = NewMappingRuleAxis(config.Config.(configparser.RuleConfigAxis), pDevs, vDevs, base) - case RuleTypeAxisCombined: + case configparser.RuleTypeAxisCombined: newRule, err = NewMappingRuleAxisCombined(config.Config.(configparser.RuleConfigAxisCombined), pDevs, vDevs, base) - case RuleTypeAxisToButton: + case configparser.RuleTypeAxisToButton: newRule, err = NewMappingRuleAxisToButton(config.Config.(configparser.RuleConfigAxisToButton), pDevs, vDevs, base) - case RuleTypeAxisToRelaxis: + case configparser.RuleTypeAxisToRelaxis: newRule, err = NewMappingRuleAxisToRelaxis(config.Config.(configparser.RuleConfigAxisToRelaxis), pDevs, vDevs, base) - case RuleTypeModeSelect: + case configparser.RuleTypeModeSelect: newRule, err = NewMappingRuleModeSelect(config.Config.(configparser.RuleConfigModeSelect), pDevs, modes, base) default: + // Shouldn't actually be possible to get here... err = fmt.Errorf("bad rule type '%s' for rule '%s'", config.Type, config.Name) } diff --git a/internal/mappingrules/variables.go b/internal/mappingrules/variables.go deleted file mode 100644 index d9a171b..0000000 --- a/internal/mappingrules/variables.go +++ /dev/null @@ -1,12 +0,0 @@ -package mappingrules - -const ( - RuleTypeButton = "button" - RuleTypeButtonCombo = "button-combo" - RuleTypeButtonLatched = "button-latched" - RuleTypeAxis = "axis" - RuleTypeAxisCombined = "axis-combined" - RuleTypeAxisToButton = "axis-to-button" - RuleTypeAxisToRelaxis = "axis-to-relaxis" - RuleTypeModeSelect = "mode-select" -)