Built-in project · Arduino Uno R3

Batch mixer on an Arduino Uno

Pick a recipe on a keypad and a relay board runs it: fill A to a level, fill B for a time, mix, then drain. The recipes are a table of numbers, a fill that stops rising is a fault, and * aborts safely by draining the tank. 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 35 minutes to build and run. Runs in your browser. Free, and no account needed.

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

The circuit runs here once the page has loaded.

What you'll learn

  • Controllers
  • Motors
  • Displays
  • Making sound with a buzzer
  • Reading a knob with an analog pin
  • Difficulty: Intermediate
  • Time: About 35 minutes
  • Board: Arduino Uno R3
  • Parts: 18

How it works

Pick a recipe on a keypad and a relay board runs it: fill A to a level, fill B for a time, mix, then drain. The recipes are a table of numbers, a fill that stops rising is a fault, and * aborts safely by draining the tank.

Arduino Uno R3 runs the sketch below. The circuit around it: Keypad, 4x3 telephone, Relay board, four channels, Solenoid, DC motor, Buzzer, Potentiometer. pinMode sets VALVE_A_PIN as an output. digitalWrite drives BUZZER_PIN HIGH. analogRead measures the voltage on LEVEL_PIN as a number. delay waits 5 milliseconds. Serial.begin opens the serial port at 9600 baud.

Parts list

18 parts, plus the jumper wires. They retail for about $51; 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
Keypad, 4x3 telephone pin R1Arduino Uno R3 pin 13
Keypad, 4x3 telephone pin R2Arduino Uno R3 pin 12
Keypad, 4x3 telephone pin R3Arduino Uno R3 pin 11
Keypad, 4x3 telephone pin R4Arduino Uno R3 pin 10
Keypad, 4x3 telephone pin C1Arduino Uno R3 pin 9
Keypad, 4x3 telephone pin C2Arduino Uno R3 pin 8
Keypad, 4x3 telephone pin C3Arduino Uno R3 pin 7
Relay board, four channels pin IN1Arduino Uno R3 pin 6
Relay board, four channels pin IN2Arduino Uno R3 pin 5
Relay board, four channels pin IN3Arduino Uno R3 pin 4
Relay board, four channels pin IN4Arduino Uno R3 pin 3
Buzzer pin 1Arduino Uno R3 pin 2
Potentiometer pin 2Arduino Uno R3 pin A0
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Relay board, four channels pin GNDGround
Relay board, four channels pin VCC5 V
Solenoid (1) pin 1Relay board, four channels pin NO1
Relay board, four channels pin COM15 V
Solenoid (2) pin 1Relay board, four channels pin NO2
Relay board, four channels pin COM25 V
DC motor pin 1Relay board, four channels pin NO3
Relay board, four channels pin COM35 V
Solenoid (3) pin 1Relay board, four channels pin NO4
Relay board, four channels pin COM45 V
Solenoid (1) pin 2Ground
Solenoid (2) pin 2Ground
DC motor pin 2Ground
Solenoid (3) pin 2Ground
Potentiometer pin 35 V
Buzzer pin 2Ground
Potentiometer pin 1Ground
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
// Batch mixer: a tank that follows a recipe, picked on a keypad, one step at a
// time.
//
//   13 12 11 10  keypad rows R1 to R4
//   9 8 7        keypad columns C1 to C3
//   6            relay 1, valve A (ingredient A in)
//   5            relay 2, valve B (ingredient B in)
//   4            relay 3, the mixer motor
//   3            relay 4, the drain valve
//   2            buzzer, + to the pin and - to GND
//   A0           the level sensor (a knob stands in for it: ends on 5 V and
//                GND, the middle to A0)
//   LCD          SDA -> A4    SCL -> A5    VCC -> 5 V    GND -> GND
//
// A batch plant does not have one program per product. It has one sequence
// and a table of recipes, and the recipe is just numbers the sequence reads:
//
//   READY    press 1, 2 or 3 to pick a recipe, then # to start.
//   FILL A  valve A open until the level reaches the recipe's target. This
//           step watches a sensor. If the level stops rising for
//           STALL_MS, something is wrong (a leak, a stuck valve), so it faults.
//   FILL B  valve B open for the recipe's time. This step only watches the
//           clock: the flow is known, so time measures the amount.
//   MIX     the mixer runs for the recipe's time.
//   DRAIN   the drain opens until the tank is below EMPTY_PCT.
//   DONE    a beep. Pick the next recipe.
//   FAULT   everything closed and an alarm. * clears it.
//
// * aborts a running batch at any step: valves shut, the mixer stops and the
// tank drains, because an empty tank is the safe one. Every output is set in
// one place, enter(), so no step can forget to close a valve.
//
// Press Run, press 1 then #, and turn the knob up slowly to fill the tank.

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

