• EUR€
  • £GBP
  • $USD

The ULTIMATE Gumball Machine

DFRobot Jul 02 2018 391

LEDs, WiFi, automatic dispensing, an LCD screen — it's got it all. This gumball machine lets users interact with it via a webpage.


Hardware components

2.8" TFT Touch Shield with 4MB Flash for Arduino and mbed
Teensy 3.5
ESP8266 Thing - Dev Board
WS2812 LED Strip 
White arcade button
Hybrid Stepper Motor for 3D Printer

Driver DRV8825 for Stepper Motors for Theremino System


Software apps and online services

Autodesk Fusion 360
Arduino IDE

Hand tools and fabrication machines

3D Printer (generic)
Soldering iron (generic)
CNC router 


What is ultimate? Infinite RGB? How about a cool LCD touchscreen? Maybe even some completely unnecessary WiFi capabilities? How about all of them-in a gumball machine.




As usual, almost everything that's more complex than making a few simple connections and a basic box needed a design in Fusion 360. I started out by sketching what I wanted the machine to look like. It needed to be tall, have enough room for all of the electronics, and also be able to support the weight of 12lbs of gumballs.


So then I tried to make a simple and elegant dispensing mechanism. It had to only dispense one gumball at a time, not get jammed, and not let more than one gumball fall through what it turns. I came to the realization that all I needed was a simple wheel with 4 holes, and that the dispensing hole would have a cover on the top of it to stop excess gumballs from falling through.


After my design was finished I exported all of the 3d-printable parts and generated toolpaths for CNC routing the housing.

Housing and Fabrication

I started by gathering the dimensions for the gumball machine's legs and then sketching them out on a huge sheet of plywood. Then I took a jigsaw and cut the four legs out. I also cut out the main housing from plywood with my CNC router.


Then I drilled holes into everything and painted it red.


The LED strip got glued onto the bottom plate so it could cast a nice glow onto the machine's stand below.



In order for users to interact with the gumball machine there needs to be an easy interface. I chose to create a simple webpage that lets users dispense gumballs and change the LEDs' color. After an action happens the webpage POSTs data to a custom Node.js webserver via AJAX.



I needed a webserver to act as an intermediary between users on the webpage and the gumball machine. Therefore, I decided to use Node.js to both send and receive data. Users send a POST request to control LED color and dispense. Then the ESP8266 sends a GET request to get the status of the machine. And what happens if someone keeps clicking "dispense"? The server keeps track of all the IPs that have clicked the dispense button and blocks them from dispensing twice.


A TFT screen takes a lot of processing power to drive, so I had to choose a fast and powerful board, leading me to use the Teensy 3.5. But now you might be thinking to yourself: "How does a Teensy use WiFi?" That was a very hard problem for me to solve. I needed to get the Teensy to listen to a local server for changes made by users. Then it dawned on me to just use an ESP8266 to check the server and then "talk" to the Teensy via Serial, which made it a lot easier.





The Teensy runs a simple script that first loads the image from the SD card and displays it onscreen. Then it checks serial data to see if it needs to change the color of the LEDs or dispense.


Using the gumball machine is pretty simple: just go to the webpage and click the "dispense" button. Or, better yet, just go up and push the button. Then just reach in and grab your rightful prize.



Teensy CodeC/C++

