基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信

0x00 什么是ESP_NOW?

ESP-NOW 是乐鑫定义的一种无线通信协议,能够在无路由器的情况下直接、快速、低功耗地控制智能设备。它能够与 Wi-Fi 和 Bluetooth LE 共存,目前支持乐鑫 ESP8266、ESP32、ESP32-S 和 ESP32-C 等多系列 SoC。ESP-NOW 广泛应用于智能家电、远程控制和传感器等领域。

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第1张
ESP_NOW通信协议特点
基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第2张
ESP_NOW应用场景
基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第3张
ESP_NOW核心优势
基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第4张
ESP_NOW网络模型

0x01 ESP_NOW通信数据帧格式

在 ESP-NOW 中,应用程序数据被封装在各个供应商的动作帧中,然后在无连接的情况下,从一个 Wi-Fi 设备传输到另一个 Wi-Fi 设备。 CTR 与 CBC-MAC 协议 (CCMP) 可用来保护动作帧的安全。所以ESP-NOW可被广泛应用于智能照明、远程控制、传感器等领域。

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第5张
ESP_NOW帧格式定义

通过上图可以得知,一帧完整数据可以发送的最多有效数据为250字节,如果需要发送到数据量大于该字节数,那数据就需要分为多帧数据发送。ESP-NOW 采用 CCMP 方法保护供应商特定动作帧的安全,具体可参考 IEEE Std. 802.11-2012。Wi-Fi 设备维护一个初始主密钥 (PMK) 和若干本地主密钥 (LMK),长度均为 16 个字节。

  • PMK 可使用 AES-128 算法加密 LMK。请调用 esp_now_set_pmk() 设置 PMK。如果未设置 PMK,将使用默认 PMK。
  • LMK 可通过 CCMP 方法对供应商特定的动作帧进行加密,最多拥有 6 个不同的 LMK。如果未设置配对设备的 LMK,则动作帧不进行加密。

0x02 ESP_NOW常用API

(1)初始化和反初始化

调用 esp_now_init() 初始化 ESP-NOW通信,调用 esp_now_deinit() 反初始化 ESP-NOW。ESP-NOW 数据必须在 Wi-Fi 启动后传输,因此建议在初始化 ESP-NOW 之前启动 Wi-Fi,并在反初始化 ESP-NOW 之后停止 Wi-Fi。 当调用 esp_now_deinit() 时,配对设备的所有信息都将被删除。

(2)添加删除配对设备

在将数据发送到其他设备之前,请先调用 esp_now_add_peer() 将其添加到配对设备列表中。如果启用了加密,则必须设置 LMK。ESP-NOW 数据可以从 Station 或 Softap 接口发送。确保在发送 ESP-NOW 数据之前已启用该接口。如果需要将已经配对的设备从列表中删除,那需要调用esp_now_del_peer()。需要注意esp_now_add_peer()和esp_now_del_peer()函数都需要esp32 s3设备的MAC地址参数才可以正常调用执行。

配对设备的最大数量是 20个,其中加密设备的数量不超过 17个,默认值是 7个。如果想要修改加密设备的数量,可以通过修改 CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM变量即可。

在发送广播数据之前必须添加具有广播 MAC 地址的设备。配对设备的信道范围是从 0 ~14。如果信道设置为 0,数据将在当前信道上发送。否则,必须使用本地设备所在的通道。

(3)发送 ESP-NOW 数据

调用 esp_now_send() 发送 ESP-NOW 数据,调用 esp_now_register_send_cb() 注册发送回调函数。如果 MAC 层成功接收到数据,则该函数将返回 ESP_NOW_SEND_SUCCESS 事件。否则,它将返回 ESP_NOW_SEND_FAIL。ESP-NOW 数据发送失败可能有几种原因,比如目标设备不存在、设备的信道不相同、动作帧在传输过程中丢失等。应用层并不一定可以总能接收到数据。如果需要,应用层可在接收 ESP-NOW 数据时发回一个应答 (ACK) 数据。如果接收 ACK 数据超时,则将重新传输 ESP-NOW 数据。可以为 ESP-NOW 数据设置序列号,从而删除重复的数据。

如果有大量 ESP-NOW 数据要发送,调用 esp_now_send() 时需注意单次发送的数据不能超过 250 字节。请注意,两个 ESP-NOW 数据包的发送间隔太短可能导致回调函数返回混乱。因此,建议在等到上一次回调函数返回 ACK 后再发送下一个 ESP-NOW 数据。发送回调函数从高优先级的 Wi-Fi 任务中运行。因此,不要在回调函数中执行冗长的操作。相反,将必要的数据发布到队列,并交给优先级较低的任务处理。

(4)接收 ESP-NOW 数据