const int VALVE_A_PIN = 6;
const int VALVE_B_PIN = 5;
const int MIXER_PIN = 4;
const int DRAIN_PIN = 3;
const int BUZZER_PIN = 2;
const int LEVEL_PIN = A0;

// The relay board switches on a low input.
const int ON = LOW;
const int OFF = HIGH;

// The recipes are data, not code. Times are scaled down to seconds so a
// batch fits in a minute on the bench.
struct Recipe {
  int levelA;                // percent of the tank to fill with A
  unsigned long fillBMs;     // how long valve B stays open
  unsigned long mixMs;       // how long the mixer runs
};
const Recipe RECIPES[] = {
  {40, 3000, 5000},
  {60, 2000, 8000},
  {25, 5000, 4000},
};
const int RECIPE_COUNT = 3;

const unsigned long STALL_MS = 5000;  // fill A must keep rising inside this
const int RISE_PCT = 2;               // a rise this big counts as progress
const int EMPTY_PCT = 5;              // below this the tank is empty
const unsigned long DONE_BEEP_MS = 1000;

const byte ROWS = 4;
const byte COLS = 3;
char keys[ROWS][COLS] = {
  {'1', '2', '3'},
  {'4', '5', '6'},
  {'7', '8', '9'},
  {'*', '0', '#'},
};
byte rowPins[ROWS] = {13, 12, 11, 10};
byte colPins[COLS] = {9, 8, 7};
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);

enum State { READY, FILL_A, FILL_B, MIX, DRAIN, DONE, FAULT };
const char* const NAMES[] = {"READY", "FILL A", "FILL B", "MIX", "DRAIN", "DONE", "FAULT"};

LiquidCrystal_I2C lcd(0x27, 16, 2);

State state = READY;
unsigned long enteredAt = 0;
int recipe = 0;            // 1..3, 0 for none picked
bool aborted = false;
int bestLevel = 0;         // the highest level seen in FILL A
unsigned long risingAt = 0;
int shownLevel = -1;       // the level on the LCD, to redraw only on a change
unsigned long beepUntil = 0;

int levelPct() {
  return (long)analogRead(LEVEL_PIN) * 100 / 1023;
}

// An LCD over I2C is slow, so only the number is redrawn, and only when it
// changes: the loop has to keep reading the keypad.
void showLevel(int level) {
  if (level == shownLevel) return;
  shownLevel = level;
  lcd.setCursor(6, 1);
  lcd.print(level);
  lcd.print("%  ");
}

void show() {
  lcd.setCursor(0, 0);
  lcd.print("R");
  if (recipe) lcd.print(recipe);
  else lcd.print("-");
  lcd.print(" ");
  lcd.print(NAMES[state]);
  lcd.print("          ");
  lcd.setCursor(0, 1);
  lcd.print("Level ");
  shownLevel = -1;
  showLevel(levelPct());
}

void beep(unsigned long ms) {
  beepUntil = millis() + ms;
  digitalWrite(BUZZER_PIN, HIGH);
}

void enter(State next) {
  state = next;
  enteredAt = millis();
  digitalWrite(VALVE_A_PIN, next == FILL_A ? ON : OFF);
  digitalWrite(VALVE_B_PIN, next == FILL_B ? ON : OFF);
  digitalWrite(MIXER_PIN, next == MIX ? ON : OFF);
  digitalWrite(DRAIN_PIN, next == DRAIN ? ON : OFF);
  Serial.print("state ");
  Serial.println(NAMES[next]);
  show();
}

