Bãi giữ xe thông minh: đếm chỗ trống bằng cảm biến và ESP32

Làm bãi giữ xe thông minh mẫu: ESP32 đọc cảm biến siêu âm từng ô, lọc người đi ngang, gửi ESP-NOW về trạm OLED, mô phỏng chọn thời gian lọc, so sánh với đếm ở cổng.

Bãi giữ xe thông minh ở mức cơ bản chỉ cần trả lời một câu: còn bao nhiêu chỗ trống. Phần cảm biến rất rẻ, cái khó là không báo sai khi người đi ngang, khi tiếng vọng mất, hay khi một mạch bị tắt. Bài này làm hệ thống mẫu với ESP32 và cảm biến siêu âm trên trần, truyền bằng ESP-NOW, hiện số chỗ trống lên OLED, mô phỏng để chọn thời gian lọc, và so sánh với cách đếm xe ở cổng.

1. Chọn cách đếm

Bảng so sánh đếm ở cổng, siêu âm trên trần, từ trường dưới sàn, camera theo lắp đặt và loại bãi
Bãi xe máy không kẻ ô thì đếm ở cổng là lựa chọn thực tế.

Ở Việt Nam, nhiều bãi xe máy không có ô cố định, xe xếp sát nhau, nên cách ô nào cảm biến nấy không dùng được. Khi đó đếm số xe vào ra ở cổng. Tầng hầm, nhà xe ô tô có ô rõ ràng thì cảm biến từng ô cho biết chính xác ô nào trống, có thể chỉ đường. Bài này làm cách cảm biến từng ô, rồi tính sai số của cách đếm ở cổng ở mục 5.

2. Hệ thống mẫu

Sơ đồ: các node ESP32 mỗi node 4 cảm biến gửi ESP-NOW về trạm ESP32, trạm hiện số chỗ trống trên OLED
ESP-NOW không cần router, phù hợp với tầng hầm không có Wi-Fi.
  • Node: ESP32 DevKit đọc 4 cảm biến HC-SR04 gắn trên trần, ngay trên 4 ô.
  • Trạm: một ESP32 nữa ở lối vào, hiện số chỗ trống lên màn hình OLED.
  • Nguồn: dùng nguồn 5 V cho cả cụm. Mỗi HC-SR04 khoảng 15 mA khi đo.

Cách làm nhiều node gửi về một trạm nói kỹ hơn ở bài ESP-NOW nhiều node.

3. Lắp cảm biến và nối dây

Sơ đồ nối hai HC-SR04 với ESP32: VCC vào VIN, TRIG D25, D26, ECHO D34, D35, GND
Bốn cảm biến đo lần lượt để không nghe nhầm tiếng vọng của nhau.

Gắn cảm biến ở giữa ô, chiếu thẳng xuống. Trần cao 2,5 m thì ô trống đọc khoảng 250 cm, có xe đọc khoảng 130 cm. Chương trình lấy ngưỡng bằng 75 % độ cao trần, tức 187 cm. Nhớ đổi TRAN_CM theo thực tế, và đo lại bằng tay ở từng ô. HC-SR04 chỉ dùng trong nhà: mưa, gió, bụi và nhiệt độ ngoài trời đều làm giảm độ tin cậy. Tốc độ âm thanh thay đổi theo nhiệt độ cũng không quan trọng ở đây, vì khoảng giữa hai mức đọc rất xa. Xem thêm cách dùng HC-SR04 ở bài xe tránh vật cản.

4. Chương trình

Chương trình node:

// PYWORLD.VN - Node bãi giữ xe: ESP32 đọc 4 cảm biến siêu âm gắn trên trần, lọc nhiễu, báo trạng thái từng ô về trạm bằng ESP-NOW
// PYWORLD.VN - TRIG GPIO25, 26, 27, 14; ECHO GPIO34, 35, 32, 33 qua cầu chia 1 kΩ, 2 kΩ; HC-SR04 cấp 5 V từ chân VIN
// PYWORLD.VN - Đổi SO_NODE cho từng mạch và TRAN_CM theo độ cao lắp; gửi khi trạng thái đổi và mỗi 30 giây một lần

