diff --git a/cmd/joyful/main.go b/cmd/joyful/main.go index 79495ef..f6b8e5f 100644 --- a/cmd/joyful/main.go +++ b/cmd/joyful/main.go @@ -95,7 +95,8 @@ func main() { case ChannelEventInput: switch channelEvent.Event.Type { case evdev.EV_SYN: - // We've received a SYN_REPORT, so now we send all of our pending events + // We've received a SYN_REPORT, so now we send all pending events; since SYN_REPORTs + // might come from multiple input devices, we'll always flush, just to be sure. for _, buffer := range vBuffersByName { buffer.SendEvents() } @@ -114,6 +115,8 @@ func main() { case ChannelEventTimer: // Timer events give us the device and event to use directly vBuffersByDevice[channelEvent.Device].AddEvent(channelEvent.Event) + // If we get a timer event, flush the output device buffer immediately + vBuffersByDevice[channelEvent.Device].SendEvents() } } } diff --git a/go.mod b/go.mod index bbe28fc..b469a18 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/goccy/go-yaml v1.18.0 github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 + github.com/jonboulle/clockwork v0.5.0 github.com/stretchr/testify v1.10.0 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b ) diff --git a/go.sum b/go.sum index 079743c..4942bf2 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1 h1:92OsBIf5KB1Tatx+uUGOhah73jyNUrt7DmfDRXXJ5Xo= github.com/holoplot/go-evdev v0.0.0-20240306072622-217e18f17db1/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/internal/config/devices.go b/internal/config/devices.go index e4157e2..d904779 100644 --- a/internal/config/devices.go +++ b/internal/config/devices.go @@ -35,6 +35,7 @@ func (parser *ConfigParser) CreateVirtualDevices() map[string]*evdev.InputDevice map[evdev.EvType][]evdev.EvCode{ evdev.EV_KEY: makeButtons(int(deviceConfig.Buttons)), evdev.EV_ABS: makeAxes(int(deviceConfig.Axes)), + evdev.EV_REL: makeRelativeAxes(deviceConfig.RelativeAxes), }, ) @@ -116,3 +117,20 @@ func makeAxes(numAxes int) []evdev.EvCode { return axes } + +func makeRelativeAxes(axes []string) []evdev.EvCode { + codes := make([]evdev.EvCode, 0) + + for _, axis := range axes { + code, ok := evdev.RELFromString[axis] + + if !ok { + logger.Logf("Relative axis '%s' invalid. Skipping.", axis) + continue + } + + codes = append(codes, code) + } + + return codes +} diff --git a/internal/config/schema.go b/internal/config/schema.go index b91bb8d..d8edaf1 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -16,12 +16,13 @@ type Config struct { } type DeviceConfig struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - DeviceName string `yaml:"device_name,omitempty"` - Uuid string `yaml:"uuid,omitempty"` - Buttons int `yaml:"buttons,omitempty"` - Axes int `yaml:"axes,omitempty"` + Name string `yaml:"name"` + Type string `yaml:"type"` + DeviceName string `yaml:"device_name,omitempty"` + Uuid string `yaml:"uuid,omitempty"` + Buttons int `yaml:"buttons,omitempty"` + Axes int `yaml:"axes,omitempty"` + RelativeAxes []string `yaml:"rel_axes,omitempty"` } type RuleConfig struct { diff --git a/internal/mappingrules/mapping_rule_axis_to_button.go b/internal/mappingrules/mapping_rule_axis_to_button.go index 9c4205a..3e15312 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button.go +++ b/internal/mappingrules/mapping_rule_axis_to_button.go @@ -4,6 +4,7 @@ import ( "time" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" ) // MappingRuleAxisToButton represents a rule that converts an axis input into a (potentially repeating) @@ -16,7 +17,10 @@ type MappingRuleAxisToButton struct { RepeatRateMax int nextEvent time.Duration lastEvent time.Time - pressed bool + repeat bool + pressed bool // "pressed" indicates that we've sent the output button signal, but still need to send the button release + active bool // "active" is true whenever the input is not in a deadzone + clock clockwork.Clock } func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, output *RuleTargetButton, repeatRateMin, repeatRateMax int) *MappingRuleAxisToButton { @@ -28,7 +32,10 @@ func NewMappingRuleAxisToButton(base MappingRuleBase, input *RuleTargetAxis, out RepeatRateMax: repeatRateMax, lastEvent: time.Now(), nextEvent: NoNextEvent, + repeat: repeatRateMin != 0 && repeatRateMax != 0, pressed: false, + active: false, + clock: clockwork.NewRealClock(), } } @@ -42,13 +49,16 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * // If we're inside the deadzone, unset the next event if rule.Input.InDeadZone(event.Value) { rule.nextEvent = NoNextEvent + rule.active = false return nil, nil } // If we aren't repeating, we trigger the event immediately - // TODO: we aren't using pressed correctly; that should be set *and released* in here... - if rule.RepeatRateMin == 0 || rule.RepeatRateMax == 0 { - rule.nextEvent = time.Millisecond + // We also only set this if active == false, so that only one + // event can be emitted per "active" period + if !rule.repeat && !rule.active { + rule.nextEvent = 0 + rule.active = true return nil, nil } @@ -56,6 +66,7 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * strength := 1.0 - rule.Input.GetAxisStrength(event.Value) rate := int64(LerpInt(rule.RepeatRateMax, rule.RepeatRateMin, strength)) rule.nextEvent = time.Duration(rate * int64(time.Millisecond)) + rule.active = true return nil, nil } @@ -63,21 +74,30 @@ func (rule *MappingRuleAxisToButton) MatchEvent(device RuleTargetDevice, event * // TimerEvent returns an event when enough time has passed (compared to the last recorded axis value) // to emit an event. func (rule *MappingRuleAxisToButton) TimerEvent() *evdev.InputEvent { - // If we pressed the button last tick, release it + // If we pressed the button last tick, release it before doing anything else if rule.pressed { rule.pressed = false return rule.Output.CreateEvent(0, nil) } - // This indicates that we should not emit another event + // If we should not emit another event, + // we just update lastEvent for station keeping if rule.nextEvent == NoNextEvent { - rule.lastEvent = time.Now() + rule.lastEvent = rule.clock.Now() return nil } - if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { - rule.lastEvent = time.Now() + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.Now() rule.pressed = true + + // The default case here is to leave nextEvent at whatever + // it has been set to by MatchEvent. Since nextEvent is a delta, + // this will naturally cause the repeat to happen + if !rule.repeat { + rule.nextEvent = NoNextEvent + } + return rule.Output.CreateEvent(1, nil) } diff --git a/internal/mappingrules/mapping_rule_axis_to_button_test.go b/internal/mappingrules/mapping_rule_axis_to_button_test.go index 5a3193b..976506c 100644 --- a/internal/mappingrules/mapping_rule_axis_to_button_test.go +++ b/internal/mappingrules/mapping_rule_axis_to_button_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/suite" ) @@ -36,52 +37,150 @@ func (t *MappingRuleAxisToButtonTests) SetupTest() { } func (t *MappingRuleAxisToButtonTests) TestMatchEvent() { - testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) // A valid input should set a nextevent - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 1001, - }, t.mode) - t.NotEqual(NoNextEvent, testRule.nextEvent) + t.Run("No Repeat", func() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0) - // And a deadzone value should clear it - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 500, - }, t.mode) - t.Equal(NoNextEvent, testRule.nextEvent) + t.Run("Valid Input", func() { + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.NotEqual(NoNextEvent, testRule.nextEvent) + }) - testRule = NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 10000, - }, t.mode) - t.Equal(time.Duration(250*time.Millisecond), testRule.nextEvent) + t.Run("Deadzone Input", func() { + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 500, + }, t.mode) + t.Equal(NoNextEvent, testRule.nextEvent) + }) + }) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 1001, - }, t.mode) - t.True(testRule.nextEvent > time.Duration(700*time.Millisecond)) + t.Run("Repeat", func() { + testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250) + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 10000, + }, t.mode) + t.Equal(time.Duration(250*time.Millisecond), testRule.nextEvent) - testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ - Type: evdev.EV_ABS, - Code: evdev.ABS_X, - Value: 5500, - }, t.mode) - t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent) + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 1001, + }, t.mode) + t.True(testRule.nextEvent > time.Duration(700*time.Millisecond)) + + testRule.MatchEvent(t.inputDevice, &evdev.InputEvent{ + Type: evdev.EV_ABS, + Code: evdev.ABS_X, + Value: 5500, + }, t.mode) + t.Equal(time.Duration(500*time.Millisecond), testRule.nextEvent) + }) } -// TODO: to add TimerEvent tests we need to use an interface to mock time. -// func (t *MappingRuleAxisToButtonTests) TestTimerEvent() { -// // STUB -// } +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) + + event := testRule.TimerEvent() + + t.EqualValues(1, event.Value) + t.Equal(true, testRule.pressed) + }) + + // Off event on second call + t.Run("Event emits off on second call", func() { + testRule, _ := buildTimerRule(t, 0, 0, 0) + + testRule.TimerEvent() + event := testRule.TimerEvent() + + t.EqualValues(0, event.Value) + t.Equal(false, testRule.pressed) + }) + + // 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.TimerEvent() + testRule.TimerEvent() + + mockClock.Advance(10 * time.Millisecond) + event := testRule.TimerEvent() + t.Nil(event) + t.Equal(false, testRule.pressed) + }) + }) + + t.Run("Repeat", func() { + t.Run("No event if called immediately", func() { + testRule, _ := buildTimerRule(t, 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) + mockClock.Advance(49 * time.Millisecond) + + event := testRule.TimerEvent() + + t.Nil(event) + }) + + t.Run("Event after 50ms", func() { + testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + mockClock.Advance(50 * time.Millisecond) + + event := testRule.TimerEvent() + + t.EqualValues(1, event.Value) + t.Equal(true, testRule.pressed) + }) + + t.Run("Additional event at 100ms", func() { + testRule, mockClock := buildTimerRule(t, 100, 10, 50*time.Millisecond) + + mockClock.Advance(50 * time.Millisecond) + testRule.TimerEvent() + testRule.TimerEvent() + + mockClock.Advance(50 * time.Millisecond) + event := testRule.TimerEvent() + + t.NotNil(event) + }) + }) +} 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 07e4b85..731d067 100644 --- a/internal/mappingrules/mapping_rule_axis_to_relaxis.go +++ b/internal/mappingrules/mapping_rule_axis_to_relaxis.go @@ -5,14 +5,13 @@ import ( "git.annabunches.net/annabunches/joyful/internal/logger" "github.com/holoplot/go-evdev" + "github.com/jonboulle/clockwork" ) // TODO: add tests -// TODO: deadzones seem to calculate correctly in one direction but not the other when computing axis strength... - // MappingRuleAxisToRelaxis represents a rule that converts an axis input into a (potentially repeating) -// button output. +// relative axis output. This is most commonly used to generate mouse output events type MappingRuleAxisToRelaxis struct { MappingRuleBase Input *RuleTargetAxis @@ -22,6 +21,7 @@ type MappingRuleAxisToRelaxis struct { Increment int32 nextEvent time.Duration lastEvent time.Time + clock clockwork.Clock } func NewMappingRuleAxisToRelaxis( @@ -39,6 +39,7 @@ func NewMappingRuleAxisToRelaxis( Increment: int32(increment), lastEvent: time.Now(), nextEvent: NoNextEvent, + clock: clockwork.NewRealClock(), } } @@ -81,12 +82,12 @@ func (rule *MappingRuleAxisToRelaxis) MatchEvent( func (rule *MappingRuleAxisToRelaxis) TimerEvent() *evdev.InputEvent { // This indicates that we should not emit another event if rule.nextEvent == NoNextEvent { - rule.lastEvent = time.Now() + rule.lastEvent = rule.clock.Now() return nil } - if time.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { - rule.lastEvent = time.Now() + if rule.clock.Now().Compare(rule.lastEvent.Add(rule.nextEvent)) > -1 { + rule.lastEvent = rule.clock.Now() return rule.Output.CreateEvent(rule.Increment, nil) } diff --git a/internal/mappingrules/rule_target_axis.go b/internal/mappingrules/rule_target_axis.go index 5808e43..c0d4b95 100644 --- a/internal/mappingrules/rule_target_axis.go +++ b/internal/mappingrules/rule_target_axis.go @@ -77,12 +77,7 @@ func NewRuleTargetAxis(device_name string, // in the deadzone, among other things. func (target *RuleTargetAxis) NormalizeValue(value int32) int32 { axisStrength := target.GetAxisStrength(value) - - if target.Inverted { - axisStrength = 1.0 - axisStrength - } - normalizedValue := LerpInt(AxisValueMin, AxisValueMax, axisStrength) - return normalizedValue + return LerpInt(AxisValueMin, AxisValueMax, axisStrength) } func (target *RuleTargetAxis) CreateEvent(value int32, mode *string) *evdev.InputEvent { @@ -111,6 +106,16 @@ func (target *RuleTargetAxis) InDeadZone(value int32) bool { return value >= target.DeadzoneStart && value <= target.DeadzoneEnd } +// GetAxisStrength returns a float between 0.0 and 1.0, representing the proportional +// position along the axis' full range. (after factoring in deadzones) +// Calling this function with `value` inside the deadzone range will produce undefined behavior func (target *RuleTargetAxis) GetAxisStrength(value int32) float64 { - return float64(value-target.deadzoneSize) / float64(target.axisSize) + if value > target.DeadzoneEnd { + value -= target.deadzoneSize + } + strength := float64(value) / float64(target.axisSize) + if target.Inverted { + strength = 1.0 - strength + } + return strength } diff --git a/internal/mappingrules/rule_target_axis_test.go b/internal/mappingrules/rule_target_axis_test.go index b4bb4c0..28d2fd1 100644 --- a/internal/mappingrules/rule_target_axis_test.go +++ b/internal/mappingrules/rule_target_axis_test.go @@ -135,6 +135,50 @@ func (t *RuleTargetAxisTests) TestCreateEvent() { t.EqualValues(expected, ruleTarget.CreateEvent(testValue, nil)) } +func (t *RuleTargetAxisTests) TestGetAxisStrength() { + t.Run("With no deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 0) + t.Equal(0.0, ruleTarget.GetAxisStrength(0)) + t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) + t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) + }) + + t.Run("With low deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 0, 5000) + t.InDelta(0.0, ruleTarget.GetAxisStrength(5001), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) + t.Equal(1.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("With high deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, false, 5000, 10000) + t.Equal(0.0, ruleTarget.GetAxisStrength(0)) + t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) + t.InDelta(1.0, ruleTarget.GetAxisStrength(4999), 0.01) + }) + + t.Run("Inverted", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 0) + t.Equal(1.0, ruleTarget.GetAxisStrength(0)) + t.Equal(0.5, ruleTarget.GetAxisStrength(5000)) + t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("Inverted with low deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 0, 5000) + t.InDelta(1.0, ruleTarget.GetAxisStrength(5001), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(7500), 0.01) + t.Equal(0.0, ruleTarget.GetAxisStrength(10000)) + }) + + t.Run("Inverted with high deadzone", func() { + ruleTarget, _ := NewRuleTargetAxis("", t.mock, evdev.ABS_X, true, 5000, 10000) + t.InDelta(0.0, ruleTarget.GetAxisStrength(4999), 0.01) + t.InDelta(0.5, ruleTarget.GetAxisStrength(2500), 0.01) + t.Equal(1.0, ruleTarget.GetAxisStrength(0)) + }) +} + func TestRunnerRuleTargetAxisTests(t *testing.T) { suite.Run(t, new(RuleTargetAxisTests)) }