Đo mực nước bồn và tự bật tắt máy bơm bằng ESP32: cảm biến siêu âm, chống chạy khô, báo lỗi
Làm bồn nước tự bơm bằng ESP32: so sánh cách đo mực nước trong bồn, cảm biến siêu âm, bật 30 % tắt 90 %, chống bơm chạy khô, giới hạn thời gian bơm, báo lỗi; code và mô phỏng 24 giờ.
Bài này làm một hệ thống đo mực nước bồn và tự bật tắt máy bơm bằng ESP32: cảm biến siêu âm chống nước gắn trên nắp bồn đo mực nước liên tục, ESP32 bật bơm khi bồn xuống 30 % và tắt khi lên 90 %, kèm các lớp bảo vệ mà rơ le phao cơ không có: không bơm khi bể ngầm cạn, dừng khi bơm quá lâu, dừng khi bơm chạy mà nước không lên, dừng khi mất tín hiệu cảm biến. Bài có so sánh các cách đo mực nước trong bồn (phao, que đo điện cực, siêu âm, áp suất), chương trình ESP32, bảng nối dây và mô phỏng 24 giờ để chọn đúng các con số cài đặt.
1. Hệ thống gồm những gì

- Cảm biến mực bồn trên: siêu âm chống nước A02YYUW (hoặc JSN-SR04T) gắn trên nắp bồn, đo khoảng cách tới mặt nước.
- Phao bể ngầm: công tắc phao báo bể ngầm hoặc giếng còn nước, để bơm không chạy khô.
- ESP32: đọc cảm biến, quyết định bật tắt, báo lỗi.
- Relay và contactor: module relay đóng cuộn hút của contactor (khởi động từ); tiếp điểm contactor đóng điện cho bơm. Bơm từ khoảng 0,5 HP trở lên có dòng khởi động lớn, nên không cho tiếp điểm relay nhỏ đóng trực tiếp. Phía trước có aptomat và chống giật theo đúng quy định điện dân dụng.
2. Các cách đo mực nước trong bồn

Rơ le phao cơ (phao điện) bán sẵn chỉ biết đầy hoặc cạn và tự đóng ngắt bơm, rất rẻ. Dùng ESP32 với cảm biến đo liên tục thì biết bồn còn bao nhiêu phần trăm, xem từ xa được, và thêm được các bảo vệ ở mục 4. Que đo mực nước kiểu điện cực rẻ nhưng bị ăn mòn, đóng cặn sau vài tháng. Cảm biến áp suất thủy tĩnh đặt dưới đáy bồn chính xác nhất, hợp bồn kín hoặc bồn có nhiều hơi nước. Cách đọc cảm biến siêu âm chống nước, khung dữ liệu UART và cách lắp đầu dò trên nắp bồn có ở bài cùng cụm về JSN-SR04T, A02YYUW; nguyên lý các loại cảm biến mức có ở bài cảm biến mức: phao, điện cực, thủy tĩnh, siêu âm.
3. Nối dây