调用 esp_now_register_recv_cb() 注册接收回调函数。当接收 ESP-NOW 数据时,需要调用接收回调函数。接收回调函数也在 Wi-Fi 任务任务中运行。因此不要在回调函数中执行冗长的操作。 相反需要将接受到必要处理的数据缓存起来,并交给优先级较低的任务处理。

0x03 获取ESP32 S3的MAC地址

ESP_NOW通信过程中,需要依据不同的ESP32 S3的MAC地址来做区分的,这个MAC地址就是ESP32 S3的硬件地址、物理地址,每个设备的 MAC 地址在出厂时都是不同的。这个MAC地址可以想象成我们的微信号,是唯一存在的,我们要想跟别人进行微信通信,那就得先添加对方的微信号为好友,这样才能与其进行通信。那ESP_NOW通信协议同理也是需要这么一个唯一标识的,所以我们需要先获取需要进行ESP_NOW通信的设备mac地址,获取mac地址的arduino代码如下:

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
}

void loop() {
  uint8_t macAddr[6];  //uint8_t类型的数组,存储mac地址
  Serial.print("Get ESP32-S3 Mac Addr: ");
  WiFi.macAddress(macAddr);  //MAC地址储存在macAddr数组里面

  for (int i = 0; i < sizeof(macAddr); i++) {
    Serial.printf("0x%02x", macAddr[i]);
    if (i < (sizeof(macAddr) - 1))
      Serial.print(":");
  }
  Serial.println("");
  delay(3000);
}

将上述代码复制到Arduino IDE中就可以编译上传到ESP32 S3中查看mac地址了,如下图所示:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第6张
运行代码查看mac地址

在获取到该esp32 s3的mac地址后,需要将其记录下来,如果你有多个esp32 s3,那就需要分别上传该代码获取到各自的mac地址,我们在后面使用ESP_NOW通信时会需要用到这些mac地址。

0x04 ESP_NOW双向通信测试

在这里我们使用两块ESP32 S3开发板来进行测试,每块板子都可以发送和接收数据,我已经提前获取到各自的mac地址,如下图所示:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第7张
ESP_NOW双向通信模式

如果我们把左边的ESP32 S3命名为A板,那对应的mac地址为0x34:0x85:0x18:0x43:0x1f:0x08。右边ESP32 S3命名为B板,那其mac地址为0x34:0x85:0x18:0x43:0x1f:0x00。这里还需要知道,ESP32 S3发送数据功能是主动调用执行的,就是根据需要可以来给配对设备主动发送数据,接收数据功能是通过回调函数执行的,当配对设备有发送数据过来后,自动调用回调函数来处理的,如果配对设备没有发送数据过来,那回调函数就等待那里,回调函数不执行。其实A的send data就会被B的recv data函数来自动处理的,同理当B向Asend data时,那A的recv data函数就会自动来处理。

那我们就可以来编写代码了,其实两者代码结构是一样的,我们只需要修改下代码中配对设备的mac地址和测试发送的消息就可以了,下面来看下A的代码:

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

esp_now_peer_info_t peerDevice;

#define CHANNEL 0
int g_test_data = 0;

//发送给配对设备的数据结构体
typedef struct struct_msg {
  char device_name[50];
  int data;
} struct_msg;

struct_msg test_send_msg;
struct_msg test_recv_msg;

//配对设备ESP32 S3的mac地址
uint8_t macAddr[6] = { 0x34, 0x85, 0x18, 0x43, 0x1F, 0x00 };

void InitESPNow() {
  if (esp_now_init() == ESP_OK) {
    Serial.println("ESPNow Init Success");
  } else {
    Serial.println("ESPNow Init Failed,Retry...");
    ESP.restart();
  }
}

void sendData() {
  const uint8_t *peer_addr = peerDevice.peer_addr;
  esp_err_t result = esp_now_send(peer_addr, (uint8_t *)&test_send_msg, sizeof(test_send_msg));
  if (result == ESP_OK) {
  } else if (result == ESP_ERR_ESPNOW_NOT_INIT) {
    Serial.println("ESPNOW not Init.");
  } else if (result == ESP_ERR_ESPNOW_ARG) {
    Serial.println("Invalid Argument");
  } else if (result == ESP_ERR_ESPNOW_INTERNAL) {
    Serial.println("Internal Error");
  } else if (result == ESP_ERR_ESPNOW_NO_MEM) {
    Serial.println("ESP_ERR_ESPNOW_NO_MEM");
  } else if (result == ESP_ERR_ESPNOW_NOT_FOUND) {
    Serial.println("Peer not found.");
  } else {
    Serial.println("Unknow Error!");
  }
}

void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) {
  memcpy(&test_recv_msg, data, sizeof(test_recv_msg));
  Serial.print("Device A Recv Data: ");
  Serial.print(test_recv_msg.device_name);
  Serial.print(" ");
  Serial.print(test_recv_msg.data);
  Serial.println();
}