#define TFT_CS 2 #define TFT_DC 5 #define SD_CS   BUILTIN_SDCARD #define DIR_PIN 16 #define STEP_PIN 17 #define LED_PIN 20 #define BUTTON_PIN 30 #define NUMPIXELS 20 #define RPM 60 #define MOTOR_STEPS 200 #define MICROSTEPS 1 //#define rxPin 36 //#define txPin 37 #include <SPI.h> #include <SD.h> #include <Adafruit_ILI9341.h> #include <Adafruit_GFX.h> #include <Adafruit_NeoPixel.h> #include <Arduino.h> #include "BasicStepperDriver.h" //#include <SoftwareSerial.h> Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUMPIXELS, LED_PIN, NEO_GRB + NEO_KHZ800); int LEDColors[] = {pixels.Color(255,0,0),pixels.Color(0,255,0),pixels.Color(0,0,255),pixels.Color(200,0,200),pixels.Color(0,0,0)}; BasicStepperDriver stepper(MOTOR_STEPS, DIR_PIN, STEP_PIN); //SoftwareSerial Serial4 = SoftwareSerial(rxPin, txPin); bool button_pressed = false; Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC); void setup(){    //pinMode(rxPin, INPUT);    //pinMode(txPin, OUTPUT);    Serial.begin(9600);    Serial4.begin(9600);    delay(500);    SPI.setMOSI(7);    SPI.setSCK(14);    if(SD.begin(SD_CS)){      Serial.println("Success");      File entry = SD.open("MAINPAGE.BMP");      if(entry != NULL){      Serial.println(entry.name());      }      entry.close();  }  stepper.begin(RPM, MICROSTEPS);  tft.begin();  pixels.begin();  tft.setRotation(1);  pixels.setBrightness(75);  pinMode(BUTTON_PIN,INPUT_PULLUP);  attachInterrupt(BUTTON_PIN,set_button,FALLING);  tft.setTextColor(0x0000);  tft.fillScreen(0xFFFF);  tft.setCursor(0,0);  tft.print("HI");  setAllPixels(0);  //displayDispensing();  delay(3000);  loadMainPage(); } elapsedMillis timer1; void loop(){    if(button_pressed){        button_pressed = false;        dispense();    }    if(Serial4.available()){        String cmd = Serial4.readStringUntil(',');        Serial.print(cmd);        Serial.print(',');        if(cmd=="LED"){            String color = Serial4.readStringUntil('\n');            Serial.println(color);            if(color=="RED\r"){                setAllPixels(0);            }            else if(color=="GREEN\r"){                setAllPixels(1);            }            else if(color=="BLUE\r"){                setAllPixels(2);            }            else if(color=="PURPLE\r"){                setAllPixels(3);            }            else if(color=="BLACK\r"){                setAllPixels(4);            }        }        else if(cmd=="DISPENSE"){            String cmd2 = Serial4.readStringUntil('\n');            Serial.println(cmd2);            if(cmd2=="true\r"){                if(timer1>5000){                //displayDispensing();                dispense();                }            }        }    }     } void loadMainPage(){    tft.fillScreen(0xFFFF);    bmpDraw("MAINPAGE.BMP",0,0); } void displayDispensing(){    tft.fillScreen(0xFFFF);    tft.setTextSize(3);    tft.setCursor(90,30);    tft.print("Dispensing \n");    tft.setCursor(100,80);    tft.print("gumball!");    delay(3000);    loadMainPage(); } void dispense(){    Serial.println("Dispensing");    displayDispensing();    stepper.move(259);    delay(3000);    loadMainPage(); } void set_button(){    button_pressed = true;    delay(200); } void setAllPixels(int colorNum){    Serial.print("Setting pixels to ");Serial.println(colorNum);    for(int i=0;i<NUMPIXELS;i++){        pixels.setPixelColor(i, LEDColors[colorNum]);    }    pixels.show(); } #define BUFFPIXEL 20 void bmpDraw(char *filename, int16_t x, int16_t y) {  File     bmpFile;  int      bmpWidth, bmpHeight;   // W+H in pixels  uint8_t  bmpDepth;              // Bit depth (currently must be 24)  uint32_t bmpImageoffset;        // Start of image data in file  uint32_t rowSize;               // Not always = bmpWidth; may have padding  uint8_t  sdbuffer[3*BUFFPIXEL]; // pixel buffer (R+G+B per pixel)  uint8_t  buffidx = sizeof(sdbuffer); // Current position in sdbuffer  boolean  goodBmp = false;       // Set to true on valid header parse  boolean  flip    = true;        // BMP is stored bottom-to-top  int      w, h, row, col, x2, y2, bx1, by1;  uint8_t  r, g, b;  uint32_t pos = 0, startTime = millis();  if((x >= tft.width()) || (y >= tft.height())) return;  Serial.println();  Serial.print(F("Loading image '"));  Serial.print(filename);  Serial.println('\'');  // Open requested file on SD card  bmpFile = SD.open(filename);  /*if ((bmpFile = SD.open(filename)) == NULL) {    Serial.print(F("File not found"));    return;  }*/  // Parse BMP header  if(read16(bmpFile) == 0x4D42) { // BMP signature    Serial.print(F("File size: ")); Serial.println(read32(bmpFile));    (void)read32(bmpFile); // Read & ignore creator bytes    bmpImageoffset = read32(bmpFile); // Start of image data    Serial.print(F("Image Offset: ")); Serial.println(bmpImageoffset, DEC);    // Read DIB header    Serial.print(F("Header size: ")); Serial.println(read32(bmpFile));    bmpWidth  = read32(bmpFile);    bmpHeight = read32(bmpFile);    if(read16(bmpFile) == 1) { // # planes -- must be '1'      bmpDepth = read16(bmpFile); // bits per pixel      Serial.print(F("Bit Depth: ")); Serial.println(bmpDepth);      if((bmpDepth == 24) && (read32(bmpFile) == 0)) { // 0 = uncompressed        goodBmp = true; // Supported BMP format -- proceed!        Serial.print(F("Image size: "));        Serial.print(bmpWidth);        Serial.print('x');        Serial.println(bmpHeight);        // BMP rows are padded (if needed) to 4-byte boundary        rowSize = (bmpWidth * 3 + 3) & ~3;        // If bmpHeight is negative, image is in top-down order.        // This is not canon but has been observed in the wild.        if(bmpHeight < 0) {          bmpHeight = -bmpHeight;          flip      = false;        }        // Crop area to be loaded        x2 = x + bmpWidth  - 1; // Lower-right corner        y2 = y + bmpHeight - 1;        if((x2 >= 0) && (y2 >= 0)) { // On screen?          w = bmpWidth; // Width/height of section to load/display          h = bmpHeight;          bx1 = by1 = 0; // UL coordinate in BMP file          if(x < 0) { // Clip left            bx1 = -x;            x   = 0;            w   = x2 + 1;          }          if(y < 0) { // Clip top            by1 = -y;            y   = 0;            h   = y2 + 1;          }          if(x2 >= tft.width())  w = tft.width()  - x; // Clip right          if(y2 >= tft.height()) h = tft.height() - y; // Clip bottom            // Set TFT address window to clipped image bounds          tft.startWrite(); // Requires start/end transaction now          tft.setAddrWindow(x, y, w, h);            for (row=0; row<h; row++) { // For each scanline...              // Seek to start of scan line.  It might seem labor-            // intensive to be doing this on every line, but this            // method covers a lot of gritty details like cropping            // and scanline padding.  Also, the seek only takes            // place if the file position actually needs to change            // (avoids a lot of cluster math in SD library).            if(flip) // Bitmap is stored bottom-to-top order (normal BMP)              pos = bmpImageoffset + (bmpHeight - 1 - (row + by1)) * rowSize;            else     // Bitmap is stored top-to-bottom              pos = bmpImageoffset + (row + by1) * rowSize;            pos += bx1 * 3; // Factor in starting column (bx1)            if(bmpFile.position() != pos) { // Need seek?              tft.endWrite(); // End TFT transaction              bmpFile.seek(pos);              buffidx = sizeof(sdbuffer); // Force buffer reload              tft.startWrite(); // Start new TFT transaction            }            for (col=0; col<w; col++) { // For each pixel...              // Time to read more pixel data?              if (buffidx >= sizeof(sdbuffer)) { // Indeed                tft.endWrite(); // End TFT transaction                bmpFile.read(sdbuffer, sizeof(sdbuffer));                buffidx = 0; // Set index to beginning                tft.startWrite(); // Start new TFT transaction              }              // Convert pixel from BMP to TFT format, push to display              b = sdbuffer[buffidx++];              g = sdbuffer[buffidx++];              r = sdbuffer[buffidx++];              tft.writePixel(tft.color565(r,g,b));            } // end pixel          } // end scanline          tft.endWrite(); // End last TFT transaction        } // end onscreen        Serial.print(F("Loaded in "));        Serial.print(millis() - startTime);        Serial.println(" ms");      } // end goodBmp    }  }  bmpFile.close();  if(!goodBmp) Serial.println(F("BMP format not recognized.")); } uint16_t read16(File &f) {  uint16_t result;  ((uint8_t *)&result)[0] = f.read(); // LSB  ((uint8_t *)&result)[1] = f.read(); // MSB  return result; } uint32_t read32(File &f) {  uint32_t result;  ((uint8_t *)&result)[0] = f.read(); // LSB  ((uint8_t *)&result)[1] = f.read();  ((uint8_t *)&result)[2] = f.read();  ((uint8_t *)&result)[3] = f.read(); // MSB  return result; }

