Máy cho cá ăn tự động bằng ESP32: servo SG90, DS3231 giữ giờ khi mất điện, đặt lịch trên web
Làm máy cho cá ăn tự động bằng ESP32: servo SG90 quay đĩa có lỗ, DS3231 giữ giờ khi mất điện, đồng bộ giờ Internet, trang web đặt 4 giờ cho ăn, không cho ăn lặp sau mất điện.
Đi vắng vài ngày mà bể cá không ai cho ăn là lý do nhiều người tự làm máy cho cá ăn. Bài này làm máy cho cá ăn bằng ESP32: servo SG90 quay một đĩa có lỗ dưới hộp đựng cám, DS3231 giữ giờ kể cả khi mất điện hay mất Wi-Fi, một trang web để đặt 4 giờ cho ăn và số phần mỗi lần, cùng nút bấm cho ăn ngay. Chương trình nhớ giờ hẹn nào đã chạy trong ngày, nên mất điện rồi có lại cũng không cho ăn lặp.
1. Cơ cấu cho ăn

- Hộp đựng cám (lọ nhựa, ống nhựa) đặt trên một đĩa tròn có một lỗ. Bên dưới đĩa là tấm đế có cửa ra ở vị trí 110°.
- Servo quay đĩa tới 110°: lỗ trùng cửa ra, cám rơi xuống bể. Quay về 0°: lỗ lệch, cám giữ lại trong hộp. Mỗi lần mở rồi đóng là một phần.
- Lượng mỗi phần tùy cỡ lỗ và loại cám (viên, mảnh). Cân thử 10 phần để biết một phần bao nhiêu gam, rồi chỉnh số phần mỗi lần.
- Đặt máy ở chỗ khô, hơi nước từ bể làm cám ẩm và kẹt; có thể thêm gói hút ẩm vào hộp.
2. Chuẩn bị
- ESP32 DevKit V1 và dây USB, cục sạc USB 5 V từ 1 A
- Servo SG90 (hoặc MG90S bánh răng kim loại cho bền hơn)
- Module DS3231 có pin CR2032
- Nút nhấn; hộp nhựa, đĩa nhựa hoặc mica cắt tròn
- Breadboard và dây cắm
3. Nối dây

- Servo chạy 5 V, lấy từ chân VIN khi ESP32 được cấp bằng cục sạc USB. Tín hiệu điều khiển 3,3 V của ESP32 servo vẫn nhận được.
- Nếu servo giật làm ESP32 khởi động lại, thêm tụ 470 µF giữa 5 V và GND gần servo.
4. Vì sao cần DS3231
ESP32 lấy được giờ từ Internet (NTP), nhưng máy cho ăn phải đúng giờ cả khi mạng nhà mất, router khởi động lại, hay cúp điện cả khu. DS3231 là đồng hồ thời gian thực có pin riêng, sai số khoảng vài giây mỗi tháng nhờ thạch anh bù nhiệt. Chương trình:
- đọc giờ từ DS3231 mỗi giây để chạy lịch;
- mỗi ngày một lần, khi có Wi-Fi, chỉnh DS3231 theo giờ Internet (múi giờ Việt Nam);
- nếu DS3231 báo từng mất nguồn nuôi (cờ OSF, ví dụ hết pin CR2032) mà chưa có giờ Internet thì tạm dừng lịch thay vì cho ăn sai giờ, và trang web hiện "Chưa có giờ đúng".
5. Điều khiển servo