bool connect() {
  bool exists = esp_now_is_peer_exist(peerDevice.peer_addr);
  if (exists) {
    return true;
  } else {
    esp_err_t addStatus = esp_now_add_peer(&peerDevice);
    if (addStatus == ESP_OK) {
      Serial.println("Pair success");
      return true;
    } else if (addStatus == ESP_ERR_ESPNOW_NOT_INIT) {
      Serial.println("ESPNOW Not Init");
      return false;
    } else if (addStatus == ESP_ERR_ESPNOW_ARG) {
      Serial.println("Invalid Argument");
      return false;
    } else if (addStatus == ESP_ERR_ESPNOW_FULL) {
      Serial.println("Peer list full");
      return false;
    } else if (addStatus == ESP_ERR_ESPNOW_NO_MEM) {
      Serial.println("Out of memory");
      return false;
    } else if (addStatus == ESP_ERR_ESPNOW_EXIST) {
      Serial.println("Peer Exists");
      return true;
    } else {
      Serial.println("Unknow Error!");
      return false;
    }
  }
}

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_MODE_STA);  //Set device in STA mode
  InitESPNow();
  esp_now_register_send_cb(OnDataSent);
  esp_now_register_recv_cb(OnDataRecv);

  memset(&peerDevice, 0, sizeof(peerDevice));
  memcpy(peerDevice.peer_addr, macAddr, sizeof(macAddr));
  peerDevice.channel = CHANNEL;
  peerDevice.encrypt = false;

  strcpy(test_send_msg.device_name, "I'm device A");
  memset(&test_recv_msg, 0, sizeof(test_recv_msg));
}

void loop() {
  if (connect()) {
    test_send_msg.data = g_test_data;
    sendData();
    g_test_data++;
  }

  delay(3000);
}

对于B的代码,其实我们只需要修改上面的第19行、55行、106行就可以了,其中第19行就是填写配对设备的mac地址,剩下两个就是修改一下为B的字符串就行。那将两个代码分别上传到各自的ESP32 S3开发板中就行,这里需要注意A的代码中第19行需要填写B的mac地址,然后将代码编译上传到A板中。B的代码中19行填写A的mac地址,然后也是编译上传到B板中,程序运行起来后的效果如下:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第8张
ESP_NOW双向通信测试效果

0x05 代码解析

这里我们来分析一下代码,首先从setup()函数入手,如下图所示:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第9张
setup()函数

在96行代码,我们首先将ESP32 S3的WIFI模块工作模式配置成station模式。那ESP32 S3可以配置的,WIFI模式定义可以在esp_wifi_types.h中找到,如下图所示:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第10张
wifi_mode_t定义了WiFi的不同模式

这里没有配置成AP模式(Access Point),因为我们不希望现在的ESP32 S3是像无线路由器那样作为中心节点,提供服务让其他设备连接。我们希望现在所有ESP32 S3都是一样的对等模式,就是station模式,这样就可以完成互相通信。

在98行、99行配置了两个回调函数,其中98行是配置是否向配对设备成功发送数据的回调函数,在此回调函数中,我们可以得知发送数据到配对设备的状态,我们可以在此得知发送状态,如果数据发送失败后,我们可以进行一些异常处理,例如数据重发等。在99行配置接收配对设备发送过来数据的回调函数,当一接收到数据,回调函数被自动调用执行。

第101行到104行代码是配置配对设备的mac地址、wifi通信通道、是否加密传输等参数。对于esp_now_peer_info_t结构体中可以配置的所有参数,我们可以在esp_now.h头文件中找到,如下图所示:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第11张
查看esp_now_peer_info_t结构体定义

接下来就是在loop()函数中不断循环执行发送数据了,当然也可以根据需要发送,不一定非要一直发送数据,根据功能需要来开发即可。首先我们先与其他设备进行配对,将其mac地址添加到自己的配对设备列表中,注意设备列表最多可以添加20个设备,那就是说我们可以使用最多20个ESP32 S3设备来组成一个网状通信,如下图所示这样的通信网络:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第12张
ESP_NOW多机互相通信模式

然后就是调用esp_now_send()函数来将数据发送出去,该函数需要有三个参数,第一个就是配对设备的mac地址,然后就是发送数据的起始地址,最后就是需要发送到字节数。总体来说,ESNP_NOW使用起来还是很简单方便的,以后可以用这个功能开发出很多实用的东西。

0x06 广播模式

ESP_NOW支持广播模式,就是一台设备可以向周边所有的ESP_NOW设备进行数据通信,这样就能实现一对多控制,如果每台设备都使用的是广播模式,那感觉就有点像病毒传播模型那样了,只要一广播,周边ESP_NOW设备都会接收到数据,这种通信方式在某些场景下还是很有实用价值的。

