Built-in project · Arduino Uno R3

XY pen plotter on an Arduino Uno

Two steppers and a pen-lift servo home to their end switches, then draw a square and a star from a list of points, counting every step on an LCD. The whole build, an Arduino Uno and 9 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 40 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $64. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Motors
  • Displays
  • Moving a servo to an angle
  • Reading a button
  • Difficulty: Intermediate
  • Time: About 40 minutes
  • Board: Arduino Uno R3
  • Parts: 17

How it works

Two steppers and a pen-lift servo home to their end switches, then draw a square and a star from a list of points, counting every step on an LCD.

Arduino Uno R3 runs the sketch below. The circuit around it: Stepper driver, A4988, Stepper motor, NEMA 17, Hobby servo, Pushbutton, Limit switch, lever microswitch, Character LCD with I2C backpack. pinMode sets START_PIN as an input with its pull-up on. digitalWrite drives pin HIGH. digitalRead reads whether pin is HIGH or LOW. Serial.begin opens the serial port at 9600 baud. millis reads the time since power-up, so the sketch can time things without stopping.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Arduino Uno R3 pin GNDGround
Stepper driver, A4988 (2) pin DIRArduino Uno R3 pin 12
Stepper driver, A4988 (2) pin STEPArduino Uno R3 pin 11
Stepper driver, A4988 (2) pin ENArduino Uno R3 pin 10
Hobby servo pin PWMArduino Uno R3 pin 9
Stepper driver, A4988 (1) pin ENArduino Uno R3 pin 8
Stepper driver, A4988 (1) pin DIRArduino Uno R3 pin 4
Stepper driver, A4988 (1) pin STEPArduino Uno R3 pin 3
Pushbutton pin 1aArduino Uno R3 pin 2
Limit switch, lever microswitch (1) pin NOArduino Uno R3 pin A0
Limit switch, lever microswitch (2) pin NOArduino Uno R3 pin A1
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Stepper driver, A4988 (1) pin MS1Ground
Stepper driver, A4988 (1) pin MS2Ground
Stepper driver, A4988 (1) pin MS3Ground
Stepper driver, A4988 (1) pin SLPStepper driver, A4988 (1) pin RST
Stepper driver, A4988 (1) pin VMOT12 V
Stepper driver, A4988 (1) pin GNDMGround
Stepper driver, A4988 (1) pin GNDGround
Stepper motor, NEMA 17 (1) pin A1Stepper driver, A4988 (1) pin 2B
Stepper motor, NEMA 17 (1) pin A2Stepper driver, A4988 (1) pin 2A
Stepper motor, NEMA 17 (1) pin B1Stepper driver, A4988 (1) pin 1A
Stepper motor, NEMA 17 (1) pin B2Stepper driver, A4988 (1) pin 1B
Stepper driver, A4988 (1) pin VDD5 V
Stepper driver, A4988 (2) pin MS1Ground
Stepper driver, A4988 (2) pin MS2Ground
Stepper driver, A4988 (2) pin MS3Ground
Stepper driver, A4988 (2) pin SLPStepper driver, A4988 (2) pin RST
Stepper driver, A4988 (2) pin VMOT12 V
Stepper driver, A4988 (2) pin GNDMGround
Stepper driver, A4988 (2) pin GNDGround
Stepper motor, NEMA 17 (2) pin A1Stepper driver, A4988 (2) pin 2B
Stepper motor, NEMA 17 (2) pin A2Stepper driver, A4988 (2) pin 2A
Stepper motor, NEMA 17 (2) pin B1Stepper driver, A4988 (2) pin 1A
Stepper motor, NEMA 17 (2) pin B2Stepper driver, A4988 (2) pin 1B
Stepper driver, A4988 (2) pin VDD5 V
Hobby servo pin GNDGround
Hobby servo pin VCC5 V
Pushbutton pin 2aGround
Limit switch, lever microswitch (1) pin COMGround
Limit switch, lever microswitch (2) pin COMGround
Character LCD with I2C backpack pin GNDGround
Character LCD with I2C backpack pin VCC5 V

The note on the canvas

Word for word, as it sits beside the circuit in the editor.

