Built-in project · Arduino Uno R3

Factory 8: Logistics robot, on an Arduino Uno

A delivery robot plans a route around the machines with A* search, fetches crates and brings them to the depot. Steer a cursor with the stick and wall off its way to watch it re-plan. The whole build, an Arduino Uno and 7 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 $41. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

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

How it works

A delivery robot plans a route around the machines with A* search, fetches crates and brings them to the depot. Steer a cursor with the stick and wall off its way to watch it re-plan.

Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Thumb stick, Pushbutton, Hobby servo, Resistor, 220 Ω, LED, green. pinMode sets WALL_PIN as an input with its pull-up on. digitalRead reads whether b.pin is HIGH or LOW. Serial.begin opens the serial port at 9600 baud. tone plays a note on PIEZO_PIN. millis reads the time since power-up, so the sketch can time things without stopping.

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
TFT display, 240x320 pin SCKArduino Uno R3 pin 13
TFT display, 240x320 pin MOSIArduino Uno R3 pin 11
TFT display, 240x320 pin CSArduino Uno R3 pin 10
TFT display, 240x320 pin DCArduino Uno R3 pin 9
Piezo speaker pin 1Arduino Uno R3 pin 6
Hobby servo pin PWMArduino Uno R3 pin 5
Pushbutton pin 1aArduino Uno R3 pin 4
Resistor, 220 Ω pin 1Arduino Uno R3 pin 3
Thumb stick pin SWArduino Uno R3 pin 2
Arduino Uno R3 pin GNDGround
Thumb stick pin VRXArduino Uno R3 pin A0
Thumb stick pin VRYArduino Uno R3 pin A1
TFT display, 240x320 pin VCC5 V
TFT display, 240x320 pin GNDGround
TFT display, 240x320 pin RST5 V
TFT display, 240x320 pin LED5 V
Thumb stick pin GNDGround
Thumb stick pin VCC5 V
Pushbutton pin 2aGround
Hobby servo pin GNDGround
Hobby servo pin VCC5 V
LED, green pin AResistor, 220 Ω pin 2
LED, green pin CGround
Piezo speaker 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
// Factory 8: Logistics robot. A* finds the way around the machines.
//
// A delivery robot lives at the depot on the left. Crates appear around the
// factory. Steer the yellow cursor with the stick and press the stick to send
// the robot to the crate under it: the robot works out the shortest route,
// walks it a tile at a time, picks the crate up with its gripper, plans a
// route back and delivers it. Press the WALL button to put a wall on the
// cursor's tile or take one away. If a wall lands on the robot's route, it
// plans a new one on the spot.
//
// Wiring, board to parts:
//   TFT   CS -> pin 10   DC -> pin 9   SCK -> pin 13   MOSI -> pin 11
//         RST and LED -> 5 V, VCC -> 5 V, GND -> GND
//   Thumb stick: VRX -> A0, VRY -> A1, SW -> pin 2, VCC -> 5 V, GND -> GND
//   WALL button: pin 4 to GND     Gripper servo: signal -> pin 5, 5 V, GND
//   Busy LED: pin 3 through 220 ohm to GND     Piezo: pin 6 to GND
//
// # What this teaches
//
// 1. A grid is a graph. Every floor tile is a node and every neighbor you can
//    step to is an edge. Walls are just missing edges. That makes "find a way
//    from here to there" a graph search.
// 2. A* search. It keeps a list of tiles still to try (the open set) and always
//    expands the one with the lowest guess of total trip cost f = g + h, where
//    g is the steps taken to reach it and h is the straight-line (Manhattan)
//    distance still to go. The guess never overestimates, so the first time it
//    reaches the goal the route is the shortest. NODES on the screen is how
//    many tiles it had to expand: a good guess searches far less than the
//    whole floor.
// 3. Re-planning. The world can change under a plan. When a wall appears on the
//    remaining route the robot throws the plan away and searches again from
//    where it stands, instead of walking into the wall.
// 4. Memory on a small board. The whole search uses four arrays of 140 bytes,
//    one per tile. Everything is a number from 0 to 139, so no pointers and
//    no dynamic allocation are needed.

#include <Servo.h>
#include "mokxi_ili9341.h"
#include "mokxi_joystick.h"

