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.
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.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Traffic light module
- 1 × Relay board, two channels
- 4 × Power, 5 V
- 1 × Solenoid
- 3 × Power, 6 V
- 1 × Mini water pump
- 1 × MX1508 dual motor driver
- 2 × DC motor
- 1 × Fan module, L9110
- 1 × Obstacle sensor, FC-51
- 1 × Emergency stop button
- 1 × Half breadboard
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Piezo speakerIn Starter Kit, 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 |
|---|---|
| Traffic light module pin R | Arduino Uno R3 pin 13 |
| Traffic light module pin Y | Arduino Uno R3 pin 12 |
| Traffic light module pin G | Arduino Uno R3 pin 11 |
| Relay board, two channels pin IN1 | Arduino Uno R3 pin 9 |
| Relay board, two channels pin IN2 | Arduino Uno R3 pin 8 |
| MX1508 dual motor driver pin IN1 | Arduino Uno R3 pin 7 |
| MX1508 dual motor driver pin IN2 | Arduino Uno R3 pin 6 |
| MX1508 dual motor driver pin IN3 | Arduino Uno R3 pin 5 |
| MX1508 dual motor driver pin IN4 | Arduino Uno R3 pin 4 |
| Fan module, L9110 pin INA | Arduino Uno R3 pin 3 |
| Fan module, L9110 pin INB | Arduino Uno R3 pin 2 |
| Arduino Uno R3 pin GND | Ground |
| Obstacle sensor, FC-51 pin OUT | Arduino Uno R3 pin A0 |
| Emergency stop button pin 1 | Arduino Uno R3 pin A1 |
| Pushbutton pin 1a | Arduino Uno R3 pin A2 |
| Piezo speaker pin 1 | Arduino Uno R3 pin A3 |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Traffic light module pin GND | Ground |
| Relay board, two channels pin GND | Ground |
| Relay board, two channels pin VCC | 5 V |
| Solenoid pin 1 | Relay board, two channels pin NO1 |
| Relay board, two channels pin COM1 | 6 V |
| Mini water pump pin V+ | Relay board, two channels pin NO2 |
| Relay board, two channels pin COM2 | 6 V |
| Solenoid pin 2 | Ground |
| Mini water pump pin V- | Ground |
| MX1508 dual motor driver pin + | 6 V |
| MX1508 dual motor driver pin - | Ground |
| DC motor (1) pin 1 | MX1508 dual motor driver pin MA1 |
| DC motor (1) pin 2 | MX1508 dual motor driver pin MA2 |
| DC motor (2) pin 1 | MX1508 dual motor driver pin MB1 |
| DC motor (2) pin 2 | MX1508 dual motor driver pin MB2 |
| Fan module, L9110 pin VCC | 5 V |
| Fan module, L9110 pin GND | Ground |
| Obstacle sensor, FC-51 pin GND | Ground |
| Obstacle sensor, FC-51 pin VCC | 5 V |
| Emergency stop button pin 2 | Ground |
| Pushbutton pin 2a | Ground |
| Piezo speaker pin 2 | Ground |
| Character LCD with I2C backpack pin GND | Ground |
| Character LCD with I2C backpack pin VCC | 5 V |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// 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 editorTroubleshooting
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.