Controlling a Nema 23 closed loop stepper motor with Arduino Nano

Code walkthrough for Arduino C++ code loaded into the famous Arduino Nano board to control a Nema 23 hybrid closed loop stepper motor.

June 04, 2020

What is covered

Code repository

Primers

Importing libraries, assigning values to constants and variables

We need the Wire library to be able to receive new settings via I2C from the master board ESP32, Arduino Nano being set up as a slave. The fantastic and very well known AccelStepper library is used of course to control our stepper motor.

Do not forget you can download the Fritzing .fzz file from here to take a look at the breadboard and schematic views. It would help you to better picture the project set up.

Between the lines 6 and 9 we assign the pin values. homeSet, isHoming, isStopping are control variables to discern the current operation the stepper motor is engaged on. stepperParams are of a size of 3 (steps to move, steps per second for inspiration, steps per second for expiration). maxBVMVolume is used to determine the ratio between the received volume setting and this maximum volume possible.

That maximum amount of volume can be pushed by the crank shaft linked to the stepper motor axle at half a revolution. That’s why we need to also know the stepsPerRevolution. maximumSpeed is for safety and defaultSpeed is for movements where steps per second are not specified. settings and prevSettings are for keeping track of the data sent by the master ESP32 board. isClockWise registers the direction of the rotation and it is a control variable as well as newStepperParams.

#include <Wire.h>
#include <AccelStepper.h>

AccelStepper stepper(1, 3, 2);

const int relayPin = 4;
const int trackerPin = 9;

const int I2CSlaveAddress = 4;

bool homeSet = false;
bool isHoming = true;
bool isStopping = false;

const int stepperParamsSize = 3;
int stepperParams[stepperParamsSize] = {0, 0, 0};

const int maxBVMVolume = 1600;
const int stepsPerRevolution = 500;
const int maximumSpeed = 3000;
const int defaultSpeed = 1000;

const int settingsSize = 4;
int settings[settingsSize] = {0, 0, 0, 0};
int prevSettings[settingsSize] = {0, 0, 0, 0};

bool isClockWise = true;
bool newStepperParams = false;

setup() function

On lines 2-3 we set the pin mode for the relay and for the line tracer used to home in the stepper motor. On lines 7-8 we initiate the Wire I2C communication and we assign an event listener for the receive event. On line 13 we open the relay, on lines 16-17 we are setting the maximum and default speeds for the stepper instance of the AccelStepper class.

void setup() {
  pinMode(relayPin, OUTPUT);
  pinMode(trackerPin, INPUT);
  
  digitalWrite(relayPin, LOW);

  Wire.begin(I2CSlaveAddress);
  Wire.onReceive(receiveEvent);
  
  Serial.begin(115200);

  delay(3000);
  digitalWrite(relayPin, HIGH);
  delay(4000);

  stepper.setMaxSpeed(maximumSpeed);
  stepper.setSpeed(defaultSpeed);
  
  Serial.println("");
  Serial.println("Starting");
}

loop() function

Please keep in mind that we do not have blocking operations induced by the stepper. We call stepper.runSpeedToPosition() at each loop cycle and if a step is due it is executed. The first thing we do is to set the “home” position. Then we check if isStopping and we call the helper function stopStepper again and again until that control variable is false.

We only proceed if there is no homing or stopping operation in progress. We then check if new settings are received (line 11). At line 14 we verify if the new settings are associated with a stop command (if all 4 values are set to 0). If we are good to go, we computeNewParams() (total steps to move, steps per second for inspiration and expiration) using the newly received settings.

Of course there are cases when there is nothing to do and we have to verify that at lines 31-33. If newStepperParams we need to move the motor back to "home" position before starting the movement with the new settings (lines 35-49). Then and only then we call the moveStepper() function.

void loop() {
  if (homeSet == false) {
    executeHoming();
  }

  if (isStopping == true) {
    stopStepper();
  }

  if (isHoming == false && isStopping == false) {
    if (hasNewValues() == true) {
      Serial.println("hasNewValues");
      
      if (isItStopping() == true) {
        isStopping = true;
      }

      if (isStopping == false) {
        if (prevSettings[0] != 0) {
          newStepperParams = true;  
        }

        computeNewParams();
      }

      for (int i = 0; i < settingsSize; i++) {
        prevSettings[i] = settings[i];
      }  
    }

    if (nothingToDo() == true) {
      return;
    }

    if (newStepperParams == true) {
      Serial.println("newStepperParams move to zero");
      stepper.moveTo(0);
      stepper.setSpeed(defaultSpeed);
      stepper.runSpeedToPosition();

      printPositionalData();
      if (stepper.distanceToGo() != 0) {
        return;
      } else {
        Serial.println("Stepper is back at zero");
        newStepperParams = false;
        isClockWise = true;
      }
    }
    
    moveStepper();
  }
}