const uint8_t TFT_CS = 10;
const uint8_t TFT_DC = 9;
const uint8_t TFT_SCK = 13;
const uint8_t TFT_MOSI = 11;
const uint8_t STICK_BUTTON = 2;
const uint8_t LED_PIN = 3;
const uint8_t WALL_PIN = 4;
const uint8_t SERVO_PIN = 5;
const uint8_t PIEZO_PIN = 6;

SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);
Joystick stick(A0, A1, STICK_BUTTON);
Servo gripper;

const int16_t TILE = 16;
const int16_t WORLD_X = 8;
const int16_t WORLD_Y = 8;
const uint8_t COLS = 14;
const uint8_t ROWS = 10;
const uint8_t CELLS = COLS * ROWS;

const uint16_t STEEL = 0x8c71;
const uint16_t DARK = 0x10a2;
const uint16_t CRATE = 0xfc60;
const uint16_t ROBOT = ILI9341_CYAN;
const uint16_t CRUMB = 0x07e0;

enum Cell { FLOOR, WALL, BOX };

uint8_t cell[CELLS];
bool crumb[CELLS];            // tiles on the planned route, drawn as dots
uint8_t path[CELLS];          // the planned route, first step first
uint8_t pathLength = 0;
uint8_t pathAt = 0;           // how far along the route the robot is
// A* works in these four arrays, one entry per tile.
uint8_t gScore[CELLS];        // steps from the start, 255 if not reached yet
uint8_t cameFrom[CELLS];      // the tile we arrived from
uint8_t status[CELLS];        // 0 not seen, 1 in the open set, 2 closed
unsigned int nodesExpanded = 0;

const uint8_t DEPOT = 4 * COLS + 0;      // column 0, row 4
uint8_t robotAt = DEPOT;
uint8_t cursorAt = 2 * COLS + 3;         // starts on the first crate
uint8_t goal = 0;
bool carrying = false;
unsigned int delivered = 0;
unsigned int shownDelivered = 65535, shownPath = 65535, shownNodes = 65535;
int8_t shownState = -1;

enum Robot { IDLE, FETCHING, RETURNING, NO_PATH };
Robot mode = IDLE;

unsigned long lastStep = 0, lastCursor = 0, respawnAt = 0;
uint8_t respawnCell = 0;

// A button that reports a press once, after the contacts have stopped bouncing.
struct Button {
  uint8_t pin;
  bool raw;
  bool stable;
  unsigned long since;
};

static bool pressedOnce(Button &b) {
  bool down = digitalRead(b.pin) == LOW;
  unsigned long now = millis();
  if (down != b.raw) {
    b.raw = down;
    b.since = now;
  }
  if (now - b.since >= 20 && b.stable != b.raw) {
    b.stable = b.raw;
    return b.stable;
  }
  return false;
}

Button sendButton = {STICK_BUTTON, false, false, 0};
Button wallButton = {WALL_PIN, false, false, 0};

static uint8_t colOf(uint8_t i) { return (uint8_t)(i % COLS); }
static uint8_t rowOf(uint8_t i) { return (uint8_t)(i / COLS); }
static uint8_t at(uint8_t c, uint8_t r) { return (uint8_t)(r * COLS + c); }
static int16_t px(uint8_t i) { return (int16_t)(WORLD_X + colOf(i) * TILE); }
static int16_t py(uint8_t i) { return (int16_t)(WORLD_Y + rowOf(i) * TILE); }

// ---- drawing ---------------------------------------------------------------

static void drawTile(uint8_t i) {
  int16_t x = px(i), y = py(i);
  tft.fillRect(x, y, TILE, TILE, ILI9341_BLACK);
  if (cell[i] == WALL) {
    tft.fillRect(x, y, TILE, TILE, STEEL);
    tft.fillRect((int16_t)(x + 3), (int16_t)(y + 3), TILE - 6, TILE - 6, DARK);
  } else if (cell[i] == BOX) {
    tft.fillRect((int16_t)(x + 2), (int16_t)(y + 2), TILE - 4, TILE - 4, CRATE);
    tft.fillRect((int16_t)(x + 2), (int16_t)(y + 7), TILE - 4, 2, DARK);
  } else if (i == DEPOT) {
    tft.rect(x, y, TILE, TILE, ILI9341_GREEN, 2);
  } else if (crumb[i]) {
    tft.fillRect((int16_t)(x + 6), (int16_t)(y + 6), 4, 4, CRUMB);
  }
  if (i == robotAt) {
    tft.fillRect((int16_t)(x + 2), (int16_t)(y + 2), TILE - 4, TILE - 4, ROBOT);
    tft.fillRect((int16_t)(x + 6), (int16_t)(y + 6), 4, 4, ILI9341_WHITE);
    if (carrying) {
      tft.fillRect((int16_t)(x + 5), y, 6, 4, CRATE);
    }
  }
  if (i == cursorAt) {
    tft.rect(x, y, TILE, TILE, ILI9341_YELLOW, 2);
  }
}

