July 11, 2025
Description
— Cheap, Functional, and Done
by rospakos
*(Engineer, not a linguist — most of this was written with ChatGPT)
A fully 3D-printed automatic fish feeder that reliably drops food every set number of hours and minutes — powered by a €2 stepper motor and an ESP8266.
It rotates a tray with 31 slots (good for a full month of feeding) using 66 steps per rotation, controlled from a simple web interface.
🧪 100% functional. 0% fancy.
I clipped it to my tank, filled it using a funnel I found on MakerWorld (not mine, but it fits the tray slots perfectly), LINK and it’s been running for weeks without issues.
🕹️ Fully adjustable feeding interval (hours & minutes)
🔄 31-slot tray, rotates 66 steps per feed
📶 Wi-Fi hotspot interface — no app, no internet
📱 Mobile control panel
⏩ Manual step buttons (1, 5, 10, 20 forward/backward)
📊 Status monitor:
→ Last fed time
→ Time since last feed
→ Time until next feed
🔥 Auto coil shutdown after 3s (reduces heating)
♻️ Reset button — returns to default (24h, 66 steps)
⚡ Power-loss safe — auto-starts feeding every 24h on reboot
Because the cheapest commercial feeder that could do something similar cost over €50, and I had an ESP and stepper motor lying around.
Also — I like functional ugly things that just do the job.
This isn’t perfect. But it worked on the first try, and I didn’t feel like improving it because… well, it just works.
I’ll probably never touch it again — unless someone messages me for help, then I might actually do something.
The feeder has a rotating tray with 31 food slots and 2mm walls between them. The tray fits directly onto the stepper shaft with a tight fit, and the rest of the structure clips onto your aquarium edge.
Everything was printed on a Bambu A1 Mini using PLA. No supports, no fancy materials — just basic, fast prints.
Some remarks:
No screws. No unnecessary hardware. Just clever tolerances and a drop of glue on the feet (okay, you got me).
🧠 ESP8266 NodeMCU V3
⚙️ 28BYJ-48 stepper + ULN2003 driver
🔌 5V power supply (≥500mA)
🖨️ 3D-printed parts:
→ Tray, carousel, mount, lid
→ (Optional) Funnel from another MakerWorld user (linked it — works great)
💻 Arduino IDE with ESP libraries
⚠️ Electronics box?
Right now it’s all open and wired — it works.
I’ll probably print a small box someday… but I’m an engineer, and this is functional enough for now 😅
| ESP8266 Pin | ULN2003 IN# |
|---|---|
| D1 | IN1 |
| D2 | IN3 |
| D3 | IN2 |
| D4 | IN4 |
(Matching this wiring ensures smooth stepping.)
I also connected the Vcc and Ground from the motor driver to the VV and G of the ESP. Common ground is important, ask me how I know :D
Your ESP boots as a Wi-Fi hotspot:
From there, you can:
🐟 Feed Now
▶️ Start Feeding
⏹️ Stop Feeding
⏩ Manual Step Control (fwd/back: 1–20 steps)
🕒 Set Feed Interval (in hours & minutes)
🔁 Reset Feeder
📊 View Live Status:
It reboots into default mode:
✔️ Starts a new countdown to feed in 24h
✔️ Keeps feeding every 24h after that
So your fish won’t be left hungry.
📦 Libraries Needed (Arduino IDE):
🛠️ Setup Steps:
It’s split into two parts:
Yes, ChatGPT helped make it cleaner — but the logic, design, and testing are mine.
If you have a different glass thickness, motor, or idea — remix it or message me.
I probably won’t upgrade this unless someone asks.
But if this saves your fish from starvation while you're on holiday, I’d call that a win.
❤️ Drop a like if it helps.
🔁 Remix it if you want.
🔌 Engineer, not artist — but hey, it works.
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <Stepper.h>
#include <FS.h>
// Stepper setup (28BYJ-48 with ULN2003)
const int stepsPerRevolution = 2048;
Stepper myStepper(stepsPerRevolution, D1, D3, D2, D4); // IN1 IN2 IN3 IN4
// Feeder settings (defaults)
int stepCount = 66;
int intervalHours = 24;
int intervalMinutes = 0;
bool active = true; // auto-start after power-up
unsigned long lastFeedTime = 0; // millis() of last feed completion
unsigned long intervalMillis = 24UL * 3600000UL;
// Physical feed button
const int feedButtonPin = D5; // wire button between D5 and GND
const unsigned long buttonDebounceMs = 300;
// Coil auto-disable
unsigned long lastMoveTime = 0;
const unsigned long coilTimeout = 3000; // 3 seconds
bool coilsActive = false;
// Feeding flag
bool feeding = false;
ESP8266WebServer server(80);
// ----- Helper: enable/disable coils -----
void enableStepper() {
coilsActive = true;
lastMoveTime = millis();
}
void disableStepper() {
digitalWrite(D1, LOW);
digitalWrite(D2, LOW);
digitalWrite(D3, LOW);
digitalWrite(D4, LOW);
coilsActive = false;
}
// ----- Vibration / jerk trick -----
void vibrationTrick() {
delay(500); // wait before starting
myStepper.setSpeed(3);
for (int i = 0; i < 3; i++) {
myStepper.step(20); // forward
delay(500);
myStepper.step(-40); // backward
delay(500);
myStepper.step(20); // forward
delay(500);
}
}
// ----- Feed action -----
void doFeed() {
if (feeding) return; // prevent re-entry
feeding = true;
enableStepper();
myStepper.setSpeed(3);
myStepper.step(stepCount);
vibrationTrick();
lastMoveTime = millis();
feeding = false;
}
// ----- Web endpoints -----
void handleRoot() {
File f = SPIFFS.open("/index.html", "r");
if (!f) {
server.send(500, "text/plain", "Missing index.html");
return;
}
server.streamFile(f, "text/html");
f.close();
}
void handleFeed() {
doFeed(); // feed once
lastFeedTime = millis();
server.send(200, "text/plain", "Fed");
}
void handleStart() {
active = true;
lastFeedTime = millis();
server.send(200, "text/plain", "Started");
}
void handleStop() {
active = false;
server.send(200, "text/plain", "Stopped");
}
void handleStep() {
String dir = server.arg("dir");
int count = server.hasArg("count") ? server.arg("count").toInt() : 1;
if (count < 1 || count > 200) count = 1;
enableStepper();
myStepper.setSpeed(3);
myStepper.step((dir == "fwd" ? 1 : -1) * count);
lastMoveTime = millis();
server.send(200, "text/plain", "Stepped " + String(count));
}
void handleSet() {
if (server.hasArg("intervalH")) intervalHours = server.arg("intervalH").toInt();
if (server.hasArg("intervalM")) intervalMinutes = server.arg("intervalM").toInt();
if (server.hasArg("steps")) stepCount = server.arg("steps").toInt();
intervalMillis = (unsigned long)intervalHours * 3600000UL + (unsigned long)intervalMinutes * 60000UL;
server.send(200, "text/plain", "Settings updated");
}
void handleReset() {
intervalHours = 24;
intervalMinutes = 0;
stepCount = 66;
intervalMillis = 24UL * 3600000UL;
active = true;
lastFeedTime = millis();
server.send(200, "text/plain", "Reset to defaults");
}
void handleStatus() {
unsigned long now = millis();
if (lastFeedTime == 0) lastFeedTime = now;
unsigned long elapsedSec = (now - lastFeedTime) / 1000;
unsigned long nextFeedSec = 0;
if (active) {
unsigned long since = now - lastFeedTime;
if (intervalMillis > since) nextFeedSec = (intervalMillis - since) / 1000;
else nextFeedSec = 0;
}
String json = "{";
json += "\"active\":" + String(active ? "true" : "false") + ",";
json += "\"sinceSeconds\":" + String(elapsedSec) + ",";
json += "\"nextSeconds\":" + String(nextFeedSec) + ",";
json += "\"intervalH\":" + String(intervalHours) + ",";
json += "\"intervalM\":" + String(intervalMinutes) + ",";
json += "\"steps\":" + String(stepCount);
json += "}";
server.send(200, "application/json", json);
}
// ----- WiFi AP -----
void setupWiFi() {
WiFi.mode(WIFI_AP);
WiFi.softAP("FishFeeder", "fishfeed123");
Serial.println("WiFi started. Connect to: http://192.168.4.1");
}
// ----- setup -----
void setup() {
Serial.begin(115200);
setupWiFi();
if (!SPIFFS.begin()) {
Serial.println("SPIFFS mount failed");
}
pinMode(D1, OUTPUT);
pinMode(D2, OUTPUT);
pinMode(D3, OUTPUT);
pinMode(D4, OUTPUT);
pinMode(feedButtonPin, INPUT_PULLUP);
disableStepper();
server.on("/", handleRoot);
server.on("/feed", handleFeed);
server.on("/start", handleStart);
server.on("/stop", handleStop);
server.on("/step", handleStep);
server.on("/set", handleSet);
server.on("/reset", handleReset);
server.on("/status.json", handleStatus);
server.begin();
Serial.println("Server started");
if (lastFeedTime == 0) lastFeedTime = millis();
intervalMillis = (unsigned long)intervalHours * 3600000UL + (unsigned long)intervalMinutes * 60000UL;
}
// ----- Physical button handler -----
void handlePhysicalButton() {
static bool lastButtonState = HIGH;
static unsigned long lastPressTime = 0;
bool currentState = digitalRead(feedButtonPin);
unsigned long now = millis();
// Falling edge with debounce
if (lastButtonState == HIGH && currentState == LOW && (now - lastPressTime) > buttonDebounceMs) {
lastPressTime = now;
Serial.println("Physical button pressed -> triggering UI feed");
handleFeed(); // Call the exact same function as the web button
}
lastButtonState = currentState;
}
// ----- main loop -----
void loop() {
server.handleClient();
// Check physical button
handlePhysicalButton();
unsigned long now = millis();
// Automatic scheduled feeding
if (active && !feeding && (now - lastFeedTime >= intervalMillis)) {
doFeed();
lastFeedTime = millis();
}
// Auto-disable coils
if (coilsActive && (now - lastMoveTime > coilTimeout)) {
disableStepper();
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fish Feeder</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 12px; background: #f8fafc; color: #111; }
h1 { margin-bottom: 6px; }
button { display:block; margin:8px auto; padding:12px 20px; font-size:16px; background:#0b78d1; color:#fff; border:none; border-radius:8px; cursor:pointer; max-width:300px; width:90%; }
.row-buttons { display:flex; justify-content:center; gap:8px; flex-wrap:wrap; margin:8px 0; }
.row-buttons button { flex:1 1 40%; min-width:80px; }
input { padding:8px; margin:5px; font-size:15px; width:110px; }
.status { margin-top:18px; font-size:15px; }
.small { font-size:12px; color:#555; }
</style>
</head>
<body>
<h1>🐟 Fish Feeder</h1>
<button onclick="fetchAction('/feed', 'Fed')">Feed Now</button>
<button onclick="fetchAction('/start', 'Auto feeding started')">Start Feeding</button>
<button onclick="fetchAction('/stop', 'Auto feeding stopped')">Stop Feeding</button>
<button onclick="fetchAction('/reset', 'Reset to defaults')">Reset Feeder</button>
<h3>Step Backward</h3>
<div class="row-buttons">
<button onclick="step('rev',1)">⬅️ 1</button>
<button onclick="step('rev',5)">⬅️ 5</button>
<button onclick="step('rev',10)">⬅️10</button>
<button onclick="step('rev',20)">⬅️20</button>
</div>
<h3>Step Forward</h3>
<div class="row-buttons">
<button onclick="step('fwd',1)">➡️ 1</button>
<button onclick="step('fwd',5)">➡️ 5</button>
<button onclick="step('fwd',10)">➡️10</button>
<button onclick="step('fwd',20)">➡️20</button>
</div>
<h3>Settings</h3>
<div>
<input id="intervalH" type="number" placeholder="Hours" min="0" />
<input id="intervalM" type="number" placeholder="Minutes" min="0" max="59" />
</div>
<div>
<input id="steps" type="number" placeholder="Steps" min="1" />
<button onclick="setValues()">Set</button>
</div>
<div class="status" id="status">Loading status...</div>
<script>
function fetchAction(url, msg) {
fetch(url).then(r => r.text()).then(() => { alert(msg); updateStatus(); }).catch(()=>alert('Error contacting feeder'));
}
function step(dir,count) {
fetch(`/step?dir=${dir}&count=${count}`)
.then(()=>{ alert('Stepped'); updateStatus(); })
.catch(()=>alert('Step failed'));
}
function setValues() {
const h = document.getElementById('intervalH').value || 0;
const m = document.getElementById('intervalM').value || 0;
const s = document.getElementById('steps').value || 66;
fetch(`/set?intervalH=${h}&intervalM=${m}&steps=${s}`)
.then(()=>{ alert('Settings updated'); updateStatus(); })
.catch(()=>alert('Failed to update settings'));
}
function formatHHMMSS(totalSeconds) {
if (isNaN(totalSeconds) || totalSeconds < 0) totalSeconds = 0;
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function updateStatus() {
fetch('/status.json')
.then(res => res.json())
.then(data => {
const since = formatHHMMSS(data.sinceSeconds);
const next = formatHHMMSS(data.nextSeconds);
document.getElementById('status').innerHTML = `
<b>Status:</b> ${data.active ? '🟢 Active' : '🔴 Stopped'}<br>
<b>Since last feed:</b> ${since} ago<br>
<b>Next feed in:</b> ${next}<br>
<b>Interval:</b> ${data.intervalH}h ${data.intervalM}m<br>
<b>Step count:</b> ${data.steps}
`;
})
.catch(()=>{ document.getElementById('status').innerText = 'Failed to load status'; });
}
updateStatus();
setInterval(updateStatus, 8000);
</script>
</body>
</html>
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <Stepper.h>
#include <FS.h>
// Stepper setup
const int stepsPerRevolution = 2048;
Stepper myStepper(stepsPerRevolution, D1, D3, D2, D4); // IN1 IN2 IN3 IN4
// Feeder settings
int stepCount = 66;
int intervalHours = 24;
int intervalMinutes = 0;
bool active = false;
unsigned long lastFeedTime = 0;
unsigned long intervalMillis = 24UL * 3600000UL;
unsigned long lastStepTime = 0;
bool stepperEnabled = false;
ESP8266WebServer server(80);
// Feed the fish
void doFeed() {
if (!active) return;
enableStepper();
myStepper.setSpeed(3); // ~2s for 66 steps
myStepper.step(stepCount);
lastFeedTime = millis();
lastStepTime = millis();
}
// Enable motor coils
void enableStepper() {
stepperEnabled = true;
lastStepTime = millis();
}
// Disable coils by setting outputs to LOW
void disableStepper() {
digitalWrite(D1, LOW);
digitalWrite(D2, LOW);
digitalWrite(D3, LOW);
digitalWrite(D4, LOW);
stepperEnabled = false;
}
// Convert seconds to hh:mm:ss string
String formatTime(unsigned long seconds) {
int h = seconds / 3600;
int m = (seconds % 3600) / 60;
int s = seconds % 60;
char buf[16];
sprintf(buf, "%02d:%02d:%02d", h, m, s);
return String(buf);
}
// Web handlers
void handleRoot() {
File f = SPIFFS.open("/index.html", "r");
if (!f) {
server.send(500, "text/plain", "Missing index.html");
return;
}
server.streamFile(f, "text/html");
f.close();
}
void handleFeed() {
doFeed();
server.send(200, "text/plain", "Fed");
}
void handleStart() {
active = true;
lastFeedTime = millis();
server.send(200, "text/plain", "Started");
}
void handleStop() {
active = false;
server.send(200, "text/plain", "Stopped");
}
void handleStep() {
String dir = server.arg("dir");
int count = server.hasArg("count") ? server.arg("count").toInt() : 1;
if (count < 1 || count > 200) count = 1;
enableStepper();
myStepper.setSpeed(3);
myStepper.step((dir == "fwd" ? 1 : -1) * count);
lastStepTime = millis();
server.send(200, "text/plain", "Stepped " + String(count));
}
void handleSet() {
if (server.hasArg("intervalH")) intervalHours = server.arg("intervalH").toInt();
if (server.hasArg("intervalM")) intervalMinutes = server.arg("intervalM").toInt();
if (server.hasArg("steps")) stepCount = server.arg("steps").toInt();
intervalMillis = (intervalHours * 3600000UL) + (intervalMinutes * 60000UL);
server.send(200, "text/plain", "Settings updated");
}
void handleReset() {
intervalHours = 24;
intervalMinutes = 0;
stepCount = 66;
intervalMillis = 24UL * 3600000UL;
lastFeedTime = millis();
server.send(200, "text/plain", "Feeder reset");
}
void handleStatus() {
unsigned long now = millis();
unsigned long sinceFeed = (now - lastFeedTime) / 1000;
unsigned long nextFeedIn = active ? (intervalMillis > (now - lastFeedTime) ? (intervalMillis - (now - lastFeedTime)) / 1000 : 0) : 0;
time_t lastFeedUnix = (now / 1000) - sinceFeed; // Approximate
String json = "{";
json += "\"active\":" + String(active ? "true" : "false") + ",";
json += "\"lastFeedUnix\":" + String(lastFeedUnix) + ",";
json += "\"sinceFeed\":" + String(sinceFeed) + ",";
json += "\"nextFeedSeconds\":" + String(nextFeedIn) + ",";
json += "\"intervalHours\":" + String(intervalHours) + ",";
json += "\"intervalMinutes\":" + String(intervalMinutes) + ",";
json += "\"steps\":" + String(stepCount);
json += "}";
server.send(200, "application/json", json);
}
// Wi-Fi AP mode
void setupWiFi() {
WiFi.mode(WIFI_AP);
WiFi.softAP("FishFeeder", "fishfeed123");
Serial.println("WiFi started. Connect to: http://192.168.4.1");
}
void setup() {
Serial.begin(115200);
setupWiFi();
if (!SPIFFS.begin()) {
Serial.println("SPIFFS mount failed");
return;
}
pinMode(D1, OUTPUT);
pinMode(D2, OUTPUT);
pinMode(D3, OUTPUT);
pinMode(D4, OUTPUT);
disableStepper();
server.on("/", handleRoot);
server.on("/feed", handleFeed);
server.on("/start", handleStart);
server.on("/stop", handleStop);
server.on("/step", handleStep);
server.on("/set", handleSet);
server.on("/reset", handleReset);
server.on("/status.json", handleStatus);
server.begin();
Serial.println("Server started");
// Auto start feeding on boot
active = true;
lastFeedTime = millis();
intervalMillis = (intervalHours * 3600000UL) + (intervalMinutes * 60000UL);
}
void loop() {
server.handleClient();
if (active && millis() - lastFeedTime >= intervalMillis) {
doFeed();
}
if (stepperEnabled && millis() - lastStepTime > 3000) {
disableStepper();
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fish Feeder</title>
<style>
body {
font-family: sans-serif;
text-align: center;
padding: 15px;
background: #f5f5f5;
}
h1 {
margin-bottom: 10px;
}
button {
display: block;
margin: 8px auto;
padding: 14px 24px;
font-size: 18px;
width: 90%;
max-width: 300px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
button:active {
background-color: #0056b3;
}
.row-buttons {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0;
}
.row-buttons button {
flex: 1 1 40%;
min-width: 80px;
}
input {
padding: 8px;
margin: 5px;
font-size: 16px;
width: 100px;
}
.status {
margin-top: 20px;
font-size: 15px;
}
</style>
</head>
<body>
<h1>🐠 Fish Feeder</h1>
<button onclick="action('/feed', '✅ Fed Successfully!')">Feed Now</button>
<button onclick="action('/start', '✅ Feeding Cycle Started')">Start Feeding</button>
<button onclick="action('/stop', '🛑 Feeding Stopped')">Stop Feeding</button>
<button onclick="action('/reset', '🔄 Feeder Reset')">Reset Feeder</button>
<h3>Step Backward</h3>
<div class="row-buttons">
<button onclick="step('rev', 1)">⬅️ 1</button>
<button onclick="step('rev', 5)">⬅️ 5</button>
<button onclick="step('rev', 10)">⬅️ 10</button>
<button onclick="step('rev', 20)">⬅️ 20</button>
</div>
<h3>Step Forward</h3>
<div class="row-buttons">
<button onclick="step('fwd', 1)">➡️ 1</button>
<button onclick="step('fwd', 5)">➡️ 5</button>
<button onclick="step('fwd', 10)">➡️ 10</button>
<button onclick="step('fwd', 20)">➡️ 20</button>
</div>
<h3>Settings</h3>
<input id="intervalH" type="number" placeholder="Hours" />
<input id="intervalM" type="number" placeholder="Minutes" />
<input id="steps" type="number" placeholder="Steps" />
<button onclick="setValues()">Set</button>
<div class="status" id="status">
Loading status...
</div>
<script>
function action(url, msg) {
fetch(url)
.then(r => r.text())
.then(() => {
alert(msg);
updateStatus();
})
.catch(() => alert("⚠️ Error contacting feeder"));
}
function step(dir, count) {
fetch(`/step?dir=${dir}&count=${count}`)
.then(() => {
alert(`✅ Stepped ${dir === 'fwd' ? 'forward' : 'backward'} ${count} steps`);
updateStatus();
})
.catch(() => alert("⚠️ Step command failed"));
}
function setValues() {
const h = document.getElementById("intervalH").value || 0;
const m = document.getElementById("intervalM").value || 0;
const s = document.getElementById("steps").value || 66;
fetch(`/set?intervalH=${h}&intervalM=${m}&steps=${s}`)
.then(() => alert("✅ Settings updated!"))
.catch(() => alert("⚠️ Failed to send settings"));
}
function updateStatus() {
fetch("/status.json")
.then(res => res.json())
.then(data => {
const sinceFeed = formatSeconds(data.sinceFeed);
const nextFeed = formatSeconds(data.nextFeedSeconds);
const lastFeedTime = new Date(data.lastFeedUnix * 1000).toLocaleString();
document.getElementById("status").innerHTML = `
<b>Status:</b> ${data.active ? "🟢 Active" : "🔴 Stopped"}<br>
<b>Last Fed:</b> ${lastFeedTime}<br>
<b>Since Last Feed:</b> ${sinceFeed}<br>
<b>Next Feed In:</b> ${nextFeed}<br>
<b>Interval:</b> ${data.intervalHours}h ${data.intervalMinutes || 0}m<br>
<b>Step Count:</b> ${data.steps}
`;
})
.catch(() => {
document.getElementById("status").innerHTML = "⚠️ Failed to load status";
});
}
function formatSeconds(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
updateStatus();
setInterval(updateStatus, 10000);
</script>
</body>
</html>
License:
MakerWorld Exclusive License