Washing machine on an Arduino Uno
Pick a cycle on the knob and press START: it fills, washes with a reversing drum, drains and spins, and it only spins with the lid shut and locked. The whole build, an Arduino Uno and 11 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 $48. On Mokxi they're free.
What you'll learn
- Motors
- Sensors
- Displays
- Controllers
- Moving a servo to an angle
- Difficulty: Intermediate
- Time: About 45 minutes
- Board: Arduino Uno R3
- Parts: 20
How it works
Pick a cycle on the knob and press START: it fills, washes with a reversing drum, drains and spins, and it only spins with the lid shut and locked.
Arduino Uno R3 runs the sketch below. The circuit around it: Hobby servo, Relay board, two channels, Solenoid, Mini water pump, Motor driver, L9110S, DC motor. pinMode sets FILL_PIN as an output. digitalRead reads whether in.pin is HIGH or LOW. analogRead measures the voltage on KNOB_PIN as a number. analogWrite sends a PWM signal out on MOTOR_B. delay waits 200 milliseconds.
Parts list
20 parts, plus the jumper wires. They retail for about $48; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Hobby servoIn Halloween Prop Kit, Christmas Lights Kit, Starter Projects Kit.
- 4 × Power, 5 V
- 1 × Relay board, two channels
- 1 × Solenoid
- 3 × Power, 6 V
- 1 × Mini water pump
- 1 × Motor driver, L9110S
- 1 × DC motor
- 1 × Half breadboard
- 1 × PotentiometerIn Halloween Prop Kit, Starter Projects Kit.
- 1 × Limit switch, lever microswitchIn Claw Machine Kit, Vending Machine Kit.
- 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 |
|---|---|
| Hobby servo pin PWM | Arduino Uno R3 pin 9 |
| Relay board, two channels pin IN1 | Arduino Uno R3 pin 8 |
| Relay board, two channels pin IN2 | Arduino Uno R3 pin 7 |
| Motor driver, L9110S pin AIA | Arduino Uno R3 pin 6 |
| Motor driver, L9110S pin AIB | Arduino Uno R3 pin 5 |
| Arduino Uno R3 pin GND | Ground |
| Potentiometer pin 2 | Arduino Uno R3 pin A0 |
| Limit switch, lever microswitch pin NO | 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 |
| Hobby servo pin GND | Ground |
| Hobby servo pin VCC | 5 V |
| 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 |
| Motor driver, L9110S pin GND | Ground |
| Motor driver, L9110S pin VCC | 6 V |
| DC motor pin 1 | Motor driver, L9110S pin OA1 |
| DC motor pin 2 | Motor driver, L9110S pin OA2 |
| Potentiometer pin 1 | Ground |
| Limit switch, lever microswitch pin COM | Ground |
| Pushbutton pin 2a | Ground |
| Piezo speaker pin 2 | Ground |
| Potentiometer pin 3 | 5 V |
| 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.
// Washing machine: a cycle controller for a little top-loader. It fills,
// washes with the drum turning one way and then the other, drains, and spins,
// and it will only spin with the lid shut and locked.
//
// cycle knob A0 <- potentiometer wiper, ends on 5 V and GND
// lid switch A1 -> microswitch NO, COM -> GND (INPUT_PULLUP, shut reads LOW)
// START/PAUSE A2 -> button -> GND (INPUT_PULLUP, pressed reads LOW)
// drum motor pin 6 -> L9110S AIA, pin 5 -> AIB, OA1/OA2 -> DC motor
// relays pin 8 -> IN1 (fill valve), pin 7 -> IN2 (drain pump), active LOW
// lid lock pin 9 -> servo PWM (0 degrees open, 90 degrees locked)
// piezo A3 -> piezo -> GND
// LCD A4 SDA, A5 SCL (16x2 on an I2C backpack at 0x27)
//
// How to play: shut the lid (click the lid switch so it stays pressed), turn
// the knob to pick Quick, Normal or Heavy, and press START. Open the lid in the
// middle of the wash and the drum stops dead until you shut it again. Press
// START again to pause and resume.
//
// The whole program is one state machine. loop() never waits: it reads the
// inputs, asks "which phase am I in, and is its time up?", and sets the
// outputs to match. That is why the lid and the button always answer at once,
// even halfway through a spin.
//
// Times are demo seconds, so a whole Quick wash takes about twenty seconds.
// Change the numbers in CYCLES to make a real one (multiply by 60!).
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Servo.h>
// The analog pins A1, A2 and A3 work as ordinary digital pins too.
const int KNOB_PIN = A0;
const int LID_PIN = A1;
const int BUTTON_PIN = A2;
const int MOTOR_A = 6; // L9110S AIA: PWM here turns the drum forward
const int MOTOR_B = 5; // L9110S AIB: PWM here turns it the other way
const int FILL_PIN = 8; // relay 1: the water inlet valve
const int DRAIN_PIN = 7; // relay 2: the drain pump
const int LOCK_PIN = 9;
const int PIEZO_PIN = A3;
// Most relay boards are active LOW: the relay pulls in when its pin is LOW.
const int RELAY_ON = LOW;
const int RELAY_OFF = HIGH;
const int LOCK_OPEN = 0;
const int LOCK_SHUT = 90;
const unsigned long LOCK_TRAVEL_MS = 600; // how long the latch takes to move
const unsigned long COAST_MS = 2500; // let the drum stop before unlocking
const int WASH_SPEED = 150; // gentle tumbling, out of 255
const int SPIN_SPEED = 255; // flat out
const unsigned long AGITATE_MS = 1500; // turn one way for this long...
const unsigned long REST_MS = 500; // ...rest, then turn the other way
// The three cycles: fill, wash, drain and spin times, in milliseconds.
struct Cycle {
const char *name;
unsigned long fill, wash, drain, spin;
};
const Cycle CYCLES[] = {
{"Quick", 3000, 8000, 3000, 6000},
{"Normal", 4000, 15000, 4000, 10000},
{"Heavy", 5000, 24000, 5000, 14000},
};
// Every phase the machine can be in.
enum Phase { IDLE, FILL, WASH, DRAIN, LOCKING, SPIN, UNLOCKING, DONE };
const char *const PHASE_NAMES[] = {"READY", "FILL", "WASH", "DRAIN", "LOCKING", "SPIN", "STOPPING", "DONE"};
LiquidCrystal_I2C lcd(0x27, 16, 2);
Servo lock;
Phase phase = IDLE;
int cycle = 0; // which of CYCLES is picked
unsigned long phaseLeft = 0; // ms left in this phase (stops counting while paused)
unsigned long lastTick = 0; // when loop() last took time off phaseLeft
bool paused = false; // paused by the button
bool lidShut = false; // the debounced lid switch
unsigned long lastLcd = 0;
// Debouncing: a reading has to stay the same for 30 ms before we believe it.
struct Debounced {
int pin;
int stable;
int last;
unsigned long changedAt;
};
Debounced lidIn = {LID_PIN, HIGH, HIGH, 0};
Debounced buttonIn = {BUTTON_PIN, HIGH, HIGH, 0};
// Returns true once each time the input settles into a new state.
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;
}
// Drive the drum: positive speeds one way, negative the other, 0 to stop.
void drum(int speed) {
if (speed > 0) {
analogWrite(MOTOR_B, 0);
analogWrite(MOTOR_A, speed);
} else if (speed < 0) {
analogWrite(MOTOR_A, 0);
analogWrite(MOTOR_B, -speed);
} else {
analogWrite(MOTOR_A, 0);
analogWrite(MOTOR_B, 0);
}
}
// Turn everything that moves water or the drum off: the safe state.
void allStop() {
drum(0);
digitalWrite(FILL_PIN, RELAY_OFF);
digitalWrite(DRAIN_PIN, RELAY_OFF);
}
int pickCycle() {
int knob = analogRead(KNOB_PIN); // 0..1023, cut into three slices
if (knob < 341) return 0;
if (knob < 682) return 1;
return 2;
}
void enter(Phase next, unsigned long ms) {
phase = next;
phaseLeft = ms;
Serial.print("Phase: ");
Serial.println(PHASE_NAMES[phase]);
}
// Seconds left in the whole cycle, for the screen.
unsigned long secondsLeft() {
const Cycle &c = CYCLES[cycle];
unsigned long ms = phaseLeft;
if (phase == FILL) ms += c.wash + c.drain + c.spin;
if (phase == WASH) ms += c.drain + c.spin;
if (phase == DRAIN) ms += c.spin;
if (phase == LOCKING) ms += c.spin;
return (ms + 999) / 1000;
}
// 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() {
char top[17];
char bottom[17];
if (phase == IDLE) {
snprintf(top, sizeof top, "Cycle: %-9s", CYCLES[cycle].name);
snprintf(bottom, sizeof bottom, "%-16s", lidShut ? "Press START" : "Shut the lid");
} else {
snprintf(top, sizeof top, "%-6s %-9s", CYCLES[cycle].name, PHASE_NAMES[phase]);
const char *note = "";
if (!lidShut) note = "LID OPEN";
else if (paused) note = "PAUSED";
else if (lock.read() >= LOCK_SHUT - 5) note = "LOCKED";
unsigned long s = secondsLeft();
snprintf(bottom, sizeof bottom, "%2lu:%02lu %-10s", s / 60, s % 60, note);
}
lcdBudget = LCD_BUDGET;
drawLine(0, top);
drawLine(1, bottom);
}
void beep(int times) {
for (int i = 0; i < times; i++) {
tone(PIEZO_PIN, 1568, 120);
delay(200); // a short tune at the very end is the one place we wait
}
}
void setup() {
Serial.begin(9600);
// Write the relay pins HIGH (off) before they become outputs, or both
// relays would click on for a moment at power-up.
digitalWrite(FILL_PIN, RELAY_OFF);
digitalWrite(DRAIN_PIN, RELAY_OFF);
pinMode(FILL_PIN, OUTPUT);
pinMode(DRAIN_PIN, OUTPUT);
pinMode(MOTOR_A, OUTPUT);
pinMode(MOTOR_B, OUTPUT);
pinMode(LID_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT_PULLUP);
drum(0);
lock.attach(LOCK_PIN);
lock.write(LOCK_OPEN);
lcd.init();
lcd.backlight();
lidShut = digitalRead(LID_PIN) == LOW;
lidIn.stable = lidIn.last = digitalRead(LID_PIN);
cycle = pickCycle();
Serial.println("Washer ready. Shut the lid, pick a cycle, press START");
lastTick = millis();
}
void loop() {
unsigned long now = millis();
unsigned long dt = now - lastTick;
lastTick = now;
// 1. Read the inputs.
if (settle(lidIn, now)) {
lidShut = lidIn.stable == LOW;
if (phase != IDLE && phase != DONE) {
Serial.println(lidShut ? "Lid shut: carrying on" : "Lid open: drum stopped");
}
}
bool pressed = settle(buttonIn, now) && buttonIn.stable == LOW;
// 2. The button: start from READY or DONE, or pause and resume a wash.
if (pressed) {
if (phase == IDLE || phase == DONE) {
if (!lidShut) {
Serial.println("Shut the lid first");
} else {
paused = false;
Serial.print("Cycle: ");
Serial.println(CYCLES[cycle].name);
enter(FILL, CYCLES[cycle].fill);
}
} else if (phase != UNLOCKING) {
paused = !paused;
Serial.println(paused ? "Paused" : "Resumed");
}
}
if (phase == IDLE) {
int picked = pickCycle();
if (picked != cycle) {
cycle = picked;
Serial.print("Picked ");
Serial.println(CYCLES[cycle].name);
}
}
// 3. The interlock. Nothing runs with the lid open or while paused, and the
// clock stops too, so the wash picks up where it left off.
bool running = lidShut && !paused;
if (phase == SPIN && !lidShut) {
// The lid should never open while locked: if the switch says it has,
// something is broken, so the motor goes off right now.
drum(0);
}
// 4. The phases.
const Cycle &c = CYCLES[cycle];
switch (phase) {
case IDLE:
case DONE:
allStop();
break;
case FILL:
digitalWrite(FILL_PIN, running ? RELAY_ON : RELAY_OFF);
if (running) phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
if (phaseLeft == 0) {
digitalWrite(FILL_PIN, RELAY_OFF);
enter(WASH, c.wash);
}
break;
case WASH: {
if (running) {
phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
// Agitate: forward, rest, backward, rest, over and over. Where we are
// in that pattern comes from the time already washed.
unsigned long washed = c.wash - phaseLeft;
unsigned long step = washed % (2 * (AGITATE_MS + REST_MS));
if (step < AGITATE_MS) drum(WASH_SPEED);
else if (step < AGITATE_MS + REST_MS) drum(0);
else if (step < 2 * AGITATE_MS + REST_MS) drum(-WASH_SPEED);
else drum(0);
} else {
drum(0);
}
if (phaseLeft == 0) {
drum(0);
enter(DRAIN, c.drain);
}
break;
}
case DRAIN:
digitalWrite(DRAIN_PIN, running ? RELAY_ON : RELAY_OFF);
if (running) phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
if (phaseLeft == 0) {
// Leave the pump running through the spin: the water flung out of the
// clothes has to go somewhere. Lock the lid first.
enter(LOCKING, LOCK_TRAVEL_MS);
}
break;
case LOCKING:
// Only lock a shut lid; the latch would jam on an open one.
if (running) {
lock.write(LOCK_SHUT);
phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
}
if (phaseLeft == 0) {
Serial.println("Lid locked");
enter(SPIN, c.spin);
}
break;
case SPIN: {
// The interlock in one line: spin only with the lid shut AND locked.
bool locked = lock.read() >= LOCK_SHUT - 5;
if (running && locked) {
unsigned long spun = c.spin - phaseLeft;
// Ramp up over two seconds so the load settles before full speed.
int speed = spun >= 2000 ? SPIN_SPEED : (int)(80 + (SPIN_SPEED - 80) * spun / 2000);
drum(speed);
digitalWrite(DRAIN_PIN, RELAY_ON);
phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
} else {
drum(0);
}
if (phaseLeft == 0) {
allStop();
enter(UNLOCKING, COAST_MS);
}
break;
}
case UNLOCKING:
// The motor is off, but a drum that heavy takes a while to stop. The
// lid stays locked until it has.
allStop();
phaseLeft = phaseLeft > dt ? phaseLeft - dt : 0;
if (phaseLeft == 0) {
lock.write(LOCK_OPEN);
Serial.println("Lid unlocked");
enter(DONE, 0);
Serial.println("Done! Your washing is clean");
beep(3);
lastTick = millis();
}
break;
}
// 5. The screen, a few characters every 40 ms.
if (now - lastLcd >= 40) {
lastLcd = now;
showScreen();
}
}
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);
// Write the relay pins HIGH (off) before they become outputs, or both
// relays would click on for a moment at power-up.
digitalWrite(FILL_PIN, RELAY_OFF);
digitalWrite(DRAIN_PIN, RELAY_OFF);
pinMode(FILL_PIN, OUTPUT);
pinMode(DRAIN_PIN, OUTPUT);
pinMode(MOTOR_A, OUTPUT);
pinMode(MOTOR_B, OUTPUT);
pinMode(LID_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT_PULLUP);
drum(0);
lock.attach(LOCK_PIN);
lock.write(LOCK_OPEN);
lcd.init();
lcd.backlight();
lidShut = digitalRead(LID_PIN) == LOW;
lidIn.stable = lidIn.last = digitalRead(LID_PIN);
cycle = pickCycle();
Serial.println("Washer ready. Shut the lid, pick a cycle, press START");
lastTick = millis();
}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();
unsigned long dt = now - lastTick;
lastTick = now;
// 1. Read the inputs.
if (settle(lidIn, now)) {
lidShut = lidIn.stable == LOW;
if (phase != IDLE && phase != DONE) {
Serial.println(lidShut ? "Lid shut: carrying on" : "Lid open: drum stopped");
}
}
bool pressed = settle(buttonIn, now) && buttonIn.stable == LOW;
// 2. The button: start from READY or DONE, or pause and resume a wash.
if (pressed) {
if (phase == IDLE || phase == DONE) {
if (!lidShut) {
Serial.println("Shut the lid first");
} else {
paused = false;
Serial.print("Cycle: ");
Serial.println(CYCLES[cycle].name);
enter(FILL, CYCLES[cycle].fill);
}
} else if (phase != UNLOCKING) {
paused = !paused;
Serial.println(paused ? "Paused" : "Resumed");
// ... 122 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- 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. - 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.