Cân IoT theo dõi bình gas bằng ESP32 và HX711: còn bao nhiêu kg, ước tính ngày hết, báo qua Telegram
Làm cân IoT đặt dưới bình gas: ESP32 đọc load cell qua HX711, trừ vỏ bình ra số kg gas còn lại, hồi quy tính mức dùng mỗi ngày, ước tính ngày hết và nhắn Telegram khi sắp hết.
Bình gas hết giữa lúc đang nấu là chuyện nhà nào cũng từng gặp. Cách đoán quen thuộc là nhấc thử, lắc thử. Bài này làm một cân IoT đặt dưới bình gas: ESP32 đọc load cell qua HX711, trừ khối lượng vỏ bình để biết còn bao nhiêu kg gas, tự tính mỗi ngày dùng bao nhiêu để ước tính còn mấy ngày, và nhắn Telegram khi gas sắp hết. Cách làm này dùng được cho mọi thứ cần theo dõi khối lượng theo thời gian: thùng nước, bao gạo, thức ăn cho vật nuôi, hàng trong kho.
1. Lưu ý an toàn
- Cân chỉ đỡ bình gas từ bên dưới. Không tháo, không sửa van, dây dẫn, van điều áp; dây dẫn gas phải đủ dài để bình đứng trên cân mà dây không bị căng.
- Cân không phát hiện được rò gas (lượng rò nhỏ không đổi khối lượng kịp thời). Muốn cảnh báo rò khí thì cần cảm biến khí ga riêng, xem bài báo rò gas qua Telegram trong cùng nhóm dự án.
- Mạch điện đặt trong hộp, cách xa bếp và lửa; dùng nguồn 5 V an toàn (cục sạc USB).
2. Cấu tạo

- Load cell thanh 50 kg (loại một điểm, single point) kẹp giữa hai tấm gỗ hoặc nhôm: một đầu bắt vào đế dưới, đầu kia bắt vào mặt trên, có khe hở để thanh uốn được. Bình gas 12 kg đầy nặng khoảng 26–27 kg nên 50 kg là vừa, còn chỗ cho lúc đặt bình mạnh tay.
- HX711: bộ khuếch đại và chuyển đổi ADC 24 bit cho cầu điện trở trong load cell; ESP32 đọc bằng hai dây DT, SCK.
- Gas còn lại = tổng khối lượng trên cân − khối lượng vỏ bình. Vỏ bình 12 kg thường nặng khoảng 13–15 kg; số chính xác in hoặc dập trên thân bình, mỗi bình một khác.
3. Chuẩn bị
- ESP32 DevKit V1 và dây USB
- Module HX711
- Load cell thanh 50 kg (4 dây), hai tấm gỗ hoặc nhôm khoảng 30 × 30 cm, bulông và miếng đệm
- Dây cắm, vật mẫu đã biết khối lượng (ví dụ 5 chai nước 1 L đã cân)
- Thư viện ArduinoJson 7 (Library Manager) cho phần Telegram; HX711 đọc không cần thư viện.
4. Nối dây

Chân và cách đọc HX711 giống bài cảm biến lực load cell trên website, nên có thể thử load cell với chương trình bài đó trước. Dây giữa HX711 và load cell nên ngắn, đi cách xa dây nguồn để số đọc ít nhiễu.
5. Hiệu chuẩn