#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>

const uint8_t SO_NODE = 1;
const int SO_O = 4;
const int CHAN_TRIG[SO_O] = {25, 26, 27, 14};
const int CHAN_ECHO[SO_O] = {34, 35, 32, 33};
const float TRAN_CM = 250.0f;                // khoảng cách tới sàn khi ô trống
const float NGUONG_CM = TRAN_CM * 0.75f;     // gần hơn mức này là có vật trong ô
const uint32_t GIU_MS = 20000;               // đổi trạng thái sau khi giữ đủ lâu
const uint32_t CHU_KY_BAO_MS = 30000;
const int SO_LAN_DO = 5;
const uint8_t KENH = 1;
const uint8_t QUANG_BA[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

struct __attribute__((packed)) GoiTin {
  uint8_t dau;                               // 0xA7
  uint8_t node;
  uint8_t coXe;                              // bit i: ô i có xe
  uint8_t matCamBien;                        // bit i: ô i không đọc được
  uint16_t thuTu;
  uint8_t xorKiem;
};

bool trangThai[SO_O];
bool ungVien[SO_O];
uint32_t lucUngVien[SO_O];
int soLanMat[SO_O];
uint16_t thuTu = 0;
uint32_t lucBao = 0;

float docMotLan(int o) {
  digitalWrite(CHAN_TRIG[o], HIGH);
  delayMicroseconds(10);
  digitalWrite(CHAN_TRIG[o], LOW);
  const unsigned long us = pulseIn(CHAN_ECHO[o], HIGH, 25000);
  return us == 0 ? -1.0f : us / 58.0f;       // không có tiếng vọng: trả -1
}

float trungVi(float *a, int n) {
  for (int i = 1; i < n; i++) {
    const float k = a[i];
    int j = i - 1;
    while (j >= 0 && a[j] > k) {
      a[j + 1] = a[j];
      j--;
    }
    a[j + 1] = k;
  }
  return a[n / 2];
}

float docO(int o) {
  float mau[SO_LAN_DO];
  int soMau = 0;
  for (int i = 0; i < SO_LAN_DO; i++) {
    const float d = docMotLan(o);
    if (d > 0) {
      mau[soMau++] = d;
    }
    delay(60);                               // chờ tiếng vọng cũ tắt
  }
  return soMau >= 3 ? trungVi(mau, soMau) : -1.0f;
}

void guiTrangThai() {
  GoiTin g{0xA7, SO_NODE, 0, 0, thuTu++, 0};
  for (int o = 0; o < SO_O; o++) {
    g.coXe |= (uint8_t)(trangThai[o] << o);
    g.matCamBien |= (uint8_t)((soLanMat[o] > 20) << o);
  }
  const uint8_t *b = (const uint8_t *)&g;
  for (size_t i = 0; i + 1 < sizeof(g); i++) {
    g.xorKiem ^= b[i];
  }
  esp_now_send(QUANG_BA, (const uint8_t *)&g, sizeof(g));
  lucBao = millis();
  Serial.printf("Gui #%u: co xe 0x%02X, mat 0x%02X\n", (unsigned)g.thuTu, g.coXe, g.matCamBien);
}

void setup() {
  Serial.begin(115200);
  for (int o = 0; o < SO_O; o++) {
    pinMode(CHAN_TRIG[o], OUTPUT);
    digitalWrite(CHAN_TRIG[o], LOW);
    pinMode(CHAN_ECHO[o], INPUT);
  }
  WiFi.mode(WIFI_STA);
  esp_wifi_set_channel(KENH, WIFI_SECOND_CHAN_NONE);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Loi ESP-NOW");
    return;
  }
  esp_now_peer_info_t ban = {};
  memcpy(ban.peer_addr, QUANG_BA, 6);
  ban.channel = KENH;
  ban.encrypt = false;
  esp_now_add_peer(&ban);
}