Chuẩn bị ESP32 DevKit V1 và module relay 1 kênh 5 V. Cách chọn relay kích mức thấp hay cao có ở bài module relay 1, 2, 4 kênh. Nếu contactor đóng làm ESP32 khởi động lại, xem bài đóng relay làm ESP32 bị reset: thường do nguồn chung và nhiễu từ cuộn hút.
4. Logic điều khiển và các lớp bảo vệ
- Khoảng trễ: bật khi bồn xuống 30 %, tắt khi lên 90 %. Nếu bật và tắt cùng một ngưỡng, sóng mặt nước làm bơm đóng ngắt liên tục, hỏng contactor và động cơ.
- Lọc trung vị 5 lần đo: loại số đo sai do giọt nước đọng trên đầu dò.
- Nghỉ tối thiểu 1 phút giữa hai lần chạy.
- Bể ngầm cạn (phao hở): không bật bơm; nếu đang bơm thì dừng và báo lỗi.
- Bơm quá lâu: một lần bơm vượt giới hạn thì dừng, báo lỗi. Thường do ống vỡ, van mở, bồn rò.
- Không lên nước: bơm chạy 5 phút mà mực không tăng 2 cm thì dừng. Đây là lớp bảo vệ khi phao bể ngầm hỏng, bơm mất mồi, đường ống tắc.
- Mất tín hiệu cảm biến quá 10 giây: dừng bơm, báo lỗi. Không đoán mực nước khi không đo được.
- Lỗi giữ nguyên cho đến khi người dùng giữ nút 3 giây để xóa, để có người kiểm tra trước khi bơm chạy lại.
5. Chương trình ESP32
// PYWORLD.VN - Tự bơm nước lên bồn bằng ESP32: đo mực nước bằng cảm biến siêu âm A02YYUW, bật dưới 30 %, tắt trên 90 %
// PYWORLD.VN - Bảo vệ: phao bể ngầm cạn thì không bơm, bơm quá 40 phút hoặc 5 phút không lên nước thì dừng và báo lỗi, cảm biến mất tín hiệu thì dừng
// PYWORLD.VN - A02YYUW TX -> GPIO16; relay (kích mức thấp, điều khiển cuộn contactor) IN -> GPIO26; phao bể ngầm GPIO27; nút GPIO4
const int CHAN_RX2 = 16, CHAN_TX2 = 17;
const int CHAN_RELAY = 26, CHAN_PHAO_NGUON = 27, CHAN_NUT = 4;
const bool RELAY_KICH_MUC_THAP = true;
const bool PHAO_CO_NUOC_MUC = LOW; // phao đóng (nối GND) khi bể ngầm còn nước
// Kích thước bồn: đo thực tế rồi sửa
const float KHOANG_CACH_TOI_DAY_CM = 150.0f; // mặt đầu dò tới đáy bồn
const float MUC_DAY_CM = 125.0f; // chiều cao nước khi đầy (chừa vùng mù trên cùng)
const float BAT_DUOI = 30, TAT_TREN = 90; // % : khoảng trễ để bơm không bật tắt liên tục
// Giới hạn một lần bơm ≈ 2 × (thể tích × 60 %) / lưu lượng bơm; bồn 1000 L, bơm 30 L/phút: 2 × 600 / 30 = 40 phút
const uint32_t BOM_TOI_DA_MS = 40UL * 60 * 1000;
const uint32_t KIEM_TRA_LEN_MS = 5UL * 60 * 1000; // sau 5 phút phải lên ít nhất 2 cm
const float LEN_TOI_THIEU_CM = 2.0f;
const uint32_t NGHI_TOI_THIEU_MS = 60UL * 1000; // nghỉ ít nhất 1 phút giữa hai lần chạy
const uint32_t MAT_CAM_BIEN_MS = 10000;
enum TrangThai { NGHI, BOM, LOI };
TrangThai trangThai = NGHI;
const char *lyDoLoi = "";
float mucCm = NAN, mucLucBatDau = 0;
uint32_t lanDocDuoc = 0, lucBat = 0, lucTat = 0;
bool bomTay = false;
void datBom(bool bat) {
digitalWrite(CHAN_RELAY, (bat == RELAY_KICH_MUC_THAP) ? LOW : HIGH);
if (bat) {
lucBat = millis();
mucLucBatDau = mucCm;
} else {
lucTat = millis();
}
}
// Gói A02YYUW: 0xFF, cao, thấp, tổng (byte thấp của 0xFF + cao + thấp); khoảng cách mm
bool docA02(uint16_t &mm) {
static uint8_t goi[4], n = 0;
while (Serial2.available()) {
uint8_t b = Serial2.read();
if (n == 0 && b != 0xFF) continue;
goi[n++] = b;
if (n == 4) {
n = 0;
if (((goi[0] + goi[1] + goi[2]) & 0xFF) == goi[3]) {
mm = (goi[1] << 8) | goi[2];
return true;
}
}
}
return false;
}
float trungVi5(float moi) {
static float vung[5];
static int dem = 0;
vung[dem % 5] = moi;
dem++;
int n = dem < 5 ? dem : 5;
float s[5];
memcpy(s, vung, sizeof(s));
for (int i = 1; i < n; i++)
for (int j = i; j > 0 && s[j - 1] > s[j]; j--) {
float t = s[j];
s[j] = s[j - 1];
s[j - 1] = t;
}
return s[n / 2];
}
void baoLoi(const char *lyDo) {
datBom(false);
trangThai = LOI;
lyDoLoi = lyDo;
Serial.printf("LOI: %s -> giu nut 3 giay de xoa\n", lyDo);
}
void docNut() {
static bool truoc = HIGH;
static uint32_t lucNhan = 0;
bool nay = digitalRead(CHAN_NUT);
if (nay == LOW && truoc == HIGH) lucNhan = millis();
if (nay == HIGH && truoc == LOW && millis() - lucNhan > 30) {
if (millis() - lucNhan >= 3000) {
if (trangThai == LOI) {
trangThai = NGHI;
Serial.println("Da xoa loi");
}
} else if (trangThai == NGHI) {
bomTay = true; // bấm ngắn: bơm tay tới khi đầy
} else if (trangThai == BOM) {
datBom(false); // bấm ngắn khi đang bơm: dừng
trangThai = NGHI;
bomTay = false;
}
}
truoc = nay;
}
void setup() {
Serial.begin(115200);
Serial2.begin(9600, SERIAL_8N1, CHAN_RX2, CHAN_TX2);
pinMode(CHAN_RELAY, INPUT); // gói esp32 3.x bỏ qua digitalWrite trước pinMode:
digitalWrite(CHAN_RELAY, RELAY_KICH_MUC_THAP ? HIGH : LOW); // ghi mức tắt khi chân còn là ngõ vào
pinMode(CHAN_RELAY, OUTPUT); // rồi mới chuyển thành ngõ ra, relay không đóng lúc khởi động
pinMode(CHAN_PHAO_NGUON, INPUT_PULLUP);
pinMode(CHAN_NUT, INPUT_PULLUP);
lucTat = millis() - NGHI_TOI_THIEU_MS;
}
void loop() {
uint16_t mm;
if (docA02(mm) && mm > 0) {
float muc = KHOANG_CACH_TOI_DAY_CM - trungVi5(mm / 10.0f);
mucCm = constrain(muc, 0.0f, KHOANG_CACH_TOI_DAY_CM);
lanDocDuoc = millis();
}
docNut();
float phanTram = 100.0f * mucCm / MUC_DAY_CM;
bool nguonCoNuoc = digitalRead(CHAN_PHAO_NGUON) == PHAO_CO_NUOC_MUC;
if (trangThai != LOI && millis() - lanDocDuoc > MAT_CAM_BIEN_MS) baoLoi("mat tin hieu cam bien");
if (trangThai == NGHI) {
bool canBom = bomTay || phanTram <= BAT_DUOI;
if (canBom && !isnan(mucCm) && phanTram < TAT_TREN && nguonCoNuoc && millis() - lucTat >= NGHI_TOI_THIEU_MS) {
datBom(true);
trangThai = BOM;
}
} else if (trangThai == BOM) {
uint32_t daChay = millis() - lucBat;
if (phanTram >= TAT_TREN) {
datBom(false);
trangThai = NGHI;
bomTay = false;
} else if (!nguonCoNuoc) {
baoLoi("be ngam can nuoc");
} else if (daChay > BOM_TOI_DA_MS) {
baoLoi("bom qua lau (ro ri, ong vo?)");
} else if (daChay > KIEM_TRA_LEN_MS && mucCm - mucLucBatDau < LEN_TOI_THIEU_CM) {
baoLoi("bom chay nhung khong len nuoc");
}
}
static uint32_t lanIn = 0;
if (millis() - lanIn >= 2000) {
lanIn = millis();
const char *ten[] = {"nghi", "bom", "loi"};
Serial.printf("Muc %.1f cm (%.0f %%), be ngam %s, %s%s%s\n", mucCm, phanTram, nguonCoNuoc ? "co nuoc" : "CAN",
ten[trangThai], trangThai == LOI ? ": " : "", lyDoLoi);
}
}
Sửa KHOANG_CACH_TOI_DAY_CM (từ mặt đầu dò tới đáy bồn) và MUC_DAY_CM (chiều cao nước khi đầy, chừa vùng mù khoảng 3–25 cm tùy cảm biến) theo bồn thật. Trong setup(), chân relay được đặt mức tắt khi còn là ngõ vào rồi mới chuyển thành ngõ ra: với gói esp32 3.x, lệnh digitalWrite() gọi trước pinMode() bị bỏ qua, relay kích mức thấp sẽ đóng ngay khi khởi động nếu viết theo thứ tự ngược lại. Serial Monitor (115200) in mỗi 2 giây dòng dạng Muc 62.0 cm (50 %), be ngam co nuoc, nghi.
6. Mô phỏng 24 giờ để chọn con số cài đặt
Script dưới đây chạy đúng logic trên với một bồn 1000 L, bơm 30 L/phút và lượng dùng thay đổi theo giờ (sáng 6–8 giờ và tối 18–21 giờ dùng nhiều).
# PYWORLD.VN - Mô phỏng 24 giờ bồn nước tự bơm theo đúng logic của chương trình ESP32: bật dưới 30 %, tắt trên 90 %
# PYWORLD.VN - Có giờ cao điểm dùng nước, nghỉ tối thiểu 1 phút, giới hạn một lần bơm, lỗi khi 5 phút không lên 2 cm (bể ngầm cạn lúc 20 giờ)
# PYWORLD.VN - Chạy: python mo_phong_bon.py [--the-tich 1000] [--luu-luong-bom 30] [--bom-toi-da 40]
"""Bồn trụ đứng: mực nước tăng khi bơm, giảm theo lượng dùng; in các lần bật, tắt và lỗi."""
from __future__ import annotations
import argparse
MUC_DAY_CM, BAT_DUOI, TAT_TREN = 125.0, 30, 90
KIEM_TRA_LEN, LEN_TOI_THIEU, NGHI_TOI_THIEU = 5 * 60, 2.0, 60
def dung_nuoc(giay: int) -> float:
"""Lít/phút dùng theo giờ trong ngày: sáng 6–8 giờ và tối 18–21 giờ nhiều, đêm gần như không."""
h = giay / 3600 % 24
if 6 <= h < 8:
return 12.0
if 18 <= h < 21:
return 10.0
if 0 <= h < 5:
return 0.2
return 2.5
def mo_phong(the_tich_l: float, bom_l_phut: float, bom_toi_da_phut: float = 40, can_tu_gio: float = 20.0, buoc: int = 10):
cm_moi_l = MUC_DAY_CM / the_tich_l
muc, trang_thai, luc_bat, luc_tat, muc_bat_dau = 0.6 * MUC_DAY_CM, "nghi", 0, -NGHI_TOI_THIEU, 0.0
vet, su_kien = [], []
for t in range(0, 24 * 3600 + 1, buoc):
nguon_co_nuoc = t / 3600 < can_tu_gio # bể ngầm cạn từ 20 giờ; giả sử phao bể ngầm hỏng (không báo),
# nên chỉ lớp bảo vệ "không lên nước" phát hiện được
vao = bom_l_phut if (trang_thai == "bom" and nguon_co_nuoc) else 0.0
muc = min(MUC_DAY_CM, max(0.0, muc + (vao - dung_nuoc(t)) * buoc / 60 * cm_moi_l))
pt = 100 * muc / MUC_DAY_CM
if trang_thai == "nghi" and pt <= BAT_DUOI and t - luc_tat >= NGHI_TOI_THIEU:
trang_thai, luc_bat, muc_bat_dau = "bom", t, muc
su_kien.append((t, "bat bom", pt))
elif trang_thai == "bom":
if pt >= TAT_TREN:
trang_thai, luc_tat = "nghi", t
su_kien.append((t, "tat bom (day)", pt))
elif t - luc_bat > bom_toi_da_phut * 60:
trang_thai = "loi"
su_kien.append((t, "LOI: bom qua lau", pt))
elif t - luc_bat > KIEM_TRA_LEN and muc - muc_bat_dau < LEN_TOI_THIEU:
trang_thai = "loi"
su_kien.append((t, "LOI: khong len nuoc", pt))
vet.append((t, pt, trang_thai))
return vet, su_kien
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--the-tich", type=float, default=1000, help="lít khi đầy")
ap.add_argument("--luu-luong-bom", type=float, default=30, help="lít/phút")
ap.add_argument("--bom-toi-da", type=float, default=40, help="phút cho một lần bơm")
a = ap.parse_args()
vet, su_kien = mo_phong(a.the_tich, a.luu_luong_bom, a.bom_toi_da)
for t, ten, pt in su_kien:
print(f"{t // 3600:02d}:{t % 3600 // 60:02d} {ten:22s} muc {pt:5.1f} %")
phut_bom = sum(10 for _, _, tt in vet if tt == "bom") / 60
print(f"Tong thoi gian bom: {phut_bom:.0f} phut, so lan bat: {sum(1 for s in su_kien if s[1] == 'bat bom')}")
if __name__ == "__main__":
main()

