Built-in project · Arduino Uno R3

PLC ladder on an Arduino Uno

A motor starter solved like a PLC: every scan reads all inputs into a table, solves the ladder rungs from it and writes the outputs at once, with a seal-in latch, fail-safe stop contacts and a latched overload fault. 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.

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

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

The circuit runs here once the page has loaded.

What you'll learn

  • Motors
  • Controllers
  • Displays
  • Reading a button
  • Difficulty: Advanced
  • Time: About 40 minutes
  • Board: Arduino Uno R3
  • Parts: 13

How it works

A motor starter solved like a PLC: every scan reads all inputs into a table, solves the ladder rungs from it and writes the outputs at once, with a seal-in latch, fail-safe stop contacts and a latched overload fault.

Arduino Uno R3 runs the sketch below. The circuit around it: Traffic light module, Relay module, DC motor, Pushbutton, Limit switch, lever microswitch, Slide switch. pinMode sets MOTOR_PIN as an output. digitalRead reads whether IN_PINS[i] is HIGH or LOW. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.

Parts list

13 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.

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
Traffic light module pin RArduino Uno R3 pin 13
Traffic light module pin YArduino Uno R3 pin 12
Traffic light module pin GArduino Uno R3 pin 11
Relay module pin INArduino Uno R3 pin 7
Pushbutton (2) pin 1aArduino Uno R3 pin 6
Slide switch (2) pin 2Arduino Uno R3 pin 5
Slide switch (1) pin 2Arduino Uno R3 pin 4
Limit switch, lever microswitch pin COMArduino Uno R3 pin 3
Pushbutton (1) pin 1aArduino Uno R3 pin 2
Character LCD with I2C backpack pin VCCArduino Uno R3 pin 5V
Character LCD with I2C backpack pin GNDArduino Uno R3 pin GND
Relay module pin GNDArduino Uno R3 pin GND
Pushbutton (1) pin 2aArduino Uno R3 pin GND
Limit switch, lever microswitch pin NCArduino Uno R3 pin GND
Slide switch (1) pin 1Arduino Uno R3 pin GND
Slide switch (2) pin 3Arduino Uno R3 pin GND
Pushbutton (2) pin 2aArduino Uno R3 pin GND
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Traffic light module pin GNDGround
Relay module pin VCC5 V
Relay module pin NO5 V
DC motor pin 1Relay module pin COM
DC motor pin 2Ground

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
// PLC ladder: a motor starter run the way a PLC runs it, one scan at a time.
//
//   2       START, a pushbutton to GND (normally open)
//   3       STOP, a lever switch: COM to pin 3, NC to GND (normally closed)
//   4       E-STOP, a slide switch: middle to pin 4, pin 1 to GND (closed is OK)
//   5       OVERLOAD test switch, a slide switch: middle to pin 5, pin 3 to GND
//   6       RESET, a pushbutton to GND
//   7       the relay, the motor's contactor (the motor's 5 V is on its contacts)
//   13/12/11 the stack light: red, yellow, green
//   LCD     SDA -> A4    SCL -> A5    VCC -> 5 V    GND -> GND
//
// A PLC does not react to a button the moment it changes. It runs the same
// three steps over and over, and each pass is called a scan:
//
//   1. READ    copy every input into the input image (a table of bits).
//   2. SOLVE   work through the rungs top to bottom, using only that table.
//   3. WRITE   copy the output bits to the real output pins, all at once.
//
// So the rungs see one frozen snapshot of the world, and a pin can never
// change halfway through the logic. The sketch times each scan and counts
// them. Printing and the LCD happen after the WRITE step, outside the timing.
//
// The rungs (a contact [ X] passes power when X is 1, [/X] when X is 0):
//
//   R1  [ OVLD]                         (S FAULT)  overload latches a fault
//   R2  [/OVLD] [ ESTOP]                ( READY)   safe to reset
//   R3  [ RESET] [ READY]               (R FAULT)  reset clears the fault
//   R4  [ START] or [ MOTOR], then
//       [ STOP] [ ESTOP] [/FAULT]       ( MOTOR)   the seal-in latch
//   R5  [ MOTOR]                        ( GREEN)
//   R6  [ FAULT], then [ READY] or [ BLINK]  ( YELLOW)
//   R7  [/ESTOP]                        ( RED)
//
// Seal-in: once MOTOR is on, its own contact in R4 keeps the rung powered
// after you let go of START. Anything that breaks the rung drops it, and it
// stays off until START is pressed again.
//
// Fail-safe stops: STOP and E-STOP are normally closed. At rest they connect
// their pin to GND, so the bit reads 1 = OK. Pressing them opens the circuit,
// and so does a broken wire or a loose plug, and either way the motor stops.
// A stop button that had to close a circuit to work would fail silently.
//
// A latched fault: an overload trips FAULT and it stays set even after the
// overload goes away. The yellow light blinks while the fault cannot be reset
// yet and goes steady once RESET will work. RESET does nothing while the
// overload is still on or the E-stop is pressed, and clearing the fault never
// restarts the motor: START is a separate, deliberate act.
//
// Press Run, press START, and try STOP, the E-stop and the overload switch.
// The Serial Monitor draws the ladder each time a bit changes: = is a wire
// with power on it, - is a dead one.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