void loop() {
  bool doi = false;
  for (int o = 0; o < SO_O; o++) {
    const float d = docO(o);
    if (d < 0) {                             // kính xe nghiêng có thể làm mất tiếng vọng: giữ nguyên trạng thái
      soLanMat[o]++;
      continue;
    }
    soLanMat[o] = 0;
    const bool coVat = d < NGUONG_CM;
    if (coVat == trangThai[o]) {
      ungVien[o] = trangThai[o];
    } else if (coVat != ungVien[o]) {
      ungVien[o] = coVat;
      lucUngVien[o] = millis();
    } else if (millis() - lucUngVien[o] >= GIU_MS) {
      trangThai[o] = coVat;
      doi = true;
    }
  }
  if (doi || millis() - lucBao >= CHU_KY_BAO_MS) {
    guiTrangThai();
  }
}

Chương trình trạm dùng màn hình OLED 0,96 inch:

// PYWORLD.VN - Trạm bãi giữ xe: ESP32 nhận trạng thái các node qua ESP-NOW, tính số chỗ trống, hiện lên OLED I2C D21, D22
// PYWORLD.VN - Node quá 90 giây không báo thì coi các ô của node đó là chưa rõ, không cộng vào số chỗ trống
// PYWORLD.VN - Đổi SO_NODE_TOI_DA theo số mạch; kênh Wi-Fi phải trùng với node

#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <Wire.h>
#include <esp_now.h>
#include <esp_wifi.h>

const int SO_NODE_TOI_DA = 8;
const int O_MOI_NODE = 4;
const uint32_t HET_HAN_MS = 90000;
const uint8_t KENH = 1;

struct __attribute__((packed)) GoiTin {
  uint8_t dau;
  uint8_t node;
  uint8_t coXe;
  uint8_t matCamBien;
  uint16_t thuTu;
  uint8_t xorKiem;
};

struct TrangThaiNode {
  volatile uint8_t coXe;
  volatile uint8_t mat;
  volatile uint32_t lucNhan;
  volatile bool daNhan;
};

TrangThaiNode nodes[SO_NODE_TOI_DA];
Adafruit_SSD1306 oled(128, 64, &Wire, -1);

void nhanGoi(const esp_now_recv_info_t *tt, const uint8_t *du, int dai) {
  if (dai != sizeof(GoiTin)) {
    return;
  }
  GoiTin g;
  memcpy(&g, du, sizeof(g));
  uint8_t x = 0;
  for (size_t i = 0; i + 1 < sizeof(g); i++) {
    x ^= du[i];
  }
  if (g.dau != 0xA7 || x != g.xorKiem || g.node == 0 || g.node > SO_NODE_TOI_DA) {
    return;
  }
  TrangThaiNode &n = nodes[g.node - 1];
  n.coXe = g.coXe;
  n.mat = g.matCamBien;
  n.lucNhan = millis();
  n.daNhan = true;
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  WiFi.mode(WIFI_STA);
  esp_wifi_set_channel(KENH, WIFI_SECOND_CHAN_NONE);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Loi ESP-NOW");
    return;
  }
  esp_now_register_recv_cb(nhanGoi);
}

