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

Bình gas đứng trên bàn cân gồm mặt trên, load cell thanh 50 kg và đế dưới; load cell nối HX711, HX711 nối ESP32, ESP32 gửi tin Telegram tới điện thoại
Bàn cân một load cell thanh, HX711 đọc, ESP32 tính và gửi tin.
  • 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

Sơ đồ nối: HX711 VCC 3V3, GND, DT D32, SCK D33; load cell dây đỏ E+, đen E−, trắng A−, xanh lá A+
Load cell 4 dây vào HX711, HX711 vào ESP32.

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

Ba bước: bàn cân trống gõ t được điểm không 84120; đặt vật 5000 g gõ c5000 được hệ số 11,15 đếm mỗi gam; đặt bình gas, tổng 25787 g trừ vỏ 14200 g còn 11587 g gas
Hiệu chuẩn và tính lượng gas (số đọc ví dụ).
  1. Nạp chương trình, mở Serial Monitor 115200 baud, chọn xuống dòng Newline.
  2. Bàn cân trống: gõ t, chương trình lưu điểm không.
  3. Đặ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.
  4. Sửa KHOI_LUONG_VO_G theo 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 /gas cho bot lúc nào cũng được trả lời tình trạng.

7. Kết quả

Đồ thị gas còn lại giảm từ 12 kg xuống dưới 1 kg trong khoảng 30 ngày, có bậc mỗi bữa nấu; ngưỡng báo 2 kg; ngày 26,8 gửi tin báo; bảng ước tính ngày 2, 7, 14, 27
Gas còn lại và ước tính số ngày còn dùng (mô hình minh họa, khoảng 0,37 kg mỗi ngày).
Tin nhắn trên điện thoại: cân gas đã chạy, bình còn 12,0 kg; người dùng nhắn /gas, bot trả lời còn 10,7 kg, dùng khoảng 0,43 kg mỗi ngày, còn khoảng 25 ngày; tin sắp hết gas khi còn 2,0 kg, còn khoảng 5 ngày
Tin nhắn Telegram (giao diện vẽ kiểu chung, số liệu theo mô hình).
Serial Monitor in điểm không và hệ số, mỗi giờ in số kg gas và mức dùng mỗi ngày; giờ thứ 24 bắt đầu có ước tính; khi gas còn 1,99 kg thì gửi tin Telegram
Serial Monitor (số liệu theo mô hình minh họa).

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ượngNguyên nhân thường gặpCách xử lý
"Khong doc duoc HX711"Sai dây DT/SCK, HX711 chưa có nguồnKiể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 đổiLoad 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 kgSai 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 tinSai token, CHAT_ID, chưa có Wi-FiXem Serial: "Gui tin Telegram: HTTP 200" là gửi được
Ước tính số ngày thất thường sau khi thay bìnhGas 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


Tin tức liên quan

Mạch hạ áp LM2596 và MP1584: cách chọn, chỉnh điện áp và cấp nguồn ESP32
Mạch hạ áp LM2596 và MP1584: cách chọn, chỉnh điện áp và cấp nguồn ESP32

So sánh mạch hạ áp LM2596 và MP1584, cách chỉnh điện áp ra bằng đồng hồ vạn năng trước khi nối tải, cấp nguồn 5 V cho ESP32 DevKit V1 từ nguồn 12 V và xử lý lỗi thường gặp.

Vẽ đồ thị dữ liệu ESP32 trên ThingSpeak: theo dõi pin 18650, ngủ sâu và ước tính thời gian chạy pin
Vẽ đồ thị dữ liệu ESP32 trên ThingSpeak: theo dõi pin 18650, ngủ sâu và ước tính thời gian chạy pin

ESP32 chạy pin 18650 thức dậy mỗi 10 phút, đo điện áp pin, gửi lên ThingSpeak rồi ngủ sâu; cách tạo kênh, ước tính thời gian chạy pin và giảm dòng lúc ngủ.

Module 4G A7680C với ESP32: gửi SMS, bật tắt relay bằng tin nhắn, vì sao SIM800L không còn dùng được
Module 4G A7680C với ESP32: gửi SMS, bật tắt relay bằng tin nhắn, vì sao SIM800L không còn dùng được

Sóng 2G ở Việt Nam đã tắt nên SIM800L không còn dùng được; chọn module 4G thay thế, nối A7680C với ESP32, dùng lệnh AT gửi SMS, nhận lệnh từ số chủ máy để bật tắt relay.

Micro I2S INMP441 với ESP32: đo mức âm thanh dBFS, dB SPL và vỗ tay hai lần bật tắt đèn
Micro I2S INMP441 với ESP32: đo mức âm thanh dBFS, dB SPL và vỗ tay hai lần bật tắt đèn

Khung I2S của micro INMP441, nối với ESP32, đổi số đo 24 bit ra dBFS và ước tính dB SPL, nhận tiếng vỗ tay bằng mức nền và độ vọt, bật tắt đèn khi vỗ tay hai lần.

Hướng dẫn dùng màn hình OLED SSD1306 với ESP8266
Hướng dẫn dùng màn hình OLED SSD1306 với ESP8266

Hướng dẫn nối OLED SSD1306 với ESP8266, quét địa chỉ I2C, cài thư viện và hiển thị thời gian bằng mã Arduino mẫu.

Đo tốc độ động cơ bằng encoder và bộ đếm xung PCNT của ESP32: vòng/phút và số vòng
Đo tốc độ động cơ bằng encoder và bộ đếm xung PCNT của ESP32: vòng/phút và số vòng

Hướng dẫn đọc encoder Hall hai kênh của động cơ DC bằng bộ đếm xung PCNT của ESP32: đếm 4 cạnh, tính vòng/phút và số vòng, lập bảng tốc độ theo duty trong giờ học STEM.

LCD I2C không hiện chữ, chỉ hiện ô vuông, sai địa chỉ 0x27/0x3F: tìm lỗi từng bước
LCD I2C không hiện chữ, chỉ hiện ô vuông, sai địa chỉ 0x27/0x3F: tìm lỗi từng bước

LCD 1602, LCD 2004 I2C không hiện chữ, chỉ hiện hàng ô vuông đen hoặc sai địa chỉ 0x27/0x3F: tìm lỗi nguồn, tương phản, dây I2C, địa chỉ và chương trình kiểm tra tự động.

Làm gateway LoRa sang MQTT bằng ESP32: node cảm biến chạy pin gửi dữ liệu về broker
Làm gateway LoRa sang MQTT bằng ESP32: node cảm biến chạy pin gửi dữ liệu về broker

Hướng dẫn làm hệ thống LoRa hoàn chỉnh với ESP32 và SX1278: node chạy pin đo điện áp, gửi gói rồi ngủ sâu; gateway nhận gói LoRa và publish dữ liệu JSON lên broker MQTT qua Wi-Fi.


Bình luận
Đã thêm vào giỏ hàng