How to Build an ESP32 Pet Treat Dispenser
Servo-fed treats with a measured bowl, OLED status, and manual dispense button
Updated

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:
- Place a known weight (for example, 100 g) in the bowl.
- Note the raw units printed to Serial Monitor.
- Calculate:
CALIBRATION_FACTOR = raw_units / known_grams. - 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_FACTORis wrong. Calibrate the scale as described above and confirm the gram reading is accurate before adjustingBOWL_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
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| ESP32 DevKit v1 | board | 1 | INR 385.00 |
| Waveshare SG90 Servo | actuator | 1 | $1.69 |
| HX711 Load Cell Amplifier | sensor | 1 | €1.65 |
| Weight Sensor (Load Cell) 0-8kg | sensor | 1 | $9.95 |
| Grove OLED Display 0.66" (SSD1306) | display | 1 | $5.50 |
| 16mm Panel Mount Momentary Pushbutton - Yellow | other | 1 | $0.95 |
| Piezo Buzzer | actuator | 1 | $1.50 |
| 5V 2A Power Supply w/ 20AWG 6' MicroUSB Cable - International | other | 1 | $14.95 |
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
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).
- Most 4-wire load cells use red (E+), black (E-), white (A+), and green (A-). Verify with your datasheet.
- Keep HX711 signal wires away from servo and buzzer wires to reduce noise.
- Do not power the HX711 from 5 V — it runs on 3.3 V and so does the ESP32 GPIO input.
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.
- A 500 mA–1 A USB power bank or 5 V wall adapter works well for bench testing.
- Twist the servo signal wire with a short GND wire to reduce PWM noise coupling.
- Never power the servo from the ESP32 3.3 V or 5 V pin — the inrush current when the gate moves can brownout the ESP32.
- Always share ground between the servo supply and the ESP32.
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.
- The default I²C address is 0x3C — check your module's label or silk screen if the display stays blank.
- Short I²C wires (under 20 cm) work reliably without pull-up resistors on most modules, which have them built in.
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.
- A momentary tactile push button works perfectly here.
- Keep button wires reasonably short to avoid picking up servo PWM noise.
Wire the piezo buzzer
Connect the piezo buzzer positive (longer) lead to GPIO26 and the negative lead to GND.
- A passive piezo buzzer driven directly from the GPIO pin is fine here — the firmware toggles the pin directly.
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.
- Let the bowl sit still for a few seconds before reading — the HX711 averages 3 samples per reading.
- Re-tare after repositioning the load cell or bowl.
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'.
- Adjust GATE_OPEN_MS and SERVO_OPEN_DEG in the code to tune how much comes out per press.
- Reduce COOLDOWN_MS during testing; restore it to 5 minutes (300000) for normal use.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | servo VCC | POWER |
| GND | servo GND | GROUND |
| GPIO 18 | servo PWM | PWM |
| 3V3 | hx711 VCC | POWER |
| GND | hx711 GND | GROUND |
| GPIO 32 | hx711 DOUT | DATA |
| GPIO 33 | hx711 SCK | DIGITAL |
| 3V3 | hx711 E+ → Load cell E+ | POWER |
| GND | hx711 E- → Load cell E- | GROUND |
| VCC | hx711 A+ → Load cell A+ | ANALOG |
| GND | hx711 A- → Load cell A- | ANALOG |
| 3V3 | ssd1306-oled VCC | POWER |
| GND | ssd1306-oled GND | GROUND |
| GPIO 21 | ssd1306-oled SDA | I2C |
| GPIO 22 | ssd1306-oled SCL | I2C |
| GPIO 27 | button SIGNAL | DIGITAL |
| GND | button GND | GROUND |
| GPIO 26 | buzzer SIGNAL | PWM |
| GND | buzzer GND | GROUND |
| 5V | power-supply 5V_OUT | POWER |
| GND | power-supply GND | GROUND |
Code
/*
* 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.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the ESP32 Pet Treat Dispenser.
Open in Schematik →Related guides
How to Build a ClawdBot Tamagotchi with ESP32
ESP32 · Beginner
How to Build a Cosmic Critter Pet with Raspberry Pi Pico
Raspberry Pi Pico · Beginner
How to Build a Plant Disco Guardian with ESP32
ESP32 · Beginner
How to Build an ESP32 Air Quality Monitor with BME280
ESP32 · Beginner