From 5e005478f10d470e0491921c58aaa062db843498 Mon Sep 17 00:00:00 2001 From: Anna Rose Wiggins Date: Mon, 15 Sep 2025 17:55:55 +0000 Subject: [PATCH] Basic hat support. (#20) Reviewed-on: https://git.annabunches.net/anna/joyful/pulls/20 Co-authored-by: Anna Rose Wiggins Co-committed-by: Anna Rose Wiggins --- docs/examples/ruletypes.yml | 21 +++++++-- docs/readme.md | 1 + internal/configparser/ruleconfig.go | 4 ++ internal/configparser/ruletarget.go | 6 +++ internal/configparser/ruletype.go | 2 + internal/configparser/schema.go | 5 +++ internal/mappingrules/init_rules.go | 2 + internal/mappingrules/interfaces.go | 4 +- internal/mappingrules/mapping_rule_hat.go | 45 +++++++++++++++++++ internal/mappingrules/rule_target_hat.go | 53 +++++++++++++++++++++++ internal/virtualdevice/variables.go | 9 ++++ readme.md | 12 ++--- 12 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 internal/mappingrules/mapping_rule_hat.go create mode 100644 internal/mappingrules/rule_target_hat.go diff --git a/docs/examples/ruletypes.yml b/docs/examples/ruletypes.yml index fe54b15..8bc0fe8 100644 --- a/docs/examples/ruletypes.yml +++ b/docs/examples/ruletypes.yml @@ -70,6 +70,17 @@ rules: device: main axis: RZ + # Hat mapping. Hats are technically an axis, but only output -1, 0, or 1, so we don't normalize + # them to an output range, we just pass them through mostly unmodified + - type: hat + input: + device: flightstick + inverted: true # hats do support inversion. As with other rule types, this only has an effect on *inputs*. + hat: hat0x # a typical joystick hat actually has 2 hat axes: x and y + output: + device: main + hat: hat0x + # Straightforward button mapping - type: button input: @@ -111,8 +122,9 @@ rules: input: device: flightstick axis: ABS_RY # This axis commonly represents thumbsticks - deadzone_start: 0 - deadzone_end: 30000 + deadzones: + - start: 0 + end: 30000 output: device: main button: BTN_BASE4 @@ -129,8 +141,9 @@ rules: input: device: flightstick axis: ABS_Z - deadzone_start: 0 - deadzone_end: 500 + deadzones: + - start: 0 + end: 500 output: device: mouse button: REL_WHEEL \ No newline at end of file diff --git a/docs/readme.md b/docs/readme.md index 5544f1b..7ac1945 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -48,6 +48,7 @@ All `rules` must have a `type` parameter. Valid values for this parameter are: * `axis-combined` - a mapping that combines 2 input axes into a single output axis. * `axis-to-button` - causes an axis input to produce a button output. This can be repeated with variable speed proportional to the axis' input value * `axis-to-relaxis` - like axis-to-button, but produces a "relative axis" output value. This is useful for simulating mouse scrollwheel and movement events. +* `hat` - a special type of axis with ternary output. Each joystick hat will typically be 2 hat axes named `ABS_HAT0X` / `ABS_HAT0Y`, where the `0` is an index between 0 - 3. So for a typical hat you would define 2 `hat` rules. Configuration options for each rule type vary. See [examples/ruletypes.yml](examples/ruletypes.yml) for an example of each type with all options specified. diff --git a/internal/configparser/ruleconfig.go b/internal/configparser/ruleconfig.go index b41e339..53c3c35 100644 --- a/internal/configparser/ruleconfig.go +++ b/internal/configparser/ruleconfig.go @@ -54,6 +54,10 @@ func (dc *RuleConfig) UnmarshalYAML(unmarshal func(data interface{}) error) erro config := RuleConfigModeSelect{} err = unmarshal(&config) dc.Config = config + case RuleTypeHat: + config := RuleConfigHat{} + err = unmarshal(&config) + dc.Config = config } return err diff --git a/internal/configparser/ruletarget.go b/internal/configparser/ruletarget.go index 094ea7b..2a2a12a 100644 --- a/internal/configparser/ruletarget.go +++ b/internal/configparser/ruletarget.go @@ -31,3 +31,9 @@ type RuleTargetConfigRelaxis struct { type RuleTargetConfigModeSelect struct { Modes []string } + +type RuleTargetConfigHat struct { + Device string + Hat string + Inverted bool +} diff --git a/internal/configparser/ruletype.go b/internal/configparser/ruletype.go index 7f43001..d305570 100644 --- a/internal/configparser/ruletype.go +++ b/internal/configparser/ruletype.go @@ -18,6 +18,7 @@ const ( RuleTypeAxisToButton RuleType = "axis-to-button" RuleTypeAxisToRelaxis RuleType = "axis-to-relaxis" RuleTypeModeSelect RuleType = "mode-select" + RuleTypeHat RuleType = "hat" ) var ( @@ -30,6 +31,7 @@ var ( "axis-to-button": RuleTypeAxisToButton, "axis-to-relaxis": RuleTypeAxisToRelaxis, "mode-select": RuleTypeModeSelect, + "hat": RuleTypeHat, } ) diff --git a/internal/configparser/schema.go b/internal/configparser/schema.go index f7a0035..55ddb24 100644 --- a/internal/configparser/schema.go +++ b/internal/configparser/schema.go @@ -40,6 +40,11 @@ type RuleConfigAxis struct { Output RuleTargetConfigAxis } +type RuleConfigHat struct { + Input RuleTargetConfigHat + Output RuleTargetConfigHat +} + type RuleConfigAxisCombined struct { InputLower RuleTargetConfigAxis `yaml:"input_lower,omitempty"` InputUpper RuleTargetConfigAxis `yaml:"input_upper,omitempty"` diff --git a/internal/mappingrules/init_rules.go b/internal/mappingrules/init_rules.go index f621875..28d4ea8 100644 --- a/internal/mappingrules/init_rules.go +++ b/internal/mappingrules/init_rules.go @@ -49,6 +49,8 @@ func NewRule(config configparser.RuleConfig, pDevs map[string]Device, vDevs map[ newRule, err = NewMappingRuleAxisToRelaxis(config.Config.(configparser.RuleConfigAxisToRelaxis), pDevs, vDevs, base) case configparser.RuleTypeModeSelect: newRule, err = NewMappingRuleModeSelect(config.Config.(configparser.RuleConfigModeSelect), pDevs, modes, base) + case configparser.RuleTypeHat: + newRule, err = NewMappingRuleHat(config.Config.(configparser.RuleConfigHat), pDevs, vDevs, 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/interfaces.go b/internal/mappingrules/interfaces.go index 33b290a..96594c6 100644 --- a/internal/mappingrules/interfaces.go +++ b/internal/mappingrules/interfaces.go @@ -22,9 +22,6 @@ type RuleTarget interface { // (e.g., inverting the value if Inverted == true) NormalizeValue(int32) int32 - // MatchEvent returns true if the provided device and input event are a match for this rule target - ValidateEvent(*evdev.InputDevice, *evdev.InputEvent) bool - // CreateEvent creates an event that can be emitted on a virtual device. // For RuleTargetModeSelect, this method modifies the active mode and returns nil. // @@ -35,6 +32,7 @@ type RuleTarget interface { // for most implementations. CreateEvent(int32, *string) *evdev.InputEvent + // MatchEvent returns true if the provided device and input event are a match for this rule target MatchEvent(device Device, event *evdev.InputEvent) bool } diff --git a/internal/mappingrules/mapping_rule_hat.go b/internal/mappingrules/mapping_rule_hat.go new file mode 100644 index 0000000..ba04323 --- /dev/null +++ b/internal/mappingrules/mapping_rule_hat.go @@ -0,0 +1,45 @@ +package mappingrules + +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 MappingRuleHat struct { + MappingRuleBase + Input *RuleTargetHat + Output *RuleTargetHat +} + +func NewMappingRuleHat(ruleConfig configparser.RuleConfigHat, + pDevs map[string]Device, + vDevs map[string]Device, + base MappingRuleBase) (*MappingRuleHat, error) { + + input, err := NewRuleTargetHatFromConfig(ruleConfig.Input, pDevs) + if err != nil { + return nil, err + } + + output, err := NewRuleTargetHatFromConfig(ruleConfig.Output, vDevs) + if err != nil { + return nil, err + } + + return &MappingRuleHat{ + MappingRuleBase: base, + Input: input, + Output: output, + }, nil +} + +func (rule *MappingRuleHat) MatchEvent(device Device, event *evdev.InputEvent, mode *string) (*evdev.InputDevice, *evdev.InputEvent) { + if !rule.MappingRuleBase.modeCheck(mode) || + !rule.Input.MatchEvent(device, event) { + return nil, nil + } + + // The cast here is safe because the interface is only ever different for unit tests + return rule.Output.Device.(*evdev.InputDevice), rule.Output.CreateEvent(rule.Input.NormalizeValue(event.Value), mode) +} diff --git a/internal/mappingrules/rule_target_hat.go b/internal/mappingrules/rule_target_hat.go new file mode 100644 index 0000000..464e559 --- /dev/null +++ b/internal/mappingrules/rule_target_hat.go @@ -0,0 +1,53 @@ +package mappingrules + +import ( + "fmt" + + "git.annabunches.net/annabunches/joyful/internal/configparser" + "git.annabunches.net/annabunches/joyful/internal/eventcodes" + "github.com/holoplot/go-evdev" +) + +type RuleTargetHat struct { + Device Device + Hat evdev.EvCode + Inverted bool +} + +func NewRuleTargetHatFromConfig(config configparser.RuleTargetConfigHat, devs map[string]Device) (*RuleTargetHat, error) { + dev, ok := devs[config.Device] + if !ok { + return nil, fmt.Errorf("device '%s' not found", config.Device) + } + + code, err := eventcodes.ParseCode(config.Hat, eventcodes.CodePrefixAxis) + if err != nil { + return nil, err + } + + return &RuleTargetHat{ + Device: dev, + Hat: code, + Inverted: config.Inverted, + }, nil +} + +func (target *RuleTargetHat) NormalizeValue(value int32) int32 { + if !target.Inverted { + return value + } + + return value * -1 +} + +func (target *RuleTargetHat) CreateEvent(value int32, _ *string) *evdev.InputEvent { + return &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: target.Hat, + Value: value, + } +} + +func (target *RuleTargetHat) MatchEvent(device Device, event *evdev.InputEvent) bool { + return device == target.Device && event.Code == target.Hat +} diff --git a/internal/virtualdevice/variables.go b/internal/virtualdevice/variables.go index 11adb46..7102bd5 100644 --- a/internal/virtualdevice/variables.go +++ b/internal/virtualdevice/variables.go @@ -49,6 +49,15 @@ var ( evdev.ABS_RZ, evdev.ABS_THROTTLE, // Also called "Slider" or "Slider1" evdev.ABS_RUDDER, // Also called "Dial", "Slider2", or "RSlider" + // Hats + evdev.ABS_HAT0X, + evdev.ABS_HAT0Y, + evdev.ABS_HAT1X, + evdev.ABS_HAT1Y, + evdev.ABS_HAT2X, + evdev.ABS_HAT2Y, + evdev.ABS_HAT3X, + evdev.ABS_HAT3Y, }, evdev.EV_KEY: { evdev.BTN_TRIGGER, diff --git a/readme.md b/readme.md index f9c0e88..afa5c8f 100644 --- a/readme.md +++ b/readme.md @@ -10,13 +10,13 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe ### Current Features -* Create virtual devices with up to 8 axes and 74 buttons. +* Create virtual devices with up to 8 axes, 4 hats, and 74 buttons. * Flexible rule system that allows several different types of rules, including: - * Simple 1:1 mappings of buttons and axes: Button1 -> VirtualButtonA + * Simple 1:1 mappings of buttons, axes, and hats: Button1 -> VirtualButtonA * Combination mappings: Button1 + Button2 -> VirtualButtonA * "Split" axis mapping: map sections of an axis to different outputs using deadzones. * "Combined" axis mapping: map two physical axes to one virtual axis. - * Axis -> button mapping with optional "proportional" repeat speed (i.e. repeat faster as the axis is engaged further) + * Axis -> Button mapping with optional "proportional" repeat speed (i.e. repeat faster as the axis is engaged further) * Axis -> Relative Axis mapping, for converting a joystick axis to mouse movement and scrollwheel events. * Define keyboard, mouse, and gamepad outputs in addition to joysticks. * Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. @@ -27,10 +27,10 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe * Macros - have a single input produce a sequence of button presses with configurable pauses. * Sequence combos - Button1, Button2, Button3 -> VirtualButtonA -* Hat support -* HIDRAW support for more button options. +* Hat -> Button and Button -> Hat support. +* HIDRAW support for more button options * Sensitivity Curves? -* Packaged builds non-Arch distributions. +* Packaged builds for non-Arch distributions. ## Configure