Factory 5: Assembler, on an Arduino Uno
An assembler builds a widget from two plates and a gear. Two knobs set how fast each arrives, and the screen shows which buffer is backing up: find the 2 to 1 balance. 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 35 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $38. On Mokxi they're free.
What you'll learn
- Displays
- Games
- Sensors
- Reading a button
- Choosing and reading a resistor
- Difficulty: Advanced
- Time: About 35 minutes
- Board: Arduino Uno R3
- Parts: 13
How it works
An assembler builds a widget from two plates and a gear. Two knobs set how fast each arrives, and the screen shows which buffer is backing up: find the 2 to 1 balance.
Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Pushbutton, Resistor, 220 Ω, LED, yellow, Piezo speaker, Potentiometer. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalWrite drives LED_PIN LOW. digitalRead reads whether b.pin is HIGH or LOW. analogRead measures the voltage on KNOB_A as a number. Serial.begin opens the serial port at 9600 baud.
Parts list
13 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × TFT display, 240x320In Handheld Console Kit.
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, yellowIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 2 × PotentiometerIn Halloween Prop Kit, Starter Projects Kit.
- 5 × Power, 5 V
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 |
|---|---|
| TFT display, 240x320 pin SCK | Arduino Uno R3 pin 13 |
| TFT display, 240x320 pin MOSI | Arduino Uno R3 pin 11 |
| TFT display, 240x320 pin CS | Arduino Uno R3 pin 10 |
| TFT display, 240x320 pin DC | Arduino Uno R3 pin 9 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 6 |
| Resistor, 220 Ω pin 1 | Arduino Uno R3 pin 3 |
| Pushbutton pin 1a | Arduino Uno R3 pin 2 |
| Arduino Uno R3 pin GND | Ground |
| Potentiometer (1) pin 2 | Arduino Uno R3 pin A0 |
| Potentiometer (2) pin 2 | Arduino Uno R3 pin A1 |
| TFT display, 240x320 pin VCC | 5 V |
| TFT display, 240x320 pin GND | Ground |
| TFT display, 240x320 pin RST | 5 V |
| TFT display, 240x320 pin LED | 5 V |
| Pushbutton pin 2a | Ground |
| LED, yellow pin A | Resistor, 220 Ω pin 2 |
| LED, yellow pin C | Ground |
| Piezo speaker pin 2 | Ground |
| Potentiometer (1) pin 1 | Ground |
| Potentiometer (1) pin 3 | 5 V |
| Potentiometer (2) pin 1 | Ground |
| Potentiometer (2) pin 3 | 5 V |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Factory 5: Assembler. Two plates and one gear make one widget.
//
// Plates arrive from feeder A and gears from feeder B. Each waits in a buffer
// that holds six. The assembler takes two plates and one gear together,
// spends two seconds building, and drops a widget in the tray. Two knobs set
// how fast the feeders deliver. If a buffer is full when something arrives,
// that item is lost, and the LOST counter ticks up.
//
// 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
// Plate knob (10k pot): middle leg -> A0 Gear knob (10k pot): middle leg -> A1
// (each pot: outer legs to 5 V and GND)
// Reset button: pin 2 to GND Status LED: pin 3 through 220 ohm to GND
// Piezo: pin 6 to GND
//
// # What this teaches
//
// 1. Ratios and throughput. The assembler can finish one widget every 2 s, so
// it would like 1 plate a second and a gear every 2 seconds. That is a
// plate:gear ratio of 2:1 and anything else wastes something. The screen
// shows the feed rates in items per minute so you can see the ratio.
// 2. Buffers. A buffer smooths out the gaps between arrivals, but it only
// delays the problem: if one feeder is faster than the recipe allows, that
// buffer fills up and the surplus is lost. If it is slower, the assembler
// sits idle waiting. Looking at which buffer is full and which is empty
// tells you which knob to turn.
// 3. Balancing is a loop you run by hand: read the symptom, change one thing,
// wait, read again. Later projects automate this with telemetry.
#include "mokxi_ili9341.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 BUTTON_PIN = 2;
const uint8_t LED_PIN = 3;
const uint8_t PIEZO_PIN = 6;
const uint8_t KNOB_A = A0;
const uint8_t KNOB_B = A1;
SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);
const int16_t TILE = 16;
const int16_t WORLD_X = 8;
const int16_t WORLD_Y = 8;
const uint16_t STEEL = 0x8c71;
const uint16_t DARK = 0x10a2;
const uint16_t PLATE = 0xc618;
const uint16_t GEAR = 0x07ff;
const uint16_t WIDGET = 0xffe0;
const uint16_t BODY = 0x4a69;
// The recipe.
const uint8_t NEED_A = 2;
const uint8_t NEED_B = 1;
const unsigned long BUILD_MS = 2000;
const uint8_t CAPACITY = 6;
// 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 resetButton = {BUTTON_PIN, false, false, 0};
uint8_t bufA = 0, bufB = 0; // items waiting
uint8_t shownA = 255, shownB = 255;
bool building = false;
unsigned long buildDone = 0; // millis() when the build finishes
unsigned long lastA = 0, lastB = 0; // when each feeder last delivered
unsigned long intervalA = 1000, intervalB = 2000;
unsigned int made = 0, lost = 0;
unsigned int shownMade = 65535, shownLost = 65535;
long shownRateA = -1, shownRateB = -1;
int8_t shownVerdict = -1;
uint8_t shownProgress = 255;
unsigned long flashA = 0, flashB = 0; // feeder tiles glow red until this time
bool flashedA = false, flashedB = false;
unsigned long lastVerdict = 0;
static int16_t tileX(uint8_t c) { return (int16_t)(WORLD_X + c * TILE); }
static int16_t tileY(uint8_t r) { return (int16_t)(WORLD_Y + r * TILE); }
static void slot(uint8_t col, uint8_t row, uint16_t colour, bool full) {
tft.fillRect(tileX(col), tileY(row), TILE, TILE, STEEL);
tft.fillRect((int16_t)(tileX(col) + 3), (int16_t)(tileY(row) + 3), TILE - 6, TILE - 6, full ? colour : DARK);
}
static void drawFeeder(uint8_t row, uint16_t colour, bool alarm) {
tft.fillRect(tileX(1), tileY(row), TILE, TILE, alarm ? ILI9341_RED : STEEL);
tft.fillRect((int16_t)(tileX(1) + 4), (int16_t)(tileY(row) + 4), 8, 8, colour);
}
static void drawBuffers(uint8_t from, uint8_t to, uint8_t row, uint16_t colour, uint8_t count) {
for (uint8_t i = from; i < to && i < CAPACITY; i++) {
slot((uint8_t)(3 + i), row, colour, i < count);
}
}
static void drawAssembler() {
tft.fillRect(tileX(9), tileY(3), 2 * TILE, 3 * TILE, BODY);
tft.rect(tileX(9), tileY(3), 2 * TILE, 3 * TILE, STEEL, 2);
}
static void drawProgress(uint8_t filled) {
tft.fillRect(tileX(9), (int16_t)(tileY(6) + 4), 2 * TILE, 8, DARK);
if (filled > 0) {
tft.fillRect(tileX(9), (int16_t)(tileY(6) + 4), filled, 8, ILI9341_GREEN);
}
}
static void drawTray(uint8_t from, uint8_t to) {
for (uint8_t i = from; i < to && i < 6; i++) {
slot((uint8_t)(12 + (i & 1)), (uint8_t)(3 + (i >> 1)), WIDGET, i < made);
}
}
static void drawLabels() {
tft.text(8, 176, "A", PLATE, ILI9341_BLACK, 2);
tft.text(80, 176, "B", GEAR, ILI9341_BLACK, 2);
tft.text(144, 176, "MADE", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(8, 198, "A/MIN", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(120, 198, "B/MIN", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(8, 220, "LOST", ILI9341_WHITE, ILI9341_BLACK, 2);
}
static void report() {
Serial.print("A ");
Serial.print(bufA);
Serial.print(" B ");
Serial.print(bufB);
Serial.print(" made ");
Serial.print(made);
Serial.print(" lost ");
Serial.println(lost);
}
// What is the line telling you? The symptom points at the knob to turn.
static int8_t verdict() {
if (bufA >= CAPACITY && bufB < NEED_B) return 1; // A backed up, B starved
if (bufB >= CAPACITY && bufA < NEED_A) return 2; // B backed up, A starved
if (bufA >= CAPACITY) return 3; // too many plates
if (bufB >= CAPACITY) return 4; // too many gears
if (!building && bufA < NEED_A && bufB >= NEED_B) return 5; // waiting for plates
if (!building && bufB < NEED_B && bufA >= NEED_A) return 6; // waiting for gears
return 0;
}
static const char *verdictText(int8_t v) {
switch (v) {
case 1: return "NEED MORE B";
case 2: return "NEED MORE A";
case 3: return "TOO MANY A ";
case 4: return "TOO MANY B ";
case 5: return "WAIT FOR A ";
case 6: return "WAIT FOR B ";
default: return "BALANCED ";
}
}
// One drawing job per call, so the buttons and the knobs are looked at often.
static void refresh() {
if (bufA != shownA) {
uint8_t old = shownA == 255 ? 0 : shownA;
shownA = bufA;
drawBuffers(old < bufA ? old : bufA, old < bufA ? bufA : old, 3, PLATE, bufA);
} else if (bufB != shownB) {
uint8_t old = shownB == 255 ? 0 : shownB;
shownB = bufB;
drawBuffers(old < bufB ? old : bufB, old < bufB ? bufB : old, 5, GEAR, bufB);
} else if (made != shownMade) {
uint8_t old = shownMade == 65535 ? 0 : (shownMade > 6 ? 6 : (uint8_t)shownMade);
shownMade = made;
drawTray(old, 6);
tft.number(144 + 5 * 12, 176, made, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
} else if (lost != shownLost) {
shownLost = lost;
tft.number(8 + 5 * 12, 220, lost, ILI9341_RED, ILI9341_BLACK, 2, 3);
} else if (60000L / (long)intervalA != shownRateA) {
shownRateA = 60000L / (long)intervalA;
tft.number(8 + 6 * 12, 198, shownRateA, ILI9341_CYAN, ILI9341_BLACK, 2, 3);
} else if (60000L / (long)intervalB != shownRateB) {
shownRateB = 60000L / (long)intervalB;
tft.number(120 + 6 * 12, 198, shownRateB, ILI9341_CYAN, ILI9341_BLACK, 2, 3);
} else if (shownVerdict != verdict() && millis() - lastVerdict > 600) {
lastVerdict = millis();
shownVerdict = verdict();
tft.text(104, 220, verdictText(shownVerdict), shownVerdict == 0 ? ILI9341_GREEN : ILI9341_ORANGE,
ILI9341_BLACK, 2);
} else {
// The assembler's progress bar, in 32 pixels.
uint8_t filled = 0;
if (building) {
unsigned long left = buildDone - millis();
filled = (uint8_t)(((BUILD_MS - (left > BUILD_MS ? BUILD_MS : left)) * 32UL) / BUILD_MS);
}
if (filled != shownProgress) {
shownProgress = filled;
drawProgress(filled);
}
}
// A feeder tile flashes red when it has just thrown something away.
unsigned long now = millis();
bool alarmA = now < flashA, alarmB = now < flashB;
if (alarmA != flashedA) {
flashedA = alarmA;
drawFeeder(3, PLATE, alarmA);
}
if (alarmB != flashedB) {
flashedB = alarmB;
drawFeeder(5, GEAR, alarmB);
}
}
void setup() {
Serial.begin(9600);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawFeeder(3, PLATE, false);
drawFeeder(5, GEAR, false);
drawBuffers(0, CAPACITY, 3, PLATE, 0);
drawBuffers(0, CAPACITY, 5, GEAR, 0);
drawAssembler();
drawProgress(0);
drawTray(0, 6);
drawLabels();
for (uint8_t i = 0; i < 12; i++) {
refresh();
}
report();
lastA = lastB = millis();
}
void loop() {
unsigned long now = millis();
// The knobs set how long each feeder waits between deliveries: 3000 ms with
// the knob all the way down to 500 ms all the way up.
intervalA = 3000UL - (unsigned long)analogRead(KNOB_A) * 5UL / 2UL;
intervalB = 3000UL - (unsigned long)analogRead(KNOB_B) * 5UL / 2UL;
if (pressedOnce(resetButton)) {
bufA = bufB = 0;
made = lost = 0;
building = false;
digitalWrite(LED_PIN, LOW);
report();
tone(PIEZO_PIN, 400, 80);
}
// Feeders deliver on their own clocks. A full buffer throws the item away.
if (now - lastA >= intervalA) {
lastA = now;
if (bufA < CAPACITY) {
bufA++;
} else {
lost++;
flashA = now + 200;
report();
}
}
if (now - lastB >= intervalB) {
lastB = now;
if (bufB < CAPACITY) {
bufB++;
} else {
lost++;
flashB = now + 200;
report();
}
}
// The assembler: take the inputs together, work for BUILD_MS, give a widget.
if (!building) {
if (bufA >= NEED_A && bufB >= NEED_B) {
bufA -= NEED_A;
bufB -= NEED_B;
building = true;
buildDone = now + BUILD_MS;
digitalWrite(LED_PIN, HIGH);
}
} else if ((long)(now - buildDone) >= 0) {
building = false;
made++;
digitalWrite(LED_PIN, LOW);
tone(PIEZO_PIN, 1100, 50);
report();
}
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(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawFeeder(3, PLATE, false);
drawFeeder(5, GEAR, false);
drawBuffers(0, CAPACITY, 3, PLATE, 0);
drawBuffers(0, CAPACITY, 5, GEAR, 0);
drawAssembler();
drawProgress(0);
drawTray(0, 6);
drawLabels();
for (uint8_t i = 0; i < 12; i++) {
refresh();
}
report();
lastA = lastB = 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();
// The knobs set how long each feeder waits between deliveries: 3000 ms with
// the knob all the way down to 500 ms all the way up.
intervalA = 3000UL - (unsigned long)analogRead(KNOB_A) * 5UL / 2UL;
intervalB = 3000UL - (unsigned long)analogRead(KNOB_B) * 5UL / 2UL;
if (pressedOnce(resetButton)) {
bufA = bufB = 0;
made = lost = 0;
building = false;
digitalWrite(LED_PIN, LOW);
report();
tone(PIEZO_PIN, 400, 80);
}
// Feeders deliver on their own clocks. A full buffer throws the item away.
if (now - lastA >= intervalA) {
lastA = now;
if (bufA < CAPACITY) {
bufA++;
} else {
lost++;
flashA = now + 200;
report();
}
}
// ... 30 more lines in the editorTroubleshooting
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 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 LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - The LED does not light, or it lights once and then dies. LED
LEDs have polarity. The longer leg (anode) goes toward the positive side. Always add a series resistor, about 220 ohms to 1 k ohm on a 5 V pin. - A blue or white LED stays dark on 3.3 V. LED
Blue and white LEDs drop about 3 V, so 3.3 V leaves almost nothing for the resistor. Use a 5 V supply, or a smaller resistor and a red or green LED.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.