Helper functions

  • moveStepper is the most important: we set the speed and move the stepper to position, clockwise and anti-clockwise.

When permitted by the previous checks, this is called at each loop cycle, in a continuous back and forth movement.

void moveStepper() {
  if (isClockWise == true) {
    stepper.moveTo(stepperParams[0]);
    stepper.setSpeed(stepperParams[1]);

    printPositionalData();
    if (stepper.distanceToGo() == 0) {
      isClockWise = false;
    }
  } else {
    stepper.moveTo(0);
    stepper.setSpeed(stepperParams[2]);
    
    printPositionalData();
    if (stepper.distanceToGo() == 0) {
      isClockWise = true;
    }
  }

  stepper.runSpeedToPosition();
}
  • stopStepper is responsible for stopping any previous command and for getting the motor to the "zero" position. When its job is done it is setting the isStopping control variable to false to let other parts of the code do their jobs.
void stopStepper() {
  Serial.println("stopStepper move to zero");
  stepper.moveTo(0);
  stepper.setSpeed(defaultSpeed);
  stepper.runSpeedToPosition();

  printPositionalData();
  if (stepper.distanceToGo() != 0) {
    return;
  } else {
    Serial.println("Stepper is back at zero");
    isStopping = false;
  }
}
  • executeHoming is setting the "zero" position with the help of the tracing sensor. Again, it is setting the isHoming control variable to false to signal other parts of the code.
void executeHoming() {
  const int notAtHome = digitalRead(trackerPin);

  if (notAtHome == false) {
    Serial.println("Homing successful");
    stepper.setCurrentPosition(0); //now the current motor speed is zero
    homeSet = true;
    isHoming = false;
    return;
  }

  stepper.runSpeed();
}
  • computeNewParams only seems complicated. It is finding the ratio between the volume setting and the maximum volume. It is using that to determine how much of the half revolution, measured in steps, it is allowed to move. Then, based on the inspiration / expiration ratio and the number of respirations per minute, determines the steps per second for inspiration and expiration.
void computeNewParams() {
  Serial.println("computeNewParams");
  Serial.println("settings[0]: " + String(settings[0]));
  Serial.println("settings[1]: " + String(settings[1]));
  Serial.println("settings[2]: " + String(settings[2]));
  Serial.println("settings[3]: " + String(settings[3]));
  
  // Compute steps to move 
  // TODO measure precise volumes to determine 
  // the correlation between piston amplitude and pushed volume of air
  // the current formula assumes a linear correlation
  stepperParams[0] = ceil((float(stepsPerRevolution) / 2) * (float(settings[0]) / float(maxBVMVolume)));

  const float secondsPerFraction = 60 / ((float(settings[2]) + float(settings[3])) * float(settings[1]));
  
  // compute the steps per second for inspiration
  stepperParams[1] = ceil(float(stepperParams[0]) / (secondsPerFraction * float(settings[2])));

  // compute the steps per second for expiration
  stepperParams[2] = ceil(float(stepperParams[0]) / (secondsPerFraction * float(settings[3])));

  Serial.println("stepperParams[0]: " + String(stepperParams[0]));
  Serial.println("stepperParams[1]: " + String(stepperParams[1]));
  Serial.println("stepperParams[2]: " + String(stepperParams[2]));
}
  • receiveEvent is the handler for the I2C "receive" event. It has to verify how many bytes were sent in the received stream. Then it is parsing that array buffer character by character until it finds a x value separator. Each of the 4 values is stored in the settings array.
void receiveEvent(int howMany) {
  Serial.println("I2C Receive event");
  char t[30];
  int i = 0;
  
  while (Wire.available()) { 
    t[i] = Wire.read();
    i = i + 1;
  }
  
  int j = 0;

  if (checkForData(t, howMany, settingsSize) == false) return;

  String sett = "";
  for (int i = 0; i < howMany; i++) {
    String current = String(t[i]);

    // look for x as the values separator
    if (current != "x") {
      sett = sett + current;   
    } else {
      settings[j] = sett.toInt();
      sett = "";
      j++;
    }

    if (i == howMany - 1) {
      settings[j] = sett.toInt();
    }
  }
}