Compare commits

..

1 commit

Author SHA1 Message Date
1ac689cd04 Add the initial rough skeleton for a config tool. 2025-08-11 21:44:05 -04:00
29 changed files with 429 additions and 655 deletions

33
cmd/joyful-config/main.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
"strings"
"git.annabunches.net/annabunches/joyful/internal/configparser"
"git.annabunches.net/annabunches/joyful/internal/logger"
flag "github.com/spf13/pflag"
)
func getConfigDir(dir string) string {
configDir := strings.ReplaceAll(dir, "~", "${HOME}")
return os.ExpandEnv(configDir)
}
func main() {
var configDir string
flag.StringVarP(&configDir, "config", "c", "~/.config/joyful", "Directory to read configuration from.")
flag.Parse()
configDir = getConfigDir(configDir)
config, err := configparser.ParseConfig(configDir)
switch err.(type) {
case *configparser.EmptyConfigError:
config = &configparser.Config{}
default:
logger.FatalIfError(err, "Fatal error reading config")
}
fmt.Printf("Config: %v\n", config)
}

View file

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"strings"
"sync" "sync"
"git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/configparser"
@ -15,7 +16,7 @@ func initPhysicalDevices(conf *configparser.Config) map[string]*evdev.InputDevic
pDeviceMap := make(map[string]*evdev.InputDevice) pDeviceMap := make(map[string]*evdev.InputDevice)
for _, devConfig := range conf.Devices { for _, devConfig := range conf.Devices {
if devConfig.Type != configparser.DeviceTypePhysical { if strings.ToLower(devConfig.Type) != configparser.DeviceTypePhysical {
continue continue
} }
@ -70,7 +71,7 @@ func initVirtualBuffers(config *configparser.Config) (map[string]*evdev.InputDev
vBuffersByDevice := make(map[*evdev.InputDevice]*virtualdevice.EventBuffer) vBuffersByDevice := make(map[*evdev.InputDevice]*virtualdevice.EventBuffer)
for _, devConfig := range config.Devices { for _, devConfig := range config.Devices {
if devConfig.Type != configparser.DeviceTypeVirtual { if strings.ToLower(devConfig.Type) != configparser.DeviceTypeVirtual {
continue continue
} }

View file

@ -92,9 +92,8 @@ rules:
input: input:
device: left-stick device: left-stick
axis: RY axis: RY
deadzones: deadzone_start: 0
- start: 0 deadzone_end: 30500
end: 30500
output: output:
device: mouse device: mouse
axis: REL_WHEEL axis: REL_WHEEL
@ -109,9 +108,8 @@ rules:
input: input:
device: left-stick device: left-stick
axis: RY axis: RY
deadzones: deadzone_start: 29500
- start: 29500 deadzone_end: 64000
end: 64000
inverted: true inverted: true
output: output:
device: mouse device: mouse

View file

@ -1,6 +1,6 @@
devices: devices:
- name: primary - name: primary
type: Virtual type: virtual
preset: joystick preset: joystick
- name: secondary - name: secondary
type: virtual type: virtual

View file

@ -18,9 +18,8 @@ rules:
input: input:
device: flightstick device: flightstick
# To find reasonable values for your device's deadzones, use the evtest command # To find reasonable values for your device's deadzones, use the evtest command
deadzones: deadzone_start: 28000
- start: 28000 deadzone_end: 30000
end: 30000
inverted: false inverted: false
axis: ABS_X axis: ABS_X
output: output:
@ -34,9 +33,8 @@ rules:
# size value. This will create a deadzone that covers a range of deadzone_size, # size value. This will create a deadzone that covers a range of deadzone_size,
# centered on the center value. Note that if your deadzone_center is at the lower or upper end # centered on the center value. Note that if your deadzone_center is at the lower or upper end
# of the axis, the total size will still be as given; the deadzone will be "shifted" into bounds. # of the axis, the total size will still be as given; the deadzone will be "shifted" into bounds.
deadzones: deadzone_center: 29000
- center: 29000 deadzone_size: 2000
size: 2000
inverted: false inverted: false
axis: Y # The ABS_ prefix is optional axis: Y # The ABS_ prefix is optional
output: output:
@ -48,9 +46,8 @@ rules:
device: flightstick device: flightstick
# A final way to specify deadzones is to use a size percentage instead of an absolute size. # A final way to specify deadzones is to use a size percentage instead of an absolute size.
# This works exactly like deadzone_size, but calculates a percentage of the axis' total range. # This works exactly like deadzone_size, but calculates a percentage of the axis' total range.
deadzones: deadzone_center: 29000
- center: 29000 deadzone_size_percent: 5
size_percent: 5
inverted: false inverted: false
axis: Y # The ABS_ prefix is optional axis: Y # The ABS_ prefix is optional
output: output:
@ -70,17 +67,6 @@ rules:
device: main device: main
axis: RZ 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 # Straightforward button mapping
- type: button - type: button
input: input:
@ -122,9 +108,8 @@ rules:
input: input:
device: flightstick device: flightstick
axis: ABS_RY # This axis commonly represents thumbsticks axis: ABS_RY # This axis commonly represents thumbsticks
deadzones: deadzone_start: 0
- start: 0 deadzone_end: 30000
end: 30000
output: output:
device: main device: main
button: BTN_BASE4 button: BTN_BASE4
@ -141,9 +126,8 @@ rules:
input: input:
device: flightstick device: flightstick
axis: ABS_Z axis: ABS_Z
deadzones: deadzone_start: 0
- start: 0 deadzone_end: 500
end: 500
output: output:
device: mouse device: mouse
button: REL_WHEEL button: REL_WHEEL

View file

@ -48,7 +48,6 @@ 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-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-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. * `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. Configuration options for each rule type vary. See [examples/ruletypes.yml](examples/ruletypes.yml) for an example of each type with all options specified.
@ -74,17 +73,13 @@ evtest | grep BTN_
**NOTE: For most axis mappings, you probably don't want to specify a deadzone!** Use deadzone configurations in your target game instead. Joyful-configured deadzones are intended to be used in conjunction with the `axis-to-button` and `axis-to-relaxis` input types, or when splitting an axis into multiple outputs. Using them with standard `axis` mappings may result in a loss of fidelity and "stuck" inputs. **NOTE: For most axis mappings, you probably don't want to specify a deadzone!** Use deadzone configurations in your target game instead. Joyful-configured deadzones are intended to be used in conjunction with the `axis-to-button` and `axis-to-relaxis` input types, or when splitting an axis into multiple outputs. Using them with standard `axis` mappings may result in a loss of fidelity and "stuck" inputs.
Axis inputs can define a list of deadzones. Each deadzone can be specified a few ways: There are three ways to specify deadzones:
* Define `start` and `end` to explicitly set the deadzone bounds. * Define `deadzone_start` and `deadzone_end` to explicitly set the deadzone bounds.
* Define `center` and `size`; this will create a deadzone of the indicated size centered at the given axis position. * Define `deadzone_center` and `deadzone_size`; this will create a deadzone of the indicated size centered at the given axis position.
* Define `center` and `size_percent` to use a percentage of the total axis size. * Define `deadzone_center` and `deadzone_size_percent` to use a percentage of the total axis size.
In addition, deadzones can set `emit` to `true` and `emit_value` to a value that should be emitted when inside the deadzone. See <examples/ruletypes.yml> for usage examples.
**Note**: The `emit_value` is the final output value and should be between -32,768 and 32,767.
See the <examples/> directory for usage examples.
## Modes ## Modes

View file

@ -50,7 +50,7 @@ func getConfigFilePaths(directory string) ([]string, error) {
if err != nil { if err != nil {
return nil, errors.New("failed to create config directory at " + directory) return nil, errors.New("failed to create config directory at " + directory)
} else { } else {
return nil, errors.New("no config files found at " + directory) return nil, &EmptyConfigError{directory}
} }
} }
@ -63,5 +63,9 @@ func getConfigFilePaths(directory string) ([]string, error) {
paths = append(paths, filepath.Join(directory, file.Name())) paths = append(paths, filepath.Join(directory, file.Name()))
} }
if len(paths) == 0 {
return nil, &EmptyConfigError{directory}
}
return paths, nil return paths, nil
} }

View file

@ -1,31 +0,0 @@
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
}

View file

@ -1,35 +0,0 @@
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
}

View file

@ -1,40 +0,0 @@
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
}

View file

@ -0,0 +1,11 @@
package configparser
import "fmt"
type EmptyConfigError struct {
directory string
}
func (e *EmptyConfigError) Error() string {
return fmt.Sprintf("no config files found at %s", e.directory)
}

View file

@ -1,64 +0,0 @@
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
case RuleTypeHat:
config := RuleConfigHat{}
err = unmarshal(&config)
dc.Config = config
}
return err
}

View file

@ -1,39 +0,0 @@
package configparser
type RuleTargetConfigButton struct {
Device string
Button string
Inverted bool
}
type RuleTargetConfigAxis struct {
Device string
Axis string
Inverted bool
Deadzones []DeadzoneConfig
}
type DeadzoneConfig struct {
Center int32 `yaml:"center,omitempty"`
Size int32 `yaml:"size,omitempty"`
SizePercent int32 `yaml:"size_percent,omitempty"`
Start int32 `yaml:"start,omitempty"`
End int32 `yaml:"end,omitempty"`
Emit bool `yaml:"emit,omitempty"`
Value int32 `yaml:"emit_value,omitempty"`
}
type RuleTargetConfigRelaxis struct {
Device string
Axis string
}
type RuleTargetConfigModeSelect struct {
Modes []string
}
type RuleTargetConfigHat struct {
Device string
Hat string
Inverted bool
}

View file

@ -1,55 +0,0 @@
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"
RuleTypeHat RuleType = "hat"
)
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,
"hat": RuleTypeHat,
}
)
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
}

View file

@ -1,13 +1,38 @@
// These types comprise the YAML schema that doesn't need custom unmarshalling. // These types comprise the YAML schema for configuring Joyful.
// The config files will be combined and then unmarshalled into this
package configparser package configparser
import (
"fmt"
)
type Config struct { type Config struct {
Devices []DeviceConfig Devices []DeviceConfig
Modes []string Modes []string
Rules []RuleConfig 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... // TODO: configure custom unmarshaling so we can overload Buttons, Axes, and RelativeAxes...
type DeviceConfigVirtual struct { type DeviceConfigVirtual struct {
Name string Name string
@ -40,11 +65,6 @@ type RuleConfigAxis struct {
Output RuleTargetConfigAxis Output RuleTargetConfigAxis
} }
type RuleConfigHat struct {
Input RuleTargetConfigHat
Output RuleTargetConfigHat
}
type RuleConfigAxisCombined struct { type RuleConfigAxisCombined struct {
InputLower RuleTargetConfigAxis `yaml:"input_lower,omitempty"` InputLower RuleTargetConfigAxis `yaml:"input_lower,omitempty"`
InputUpper RuleTargetConfigAxis `yaml:"input_upper,omitempty"` InputUpper RuleTargetConfigAxis `yaml:"input_upper,omitempty"`
@ -70,3 +90,136 @@ type RuleConfigModeSelect struct {
Input RuleTargetConfigButton Input RuleTargetConfigButton
Output RuleTargetConfigModeSelect 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 *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
}

View file

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

View file

@ -1,98 +0,0 @@
package mappingrules
import (
"errors"
"fmt"
"git.annabunches.net/annabunches/joyful/internal/configparser"
"github.com/holoplot/go-evdev"
)
// TODO: need tests for multiple deadzones
// TODO: need tests for emitting deadzones
type Deadzone struct {
Start int32
End int32
Size int32
Emit bool
EmitValue int32
}
// DeadzoneState indicates whether a value is in a Deadzone and, if it is, whether the deadzone
// should emit an event
type DeadzoneState int
const (
// DeadzoneClear indicates the value is *not* in the deadzone.
DeadzoneClear DeadzoneState = iota
DeadzoneEmit
DeadzoneNoEmit
)
// calculateDeadzones produces the deadzone start and end values in absolute terms
func NewDeadzoneFromConfig(dzConfig configparser.DeadzoneConfig, device Device, axis evdev.EvCode) (Deadzone, error) {
dz := Deadzone{}
dz.Emit = dzConfig.Emit
dz.EmitValue = dzConfig.Value
var min, max int32
absInfoMap, err := device.AbsInfos()
if err != nil {
return dz, err
} else {
absInfo := absInfoMap[axis]
min = absInfo.Minimum
max = absInfo.Maximum
}
if dzConfig.Start != 0 || dzConfig.End != 0 {
dz.Start = Clamp(dzConfig.Start, min, max)
dz.End = Clamp(dzConfig.End, min, max)
if dz.Start > dz.End {
return dz, errors.New("deadzone end must be greater than deadzone start")
}
} else {
center := Clamp(dzConfig.Center, min, max)
var deadzoneSize int32
switch {
case dzConfig.Size != 0:
deadzoneSize = dzConfig.Size
case dzConfig.SizePercent != 0:
deadzoneSize = (max - min) / dzConfig.SizePercent
default:
return dz, fmt.Errorf("deadzone configured incorrectly; must define start and end or center and size")
}
dz.Start = center - deadzoneSize/2
dz.End = center + deadzoneSize/2
dz.Start, dz.End = clampAndShift(dz.Start, dz.End, min, max)
}
dz.Size = dz.End - dz.Start
return dz, nil
}
func CalculateDeadzoneSize(dzs []Deadzone) int32 {
var size int32
for _, dz := range dzs {
size += dz.Size
}
return size
}
// Match checks whether the target value is inside the deadzone.
// It returns a DeadzoneState enum and possibly an int32.
func (dz Deadzone) Match(value int32) (DeadzoneState, int32) {
if value < dz.Start || value > dz.End {
return DeadzoneClear, value
}
if dz.Emit {
return DeadzoneEmit, dz.EmitValue
}
return DeadzoneNoEmit, value
}

View file

@ -125,12 +125,8 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() {
t.Run("Invalid deadzone", func() { t.Run("Invalid deadzone", func() {
config := configparser.RuleTargetConfigAxis{Device: "test"} config := configparser.RuleTargetConfigAxis{Device: "test"}
config.Axis = "x" config.Axis = "x"
config.Deadzones = []configparser.DeadzoneConfig{ config.DeadzoneEnd = 100
{ config.DeadzoneStart = 1000
End: 100,
Start: 1000,
},
}
_, err := NewRuleTargetAxisFromConfig(config, t.devs) _, err := NewRuleTargetAxisFromConfig(config, t.devs)
t.NotNil(err) t.NotNil(err)
}) })
@ -149,21 +145,30 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() {
for _, tc := range relDeadzoneTestCases { for _, tc := range relDeadzoneTestCases {
t.Run(fmt.Sprintf("Relative Deadzone %d +- %d", tc.inCenter, tc.inSize), func() { t.Run(fmt.Sprintf("Relative Deadzone %d +- %d", tc.inCenter, tc.inSize), func() {
config := configparser.RuleTargetConfigAxis{ config := configparser.RuleTargetConfigAxis{
Device: "test", Device: "test",
Axis: "x", Axis: "x",
Deadzones: []configparser.DeadzoneConfig{{ DeadzoneCenter: tc.inCenter,
Center: tc.inCenter, DeadzoneSize: tc.inSize,
Size: tc.inSize,
}},
} }
rule, err := NewRuleTargetAxisFromConfig(config, t.devs) rule, err := NewRuleTargetAxisFromConfig(config, t.devs)
t.Nil(err) t.Nil(err)
t.Equal(tc.outStart, rule.Deadzones[0].Start) t.Equal(tc.outStart, rule.DeadzoneStart)
t.Equal(tc.outEnd, rule.Deadzones[0].End) t.Equal(tc.outEnd, rule.DeadzoneEnd)
}) })
} }
t.Run("Deadzone center/size invalid center", func() {
config := configparser.RuleTargetConfigAxis{
Device: "test",
Axis: "x",
DeadzoneCenter: 20000,
DeadzoneSize: 500,
}
_, err := NewRuleTargetAxisFromConfig(config, t.devs)
t.NotNil(err)
})
relDeadzonePercentTestCases := []struct { relDeadzonePercentTestCases := []struct {
inCenter int32 inCenter int32
inSizePercent int32 inSizePercent int32
@ -178,20 +183,29 @@ func (t *MakeRuleTargetsTests) TestMakeRuleTargetAxis() {
for _, tc := range relDeadzonePercentTestCases { for _, tc := range relDeadzonePercentTestCases {
t.Run(fmt.Sprintf("Relative percent deadzone %d +- %d%%", tc.inCenter, tc.inSizePercent), func() { t.Run(fmt.Sprintf("Relative percent deadzone %d +- %d%%", tc.inCenter, tc.inSizePercent), func() {
config := configparser.RuleTargetConfigAxis{ config := configparser.RuleTargetConfigAxis{
Device: "test", Device: "test",
Axis: "x", Axis: "x",
Deadzones: []configparser.DeadzoneConfig{{ DeadzoneCenter: tc.inCenter,
Center: tc.inCenter, DeadzoneSizePercent: tc.inSizePercent,
SizePercent: tc.inSizePercent,
}},
} }
rule, err := NewRuleTargetAxisFromConfig(config, t.devs) rule, err := NewRuleTargetAxisFromConfig(config, t.devs)
t.Nil(err) t.Nil(err)
t.Equal(tc.outStart, rule.Deadzones[0].Start) t.Equal(tc.outStart, rule.DeadzoneStart)
t.Equal(tc.outEnd, rule.Deadzones[0].End) t.Equal(tc.outEnd, rule.DeadzoneEnd)
}) })
} }
t.Run("Deadzone center/percent invalid center", func() {
config := configparser.RuleTargetConfigAxis{
Device: "test",
Axis: "x",
DeadzoneCenter: 20000,
DeadzoneSizePercent: 10,
}
_, err := NewRuleTargetAxisFromConfig(config, t.devs)
t.NotNil(err)
})
} }
func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() { func (t *MakeRuleTargetsTests) TestMakeRuleTargetRelaxis() {

View file

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
"strings"
"git.annabunches.net/annabunches/joyful/internal/configparser" "git.annabunches.net/annabunches/joyful/internal/configparser"
"git.annabunches.net/annabunches/joyful/internal/logger" "git.annabunches.net/annabunches/joyful/internal/logger"
@ -32,27 +33,24 @@ func NewRule(config configparser.RuleConfig, pDevs map[string]Device, vDevs map[
base := NewMappingRuleBase(config.Name, config.Modes) base := NewMappingRuleBase(config.Name, config.Modes)
switch config.Type { switch strings.ToLower(config.Type) {
case configparser.RuleTypeButton: case RuleTypeButton:
newRule, err = NewMappingRuleButton(config.Config.(configparser.RuleConfigButton), pDevs, vDevs, base) newRule, err = NewMappingRuleButton(config.Config.(configparser.RuleConfigButton), pDevs, vDevs, base)
case configparser.RuleTypeButtonCombo: case RuleTypeButtonCombo:
newRule, err = NewMappingRuleButtonCombo(config.Config.(configparser.RuleConfigButtonCombo), pDevs, vDevs, base) newRule, err = NewMappingRuleButtonCombo(config.Config.(configparser.RuleConfigButtonCombo), pDevs, vDevs, base)
case configparser.RuleTypeButtonLatched: case RuleTypeButtonLatched:
newRule, err = NewMappingRuleButtonLatched(config.Config.(configparser.RuleConfigButtonLatched), pDevs, vDevs, base) newRule, err = NewMappingRuleButtonLatched(config.Config.(configparser.RuleConfigButtonLatched), pDevs, vDevs, base)
case configparser.RuleTypeAxis: case RuleTypeAxis:
newRule, err = NewMappingRuleAxis(config.Config.(configparser.RuleConfigAxis), pDevs, vDevs, base) newRule, err = NewMappingRuleAxis(config.Config.(configparser.RuleConfigAxis), pDevs, vDevs, base)
case configparser.RuleTypeAxisCombined: case RuleTypeAxisCombined:
newRule, err = NewMappingRuleAxisCombined(config.Config.(configparser.RuleConfigAxisCombined), pDevs, vDevs, base) newRule, err = NewMappingRuleAxisCombined(config.Config.(configparser.RuleConfigAxisCombined), pDevs, vDevs, base)
case configparser.RuleTypeAxisToButton: case RuleTypeAxisToButton:
newRule, err = NewMappingRuleAxisToButton(config.Config.(configparser.RuleConfigAxisToButton), pDevs, vDevs, base) newRule, err = NewMappingRuleAxisToButton(config.Config.(configparser.RuleConfigAxisToButton), pDevs, vDevs, base)
case configparser.RuleTypeAxisToRelaxis: case RuleTypeAxisToRelaxis:
newRule, err = NewMappingRuleAxisToRelaxis(config.Config.(configparser.RuleConfigAxisToRelaxis), pDevs, vDevs, base) newRule, err = NewMappingRuleAxisToRelaxis(config.Config.(configparser.RuleConfigAxisToRelaxis), pDevs, vDevs, base)
case configparser.RuleTypeModeSelect: case RuleTypeModeSelect:
newRule, err = NewMappingRuleModeSelect(config.Config.(configparser.RuleConfigModeSelect), pDevs, modes, base) newRule, err = NewMappingRuleModeSelect(config.Config.(configparser.RuleConfigModeSelect), pDevs, modes, base)
case configparser.RuleTypeHat:
newRule, err = NewMappingRuleHat(config.Config.(configparser.RuleConfigHat), pDevs, vDevs, base)
default: default:
// Shouldn't actually be possible to get here...
err = fmt.Errorf("bad rule type '%s' for rule '%s'", config.Type, config.Name) err = fmt.Errorf("bad rule type '%s' for rule '%s'", config.Type, config.Name)
} }

View file

@ -22,6 +22,9 @@ type RuleTarget interface {
// (e.g., inverting the value if Inverted == true) // (e.g., inverting the value if Inverted == true)
NormalizeValue(int32) int32 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. // CreateEvent creates an event that can be emitted on a virtual device.
// For RuleTargetModeSelect, this method modifies the active mode and returns nil. // For RuleTargetModeSelect, this method modifies the active mode and returns nil.
// //
@ -32,7 +35,6 @@ type RuleTarget interface {
// for most implementations. // for most implementations.
CreateEvent(int32, *string) *evdev.InputEvent 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 MatchEvent(device Device, event *evdev.InputEvent) bool
} }

View file

@ -28,7 +28,6 @@ func TestRunnerMappingRuleAxisCombined(t *testing.T) {
} }
func (t *MappingRuleAxisCombinedTests) SetupTest() { func (t *MappingRuleAxisCombinedTests) SetupTest() {
noDeadzone := make([]Deadzone, 0)
mode := "*" mode := "*"
t.mode = &mode t.mode = &mode
@ -38,13 +37,13 @@ func (t *MappingRuleAxisCombinedTests) SetupTest() {
evdev.ABS_Y: {Minimum: 0, Maximum: 10000}, evdev.ABS_Y: {Minimum: 0, Maximum: 10000},
}, nil) }, nil)
t.inputTargetLower, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, true, noDeadzone) t.inputTargetLower, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, true, 0, 0)
t.inputTargetLower.OutputMax = 0 t.inputTargetLower.OutputMax = 0
t.inputTargetUpper, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_Y, false, noDeadzone) t.inputTargetUpper, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_Y, false, 0, 0)
t.inputTargetUpper.OutputMin = 0 t.inputTargetUpper.OutputMin = 0
t.outputDevice = &evdev.InputDevice{} t.outputDevice = &evdev.InputDevice{}
t.outputTarget, _ = NewRuleTargetAxis("test-output", t.outputDevice, evdev.ABS_X, false, noDeadzone) t.outputTarget, _ = NewRuleTargetAxis("test-output", t.outputDevice, evdev.ABS_X, false, 0, 0)
t.base = NewMappingRuleBase("", []string{"*"}) t.base = NewMappingRuleBase("", []string{"*"})
@ -68,10 +67,10 @@ func (t *MappingRuleAxisCombinedTests) TestNewMappingRuleAxisCombined() {
}, nil) }, nil)
rule := &MappingRuleAxisCombined{ rule := &MappingRuleAxisCombined{
// MappingRuleBase: t.base, MappingRuleBase: t.base,
InputLower: t.inputTargetLower, InputLower: t.inputTargetLower,
InputUpper: t.inputTargetUpper, InputUpper: t.inputTargetUpper,
// Output: t.outputTarget, Output: t.outputTarget,
} }
t.EqualValues(0, rule.InputLower.OutputMax) t.EqualValues(0, rule.InputLower.OutputMax)
t.EqualValues(0, rule.InputUpper.OutputMin) t.EqualValues(0, rule.InputUpper.OutputMin)

View file

@ -67,7 +67,7 @@ func (t *MappingRuleAxisToButtonTests) SetupTest() {
Maximum: 10000, Maximum: 10000,
}, },
}, nil) }, nil)
t.inputRule, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, false, []Deadzone{{Start: 0, End: 1000}}) t.inputRule, _ = NewRuleTargetAxis("test-input", t.inputDevice, evdev.ABS_X, false, int32(0), int32(1000))
t.outputDevice = &evdev.InputDevice{} t.outputDevice = &evdev.InputDevice{}
t.outputRule, _ = NewRuleTargetButton("test-output", t.outputDevice, evdev.ABS_X, false) t.outputRule, _ = NewRuleTargetButton("test-output", t.outputDevice, evdev.ABS_X, false)
@ -113,16 +113,14 @@ func (t *MappingRuleAxisToButtonTests) TestMatchEvent() {
Code: evdev.ABS_X, Code: evdev.ABS_X,
Value: 1001, Value: 1001,
}, t.mode) }, t.mode)
// Allow leeway since time passes during the test t.True(testRule.nextEvent > time.Duration(700*time.Millisecond))
t.True(testRule.nextEvent > time.Duration(650*time.Millisecond))
testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{
Type: evdev.EV_ABS, Type: evdev.EV_ABS,
Code: evdev.ABS_X, Code: evdev.ABS_X,
Value: 5500, Value: 5500,
}, t.mode) }, t.mode)
// Allow up to 50 ms leeway since time passes during the test t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent)
t.InDelta(time.Duration(500*time.Millisecond), testRule.nextEvent, 50000000)
}) })
} }

View file

@ -1,45 +0,0 @@
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)
}

View file

@ -10,15 +10,16 @@ import (
) )
type RuleTargetAxis struct { type RuleTargetAxis struct {
DeviceName string DeviceName string
Device Device Device Device
Axis evdev.EvCode Axis evdev.EvCode
Inverted bool Inverted bool
Deadzones []Deadzone DeadzoneStart int32
OutputMin int32 DeadzoneEnd int32
OutputMax int32 OutputMin int32
axisSize int32 OutputMax int32
deadzoneSize int32 axisSize int32
deadzoneSize int32
} }
func NewRuleTargetAxisFromConfig(targetConfig configparser.RuleTargetConfigAxis, devs map[string]Device) (*RuleTargetAxis, error) { func NewRuleTargetAxisFromConfig(targetConfig configparser.RuleTargetConfigAxis, devs map[string]Device) (*RuleTargetAxis, error) {
@ -27,18 +28,18 @@ func NewRuleTargetAxisFromConfig(targetConfig configparser.RuleTargetConfigAxis,
return nil, fmt.Errorf("non-existent device '%s'", targetConfig.Device) 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) eventCode, err := eventcodes.ParseCode(targetConfig.Axis, eventcodes.CodePrefixAxis)
if err != nil { if err != nil {
return nil, err return nil, err
} }
deadzones := make([]Deadzone, 0) deadzoneStart, deadzoneEnd, err := calculateDeadzones(targetConfig, device, eventCode)
for _, dzConfig := range targetConfig.Deadzones { if err != nil {
dz, err := NewDeadzoneFromConfig(dzConfig, device, eventCode) return nil, err
if err != nil {
return nil, err
}
deadzones = append(deadzones, dz)
} }
return NewRuleTargetAxis( return NewRuleTargetAxis(
@ -46,15 +47,58 @@ func NewRuleTargetAxisFromConfig(targetConfig configparser.RuleTargetConfigAxis,
device, device,
eventCode, eventCode,
targetConfig.Inverted, targetConfig.Inverted,
deadzones, 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, func NewRuleTargetAxis(device_name string,
device Device, device Device,
axis evdev.EvCode, axis evdev.EvCode,
inverted bool, inverted bool,
deadzones []Deadzone) (*RuleTargetAxis, error) { deadzoneStart int32,
deadzoneEnd int32) (*RuleTargetAxis, error) {
info, err := device.AbsInfos() info, err := device.AbsInfos()
@ -73,7 +117,11 @@ func NewRuleTargetAxis(device_name string,
return nil, fmt.Errorf("device does not support axis %v", axis) return nil, fmt.Errorf("device does not support axis %v", axis)
} }
deadzoneSize := CalculateDeadzoneSize(deadzones) if deadzoneStart > deadzoneEnd {
return nil, errors.New("deadzone_end must be a higher value than deadzone_start")
}
deadzoneSize := Abs(deadzoneEnd - deadzoneStart)
// Our output range is limited to 16 bits, but we represent values internally with 32 bits. // Our output range is limited to 16 bits, but we represent values internally with 32 bits.
// As a result, we shouldn't need to worry about integer overruns // As a result, we shouldn't need to worry about integer overruns
@ -84,15 +132,16 @@ func NewRuleTargetAxis(device_name string,
} }
return &RuleTargetAxis{ return &RuleTargetAxis{
DeviceName: device_name, DeviceName: device_name,
Device: device, Device: device,
Axis: axis, Axis: axis,
Inverted: inverted, Inverted: inverted,
OutputMin: AxisValueMin, OutputMin: AxisValueMin,
OutputMax: AxisValueMax, OutputMax: AxisValueMax,
Deadzones: deadzones, DeadzoneStart: deadzoneStart,
deadzoneSize: deadzoneSize, DeadzoneEnd: deadzoneEnd,
axisSize: axisSize, deadzoneSize: deadzoneSize,
axisSize: axisSize,
}, nil }, nil
} }
@ -101,17 +150,9 @@ func NewRuleTargetAxis(device_name string,
// Axis inputs are normalized to the full signed int32 range to match the virtual device's axis // Axis inputs are normalized to the full signed int32 range to match the virtual device's axis
// characteristics. // characteristics.
// //
// If the raw value is inside the deadzone, we either emit no event, or we emit the deadzoneValue.
// Typically this function is called after RuleTargetAxis.MatchEvent, which checks whether we are // Typically this function is called after RuleTargetAxis.MatchEvent, which checks whether we are
// in the deadzone, among other things. // in the deadzone, among other things.
func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { func (target *RuleTargetAxis) NormalizeValue(value int32) int32 {
for _, dz := range target.Deadzones {
state, dzValue := dz.Match(value)
if state == DeadzoneEmit {
return Clamp(dzValue, target.OutputMin, target.OutputMax)
}
}
axisStrength := target.GetAxisStrength(value) axisStrength := target.GetAxisStrength(value)
return LerpInt(target.OutputMin, target.OutputMax, axisStrength) return LerpInt(target.OutputMin, target.OutputMax, axisStrength)
} }
@ -137,30 +178,19 @@ func (target *RuleTargetAxis) MatchEventDeviceAndCode(device Device, event *evde
event.Code == target.Axis event.Code == target.Axis
} }
// InDeadZone checks each deadzone for whether the target value falls within it.
// If *any* non-emitting deadzone matches, we return true.
// TODO: Add tests // TODO: Add tests
func (target *RuleTargetAxis) InDeadZone(value int32) bool { func (target *RuleTargetAxis) InDeadZone(value int32) bool {
for _, dz := range target.Deadzones { return target.deadzoneSize > 0 && value >= target.DeadzoneStart && value <= target.DeadzoneEnd
state, _ := dz.Match(value)
if state == DeadzoneNoEmit {
return true
}
}
return false
} }
// GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional // GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional
// position along the axis' full range. (after factoring in deadzones) // position along the axis' full range. (after factoring in deadzones)
// Calling this function with `value` inside the deadzone range will produce undefined behavior // Calling this function with `value` inside the deadzone range will produce undefined behavior
func (target *RuleTargetAxis) GetAxisStrength(value int32) float64 { func (target *RuleTargetAxis) GetAxisStrength(value int32) float64 {
adjValue := value if value > target.DeadzoneEnd {
for _, dz := range target.Deadzones { value -= target.deadzoneSize
if value > dz.End {
adjValue -= dz.Size
}
} }
strength := float64(adjValue) / float64(target.axisSize) strength := float64(value) / float64(target.axisSize)
if target.Inverted { if target.Inverted {
strength = 1.0 - strength strength = 1.0 - strength
} }

View file

@ -38,42 +38,42 @@ func (t *RuleTargetAxisTests) TearDownTest() {
} }
func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() { func (t *RuleTargetAxisTests) TestNewRuleTargetAxis() {
noDeadzone := make([]Deadzone, 0)
// RuleTargets should get created // RuleTargets should get created
ruleTarget, err := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, err := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Nil(err) t.Nil(err)
t.EqualValues(10000, ruleTarget.axisSize) t.EqualValues(10000, ruleTarget.axisSize)
ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, noDeadzone) ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, 0, 0)
t.Nil(err) t.Nil(err)
t.EqualValues(20000, ruleTarget.axisSize) t.EqualValues(20000, ruleTarget.axisSize)
// Creating a rule with a deadzone should work and reduce the axisSize // Creating a rule with a deadzone should work and reduce the axisSize
ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, []Deadzone{{Start: -500, End: 500, Size: 1000}}) ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500)
t.Nil(err) t.Nil(err)
t.EqualValues(19000, ruleTarget.axisSize) t.EqualValues(19000, ruleTarget.axisSize)
t.EqualValues(-500, ruleTarget.Deadzones[0].Start) t.EqualValues(-500, ruleTarget.DeadzoneStart)
t.EqualValues(500, ruleTarget.Deadzones[0].End) t.EqualValues(500, ruleTarget.DeadzoneEnd)
// Creating a rule with a deadzone should fail if end > start
_, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, 500, -500)
t.NotNil(err)
// Creating a rule on a non-existent axis should err // Creating a rule on a non-existent axis should err
_, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Z, false, noDeadzone) _, err = NewRuleTargetAxis("", t.mock, evdev.ABS_Z, false, 0, 0)
t.NotNil(err) t.NotNil(err)
// If Absinfo has an error, we should create a device with permissive bounds // If Absinfo has an error, we should create a device with permissive bounds
t.call.Unset() t.call.Unset()
t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{}, errors.New("Test Error")) t.mock.On("AbsInfos").Return(map[evdev.EvCode]evdev.AbsInfo{}, errors.New("Test Error"))
ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, err = NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Nil(err) t.Nil(err)
t.Equal(AxisValueMax-AxisValueMin, ruleTarget.axisSize) t.Equal(AxisValueMax-AxisValueMin, ruleTarget.axisSize)
} }
func (t *RuleTargetAxisTests) TestNormalizeValue() { func (t *RuleTargetAxisTests) TestNormalizeValue() {
noDeadzone := make([]Deadzone, 0)
// Basic normalization should work // Basic normalization should work
t.Run("Simple normalization", func() { t.Run("Simple normalization", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(0)))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000))) t.EqualValues(0, ruleTarget.NormalizeValue(int32(5000)))
@ -81,26 +81,26 @@ func (t *RuleTargetAxisTests) TestNormalizeValue() {
// Normalization with a deadzone should work // Normalization with a deadzone should work
t.Run("With Deadzone", func() { t.Run("With Deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, []Deadzone{{Start: 0, End: 5000, Size: 5000}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(10000)))
t.InDelta(int32(-32000), ruleTarget.NormalizeValue(int32(5001)), 1000) t.True(ruleTarget.NormalizeValue(int32(5001)) < int32(-31000))
t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500))) t.EqualValues(0, ruleTarget.NormalizeValue(int32(7500)))
}) })
t.Run("Inverted", func() { t.Run("Inverted", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0)
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(0)))
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(10000)))
}) })
t.Run("Out of bounds", func() { // Normalization past the stated axis bounds should clamp t.Run("Out of bounds", func() { // Normalization past the stated axis bounds should clamp
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000))) t.Equal(AxisValueMin, ruleTarget.NormalizeValue(int32(-30000)))
t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000))) t.Equal(AxisValueMax, ruleTarget.NormalizeValue(int32(30000)))
}) })
t.Run("With partial output range", func() { t.Run("With partial output range", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
ruleTarget.OutputMin = 0 ruleTarget.OutputMin = 0
ruleTarget.OutputMax = AxisValueMax ruleTarget.OutputMax = AxisValueMax
t.EqualValues(0, ruleTarget.NormalizeValue(int32(0))) t.EqualValues(0, ruleTarget.NormalizeValue(int32(0)))
@ -110,7 +110,7 @@ func (t *RuleTargetAxisTests) TestNormalizeValue() {
} }
func (t *RuleTargetAxisTests) TestMatchEvent() { func (t *RuleTargetAxisTests) TestMatchEvent() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, []Deadzone{{Start: -500, End: 500}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_Y, false, -500, 500)
validEvent := &evdev.InputEvent{ validEvent := &evdev.InputEvent{
Type: evdev.EV_ABS, Type: evdev.EV_ABS,
Code: evdev.ABS_Y, Code: evdev.ABS_Y,
@ -133,9 +133,7 @@ func (t *RuleTargetAxisTests) TestMatchEvent() {
} }
func (t *RuleTargetAxisTests) TestCreateEvent() { func (t *RuleTargetAxisTests) TestCreateEvent() {
noDeadzone := make([]Deadzone, 0) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone)
expected := &evdev.InputEvent{ expected := &evdev.InputEvent{
Type: evdev.EV_ABS, Type: evdev.EV_ABS,
Code: evdev.ABS_X, Code: evdev.ABS_X,
@ -157,45 +155,43 @@ func (t *RuleTargetAxisTests) TestCreateEvent() {
} }
func (t *RuleTargetAxisTests) TestGetAxisStrength() { func (t *RuleTargetAxisTests) TestGetAxisStrength() {
noDeadzone := make([]Deadzone, 0)
t.Run("With no deadzone", func() { t.Run("With no deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0)
t.Equal(0.0, ruleTarget.GetAxisStrength(0)) t.Equal(0.0, ruleTarget.GetAxisStrength(0))
t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) t.Equal(1.0, ruleTarget.GetAxisStrength(10000))
t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) t.Equal(0.5, ruleTarget.GetAxisStrength(5000))
}) })
t.Run("With low deadzone", func() { t.Run("With low deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, []Deadzone{{Start: 0, End: 5000, Size: 5000}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000)
t.InDelta(0.0, ruleTarget.GetAxisStrength(5001), 0.01) t.InDelta(0.0, ruleTarget.GetAxisStrength(5001), 0.01)
t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01)
t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) t.Equal(1.0, ruleTarget.GetAxisStrength(10000))
}) })
t.Run("With high deadzone", func() { t.Run("With high deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, []Deadzone{{Start: 5000, End: 10000, Size: 5000}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 5000, 10000)
t.Equal(0.0, ruleTarget.GetAxisStrength(0)) t.Equal(0.0, ruleTarget.GetAxisStrength(0))
t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01)
t.InDelta(1.0, ruleTarget.GetAxisStrength(4999), 0.01) t.InDelta(1.0, ruleTarget.GetAxisStrength(4999), 0.01)
}) })
t.Run("Inverted", func() { t.Run("Inverted", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, noDeadzone) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0)
t.Equal(1.0, ruleTarget.GetAxisStrength(0)) t.Equal(1.0, ruleTarget.GetAxisStrength(0))
t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) t.Equal(0.5, ruleTarget.GetAxisStrength(5000))
t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) t.Equal(0.0, ruleTarget.GetAxisStrength(10000))
}) })
t.Run("Inverted with low deadzone", func() { t.Run("Inverted with low deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, []Deadzone{{Start: 0, End: 5000, Size: 5000}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 5000)
t.InDelta(1.0, ruleTarget.GetAxisStrength(5001), 0.01) t.InDelta(1.0, ruleTarget.GetAxisStrength(5001), 0.01)
t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01)
t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) t.Equal(0.0, ruleTarget.GetAxisStrength(10000))
}) })
t.Run("Inverted with high deadzone", func() { t.Run("Inverted with high deadzone", func() {
ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, []Deadzone{{Start: 5000, End: 10000, Size: 5000}}) ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 5000, 10000)
t.InDelta(0.0, ruleTarget.GetAxisStrength(4999), 0.01) t.InDelta(0.0, ruleTarget.GetAxisStrength(4999), 0.01)
t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01)
t.Equal(1.0, ruleTarget.GetAxisStrength(0)) t.Equal(1.0, ruleTarget.GetAxisStrength(0))

View file

@ -1,53 +0,0 @@
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
}

View file

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

View file

@ -49,15 +49,6 @@ var (
evdev.ABS_RZ, evdev.ABS_RZ,
evdev.ABS_THROTTLE, // Also called "Slider" or "Slider1" evdev.ABS_THROTTLE, // Also called "Slider" or "Slider1"
evdev.ABS_RUDDER, // Also called "Dial", "Slider2", or "RSlider" 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.EV_KEY: {
evdev.BTN_TRIGGER, evdev.BTN_TRIGGER,

View file

@ -10,13 +10,13 @@ Joyful is ideal for Linux gamers who enjoy space and flight sims and miss the fe
### Current Features ### Current Features
* Create virtual devices with up to 8 axes, 4 hats, and 74 buttons. * Create virtual devices with up to 8 axes and 74 buttons.
* Flexible rule system that allows several different types of rules, including: * Flexible rule system that allows several different types of rules, including:
* Simple 1:1 mappings of buttons, axes, and hats: Button1 -> VirtualButtonA * Simple 1:1 mappings of buttons and axes: Button1 -> VirtualButtonA
* Combination mappings: Button1 + Button2 -> VirtualButtonA * Combination mappings: Button1 + Button2 -> VirtualButtonA
* "Split" axis mapping: map sections of an axis to different outputs using deadzones. * "Split" axis mapping: map sections of an axis to different outputs using deadzones.
* "Combined" axis mapping: map two physical axes to one virtual axis. * "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. * 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. * Define keyboard, mouse, and gamepad outputs in addition to joysticks.
* Configure per-rule configurable deadzones for axes, with multiple ways to specify deadzones. * 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. * Macros - have a single input produce a sequence of button presses with configurable pauses.
* Sequence combos - Button1, Button2, Button3 -> VirtualButtonA * Sequence combos - Button1, Button2, Button3 -> VirtualButtonA
* Hat -> Button and Button -> Hat support. * Hat support
* HIDRAW support for more button options * HIDRAW support for more button options.
* Sensitivity Curves? * Sensitivity Curves?
* Packaged builds for non-Arch distributions. * Packaged builds non-Arch distributions.
## Configure ## Configure