← 返回教程库

网关与多设备组网:从一个设备到一屋子

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 理解网关是什么、为什么一屋子设备需要它,而不是每块板都直连 WiFi
  • 看懂星型 / Mesh / 网关汇聚三种组网拓扑,知道各自适合什么场景
  • 用 esp_now 接收子设备数据 + esp-mqtt 转发上云 + cJSON 转换,搭起一套能上云的传感网雏形
  • 想透 ESP-NOW 与 WiFi 共存(同信道、先连 WiFi 再 esp_now_init)这个网关最大的坑

你做的第一个联网项目,多半就一块板子:ESP32-S3 连上 WiFi,把温度发到 MQTT,搞定。但等你真把它用起来,麻烦才开始——客厅要一个、卧室要一个、阳台再来一个,过两天又想在门口加个人体感应。一屋子设备,每块都直连家里的路由器,每块都自己跑 wifi_init_sta()、自己连 broker、自己重连。你会很快发现三个头疼的地方:墙角那块板 WiFi 信号弱得老掉线;想电池供电的那个,连着 WiFi 根本撑不过两天;六七块板子各发各的,topic 乱成一锅粥,哪块没数据了你都不知道。

这一节是 L3 的收官篇。前面你学会了让单个设备连 WiFi上 esp-mqtt用 ESP-NOW 让板子之间直接对话。现在把这些拼起来,搭一套真正成体系的东西:一群省电的末端节点 + 一个负责上云的网关。读完你会明白"组网"该怎么想,手里也会有一套 2 节点 + 1 网关的传感网骨架。

前置:本节默认你已经跑通过 MQTT 那一节,手里有那套 esp-mqtt 事件回调骨架(init → register_event → start),也知道 broker、topic、publish 是什么;最好也读过 ESP-NOW,知道 esp_now_init / esp_now_register_recv_cb 怎么收发。


网关是什么:连接两种网络的桥

先把"网关"这个词从神坛上拉下来。它听着唬人,本质就一句话:一座连接两种不同网络的桥

家里的路由器就是个网关——它一边是你的局域网(WiFi、网线),一边是运营商的公网,负责把两边的流量翻译、转发。我们要做的网关是同一个意思,只是换了两种网络:

  • 一边:一群末端节点。它们用 ESP-NOW / BLE / LoRa 这类低功耗、近距离、不依赖路由器的方式说话。
  • 另一边:互联网。要把数据送上云,得走 WiFi → esp-mqtt → broker

网关就站在中间,把第一种网络收到的数据,翻译、打包,从第二种网络发出去。末端节点根本不知道云的存在,它们只管把数据扔给网关;网关也不关心数据是谁产生的,它只管收齐了往上送。

为什么非要这么一道弯,不让每块板都直连 WiFi?三个实打实的理由:

  1. 末端省电。ESP32 连着 WiFi 时电流轻松 100–150mA,发个 ESP-NOW 包却可能只要十几毫安、几毫秒就发完然后睡过去。想靠电池跑几个月的传感器节点,绝不能挂在 WiFi 上。这是网关方案最硬的动机。
  2. 统一出口好管。十块板子各连各的 broker,出了问题你要排查十条连接;一个网关上云,你只盯这一个出口,所有数据从这里汇总、这里转发、这里记日志(ESP_LOGI)。
  3. 协议转换。LoRa 能传几公里但带宽极小,ESP-NOW 不联网但极省电——这些"末端友好"的协议没法直接上云。网关就是那个翻译官,把它们统统转成云能听懂的 MQTT。

三种组网拓扑,怎么选

把多个设备连起来,主流就三种结构。给你结论,再说理由。

星型(Star):所有节点都连到一个中心(比如都连家里的路由器,或都连一块协调板)。结构最简单,每个节点和中心一跳直达。缺点是覆盖受中心的信号范围限制,墙角那块够不着就抓瞎;中心一挂,全网瘫。适合:设备少、都在一个房间、离中心都不远的场景。

网状(Mesh):节点之间互相中继,A 够不着中心可以让 B 帮忙转发,数据多跳接力。ESP-IDF 有现成的 ESP-WIFI-MESH 组件,能自组网、自愈、自动选路。优点是覆盖广、抗单点故障,加节点就能扩大范围。代价是复杂——调试、定位丢包都比星型难,延迟也不稳定(数据可能绕了好几跳)。适合:覆盖范围大、节点多、布线又不方便的场景,比如大平层、厂区。