void loop() {
  static uint32_t lucVe = 0;
  if (millis() - lucVe < 1000) {
    return;
  }
  lucVe = millis();
  int trong = 0, coXe = 0, chuaRo = 0, nodeSong = 0;
  for (int i = 0; i < SO_NODE_TOI_DA; i++) {
    const TrangThaiNode &n = nodes[i];
    if (!n.daNhan) {
      continue;
    }
    if (millis() - n.lucNhan > HET_HAN_MS) {
      chuaRo += O_MOI_NODE;
      continue;
    }
    nodeSong++;
    for (int o = 0; o < O_MOI_NODE; o++) {
      if ((n.mat >> o) & 1) {
        chuaRo++;
      } else if ((n.coXe >> o) & 1) {
        coXe++;
      } else {
        trong++;
      }
    }
  }
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.print("CHO TRONG");
  oled.setTextSize(4);
  oled.setCursor(0, 14);
  oled.print(trong);
  oled.setTextSize(1);
  oled.setCursor(0, 50);
  oled.printf("Xe %d  Chua ro %d  N%d", coXe, chuaRo, nodeSong);
  oled.display();
  Serial.printf("Trong %d, co xe %d, chua ro %d, %d node\n", trong, coXe, chuaRo, nodeSong);
}

Vài điểm trong logic:

  • Trung vị 5 lần bỏ số đọc lẻ. Cần ít nhất 3 lần có tiếng vọng.
  • Mất tiếng vọng thì giữ nguyên trạng thái. Kính xe nghiêng có thể hắt sóng đi nơi khác. Nếu coi đó là ô trống thì sẽ báo sai ngay lúc xe đỗ.
  • Đếm mất cảm biến: quá 20 vòng không đọc được, ô đó báo hỏng và trạm cho vào mục Chưa rõ. Số chỗ trống vì thế không đếm những ô không chắc.
  • Node im lặng cũng là thông tin. Quá 90 giây không có tin, trạm không tiếp tục tin vào số cũ. Node chưa gửi gói nào từ lúc trạm bật thì trạm chưa biết tới, nên cần đếm tổng số ô khi lắp.

5. Chọn thời gian giữ, và sai số ở cổng

Script dưới đây mô phỏng một ô trong nhiều ngày, với đúng logic của node, và tính cả sai số của cách đếm ở cổng.

# PYWORLD.VN - Mô phỏng hai cách đếm chỗ trống: cảm biến từng ô với bộ lọc giữ trạng thái, và đếm xe vào ra ở cổng
# PYWORLD.VN - Dữ liệu giả lập: 200 ngày-ô, người đi qua che cảm biến, nhiễu đọc sai, mất tiếng vọng khi có xe; tỉ lệ sót ở cổng là số tự chọn
# PYWORLD.VN - Chạy: python mo_phong_bai_xe.py          (cần numpy)
import numpy as np

TRAN_CM = 250
NGUONG_CM = TRAN_CM * 0.75


def tao_ngay(r):
    """Một ngày, mỗi giây một số đo đã qua trung vị: trả về (có xe thật, khoảng cách; -1 là mất tiếng vọng)."""
    n = 86400
    xe = np.zeros(n, bool)
    t = int(r.uniform(6, 9) * 3600)
    while t < 22 * 3600:
        o_lai = int(r.exponential(2.5 * 3600)) + 600
        xe[t:t + o_lai] = True
        t += o_lai + int(r.exponential(1800)) + 120
    d = np.where(xe, r.normal(130, 4, n), r.normal(TRAN_CM, 2, n))
    for _ in range(r.poisson(90)):              # người, xe khác đi ngang qua ô trống hoặc che cảm biến
        bat_dau = int(r.uniform(6, 22) * 3600)
        d[bat_dau:bat_dau + int(r.uniform(2, 14))] = r.uniform(70, 170)
    loi = r.random(n) < 0.002                   # số đọc sai còn sót sau trung vị
    d[loi] = r.uniform(30, 300, loi.sum())
    mat = xe & (r.random(n) < 0.03)             # kính xe nghiêng làm mất tiếng vọng
    d[mat] = -1
    return xe, d


