diff --git a/cmd/joyful/threads.go b/cmd/joyful/threads.go index 410bf5c..dabd2c7 100644 --- a/cmd/joyful/threads.go +++ b/cmd/joyful/threads.go @@ -32,7 +32,11 @@ func timerWatcher(rule *mappingrules.ProportionalAxisMappingRule, channel chan<- for { event := rule.TimerEvent() if event != nil { - channel <- ChannelEvent{Device: rule.Output.Device, Event: event, Type: ChannelEventTimer} + channel <- ChannelEvent{ + Device: rule.Output.(*mappingrules.RuleTargetModeSelect).Device, + Event: event, + Type: ChannelEventTimer, + } } time.Sleep(TimerCheckIntervalMs * time.Millisecond) } diff --git a/internal/config/rules.go b/internal/config/rules.go index 6822ea7..caab802 100644 --- a/internal/config/rules.go +++ b/internal/config/rules.go @@ -107,29 +107,45 @@ func makeLatchedRule(ruleConfig RuleConfig, pDevs map[string]*evdev.InputDevice, // makeInputRuleTarget takes an Input declaration from the YAML and returns a fully formed RuleTarget. func makeRuleTarget(targetConfig RuleTargetConfig, devs map[string]*evdev.InputDevice) (mappingrules.RuleTarget, error) { - ruleTarget := mappingrules.RuleTarget{} - if len(targetConfig.ModeSelect) > 0 { - ruleTarget.ModeSelect = targetConfig.ModeSelect - return ruleTarget, nil + return &mappingrules.RuleTargetModeSelect{ + ModeSelect: targetConfig.ModeSelect, + }, nil } device, ok := devs[targetConfig.Device] if !ok { - return mappingrules.RuleTarget{}, fmt.Errorf("couldn't build rule due to non-existent device '%s'", targetConfig.Device) + return nil, fmt.Errorf("couldn't build rule due to non-existent device '%s'", targetConfig.Device) } - ruleTarget.Device = device eventType, eventCode, err := decodeRuleTargetValues(targetConfig) if err != nil { - return ruleTarget, err + return nil, err } - ruleTarget.Type = eventType - ruleTarget.Code = eventCode - ruleTarget.Inverted = targetConfig.Inverted - ruleTarget.DeviceName = targetConfig.Device - return ruleTarget, nil + baseParams := mappingrules.RuleTargetBase{ + DeviceName: targetConfig.Device, + Device: device, + Inverted: targetConfig.Inverted, + Code: eventCode, + } + + switch eventType { + case evdev.EV_KEY: + return &mappingrules.RuleTargetButton{ + RuleTargetBase: baseParams, + }, nil + + case evdev.EV_ABS: + return &mappingrules.RuleTargetAxis{ + RuleTargetBase: baseParams, + AxisStart: targetConfig.AxisStart, + AxisEnd: targetConfig.AxisEnd, + }, nil + + default: + return nil, fmt.Errorf("skipping rule due to unsupported event type '%d'", eventType) + } } // decodeRuleTargetValues returns the appropriate evdev.EvType and evdev.EvCode values diff --git a/internal/config/schema.go b/internal/config/schema.go index 787bafe..2ea4c97 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -31,6 +31,8 @@ type RuleTargetConfig struct { Device string `yaml:"device,omitempty"` Button string `yaml:"button,omitempty"` Axis string `yaml:"axis,omitempty"` + AxisStart int32 `yaml:"axis_start,omitempty"` + AxisEnd int32 `yaml:"axis_end,omitempty"` Inverted bool `yaml:"inverted,omitempty"` ModeSelect []string `yaml:"mode_select,omitempty"` } diff --git a/internal/mappingrules/matching.go b/internal/mappingrules/matching.go index 07a0d66..7bbad7c 100644 --- a/internal/mappingrules/matching.go +++ b/internal/mappingrules/matching.go @@ -3,12 +3,11 @@ package mappingrules import ( "slices" - "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" ) func (rule *MappingRuleBase) OutputName() string { - return rule.Output.DeviceName + return rule.Output.GetDeviceName() } func (rule *MappingRuleBase) modeCheck(mode *string) bool { @@ -18,64 +17,17 @@ func (rule *MappingRuleBase) modeCheck(mode *string) bool { return slices.Contains(rule.Modes, *mode) } -// eventFromTarget creates an outputtable event from a RuleTarget -func eventFromTarget(output RuleTarget, value int32, mode *string) *evdev.InputEvent { - // TODO: this could perhaps use some sort of multiclassing... then again, maybe this is fine? - if len(output.ModeSelect) > 0 { - if value == 0 { - return nil - } - index := 0 - if currentMode := slices.Index(output.ModeSelect, *mode); currentMode != -1 { - // find the next mode - index = (currentMode + 1) % len(output.ModeSelect) - } - - *mode = output.ModeSelect[index] - logger.Logf("Mode changed to '%s'", *mode) - return nil - } - - return &evdev.InputEvent{ - Type: output.Type, - Code: output.Code, - Value: value, - } -} - -// valueFromTarget determines the value to output from an input specification,given a RuleTarget's constraints -func valueFromTarget(rule RuleTarget, event *evdev.InputEvent) int32 { - // how we process inverted rules depends on the event type - value := event.Value - if rule.Inverted { - switch rule.Type { - case evdev.EV_KEY: - if value == 0 { - value = 1 - } else { - value = 0 - } - case evdev.EV_ABS: - logger.Logf("STUB: Inverting axes is not yet implemented.") - default: - logger.Logf("Inverted rule for unknown event type '%d'. Not inverting value", event.Type) - } - } - - return value -} - func (rule *SimpleMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { if !rule.MappingRuleBase.modeCheck(mode) { return nil } - if device != rule.Input.Device || - event.Code != rule.Input.Code { + if device != rule.Input.GetDevice() || + event.Code != rule.Input.GetCode() { return nil } - return eventFromTarget(rule.Output, valueFromTarget(rule.Input, event), mode) + return rule.Output.CreateEvent(rule.Input.NormalizeValue(event.Value), mode) } func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { @@ -84,11 +36,11 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev } // Check each of the inputs, and if we find a match, proceed - var match *RuleTarget + var match RuleTarget for _, input := range rule.Inputs { - if device == input.Device && - event.Code == input.Code { - match = &input + if device == input.GetDevice() && + event.Code == input.GetCode() { + match = input } } @@ -97,7 +49,7 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev } // Get the value and add/subtract it from State - inputValue := valueFromTarget(*match, event) + inputValue := match.NormalizeValue(event.Value) oldState := rule.State if inputValue == 0 { rule.State = max(rule.State-1, 0) @@ -108,10 +60,10 @@ func (rule *ComboMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev targetState := len(rule.Inputs) if oldState == targetState-1 && rule.State == targetState { - return eventFromTarget(rule.Output, 1, mode) + return rule.Output.CreateEvent(1, mode) } if oldState == targetState && rule.State == targetState-1 { - return eventFromTarget(rule.Output, 0, mode) + return rule.Output.CreateEvent(0, mode) } return nil } @@ -121,9 +73,9 @@ func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evd return nil } - if device != rule.Input.Device || - event.Code != rule.Input.Code || - valueFromTarget(rule.Input, event) == 0 { + if device != rule.Input.GetDevice() || + event.Code != rule.Input.GetCode() || + rule.Input.NormalizeValue(event.Value) == 0 { return nil } @@ -136,7 +88,7 @@ func (rule *LatchedMappingRule) MatchEvent(device *evdev.InputDevice, event *evd value = 0 } - return eventFromTarget(rule.Output, value, mode) + return rule.Output.CreateEvent(value, mode) } func (rule *ProportionalAxisMappingRule) MatchEvent(device *evdev.InputDevice, event *evdev.InputEvent, mode *string) *evdev.InputEvent { diff --git a/internal/mappingrules/targets.go b/internal/mappingrules/targets.go new file mode 100644 index 0000000..81c6022 --- /dev/null +++ b/internal/mappingrules/targets.go @@ -0,0 +1,87 @@ +package mappingrules + +import ( + "slices" + + "git.annabunches.net/annabunches/joyful/internal/logger" + "github.com/holoplot/go-evdev" +) + +func (target *RuleTargetBase) GetCode() evdev.EvCode { + return target.Code +} + +func (target *RuleTargetBase) GetDeviceName() string { + return target.DeviceName +} + +func (target *RuleTargetBase) GetDevice() *evdev.InputDevice { + return target.Device +} + +func (target *RuleTargetButton) NormalizeValue(value int32) int32 { + if value == 0 { + return 1 + } + return 0 +} + +func (target *RuleTargetButton) CreateEvent(value int32, mode *string) *evdev.InputEvent { + return &evdev.InputEvent{ + Type: evdev.EV_KEY, + Code: target.Code, + Value: value, + } +} + +func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { + if !target.Inverted { + return value + } + + axisRange := target.AxisEnd - target.AxisStart + axisMid := target.AxisEnd - axisRange/2 + delta := value - axisMid + if delta < 0 { + delta = -delta + } + + if value < axisMid { + return axisMid + delta + } else if value > axisMid { + return axisMid - delta + } + + // If we reach here, we're either exactly at the midpoint or something + // strange has happened. Either way, just return the value. + return value +} + +func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { + return &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: target.Code, + Value: value, + } +} + +// RuleTargetModeSelect doesn't make sense as an input type +func (target *RuleTargetModeSelect) NormalizeValue(value int32) int32 { + return -1 +} + +func (target *RuleTargetModeSelect) CreateEvent(value int32, mode *string) *evdev.InputEvent { + if value == 0 { + return nil + } + + index := 0 + if currentMode := slices.Index(target.ModeSelect, *mode); currentMode != -1 { + // find the next mode + index = (currentMode + 1) % len(target.ModeSelect) + } + + *mode = target.ModeSelect[index] + logger.Logf("Mode changed to '%s'", *mode) + return nil +} diff --git a/internal/mappingrules/types.go b/internal/mappingrules/types.go index dcd79a6..e7aa72f 100644 --- a/internal/mappingrules/types.go +++ b/internal/mappingrules/types.go @@ -44,11 +44,45 @@ type ProportionalAxisMappingRule struct { LastEvent time.Time } -type RuleTarget struct { +// RuleTargets represent either a device input to match on, or an output to produce. +// Some RuleTarget types may work via side effects, such as RuleTargetModeSelect. +type RuleTarget interface { + // NormalizeValue takes the raw input value and possibly modifies it based on the Target settings. + // (e.g., inverting the value if Inverted == true) + NormalizeValue(int32) int32 + + // CreateEvent typically takes the (probably normalized) value and returns an event that can be emitted + // on a virtual device. + // + // For RuleTargetModeSelect, this method modifies the active mode and returns nil. + // + // TODO: should we normalize inside this function to simplify the interface? + CreateEvent(int32, *string) *evdev.InputEvent + + GetCode() evdev.EvCode + GetDeviceName() string + GetDevice() *evdev.InputDevice +} + +type RuleTargetBase struct { DeviceName string - ModeSelect []string Device *evdev.InputDevice - Type evdev.EvType Code evdev.EvCode Inverted bool } + +type RuleTargetButton struct { + RuleTargetBase +} + +type RuleTargetAxis struct { + RuleTargetBase + AxisStart int32 + AxisEnd int32 + Sensitivity float64 +} + +type RuleTargetModeSelect struct { + RuleTargetBase + ModeSelect []string +} diff --git a/readme.md b/readme.md index d6ecf77..3fc83ad 100644 --- a/readme.md +++ b/readme.md @@ -20,22 +20,23 @@ Joyful might be the tool for you. * Create virtual devices with up to 8 axes and 80 buttons. * Make simple 1:1 mappings of buttons and axes: Button1 -> VirtualButtonA * Make combination mappings: Button1 + Button2 -> VirtualButtonA +* Multiple modes with per-mode behavior. ### Future Features - try them at an unspecified point in the future! -* Multiple modes with per-mode behavior. * Partial axis mapping: map sections of an axis to different outputs. * Highly configurable deadzones * Macros - have a single input produce a sequence of button presses with configurable pauses. * Sequence combos - Button1, Button2, Button3 -> VirtualButtonA +* Proportional axis to button mapping; repeatedly trigger a button with an axis, with frequency controlled by the axis value ## Configuration Configuration is currently done via hand-written YAML files in `~/.config/joyful/`. Joyful will read every yaml file in this directory and combine them, so you can split your configuration up however you like. -Configuration is divided into two sections: `devices` and `rules`. Each of these is a YAML list. -The options for each are described in some detail below. See the `examples/` directory for concrete examples. +Configuration is divided into three sections: `devices`, `modes`, and `rules`. See the `examples/` directory for concrete examples. +Select options are explained in detail below. ### Device configuration @@ -65,7 +66,10 @@ Configuration options for each type vary. See for an ex ### Modes -All rules can have a `modes` field that is a list of strings. +The top-level `modes` field is a simple list of strings, defining the different modes available to rules. The initial mode is always +the first one in the list. (TODO) + +All rules can have a `modes` field that is a list of strings. If no `modes` field is present, the rule will be active in all modes. ## Technical details