网关汇聚(Gateway):节点 → 网关 → 云。末端节点用省电协议把数据汇到一个网关,网关统一上云。这是我的推荐,也是绝大多数真实家庭/小型项目最实用的结构。它兼顾了省电(末端不联 WiFi)、好管(一个出口)、易扩(加节点只是网关多认一个 ID),复杂度又远低于 Mesh。

💡 提示

别一上来就上 Mesh。Mesh 听着酷,但自组网的不确定性会让你调试到怀疑人生。99% 的家庭项目,"一群 ESP-NOW 节点 + 一个网关上云"这种汇聚结构就足够了,简单、稳、好排查。等你真碰到"一个网关覆盖不过来"的规模,再考虑 Mesh 不迟。


主线方案:ESP-NOW 节点 + 网关 + esp-mqtt

下面把网关汇聚这条路走通。整套系统长这样:

[节点A: 温湿度]  --ESP-NOW-->  ┐
                               ├-->  [网关 ESP32-S3]  --WiFi/esp-mqtt-->  [broker]  -->  [云端仪表盘]
[节点B: 人体感应] --ESP-NOW-->  ┘
  • 多个传感器节点:每个就是一块 ESP32(或更小的 ESP32-C3),接个传感器(比如 DHT11 温湿度),用 ESP-NOW 把数据发给网关的 MAC 地址,发完就深睡省电。它们完全不碰 WiFi、不碰 broker
  • 一个网关 ESP32-S3:这块板子身兼两职——同时开着 ESP-NOW 接收 + WiFi 连 broker。收到任一节点的数据,就用 cJSON 打包成一条 MQTT 消息 esp_mqtt_client_publish 上去。

这里有一个最容易踩的坑,单独拎出来说:ESP-NOW 和 WiFi 必须在同一个信道(channel)上。ESP32 只有一套射频,同一时刻只能待在一个信道。网关一旦连上 WiFi,它的信道就由路由器决定了;这时候节点如果还用默认信道发 ESP-NOW,网关压根收不到。解决办法有两个:要么让节点先查出路由器用的是几信道、跟着切过去;要么省事点——把节点和网关都硬编码到同一个固定信道(比如都用信道 1),并确认你的路由器也工作在那个信道。新手强烈建议后者,先跑通再说。

网关端代码骨架

下面是网关的核心逻辑,一个完整的 main/main.c。它复用了你 WiFi 那节wifi_init_sta()MQTT 那节的 esp-mqtt 事件回调骨架,新增的只有 ESP-NOW 接收回调和 cJSON 打包。重点看:ESP-NOW 回调里怎么把收到的 struct 转成 JSON、再 publish 上云,以及两种射频怎么共存。

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#include "esp_now.h"
#include "mqtt_client.h"   // esp-mqtt:ESP-IDF 内置,无需额外依赖
#include "cJSON.h"         // ESP-IDF 内置组件,用来打包 JSON

#define ESPNOW_CHANNEL 1   // 关键:和节点、路由器保持同一信道

static const char *TAG = "gateway";

#define BROKER_URI "mqtt://broker.emqx.io:1883"   // 公共测试 broker,明文 1883

// 上一篇 l3-wifi 的实现:连上 2.4G WiFi、拿到 IP 才返回。把它的代码贴进来。
extern void wifi_init_sta(void);

// 节点和网关约定好的数据结构(节点发的是同样的 struct,两端必须字节级一致)
typedef struct {
    uint8_t nodeId;     // 每个节点一个唯一 ID
    float   temp;
    float   humi;
} sensor_packet_t;

// 全局 client 句柄,供 ESP-NOW 回调里 publish 用
static esp_mqtt_client_handle_t s_mqtt_client = NULL;
static volatile bool s_mqtt_connected = false;

// ---- esp-mqtt 事件回调:和 l3-mqtt 那节同一个范式 ----
static void mqtt_event_handler(void *arg, esp_event_base_t base,
                               int32_t event_id, void *event_data) {
    switch ((esp_mqtt_event_id_t) event_id) {
    case MQTT_EVENT_CONNECTED:
        s_mqtt_connected = true;
        ESP_LOGI(TAG, "网关已连上 broker");
        break;
    case MQTT_EVENT_DISCONNECTED:
        s_mqtt_connected = false;
        ESP_LOGW(TAG, "和 broker 断开了(esp-mqtt 会自动重连)");
        break;
    default:
        break;
    }
}