- Nạp chương trình, mở Serial Monitor 115200 baud, chọn xuống dòng Newline.
- Bàn cân trống: gõ
t, chương trình lưu điểm không. - Đặt vật mẫu, ví dụ 5000 g: gõ
c5000, chương trình tính và lưu hệ số đếm mỗi gam. - Sửa
KHOI_LUONG_VO_Gtheo số ghi trên vỏ bình nhà mình rồi nạp lại.
6. Chương trình
Điền TEN_WIFI, MAT_KHAU_WIFI, BOT_TOKEN, CHAT_ID. Cách tạo bot và lấy chat id giống bài chuông cửa ESP32-CAM trong cùng nhóm: nhắn /start cho bot khi CHAT_ID = 0, chat id hiện trên Serial.
// PYWORLD.VN - Cân IoT theo dõi bình gas: ESP32 đọc HX711 không cần thư viện, tính số kg gas còn lại, ước tính số ngày dùng tiếp
// PYWORLD.VN - HX711: VCC 3V3, GND, DT GPIO32, SCK GPIO33; bàn cân 4 load cell nửa cầu 50 kg qua mạch ghép; báo Telegram khi gas sắp hết
// PYWORLD.VN - Lệnh Serial: "t" trừ bì khi bàn cân trống, "c5000" hiệu chuẩn bằng vật 5000 g; lệnh Telegram /gas xem tình trạng
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const char *BOT_TOKEN = "123456789:MA_TOKEN_CUA_BOT";
const long long CHAT_ID = 0; // lấy như bài chuông cửa: nhắn /start cho bot, xem Serial
const int CHAN_DT = 32;
const int CHAN_SCK = 33;
const float KHOI_LUONG_VO_G = 14200; // khối lượng vỏ bình, đọc trên thân bình (đổi theo bình nhà bạn)
const float GAS_DAY_G = 12000; // bình 12 kg
const float BAO_CON_G = 2000; // còn dưới 2 kg thì báo
const float THAY_BINH_G = 3000; // gas tăng hơn 3 kg: coi như vừa thay bình mới
const uint32_t CHU_KY_DO_MS = 2000;
const uint32_t MOT_GIO_MS = 3600000;
const int SO_GIO = 168; // giữ 7 ngày số liệu theo giờ để ước tính mức dùng
portMUX_TYPE khoaNgat = portMUX_INITIALIZER_UNLOCKED;
Preferences boNho;
WiFiClientSecure tls;
int32_t diemKhong = 0;
float heSo = 11.0f; // số đếm mỗi gam: giá trị phỏng chừng, phải hiệu chuẩn bằng lệnh c<khối lượng>
float gasG = NAN; // gas còn lại, gam
float lichSu[SO_GIO]; // gas mỗi giờ, [0] là giờ mới nhất
int soGio = 0;
bool daBaoSapHet = false, daBaoKhoiDong = false;
uint32_t lanGio = 0, lanHoi = 0, lanBao = 0;
long long updateTiep = 0;
// ---- HX711: 24 xung đọc dữ liệu, xung 25 chọn kênh A hệ số 128 (như bài cảm biến lực) ----
bool docTho(int32_t *ketQua) {
uint32_t batDau = millis();
while (digitalRead(CHAN_DT) == HIGH) {
if (millis() - batDau > 500) return false;
delay(1);
}
uint32_t giaTri = 0;
portENTER_CRITICAL(&khoaNgat); // SCK cao quá 60 µs thì HX711 tự tắt
for (int i = 0; i < 24; i++) {
digitalWrite(CHAN_SCK, HIGH);
delayMicroseconds(1);
giaTri = (giaTri << 1) | (uint32_t)digitalRead(CHAN_DT);
digitalWrite(CHAN_SCK, LOW);
delayMicroseconds(1);
}
digitalWrite(CHAN_SCK, HIGH);
delayMicroseconds(1);
digitalWrite(CHAN_SCK, LOW);
portEXIT_CRITICAL(&khoaNgat);
if (giaTri & 0x800000) giaTri |= 0xFF000000;
*ketQua = (int32_t)giaTri;
return true;
}
// Trung vị của 7 mẫu: bỏ được các mẫu nhảy khi có người chạm vào bình.
bool docTrungVi(int32_t *ketQua) {
int32_t m[7];
for (int i = 0; i < 7; i++) {
if (!docTho(&m[i])) return false;
}
for (int i = 1; i < 7; i++) {
for (int j = i; j > 0 && m[j - 1] > m[j]; j--) {
int32_t t = m[j];
m[j] = m[j - 1];
m[j - 1] = t;
}
}
*ketQua = m[3];
return true;
}
// ---- Telegram (như bài chuông cửa ESP32-CAM) ----
bool guiTin(const String &noiDung) {
if (WiFi.status() != WL_CONNECTED || CHAT_ID == 0) return false;
HTTPClient http;
http.begin(tls, String("https://api.telegram.org/bot") + BOT_TOKEN + "/sendMessage");
http.addHeader("Content-Type", "application/json");
JsonDocument doc;
doc["chat_id"] = CHAT_ID;
doc["text"] = noiDung;
String than;
serializeJson(doc, than);
int ma = http.POST(than);
http.end();
Serial.printf("Gui tin Telegram: HTTP %d\n", ma);
return ma == 200;
}
// ---- Ước tính mức dùng: hồi quy tuyến tính gas theo giờ trên số liệu đã có ----
float dungMoiNgayG() {
if (soGio < 24) return NAN; // cần ít nhất một ngày số liệu
double sx = 0, sy = 0, sxx = 0, sxy = 0;
for (int i = 0; i < soGio; i++) {
double x = -i, y = lichSu[i]; // x: giờ tính lùi từ hiện tại
sx += x;
sy += y;
sxx += x * x;
sxy += x * y;
}
double doDoc = (soGio * sxy - sx * sy) / (soGio * sxx - sx * sx); // gam mỗi giờ, âm khi gas giảm
return doDoc < 0 ? -doDoc * 24 : 0;
}
String trangThai() {
if (isnan(gasG)) return "Chưa thấy bình gas trên cân.";
char s[160];
float dung = dungMoiNgayG();
int n = snprintf(s, sizeof(s), "Bình gas còn %.1f kg (%.0f %%).", gasG / 1000, gasG / GAS_DAY_G * 100);
if (isnan(dung)) snprintf(s + n, sizeof(s) - n, " Chưa đủ 1 ngày số liệu để ước tính.");
else if (dung < 20) snprintf(s + n, sizeof(s) - n, " Mấy ngày qua gần như không dùng.");
else snprintf(s + n, sizeof(s) - n, " Dùng khoảng %.2f kg/ngày, còn khoảng %.0f ngày.", dung / 1000, gasG / dung);
return String(s);
}
void xuLyLenh(String lenh) {
lenh.trim();
lenh.toLowerCase();
guiTin(lenh == "/gas" ? trangThai() : String("Lệnh: /gas xem lượng gas còn lại"));
}
void docTinMoi() {
HTTPClient http;
char so[24];
snprintf(so, sizeof(so), "%lld", updateTiep);
http.begin(tls, String("https://api.telegram.org/bot") + BOT_TOKEN + "/getUpdates?timeout=0&limit=5&offset=" + so);
int ma = http.GET();
String than = ma == 200 ? http.getString() : String();
http.end();
JsonDocument doc;
if (ma != 200 || deserializeJson(doc, than)) return;
for (JsonObject u : doc["result"].as<JsonArray>()) {
updateTiep = u["update_id"].as<long long>() + 1;
long long chat = u["message"]["chat"]["id"].as<long long>();
const char *chu = u["message"]["text"] | "";
if (CHAT_ID == 0 || chat != CHAT_ID) {
Serial.printf("Tin tu chat id %lld (bo qua): %s\n", chat, chu);
continue;
}
xuLyLenh(String(chu));
}
}
// ---- Cân, lịch sử, cảnh báo ----
void lenhSerial() {
if (!Serial.available()) return;
String lenh = Serial.readStringUntil('\n');
lenh.trim();
int32_t tho;
if (!docTrungVi(&tho)) return;
if (lenh == "t") {
diemKhong = tho;
boNho.putInt("khong", diemKhong);
Serial.printf("Da tru bi: diem khong = %ld\n", (long)diemKhong);
} else if (lenh.startsWith("c") && lenh.substring(1).toFloat() > 0) {
heSo = (tho - diemKhong) / lenh.substring(1).toFloat();
boNho.putFloat("heso", heSo);
Serial.printf("He so moi: %.3f dem/g (da luu)\n", heSo);
}
}
void canMotLan() {
int32_t tho;
if (!docTrungVi(&tho)) {
Serial.println("Khong doc duoc HX711: kiem tra day DT, SCK, nguon");
return;
}
float tongG = (tho - diemKhong) / heSo;
if (tongG < KHOI_LUONG_VO_G * 0.5f) { // bàn cân trống hoặc đang thay bình
gasG = NAN;
return;
}
float moi = max(0.0f, tongG - KHOI_LUONG_VO_G);
if (!isnan(gasG) && moi - gasG > THAY_BINH_G) {
soGio = 0; // bình mới: bỏ lịch sử của bình cũ
daBaoSapHet = false;
Serial.printf("Da thay binh moi: gas %.1f kg\n", moi / 1000);
guiTin("Đã thay bình gas mới.");
}
gasG = isnan(gasG) ? moi : 0.8f * gasG + 0.2f * moi; // lọc nhẹ để số không nhảy
}
void luuGio() {
if (isnan(gasG)) return;
for (int i = SO_GIO - 1; i > 0; i--) lichSu[i] = lichSu[i - 1];
lichSu[0] = gasG;
if (soGio < SO_GIO) soGio++;
float dung = dungMoiNgayG();
Serial.printf("Gio %d: gas %.2f kg, dung %.2f kg/ngay\n", soGio, gasG / 1000, isnan(dung) ? 0.0f : dung / 1000);
if (!daBaoSapHet && gasG < BAO_CON_G) {
daBaoSapHet = guiTin(String("Sắp hết gas! ") + trangThai() + " Nên đặt bình mới.");
}
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_DT, INPUT);
pinMode(CHAN_SCK, OUTPUT);
digitalWrite(CHAN_SCK, LOW);
boNho.begin("cangas", false);
diemKhong = boNho.getInt("khong", 0);
heSo = boNho.getFloat("heso", heSo);
Serial.printf("Diem khong %ld, he so %.3f dem/g. Lenh: t (tru bi), c5000 (hieu chuan 5000 g)\n", (long)diemKhong, heSo);
tls.setInsecure();
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
}
void loop() {
lenhSerial();
static uint32_t lanDo = 0;
if (millis() - lanDo >= CHU_KY_DO_MS) {
lanDo = millis();
canMotLan();
}
if (millis() - lanGio >= MOT_GIO_MS) {
lanGio = millis();
luuGio();
}
if (WiFi.status() != WL_CONNECTED) return;
if (!daBaoKhoiDong && (lanBao == 0 || millis() - lanBao > 10000)) {
lanBao = millis();
if (CHAT_ID == 0) Serial.println("Chua co CHAT_ID: nhan /start cho bot roi xem chat id in ra o day");
daBaoKhoiDong = CHAT_ID == 0 || guiTin(String("Cân gas đã chạy. ") + trangThai());
}
if (millis() - lanHoi >= 3000) {
lanHoi = millis();
docTinMoi();
}
}
Giải thích
docTrungVi()đọc 7 mẫu liền và lấy mẫu ở giữa sau khi sắp xếp: có người chạm vào bình, đặt nồi lên kệ cạnh đó, vài mẫu nhảy cũng không làm sai kết quả. Sau đócanMotLan()lọc nhẹ thêm (80 % số cũ, 20 % số mới).- Bàn cân trống (tổng nhỏ hơn nửa khối lượng vỏ): coi như đang thay bình, không tính. Gas tăng hơn 3 kg so với trước: coi như vừa lắp bình mới, xóa lịch sử của bình cũ và nhắn "Đã thay bình gas mới".
- Lịch sử theo giờ: mỗi giờ lưu một số vào mảng 168 phần tử (7 ngày).
dungMoiNgayG()vẽ một đường thẳng khớp nhất qua các điểm (hồi quy tuyến tính) và lấy độ dốc: gam mỗi giờ, nhân 24 ra gam mỗi ngày. Cách này dùng được mọi điểm số liệu nên ít bị ảnh hưởng bởi một bữa nấu nhiều hay ít. Cần ít nhất 24 giờ số liệu mới ước tính.- Báo sắp hết: gas dưới 2 kg thì nhắn một lần, kèm số kg và số ngày ước tính. Nhắn
/gascho bot lúc nào cũng được trả lời tình trạng.
7. Kết quả



