Đ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?

Mô hình ESP-NOW hai chiều giữa remote và mạch relay ESP32: lệnh đi, phản hồi về, kèm bảng các trường của gói GoiDieuKhien và danh sách lệnh
Lệnh và phản hồi dùng chung một cấu trúc gói 8 byte (hình vẽ minh họa).
  • Ở 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ị

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

Sơ đồ nối mạch relay ESP-NOW: ESP32 DevKit V1 với module relay 5 V, IN1 nối D18, VCC nối VIN, GND nối GND
Sơ đồ nối ESP32 với module relay (hình vẽ minh họa). Remote không cần nối dây.
Module relayESP32 DevKit V1 (mạch relay)
IN1D18 (GPIO18)
VCCVIN (5 V từ cổng USB)
GNDGND

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_RELAY trong chương trình remote.
  • MAC của remote điền vào MAC_REMOTE trong 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

Trình tự xử lý một lần nhấn nút trên remote ESP-NOW: nhấn nút, gửi lệnh, mạch relay kiểm tra địa chỉ MAC, thực hiện và phản hồi, LED remote hiển thị
Năm bước của một lần nhấn nút và cách remote báo khi không có phản hồi (hình vẽ minh họa).
Hình minh họa Serial Monitor của remote và mạch relay ESP-NOW: ba lệnh được thực hiện, lệnh thứ tư không có phản hồi vì mạch relay tắt nguồn
Kết quả mong đợi trên Serial Monitor của hai mạch (hình minh họa).
  • 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 loai phâ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:

  1. Trong cả hai chương trình, đổi DUNG_MA_HOA thành true.
  2. Đổ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.
  3. 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 GoiDieuKhien và 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_BAT và LENH_TAT thay 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ượngNguyên nhân thường gặpCách xử lý
Remote luôn in KHONG co phan hoi, relay không đổiSai MAC_MACH_RELAY ở remote hoặc sai MAC_REMOTE ở mạch relayXem 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ồiMạch relay chưa khai báo được remote làm peer nên không gửi phản hồi đượcKiể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 đượcChỉ 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 LEDModule 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ầnNút nhấn rời bị dội nhiều hơn nút trên mạchTăng CHONG_DOI_MS lên 80 đến 100
Điều khiển chập chờn khi relay nối tải lớnNguồn 5 V từ cổng USB sụt áp khi cuộn relay hútCấ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


Tin tức liên quan

Phased array siêu âm 40 kHz với ESP32 (phần 2): mạch đệm 12 V, đầu thu và quét búp sóng
Phased array siêu âm 40 kHz với ESP32 (phần 2): mạch đệm 12 V, đầu thu và quét búp sóng

Phần 2 của loạt phased array siêu âm: thêm mạch đệm 12 V bằng hai module L298N, lắp mạch thu dùng LM358, đo biên độ 40 kHz bằng ADC của ESP32 và chạy chương trình tự quét góc lái để xem búp sóng.

Phased array siêu âm 40 kHz với ESP32 (phần 1): nguyên lý lái búp sóng và tạo 8 kênh lệch pha
Phased array siêu âm 40 kHz với ESP32 (phần 1): nguyên lý lái búp sóng và tạo 8 kênh lệch pha

Nguyên lý phased array và cách dùng ESP32 tạo 8 kênh xung 40 kHz lệch pha để lái búp sóng siêu âm: công thức độ trễ, giới hạn do khoảng cách đầu phát, cấu hình LEDC với hpoint, sơ đồ nối và code.

Phát hiện chuyển động bằng sóng Wi-Fi (CSI) trên ESP32: không cần cảm biến
Phát hiện chuyển động bằng sóng Wi-Fi (CSI) trên ESP32: không cần cảm biến

Giải thích Wi-Fi CSI và hướng dẫn dùng ESP32 phát hiện người di chuyển trong phòng chỉ bằng sóng Wi-Fi từ router: lấy dữ liệu CSI, tính điểm chuyển động, đặt ngưỡng và xem trên Serial Plotter.

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.

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.

Cấu hình LoRa trên ESP32: SF, băng thông, tỉ lệ mã, công suất và cách thử tầm xa
Cấu hình LoRa trên ESP32: SF, băng thông, tỉ lệ mã, công suất và cách thử tầm xa

Giải thích bốn tham số cấu hình LoRa (SF, băng thông, tỉ lệ mã, công suất), cách tính thời gian phát một gói và hướng dẫn làm bộ thử tầm xa bằng ESP32, SX1278 và màn hình OLED.

Hướng dẫn dùng LoRa SX1278 (Ra-02) với ESP32: nối dây SPI, gửi và nhận gói tin đầu tiên
Hướng dẫn dùng LoRa SX1278 (Ra-02) với ESP32: nối dây SPI, gửi và nhận gói tin đầu tiên

Hướng dẫn nối module LoRa SX1278 Ra-02 433 MHz với ESP32 qua SPI, cài thư viện LoRa trên Arduino IDE, viết chương trình gửi và nhận gói tin, đọc RSSI, SNR và xử lý lỗi thường gặp.

ESP-NOW nhiều node với ESP32: nhiều mạch gửi về một trạm thu, hiển thị trên OLED
ESP-NOW nhiều node với ESP32: nhiều mạch gửi về một trạm thu, hiển thị trên OLED

Hướng dẫn dựng mô hình ESP-NOW nhiều node: tối đa bốn ESP32 gửi số đo về một trạm thu, trạm thu hiện điện áp, RSSI và tình trạng từng node trên màn hình OLED 0.96 inch.


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