// ---- ESP-NOW 收到子设备数据时的回调(IDF 5.x 签名:第一个参数是 recv_info)----
static void on_espnow_recv(const esp_now_recv_info_t *info,
                           const uint8_t *data, int len) {
    if (len != sizeof(sensor_packet_t)) {
        ESP_LOGW(TAG, "收到长度异常的包:%d 字节,丢弃", len);
        return;   // 两端 struct 不一致 / 来路不明的包,直接扔
    }

    sensor_packet_t pkt;
    memcpy(&pkt, data, sizeof(pkt));   // 回调里只做轻活:拷出来

    // 用 cJSON 把子设备原始数据打包成上云用的 JSON 字符串
    cJSON *root = cJSON_CreateObject();
    cJSON_AddNumberToObject(root, "temp", pkt.temp);
    cJSON_AddNumberToObject(root, "humi", pkt.humi);
    char *payload = cJSON_PrintUnformatted(root);   // {"temp":24.5,"humi":58}

    // 用 nodeId 拼出这个节点专属的 topic
    char topic[48];
    snprintf(topic, sizeof(topic), "qifudev/sensors/node%u", pkt.nodeId);

    if (s_mqtt_connected) {
        // 转发上云:len 填 0 让它自动 strlen,QoS 0、retain 0
        esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, 0, 0);
        ESP_LOGI(TAG, "转发 节点%u -> %s : %s", pkt.nodeId, topic, payload);
    } else {
        ESP_LOGW(TAG, "broker 没连上,本条丢弃");  // 进阶可在此做本地缓存
    }

    cJSON_free(payload);   // cJSON_Print* 出来的字符串要自己释放
    cJSON_Delete(root);
}

void app_main(void) {
    ESP_ERROR_CHECK(nvs_flash_init());   // WiFi 校准数据存 NVS,连网前必做

    // 1) 先连 WiFi —— 这一步会决定芯片待在哪个信道
    wifi_init_sta();                     // l3-wifi 的实现,连上拿到 IP 才返回

    // 2) 把信道强行钉到 ESPNOW_CHANNEL(确保和节点、路由器一致)
    //    primary 用我们约定的固定信道,secondary 用 NONE
    ESP_ERROR_CHECK(esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE));
    uint8_t ch; wifi_second_chan_t sc;
    esp_wifi_get_channel(&ch, &sc);
    ESP_LOGI(TAG, "WiFi 已连,当前信道=%u", ch);

    // 3) 再初始化 ESP-NOW,注册收包回调(WiFi 已就绪,射频才在确定信道上)
    ESP_ERROR_CHECK(esp_now_init());
    ESP_ERROR_CHECK(esp_now_register_recv_cb(on_espnow_recv));

    // 4) 起 esp-mqtt:init → register_event → start,后台任务自己连/保活/重连
    esp_mqtt_client_config_t cfg = {
        .broker.address.uri = BROKER_URI,
    };
    s_mqtt_client = esp_mqtt_client_init(&cfg);
    esp_mqtt_client_register_event(s_mqtt_client, ESP_EVENT_ANY_ID,
                                   mqtt_event_handler, NULL);
    esp_mqtt_client_start(s_mqtt_client);

    // 主循环不用管收发:ESP-NOW 收包靠回调,MQTT 收发/心跳/重连靠后台任务
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(10000));   // 网关只需活着;要心跳可在此 publish
    }
}

节点端则极简:醒来 → 读传感器 → 填好 sensor_packet_t(带上自己的 nodeId)→ esp_now_send 给网关的 MAC → 深睡几分钟。这部分的 ESP-NOW 发送写法,ESP-NOW 那一节讲得很细(esp_now_initesp_now_add_peeresp_now_send),这里不重复。

🚧 避坑

app_main 里的顺序不能反:wifi_init_sta() 连上 WiFi,再 esp_now_init()。因为连 WiFi 会把芯片信道固定下来,ESP-NOW 接收得在这个确定的信道上才工作。顺序反了,最典型的症状就是"网关一连上 WiFi,ESP-NOW 就再也收不到包了"。上面代码还多走一步 esp_wifi_set_channel 把信道钉死到 1,就是为了和硬编码到信道 1 的节点严丝合缝对上。

📌 说明