Servo đọc độ rộng xung trong mỗi chu kỳ 20 ms: khoảng 0,5 ms là 0°, 2,4 ms là 180°. Cách tính giống bài servo SG90 trên website. Sau mỗi lần cho ăn, chương trình gửi ledcWrite(CHAN_SERVO, 0) để ngừng phát xung: servo đứng yên, không kêu rè rè, không tốn điện giữ góc.
6. Chương trình
// PYWORLD.VN - Máy cho cá ăn tự động: ESP32 quay servo SG90 theo lịch; giờ lấy từ DS3231 (giữ giờ khi mất điện), đồng bộ qua Internet
// PYWORLD.VN - Servo tín hiệu D26, nguồn 5 V; DS3231 SDA D21, SCL D22; nút "cho ăn ngay" D4 xuống GND; đèn D2 sáng khi đang cho ăn
// PYWORLD.VN - Trang web đặt 4 giờ cho ăn và số phần mỗi lần; mỗi giờ hẹn chỉ chạy một lần trong ngày, khởi động lại không cho ăn lặp
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Preferences.h>
#include <time.h>
#include <stdarg.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const int CHAN_SERVO = 26, CHAN_NUT = 4, CHAN_DEN = 2;
const uint8_t DIA_CHI_DS3231 = 0x68;
const int GOC_DONG = 0, GOC_MO = 110; // góc của đĩa: lỗ đóng / lỗ mở trên cửa ra thức ăn (chỉnh theo hộp)
const uint32_t GIU_MO_MS = 350; // giữ lỗ mở bao lâu cho mỗi phần
const int TRE_TOI_DA_PHUT = 10; // mất điện rồi có lại trong 10 phút sau giờ hẹn vẫn cho ăn
const int SO_LICH = 4, SO_SU_KIEN = 10;
// Các struct khai báo trước mọi hàm.
struct Lich { uint8_t gio, phut, phan; bool bat; };
struct ThoiGian { uint16_t nam; uint8_t thang, ngay, gio, phut, giay; };
struct SuKien { char noiDung[64]; };
Lich lich[SO_LICH] = {{7, 0, 2, true}, {17, 30, 2, true}, {12, 0, 1, false}, {21, 0, 1, false}};
long daChoAn[SO_LICH]; // ngày (yyyymmdd) mà giờ hẹn đó đã chạy
long daBaoLo[SO_LICH]; // ngày đã ghi "bỏ lỡ" cho giờ hẹn đó
WebServer may(80);
Preferences boNho;
SuKien suKien[SO_SU_KIEN];
int soSuKien = 0, viTriSuKien = -1, phanHomNay = 0;
long homNay = 0, ngayDongBo = 0;
ThoiGian bay;
bool gioHopLe = false;
// ---- DS3231 (như bài DS3231) ----
uint8_t sangBcd(uint8_t v) { return ((v / 10) << 4) | (v % 10); }
uint8_t tuBcd(uint8_t v) { return (v >> 4) * 10 + (v & 0x0F); }
bool ghiThanhGhi(uint8_t tg, const uint8_t *du, size_t n) {
Wire.beginTransmission(DIA_CHI_DS3231);
Wire.write(tg);
Wire.write(du, n);
return Wire.endTransmission() == 0;
}
bool docThanhGhi(uint8_t tg, uint8_t *du, size_t n) {
Wire.beginTransmission(DIA_CHI_DS3231);
Wire.write(tg);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(DIA_CHI_DS3231, (uint8_t)n) != n) return false;
for (size_t i = 0; i < n; i++) du[i] = Wire.read();
return true;
}
bool datGio(const struct tm &t) {
uint8_t du[7] = {sangBcd(t.tm_sec), sangBcd(t.tm_min), sangBcd(t.tm_hour), (uint8_t)(t.tm_wday + 1),
sangBcd(t.tm_mday), sangBcd(t.tm_mon + 1), sangBcd(t.tm_year - 100)};
uint8_t trangThai;
if (!ghiThanhGhi(0x00, du, 7) || !docThanhGhi(0x0F, &trangThai, 1)) return false;
trangThai &= ~0x80; // xóa cờ OSF: giờ đã hợp lệ
return ghiThanhGhi(0x0F, &trangThai, 1);
}
// Đọc giờ; trả về false nếu không đọc được hoặc DS3231 từng mất nguồn nuôi (cờ OSF) mà chưa đặt lại giờ.
bool docGio(ThoiGian &tg) {
uint8_t du[7], trangThai;
if (!docThanhGhi(0x00, du, 7) || !docThanhGhi(0x0F, &trangThai, 1)) return false;
tg.giay = tuBcd(du[0] & 0x7F);
tg.phut = tuBcd(du[1] & 0x7F);
tg.gio = tuBcd(du[2] & 0x3F);
tg.ngay = tuBcd(du[4] & 0x3F);
tg.thang = tuBcd(du[5] & 0x1F);
tg.nam = 2000 + tuBcd(du[6]);
return !(trangThai & 0x80);
}
// ---- Sự kiện ----
void ghiSuKien(const char *dang, ...) {
viTriSuKien = (viTriSuKien + 1) % SO_SU_KIEN;
if (soSuKien < SO_SU_KIEN) soSuKien++;
char noiDung[48];
va_list ds;
va_start(ds, dang);
vsnprintf(noiDung, sizeof(noiDung), dang, ds);
va_end(ds);
snprintf(suKien[viTriSuKien].noiDung, sizeof(suKien[0].noiDung), "%02u/%02u %02u:%02u %s", bay.ngay, bay.thang, bay.gio, bay.phut, noiDung);
Serial.println(suKien[viTriSuKien].noiDung);
}
// ---- Servo và cho ăn ----
void datGoc(int goc) { // xung 500-2400 us trong chu kỳ 20 ms (như bài servo SG90)
uint32_t us = 500 + (uint32_t)1900 * constrain(goc, 0, 180) / 180;
ledcWrite(CHAN_SERVO, (uint32_t)((uint64_t)us * 65536 / 20000));
}
void choAn(int phan, const char *nguon) {
digitalWrite(CHAN_DEN, HIGH);
for (int k = 0; k < phan; k++) {
datGoc(GOC_MO);
delay(GIU_MO_MS);
datGoc(GOC_DONG);
delay(GIU_MO_MS + 150);
}
ledcWrite(CHAN_SERVO, 0); // ngừng phát xung: servo nghỉ, không rung, đỡ tốn điện
digitalWrite(CHAN_DEN, LOW);
phanHomNay += phan;
ghiSuKien("Cho an %d phan (%s)", phan, nguon);
}
// Giờ hẹn nào đã tới (trễ không quá TRE_TOI_DA_PHUT) mà hôm nay chưa chạy thì cho ăn.
void kiemTraLich() {
long ngay = bay.nam * 10000L + bay.thang * 100 + bay.ngay;
if (ngay != homNay) {
homNay = ngay;
phanHomNay = 0;
}
int phutNgay = bay.gio * 60 + bay.phut;
for (int i = 0; i < SO_LICH; i++) {
int hen = lich[i].gio * 60 + lich[i].phut;
if (!lich[i].bat || daChoAn[i] == homNay || phutNgay < hen) continue;
if (phutNgay > hen + TRE_TOI_DA_PHUT) { // quá trễ (mất điện lâu): không cho ăn bù, chỉ ghi lại để người nuôi biết
if (daBaoLo[i] != homNay) {
daBaoLo[i] = homNay;
ghiSuKien("Bo lo lich %02u:%02u", lich[i].gio, lich[i].phut);
}
continue;
}
daChoAn[i] = homNay;
char khoa[4] = {'d', (char)('0' + i), 0};
boNho.putLong(khoa, homNay); // ghi trước khi quay: lỡ mất điện giữa chừng cũng không cho ăn lặp
char nguon[16];
snprintf(nguon, sizeof(nguon), "lich %02u:%02u", lich[i].gio, lich[i].phut);
choAn(lich[i].phan, nguon);
}
}
// Mỗi ngày đồng bộ DS3231 với giờ Internet một lần (khi có Wi-Fi).
void dongBoGio() {
struct tm t;
if (WiFi.status() != WL_CONNECTED || !getLocalTime(&t, 0)) return;
long ngay = (t.tm_year + 1900) * 10000L + (t.tm_mon + 1) * 100 + t.tm_mday;
if (ngay == ngayDongBo) return;
if (datGio(t)) {
ngayDongBo = ngay;
gioHopLe = docGio(bay);
ghiSuKien("Dong bo DS3231 voi gio Internet");
}
}
// ---- Trang web ----
void trangChu() {
String s;
s.reserve(3000);
s += F("<!doctype html><html lang='vi'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Cho cá ăn</title><style>body{font-family:sans-serif;margin:16px;background:#f4f6f9}"
"input{font-size:16px;margin:4px}button{font-size:18px;padding:10px 16px}li{margin:4px 0}</style></head><body>"
"<h2>Máy cho cá ăn</h2>");
char dong[120];
if (gioHopLe) snprintf(dong, sizeof(dong), "<p>Bây giờ: %02u:%02u ngày %02u/%02u/%u. Hôm nay đã cho %d phần.</p>",
bay.gio, bay.phut, bay.ngay, bay.thang, bay.nam, phanHomNay);
else snprintf(dong, sizeof(dong), "<p><b>Chưa có giờ đúng</b>: kết nối Wi-Fi để đồng bộ, lịch tạm dừng.</p>");
s += dong;
s += F("<form action='/luu'><h3>Lịch cho ăn</h3>");
for (int i = 0; i < SO_LICH; i++) {
snprintf(dong, sizeof(dong), "<div><input type='checkbox' name='b%d'%s> <input type='time' name='g%d' value='%02u:%02u'>",
i, lich[i].bat ? " checked" : "", i, lich[i].gio, lich[i].phut);
s += dong;
snprintf(dong, sizeof(dong), " <input type='number' name='p%d' min='1' max='5' value='%u' style='width:50px'> phần</div>", i, lich[i].phan);
s += dong;
}
s += F("<p><button>Lưu lịch</button></p></form><form action='/cho-an' method='post'><button>Cho ăn ngay 1 phần</button></form><h3>Gần đây</h3><ul>");
for (int k = 0; k < soSuKien; k++) {
s += "<li>";
s += suKien[(viTriSuKien - k + SO_SU_KIEN) % SO_SU_KIEN].noiDung;
s += "</li>";
}
s += F("</ul></body></html>");
may.send(200, "text/html; charset=utf-8", s);
}
void chuyenVeTrangChu() {
may.sendHeader("Location", "/");
may.send(303);
}
void luuLich() {
for (int i = 0; i < SO_LICH; i++) {
int g, p;
char ten[4] = {'g', (char)('0' + i), 0};
if (sscanf(may.arg(ten).c_str(), "%d:%d", &g, &p) == 2 && g >= 0 && g < 24 && p >= 0 && p < 60) {
lich[i].gio = g;
lich[i].phut = p;
}
ten[0] = 'p';
lich[i].phan = constrain(may.arg(ten).toInt(), 1, 5);
ten[0] = 'b';
lich[i].bat = may.hasArg(ten);
}
boNho.putBytes("lich", lich, sizeof(lich));
ghiSuKien("Da luu lich moi");
chuyenVeTrangChu();
}
void choAnNgay() {
choAn(1, "web");
chuyenVeTrangChu();
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_NUT, INPUT_PULLUP);
pinMode(CHAN_DEN, OUTPUT);
ledcAttach(CHAN_SERVO, 50, 16);
datGoc(GOC_DONG);
delay(500);
ledcWrite(CHAN_SERVO, 0);
Wire.begin(21, 22);
boNho.begin("choca", false);
boNho.getBytes("lich", lich, sizeof(lich));
for (int i = 0; i < SO_LICH; i++) {
char khoa[4] = {'d', (char)('0' + i), 0};
daChoAn[i] = boNho.getLong(khoa, 0);
}
gioHopLe = docGio(bay);
Serial.println(gioHopLe ? "DS3231: gio hop le" : "DS3231: chua co gio dung (mat nguon nuoi hoac chua dat), cho dong bo Internet");
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
configTzTime("ICT-7", "pool.ntp.org", "time.google.com");
may.on("/", trangChu);
may.on("/luu", luuLich);
may.on("/cho-an", HTTP_POST, choAnNgay);
may.begin();
}
void loop() {
may.handleClient();
static uint32_t lanGiay = 0, lanIp = 0;
if (millis() - lanGiay >= 1000) {
lanGiay = millis();
dongBoGio();
gioHopLe = docGio(bay);
if (gioHopLe) kiemTraLich();
}
if (WiFi.status() == WL_CONNECTED && lanIp == 0) {
lanIp = millis();
Serial.printf("Mo trinh duyet: http://%s\n", WiFi.localIP().toString().c_str());
}
static bool truoc = HIGH;
static uint32_t lucBam = 0;
bool doc = digitalRead(CHAN_NUT);
if (doc == LOW && truoc == HIGH && millis() - lucBam > 500) {
lucBam = millis();
choAn(1, "nut");
}
truoc = doc;
}
Giải thích
kiemTraLich()chạy mỗi giây. Với mỗi giờ hẹn đang bật: nếu đã tới giờ, chưa quá 10 phút và hôm nay chưa chạy thì cho ăn. Ngày đã chạy được ghi vào Preferences trước khi quay servo, nên mất điện giữa lúc quay, có điện lại cũng không cho ăn lặp.- Trễ tối đa 10 phút: mất điện ngắn quanh giờ hẹn thì có điện lại vẫn cho ăn bù. Mất điện lâu hơn thì bỏ qua và ghi "Bo lo lich", tránh cho ăn quá gần bữa sau. Lần đầu nạp chương trình vào buổi tối cũng sẽ thấy các dòng "bỏ lỡ" cho giờ hẹn đã qua trong ngày, đó là bình thường.
- Trang web:
/hiện giờ, lịch, số phần đã cho hôm nay và 10 sự kiện gần nhất;/luunhận lịch mới từ biểu mẫu và lưu vào Preferences;/cho-ancho ăn ngay 1 phần. - Nút D4: bấm để cho ăn ngay 1 phần, tiện thử cơ cấu hoặc cho thêm khi đang ở nhà.
7. Kết quả



