From 7a9a2ba9e279a4d1572a1a5dfaae9350e6dcaef3 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Tue, 5 Aug 2025 16:07:29 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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" -)