Completed implementation.

This commit is contained in:
Anna Rose Wiggins 2025-07-15 15:27:49 -04:00
parent 0915ea059a
commit 58abd4cc34
10 changed files with 260 additions and 66 deletions

View file

@ -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()
}
}
}

1
go.mod
View file

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

2
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -22,6 +22,7 @@ type DeviceConfig struct {
Uuid string `yaml:"uuid,omitempty"`
Buttons int `yaml:"buttons,omitempty"`
Axes int `yaml:"axes,omitempty"`
RelativeAxes []string `yaml:"rel_axes,omitempty"`
}
type RuleConfig struct {

View file

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

View file

@ -5,6 +5,7 @@ import (
"time"
"github.com/holoplot/go-evdev"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/suite"
)
@ -36,25 +37,32 @@ 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
t.Run("No Repeat", func() {
testRule := NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 0, 0)
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)
})
// And a deadzone value should clear it
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 = NewMappingRuleAxisToButton(t.base, t.inputRule, t.outputRule, 750, 250)
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,
@ -75,13 +83,104 @@ func (t *MappingRuleAxisToButtonTests) TestMatchEvent() {
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
}

View file

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

View file

@ -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
}

View file

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