const int START_PIN = 2;
const int STOP_PIN = 3;
const int ESTOP_PIN = 4;
const int OVERLOAD_PIN = 5;
const int RESET_PIN = 6;
const int MOTOR_PIN = 7;
const int RED_PIN = 13;
const int YELLOW_PIN = 12;
const int GREEN_PIN = 11;

const unsigned long FILTER_MS = 20;    // an input must hold this long to count
const unsigned long BLINK_MS = 250;    // the clock bit's half period
const unsigned long REPORT_MS = 5000;  // how often the scan stats are printed

// The relay switches on a low input.
const int RELAY_ON = LOW;
const int RELAY_OFF = HIGH;

LiquidCrystal_I2C lcd(0x27, 16, 2);

// The input image, in the order the LCD shows it.
const int INPUTS = 5;
const int IN_PINS[INPUTS] = {START_PIN, STOP_PIN, ESTOP_PIN, OVERLOAD_PIN, RESET_PIN};
bool image[INPUTS];          // what the rungs see this scan
bool raw[INPUTS];            // the pin as last read
unsigned long rawSince[INPUTS];

// Names for the bits, so the rungs read like a ladder.
#define iStart image[0]
#define iStopOk image[1]
#define iEstopOk image[2]
#define iOverload image[3]
#define iReset image[4]

bool sBlink = false;  // a clock bit, like a PLC's blinking system bit
bool mFault = false;  // internal relays (memory bits)
bool mReady = false;
bool qMotor = false;  // the output image
bool qGreen = false;
bool qYellow = false;
bool qRed = false;

unsigned long scans = 0;
unsigned long scanUs = 0;
unsigned long maxScanUs = 0;
unsigned long lastReport = 0;
int lastState = -1;
int lastShown = -1;

// ---- 1. READ ---------------------------------------------------------------

// Every input is wired to GND through its contact, so LOW means the contact
// is closed. For START and RESET that means pressed; for STOP and E-STOP it
// means all is well; for OVERLOAD it means the overload has tripped.
void readInputs(unsigned long now) {
  for (int i = 0; i < INPUTS; i++) {
    bool closed = digitalRead(IN_PINS[i]) == LOW;
    if (closed != raw[i]) {
      raw[i] = closed;
      rawSince[i] = now;
    }
    // An input filter: a bit changes only once the pin has held still.
    if (now - rawSince[i] >= FILTER_MS) image[i] = raw[i];
  }
  sBlink = (now / BLINK_MS) % 2 == 1;
}

// ---- 2. SOLVE ----------------------------------------------------------------

void solveRungs() {
  if (iOverload) mFault = true;                                  // R1 set
  mReady = !iOverload && iEstopOk;                               // R2
  if (iReset && mReady) mFault = false;                          // R3 reset
  qMotor = (iStart || qMotor) && iStopOk && iEstopOk && !mFault;  // R4
  qGreen = qMotor;                                               // R5
  qYellow = mFault && (mReady || sBlink);                        // R6
  qRed = !iEstopOk;                                              // R7
}

// ---- 3. WRITE ----------------------------------------------------------------

void writeOutputs() {
  digitalWrite(MOTOR_PIN, qMotor ? RELAY_ON : RELAY_OFF);
  digitalWrite(GREEN_PIN, qGreen);
  digitalWrite(YELLOW_PIN, qYellow);
  digitalWrite(RED_PIN, qRed);
}