void setup() {
  pinMode(VALVE_A_PIN, OUTPUT);
  pinMode(VALVE_B_PIN, OUTPUT);
  pinMode(MIXER_PIN, OUTPUT);
  pinMode(DRAIN_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(VALVE_A_PIN, OFF);
  digitalWrite(VALVE_B_PIN, OFF);
  digitalWrite(MIXER_PIN, OFF);
  digitalWrite(DRAIN_PIN, OFF);
  Serial.begin(9600);
  Serial.println("Batch mixer");
  lcd.init();
  lcd.backlight();
  enter(READY);
}

void handleKey(char key) {
  bool running = state == FILL_A || state == FILL_B || state == MIX;
  if (key == '*') {
    if (running) {
      Serial.println("abort");
      aborted = true;
      enter(DRAIN);
    } else if (state == FAULT) {
      enter(READY);
    }
    return;
  }
  if (state != READY && state != DONE) return;
  if (key >= '1' && key <= '0' + RECIPE_COUNT) {
    recipe = key - '0';
    Serial.print("recipe ");
    Serial.println(recipe);
    enter(READY);
  } else if (key == '#' && recipe) {
    Serial.print("start recipe ");
    Serial.println(recipe);
    aborted = false;
    bestLevel = levelPct();
    risingAt = millis();
    enter(FILL_A);
  }
}

void loop() {
  char key = keypad.getKey();
  if (key) handleKey(key);
  unsigned long now = millis();

  int level = levelPct();
  const Recipe& r = RECIPES[recipe > 0 ? recipe - 1 : 0];

  switch (state) {
    case FILL_A:
      // Level control: the sensor says when to stop.
      if (level >= r.levelA) {
        enter(FILL_B);
      } else {
        // The watchdog: a fill that stops rising is a fault, not a wait.
        if (level >= bestLevel + RISE_PCT) {
          bestLevel = level;
          risingAt = now;
        }
        if (now - risingAt >= STALL_MS) {
          Serial.println("fault: level not rising");
          beep(DONE_BEEP_MS);
          enter(FAULT);
        }
      }
      break;
    case FILL_B:
      // Time control: the clock says when to stop.
      if (now - enteredAt >= r.fillBMs) enter(MIX);
      break;
    case MIX:
      if (now - enteredAt >= r.mixMs) enter(DRAIN);
      break;
    case DRAIN:
      if (level < EMPTY_PCT) {
        if (aborted) {
          Serial.println("aborted, tank empty");
          enter(READY);
        } else {
          Serial.print("batch done, recipe ");
          Serial.println(recipe);
          beep(DONE_BEEP_MS);
          enter(DONE);
        }
      }
      break;
    default:
      break;
  }

  if (beepUntil && now >= beepUntil) {
    beepUntil = 0;
    digitalWrite(BUZZER_PIN, LOW);
  }
  showLevel(level);
  delay(5);
}

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() {
  pinMode(VALVE_A_PIN, OUTPUT);
  pinMode(VALVE_B_PIN, OUTPUT);
  pinMode(MIXER_PIN, OUTPUT);
  pinMode(DRAIN_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(VALVE_A_PIN, OFF);
  digitalWrite(VALVE_B_PIN, OFF);
  digitalWrite(MIXER_PIN, OFF);
  digitalWrite(DRAIN_PIN, OFF);
  Serial.begin(9600);
  Serial.println("Batch mixer");
  lcd.init();
  lcd.backlight();
  enter(READY);
}

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() {
  char key = keypad.getKey();
  if (key) handleKey(key);
  unsigned long now = millis();

  int level = levelPct();
  const Recipe& r = RECIPES[recipe > 0 ? recipe - 1 : 0];

  switch (state) {
    case FILL_A:
      // Level control: the sensor says when to stop.
      if (level >= r.levelA) {
        enter(FILL_B);
      } else {
        // The watchdog: a fill that stops rising is a fault, not a wait.
        if (level >= bestLevel + RISE_PCT) {
          bestLevel = level;
          risingAt = now;
        }
        if (now - risingAt >= STALL_MS) {
          Serial.println("fault: level not rising");
          beep(DONE_BEEP_MS);
          enter(FAULT);
        }
      }
      break;
    case FILL_B:
      // Time control: the clock says when to stop.
  // ... 29 more lines in the editor

Troubleshooting

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

  • Pressing one key reports several keys or the wrong key. Keypad
    Keep the row and column pin order right. Scan by driving one column low and reading the rows with pull-ups. Use the Keypad library with the correct pin lists, and match the pin map to your wiring.
  • Keys do not register or float. Keypad
    Rows need pull-ups, either INPUT_PULLUP or external resistors. Give each key press a few milliseconds of debounce and check for loose wires on all seven pins.
  • The relay clicks on at startup, or does the opposite of what the code says. Relay board
    These boards are active low: LOW turns the relay on and HIGH turns it off. Set the pin HIGH before pinMode OUTPUT so the relay does not pulse on at boot.
  • The board resets or relays chatter when they switch. Relay board
    Power the relay coils from a separate 5 V supply that can handle all four (about 70 mA each), and join the grounds. Mains loads are dangerous, so use low voltage for learning.
  • 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.
  • 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.

Change it and keep it

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