下面我们就来做这样一个测试,这里唯一需要特别注意地方是,我们在使用esp_now_send()函数发送数据时,第一个参数本来是指定设备的mac地址,这里我们是通过发送特殊的 MAC 地址FF:FF:FF:FF:FF:FF来创建广播消息,这样每个ESP_NOW设备都会回复它的 MAC 地址,然后我们拿到这些地址后,就可以依次发送数据到这些mac地址设备上。

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

int g_test_data = 0;

// 函数声明
void formatMacAddress(const uint8_t *macAddr, char *buffer, int maxLength);
void receiveCallback(const uint8_t *macAddr, const uint8_t *data, int dataLen);
void sentCallback(const uint8_t *macAddr, esp_now_send_status_t status);
void broadcast(const String &message);

// 格式化MAC地址
void formatMacAddress(const uint8_t *macAddr, char *buffer, int maxLength) {
  snprintf(buffer, maxLength, "%02x:%02x:%02x:%02x:%02x:%02x", macAddr[0], macAddr[1], macAddr[2], macAddr[3], macAddr[4], macAddr[5]);
}

//  接收到数据时的回调函数
void receiveCallback(const uint8_t *macAddr, const uint8_t *data, int dataLen) {
  // 格式化MAC地址
  char macStr[18];
  formatMacAddress(macAddr, macStr, 18);

  Serial.printf("Received message from: %s - %d\n", macStr, *data);
}

// 消息发送后的回调函数,用以判断对方是否成功收到消息等
void sentCallback(const uint8_t *macAddr, esp_now_send_status_t status) {
  char macStr[18];
  formatMacAddress(macAddr, macStr, 18);
  Serial.print("Last Packet Sent to: ");
  Serial.println(macStr);
  Serial.print("Last Packet Send Status: ");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

// 将消息广播到在范围内的所有ESP_NOW设备
void broadcast(int data) {
  uint8_t broadcastAddress[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
  esp_now_peer_info_t peerInfo = {};

  memcpy(&peerInfo.peer_addr, broadcastAddress, sizeof(broadcastAddress));
  if (!esp_now_is_peer_exist(broadcastAddress)) {
    esp_now_add_peer(&peerInfo);
  }

  // 发送消息给所有范围内的设备
  esp_err_t result = esp_now_send(broadcastAddress, (const uint8_t *)&data, sizeof(int));

  // 将发送结果打印到串口
  if (result != ESP_OK) {
    Serial.println("broadcast data error !");
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_MODE_STA);

  // 初始化espnow,如果失败则打印错误信息并退出重启
  if (esp_now_init() == ESP_OK) {
    Serial.println("ESP-NOW Init Success");
    esp_now_register_recv_cb(receiveCallback);  // 注册接受消息的回调函数
    esp_now_register_send_cb(sentCallback);     // 注册消息发送的回调函数
  } else {
    Serial.println("ESP-NOW Init Failed");
    delay(3000);
    ESP.restart();  // 重启esp设备
  }
}

void loop() {
  broadcast(g_test_data);
  g_test_data++;
  delay(3000);
}

我们可以将上述代码上传到需要测试的各ESP32 S3开发板中,代码实现的效果是不断的向其他ESP_NOW设备发送一个不断增加的int类型测试数据,每隔3秒钟数据广播发送一次。其实代码跟我们双向通信代码结构差不多,就是换了一下mac地址,从指定的设备mac地址换成了广播地址ff:ff:ff:ff:ff:ff,只要是ESP_NOW设备接收到广播数据,都会将自身的mac地址反馈给该广播设备,这样mac地址我们就可以获取到了,进行数据发送过来,下面是运行效果:

基于Arduino在ESP32 S3上使用ESP_NOW实现设备间通信 - 第13张
ESP_NOW广播模式运行效果

我使用两块ESP32 S3开发板来做测试,代码都是一样的,各自上传到开发板里,就可以看到测试效果了,可以看到广播设备是可以收到其他设备的数据的,自己的数据也可以正常发送过去。

0x07 参考资料

[0].乐鑫官网ESP_NOW介绍. https://www.espressif.com.cn/zh-hans/solutions/low-power-solutions/esp-now

[1].ESP_NOW API参考手册. https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/api-reference/network/esp_now.html

[2].ESP32入门笔记07:ESP-NOW(ESP32 for Arduino). https://blog.csdn.net/Naiva/article/details/127980364

[3].ESP NOW简介. https://www.cnblogs.com/dapenson/p/esp-now.html

本文原创,作者:corvin_zhang,其版权均为ROS小课堂所有。
如需转载,请注明出处:https://www.corvin.cn/3379.html