Điều khiển relay từ xa bằng ESP-NOW: remote ESP32 một nút nhấn có phản hồi trạng thái
Hướng dẫn làm bộ điều khiển relay từ xa bằng hai mạch ESP32 và ESP-NOW hai chiều: remote gửi lệnh, mạch relay báo lại trạng thái, lọc theo địa chỉ MAC và bật mã hóa.
Với ESP-NOW, hai mạch ESP32 làm được một bộ điều khiển từ xa hoàn chỉnh mà không cần router hay điện thoại: một mạch làm remote có nút nhấn, mạch còn lại đóng cắt relay. Bài viết hướng dẫn dùng ESP-NOW theo hai chiều: remote gửi lệnh, mạch relay thực hiện rồi báo lại trạng thái, nhờ đó đèn trên remote luôn cho biết relay đang đóng hay nhả. Bài cũng trình bày cách chỉ nhận lệnh từ đúng remote và cách bật mã hóa.
1. ESP-NOW hai chiều hoạt động thế nào?
- Ở ESP-NOW, mạch nào muốn gửi thì khai báo mạch kia làm peer. Để trao đổi hai chiều, cả hai mạch đều khai báo địa chỉ MAC của nhau.
- Xác nhận ở tầng vô tuyến chỉ cho biết gói đã tới mạch nhận, chưa cho biết relay đã đổi trạng thái. Vì vậy mạch relay gửi thêm một gói phản hồi mang trạng thái thật của relay.
- Phản hồi mang cùng số thứ tự với lệnh. Remote chỉ chấp nhận phản hồi có đúng số thứ tự của lệnh vừa gửi.
2. Chuẩn bị
- Hai mạch ESP32 DevKit V1 30 chân. Cần điều khiển ở khoảng cách xa thì dùng bản ESP32 có cổng anten rời.
- Module relay bản 5 V và dây cắm test board.
- Hai cáp USB có truyền dữ liệu.
- Arduino IDE 2.x với gói esp32 by Espressif Systems (bài dùng phiên bản 3.3.10). Không cần cài thêm thư viện.
An toàn điện: chạy thử với module relay chưa nối tải. Khi dùng relay đóng cắt điện lưới 220 V, phải ngắt nguồn trước khi đấu dây, dùng hộp cách điện và không chạm vào mạch lúc có điện. Xem phần an toàn trong bài ESP32 điều khiển module relay.
3. Nối dây mạch relay
| Module relay | ESP32 DevKit V1 (mạch relay) |
|---|---|
| IN1 | D18 (GPIO18) |
| VCC | VIN (5 V từ cổng USB) |
| GND | GND |
Remote dùng nút BOOT (GPIO0) và LED có sẵn trên mạch (GPIO2). Khi làm remote thật, có thể nối một nút nhấn rời giữa một chân GPIO khác và GND rồi đổi hằng NUT_NHAN.
4. Lấy địa chỉ MAC của hai mạch
Bài này cần địa chỉ MAC của cả hai mạch. Nạp chương trình xem địa chỉ MAC (ở bài ESP-NOW cơ bản) lần lượt vào từng mạch và ghi lại:
- MAC của mạch relay điền vào
MAC_MACH_RELAYtrong chương trình remote. - MAC của remote điền vào
MAC_REMOTEtrong chương trình mạch relay.
5. Chương trình cho remote
/*
PYWORLD.VN - ESP-NOW hai chiều: REMOTE một nút nhấn
Nhấn nút BOOT (GPIO0) để gửi lệnh đảo relay; mạch relay báo lại trạng thái, LED GPIO2 sáng khi relay đang đóng
*/
#include <WiFi.h>
#include <esp_now.h>
// Địa chỉ MAC của mạch relay: sửa theo mạch thật
uint8_t MAC_MACH_RELAY[6] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56};
#define NUT_NHAN 0
#define LED_PIN 2
const unsigned long CHONG_DOI_MS = 50;
const unsigned long CHO_PHAN_HOI_MS = 300;
// Bật mã hóa: đặt true ở CẢ HAI chương trình và dùng cùng hai khóa bên dưới
const bool DUNG_MA_HOA = false;
const char KHOA_CHUNG[17] = "PYWORLD-PMK-0001"; // PMK, đúng 16 ký tự
const char KHOA_RIENG[17] = "PYWORLD-LMK-0001"; // LMK, đúng 16 ký tự
enum LoaiGoi : uint8_t { GOI_LENH = 1, GOI_PHAN_HOI = 2 };
enum Lenh : uint8_t { LENH_HOI = 0, LENH_DAO = 1, LENH_BAT = 2, LENH_TAT = 3 };
// Hai mạch phải khai báo cấu trúc này giống hệt nhau
typedef struct {
uint8_t loai; // GOI_LENH hoặc GOI_PHAN_HOI
uint8_t lenh; // Lệnh gửi đi (mạch relay chép lại trong phản hồi)
uint8_t relayBat; // Trạng thái relay sau khi thực hiện, chỉ có nghĩa trong phản hồi
uint32_t soThuTu; // Phản hồi mang cùng số thứ tự với lệnh
} GoiDieuKhien;
volatile bool coPhanHoi = false;
volatile uint8_t relayBat = 0;
volatile uint32_t soPhanHoi = 0;
uint32_t soThuTu = 0;
bool sanSang = false;
bool nutTruoc = HIGH;
unsigned long lucDoiNut = 0;
void khiNhan(const esp_now_recv_info_t *thongTin, const uint8_t *duLieu, int doDai) {
if (doDai != sizeof(GoiDieuKhien) || memcmp(thongTin->src_addr, MAC_MACH_RELAY, 6) != 0) {
return;
}
GoiDieuKhien goi;
memcpy(&goi, duLieu, sizeof(goi));
if (goi.loai != GOI_PHAN_HOI) {
return;
}
relayBat = goi.relayBat;
soPhanHoi = goi.soThuTu;
coPhanHoi = true;
}
// Gửi một lệnh rồi chờ phản hồi; trả về true khi mạch relay trả lời đúng số thứ tự
bool guiLenh(uint8_t lenh) {
GoiDieuKhien goi = {};
goi.loai = GOI_LENH;
goi.lenh = lenh;
goi.soThuTu = ++soThuTu;
coPhanHoi = false;
esp_now_send(MAC_MACH_RELAY, (const uint8_t *)&goi, sizeof(goi));
unsigned long batDau = millis();
while (millis() - batDau < CHO_PHAN_HOI_MS) {
if (coPhanHoi && soPhanHoi == goi.soThuTu) {
digitalWrite(LED_PIN, relayBat ? HIGH : LOW);
Serial.printf("Lenh %u (goi %lu): relay dang %s\n", lenh, (unsigned long)goi.soThuTu, relayBat ? "DONG" : "nha");
return true;
}
delay(2);
}
Serial.printf("Lenh %u (goi %lu): KHONG co phan hoi\n", lenh, (unsigned long)goi.soThuTu);
return false;
}
void baoLoi() {
for (int i = 0; i < 6; i++) {
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
delay(80);
}
digitalWrite(LED_PIN, relayBat ? HIGH : LOW);
}
void setup() {
Serial.begin(115200);
pinMode(NUT_NHAN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("Khoi dong ESP-NOW that bai");
return;
}
esp_now_register_recv_cb(khiNhan);
esp_now_peer_info_t machRelay = {};
memcpy(machRelay.peer_addr, MAC_MACH_RELAY, 6);
machRelay.channel = 0;
if (DUNG_MA_HOA) {
esp_now_set_pmk((const uint8_t *)KHOA_CHUNG);
memcpy(machRelay.lmk, KHOA_RIENG, 16);
machRelay.encrypt = true;
}
if (esp_now_add_peer(&machRelay) != ESP_OK) {
Serial.println("Khong them duoc mach relay");
return;
}
sanSang = true;
Serial.println("Remote san sang, nhan nut BOOT de dao relay");
guiLenh(LENH_HOI); // Hỏi trạng thái hiện tại để LED hiển thị đúng ngay từ đầu
}
void loop() {
if (!sanSang) {
return;
}
bool nut = digitalRead(NUT_NHAN);
if (nut != nutTruoc && millis() - lucDoiNut > CHONG_DOI_MS) {
lucDoiNut = millis();
nutTruoc = nut;
if (nut == LOW) { // Vừa nhấn xuống
if (!guiLenh(LENH_DAO)) {
baoLoi();
}
}
}
}
6. Chương trình cho mạch relay
Hằng RELAY_ACTIVE_LOW để true cho module kích mức thấp (loại phổ biến). Nếu relay đóng ngược với lệnh, đổi thành false.
/*
PYWORLD.VN - ESP-NOW hai chiều: MẠCH RELAY
Nhận lệnh từ remote, đóng/nhả relay rồi báo lại trạng thái
Module relay bản 5 V: VCC = VIN, GND = GND, IN1 = GPIO18
*/
#include <WiFi.h>
#include <esp_now.h>
// Địa chỉ MAC của remote: mạch relay chỉ nhận lệnh từ địa chỉ này
uint8_t MAC_REMOTE[6] = {0x24, 0x6F, 0x28, 0xAB, 0xCD, 0xEF};
#define RELAY_PIN 18
// true: module kích mức thấp (IN = LOW thì relay đóng)
// false: module kích mức cao (IN = HIGH thì relay đóng)
const bool RELAY_ACTIVE_LOW = true;
// Bật mã hóa: đặt true ở CẢ HAI chương trình và dùng cùng hai khóa bên dưới
const bool DUNG_MA_HOA = false;
const char KHOA_CHUNG[17] = "PYWORLD-PMK-0001"; // PMK, đúng 16 ký tự
const char KHOA_RIENG[17] = "PYWORLD-LMK-0001"; // LMK, đúng 16 ký tự
enum LoaiGoi : uint8_t { GOI_LENH = 1, GOI_PHAN_HOI = 2 };
enum Lenh : uint8_t { LENH_HOI = 0, LENH_DAO = 1, LENH_BAT = 2, LENH_TAT = 3 };
// Hai mạch phải khai báo cấu trúc này giống hệt nhau
typedef struct {
uint8_t loai;
uint8_t lenh;
uint8_t relayBat;
uint32_t soThuTu;
} GoiDieuKhien;
GoiDieuKhien lenhCho;
volatile bool coLenh = false;
bool relayDangBat = false;
void datRelay(bool bat) {
bool muc = RELAY_ACTIVE_LOW ? !bat : bat;
digitalWrite(RELAY_PIN, muc ? HIGH : LOW);
relayDangBat = bat;
}
// Chạy trong tác vụ Wi-Fi: kiểm tra nguồn gửi, chép lệnh rồi thoát
void khiNhan(const esp_now_recv_info_t *thongTin, const uint8_t *duLieu, int doDai) {
if (doDai != sizeof(GoiDieuKhien) || memcmp(thongTin->src_addr, MAC_REMOTE, 6) != 0) {
return; // Bỏ qua gói sai kích thước hoặc không phải từ remote đã khai báo
}
GoiDieuKhien goi;
memcpy(&goi, duLieu, sizeof(goi));
if (goi.loai != GOI_LENH) {
return;
}
lenhCho = goi;
coLenh = true;
}
void setup() {
Serial.begin(115200);
// Đặt mức "tắt" trước khi chuyển chân thành ngõ ra để relay không đóng lúc khởi động
digitalWrite(RELAY_PIN, RELAY_ACTIVE_LOW ? HIGH : LOW);
pinMode(RELAY_PIN, OUTPUT);
datRelay(false);
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("Khoi dong ESP-NOW that bai");
return;
}
esp_now_register_recv_cb(khiNhan);
// Khai báo remote làm peer để gửi phản hồi
esp_now_peer_info_t remote = {};
memcpy(remote.peer_addr, MAC_REMOTE, 6);
remote.channel = 0;
if (DUNG_MA_HOA) {
esp_now_set_pmk((const uint8_t *)KHOA_CHUNG);
memcpy(remote.lmk, KHOA_RIENG, 16);
remote.encrypt = true;
}
if (esp_now_add_peer(&remote) != ESP_OK) {
Serial.println("Khong them duoc remote");
return;
}
Serial.println("Mach relay san sang, dang cho lenh");
}
void loop() {
if (!coLenh) {
return;
}
coLenh = false;
GoiDieuKhien lenh = lenhCho;
switch (lenh.lenh) {
case LENH_DAO: datRelay(!relayDangBat); break;
case LENH_BAT: datRelay(true); break;
case LENH_TAT: datRelay(false); break;
default: break; // LENH_HOI: chỉ báo trạng thái
}
GoiDieuKhien phanHoi = {};
phanHoi.loai = GOI_PHAN_HOI;
phanHoi.lenh = lenh.lenh;
phanHoi.relayBat = relayDangBat ? 1 : 0;
phanHoi.soThuTu = lenh.soThuTu;
esp_now_send(MAC_REMOTE, (const uint8_t *)&phanHoi, sizeof(phanHoi));
Serial.printf("Nhan lenh %u (goi %lu) -> relay %s\n", lenh.lenh, (unsigned long)lenh.soThuTu,
relayDangBat ? "DONG" : "nha");
}
7. Kết quả mong đợi
- Cấp nguồn mạch relay trước, sau đó cấp nguồn remote: remote hỏi trạng thái và LED hiện đúng ngay.
- Mỗi lần nhấn nút BOOT trên remote, relay đổi trạng thái (nghe tiếng "tách"), LED trên remote sáng khi relay đang đóng.
- Tắt nguồn mạch relay rồi nhấn nút: LED trên remote nháy nhanh ba lần, Serial in
KHONG co phan hoi.
8. Giải thích các điểm chính
- Một cấu trúc cho cả hai chiều: trường
loaiphân biệt lệnh với phản hồi, nên hai mạch dùng chung hàm nhận và chung kích thước gói. - Lọc theo địa chỉ MAC: lệnh
memcmp(thongTin->src_addr, MAC_REMOTE, 6)loại mọi gói không đến từ remote đã khai báo. - Chờ phản hồi có thời hạn: hàm
guiLenh()chờ tối đa 300 ms. Hết thời gian mà chưa có phản hồi đúng số thứ tự thì coi là thất bại. - Chống dội nút: chương trình chỉ nhận một lần đổi trạng thái của nút sau mỗi 50 ms và chỉ gửi lệnh ở lúc nút vừa được nhấn xuống.
- Relay không đóng lúc khởi động: mạch relay ghi mức "tắt" ra chân trước khi chuyển chân thành ngõ ra.
- Xử lý ngoài hàm gọi lại: hàm nhận chỉ chép lệnh vào biến; việc đóng relay và gửi phản hồi làm trong
loop().
9. Bật mã hóa
Mặc định gói ESP-NOW không mã hóa. Việc lọc theo địa chỉ MAC ngăn được nhầm lẫn giữa các bộ điều khiển đặt gần nhau, nhưng địa chỉ MAC có thể bị giả mạo. Với thiết bị điều khiển tải thật nên bật mã hóa:
- Trong cả hai chương trình, đổi
DUNG_MA_HOAthànhtrue. - Đổi hai khóa
KHOA_CHUNG(PMK) vàKHOA_RIENG(LMK) thành chuỗi riêng, mỗi chuỗi đúng 16 ký tự, và đặt giống nhau ở hai chương trình. - Nạp lại cả hai mạch.
Khi đó ESP-NOW mã hóa gói bằng thuật toán CCMP. Mạch nào không có đúng khóa sẽ không đọc được và không gửi được lệnh hợp lệ. Số peer có mã hóa trên một mạch ít hơn số peer không mã hóa, nên cần lưu ý khi một mạch điều khiển nhiều thiết bị.
10. Mở rộng
- Nhiều nút, nhiều relay: thêm trường số kênh vào
GoiDieuKhienvà dùng các chân IN2 đến IN4 của module relay 4 kênh. - Bật, tắt riêng: dùng
LENH_BATvàLENH_TATthay cho lệnh đảo khi cần chắc chắn về trạng thái cuối, ví dụ nút "tắt hết". - Remote chạy pin: cho remote ngủ sâu và đánh thức bằng chính nút nhấn, xem bài Deep Sleep trên ESP32. Nguồn pin có thể lấy từ khay pin 18650 hoặc mạch ESP32 tích hợp pin 18650.
- Gửi lại khi thất bại: gọi lại
guiLenh()một hoặc hai lần trước khi báo lỗi. Với lệnh đảo cần cẩn thận vì gửi lại có thể đảo relay hai lần; lệnh bật, tắt riêng không gặp vấn đề này.
11. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
Remote luôn in KHONG co phan hoi, relay không đổi | Sai MAC_MACH_RELAY ở remote hoặc sai MAC_REMOTE ở mạch relay | Xem lại địa chỉ MAC của từng mạch; lưu ý không điền nhầm địa chỉ của chính mạch đó |
| Relay đổi trạng thái nhưng remote vẫn báo không có phản hồi | Mạch relay chưa khai báo được remote làm peer nên không gửi phản hồi được | Kiểm tra MAC_REMOTE; xem Serial Monitor của mạch relay có dòng báo lỗi lúc khởi động không |
| Sau khi bật mã hóa thì không điều khiển được | Chỉ bật ở một mạch hoặc hai khóa không giống nhau | Đặt DUNG_MA_HOA và hai khóa giống hệt ở hai chương trình rồi nạp lại cả hai |
| Relay đóng ngay khi cấp nguồn hoặc đóng ngược với LED | Module kích mức cao nhưng chương trình đặt kích mức thấp | Đổi RELAY_ACTIVE_LOW thành false |
| Một lần nhấn mà relay đổi hai lần | Nút nhấn rời bị dội nhiều hơn nút trên mạch | Tăng CHONG_DOI_MS lên 80 đến 100 |
| Điều khiển chập chờn khi relay nối tải lớn | Nguồn 5 V từ cổng USB sụt áp khi cuộn relay hút | Cấp nguồn riêng 5 V cho module relay và nối chung GND, xem bài về mạch hạ áp LM2596 và MP1584 |
12. Xem thêm
- ESP32 điều khiển module relay 4 kênh an toàn: cách đấu tải và các lưu ý an toàn điện.
- ESP32 làm web server điều khiển relay qua Wi-Fi và điều khiển relay bằng điện thoại qua Bluetooth: hai cách điều khiển khác khi có điện thoại.
- Ngắt GPIO trên ESP32: đọc nút nhấn và chống dội.
- Các mạch ESP32 tại PYWORLD: danh mục ESP32.
Xem thêm