diff --git a/components/motor/gpiostepper/gpiostepper.go b/components/motor/gpiostepper/gpiostepper.go index ff293c453b8..b534ff0b4d8 100644 --- a/components/motor/gpiostepper/gpiostepper.go +++ b/components/motor/gpiostepper/gpiostepper.go @@ -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. @@ -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 { + 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 } @@ -123,10 +137,12 @@ func newGPIOStepper( return nil, errors.New("expected ticks_per_rotation in config for motor") } + microsteps := max(mc.Microsteps, 1) + m := &gpioStepper{ Named: conf.ResourceName().AsNamed(), theBoard: b, - stepsPerRotation: mc.TicksPerRotation, + stepsPerRotation: mc.TicksPerRotation * microsteps, logger: logger, opMgr: operation.NewSingleOperationManager(), } @@ -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.", + 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 @@ -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 } diff --git a/components/motor/gpiostepper/gpiostepper_test.go b/components/motor/gpiostepper/gpiostepper_test.go index 881b1c53192..9a8481f8854 100644 --- a/components/motor/gpiostepper/gpiostepper_test.go +++ b/components/motor/gpiostepper/gpiostepper_test.go @@ -2,7 +2,9 @@ package gpiostepper import ( "context" + "fmt" "math" + "strings" "sync" "testing" "time" @@ -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) { @@ -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)