Try it
1. Press Run. The X motor turns, looking for its end switch: click the X switch to say the pen got there, then let it go.
2. Do the same for the Y switch. The LCD reads Ready.
3. Press START. The pen comes down and draws a square, then a star, while the LCD counts the steps and the percent done.
4. Click an end switch in the middle of a drawing: the pen lifts and the motors stop. Press START to home again.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// XY pen plotter: two NEMA 17 steppers on A4988 drivers move a pen over the
// paper, a small servo lifts the pen and puts it down, and two end switches
// tell the plotter where the corner of the paper is. Press START and it draws
// a square, then a five-pointed star, from a list of points, counting every
// step and showing how far along it is on the LCD.
//
//   X driver  STEP -> 3,  DIR -> 4,  EN -> 8
//   Y driver  STEP -> 11, DIR -> 12, EN -> 10
//             on both: EN LOW = motor powered, RST -> SLP,
//             MS1, MS2, MS3 -> GND (full steps), VMOT -> 12 V, VDD -> 5 V
//   pen servo PWM -> 9, VCC -> 5 V, GND -> GND
//   X end switch  NO -> A0, COM -> GND   (INPUT_PULLUP, pressed reads LOW)
//   Y end switch  NO -> A1, COM -> GND
//   START button  -> 2 and GND
//   LCD 16x2 I2C  SDA -> A4, SCL -> A5 (address 0x27)
//
// The numbers. A NEMA 17 takes 200 full steps a turn, and a 20 tooth GT2
// pulley moves the belt 40 mm a turn, so one millimeter is 5 steps. The
// points below are in millimeters from the corner the end switches find.
//
// How it moves in a straight line. To go from A to B the plotter takes as
// many "ticks" as the longer side of the move needs steps. On each tick it
// works out where the pen should be by now (a simple fraction of the way)
// and steps each motor that is behind. A diagonal steps both motors, a flat
// line only one. No delay() in the main flow: micros() says when the next
// step is due, so the plotter still reads its switches between steps.
//
// Safety. Once homed, the pen never goes near the end switches again, so a
// switch pressed while drawing means something is wrong (a jam, a hand, a
// loose belt). The plotter lifts the pen, powers the motors off and waits.
// Press START to home again.
//
// Things to try. Add your own shape to the SHAPES table: a house, your
// initials. Change DRAW_US to draw faster or slower.

#include <Servo.h>
#include <LiquidCrystal_I2C.h>

// ---- pins ----
const int START_PIN = 2;
const int X_STEP = 3, X_DIR = 4;
const int Y_STEP = 11, Y_DIR = 12;
const int X_ENABLE = 8, Y_ENABLE = 10;   // LOW powers a driver
const int PEN_PIN = 9;
const int X_LIMIT = A0, Y_LIMIT = A1;    // analog pins work as digital ones too

// ---- the machine ----
const long STEPS_PER_MM = 5;
const int PEN_UP = 90, PEN_DOWN = 30;   // servo angles
const unsigned long PEN_MS = 300;       // time for the pen to settle
const unsigned long DRAW_US = 8000;     // between steps with the pen down
const unsigned long TRAVEL_US = 4000;   // with the pen up
const unsigned long HOME_US = 5000;     // while it looks for a switch
const long HOME_MAX_STEPS = 120 * STEPS_PER_MM;  // further than the bed
const long BACKOFF_STEPS = 3 * STEPS_PER_MM;     // clear of the switch

// ---- the drawings: points in mm. The pen travels up to the first point
// of each shape, then goes down and draws through the rest. ----
struct Point { int x; int y; };

const Point SQUARE[] = {{5, 5}, {35, 5}, {35, 35}, {5, 35}, {5, 5}};
const Point STAR[] = {{60, 37}, {51, 10}, {74, 27}, {46, 27}, {69, 10}, {60, 37}};

struct Shape { const char *name; const Point *points; int count; };
const Shape SHAPES[] = {
  {"Square", SQUARE, 5},
  {"Star", STAR, 6},
};
const int SHAPE_COUNT = 2;

Servo pen;
LiquidCrystal_I2C lcd(0x27, 16, 2);

// ---- what the plotter is doing ----
enum State { HOMING_X, HOMING_Y, READY, PEN_WAIT, MOVING, FAULT };
State state = HOMING_X;

