Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions components/motor/gpiostepper/gpiostepper.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,18 @@ type PinConfig struct {

// Config describes the configuration of a motor.
type Config struct {
Pins PinConfig `json:"pins"`
BoardName string `json:"board"`
StepperDelay int `json:"stepper_delay_usec,omitempty"` // When using stepper motors, the time to remain high
TicksPerRotation int `json:"ticks_per_rotation"`
Pins PinConfig `json:"pins"`
BoardName string `json:"board"`
// TicksPerRotation is the number of full motor steps per shaft revolution
TicksPerRotation int `json:"ticks_per_rotation"`
// Microsteps is the driver's microsteps (1, 2, 4, 8, 16, 32, ...),
Microsteps int `json:"microsteps,omitempty"`
// MaxRPM is the motor's maximum shaft speed and is the preferred way to cap
// the step-pin pulse frequency. Therefore maxFreq = MaxRPM * TicksPerRotation * Microsteps / 60.
MaxRPM float64 `json:"max_rpm,omitempty"`
// StepperDelay is the minimum delay between step pulses in microseconds.
// Deprecated: set MaxRPM instead. Still honored when MaxRPM is unset.
StepperDelay int `json:"stepper_delay_usec,omitempty"`
}

// Validate ensures all parts of the config are valid.
Expand All @@ -79,6 +87,12 @@ func (cfg *Config) Validate(path string) ([]string, []string, error) {
if cfg.Pins.Step == "" {
return nil, nil, resource.NewConfigValidationFieldRequiredError(path, "step")
}
if cfg.Microsteps < 0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also make sure that it's not odd?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can't because 1 is a valid value

return nil, nil, resource.NewConfigValidationError(path, errors.New("microsteps must be >= 0 (0 defaults to 1)"))
}
if cfg.MaxRPM < 0 {
return nil, nil, resource.NewConfigValidationError(path, errors.New("max_rpm must be >= 0"))
}
deps = append(deps, cfg.BoardName)
return deps, nil, nil
}
Expand Down Expand Up @@ -123,10 +137,12 @@ func newGPIOStepper(
return nil, errors.New("expected ticks_per_rotation in config for motor")
}

microsteps := max(mc.Microsteps, 1)
Comment thread
nfranczak marked this conversation as resolved.

m := &gpioStepper{
Named: conf.ResourceName().AsNamed(),
theBoard: b,
stepsPerRotation: mc.TicksPerRotation,
stepsPerRotation: mc.TicksPerRotation * microsteps,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels incorrect.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when microstepping the total counts for a rev is stepPerRev*microsteps

logger: logger,
opMgr: operation.NewSingleOperationManager(),
}
Expand Down Expand Up @@ -156,10 +172,30 @@ func newGPIOStepper(
return nil, err
}

if mc.StepperDelay > 0 {
m.minDelay = time.Duration(mc.StepperDelay * int(time.Microsecond))
switch {
case mc.MaxRPM > 0:
maxFreq := mc.MaxRPM * float64(m.stepsPerRotation) / 60.0
m.minDelay = time.Duration(float64(time.Second) / maxFreq)
if mc.StepperDelay > 0 {
logger.Warnf(
"motor (%s) has both max_rpm and stepper_delay_usec set; max_rpm wins. "+
"stepper_delay_usec is deprecated, drop it from the config.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if stepper delay is deprecated why did you give it a value above?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because we technically still use it in the code to calculate requested frequency

conf.ResourceName().Name,
)
}
case mc.StepperDelay > 0:
m.minDelay = time.Duration(mc.StepperDelay) * time.Microsecond
logger.Warnf(
"motor (%s) uses deprecated stepper_delay_usec; set max_rpm instead",
conf.ResourceName().Name,
)
}

logger.Infof(
"motor (%s) configured with %d full steps/rev * %d microsteps = %d pulses/rev",
conf.ResourceName().Name, mc.TicksPerRotation, microsteps, m.stepsPerRotation,
)

err = m.enable(ctx, false)
if err != nil {
return nil, err
Expand Down Expand Up @@ -247,6 +283,13 @@ func (m *gpioStepper) startPWM(ctx context.Context, forward bool, freqHz uint) (
actualFreq = float64(freqHz)
}

m.logger.Infow("PWM started on step pin",
"requested_freq_hz", freqHz,
"confirmed_freq_hz", actualFreq,
"forward", forward,
"steps_per_rotation", m.stepsPerRotation,
)

return actualFreq, nil
}

Expand Down
198 changes: 198 additions & 0 deletions components/motor/gpiostepper/gpiostepper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package gpiostepper

import (
"context"
"fmt"
"math"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -194,6 +196,125 @@ func TestConfigs(t *testing.T) {
test.That(t, err, test.ShouldBeNil)
test.That(t, properties.PositionReporting, test.ShouldBeTrue)
})

t.Run("microsteps defaults to 1 and is multiplicative on TicksPerRotation", func(t *testing.T) {
// missing microsteps → effective stepsPerRotation == TicksPerRotation
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c"},
TicksPerRotation: 200,
},
}
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)
test.That(t, m.(*gpioStepper).stepsPerRotation, test.ShouldEqual, 200)

// Microsteps=8, TicksPerRotation=200 → 1600
c2 := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c"},
TicksPerRotation: 200,
Microsteps: 8,
},
}
m2, err := newGPIOStepper(ctx, deps, c2, logger)
test.That(t, err, test.ShouldBeNil)
defer m2.Close(ctx)
test.That(t, m2.(*gpioStepper).stepsPerRotation, test.ShouldEqual, 1600)
})