8. Lưu ý khi nuôi cá
- Cho cá ăn quá nhiều làm bẩn nước nhanh hơn là để cá đói vài ngày. Lượng mỗi bữa nên vừa đủ cá ăn hết trong vài phút.
- Chạy thử máy vài ngày khi đang ở nhà, quan sát lượng cám rơi và cá ăn hết hay không, rồi mới để máy tự chạy khi đi vắng.
- Trang web không có mật khẩu: ai cùng mạng Wi-Fi cũng bấm "Cho ăn ngay" được. Với mạng gia đình thường không sao; nếu cần, thêm kiểm tra mật khẩu trong
luuLich()vàchoAnNgay().
9. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "DS3231: chua co gio dung" | Mới lắp pin, pin hết, chưa từng đặt giờ | Cho ESP32 vào Wi-Fi để đồng bộ; thay pin CR2032 |
| Servo rung, ESP32 khởi động lại | Nguồn yếu, servo kẹt cơ khí | Cục sạc từ 1 A, thêm tụ 470 µF; kiểm tra đĩa quay trơn |
| Cám không rơi hoặc rơi quá nhiều | Góc mở chưa đúng vị trí cửa ra, lỗ quá nhỏ hay quá to | Chỉnh GOC_MO, GIU_MO_MS; khoan lại lỗ |
| Cám bị kẹt, vón cục | Hơi ẩm từ bể | Đặt hộp cao hơn mặt nước, thêm gói hút ẩm, dùng cám viên |
| Đổi lịch trên web không có tác dụng | Bỏ trống ô giờ, ô bật chưa đánh dấu | Kiểm tra ô đánh dấu; xem dòng "Da luu lich moi" trên Serial |
10. Xem thêm
- ESP32 điều khiển servo SG90 bằng PWM, không cần thư viện
- Đồng hồ Internet với ESP32, NTP và OLED
- Bài DS3231 trong loạt mô-đun: đọc giờ, đặt giờ, báo thức, nhiệt độ.
Xem thêm