long posX = 0, posY = 0;          // where the pen is, in steps
long stepsX = 0, stepsY = 0;      // every step taken while drawing
long homeSteps = 0;               // steps taken looking for a switch
unsigned long lastStepUs = 0;
unsigned long penSince = 0;
bool penIsDown = false;

// the move in progress: from (startX, startY), by (dx, dy), in `ticks` ticks
long startX, startY, dx, dy, ticks, tick;
unsigned long stepUs = DRAW_US;

int shapeIndex = 0, pointIndex = 0;
bool parking = false;             // the last move: pen up, back to 0,0
int movesDone = 0;
int totalMoves = 0;               // worked out in setup()

// START, debounced
bool buttonStable = HIGH, buttonLast = HIGH;
unsigned long buttonChanged = 0;

// ---- small helpers ----
void motors(bool on) {
  digitalWrite(X_ENABLE, on ? LOW : HIGH);
  digitalWrite(Y_ENABLE, on ? LOW : HIGH);
}

void setPen(bool down) {
  penIsDown = down;
  pen.write(down ? PEN_DOWN : PEN_UP);
  penSince = millis();
}

// One pulse on a driver's STEP pin moves its motor 1.8 degrees.
void pulse(int pin) {
  digitalWrite(pin, HIGH);
  delayMicroseconds(4);
  digitalWrite(pin, LOW);
}

void stepX(int dir) {
  digitalWrite(X_DIR, dir > 0 ? HIGH : LOW);
  pulse(X_STEP);
  posX += dir;
}

void stepY(int dir) {
  digitalWrite(Y_DIR, dir > 0 ? HIGH : LOW);
  pulse(Y_STEP);
  posY += dir;
}

bool pressed(int pin) { return digitalRead(pin) == LOW; }

void show(const char *top, const char *bottom) {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(top);
  lcd.setCursor(0, 1);
  lcd.print(bottom);
}

void showProgress() {
  int percent = movesDone * 100 / totalMoves;
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(parking ? "Parking" : SHAPES[shapeIndex].name);
  lcd.setCursor(12, 0);
  lcd.print(percent);
  lcd.print("%");
  lcd.setCursor(0, 1);
  lcd.print("steps ");
  lcd.print(stepsX + stepsY);
}

void fault(const char *why) {
  setPen(false);
  motors(false);
  state = FAULT;
  Serial.print("FAULT: ");
  Serial.println(why);
  Serial.println("Motors off. Press START to home again");
  show("FAULT", why);
}

void startHoming() {
  motors(true);
  setPen(false);
  homeSteps = 0;
  state = HOMING_X;
  Serial.println("Homing X");
  show("Homing...", "");
}

// Begin a straight move to a point, in mm.
void beginMove(int xmm, int ymm) {
  startX = posX;
  startY = posY;
  dx = xmm * STEPS_PER_MM - posX;
  dy = ymm * STEPS_PER_MM - posY;
  ticks = max(labs(dx), labs(dy));
  tick = 0;
  stepUs = penIsDown ? DRAW_US : TRAVEL_US;
  state = MOVING;
}

// Decide what comes next: a pen change, a line, the trip home, or done.
void nextMove() {
  if (parking) {
    parking = false;
    motors(false);
    state = READY;
    Serial.print("Done! X steps ");
    Serial.print(stepsX);
    Serial.print(", Y steps ");
    Serial.println(stepsY);
    show("Done! START=again", "");
    lcd.setCursor(0, 1);
    lcd.print("steps ");
    lcd.print(stepsX + stepsY);
    return;
  }
  if (pointIndex == SHAPES[shapeIndex].count) {
    Serial.print(SHAPES[shapeIndex].name);
    Serial.println(" finished");
    shapeIndex++;
    pointIndex = 0;
    if (shapeIndex == SHAPE_COUNT) {
      // everything is drawn: lift the pen and go back to the corner
      parking = true;
      shapeIndex = SHAPE_COUNT - 1;
      if (penIsDown) { setPen(false); state = PEN_WAIT; return; }
      beginMove(0, 0);
      return;
    }
    Serial.print("Drawing ");
    Serial.println(SHAPES[shapeIndex].name);
  }
  // the first point of a shape is reached with the pen up
  bool wantDown = pointIndex > 0;
  if (wantDown != penIsDown) {
    setPen(wantDown);
    state = PEN_WAIT;
    return;
  }
  const Point &p = SHAPES[shapeIndex].points[pointIndex];
  beginMove(p.x, p.y);
}