def loc(d, giu_s):
    """Đúng logic loop() của node: giữ đủ lâu mới đổi, mất tiếng vọng thì giữ nguyên."""
    ra = np.zeros(d.size, bool)
    tt = uv = False
    luc = 0
    for i, v in enumerate(d):
        if v >= 0:
            co = v < NGUONG_CM
            if co == tt:
                uv = tt
            elif co != uv:
                uv = co
                luc = i
            elif i - luc >= giu_s:
                tt = co
        ra[i] = tt
    return ra


def danh_gia(giu_s, so_ngay=200, hat=3):
    r = np.random.default_rng(hat)
    bao_sai, doi_that, sai_giay = 0, 0, 0
    for _ in range(so_ngay):
        xe, d = tao_ngay(r)
        ra = loc(d, giu_s)
        doi_ra = np.flatnonzero(ra[1:] != ra[:-1]).size
        doi_xe = np.flatnonzero(xe[1:] != xe[:-1]).size
        bao_sai += max(doi_ra - doi_xe, 0)
        doi_that += doi_xe
        sai_giay += int((ra != xe).sum())
    return dict(giu=giu_s, bao_sai=bao_sai / so_ngay, doi=doi_that / so_ngay, sai=sai_giay / so_ngay / 86400 * 100, tre=giu_s + 1)


def troi_cong(ngay, luot_ngay=300, sot=0.01, trung=0.003, so_lan=4000, hat=5):
    """Sai số số xe trong bãi nếu chỉ đếm ở cổng, không đặt lại: đếm sót xe vào, xe ra, đếm trùng."""
    r = np.random.default_rng(hat)
    n = ngay * luot_ngay
    vao_sot = r.binomial(n, sot, so_lan)
    ra_sot = r.binomial(n, sot, so_lan)
    vao_trung = r.binomial(n, trung, so_lan)
    ra_trung = r.binomial(n, trung, so_lan)
    e = (ra_sot - vao_sot) + (vao_trung - ra_trung)
    return float(np.std(e)), float(np.percentile(np.abs(e), 95))


if __name__ == "__main__":
    print("Giu (s) | bao sai/ngay | doi that/ngay | % thoi gian sai | tre")
    for g in (0, 5, 10, 20, 40):
        k = danh_gia(g)
        print(f"{g:3d} | {k['bao_sai']:6.1f} | {k['doi']:4.1f} | {k['sai']:5.2f} | {k['tre']} s")
    print("Bai 100 cho, 300 luot moi ngay, chi dem o cong:")
    for ngay in (1, 7, 30):
        s, p = troi_cong(ngay)
        print(f"  {ngay:2d} ngay: lech chuan {s:.1f} xe, 95 % trong {p:.0f} xe")
Bảng thời gian giữ 0, 5, 10, 20, 40 giây với số lần đổi sai mỗi ngày, trễ và tỉ lệ sai
Giả định người đi qua che cảm biến tối đa 14 giây.

Không lọc, mỗi ô đổi sai 43 lần mỗi ngày, gấp bốn lần số lần đổi đúng. Giữ 20 giây thì hết đổi sai trong mô phỏng, đổi lại bảng báo chậm 21 giây, điều chấp nhận được với xe đang tìm chỗ. Số 14 giây là giả định: ở bãi có xe dừng chờ lâu hơn thì phải tăng thời gian giữ.

Bảng sai số số xe khi chỉ đếm ở cổng sau 1, 7, 30 ngày không đặt lại
Sót 1 % nghe nhỏ, nhưng sai số cộng dồn.

Đếm ở cổng thì rẻ, nhưng sai dồn: sau 7 ngày, số xe lệch tới khoảng 15 xe trong 95 % trường hợp. Giải pháp là đặt lại mỗi đêm bằng số đếm thật. Hai cảm biến phải đặt cách nhau để xác định chiều đi. Xe máy đi sát nhau là nguyên nhân chính gây đếm sót.