ESP8266 CodeC/C++

#include <Arduino.h> #include <ESP8266WiFi.h> #include <ESP8266WiFiMulti.h> #include <ESP8266HTTPClient.h> ESP8266WiFiMulti WiFiMulti; void setup(){    Serial.begin(9600);    WiFiMulti.addAP("SSID", "PSK"); } void loop(){    if((WiFiMulti.run() == WL_CONNECTED)){        HTTPClient http;                http.begin("http://local_ip (change these values):3010/status/led");        int httpCode = http.GET();                String payload = http.getString();        if(payload){            Serial.print("LED,");            Serial.println(payload);        }        http.end();                http.begin("http://local_ip:3010/status/dispense");        httpCode = http.GET();                payload = http.getString();        if(payload){            Serial.print("DISPENSE,");            Serial.println(payload);            if(payload=="true"){                delay(4000);            }        }        http.end();        delay(1000);    } }

Node JS Server Code

var express = require('express'); var myParser = require('body-parser'); var app = express(); const cors = require('cors'); var latestColor = "RED"; var dispense_active = false; var usedIPs = []; const whitelist = ['::ffff:local_ip'] app.use(myParser.json({extended: true})); app.use(cors()); app.options('*',cors()); app.post("/gumball", function(request, response){    console.log(request.body);    response.send("OK, 200");    if(typeof request.body.LED !=='undefined'){        latestColor = request.body.LED;    }     if(typeof request.body.DISPENSE !=='undefined'){        dispense_active = request.body.DISPENSE;        if(dispense_active = true){            if(usedIPs.indexOf(request.ip)==-1){            usedIPs.push(request.ip);            console.log(usedIPs);            }            else if(whitelist.indexOf(request.ip)>-1){                dispense_active = true;            }            else{                dispense_active = false;            }        }    } }); app.get('/status/dispense',function(req,res){    res.send(dispense_active);    dispense_active = false; }); app.get('/status/led',function(req,res){    res.send(latestColor); }); app.listen(3010);

