Built-in project · Arduino Uno R3

Car wash on an Arduino Uno

Drive a car up to the sensor and press START: it rinses, soaps, brushes, rinses and dries on a timeline, and the emergency stop drops everything at once. The whole build, an Arduino Uno and 13 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 45 minutes to build and run. Runs in your browser. Free, and no account needed.

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

The circuit runs here once the page has loaded.

What you'll learn

  • Motors
  • Sensors
  • Displays
  • Controllers
  • Reading a button
  • Difficulty: Intermediate
  • Time: About 45 minutes
  • Board: Arduino Uno R3
  • Parts: 22

How it works

Drive a car up to the sensor and press START: it rinses, soaps, brushes, rinses and dries on a timeline, and the emergency stop drops everything at once.

Arduino Uno R3 runs the sketch below. The circuit around it: Traffic light module, Relay board, two channels, Solenoid, Mini water pump, MX1508 dual motor driver, DC motor. pinMode sets pin as an output. digitalWrite drives BRUSH_A HIGH. digitalRead reads whether in.pin is HIGH or LOW. analogWrite sends a PWM signal out on DRYER_A. Serial.begin opens the serial port at 9600 baud.

Parts list

22 parts, plus the jumper wires. They retail for about $54; 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 board, two channels pin IN1Arduino Uno R3 pin 9
Relay board, two channels pin IN2Arduino Uno R3 pin 8
MX1508 dual motor driver pin IN1Arduino Uno R3 pin 7
MX1508 dual motor driver pin IN2Arduino Uno R3 pin 6
MX1508 dual motor driver pin IN3Arduino Uno R3 pin 5
MX1508 dual motor driver pin IN4Arduino Uno R3 pin 4
Fan module, L9110 pin INAArduino Uno R3 pin 3
Fan module, L9110 pin INBArduino Uno R3 pin 2
Arduino Uno R3 pin GNDGround
Obstacle sensor, FC-51 pin OUTArduino Uno R3 pin A0
Emergency stop button pin 1Arduino Uno R3 pin A1
Pushbutton pin 1aArduino Uno R3 pin A2
Piezo speaker pin 1Arduino Uno R3 pin A3
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 board, two channels pin GNDGround
Relay board, two channels pin VCC5 V
Solenoid pin 1Relay board, two channels pin NO1
Relay board, two channels pin COM16 V
Mini water pump pin V+Relay board, two channels pin NO2
Relay board, two channels pin COM26 V
Solenoid pin 2Ground
Mini water pump pin V-Ground
MX1508 dual motor driver pin +6 V
MX1508 dual motor driver pin -Ground
DC motor (1) pin 1MX1508 dual motor driver pin MA1
DC motor (1) pin 2MX1508 dual motor driver pin MA2
DC motor (2) pin 1MX1508 dual motor driver pin MB1
DC motor (2) pin 2MX1508 dual motor driver pin MB2
Fan module, L9110 pin VCC5 V
Fan module, L9110 pin GNDGround
Obstacle sensor, FC-51 pin GNDGround
Obstacle sensor, FC-51 pin VCC5 V
Emergency stop button pin 2Ground
Pushbutton pin 2aGround
Piezo speaker pin 2Ground
Character LCD with I2C backpack pin GNDGround
Character LCD with I2C backpack pin VCC5 V

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
// Car wash: a staged sequencer for a drive-through car wash. A car rolls up to
// the entry sensor, the driver presses START, and the conveyor pulls it
// through five stages: rinse, soap, brush, rinse again, and dry. A big red
// emergency stop drops everything at once, at any moment.
//
//   traffic light  13 red, 12 yellow, 11 green (the module has its own resistors)
//   relays         pin 9 -> IN1 (rinse water valve), pin 8 -> IN2 (soap pump), active LOW
//   MX1508         7, 6 -> IN1, IN2 (brush motor); 5, 4 -> IN3, IN4 (conveyor)
//   dryer fan      3 -> INA (PWM speed), 2 -> INB
//   entry sensor   A0 <- FC-51 OUT (goes LOW when a car is in front of it)
//   E-STOP         A1 -> e-stop contact -> GND (normally closed: pressed reads HIGH)
//   START          A2 -> button -> GND        (INPUT_PULLUP, pressed reads LOW)
//   piezo          A3 -> piezo -> GND
//   LCD            A4 SDA, A5 SCL (16x2 on an I2C backpack at 0x27)
//
// How to play: drag the entry sensor's slider so the car is close (under
// 13 cm), wait for "press START", and press START. Watch the LCD's timeline
// fill up as the stages go by. Press the E-STOP in the middle of a wash: every
// motor and pump stops at once. Release it and nothing restarts by itself;
// press START to reset.
//
// Why the e-stop is wired "normally closed": while it is out, its contact
// holds A1 at ground. Pressing it opens the contact, and so does a cut wire
// or a loose plug. Either way the pin reads HIGH and the machine stops. A
// safety input should fail safe, so a broken wire means STOP, never GO.

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