static void drawLabels() {
  tft.text(8, 176, "DONE", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(104, 176, "PATH", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(8, 198, "NODES", ILI9341_WHITE, ILI9341_BLACK, 2);
}

static const char *modeName() {
  return mode == IDLE ? "READY     " : (mode == FETCHING ? "FETCHING  " : (mode == RETURNING ? "DELIVERING" : "NO PATH   "));
}

static void report() {
  Serial.print("done ");
  Serial.print(delivered);
  Serial.print(" path ");
  Serial.print(pathLength);
  Serial.print(" nodes ");
  Serial.print(nodesExpanded);
  Serial.print(" ");
  switch (mode) {
    case IDLE: Serial.println("READY"); break;
    case FETCHING: Serial.println("FETCHING"); break;
    case RETURNING: Serial.println("DELIVERING"); break;
    default: Serial.println("NOPATH"); break;
  }
}

// One drawing job per call, so the stick and buttons are looked at often.
static void refresh() {
  if (delivered != shownDelivered) {
    shownDelivered = delivered;
    tft.number(8 + 5 * 12, 176, delivered, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
  } else if (pathLength != shownPath) {
    shownPath = pathLength;
    tft.number(104 + 5 * 12, 176, pathLength, ILI9341_CYAN, ILI9341_BLACK, 2, 3);
  } else if (nodesExpanded != shownNodes) {
    shownNodes = nodesExpanded;
    tft.number(8 + 6 * 12, 198, nodesExpanded, ILI9341_CYAN, ILI9341_BLACK, 2, 3);
  } else if ((int8_t)mode != shownState) {
    shownState = (int8_t)mode;
    tft.text(8, 220, modeName(), mode == NO_PATH ? ILI9341_RED : (mode == IDLE ? ILI9341_GREEN : ILI9341_YELLOW),
             ILI9341_BLACK, 2);
  }
}

// ---- A* --------------------------------------------------------------------

// The guess: how many steps it would take with no walls in the way.
static uint8_t manhattan(uint8_t a, uint8_t b) {
  int dc = (int)colOf(a) - (int)colOf(b);
  int dr = (int)rowOf(a) - (int)rowOf(b);
  return (uint8_t)((dc < 0 ? -dc : dc) + (dr < 0 ? -dr : dr));
}

// Search from `from` to `to`. Returns the number of steps, or -1 if walls make
// it impossible. The route is left in path[].
static int plan(uint8_t from, uint8_t to) {
  for (uint8_t i = 0; i < CELLS; i++) {
    gScore[i] = 255;
    status[i] = 0;
  }
  nodesExpanded = 0;
  gScore[from] = 0;
  status[from] = 1;
  while (true) {
    // Pick the open tile with the lowest f = g + h.
    int best = -1;
    unsigned int bestF = 9999;
    for (uint8_t i = 0; i < CELLS; i++) {
      if (status[i] != 1) {
        continue;
      }
      unsigned int f = (unsigned int)gScore[i] + manhattan(i, to);
      if (f < bestF) {
        bestF = f;
        best = i;
      }
    }
    if (best < 0) {
      return -1;                       // nothing left to try: there is no way
    }
    if (best == to) {
      break;
    }
    status[best] = 2;
    nodesExpanded++;
    uint8_t c = colOf((uint8_t)best), r = rowOf((uint8_t)best);
    for (uint8_t d = 0; d < 4; d++) {
      int nc = c, nr = r;
      if (d == 0) nc = c + 1;
      if (d == 1) nc = c - 1;
      if (d == 2) nr = r + 1;
      if (d == 3) nr = r - 1;
      if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) {
        continue;
      }
      uint8_t n = at((uint8_t)nc, (uint8_t)nr);
      if (cell[n] == WALL || status[n] == 2) {
        continue;
      }
      uint8_t ng = (uint8_t)(gScore[best] + 1);
      if (status[n] == 0 || ng < gScore[n]) {
        gScore[n] = ng;
        cameFrom[n] = (uint8_t)best;
        status[n] = 1;
      }
    }
  }
  // Walk back from the goal to the start to read the route off.
  uint8_t length = gScore[to];
  uint8_t node = to;
  for (int i = length - 1; i >= 0; i--) {
    path[i] = node;
    node = cameFrom[node];
  }
  return length;
}

// Plan a route to `to`, draw its dots, and report whether there is one.
static bool startRoute(uint8_t to) {
  // forget the old dots
  for (uint8_t i = 0; i < pathLength; i++) {
    uint8_t t = path[i];
    if (crumb[t]) {
      crumb[t] = false;
      if (i >= pathAt) {
        drawTile(t);
      }
    }
  }
  int length = plan(robotAt, to);
  if (length < 0) {
    pathLength = 0;
    pathAt = 0;
    return false;
  }
  pathLength = (uint8_t)length;
  pathAt = 0;
  goal = to;
  for (uint8_t i = 0; i + 1 < pathLength; i++) {
    crumb[path[i]] = true;
    drawTile(path[i]);
  }
  return true;
}

// ---- the robot ---------------------------------------------------------------

static void setMode(Robot next) {
  mode = next;
  digitalWrite(LED_PIN, next == FETCHING || next == RETURNING ? HIGH : LOW);
  report();
}

static void moveRobotTo(uint8_t tile) {
  uint8_t old = robotAt;
  robotAt = tile;
  drawTile(old);
  drawTile(tile);
}

static void arrive() {
  if (mode == FETCHING) {
    cell[robotAt] = FLOOR;
    carrying = true;
    respawnCell = robotAt;
    respawnAt = millis() + 2500;
    gripper.write(20);                 // close the gripper on the crate
    tone(PIEZO_PIN, 900, 60);
    moveRobotTo(robotAt);
    if (startRoute(DEPOT)) {
      setMode(RETURNING);
    } else {
      setMode(NO_PATH);
    }
  } else if (mode == RETURNING) {
    carrying = false;
    delivered++;
    gripper.write(100);                // open it over the depot
    tone(PIEZO_PIN, 1200, 80);
    moveRobotTo(robotAt);
    pathLength = 0;
    setMode(IDLE);
  }
}

// A wall has just changed: if it is on the rest of the route, plan again.
static void replanIfBlocked() {
  if (mode != FETCHING && mode != RETURNING) {
    return;
  }
  for (uint8_t i = pathAt; i < pathLength; i++) {
    if (cell[path[i]] == WALL) {
      if (!startRoute(goal)) {
        setMode(NO_PATH);
      } else {
        report();
      }
      return;
    }
  }
}

void setup() {
  Serial.begin(9600);
  pinMode(WALL_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  stick.begin();
  gripper.attach(SERVO_PIN);
  gripper.write(100);
  // The machines: a long wall with gaps at the top and bottom, and two blocks.
  for (uint8_t r = 1; r <= 7; r++) {
    cell[at(6, r)] = WALL;
  }
  for (uint8_t r = 3; r <= 5; r++) {
    cell[at(9, r)] = WALL;
  }
  cell[at(3, 7)] = WALL;
  cell[at(4, 7)] = WALL;
  // Three crates.
  cell[at(3, 2)] = BOX;
  cell[at(11, 2)] = BOX;
  cell[at(11, 7)] = BOX;
  tft.begin();
  tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
  for (uint8_t i = 0; i < CELLS; i++) {
    drawTile(i);
  }
  drawLabels();
  for (uint8_t i = 0; i < 8; i++) {
    refresh();
  }
  report();
}

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

  // The cursor follows the stick, one tile at a time.
  int8_t dx = stick.stepX();
  int8_t dy = stick.stepY();
  if ((dx != 0 || dy != 0) && now - lastCursor >= 150) {
    lastCursor = now;
    int nc = (int)colOf(cursorAt) + dx;
    int nr = (int)rowOf(cursorAt) + dy;
    if (nc >= 0 && nc < COLS && nr >= 0 && nr < ROWS) {
      uint8_t old = cursorAt;
      cursorAt = at((uint8_t)nc, (uint8_t)nr);
      drawTile(old);
      drawTile(cursorAt);
    }
  }

  // Pressing the stick sends the robot to the crate under the cursor.
  if (pressedOnce(sendButton) && (mode == IDLE || mode == NO_PATH)) {
    if (cell[cursorAt] == BOX) {
      if (startRoute(cursorAt)) {
        setMode(FETCHING);
      } else {
        setMode(NO_PATH);
        tone(PIEZO_PIN, 200, 200);
      }
    } else {
      tone(PIEZO_PIN, 200, 80);          // nothing to fetch there
    }
  }

  // The WALL button puts a wall on the cursor's tile, or removes one. Not on the
  // depot, the robot or a crate.
  if (pressedOnce(wallButton)) {
    if (cursorAt != DEPOT && cursorAt != robotAt && cell[cursorAt] != BOX) {
      cell[cursorAt] = cell[cursorAt] == WALL ? FLOOR : WALL;
      drawTile(cursorAt);
      tone(PIEZO_PIN, 600, 30);
      replanIfBlocked();
    }
  }

  // The robot walks its route, a tile every 130 ms.
  if ((mode == FETCHING || mode == RETURNING) && now - lastStep >= 130) {
    lastStep = now;
    if (pathAt < pathLength) {
      uint8_t next = path[pathAt++];
      crumb[next] = false;
      moveRobotTo(next);
      if (pathAt >= pathLength) {
        arrive();
      }
    }
  }

  // A delivered crate is replaced a moment later where it came from.
  if (respawnAt != 0 && now >= respawnAt && cell[respawnCell] == FLOOR && respawnCell != robotAt) {
    respawnAt = 0;
    cell[respawnCell] = BOX;
    drawTile(respawnCell);
  }

  refresh();
}

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);
  pinMode(WALL_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  stick.begin();
  gripper.attach(SERVO_PIN);
  gripper.write(100);
  // The machines: a long wall with gaps at the top and bottom, and two blocks.
  for (uint8_t r = 1; r <= 7; r++) {
    cell[at(6, r)] = WALL;
  }
  for (uint8_t r = 3; r <= 5; r++) {
    cell[at(9, r)] = WALL;
  }
  cell[at(3, 7)] = WALL;
  cell[at(4, 7)] = WALL;
  // Three crates.
  cell[at(3, 2)] = BOX;
  cell[at(11, 2)] = BOX;
  cell[at(11, 7)] = BOX;
  tft.begin();
  tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
  for (uint8_t i = 0; i < CELLS; i++) {
    drawTile(i);
  }
  drawLabels();
  for (uint8_t i = 0; i < 8; i++) {
    refresh();
  // ... 3 more lines in the editor

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

  // The cursor follows the stick, one tile at a time.
  int8_t dx = stick.stepX();
  int8_t dy = stick.stepY();
  if ((dx != 0 || dy != 0) && now - lastCursor >= 150) {
    lastCursor = now;
    int nc = (int)colOf(cursorAt) + dx;
    int nr = (int)rowOf(cursorAt) + dy;
    if (nc >= 0 && nc < COLS && nr >= 0 && nr < ROWS) {
      uint8_t old = cursorAt;
      cursorAt = at((uint8_t)nc, (uint8_t)nr);
      drawTile(old);
      drawTile(cursorAt);
    }
  }

  // Pressing the stick sends the robot to the crate under the cursor.
  if (pressedOnce(sendButton) && (mode == IDLE || mode == NO_PATH)) {
    if (cell[cursorAt] == BOX) {
      if (startRoute(cursorAt)) {
        setMode(FETCHING);
      } else {
        setMode(NO_PATH);
        tone(PIEZO_PIN, 200, 200);
      }
    } else {
  // ... 37 more lines in the editor

Troubleshooting

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

  • The screen stays white or black. TFT display
    Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation.
  • Slow drawing or garbled colors. TFT display
    Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order.
  • The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
    Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts.
  • The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
    Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference.
  • 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.
  • 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.

Change it and keep it

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