How to Build an ESP32 Pet Treat Dispenser

Servo-fed treats with a measured bowl, OLED status, and manual dispense button

ESP32PetsIntermediate55 minutes8 components

Updated

How to Build an ESP32 Pet Treat Dispenser
For illustrative purposes only
On this page

What you'll build

Build a desk-sized pet treat dispenser with an SG90 servo-controlled gate, an HX711 load-cell bowl check, a compact SSD1306 OLED display, a manual dispense button, and a piezo buzzer for short status chirps. Press the button and the ESP32 reads the bowl weight through the HX711. If the bowl already holds at least BOWL_FULL_THRESHOLD_G grams (default 20 g), the gate stays closed and the OLED shows "Bowl full". If the bowl is ready, the servo opens the gate for GATE_OPEN_MS milliseconds (default 600 ms), a chirp confirms the dispense, and the display shows the new weight estimate. A COOLDOWN_MS gap (default five minutes) prevents rapid re-feeding.

The useful part of this build is the safety logic around a simple mechanism. The HX711 tares automatically on boot with an empty bowl, a debounced button triggers the dispense check, and millis() timers keep the loop non-blocking throughout. The servo runs from its own 5 V supply to avoid brownouts when the gate moves.

The firmware has five jobs: tare the HX711 on boot with the bowl empty, then read bowl weight on demand; check bowl weight against BOWL_FULL_THRESHOLD_G before opening the gate; pulse the SG90 micro servo open for GATE_OPEN_MS then close it using millis() to avoid blocking; enforce a COOLDOWN_MS gap between dispensing cycles; and show dispense state, weight, and cooldown countdown on the SSD1306 OLED display.

Upload and calibrate

Flash the sketch from Schematik and open Serial Monitor at 115200 baud. On boot the firmware tares the scale with an empty bowl — the OLED shows "Taring…" and then "Ready to feed". Confirm the bowl weight reads near zero grams.

To calibrate CALIBRATION_FACTOR:

  1. Place a known weight (for example, 100 g) in the bowl.
  2. Note the raw units printed to Serial Monitor.
  3. Calculate: CALIBRATION_FACTOR = raw_units / known_grams.
  4. Update the constant near the top of the code and re-flash.

The default value is 420.0. Most load cells will need a different value. After re-flashing, place the same known weight back in the bowl and confirm the OLED reads close to the expected gram value. To adjust portion size, change SERVO_OPEN_DEG (default 90°) or GATE_OPEN_MS (default 600 ms). Reduce COOLDOWN_MS during testing, then restore it to 300000 (five minutes) for normal use.

Troubleshooting

  • Scale reads random or noisy values. Check that HX711 is powered from 3.3 V not 5 V. Keep HX711 signal wires away from the servo PWM wire. Re-tare after repositioning the load cell or bowl.
  • Servo does not move or makes a whining noise. Confirm the servo is powered from an external 5 V supply, not an ESP32 pin. Check that GND is shared between the servo supply and the ESP32.
  • SSD1306 OLED display stays blank. The default I2C address is 0x3C. If wiring looks correct, check whether your module is 0x3D and update the begin() call in the firmware.
  • Button press does nothing. GPIO 27 uses INPUT_PULLUP. Confirm the button shorts GPIO 27 to GND when pressed. The firmware uses a 50 ms debounce window; noise on long button wires can interfere.
  • Dispenser opens but bowl never registers as full. CALIBRATION_FACTOR is wrong. Calibrate the scale as described above and confirm the gram reading is accurate before adjusting BOWL_FULL_THRESHOLD_G.
  • ESP32 resets when servo moves. The servo inrush is pulling too much current. Confirm the servo VCC is connected to the external supply, not a pin on the ESP32.

Going further

Once manual dispense and bowl-full detection are reliable, scheduled feeding windows are a straightforward addition: store a feed time list and check localtime() on each loop iteration. For remote monitoring, add Wi-Fi and log the bowl weight and dispense count to a simple endpoint or MQTT broker. The CALIBRATION_FACTOR, COOLDOWN_MS, and BOWL_FULL_THRESHOLD_G constants are good candidates to make editable over Wi-Fi so you can tune portion size without re-flashing.

Wiring diagram

Loading diagram…
Interactive wiring diagram

Components needed

Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.

Assembly

1

Wire the HX711 load cell amplifier

