diff --git a/adaptor.go b/adaptor.go index 596332642..394d3c079 100644 --- a/adaptor.go +++ b/adaptor.go @@ -27,6 +27,12 @@ type DigitalPinOptioner interface { // of events for all the lines in this line request, lseqno is the same but for this line SetEventHandlerForEdge(handler func(lineOffset int, timestamp time.Duration, detectedEdge string, seqno uint32, lseqno uint32), edge int) (changed bool) + // SetPollForEdgeDetection use a discrete input polling method to detect edges. A poll interval of zero or smaller will + // deactivate this function. Please note: Using this feature is CPU consuming and less accurate than using cdev event + // handler (gpiod implementation) and should be done only if the former is not implemented or not working for the + // adaptor. E.g. sysfs driver in gobot has not implemented edge detection yet. The function is only useful together with + // SetEventHandlerForEdge() and its corresponding With*() functions. + SetPollForEdgeDetection(pollInterval time.Duration, pollQuitChan chan struct{}) (changed bool) } // DigitalPinOptionApplier is the interface to apply options to change pin behavior immediately diff --git a/system/GPIO.md b/system/GPIO.md index ee9586c0c..e2a4fae1c 100644 --- a/system/GPIO.md +++ b/system/GPIO.md @@ -142,6 +142,28 @@ Connect the input header pin26 to +3.3V with an resistor (e.g. 1kOhm). 1 ``` +### Test edge detection behavior of gpio251 (sysfs Tinkerboard) + +investigate status: + +```sh +# cat /sys/class/gpio/gpio251/edge +none +``` + +The file exists only if the pin can be configured as an interrupt generating input pin. To activate edge detection, +"rising", "falling", or "both" needs to be set. + +```sh +# cat /sys/class/gpio/gpio251/value +1 +``` + +If edge detection is activated, a poll will return only when the interrupt was triggered. The new value is written to +the beginning of the file. + +> Not tested yet, not supported by gobot yet. + ### Test output behavior of gpio251 (sysfs Tinkerboard) Connect the output header pin26 to +3.3V with an resistor (e.g. 1kOhm leads to ~0.3mA, 300Ohm leads to ~10mA). diff --git a/system/digitalpin_config.go b/system/digitalpin_config.go index 26d8bafaf..b11962b3a 100644 --- a/system/digitalpin_config.go +++ b/system/digitalpin_config.go @@ -53,6 +53,8 @@ type digitalPinConfig struct { debouncePeriod time.Duration edge int edgeEventHandler func(lineOffset int, timestamp time.Duration, detectedEdge string, seqno uint32, lseqno uint32) + pollInterval time.Duration + pollQuitChan chan struct{} } func newDigitalPinConfig(label string, options ...func(gobot.DigitalPinOptioner) bool) *digitalPinConfig { @@ -140,6 +142,13 @@ func WithPinEventOnBothEdges(handler func(lineOffset int, timestamp time.Duratio } } +// WithPinPollForEdgeDetection initializes a discrete input pin polling function to use for edge detection. +func WithPinPollForEdgeDetection(pollInterval time.Duration, pollQuitChan chan struct{}) func(gobot.DigitalPinOptioner) bool { + return func(d gobot.DigitalPinOptioner) bool { + return d.SetPollForEdgeDetection(pollInterval, pollQuitChan) + } +} + // SetLabel sets the label to use for next reconfigure. The function is intended to use by WithPinLabel(). func (d *digitalPinConfig) SetLabel(label string) bool { if d.label == label { @@ -221,3 +230,18 @@ func (d *digitalPinConfig) SetEventHandlerForEdge(handler func(int, time.Duratio d.edgeEventHandler = handler return true } + +// SetPollForEdgeDetection use a discrete input polling method to detect edges. A poll interval of zero or smaller will +// deactivate this function. Please note: Using this feature is CPU consuming and less accurate than using cdev event +// handler (gpiod implementation) and should be done only if the former is not implemented or not working for the +// adaptor. E.g. sysfs driver in gobot has not implemented edge detection yet. The function is only useful together with +// SetEventHandlerForEdge() and its corresponding With*() functions. +// The function is intended to use by WithPinPollForEdgeDetection(). +func (d *digitalPinConfig) SetPollForEdgeDetection(pollInterval time.Duration, pollQuitChan chan struct{}) (changed bool) { + if d.pollInterval == pollInterval { + return false + } + d.pollInterval = pollInterval + d.pollQuitChan = pollQuitChan + return true +} diff --git a/system/digitalpin_config_test.go b/system/digitalpin_config_test.go index be73c5190..c14d6fb5b 100644 --- a/system/digitalpin_config_test.go +++ b/system/digitalpin_config_test.go @@ -413,3 +413,36 @@ func TestWithPinEventOnBothEdges(t *testing.T) { }) } } + +func TestWithPinPollForEdgeDetection(t *testing.T) { + const ( + oldVal = time.Duration(1) + newVal = time.Duration(3) + ) + tests := map[string]struct { + oldPollInterval time.Duration + want bool + wantVal time.Duration + }{ + "no_change": { + oldPollInterval: newVal, + }, + "change": { + oldPollInterval: oldVal, + want: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + dpc := &digitalPinConfig{pollInterval: tc.oldPollInterval} + stopChan := make(chan struct{}) + defer close(stopChan) + // act + got := WithPinPollForEdgeDetection(newVal, stopChan)(dpc) + // assert + assert.Equal(t, tc.want, got) + assert.Equal(t, newVal, dpc.pollInterval) + }) + } +} diff --git a/system/digitalpin_gpiod.go b/system/digitalpin_gpiod.go index df913cad1..c161dc00f 100644 --- a/system/digitalpin_gpiod.go +++ b/system/digitalpin_gpiod.go @@ -215,7 +215,8 @@ func digitalPinGpiodReconfigureLine(d *digitalPinGpiod, forceInput bool) error { opts = append(opts, gpiod.WithDebounce(d.debouncePeriod)) } // edge detection - if d.edgeEventHandler != nil { + if d.edgeEventHandler != nil && d.pollInterval <= 0 { + // use edge detection provided by gpiod wrappedHandler := digitalPinGpiodGetWrappedEventHandler(d.edgeEventHandler) switch d.edge { case digitalPinEventOnFallingEdge: @@ -277,10 +278,20 @@ func digitalPinGpiodReconfigureLine(d *digitalPinGpiod, forceInput bool) error { } d.line = gpiodLine + // start discrete polling function and wait for first read is done + if (d.direction == IN || forceInput) && d.pollInterval > 0 { + if err := startEdgePolling(d.label, d.Read, d.pollInterval, d.edge, d.edgeEventHandler, + d.pollQuitChan); err != nil { + return err + } + } + return nil } -func digitalPinGpiodGetWrappedEventHandler(handler func(int, time.Duration, string, uint32, uint32)) func(gpiod.LineEvent) { +func digitalPinGpiodGetWrappedEventHandler( + handler func(int, time.Duration, string, uint32, uint32), +) func(gpiod.LineEvent) { return func(evt gpiod.LineEvent) { detectedEdge := "none" switch evt.Type { diff --git a/system/digitalpin_gpiod_test.go b/system/digitalpin_gpiod_test.go index 04f8bdd61..0d5b6edca 100644 --- a/system/digitalpin_gpiod_test.go +++ b/system/digitalpin_gpiod_test.go @@ -114,7 +114,7 @@ func TestApplyOptions(t *testing.T) { } } -func TestExport(t *testing.T) { +func TestExportGpiod(t *testing.T) { tests := map[string]struct { simErr error wantReconfigured int @@ -155,7 +155,7 @@ func TestExport(t *testing.T) { } } -func TestUnexport(t *testing.T) { +func TestUnexportGpiod(t *testing.T) { tests := map[string]struct { simNoLine bool simReconfErr error @@ -217,7 +217,7 @@ func TestUnexport(t *testing.T) { } } -func TestWrite(t *testing.T) { +func TestWriteGpiod(t *testing.T) { tests := map[string]struct { val int simErr error @@ -266,7 +266,7 @@ func TestWrite(t *testing.T) { } } -func TestRead(t *testing.T) { +func TestReadGpiod(t *testing.T) { tests := map[string]struct { simVal int simErr error diff --git a/system/digitalpin_poll.go b/system/digitalpin_poll.go new file mode 100644 index 000000000..6526acfee --- /dev/null +++ b/system/digitalpin_poll.go @@ -0,0 +1,81 @@ +package system + +import ( + "fmt" + "sync" + "time" +) + +func startEdgePolling( + pinLabel string, + pinReadFunc func() (int, error), + pollInterval time.Duration, + wantedEdge int, + eventHandler func(offset int, t time.Duration, et string, sn uint32, lsn uint32), + quitChan chan struct{}, +) error { + if eventHandler == nil { + return fmt.Errorf("an event handler is mandatory for edge polling") + } + if quitChan == nil { + return fmt.Errorf("the quit channel is mandatory for edge polling") + } + + const allEdges = "all" + + triggerEventOn := "none" + switch wantedEdge { + case digitalPinEventOnFallingEdge: + triggerEventOn = DigitalPinEventFallingEdge + case digitalPinEventOnRisingEdge: + triggerEventOn = DigitalPinEventRisingEdge + case digitalPinEventOnBothEdges: + triggerEventOn = allEdges + default: + return fmt.Errorf("unsupported edge type %d for edge polling", wantedEdge) + } + + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + var oldState int + var readStart time.Time + var firstLoopDone bool + for { + select { + case <-quitChan: + return + default: + // note: pure reading takes between 30us and 1ms on rasperry Pi1, typically 50us, with sysfs also 500us + // can happen, so we use the time stamp before start of reading to reduce random duration offset + readStart = time.Now() + readValue, err := pinReadFunc() + if err != nil { + fmt.Printf("edge polling error occurred while reading the pin %s: %v", pinLabel, err) + readValue = oldState // keep the value + } + if readValue != oldState { + detectedEdge := DigitalPinEventRisingEdge + if readValue < oldState { + detectedEdge = DigitalPinEventFallingEdge + } + if firstLoopDone && (triggerEventOn == allEdges || triggerEventOn == detectedEdge) { + eventHandler(0, time.Duration(readStart.UnixNano()), detectedEdge, 0, 0) + } + oldState = readValue + } + // the real poll interval is increased by the reading time, see also note above + // negative or zero duration causes no sleep + time.Sleep(pollInterval - time.Since(readStart)) + if !firstLoopDone { + wg.Done() + firstLoopDone = true + } + } + } + }() + + wg.Wait() + return nil +} diff --git a/system/digitalpin_poll_test.go b/system/digitalpin_poll_test.go new file mode 100644 index 000000000..c9a9fd3d1 --- /dev/null +++ b/system/digitalpin_poll_test.go @@ -0,0 +1,175 @@ +package system + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_startEdgePolling(t *testing.T) { + type readValue struct { + value int + err string + } + tests := map[string]struct { + eventOnEdge int + simulateReadValues []readValue + simulateNoEventHandler bool + simulateNoQuitChan bool + wantEdgeTypes []string + wantErr string + }{ + "edge_falling": { + eventOnEdge: digitalPinEventOnFallingEdge, + simulateReadValues: []readValue{ + {value: 1}, + {value: 0}, + {value: 1}, + {value: 0}, + {value: 0}, + }, + wantEdgeTypes: []string{DigitalPinEventFallingEdge, DigitalPinEventFallingEdge}, + }, + "no_edge_falling": { + eventOnEdge: digitalPinEventOnFallingEdge, + simulateReadValues: []readValue{ + {value: 0}, + {value: 1}, + {value: 1}, + }, + wantEdgeTypes: nil, + }, + "edge_rising": { + eventOnEdge: digitalPinEventOnRisingEdge, + simulateReadValues: []readValue{ + {value: 0}, + {value: 1}, + {value: 0}, + {value: 1}, + {value: 1}, + }, + wantEdgeTypes: []string{DigitalPinEventRisingEdge, DigitalPinEventRisingEdge}, + }, + "no_edge_rising": { + eventOnEdge: digitalPinEventOnRisingEdge, + simulateReadValues: []readValue{ + {value: 1}, + {value: 0}, + {value: 0}, + }, + wantEdgeTypes: nil, + }, + "edge_both": { + eventOnEdge: digitalPinEventOnBothEdges, + simulateReadValues: []readValue{ + {value: 0}, + {value: 1}, + {value: 0}, + {value: 1}, + {value: 1}, + }, + wantEdgeTypes: []string{DigitalPinEventRisingEdge, DigitalPinEventFallingEdge, DigitalPinEventRisingEdge}, + }, + "no_edges_low": { + eventOnEdge: digitalPinEventOnBothEdges, + simulateReadValues: []readValue{ + {value: 0}, + {value: 0}, + {value: 0}, + }, + wantEdgeTypes: nil, + }, + "no_edges_high": { + eventOnEdge: digitalPinEventOnBothEdges, + simulateReadValues: []readValue{ + {value: 1}, + {value: 1}, + {value: 1}, + }, + wantEdgeTypes: nil, + }, + "read_error_keep_state": { + eventOnEdge: digitalPinEventOnBothEdges, + simulateReadValues: []readValue{ + {value: 0}, + {value: 1, err: "read error suppress rising and falling edge"}, + {value: 0}, + {value: 1}, + {value: 1}, + }, + wantEdgeTypes: []string{DigitalPinEventRisingEdge}, + }, + "error_no_eventhandler": { + simulateNoEventHandler: true, + wantErr: "event handler is mandatory", + }, + "error_no_quitchannel": { + simulateNoQuitChan: true, + wantErr: "quit channel is mandatory", + }, + "error_unsupported_edgetype_none": { + eventOnEdge: digitalPinEventNone, + wantErr: "unsupported edge type 0", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + pinLabel := "test_pin" + pollInterval := time.Microsecond // zero is possible, just to show usage + // arrange event handler + var edgeTypes []string + var eventHandler func(int, time.Duration, string, uint32, uint32) + if !tc.simulateNoEventHandler { + eventHandler = func(offset int, t time.Duration, et string, sn uint32, lsn uint32) { + edgeTypes = append(edgeTypes, et) + } + } + // arrange quit channel + var quitChan chan struct{} + if !tc.simulateNoQuitChan { + quitChan = make(chan struct{}) + } + defer func() { + if quitChan != nil { + close(quitChan) + } + }() + // arrange reads + numCallsRead := 0 + wg := sync.WaitGroup{} + if tc.simulateReadValues != nil { + wg.Add(1) + } + readFunc := func() (int, error) { + numCallsRead++ + readVal := tc.simulateReadValues[numCallsRead-1] + var err error + if readVal.err != "" { + err = fmt.Errorf(readVal.err) + } + if numCallsRead >= len(tc.simulateReadValues) { + close(quitChan) // ensure no further read call + quitChan = nil // lets skip defer routine + wg.Done() // release assertions + } + + return readVal.value, err + } + // act + err := startEdgePolling(pinLabel, readFunc, pollInterval, tc.eventOnEdge, eventHandler, quitChan) + wg.Wait() + // assert + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + assert.Equal(t, len(tc.simulateReadValues), numCallsRead) + assert.Equal(t, tc.wantEdgeTypes, edgeTypes) + }) + } +} diff --git a/system/digitalpin_sysfs.go b/system/digitalpin_sysfs.go index f29a4157e..d27cd5bd9 100644 --- a/system/digitalpin_sysfs.go +++ b/system/digitalpin_sysfs.go @@ -47,7 +47,7 @@ func newDigitalPinSysfs(fs filesystem, pin string, options ...func(gobot.Digital func (d *digitalPinSysfs) ApplyOptions(options ...func(gobot.DigitalPinOptioner) bool) error { anyChange := false for _, option := range options { - anyChange = anyChange || option(d) + anyChange = option(d) || anyChange } if anyChange { return d.reconfigure() @@ -169,26 +169,38 @@ func (d *digitalPinSysfs) reconfigure() error { } } - // configure bias (unsupported) + // configure bias (inputs and outputs, unsupported) if err == nil { if d.bias != digitalPinBiasDefault && systemSysfsDebug { log.Printf("bias options (%d) are not supported by sysfs, please use hardware resistors instead\n", d.bias) } } - // configure drive (unsupported) - if d.drive != digitalPinDrivePushPull && systemSysfsDebug { - log.Printf("drive options (%d) are not supported by sysfs\n", d.drive) - } + // configure debounce period (inputs only), edge detection (inputs only) and drive (outputs only) + if d.direction == IN { + // configure debounce (unsupported) + if d.debouncePeriod != 0 && systemSysfsDebug { + log.Printf("debounce period option (%d) is not supported by sysfs\n", d.debouncePeriod) + } - // configure debounce (unsupported) - if d.debouncePeriod != 0 && systemSysfsDebug { - log.Printf("debounce period option (%d) is not supported by sysfs\n", d.debouncePeriod) - } + // configure edge detection + if err == nil { + if d.edge != 0 && d.pollInterval <= 0 { + err = fmt.Errorf("edge detect option (%d) is not implemented for sysfs without discrete polling", d.edge) + } + } - // configure edge detection (not implemented) - if d.edge != 0 && systemSysfsDebug { - log.Printf("edge detect option (%d) is not implemented for sysfs\n", d.edge) + // start discrete polling function and wait for first read is done + if err == nil { + if d.pollInterval > 0 { + err = startEdgePolling(d.label, d.Read, d.pollInterval, d.edge, d.edgeEventHandler, d.pollQuitChan) + } + } + } else { + // configure drive (unsupported) + if d.drive != digitalPinDrivePushPull && systemSysfsDebug { + log.Printf("drive options (%d) are not supported by sysfs\n", d.drive) + } } if err != nil { diff --git a/system/digitalpin_sysfs_test.go b/system/digitalpin_sysfs_test.go index 1c16c518b..260ed4b8a 100644 --- a/system/digitalpin_sysfs_test.go +++ b/system/digitalpin_sysfs_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gobot.io/x/gobot/v2" ) @@ -16,20 +17,109 @@ var ( _ gobot.DigitalPinOptionApplier = (*digitalPinSysfs)(nil) ) -func initTestDigitalPinSysFsWithMockedFilesystem(mockPaths []string) (*digitalPinSysfs, *MockFilesystem) { +func initTestDigitalPinSysfsWithMockedFilesystem(mockPaths []string) (*digitalPinSysfs, *MockFilesystem) { fs := newMockFilesystem(mockPaths) pin := newDigitalPinSysfs(fs, "10") return pin, fs } -func TestDigitalPin(t *testing.T) { +func Test_newDigitalPinSysfs(t *testing.T) { + // arrange + m := &MockFilesystem{} + const pinID = "1" + // act + pin := newDigitalPinSysfs(m, pinID, WithPinOpenDrain()) + // assert + assert.Equal(t, pinID, pin.pin) + assert.Equal(t, m, pin.fs) + assert.Equal(t, "gpio"+pinID, pin.label) + assert.Equal(t, "in", pin.direction) + assert.Equal(t, 1, pin.drive) +} + +func TestApplyOptionsSysfs(t *testing.T) { + tests := map[string]struct { + changed []bool + simErr bool + wantExport string + wantErr string + }{ + "both_changed": { + changed: []bool{true, true}, + wantExport: "10", + }, + "first_changed": { + changed: []bool{true, false}, + wantExport: "10", + }, + "second_changed": { + changed: []bool{false, true}, + wantExport: "10", + }, + "none_changed": { + changed: []bool{false, false}, + wantExport: "", + }, + "error_on_change": { + changed: []bool{false, true}, + simErr: true, + wantExport: "10", + wantErr: "unexport: no such file", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + mockPaths := []string{ + "/sys/class/gpio/export", + "/sys/class/gpio/gpio10/direction", + } + if !tc.simErr { + mockPaths = append(mockPaths, "/sys/class/gpio/unexport") + } + pin, fs := initTestDigitalPinSysfsWithMockedFilesystem(mockPaths) + + optionFunction1 := func(gobot.DigitalPinOptioner) bool { + pin.digitalPinConfig.direction = OUT + return tc.changed[0] + } + optionFunction2 := func(gobot.DigitalPinOptioner) bool { + pin.digitalPinConfig.drive = 15 + return tc.changed[1] + } + // act + err := pin.ApplyOptions(optionFunction1, optionFunction2) + // assert + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + } else { + assert.NoError(t, err) + } + assert.Equal(t, OUT, pin.digitalPinConfig.direction) + assert.Equal(t, 15, pin.digitalPinConfig.drive) + // marker for call of reconfigure, correct reconfigure is tested independently + assert.Equal(t, tc.wantExport, fs.Files["/sys/class/gpio/export"].Contents) + }) + } +} + +func TestDirectionBehaviorSysfs(t *testing.T) { + // arrange + pin := newDigitalPinSysfs(nil, "1") + require.Equal(t, "in", pin.direction) + pin.direction = "test" + // act && assert + assert.Equal(t, "test", pin.DirectionBehavior()) +} + +func TestDigitalPinSysfs(t *testing.T) { mockPaths := []string{ "/sys/class/gpio/export", "/sys/class/gpio/unexport", "/sys/class/gpio/gpio10/value", "/sys/class/gpio/gpio10/direction", } - pin, fs := initTestDigitalPinSysFsWithMockedFilesystem(mockPaths) + pin, fs := initTestDigitalPinSysfsWithMockedFilesystem(mockPaths) assert.Equal(t, "10", pin.pin) assert.Equal(t, "gpio10", pin.label) @@ -97,12 +187,12 @@ func TestDigitalPin(t *testing.T) { assert.ErrorContains(t, err.(*os.PathError).Err, "write error") } -func TestDigitalPinExportError(t *testing.T) { +func TestDigitalPinExportErrorSysfs(t *testing.T) { mockPaths := []string{ "/sys/class/gpio/export", "/sys/class/gpio/gpio11/direction", } - pin, _ := initTestDigitalPinSysFsWithMockedFilesystem(mockPaths) + pin, _ := initTestDigitalPinSysfsWithMockedFilesystem(mockPaths) writeFile = func(File, []byte) (int, error) { return 0, &os.PathError{Err: Syscall_EBUSY} @@ -112,11 +202,11 @@ func TestDigitalPinExportError(t *testing.T) { assert.ErrorContains(t, err, " : /sys/class/gpio/gpio10/direction: no such file") } -func TestDigitalPinUnexportError(t *testing.T) { +func TestDigitalPinUnexportErrorSysfs(t *testing.T) { mockPaths := []string{ "/sys/class/gpio/unexport", } - pin, _ := initTestDigitalPinSysFsWithMockedFilesystem(mockPaths) + pin, _ := initTestDigitalPinSysfsWithMockedFilesystem(mockPaths) writeFile = func(File, []byte) (int, error) { return 0, &os.PathError{Err: Syscall_EBUSY}