6. Mở rộng và lưu ý

  • Tổng số ô: khai báo cố định trong trạm, để phát hiện node chưa từng gửi tin.
  • Chiều cao xe: xe bán tải, xe tải nhỏ làm khoảng cách nhỏ hơn nữa, vẫn nằm dưới ngưỡng. Xe máy đỗ vào ô ô tô thì có thể bị lọt nếu cảm biến không chiếu trúng.
  • Đèn chỉ dẫn: thêm LED xanh, đỏ ở mỗi ô, hoặc bảng số ở đầu mỗi dãy.
  • Bảo mật: ESP-NOW trong bài không mã hóa. Hệ thống có thu phí hoặc mở cổng thì phải dùng mã hóa, và không tin dữ liệu từ gói tin không xác thực.

Tin tức liên quan

Bàn phím 4x4 và còi với ESP32: quét ma trận, chống dội, khóa mật khẩu và âm báo không cần thư viện
Bàn phím 4x4 và còi với ESP32: quét ma trận, chống dội, khóa mật khẩu và âm báo không cần thư viện

Cách quét bàn phím ma trận 4x4, chống dội phím, nối còi thụ động qua transistor, chương trình khóa mật khẩu có đổi mật khẩu, khóa tạm khi nhập sai và âm báo không dùng delay.

Đo dòng điện với ESP32: INA219 hay ACS712, đo dòng, điện áp, công suất và mAh không cần thư viện
Đo dòng điện với ESP32: INA219 hay ACS712, đo dòng, điện áp, công suất và mAh không cần thư viện

So sánh INA219 (điện trở shunt, I2C) và ACS712 (cảm biến Hall), nối với ESP32 có cầu phân áp, chương trình đo dòng, điện áp, công suất, điện lượng mAh không cần thư viện, kèm bảng lỗi.

Hướng dẫn dùng thanh cảm biến dò line 5 kênh TCRT5000 với ESP32
Hướng dẫn dùng thanh cảm biến dò line 5 kênh TCRT5000 với ESP32

Hướng dẫn nối thanh dò line 5 kênh TCRT5000 (BFD-1000) với ESP32 DevKit V1, đọc trạng thái 5 kênh trên Serial Monitor, tính vị trí vạch và gợi ý hướng lái cho xe dò line.

Hướng dẫn bắt đầu với Wemos D1 R2 (ESP8266) trên Arduino IDE
Hướng dẫn bắt đầu với Wemos D1 R2 (ESP8266) trên Arduino IDE

Hướng dẫn dùng Wemos D1 R2 (ESP8266 khung Arduino UNO) với Arduino IDE 2.x: sơ đồ chân, cài driver CH340 và gói board ESP8266, nạp code nháy LED, kết nối WiFi và xử lý lỗi thường gặp.

Hướng dẫn ESP32 điều khiển module relay 4 kênh an toàn
Hướng dẫn ESP32 điều khiển module relay 4 kênh an toàn

Hướng dẫn ESP32 điều khiển relay 4 kênh: chọn GPIO an toàn, cấp nguồn 5 V phù hợp, xác định active LOW và lưu ý khi đóng cắt tải.

Đưa ESP32 và relay 4 kênh vào Home Assistant bằng MQTT discovery, không viết YAML
Đưa ESP32 và relay 4 kênh vào Home Assistant bằng MQTT discovery, không viết YAML

ESP32 tự gửi tin cấu hình MQTT discovery, Home Assistant tạo 4 công tắc relay và cảm biến Wi-Fi; Mosquitto, tài khoản, di chúc, retain và bảng xử lý lỗi.

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.

Phát hiện chuyển động bằng cảm biến PIR HC-SR501 với ESP32: tự bật đèn qua relay
Phát hiện chuyển động bằng cảm biến PIR HC-SR501 với ESP32: tự bật đèn qua relay

Hướng dẫn dùng cảm biến chuyển động PIR HC-SR501 với ESP32: nguyên lý, cách chỉnh module, đọc tín hiệu bằng ngắt và ghép relay làm đèn tự bật khi có người, tự tắt sau 60 giây.


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