- Bơm bật 6 lần trong ngày, tổng khoảng 144 phút.
- Lúc 20:12 bơm bật nhưng bể ngầm đã cạn (giả sử phao hỏng). Đến 20:17 mực không tăng 2 cm: chương trình dừng bơm, báo "khong len nuoc". Bơm không chạy khô cả đêm.
- Giới hạn thời gian bơm phải tính theo bồn và bơm. Chạy script với
--bom-toi-da 20: lúc 06:27 chương trình báo "bom qua lau" khi bồn mới 66 %, vì giờ cao điểm vừa bơm vừa dùng 12 L/phút nên bơm từ 30 % lên 90 % mất hơn 30 phút. Công thức trong chương trình: giới hạn ≈ 2 × (thể tích × 60 %) / lưu lượng bơm; bồn 1000 L, bơm 30 L/phút là 40 phút.
Lưu lượng thật của bơm khi đẩy lên mái thấp hơn số ghi trên nhãn (phụ thuộc chiều cao đẩy). Cách đo: ghi thời gian bơm từ 30 % lên 90 % vào lúc ít dùng nước, rồi tính lại.
7. Lỗi hay gặp
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Mực nước nhảy lung tung | Giọt nước đọng trên đầu dò, đầu dò sát thành bồn, sóng khi đang bơm | Đặt đầu dò giữa nắp, xa ống xả; tăng số lần lọc trung vị |
| Báo "bom qua lau" dù bồn bình thường | Giới hạn thời gian nhỏ hơn thời gian bơm thật | Đo thời gian bơm, tính lại theo mục 6 |
| Báo "mat tin hieu cam bien" | Dây tín hiệu dài bị nhiễu, đứt; cảm biến hỏng | Dùng cáp có vỏ chống nhiễu, kiểm tra nguồn cảm biến |
| Relay đóng một lúc khi cấp điện | Đặt mức chân relay sau pinMode(OUTPUT) | Làm theo thứ tự trong setup() của bài |
| ESP32 khởi động lại khi bơm đóng | Nhiễu từ cuộn contactor, nguồn yếu | Nguồn riêng cho ESP32, mạch dập RC hoặc varistor cho cuộn hút |
| Bồn đầy rồi vẫn báo thấp | Mực nước lên tới vùng mù của cảm biến | Đặt đầu dò cao hơn hoặc giảm MUC_DAY_CM |
8. Mở rộng
- Xem mực nước trên điện thoại và nhận cảnh báo khi báo lỗi: bài ESP32 gửi cảnh báo Telegram hoặc điều khiển relay bằng điện thoại với Blynk.
- Đếm lượng nước bơm lên bằng cảm biến lưu lượng: bài cảm biến lưu lượng.
- Tưới cây từ bồn: bài tưới cây tự động với ESP32.
Bài cùng cụm chủ đề: cảm biến siêu âm chống nước JSN-SR04T, A02YYUW đo mực nước; tưới vườn nhiều vùng bằng van điện từ.
Xem thêm