void setup() {
  Serial.begin(9600);
  pinMode(START_PIN, INPUT_PULLUP);
  pinMode(X_LIMIT, INPUT_PULLUP);
  pinMode(Y_LIMIT, INPUT_PULLUP);
  pinMode(X_STEP, OUTPUT);
  pinMode(X_DIR, OUTPUT);
  pinMode(Y_STEP, OUTPUT);
  pinMode(Y_DIR, OUTPUT);
  pinMode(X_ENABLE, OUTPUT);
  pinMode(Y_ENABLE, OUTPUT);
  pen.attach(PEN_PIN);
  lcd.init();
  lcd.backlight();

  // every point is one move, plus one more to park at the end
  for (int s = 0; s < SHAPE_COUNT; s++) totalMoves += SHAPES[s].count;
  totalMoves += 1;

  Serial.println("XY plotter");
  startHoming();
}

void loop() {
  unsigned long now = millis();

  // START, debounced: act once, on the press
  bool reading = digitalRead(START_PIN);
  bool startPressed = false;
  if (reading != buttonLast) { buttonLast = reading; buttonChanged = now; }
  if (now - buttonChanged > 30 && reading != buttonStable) {
    buttonStable = reading;
    if (buttonStable == LOW) startPressed = true;
  }

  // the interlock: an end switch pressed while drawing stops everything
  if (state == MOVING || state == PEN_WAIT) {
    if (pressed(X_LIMIT)) { fault("X limit hit"); return; }
    if (pressed(Y_LIMIT)) { fault("Y limit hit"); return; }
  }

  switch (state) {
    case HOMING_X:
    case HOMING_Y: {
      bool xAxis = state == HOMING_X;
      if (micros() - lastStepUs < HOME_US) break;
      lastStepUs = micros();
      if (pressed(xAxis ? X_LIMIT : Y_LIMIT)) {
        // found it: back off a little and call that spot zero
        for (long i = 0; i < BACKOFF_STEPS; i++) {
          if (xAxis) stepX(+1); else stepY(+1);
          delayMicroseconds(2000);
        }
        Serial.print(xAxis ? "X" : "Y");
        Serial.print(" home after ");
        Serial.print(homeSteps);
        Serial.println(" steps");
        homeSteps = 0;
        if (xAxis) {
          posX = 0;
          state = HOMING_Y;
          Serial.println("Homing Y");
        } else {
          posY = 0;
          motors(false);
          state = READY;
          Serial.println("Ready. Press START to draw");
          show("Ready", "Press START");
        }
        break;
      }
      if (homeSteps >= HOME_MAX_STEPS) {
        fault(xAxis ? "no X switch" : "no Y switch");
        break;
      }
      if (xAxis) stepX(-1); else stepY(-1);
      homeSteps++;
      break;
    }

    case READY:
      if (startPressed) {
        if (pressed(X_LIMIT) || pressed(Y_LIMIT)) {
          Serial.println("An end switch is pressed: clear it first");
          break;
        }
        motors(true);
        stepsX = stepsY = 0;
        shapeIndex = pointIndex = 0;
        movesDone = 0;
        parking = false;
        Serial.print("Drawing ");
        Serial.println(SHAPES[0].name);
        showProgress();
        nextMove();
      }
      break;

    case PEN_WAIT:
      if (now - penSince >= PEN_MS) {
        if (parking) beginMove(0, 0); else nextMove();
      }
      break;

    case MOVING:
      if (micros() - lastStepUs < stepUs) break;
      lastStepUs = micros();
      if (tick < ticks) {
        tick++;
        // where the pen should be after this tick, a fraction of the way
        long wantX = startX + dx * tick / ticks;
        long wantY = startY + dy * tick / ticks;
        if (wantX != posX) { stepX(wantX > posX ? 1 : -1); stepsX++; }
        if (wantY != posY) { stepY(wantY > posY ? 1 : -1); stepsY++; }
      } else {
        // the move is finished
        movesDone++;
        if (!parking) {
          Serial.print("Move ");
          Serial.print(movesDone);
          Serial.print("/");
          Serial.print(totalMoves);
          Serial.print(" at ");
          Serial.print(posX / STEPS_PER_MM);
          Serial.print(",");
          Serial.print(posY / STEPS_PER_MM);
          Serial.println(" mm");
          pointIndex++;
        }
        showProgress();
        nextMove();
      }
      break;

    case FAULT:
      if (startPressed) startHoming();
      break;
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  Serial.begin(9600);
  pinMode(START_PIN, INPUT_PULLUP);
  pinMode(X_LIMIT, INPUT_PULLUP);
  pinMode(Y_LIMIT, INPUT_PULLUP);
  pinMode(X_STEP, OUTPUT);
  pinMode(X_DIR, OUTPUT);
  pinMode(Y_STEP, OUTPUT);
  pinMode(Y_DIR, OUTPUT);
  pinMode(X_ENABLE, OUTPUT);
  pinMode(Y_ENABLE, OUTPUT);
  pen.attach(PEN_PIN);
  lcd.init();
  lcd.backlight();

  // every point is one move, plus one more to park at the end
  for (int s = 0; s < SHAPE_COUNT; s++) totalMoves += SHAPES[s].count;
  totalMoves += 1;

  Serial.println("XY plotter");
  startHoming();
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  unsigned long now = millis();

  // START, debounced: act once, on the press
  bool reading = digitalRead(START_PIN);
  bool startPressed = false;
  if (reading != buttonLast) { buttonLast = reading; buttonChanged = now; }
  if (now - buttonChanged > 30 && reading != buttonStable) {
    buttonStable = reading;
    if (buttonStable == LOW) startPressed = true;
  }

  // the interlock: an end switch pressed while drawing stops everything
  if (state == MOVING || state == PEN_WAIT) {
    if (pressed(X_LIMIT)) { fault("X limit hit"); return; }
    if (pressed(Y_LIMIT)) { fault("Y limit hit"); return; }
  }

  switch (state) {
    case HOMING_X:
    case HOMING_Y: {
      bool xAxis = state == HOMING_X;
      if (micros() - lastStepUs < HOME_US) break;
      lastStepUs = micros();
      if (pressed(xAxis ? X_LIMIT : Y_LIMIT)) {
        // found it: back off a little and call that spot zero
        for (long i = 0; i < BACKOFF_STEPS; i++) {
          if (xAxis) stepX(+1); else stepY(+1);
  // ... 88 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The motor does nothing and the driver stays dead. Stepper driver
    Tie RST to SLP with a jumper, or drive both HIGH. Those two pins float on many boards. Also connect the logic VDD to 5 V and ground, and the motor VMOT to its own supply.
  • The driver or the chip dies when you power up. Stepper driver
    Put a 100 uF capacitor across VMOT and GND, close to the driver. Never connect or unplug the motor while powered, because the voltage spike can kill the chip.
  • The motor buzzes, shudders, or only vibrates instead of turning. Stepper motor
    Coils are swapped. Find the two coil pairs by measuring resistance with a meter (a few ohms between wires of one coil). Connect each pair to one driver coil output, A to A and B to B.
  • The motor stalls or loses steps when you speed up. Stepper motor
    A stepper cannot jump to a high speed. Start with slow step pulses and ramp up gradually. Check the driver current limit (Vref) and give the supply enough voltage, such as 12 V.
  • The servo jitters, twitches or resets the board when it moves. Hobby servo
    Power it from its own 5 V supply, not the Arduino 5 V pin, and join the grounds. A hobby servo can pull several hundred milliamps. Add a 100 uF to 470 uF capacitor across its supply.
  • The servo hums at the end of its travel or will not reach full angle. Hobby servo
    Stay inside the pulse range, about 1000 to 2000 us for most hobby servos. Pushing past the end stop makes it strain. Calibrate the min and max pulse for your servo.
  • The input reads random HIGH and LOW when the button is not pressed. Pushbutton
    A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW.
  • The button seems always pressed, or never changes. Pushbutton
    A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.