ESP32 综合教程:用 ESP-NOW 搭建多节点采集与 WiFi 网关

本文由 AI 协助整理,示例以 Arduino-ESP32 为主。不同芯片型号和 Arduino Core 版本的回调函数签名可能不同,请以当前版本示例为准。无线性能受天线、距离、遮挡、信道和供电影响,文中不虚构固定通信距离。

ESP-NOW 多节点采集与 WiFi 网关

为什么需要一个 ESP-NOW 网关

做家庭传感器时,最直接的方法是每块 ESP32 都连接家里的 WiFi,然后把数据发到 MQTT。这个方案简单,但节点一多就会出现几个问题:每个节点都要保存 WiFi 密码,路由器换密码要逐个处理;远处信号弱的节点频繁重连;电池节点保持 WiFi 连接又比较耗电。

ESP-NOW 提供了另一种思路:采集节点不加入路由器,只和一块网关 ESP32 直接通信。网关统一连接 WiFi,把数据转给 MQTT、HTTP 服务或数据库。节点负责“采集和发送”,网关负责“接收和联网”,职责比较清楚。

它也不是魔法无线电。ESP-NOW 仍使用 2.4GHz WiFi 射频,信道、干扰和天线依然重要;发送成功回调也不等于业务系统已经保存数据。这个项目的重点不是宣称“又快又远”,而是搭建一条能识别节点、发现重复包、记录离线状态的数据链路。

项目目标与硬件

本教程先完成以下功能:

  1. 两个 ESP32 节点周期发送温度、湿度或开关量。
  2. 网关根据节点编号区分来源,并检查数据长度。
  3. 每个数据包带递增序号,网关可以发现重复或跳号。
  4. 网关同时连接家庭 WiFi,把有效数据发布到 MQTT。
  5. 网络断开时,ESP-NOW 接收不被长时间阻塞。

至少需要三块 ESP32:两块作为节点,一块作为网关。传感器可以先不接,用固定变化数据验证链路。初次调试时,少一个真实传感器就少一种变量,先让三块板互相认识,再给它们安排工作。

最关键的信道问题

ESP-NOW 节点和网关必须工作在相同 WiFi 信道。网关如果同时连接路由器,它的信道会跟随路由器,因此节点也必须使用该信道。

例如路由器 2.4GHz 网络工作在信道 6,网关连接后也位于信道 6,两个发送节点就要固定到信道 6。路由器若启用自动信道并在重启后切换,节点可能突然全部失联。

开发阶段可以在网关连接 WiFi 后打印信道:

Serial.printf("WiFi channel: %d\n", WiFi.channel());

然后节点使用相同的 CHANNEL。长期运行时,可以把家庭 2.4GHz WiFi 固定到一个干扰较少的信道,或者设计节点重新扫描网关信道的机制。教程先使用固定信道,便于理解和排错。

先定义数据包

无线数据包不要直接发送含有 String、指针或动态容器的对象。定义只包含固定长度字段的结构体:

#include <Arduino.h>

struct __attribute__((packed)) SensorPacket {
  uint8_t version;
  uint8_t nodeId;
  uint32_t sequence;
  float temperature;
  float humidity;
  uint16_t batteryMv;
};

static_assert(sizeof(SensorPacket) == 16, "Unexpected packet size");

version 用于以后升级格式,nodeId 区分节点,sequence 是发送序号。电池电压使用毫伏整数,避免传一个没有必要的浮点数。

发送端和接收端必须使用完全一致的字段顺序、类型和打包方式。协议升级时不要悄悄改变结构,至少提高 version,让网关知道自己收到的是新格式,而不是把新数据按旧格式认真解释一遍。

获取网关 MAC 地址

ESP-NOW 单播需要知道对端 MAC。先给网关烧录下面的最小程序,在串口中读取 STA MAC:

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  Serial.println(WiFi.macAddress());
}

void loop() {}

把结果转换成六个十六进制字节,例如 24:6F:28:12:34:56 对应:

uint8_t gatewayMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56};

不要照抄文章里的示例地址,每块板都不一样。

发送节点最小程序

下面是 Arduino-ESP32 常用底层 API 的发送骨架。把 CHANNEL 改成网关连接路由器后的实际信道,把 MAC 改成自己的网关地址。

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

constexpr uint8_t NODE_ID = 1;
constexpr uint8_t CHANNEL = 6;
uint8_t gatewayMac[] = {0x24, 0x6F, 0x28, 0x12, 0x34, 0x56};
uint32_t sequence = 0;

struct __attribute__((packed)) SensorPacket {
  uint8_t version;
  uint8_t nodeId;
  uint32_t sequence;
  float temperature;
  float humidity;
  uint16_t batteryMv;
};

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }

  esp_now_peer_info_t peer{};
  memcpy(peer.peer_addr, gatewayMac, 6);
  peer.channel = CHANNEL;
  peer.encrypt = false;

  if (esp_now_add_peer(&peer) != ESP_OK) {
    Serial.println("Add peer failed");
  }
}

void loop() {
  SensorPacket packet{};
  packet.version = 1;
  packet.nodeId = NODE_ID;
  packet.sequence = sequence++;
  packet.temperature = 23.5f; // 换成真实传感器读数
  packet.humidity = 51.0f;
  packet.batteryMv = 4100;

  esp_err_t result = esp_now_send(
      gatewayMac,
      reinterpret_cast<const uint8_t*>(&packet),
      sizeof(packet));

  Serial.printf("send seq=%lu result=%d\n",
                static_cast<unsigned long>(packet.sequence), result);
  delay(5000);
}

第二个节点只需要修改 NODE_ID,MAC 和信道保持一致。真正读取传感器时,先过滤 NaN 或明显越界值,不要把失败读数发给网关。

esp_now_send() 返回成功,只表示发送请求已被协议栈接受,不代表 MQTT 服务端已经收到。需要更严格可靠性时,应由网关发送应用层确认,并在节点端实现超时重发;重发又会制造重复包,所以序号正好派上用场。

网关的接收与转发

网关先连接路由器,再初始化 ESP-NOW。顺序很重要,因为连接路由器以后信道已经确定。

接收回调属于通信上下文,不适合在里面连接 MQTT、拼大段 JSON 或写文件。正确做法是快速检查并复制数据,让主循环慢慢处理。

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

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";

struct __attribute__((packed)) SensorPacket {
  uint8_t version;
  uint8_t nodeId;
  uint32_t sequence;
  float temperature;
  float humidity;
  uint16_t batteryMv;
};

portMUX_TYPE packetMux = portMUX_INITIALIZER_UNLOCKED;
SensorPacket pendingPacket{};
volatile bool packetReady = false;

void onDataRecv(const esp_now_recv_info_t* info,
                const uint8_t* data, int len) {
  if (len != sizeof(SensorPacket)) return;

  portENTER_CRITICAL(&packetMux);
  memcpy(&pendingPacket, data, sizeof(SensorPacket));
  packetReady = true;
  portEXIT_CRITICAL(&packetMux);
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  unsigned long started = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) {
    delay(200);
  }

  Serial.printf("channel=%d ip=%s\n",
                WiFi.channel(), WiFi.localIP().toString().c_str());

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_recv_cb(onDataRecv);
}

void loop() {
  SensorPacket local{};
  bool hasPacket = false;

  portENTER_CRITICAL(&packetMux);
  if (packetReady) {
    local = pendingPacket;
    packetReady = false;
    hasPacket = true;
  }
  portEXIT_CRITICAL(&packetMux);

  if (hasPacket) {
    if (local.version != 1 || local.nodeId == 0) {
      Serial.println("Invalid packet");
      return;
    }

    Serial.printf("node=%u seq=%lu temp=%.1f hum=%.1f battery=%umV\n",
                  local.nodeId,
                  static_cast<unsigned long>(local.sequence),
                  local.temperature, local.humidity, local.batteryMv);

    // 在这里发布 MQTT,不要放到接收回调里。
  }
}

