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

Ở 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

- 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

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")

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ữ.

Đế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.
Xem thêm