const int RED_PIN = 13;
const int YELLOW_PIN = 12;
const int GREEN_PIN = 11;
const int PIEZO_PIN = A3;     // the analog pins work as digital pins too
const int WATER_PIN = 9;       // relay 1: the rinse water valve
const int SOAP_PIN = 8;        // relay 2: the soap pump
const int BRUSH_A = 7;         // MX1508 IN1
const int BRUSH_B = 6;         // MX1508 IN2
const int CONVEYOR_A = 5;      // MX1508 IN3, PWM sets the speed
const int CONVEYOR_B = 4;      // MX1508 IN4
const int DRYER_A = 3;         // fan module INA, PWM sets the speed
const int DRYER_B = 2;         // fan module INB
const int ENTRY_PIN = A0;
const int ESTOP_PIN = A1;
const int START_PIN = A2;

const int RELAY_ON = LOW;      // most relay boards are active LOW
const int RELAY_OFF = HIGH;

const int CONVEYOR_SPEED = 170;      // out of 255
const int DRYER_SPEED = 255;
const unsigned long BRUSH_FLIP_MS = 1500;   // the brush changes direction this often
const unsigned long CAR_SETTLE_MS = 500;    // the car must sit still this long

// The five stages, in order. Each one says what is switched on.
struct Stage {
  const char *name;
  unsigned long ms;
  bool water, soap, brush, dryer;
};
const Stage STAGES[] = {
  {"RINSE", 4000, true, false, false, false},
  {"SOAP", 4000, false, true, false, false},
  {"BRUSH", 6000, false, false, true, false},
  {"RINSE", 4000, true, false, false, false},
  {"DRY", 5000, false, false, false, true},
};
const int STAGE_COUNT = sizeof STAGES / sizeof STAGES[0];

enum Mode { IDLE, WAITING, WASHING, DONE, ESTOP };
Mode mode = IDLE;
int stage = 0;
unsigned long stageStart = 0;
unsigned long washStart = 0;
unsigned long carSince = 0;   // when the sensor first saw the car, 0 for no car
bool carHere = false;
bool estopReleased = true;    // while in ESTOP: has the button been pulled out yet?

LiquidCrystal_I2C lcd(0x27, 16, 2);

// A debounced input: a new reading has to hold for 30 ms.
struct Debounced {
  int pin;
  int stable;
  int last;
  unsigned long changedAt;
};
Debounced startIn = {START_PIN, HIGH, HIGH, 0};

bool settle(Debounced &in, unsigned long now) {
  int reading = digitalRead(in.pin);
  if (reading != in.last) {
    in.last = reading;
    in.changedAt = now;
  }
  if (reading != in.stable && now - in.changedAt >= 30) {
    in.stable = reading;
    return true;
  }
  return false;
}

void lights(bool red, bool yellow, bool green) {
  digitalWrite(RED_PIN, red);
  digitalWrite(YELLOW_PIN, yellow);
  digitalWrite(GREEN_PIN, green);
}