Connect the HX711 VCC to the ESP32 3.3 V rail and GND to the common ground rail. Run DOUT to GPIO32 and SCK to GPIO33. Then connect the load cell wires to the HX711 cell terminals: load cell E+ and E- to the HX711 excitation pins, and A+ and A- to the signal pins (follow your specific load cell's colour code).

2

Wire the servo with its own 5 V supply

Connect the SG90 servo signal wire to GPIO18. Connect the servo VCC (red wire) to the 5 V output of the external 5 V supply, NOT to the ESP32 3.3 V pin. Connect the servo GND (brown/black wire) and the supply GND to the common ground rail shared with the ESP32.

3

Wire the SSD1306 OLED display

Connect the OLED VCC to the ESP32 3.3 V rail and GND to the common ground rail. Connect SDA to GPIO21 and SCL to GPIO22.

4

Wire the manual dispense button

Connect one terminal of the button to GPIO27 and the other terminal to GND. The firmware uses INPUT_PULLUP so no external resistor is needed.

5

Wire the piezo buzzer

Connect the piezo buzzer positive (longer) lead to GPIO26 and the negative lead to GND.

6

Upload the firmware and calibrate the scale

Flash the firmware from Schematik and open Serial Monitor at 115200 baud. On boot the firmware tares the scale with an empty bowl. To calibrate: place a known weight (e.g. 100 g) in the bowl, note the raw units printed in the serial output, then calculate CALIBRATION_FACTOR = raw_units / known_grams. Update the constant near the top of the code and re-flash.

7

Test dispense and cooldown

Press the manual dispense button. The OLED should show the gate opening, the servo should pulse open for about 600 ms, a short chirp confirms the dispense, and the display switches to showing the cooldown countdown. If the bowl weight meets or exceeds BOWL_FULL_THRESHOLD_G (default 20 g), the dispenser skips the cycle and gives two chirps instead. After COOLDOWN_MS (default 5 minutes) the display shows 'Ready to feed'.

Pin assignments

PinConnectionType
3V3servo VCCPOWER
GNDservo GNDGROUND
GPIO 18servo PWMPWM
3V3hx711 VCCPOWER
GNDhx711 GNDGROUND
GPIO 32hx711 DOUTDATA
GPIO 33hx711 SCKDIGITAL
3V3hx711 E+ Load cell E+POWER
GNDhx711 E- Load cell E-GROUND
VCChx711 A+ Load cell A+ANALOG
GNDhx711 A- Load cell A-ANALOG
3V3ssd1306-oled VCCPOWER
GNDssd1306-oled GNDGROUND
GPIO 21ssd1306-oled SDAI2C
GPIO 22ssd1306-oled SCLI2C
GPIO 27button SIGNALDIGITAL
GNDbutton GNDGROUND
GPIO 26buzzer SIGNALPWM
GNDbuzzer GNDGROUND
5Vpower-supply 5V_OUTPOWER
GNDpower-supply GNDGROUND

Code

Arduino C++
/*
 * ESP32 Pet Treat Dispenser
 *
 * Hardware:
 *   - HX711 load cell amplifier  → GPIO32 (DOUT), GPIO33 (SCK)
 *   - SG90 servo (gate)          → GPIO18 (PWM)
 *   - SSD1306 OLED 128×64        → GPIO21 (SDA), GPIO22 (SCL)
 *   - Manual dispense button     → GPIO27 (active-low, INPUT_PULLUP)
 *   - Piezo buzzer               → GPIO26 (PWM)
 *
 * Libraries: bogde/HX711, ESP32Servo, Adafruit SSD1306, Adafruit GFX
 *
 * Power note: servo VCC → external 5 V supply; GND shared with ESP32.
 */

#include <Arduino.h>
#include <Wire.h>
#include <HX711.h>
#include <ESP32Servo.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// ── Pin definitions ────────────────────────────────────────────────────────────
#define HX711_DOUT_PIN  32
#define HX711_SCK_PIN   33
#define SERVO_PIN       18
#define BUTTON_PIN      27
#define BUZZER_PIN      26
#define SDA_PIN         21
#define SCL_PIN         22

// ── Calibration & tuning ───────────────────────────────────────────────────────
// CALIBRATION_FACTOR: divide raw HX711 units by this value to get grams.
// How to calibrate:
//   1. Tare with an empty bowl (power cycle or hold button 3 s).
//   2. Place a known weight (e.g. 100 g) in the bowl.
//   3. Open Serial Monitor at 115200. The firmware prints raw units.
//   4. CALIBRATION_FACTOR = raw_units / known_grams.
//   5. Update the value below and re-flash.
const float CALIBRATION_FACTOR     = 420.0f;

// Grams of treats the servo should drop per dispense cycle.
const float TARGET_DISPENSE_GRAMS  = 10.0f;

// If the bowl already holds at least this many grams, skip dispensing.
const float BOWL_FULL_THRESHOLD_G  = 20.0f;

// Minimum time between dispensing cycles (ms). Prevents rapid re-feeding.
// Default: 5 minutes.
const unsigned long COOLDOWN_MS    = 5UL * 60UL * 1000UL;

// Servo angle when the gate is closed and open.
const int SERVO_CLOSED_DEG  = 0;
const int SERVO_OPEN_DEG    = 90;

// How long the servo stays open for one dispense pulse (ms).
const unsigned long GATE_OPEN_MS   = 600UL;

// ── OLED ───────────────────────────────────────────────────────────────────────
#define OLED_WIDTH  128
#define OLED_HEIGHT  64
#define OLED_ADDR   0x3C

// ── Globals ───────────────────────────────────────────────────────────────────
HX711           scale;
Servo           gateServo;
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);