// ---- the ladder, drawn in text -----------------------------------------------

bool flow;          // is there power on the rung at this point?
int col;            // characters printed on this line so far
int branchCol;      // where the rung's OR branch starts
bool branchIn;      // the power going into it
const char* branchName = 0;
bool branchBit;

void put(char c) {
  Serial.print(c);
  col++;
}

void text(const char* s) {
  while (*s) put(*s++);
}

void wire(int n) {
  while (n-- > 0) put(flow ? '=' : '-');
}

void contact(const char* name, bool bit, bool closedWhenOff) {
  put('[');
  put(closedWhenOff ? '/' : ' ');
  text(name);
  put(']');
  flow = flow && (closedWhenOff ? !bit : bit);
  wire(2);
}

// Two contacts side by side: power gets through either one. The lower one is
// drawn on the next line, once this line is done.
void either(const char* a, bool aBit, const char* b, bool bBit) {
  bool in = flow;
  branchCol = col;
  branchIn = in;
  branchName = b;
  branchBit = bBit;
  put('+');
  wire(1);
  contact(a, aBit, false);
  flow = in && (aBit || bBit);
  put('+');
  wire(2);
}

// A coil, and whether the rung has power at it. A set or reset coil also
// says what its latched bit holds now.
void coil(const char* name, int latched = -1) {
  // Pad every coil to the same column so the rungs line up.
  while (col < 56) put(flow ? '=' : '-');
  put('(');
  text(name);
  put(')');
  Serial.print(flow ? F(" ON") : F(" off"));
  if (latched >= 0) Serial.print(latched ? F("   FAULT is 1") : F("   FAULT is 0"));
  Serial.println();
  if (branchName) {
    // The lower half of the OR branch.
    col = 0;
    while (col < branchCol) put(col == 3 ? '|' : ' ');
    flow = branchIn;
    put('+');
    wire(1);
    contact(branchName, branchBit, false);
    Serial.println('+');
    branchName = 0;
  }
}

void rung(const char* label) {
  col = 0;
  text(label);
  text(" |");
  flow = true;  // the left rail always has power
  wire(2);
}

void drawLadder() {
  Serial.print(F("scan "));
  Serial.print(scans);
  Serial.print(F("  "));
  Serial.print(scanUs);
  Serial.println(F(" us"));
  rung("R1");
  contact("OVLD ", iOverload, false);
  coil("S FAULT", mFault);
  rung("R2");
  contact("OVLD ", iOverload, true);
  contact("ESTOP", iEstopOk, false);
  coil("READY");
  rung("R3");
  contact("RESET", iReset, false);
  contact("READY", mReady, false);
  coil("R FAULT", mFault);
  rung("R4");
  either("START", iStart, "MOTOR", qMotor);
  contact("STOP ", iStopOk, false);
  contact("ESTOP", iEstopOk, false);
  contact("FAULT", mFault, true);
  coil("MOTOR");
  rung("R5");
  contact("MOTOR", qMotor, false);
  coil("GREEN");
  rung("R6");
  contact("FAULT", mFault, false);
  either("READY", mReady, "BLINK", sBlink);
  coil("YELLOW");
  rung("R7");
  contact("ESTOP", iEstopOk, true);
  coil("RED");
  Serial.println();
}

// ---- the LCD: the image table as bits ----------------------------------------

void showTable() {
  lcd.setCursor(0, 0);
  lcd.print("I:SSEOR  O:MGYR");
  lcd.setCursor(2, 1);
  for (int i = 0; i < INPUTS; i++) lcd.print(image[i] ? '1' : '0');
  lcd.print("    ");
  lcd.print(qMotor ? '1' : '0');
  lcd.print(qGreen ? '1' : '0');
  lcd.print(qYellow ? '1' : '0');
  lcd.print(qRed ? '1' : '0');
}

// Every bit the ladder depends on, except the clock, packed into one number.
int stateBits() {
  int bits = 0;
  for (int i = 0; i < INPUTS; i++) bits = (bits << 1) | image[i];
  return (bits << 3) | (mFault << 2) | (mReady << 1) | qMotor;
}