// Everything that moves or sprays, off. The conveyor and the brush brake
// (both inputs HIGH shorts the motor through the driver) rather than coast.
void allOff() {
  digitalWrite(WATER_PIN, RELAY_OFF);
  digitalWrite(SOAP_PIN, RELAY_OFF);
  digitalWrite(BRUSH_A, HIGH);
  digitalWrite(BRUSH_B, HIGH);
  digitalWrite(CONVEYOR_A, HIGH);
  digitalWrite(CONVEYOR_B, HIGH);
  analogWrite(DRYER_A, 0);
  digitalWrite(DRYER_B, LOW);
}

// Switch on what this stage needs, and nothing else.
void runStage(const Stage &s, unsigned long inStage) {
  digitalWrite(WATER_PIN, s.water ? RELAY_ON : RELAY_OFF);
  digitalWrite(SOAP_PIN, s.soap ? RELAY_ON : RELAY_OFF);
  if (s.brush) {
    // Scrub one way, then the other.
    bool forward = (inStage / BRUSH_FLIP_MS) % 2 == 0;
    digitalWrite(BRUSH_A, forward ? HIGH : LOW);
    digitalWrite(BRUSH_B, forward ? LOW : HIGH);
  } else {
    // Brake rather than coast, so the brush stops before the next stage.
    digitalWrite(BRUSH_A, HIGH);
    digitalWrite(BRUSH_B, HIGH);
  }
  analogWrite(DRYER_A, s.dryer ? DRYER_SPEED : 0);
  digitalWrite(DRYER_B, LOW);
  // The conveyor creeps the car through every stage.
  digitalWrite(CONVEYOR_B, LOW);
  analogWrite(CONVEYOR_A, CONVEYOR_SPEED);
}

unsigned long totalMs() {
  unsigned long t = 0;
  for (int i = 0; i < STAGE_COUNT; i++) t += STAGES[i].ms;
  return t;
}

void startStage(int n, unsigned long now) {
  stage = n;
  stageStart = now;
  Serial.print("Stage ");
  Serial.print(n + 1);
  Serial.print("/");
  Serial.print(STAGE_COUNT);
  Serial.print(": ");
  Serial.println(STAGES[n].name);
  tone(PIEZO_PIN, 1200, 60);
}

void emergencyStop() {
  allOff();
  mode = ESTOP;
  estopReleased = false;
  lights(true, false, false);
  Serial.println("EMERGENCY STOP: everything off");
}

// What the LCD shows right now, so we only send the characters that change.
// Talking to the LCD over I2C is slow (a few milliseconds a character), and a
// loop() stuck redrawing all 32 could miss a quick button press. So each pass
// sends at most LCD_BUDGET characters, and the rest follow on the next passes.
const int LCD_BUDGET = 3;
char shown[2][17] = {"", ""};
int lcdBudget = 0;

void drawLine(int row, const char *text) {
  for (int col = 0; col < 16 && lcdBudget > 0; col++) {
    char c = text[col] ? text[col] : ' ';
    if (shown[row][col] != c) {
      lcd.setCursor(col, row);
      lcd.print(c);
      shown[row][col] = c;
      lcdBudget--;
    }
  }
}

void showScreen(unsigned long now) {
  char top[17];
  char bottom[17];
  if (mode == WASHING) {
    unsigned long done = now - washStart;
    unsigned long total = totalMs();
    unsigned long left = done < total ? (total - done + 999) / 1000 : 0;
    snprintf(top, sizeof top, "%d/%d %-6s %3lus", stage + 1, STAGE_COUNT, STAGES[stage].name, left);
    // The timeline: 16 cells for the whole wash, filled as it goes, with
    // the first letter of each stage where that stage starts.
    unsigned long at = 0;
    for (int col = 0; col < 16; col++) bottom[col] = '.';
    for (int i = 0; i < STAGE_COUNT; i++) {
      bottom[at * 16 / total] = STAGES[i].name[0];
      at += STAGES[i].ms;
    }
    int filled = done * 16 / total;
    for (int col = 0; col < filled && col < 16; col++) bottom[col] = '#';
    bottom[16] = 0;
  } else if (mode == ESTOP) {
    snprintf(top, sizeof top, "EMERGENCY STOP");
    snprintf(bottom, sizeof bottom, "%s", estopReleased ? "START to reset" : "Release E-STOP");
  } else if (mode == WAITING) {
    snprintf(top, sizeof top, "Car in position");
    snprintf(bottom, sizeof bottom, "Press START");
  } else if (mode == DONE) {
    snprintf(top, sizeof top, "All clean!");
    snprintf(bottom, sizeof bottom, "Drive out");
  } else {
    snprintf(top, sizeof top, "Car wash ready");
    snprintf(bottom, sizeof bottom, "Drive in slowly");
  }
  lcdBudget = LCD_BUDGET;
  drawLine(0, top);
  drawLine(1, bottom);
}