t.Run("negative microsteps fails Validate", func(t *testing.T) {
mc := goodConfig
mc.Microsteps = -1
_, _, err := mc.Validate("")
test.That(t, err, test.ShouldNotBeNil)
test.That(t, err.Error(), test.ShouldContainSubstring, "microsteps")
})

t.Run("negative max_rpm fails Validate", func(t *testing.T) {
mc := goodConfig
mc.MaxRPM = -1
_, _, err := mc.Validate("")
test.That(t, err, test.ShouldNotBeNil)
test.That(t, err.Error(), test.ShouldContainSubstring, "max_rpm")
})

t.Run("max_rpm sets minDelay", func(t *testing.T) {
// MaxRPM=60, TicksPerRotation=200, no microsteps → maxFreq=200Hz → minDelay=5ms
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c"},
TicksPerRotation: 200,
MaxRPM: 60,
},
}
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)
test.That(t, m.(*gpioStepper).minDelay, test.ShouldEqual, 5*time.Millisecond)
})

t.Run("max_rpm wins when stepper_delay_usec is also set, logs deprecation", func(t *testing.T) {
obsLogger, obs := logging.NewObservedTestLogger(t)
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c"},
TicksPerRotation: 200,
MaxRPM: 60,
StepperDelay: 999, // would imply different minDelay if it won
},
}
m, err := newGPIOStepper(ctx, deps, c, obsLogger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)
// minDelay derived from MaxRPM, not StepperDelay
test.That(t, m.(*gpioStepper).minDelay, test.ShouldEqual, 5*time.Millisecond)

// deprecation warning was logged
var found bool
for _, entry := range obs.All() {
if strings.Contains(fmt.Sprint(entry), "deprecated") {
found = true
break
}
}
test.That(t, found, test.ShouldBeTrue)
})

t.Run("stepper_delay_usec alone logs deprecation", func(t *testing.T) {
obsLogger, obs := logging.NewObservedTestLogger(t)
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c"},
TicksPerRotation: 200,
StepperDelay: 30,
},
}
m, err := newGPIOStepper(ctx, deps, c, obsLogger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)
test.That(t, m.(*gpioStepper).minDelay, test.ShouldEqual, 30*time.Microsecond)

var found bool
for _, entry := range obs.All() {
if strings.Contains(fmt.Sprint(entry), "deprecated") {
found = true
break
}
}
test.That(t, found, test.ShouldBeTrue)
})
}

func TestRunning(t *testing.T) {
Expand Down Expand Up @@ -641,6 +762,83 @@ func TestRunning(t *testing.T) {
test.That(t, duty, test.ShouldEqual, 0.0)
})

t.Run("rpmToFreqHz scales with microsteps", func(t *testing.T) {
// TicksPerRotation=200, Microsteps=8 → stepsPerRotation=1600
// 50 RPM: 50 * 1600 / 60 = 1333.33 → 1333 Hz
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c", EnablePinHigh: "d", EnablePinLow: "e"},
TicksPerRotation: 200,
Microsteps: 8,
StepperDelay: 30,
},
}
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)

s := m.(*gpioStepper)
test.That(t, s.rpmToFreqHz(50), test.ShouldEqual, uint(1333))
})

t.Run("GoFor target step position scales with microsteps", func(t *testing.T) {
// TicksPerRotation=200, Microsteps=4 → stepsPerRotation=800
// GoFor(rpm=10000, revs=1) should target 800 pulses
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c", EnablePinHigh: "d", EnablePinLow: "e"},
TicksPerRotation: 200,
Microsteps: 4,
StepperDelay: 30,
},
}
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)

s := m.(*gpioStepper)
err = s.GoFor(ctx, 10000, 1, nil)
test.That(t, err, test.ShouldBeNil)
test.That(t, s.targetStepPosition.Load(), test.ShouldEqual, 800)

pos, err := m.Position(ctx, nil)
test.That(t, err, test.ShouldBeNil)
test.That(t, pos, test.ShouldEqual, 1)
})

t.Run("SetPower works with max_rpm and no stepper_delay_usec", func(t *testing.T) {
// MaxRPM=100, TicksPerRotation=200, Microsteps=1
// maxFreq = 100 * 200 / 60 = 333.33 Hz → minDelay = 3ms
// SetPower(0.5): freq = 0.5 / 0.003 = 166.67 → 166 Hz (uint truncation)
c := resource.Config{
Name: "fake_gpiostepper",
ConvertedAttributes: &Config{
BoardName: "brd",
Pins: PinConfig{Direction: "b", Step: "c", EnablePinHigh: "d", EnablePinLow: "e"},
TicksPerRotation: 200,
MaxRPM: 100,
},
}
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
defer m.Close(ctx)

err = m.SetPower(ctx, 0.5, nil)
test.That(t, err, test.ShouldBeNil)
defer m.Stop(ctx, nil)

testutils.WaitForAssertion(t, func(tb testing.TB) {
tb.Helper()
freq, err := pinC.PWMFreq(ctx, nil)
test.That(tb, err, test.ShouldBeNil)
test.That(tb, freq, test.ShouldEqual, uint(166))
})
})

t.Run("test stop signals waiters", func(t *testing.T) {
m, err := newGPIOStepper(ctx, deps, c, logger)
test.That(t, err, test.ShouldBeNil)
Expand Down
Loading