void setup() {
  // Set the relay's pin high before it becomes an output, so the motor never
  // twitches at power up.
  digitalWrite(MOTOR_PIN, RELAY_OFF);
  pinMode(MOTOR_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(GREEN_PIN, OUTPUT);
  for (int i = 0; i < INPUTS; i++) {
    pinMode(IN_PINS[i], INPUT_PULLUP);
    raw[i] = image[i] = digitalRead(IN_PINS[i]) == LOW;
    rawSince[i] = 0;
  }
  Serial.begin(115200);
  Serial.println(F("PLC ladder"));
  lcd.init();
  lcd.backlight();
}

void loop() {
  unsigned long start = micros();
  readInputs(millis());
  solveRungs();
  writeOutputs();
  scanUs = micros() - start;
  scans++;
  if (scanUs > maxScanUs) maxScanUs = scanUs;

  // Housekeeping, between scans: what a PLC does with its spare time.
  int state = stateBits();
  if (state != lastState) {
    lastState = state;
    drawLadder();
  }
  int shown = (state << 4) | (qMotor << 3) | (qGreen << 2) | (qYellow << 1) | qRed;
  if (shown != lastShown) {
    lastShown = shown;
    showTable();
  }
  unsigned long now = millis();
  if (now - lastReport >= REPORT_MS) {
    lastReport = now;
    Serial.print(F("scans "));
    Serial.print(scans);
    Serial.print(F("  last "));
    Serial.print(scanUs);
    Serial.print(F(" us  max "));
    Serial.print(maxScanUs);
    Serial.println(F(" us"));
  }
}

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() {
  // Set the relay's pin high before it becomes an output, so the motor never
  // twitches at power up.
  digitalWrite(MOTOR_PIN, RELAY_OFF);
  pinMode(MOTOR_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(GREEN_PIN, OUTPUT);
  for (int i = 0; i < INPUTS; i++) {
    pinMode(IN_PINS[i], INPUT_PULLUP);
    raw[i] = image[i] = digitalRead(IN_PINS[i]) == LOW;
    rawSince[i] = 0;
  }
  Serial.begin(115200);
  Serial.println(F("PLC ladder"));
  lcd.init();
  lcd.backlight();
}

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 start = micros();
  readInputs(millis());
  solveRungs();
  writeOutputs();
  scanUs = micros() - start;
  scans++;
  if (scanUs > maxScanUs) maxScanUs = scanUs;

  // Housekeeping, between scans: what a PLC does with its spare time.
  int state = stateBits();
  if (state != lastState) {
    lastState = state;
    drawLadder();
  }
  int shown = (state << 4) | (qMotor << 3) | (qGreen << 2) | (qYellow << 1) | qRed;
  if (shown != lastShown) {
    lastShown = shown;
    showTable();
  }
  unsigned long now = millis();
  if (now - lastReport >= REPORT_MS) {
    lastReport = now;
    Serial.print(F("scans "));
    Serial.print(scans);
    Serial.print(F("  last "));
    Serial.print(scanUs);
    Serial.print(F(" us  max "));
  // ... 4 more lines in the editor

Troubleshooting

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

  • One color never lights, or all three light together. Traffic light module
    Check each wire goes to its own pin labeled R, Y, G, and that GND is connected. A pin driven high lights its LED. Resistors are already on the board, so do not add more.
  • Lights seem dim or the wrong color comes on in a sequence. Traffic light module
    Confirm the pins in your code match the wires, and that the module gets the right supply voltage. Keep each state's delay long enough to see, and turn the old color off before the next.
  • The relay does not click, or the board resets when it switches. Relay module
    A relay coil needs more current than a pin can give. Use a module with a transistor driver, and power VCC from 5 V. Share ground between the module and the Arduino.
  • The relay works backwards: on when you expect off. Relay module
    Many modules are active LOW: the input must go LOW to turn the relay on. Set the pin HIGH at startup so the load does not switch on during boot.
  • The motor does not spin, or the pin gets hot or the board resets when it is wired straight to a GPIO pin. DC motor
    A pin gives about 20 mA and a motor needs far more. Drive it through a transistor or motor driver, with its own supply and a shared ground.
  • The transistor or pin is damaged by spikes when the motor stops. DC motor
    Put a flyback diode across the motor, cathode to the positive side. A motor is a coil and kicks back voltage when switched off. Add a 0.1 uF capacitor across the motor terminals to cut noise.
  • 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.