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.
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.
Parts list
17 parts, plus the jumper wires. They retail for about $64; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 2 × Stepper driver, A4988In Claw Machine Kit.
- 2 × Stepper motor, NEMA 17
- 2 × Power, 12 V
- 4 × Power, 5 V
- 1 × Hobby servoIn Halloween Prop Kit, Christmas Lights Kit, Starter Projects Kit.
- 1 × Half breadboard
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 2 × Limit switch, lever microswitchIn Claw Machine Kit, Vending Machine Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
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 pin | Connects to |
|---|---|
| Arduino Uno R3 pin GND | Ground |
| Stepper driver, A4988 (2) pin DIR | Arduino Uno R3 pin 12 |
| Stepper driver, A4988 (2) pin STEP | Arduino Uno R3 pin 11 |
| Stepper driver, A4988 (2) pin EN | Arduino Uno R3 pin 10 |
| Hobby servo pin PWM | Arduino Uno R3 pin 9 |
| Stepper driver, A4988 (1) pin EN | Arduino Uno R3 pin 8 |
| Stepper driver, A4988 (1) pin DIR | Arduino Uno R3 pin 4 |
| Stepper driver, A4988 (1) pin STEP | Arduino Uno R3 pin 3 |
| Pushbutton pin 1a | Arduino Uno R3 pin 2 |
| Limit switch, lever microswitch (1) pin NO | Arduino Uno R3 pin A0 |
| Limit switch, lever microswitch (2) pin NO | Arduino Uno R3 pin A1 |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Stepper driver, A4988 (1) pin MS1 | Ground |
| Stepper driver, A4988 (1) pin MS2 | Ground |
| Stepper driver, A4988 (1) pin MS3 | Ground |
| Stepper driver, A4988 (1) pin SLP | Stepper driver, A4988 (1) pin RST |
| Stepper driver, A4988 (1) pin VMOT | 12 V |
| Stepper driver, A4988 (1) pin GNDM | Ground |
| Stepper driver, A4988 (1) pin GND | Ground |
| Stepper motor, NEMA 17 (1) pin A1 | Stepper driver, A4988 (1) pin 2B |
| Stepper motor, NEMA 17 (1) pin A2 | Stepper driver, A4988 (1) pin 2A |
| Stepper motor, NEMA 17 (1) pin B1 | Stepper driver, A4988 (1) pin 1A |
| Stepper motor, NEMA 17 (1) pin B2 | Stepper driver, A4988 (1) pin 1B |
| Stepper driver, A4988 (1) pin VDD | 5 V |
| Stepper driver, A4988 (2) pin MS1 | Ground |
| Stepper driver, A4988 (2) pin MS2 | Ground |
| Stepper driver, A4988 (2) pin MS3 | Ground |
| Stepper driver, A4988 (2) pin SLP | Stepper driver, A4988 (2) pin RST |
| Stepper driver, A4988 (2) pin VMOT | 12 V |
| Stepper driver, A4988 (2) pin GNDM | Ground |
| Stepper driver, A4988 (2) pin GND | Ground |
| Stepper motor, NEMA 17 (2) pin A1 | Stepper driver, A4988 (2) pin 2B |
| Stepper motor, NEMA 17 (2) pin A2 | Stepper driver, A4988 (2) pin 2A |
| Stepper motor, NEMA 17 (2) pin B1 | Stepper driver, A4988 (2) pin 1A |
| Stepper motor, NEMA 17 (2) pin B2 | Stepper driver, A4988 (2) pin 1B |
| Stepper driver, A4988 (2) pin VDD | 5 V |
| Hobby servo pin GND | Ground |
| Hobby servo pin VCC | 5 V |
| Pushbutton pin 2a | Ground |
| Limit switch, lever microswitch (1) pin COM | Ground |
| Limit switch, lever microswitch (2) pin COM | Ground |
| Character LCD with I2C backpack pin GND | Ground |
| Character LCD with I2C backpack pin VCC | 5 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.
// 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 editorTroubleshooting
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.