cJSON 是 ESP-IDF 自带的组件(#include "cJSON.h" 即可用,不用在 idf_component.yml 里加依赖)。这里用它把 struct 转成 JSON 上云——比手写 snprintf{"temp":...} 更不容易出错,字段一多优势更明显。记住一条:cJSON_Print* 系列返回的字符串是堆上分配的,用完必须 cJSON_free,对象本身再 cJSON_Delete,否则网关跑久了会内存泄漏、最后崩重启。

你应该看到什么

烧好网关、烧好两个节点、都上电之后,跑 idf.py monitor 盯着网关日志:

I (2980) gateway: WiFi 已连,当前信道=1
I (3210) gateway: 网关已连上 broker
I (3540) gateway: 转发 节点1 -> qifudev/sensors/node1 : {"temp":24.5,"humi":58}
I (4120) gateway: 转发 节点2 -> qifudev/sensors/node2 : {"temp":23.8,"humi":61}
I (8560) gateway: 转发 节点1 -> qifudev/sensors/node1 : {"temp":24.6,"humi":57}

每隔节点设定的间隔,就有一行"转发"滚出来。再打开手机上的 MQTT 客户端,订阅 qifudev/sensors/## 是通配符,意思是"这底下所有 topic 全要"),你会看到两个节点的数据汇总到一处,源源不断地推过来。把这些数据接进一个云端仪表盘(巴法云、Home Assistant 都行),你就有了一面能同时看一屋子设备的"墙"。这一刻,你做的就不再是"一个联网设备",而是一套系统了。


设备标识与扩展性

系统一旦超过两三个设备,"谁是谁"就成了头等问题。

给每个节点一个唯一 ID。上面代码里的 nodeId 就干这个——它是节点的身份证。topic 里带上它(node1 / node2),云端一看 topic 就知道这条数据来自哪块板。别偷懒用 MAC 地址当 topic,太长太难记;自己编一套 1、2、3kitchen、bedroom 的短 ID,可读性和管理性都好得多。如果你想更精细,ESP-NOW 回调的 info->src_addr 里就带着发包方的真实 MAC,可以拿来做"白名单"——只接受认识的几个 MAC,挡掉来路不明的包。

扩展性恰恰是这套结构最爽的地方。想加第三个节点?给它编号 node3、烧上同样的节点固件、让它发给网关 MAC——网关一行代码都不用改,回调里 snprintf 自动就拼出 node3 的 topic 转发上去了。"加设备 = 多一个 ID",这种线性、无痛的扩展,正是网关汇聚结构相比"每块板各自为政"的最大优势。

当节点多到一个网关忙不过来,或者距离超出 ESP-NOW 范围(几十米),再往上就是两条路:换 LoRa 把单跳距离拉到几公里,或上 ESP-WIFI-MESH 让节点互相接力。但那是更后面的事了。


L3 收官:你已经能搭一套完整的 IoT 系统了

走到这里,L3 这一整阶的拼图就齐了。回头看你这一路攒下的能力:

  • 单个设备能连上 WiFi、能上 esp-mqtt 收发
  • 板子之间能用 ESP-NOW 直接对话,不靠路由器;
  • 现在,你能把一群设备组成网络,用网关 esp_now 收、cJSON 转、esp_mqtt 送汇聚上云,做成一套有节点、有汇聚、有云端、能扩展的真系统。

从"点亮一颗灯"到"管理一屋子互联设备",这是从玩具到系统的跨越。你已经具备了搭建一个完整 IoT 项目的全部基础设施。

但这套系统现在还只是"忠实地搬运数据"——节点发什么,云端显示什么,仅此而已。它不会判断、不会决策、不懂人话。下一阶 L4,要做的就是给这套系统装上大脑:让设备能理解自然语言、能根据数据自己做判断、能和大模型对话。从把 LLM 接进硬件开始,你那一屋子"听话"的设备,就要变得"聪明"起来了。


故障排查表

现象 可能原因 怎么查
网关日志一行"转发"都没有 信道不一致,ESP-NOW 收不到包 确认节点、网关、路由器三方同信道;日志里 当前信道= 打印的值要和节点硬编码的一致,必要时用 esp_wifi_set_channel 钉死
连上 WiFi 之前能收,连上之后就收不到了 app_mainesp_now_initwifi_init_sta 先调 调整顺序:先连 WiFi(wifi_init_sta),再 esp_now_init
网关收到了但云端没数据 broker 没连上,回调里走了"丢弃"分支 看日志是否打印"broker 没连上";先单独验证网关能否连 MQTT
偶尔丢几条数据 ESP-NOW 是"发了不保证收到",加上射频拥挤 节点端发完用 esp_now_register_send_cb 看回执、失败重发一次;或拉长上报间隔
收到的包长度对不上、被丢弃 两端 sensor_packet_t 字段顺序/类型不一致 回调里 len != sizeof(...) 这道闸就是拦这个;确认网关和节点的 struct 字节级一致(字段顺序、类型、对齐)
网关跑一阵就重启(内存越用越少) cJSON 对象/字符串没释放,泄漏 每次 publish 后 cJSON_free(payload) + cJSON_Delete(root) 一个都不能少;回调里别干阻塞式联网的重活

变体

  • 断网续传(本地缓存):网关那个"broker 没连上就丢弃"的分支太可惜。改成把收到的数据先存进一个 FreeRTOS 队列(xQueueCreate),甚至写 NVS / SD 卡,等 MQTT_EVENT_CONNECTED 重新置位后再批量补发。这样路由器重启的几分钟里,数据不丢。这是从"能用"到"可靠"的关键一步。
  • 换成 Mesh 拓扑:如果你的场景是大平层、节点离得远,可以把 ESP-NOW 汇聚换成 ESP-WIFI-MESH 自组网,让中间的节点帮边缘节点中继。覆盖范围立竿见影变大,代价是调试复杂度上一个台阶——先把汇聚版跑熟了再碰它。
  • 网关也下发命令:现在网关只往上送数据。让它在 MQTT_EVENT_CONNECTED 里多 esp_mqtt_client_subscribe 一个控制 topic,MQTT_EVENT_DATA 里收到云端命令后,再用 esp_now_send 转发给某个节点(节点要先 esp_now_add_peer)——双向就通了,你就能从手机控制末端那颗灯了。

动手挑战

别停在读,搭一套出来才算数:

  1. 2 节点 + 1 网关的小传感网:找三块 ESP32。两块当节点,各接一个传感器(哪怕一块接 DHT11、另一块就发个假数据也行),编号 node1node2,用 esp_now_send 发给第三块;第三块当网关(ESP32-S3),按上面骨架 cJSON 打包后 esp_mqtt_client_publish 转发上云。在手机上订阅 qifudev/sensors/#,看两路数据汇到一处。
  2. 拔一个节点的电:观察网关日志和云端——它会"安静地少一路",而不会报错或崩溃。体会一下汇聚结构对单点故障的从容。
  3. 加第三个节点:编号 node3,烧同样的节点固件上电。验证一件事:网关一行都不用改node3 的数据就自动出现在了云端。亲手感受"加设备 = 多一个 ID"的扩展性。

卡住了?把你的网关 main.c、节点固件、还有 idf.py monitor 的完整日志一起发给 AI,让它帮你对信道、对 struct、补缓存逻辑——这类"两端约定要一致"的活,AI 排查起来比你逐行比对快。记得提醒它按 ESP-IDF 5.x 的 esp_now + esp-mqtt 接口写,别给你回 Arduino 的 WiFi.h / PubSubClient


小结 · 下一步

  • 你理解了网关的本质:一座连接两种网络的桥,把省电的末端节点(ESP-NOW/BLE/LoRa)汇聚后统一上云。
  • 你能区分星型、Mesh、网关汇聚三种拓扑,也知道为什么家庭/小项目首选网关汇聚——省电、好管、易扩。
  • 你有了一套完整能跑的传感网骨架:esp_now 收子设备 + cJSON 转 JSON + esp_mqtt 上云,还会用 nodeId 管设备、查信道冲突、记得给 cJSON 收尾。
  • 你给设备编了 ID、想清了扩展路径,也踩明白了"ESP-NOW 与 WiFi 必须同信道、先连 WiFi 再 esp_now_init"这两个最坑的点。

本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校,尤其是 esp_now_recv_cb_t 的回调签名(5.x 第一个参数是 esp_now_recv_info_t *)和 esp_mqtt_client_config_t 的字段随版本可能微调,以官方 esp_now 文档esp-mqtt 文档为准。

这是 L3 的终点,也是一个分水岭:往前,你做的是"会联网的设备";往后,你要做"会思考的系统"。

下一步,进入 L4 阶梯,给你这套系统装上大脑——从把大模型接进硬件开始,让设备听得懂人话、做得了判断。完整的进阶地图见学习路线

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明