// State machine
unsigned long lastDispenseMs  = 0;    // millis() at the last successful dispense
unsigned long gateOpenedMs    = 0;    // millis() when gate was opened
bool          gateOpen        = false;
float         bowlWeightG     = 0.0f;
bool          oledAvailable   = false;
bool          scaleAvailable  = false;

// Button debounce
bool          lastButtonState = HIGH;
unsigned long lastDebounceMs  = 0;
const unsigned long DEBOUNCE_MS = 50;

// ── Forward declarations ───────────────────────────────────────────────────────
void     updateDisplay();
void     startDispense();
void     closeGate();
void     chirpBuzzer(int times);
float    readBowlWeight();
void     serialStatus();

// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
    Serial.begin(115200);
    delay(200);
    Serial.println("=== ESP32 Pet Treat Dispenser ===");

    // I²C for OLED
    Wire.begin(SDA_PIN, SCL_PIN);

    // OLED init
    if (display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
        oledAvailable = true;
        display.clearDisplay();
        display.setTextColor(SSD1306_WHITE);
        display.setTextSize(1);
        display.setCursor(0, 0);
        display.println("Pet Treat Dispenser");
        display.println("Initialising...");
        display.display();
        Serial.println("OLED OK");
    } else {
        Serial.println("WARN: SSD1306 not found — check SDA/SCL on GPIO21/22");
    }

    // HX711 load cell
    scale.begin(HX711_DOUT_PIN, HX711_SCK_PIN);
    if (scale.is_ready()) {
        scale.set_scale(CALIBRATION_FACTOR);
        scale.tare();            // zero the scale with empty bowl
        scaleAvailable = true;
        Serial.println("HX711 OK — tared");
    } else {
        Serial.println("WARN: HX711 not ready — check wiring on GPIO32/33");
    }

    // Servo
    gateServo.attach(SERVO_PIN);
    gateServo.write(SERVO_CLOSED_DEG);
    Serial.println("Servo OK — gate closed");

    // Button
    pinMode(BUTTON_PIN, INPUT_PULLUP);

    // Buzzer
    pinMode(BUZZER_PIN, OUTPUT);
    digitalWrite(BUZZER_PIN, LOW);

    // Brief startup chirp
    chirpBuzzer(1);

    updateDisplay();
    Serial.println("Ready. Press button to dispense.");
}

// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
    unsigned long now = millis();

    // ── 1. Close gate after GATE_OPEN_MS ──────────────────────────────────────
    if (gateOpen && (now - gateOpenedMs >= GATE_OPEN_MS)) {
        closeGate();
    }

    // ── 2. Read scale at moderate rate (every 200 ms) ─────────────────────────
    static unsigned long lastScaleRead = 0;
    if (scaleAvailable && (now - lastScaleRead >= 200)) {
        lastScaleRead = now;
        bowlWeightG = readBowlWeight();
    }

    // ── 3. Button debounce ────────────────────────────────────────────────────
    bool rawBtn = digitalRead(BUTTON_PIN);
    if (rawBtn != lastButtonState) {
        lastDebounceMs = now;
    }
    lastButtonState = rawBtn;

    if ((now - lastDebounceMs >= DEBOUNCE_MS) && rawBtn == LOW) {
        // Rising-edge detected after debounce — trigger dispense
        lastDebounceMs = now + 500;   // prevent immediate re-trigger

        Serial.println("Button pressed");

        if (gateOpen) {
            Serial.println("Gate already open — ignoring");
        } else if (now - lastDispenseMs < COOLDOWN_MS && lastDispenseMs != 0) {
            unsigned long remaining = (COOLDOWN_MS - (now - lastDispenseMs)) / 1000;
            Serial.printf("Cooldown active — %lu s remaining\n", remaining);
            chirpBuzzer(2);   // double-chirp = denied
            updateDisplay();
        } else if (scaleAvailable && bowlWeightG >= BOWL_FULL_THRESHOLD_G) {
            Serial.printf("Bowl full (%.1f g) — skipping dispense\n", bowlWeightG);
            chirpBuzzer(2);
            updateDisplay();
        } else {
            startDispense();
        }
    }

    // ── 4. Refresh OLED every second ──────────────────────────────────────────
    static unsigned long lastDisplayUpdate = 0;
    if (now - lastDisplayUpdate >= 1000) {
        lastDisplayUpdate = now;
        updateDisplay();
        serialStatus();
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

float readBowlWeight() {
    if (!scale.is_ready()) return bowlWeightG;  // return last known value
    float w = scale.get_units(3);               // average 3 readings
    if (w < 0) w = 0;
    return w;
}

void startDispense() {
    Serial.println("Dispensing...");
    gateServo.write(SERVO_OPEN_DEG);
    gateOpen      = true;
    gateOpenedMs  = millis();
    lastDispenseMs = millis();
    chirpBuzzer(1);
    updateDisplay();
}

void closeGate() {
    gateServo.write(SERVO_CLOSED_DEG);
    gateOpen = false;
    Serial.println("Gate closed");
    updateDisplay();
}

void chirpBuzzer(int times) {
    for (int i = 0; i < times; i++) {
        digitalWrite(BUZZER_PIN, HIGH);
        delay(80);
        digitalWrite(BUZZER_PIN, LOW);
        if (i < times - 1) delay(120);
    }
}

void updateDisplay() {
    if (!oledAvailable) return;

    unsigned long now = millis();
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);

    // ── Header ────────────────────────────────────────────────────────────────
    display.setCursor(0, 0);
    display.println("  Pet Treat Dispenser");
    display.drawLine(0, 10, OLED_WIDTH - 1, 10, SSD1306_WHITE);

    // ── Bowl weight ───────────────────────────────────────────────────────────
    display.setCursor(0, 14);
    if (scaleAvailable) {
        display.printf("Bowl: %.1f g", bowlWeightG);
        if (bowlWeightG >= BOWL_FULL_THRESHOLD_G) {
            display.setCursor(0, 24);
            display.println("  [Bowl full]");
        }
    } else {
        display.println("Bowl: -- (no sensor)");
    }

    // ── Gate status ───────────────────────────────────────────────────────────
    display.setCursor(0, 36);
    display.print("Gate: ");
    display.println(gateOpen ? "OPEN" : "closed");

    // ── Cooldown / last feed ──────────────────────────────────────────────────
    display.setCursor(0, 48);
    if (lastDispenseMs == 0) {
        display.println("Last feed: --");
    } else if (now - lastDispenseMs < COOLDOWN_MS) {
        unsigned long remainSec = (COOLDOWN_MS - (now - lastDispenseMs)) / 1000;
        if (remainSec >= 60) {
            display.printf("Cooldown: %lu m", remainSec / 60);
        } else {
            display.printf("Cooldown: %lu s", remainSec);
        }
    } else {
        display.println("Ready to feed");
    }

    display.display();
}

void serialStatus() {
    unsigned long now = millis();
    Serial.printf("[%.1f s] Bowl: %.1f g | Gate: %s | Cooldown: ",
        now / 1000.0f,
        bowlWeightG,
        gateOpen ? "OPEN" : "closed");

    if (lastDispenseMs == 0) {
        Serial.println("none");
    } else if (now - lastDispenseMs < COOLDOWN_MS) {
        Serial.printf("%lu s remaining\n",
            (COOLDOWN_MS - (now - lastDispenseMs)) / 1000);
    } else {
        Serial.println("ready");
    }
}

// Run this and build other cool things at schematik.io
Libraries: HX711, ESP32Servo, Adafruit SSD1306, Adafruit GFX Library