void setup() {
  Serial.begin(9600);
  // Relays off before the pins become outputs, so nothing clicks at power-up.
  digitalWrite(WATER_PIN, RELAY_OFF);
  digitalWrite(SOAP_PIN, RELAY_OFF);
  const int outputs[] = {RED_PIN, YELLOW_PIN, GREEN_PIN, WATER_PIN, SOAP_PIN, BRUSH_A,
                         BRUSH_B, CONVEYOR_A, CONVEYOR_B, DRYER_A, DRYER_B};
  for (int pin : outputs) pinMode(pin, OUTPUT);
  pinMode(ENTRY_PIN, INPUT);
  pinMode(START_PIN, INPUT_PULLUP);
  pinMode(ESTOP_PIN, INPUT_PULLUP);
  allOff();
  lcd.init();
  lcd.backlight();
  lights(false, false, true);
  Serial.println("Car wash ready: drive in");
}

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

  // 1. The emergency stop comes first, before anything else gets a say.
  //    No debounce on the way in: the first HIGH reading stops the machine.
  bool estopPressed = digitalRead(ESTOP_PIN) == HIGH;
  if (estopPressed && mode != ESTOP) emergencyStop();
  if (mode == ESTOP) {
    allOff();   // and keep it off, every pass
    if (!estopPressed && !estopReleased) {
      estopReleased = true;
      Serial.println("E-stop released: press START to reset");
    }
  }

  // 2. The entry sensor: a car counts once it has been there half a second.
  bool seen = digitalRead(ENTRY_PIN) == LOW;
  if (!seen) {
    carSince = 0;
    carHere = false;
  } else if (carSince == 0) {
    carSince = now;
  } else if (now - carSince >= CAR_SETTLE_MS) {
    carHere = true;
  }

  bool pressed = settle(startIn, now) && startIn.stable == LOW;

  // 3. What each mode does.
  switch (mode) {
    case IDLE:
      if (carHere) {
        mode = WAITING;
        lights(false, true, false);
        Serial.println("Car in position: press START");
      } else if (pressed) {
        Serial.println("No car: drive in first");
      }
      break;

    case WAITING:
      // Blink the yellow light while it waits for the driver.
      digitalWrite(YELLOW_PIN, (now / 400) % 2);
      if (!carHere) {
        mode = IDLE;
        lights(false, false, true);
        Serial.println("Car gone: ready");
      } else if (pressed) {
        mode = WASHING;
        washStart = now;
        lights(false, true, false);
        startStage(0, now);
      }
      break;

    case WASHING: {
      unsigned long inStage = now - stageStart;
      if (inStage >= STAGES[stage].ms) {
        if (stage + 1 < STAGE_COUNT) {
          startStage(stage + 1, now);
          inStage = 0;
        } else {
          allOff();
          mode = DONE;
          lights(false, false, true);
          Serial.println("Wash done: drive out");
          tone(PIEZO_PIN, 1568, 300);
          break;
        }
      }
      runStage(STAGES[stage], inStage);
      break;
    }

    case DONE:
      // Wait for the car to leave before the next one may come in.
      digitalWrite(GREEN_PIN, (now / 300) % 2);
      if (!seen) {
        mode = IDLE;
        lights(false, false, true);
        Serial.println("Car gone: ready");
      }
      break;

    case ESTOP:
      // Releasing the e-stop must never restart the machine by itself. It
      // takes a deliberate press of START, and then we start from the top.
      if (estopReleased && pressed) {
        mode = IDLE;
        lights(false, false, true);
        Serial.println("Reset: ready");
      } else if (!estopReleased) {
        // A slow beep while the button is still in.
        if ((now / 500) % 2 == 0) tone(PIEZO_PIN, 880);
        else noTone(PIEZO_PIN);
      } else {
        noTone(PIEZO_PIN);
      }
      break;
  }

  // 4. The screen, a few characters every 40 ms.
  static unsigned long lastLcd = 0;
  if (now - lastLcd >= 40) {
    lastLcd = now;
    showScreen(now);
  }
}

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);
  // Relays off before the pins become outputs, so nothing clicks at power-up.
  digitalWrite(WATER_PIN, RELAY_OFF);
  digitalWrite(SOAP_PIN, RELAY_OFF);
  const int outputs[] = {RED_PIN, YELLOW_PIN, GREEN_PIN, WATER_PIN, SOAP_PIN, BRUSH_A,
                         BRUSH_B, CONVEYOR_A, CONVEYOR_B, DRYER_A, DRYER_B};
  for (int pin : outputs) pinMode(pin, OUTPUT);
  pinMode(ENTRY_PIN, INPUT);
  pinMode(START_PIN, INPUT_PULLUP);
  pinMode(ESTOP_PIN, INPUT_PULLUP);
  allOff();
  lcd.init();
  lcd.backlight();
  lights(false, false, true);
  Serial.println("Car wash ready: drive in");
}

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();

  // 1. The emergency stop comes first, before anything else gets a say.
  //    No debounce on the way in: the first HIGH reading stops the machine.
  bool estopPressed = digitalRead(ESTOP_PIN) == HIGH;
  if (estopPressed && mode != ESTOP) emergencyStop();
  if (mode == ESTOP) {
    allOff();   // and keep it off, every pass
    if (!estopPressed && !estopReleased) {
      estopReleased = true;
      Serial.println("E-stop released: press START to reset");
    }
  }

  // 2. The entry sensor: a car counts once it has been there half a second.
  bool seen = digitalRead(ENTRY_PIN) == LOW;
  if (!seen) {
    carSince = 0;
    carHere = false;
  } else if (carSince == 0) {
    carSince = now;
  } else if (now - carSince >= CAR_SETTLE_MS) {
    carHere = true;
  }

  bool pressed = settle(startIn, now) && startIn.stable == LOW;

  // ... 80 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.
  • A relay is on at boot or the logic is inverted. Relay board
    These boards are active low, so LOW on IN turns the relay on. Set the pin HIGH before setting it as an output so it does not click at start.
  • The relay clicks but the load does nothing, or the board resets. Relay board
    Use the correct contact: COM with NO for normally off. Power the relay coil with enough current, ideally from a separate 5 V supply, with grounds joined. Mains wiring is dangerous, so be careful.
  • The board resets, or the pin burns out, when the solenoid is wired straight to a pin. Solenoid
    A solenoid draws far too much for a pin. Switch it with a MOSFET or transistor from its own supply (such as 12 V), with the grounds joined.
  • The transistor or the controller fails after some switching. Solenoid
    A coil produces a voltage spike when current stops. Put a diode across the coil, cathode on the supply side, such as a 1N4007 or 1N5819, to absorb the kick.
  • A pump wired straight to a microcontroller pin does not run or resets the board. It draws far more than a pin can give. Mini water pump
    Switch it with a logic level MOSFET or a transistor with a base resistor, from its own supply of 3 to 6 V. Join the grounds.
  • The motor spikes damage the driver, or the pump runs dry and burns out. A coil load makes voltage kicks when it stops. Mini water pump
    Add a flyback diode across the pump, stripe to the positive side. Keep the pump under water, since a dry run overheats it and wears the seal.

Change it and keep it

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