Webpage main HTML

<html> <head> <title>IoT Gumball Machine</title> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"> </script> <link rel="stylesheet" type="text/css" href="style.css"> </head> <body> <script> function changeLED(color){ console.log(color.toUpperCase()); var colorName = color.toUpperCase(); var obj = {"LED": colorName}; $.ajax('http://local_ip:3010/gumball',{ data: JSON.stringify(obj), contentType: 'application/json', type: 'POST' }); } function dispense(){ console.log("Dispensing"); var obj = {"DISPENSE": true}; $.ajax('http://local_ip (change these values):3010/gumball',{ data: JSON.stringify(obj), contentType: 'application/json', type: 'POST' }); } function changeColor(color){ document.getElementById("color_list").style.color = color; console.log(color); } </script> <div class="centered"> <form id="LED_change" onsubmit="changeLED(this.color_list.value); return false" onchange="changeColor(this.color_list.value); return false"> <select id="color_list" name="color_list"> <option class="sRed" value="red">Red</option> <option class="sGreen" value="green">Green</option> <option class="sBlue" value="blue">Blue</option> <option class="sPurple" value="purple">Purple</option> <option class="sOff" value="black">Off</option> </select> <input type=submit value="Change color"> </form> <button id="dButton" onclick="dispense(); return false">Dispense gumball</button> </div> </body> </html>

Webpage CSS

#dButton{ width: 200px; height: 100px; font-size: 18px; color: black; background-color: white; border-color: lightgray; border-radius: 18px; margin:30px 10px; } #dButton:hover{ cursor: pointer; } select{ width: 100px; font-size: 18px; } #LED_change>input[type=submit]{ width: 100px; height: 40px; background-color: white; border-color: black; border-radius: 4px; margin-left: 30px; } #LED_change>input[type=submit]:hover{ cursor: pointer; } .centered{ position: fixed; top: 40%; left: 40%; } .sRed{ color: red; } .sGreen{ color: green; } .sBlue{ color: blue; } .sPurple{ color: purple; } .sOff{ color: black; } body{ font-family: Verdana; font-size: 20px; }