Mấy ngày đầu, ước tính dao động nhiều vì chỉ có vài bữa nấu; sau khoảng một tuần thì ổn định. Gia đình nấu ít vào ngày thường, nhiều vào cuối tuần sẽ thấy ước tính thay đổi theo tuần, đó là bình thường.
8. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "Khong doc duoc HX711" | Sai dây DT/SCK, HX711 chưa có nguồn | Kiểm tra DT vào D32, SCK vào D33, VCC 3V3 |
| Đặt vật lên, số giảm thay vì tăng | Đảo cực tín hiệu | Đổi chỗ dây A+ và A−, hiệu chuẩn lại |
| Số đọc trôi dần khi không có gì thay đổi | Load cell bị lực kẹp lệch, nhiệt độ thay đổi, mặt cân chạm đế | Kiểm tra khe hở, bulông; đặt cân tránh nắng và hơi nóng của bếp |
| Gas âm hoặc lớn hơn 12 kg | Sai khối lượng vỏ, hiệu chuẩn sai | Đọc lại số trên vỏ bình; hiệu chuẩn lại với vật mẫu nặng hơn |
| Không nhận được tin | Sai token, CHAT_ID, chưa có Wi-Fi | Xem Serial: "Gui tin Telegram: HTTP 200" là gửi được |
| Ước tính số ngày thất thường sau khi thay bình | Gas tăng chưa tới 3 kg (bình mới không đầy) | Giảm THAY_BINH_G hoặc khởi động lại ESP32 sau khi thay bình |
9. Hướng mở rộng
- Thêm màn hình OLED để xem số kg ngay tại bếp.
- Lưu lịch sử vào Preferences mỗi vài giờ để khởi động lại không mất số liệu.
- Dùng cùng chương trình cho thùng nước, bao gạo: đổi khối lượng vỏ và ngưỡng báo.
10. Xem thêm
- Làm cân điện tử với ESP32, HX711 và load cell
- Cảm biến lực load cell: cấu tạo, nguyên lý, tự đọc HX711 bằng ESP32
- Bài chuông cửa ESP32-CAM (tạo bot Telegram) và bài báo rò gas qua Telegram trong cùng nhóm dự án.
Xem thêm