上面只有一个 pendingPacket,如果数据来得很快,旧包可能被新包覆盖。低频学习项目够用;正式多节点项目应使用 FreeRTOS 队列,让每个回调数据包都有位置,并统计队列满的次数。

ESP-NOW 网关处理流程

接入 MQTT

可以按节点划分主题:

home/sensors/node-01/state
home/sensors/node-02/state
home/sensors/gateway/status

网关收到节点 1 后组装 JSON:

{
  "sequence": 103,
  "temperature": 23.5,
  "humidity": 51.0,
  "battery_mv": 4100
}

MQTT 断线时不要阻塞 ESP-NOW 接收。主循环每隔几秒尝试重连,失败就继续接收和打印。需要离线缓存时,可以保存少量最新数据,但要设置容量上限。网络断一天产生的每个五秒采样都塞进内存,ESP32 很快会用沉默表达意见。

序号、重复包与离线判断

为每个节点保存 lastSequence 和 lastSeenMillis:

  • 序号等于上次序号:可能是重发,按业务决定丢弃。
  • 序号大于上次加一:中间可能丢包,增加丢包计数。
  • 序号突然变小:节点可能重启或计数回绕,结合在线时间判断。
  • 超过预定时间没收到数据:标记节点离线,而不是把旧数据继续当实时值。

节点重启后序号从零开始并不一定是错误。可以额外增加 bootId,每次开机生成随机值,网关便能区分“旧包重复”和“设备重新启动”。

建议的分阶段验证

第一阶段只使用一发一收,关闭 WiFi 和 MQTT,确认 MAC、信道和结构体长度正确。

第二阶段增加第二个节点,确认 nodeId 不冲突,串口能交替显示两路数据。

第三阶段让网关连接路由器,打印实际信道,再调整节点信道。此时先不接 MQTT。

第四阶段加入 MQTT,只发布固定测试消息,再发布真实节点消息。

第五阶段测试异常:重启路由器、断开 Broker、关闭一个节点、让节点快速发送,观察网关是否仍能运行并给出合理状态。

常见故障

现象 优先检查
完全收不到数据 MAC、信道、STA 模式、ESP-NOW 初始化返回值
网关连接 WiFi 后节点失联 路由器信道与节点固定信道不一致
数据数值乱码 两端结构体、字段类型、长度和版本不一致
偶尔丢包 距离、天线方向、供电、2.4GHz 干扰、发送间隔
MQTT 一断网网关就卡住 重连逻辑阻塞,或在接收回调中执行网络操作
两个节点数据互相覆盖 接收端只有单缓冲,应改用队列

安全与后续扩展

学习阶段代码把 encrypt 设为 false,只适合非敏感实验。ESP-NOW 支持加密通信,正式使用应根据当前 Espressif 文档配置密钥、管理配对设备,并保护网关中的 WiFi 与 MQTT 凭据。

后续可以加入深度睡眠,让电池节点唤醒、采样、发送后立即休眠;也可以让网关维护节点表,在网页中显示最后在线时间、信号信息和丢包计数。再往后增加双向命令与确认机制,就能做低功耗门磁、按钮、土壤湿度和远端告警节点。

总结

ESP-NOW 多节点网关的价值,是把低功耗本地采集与常规 IP 网络分开。节点不必都维护 WiFi 和 MQTT,网关则成为统一的协议翻译与状态管理入口。

真正容易踩坑的不是发送函数,而是信道一致、结构体版本、回调职责、重复包和离线状态。先把这些边界设计好,三块 ESP32 才不会只是“偶尔能互相说话”,而会逐渐成为一套能观察、能排错、能继续扩展的小型无线网络。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-19 15:19  潇洒石头  阅读(30)  评论(0)    收藏  举报

导航