ESP-NOW实战进阶:双向通信、可靠性与低功耗节点设计
上一篇文章我们把 ESP-NOW 从零跑通了一条单向链路:A 板发送、B 板接收,串口把数据打出来。那块 demo 板放在桌面上闪着灯,看起来一切正常。但真正把这个协议往一个实际项目里放的时候,你会发现单向发送只是把大象的一只脚摸清了。这篇文章继续聊 ESP-NOW 的第二轮实战:双向确认、一对多拓扑、可靠性设计,以及低功耗节点怎么在 Deep Sleep 唤醒后快速完成一次收发。
目标是让已经跑过基础 demo 的开发者,能直接把 ESP-NOW 用到具备完整功能的场景里:智能开关要回传状态,传感器节点要确认网关收到数据,电池供电的采集端要能在低功耗模式下稳定工作。如果你正准备做这类事情,这篇文章里的代码、时序和踩坑记录可以直接拿过去改改就上。
1. 双向通信的硬需求:发出去不代表送到,业务层必须自己握手
1.1 单向 demo 的盲区:send 成功只代表"帧已上射频"
Part 1 的例子里,发送端通常就是循环调用esp_now_send(),然后看返回ESP_OK就觉得发出去了。接收端回调一打,数据出来了,demo 就算完成。但这里藏着一个很多人没意识到的坑:esp_now_send()返回ESP_OK只代表帧已经成功交给 Wi-Fi 硬件发送,并不代表接收端真的收到了,更不代表接收端处理成功了。
我们做个实验就能验证:把接收端断电,发送端继续调用esp_now_send(),返回值大概率还是ESP_OK。只有在注册了发送回调esp_now_send_cb_t之后,你才会收到一个status,可能是ESPNOW_SEND_SUCCESS,也可能是ESPNOW_SEND_FAIL。但即使收到ESPNOW_SEND_SUCCESS,它也只说明这帧数据在空口上被成功发送了,对方有没有正确处理,协议层给不了答案。
这不是 ESP-NOW 的偷懒,而是所有无连接无线协议的共性。就像你往楼下喊了一嗓子,声音确实出去了,但对方到底听见没有、听懂没有,你得等他回一句话才知道。所以,只要业务上需要确认"对方确实收到并且处理成功了",就必须在应用层自己做一套请求-应答机制。
1.2 请求-应答模型实现:一个带 ACK 的远程控制帧
最简单的可靠通信模型,就是模仿网络协议里最经典的 ACK 机制:A 给 B 发一条命令帧,B 收到后立刻回一条 ACK 帧,A 收到 ACK 才认为这次发送真正完成了。
以 Arduino-ESP32 框架为例,我实际项目里用到的双向通信骨架大概是这样的。先定义消息类型和数据结构:
typedef enum { MSG_TYPE_CMD = 0, MSG_TYPE_ACK, MSG_TYPE_DATA } msg_type_t; typedef struct { uint8_t type; // msg_type_t uint8_t seq; // 序列号,ACK 回同一序列号 uint8_t cmd; // 命令字,比如 0x01 = 开灯 uint8_t reserved; uint32_t timestamp; // 发送端时间戳 } control_frame_t; // 对端设备的 MAC 地址 uint8_t peer_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};初始化 ESP-NOW 并添加对端,这部分要做的准备比 Part 1 多一些,我一般会把初始化封装成一个函数:
void espnow_init_bidirectional() { WiFi.mode(WIFI_STA); // ESP-NOW 在 STA 模式下工作最稳定 WiFi.disconnect(); if (esp_now_init() != ESP_OK) { Serial.println("esp_now init failed"); return; } esp_now_register_send_cb(espnow_send_cb); esp_now_register_recv_cb(espnow_recv_cb); esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, peer_mac, 6); peer.channel = 1; // 信道必须与对端一致 peer.encrypt = false; // 裸传输,后面可以开加密 esp_now_add_peer(&peer); }发送端维护一个简单的状态:命令发出去之后,等待对应的 ACK 回来,超时再重发。核心逻辑大致如下:
uint8_t current_seq = 0; bool ack_received = false; unsigned long last_send_time = 0; void send_cmd_with_ack(uint8_t cmd) { control_frame_t frame; frame.type = MSG_TYPE_CMD; frame.seq = current_seq; frame.cmd = cmd; frame.timestamp = millis(); ack_received = false; esp_now_send(peer_mac, (uint8_t *)&frame, sizeof(frame)); last_send_time = millis(); // 等待 ACK,超时 500ms while (!ack_received && millis() - last_send_time < 500) { delay(10); } } void espnow_send_cb(const uint8_t *mac_addr, esp_now_send_status_t status) { // 这里只能判断这帧是否成功发出,不能判断业务 ACK if (status != ESPNOW_SEND_SUCCESS) { Serial.println("send failed at RF level"); } } void espnow_recv_cb(const uint8_t *mac_addr, const uint8_t *data, int data_len) { if (data_len != sizeof(control_frame_t)) return; control_frame_t *rx = (control_frame_t *)data; if (rx->type == MSG_TYPE_CMD) { // 收到命令,先回 ACK control_frame_t ack; ack.type = MSG_TYPE_ACK; ack.seq = rx->seq; ack.cmd = rx->cmd; ack.timestamp = rx->timestamp; esp_now_send(mac_addr, (uint8_t *)&ack, sizeof(ack)); // 再执行具体命令 if (rx->cmd == 0x01) { // 开灯 } } else if (rx->type == MSG_TYPE_ACK) { if (rx->seq == current_seq) { ack_received = true; } } }这段代码的关键在于 ACK 里回带了序列号seq。如果不带序列号,发送端无法分辨这个 ACK 是回应哪一条命令的,特别是在连续快速发送多条命令时,ACK 串了会导致状态错乱。加一个seq字段,成本只有 1 字节,换来的是通信状态可追踪。
1.3 底层 ACK 与应用层 ACK 的边界:快递回执与内容验收
有个问题经常被问到:ESP-NOW 协议本身不是有 ACK 吗?为什么还要应用层再做一套 ACK?
没错,默认情况下 ESP-NOW 在数据链路层是会做 ACK 的,也就是每帧数据发出后,对端 Wi-Fi 硬件收到会回一个链路层确认。这也是为什么esp_now_send_cb里能拿到ESPNOW_SEND_SUCCESS或ESPNOW_SEND_FAIL。但链路层 ACK 只说明"帧被对端网卡收到了",不能说明"对端应用层处理成功了"。比如接收端收到帧后,在回调里解析失败、业务逻辑报错,或者设备只剩 1% 电量收到数据但没来得及保存就关机了,这些情况链路层 ACK 统统不知道。
拿生活类比就是:快递物流显示"已签收",但包裹里的东西是坏的、或者签收人根本不在家,物流系统不会管。应用层 ACK 就是让你在业务层面确认"收到的是对的东西"。
实际项目里,链路层 ACK 通常用来判断"是否需要重发整帧",应用层 ACK 用来判断"业务是否完成"。多层确认各管各的,两者结合才能构建出可靠的通信链路。对于大多数传感器上报场景,链路层 ACK 加上超时重试就够了;但涉及控制类命令、状态变更、固件参数下发这类必须确保执行的场景,应用层 ACK 不能省。
2. 一对多组网:广播唤不醒"特定设备",伪组播才是常态
2.1 广播地址 FF:FF:FF:FF:FF:FF 的边界:集体唤醒可以,精准控制不行
ESP-NOW 支持广播,地址就是FF:FF:FF:FF:FF:FF。发送端不需要为广播地址添加 peer,直接调用esp_now_send就能把帧发出去,所有处于同一信道、支持 ESP-NOW 的设备都能收到。
这个机制在特定场景下非常香:批量唤醒。比如几十个低功耗传感器节点都在 Deep Sleep,网关想告诉它们"现在开始上报",用单播就得一个个发,频次低、耗时长;用广播一条指令出去,所有节点同时醒来,效率高得多。
但广播的缺点也突出:无法精准控制,没有对端 ACK。你在esp_now_send_cb里回调拿到ESPNOW_SEND_SUCCESS,那只是说广播帧在空口被发出去了,至于这一个包有几个设备收到,一个都没有还是全部收到,回调不告诉你。而且广播帧还会占用大量空口时间,低速率下尤其明显。我在一个 30 节点的测试环境里试过,如果所有节点都用广播做业务数据上报,冲突率会高到离谱,丢包率直接可以到 10% 以上。
所以广播的定位就一句话:适合通知、唤醒、时间同步,不适合业务数据确认。业务数据要可靠送达,得走下面的伪组播方案。
2.2 多 peer 逐个单播和目标 ID 过滤:两种伪组播的实际取舍
ESP-NOW 没有一个"组播组"的概念,但实际项目里经常需要发给多个设备。我实践中常用两种方式来模拟组播。
第一种方式:手动维护一张 peer 列表,需要发送时逐个单播。这个方法可靠性最高,因为每一条单播都有独立的发送状态回调,能逐个确认。代价是空中占用线性上升,10 个设备就是 10 帧,20 个就是 20 帧。如果设备数量少、数据重要,就选这个。
第二种方式:一帧数据里带上"目标设备 ID",接收端收到后先检查 ID,不是自己的就丢弃。这种方式在空中仍然是一次广播或一次单播,但接收端增加了过滤层。设备 ID 可以是设备编号、设备类型、或者分组 ID。比如我可以定义group_id=0x01表示客厅设备,广播帧里带上group_id=0x01,只有客厅设备处理,卧室设备直接丢弃。
两种方案放一起对比:
| 方案 | 能否逐个确认到达 | 空中开销 | 实现复杂度 | 适合场景 |
|---|---|---|---|---|
| 多 peer 逐个单播 | 可以,每帧有独立回调 | O(n),n 为设备数量 | 低,循环调用即可 | 控制命令、固件参数下发 |
| 目标 ID 过滤 | 取决于发送方式,广播则不能确认 | O(1),一次广播 | 中,需要维护 ID 表 | 分组通知、批量配置 |
| 混用 | 关键设备单播,其余广播 | 视混合比例 | 较高 | 大群组+重点保障 |
实际项目里我更推荐混用:网关给一组设备同步参数,用广播带group_id的方式,效率高;重要控制指令,比如窗帘电机、门锁这类必须到达的设备,用单播并等待 ACK。
2.3 信道一致性:所有节点必须坐进同一个频段房间
ESP-NOW 是基于 Wi-Fi 帧的,所以信道一致性是硬约束。节点在信道 1,对端在信道 6,无论你调用多少次esp_now_send,对方都收不到。
这里有一个很容易忽略的坑:如果设备同时连着路由器,信道是跟随路由器的。某些路由器的 2.4GHz 频段有自动信道优化功能,可能在某个时间点忽然切换信道,比如从 1 跳到 11。对普通网页访问来说,这个切换影响不大,终端会自动重连;但对 ESP-NOW 来说,设备信道变了,另外一端还停在老信道,两边就直接失联了,而且这种失联在日志上没有任何报错,非常难排查。
我的建议是:所有 ESP-NOW 设备在使用期间显式固定信道,在初始化阶段调用:
esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);如果同一环境里还有别的 Wi-Fi 网络要共存,固定信道前先做个现场勘查,选一个干扰最小的信道,通常 1、6、11 三选一。别依赖路由器的自动信道,也别相信所有设备默认会落在同一信道。
3. 可靠性三件套:超时重传、序列号去重、上报错峰
3.1 实测丢包率:从"几乎为 0"到"惨不忍睹"只隔一个干扰源
ESP-NOW 在理想环境下确实很稳定。两块板子放同一张桌子,距离半米,周围没有其他 Wi-Fi 设备,我一小时发 3600 帧,丢包率为 0。但一旦场景变成现实环境,情况就急转直下:隔一堵墙,丢包率开始有小数点;旁边放一个 USB 3.0 硬盘盒在传数据,丢包率能冲到 3% 以上;如果几个 ESP-NOW 节点同时高频发送,互相碰撞导致的丢包率会更高。
为什么?因为 2.4GHz 这个频段太拥挤了。Wi-Fi、蓝牙、Zigbee、微波炉都在这个频段附近,而 ESP-NOW 没有 CSMA/CA 那种强冲突避让机制,发送前虽然会做空闲检测,但多节点同时发送的概率还是不低。再加上 ESP-NOW 默认速率在 1Mbps 到 11Mbps 之间,低速率意味着每帧在空中占用时间长,碰撞窗口就更大。
所以做 ESP-NOW 项目,我从来不敢赌"丢包率很低,不用做重传"。设计通信协议时,一定要把重传、去重、错峰这三件事一起考虑进去。
3.2 重传状态机:指数退避和随机抖动缺一不可
重传不能简单粗暴地"没收到就再发一次"。如果发送端和接收端之间持续存在干扰,盲目重传只会让空口更拥塞,形成恶性循环。我用的是一个带指数退避的轻量状态机。
const int MAX_RETRY = 5; const unsigned long BASE_TIMEOUT = 100; // 初始超时 100ms const unsigned long MAX_TIMEOUT = 1000; // 最大超时 1s int retry_count = 0; unsigned long timeout_ms; void send_with_retry() { retry_count = 0; timeout_ms = BASE_TIMEOUT; send_frame(); } void on_ack_received() { retry_count = 0; // 收到 ACK,重置状态 timeout_ms = BASE_TIMEOUT; } void on_timeout() { if (retry_count >= MAX_RETRY) { // 彻底失败,告诉业务层这次发送失败 mark_transmission_failed(); return; } retry_count++; timeout_ms *= 2; // 指数退避 // 加随机抖动,避免多个节点在相同时刻一起重发 timeout_ms += random(0, 50); send_frame(); }这里有个细节:每次重发要重新生成一个新的seq值,不能沿用旧的。因为对端可能早就收到第一帧了,只是 ACK 丢了,你重发一个相同seq的帧,对端在去重表里发现已经处理过,可能直接丢弃,然后你的 ACK 还是不会回来,白白浪费一次重试。改用新seq,对端就会认为这是新消息,重新处理并回 ACK。
还有,超时时间从 100ms 起步是我测试下来比较合理的值。ESP-NOW 一帧在空口的传输时间非常短,正常 100ms 内 ACK 肯定回来了。如果 100ms 没回来,大概率是信道有问题或对端不在线,这时候继续死等没有意义,直接进入退避重试更高效。
3.3 去重表:重传带来的副作用必须用序列号消掉
有了重传机制,接收端就一定存在收到重复帧的可能。如果接收端收到重复的"开灯"命令,再执行一次开灯,灯不会坏,但如果是收到重复的"切换继电器状态"命令,继电器就会来回翻转,整个系统直接逻辑混乱。
去重的做法不复杂,维护一张最近收到的帧记录表:(源 MAC、序列号、收到时间)。每条新帧进来先查表,命中就丢弃,未命中就加入表。表里记录设置了超时时间,一般 5 到 10 秒就清理一遍,防止表被撑爆。
typedef struct { uint8_t mac[6]; uint8_t seq; unsigned long last_time; bool used; } dedup_entry_t; dedup_entry_t dedup_table[32]; bool is_duplicate(uint8_t *mac, uint8_t seq) { unsigned long now = millis(); for (int i = 0; i < 32; i++) { if (dedup_table[i].used && now - dedup_table[i].last_time > 10000) { dedup_table[i].used = false; // 过期清理 } } for (int i = 0; i < 32; i++) { if (dedup_table[i].used && memcmp(dedup_table[i].mac, mac, 6) == 0 && dedup_table[i].seq == seq) { return true; } } // 写入新条目 for (int i = 0; i < 32; i++) { if (!dedup_table[i].used) { memcpy(dedup_table[i].mac, mac, 6); dedup_table[i].seq = seq; dedup_table[i].last_time = now; dedup_table[i].used = true; break; } } return false; }注意一个实际问题:ESP32 的序列号是 8 位uint8_t,最多 256 个值。如果发送端发得很快,接收端表里可能还留着某个seq的记录,但发送端已经循环用到相同seq了。这种极端情况碰撞概率极低,但设计严格的系统可以把seq扩展到 16 位,或者加上时间戳一起校验。对于大多数场景,8 位序列号加 10 秒过期清理已经完全够用。
3.4 多对一上报的拥塞控制:让每个节点学会排队
一对多和广播解决了"怎么同时给一批设备发指令"的问题,反过来还有另一个方向的问题:多个传感器节点同时向网关上报数据。这个问题在很多 ESP-NOW 教程里被忽略了,但实际项目里它是丢包重灾区。
20 个传感器如果都设置成每 60 秒上报一次,各位可以发现一个诡异的现象:所有节点几乎在同一时刻醒来(因为定时器都是从整点开始算的),然后一窝蜂挤着发送。结果就是空口瞬间冲突爆炸,大量帧丢失,触发重传,重传再次同频碰撞,形成所谓"重传风暴"。
解法也很直接:上报时间加随机偏移。我习惯在每个节点启动时生成一个 0 到 1000ms 的随机延迟,存储到 RTC 内存里,每次上报时先等这个延迟再发。更进一步,可以让网关在广播时间同步包时,顺手为每个节点分配不同的上报时隙,类似 TDMA 的思路。虽然精度不高,但足以让各节点错开碰撞窗口。
对单节点来说,这个改动只是加了一行delay(random(0, 1000)),但整个系统的收包成功率能提升一个级别,这个优化性价比极高。
4. Deep Sleep 唤醒后快速收发:低功耗节点的完整时序
4.1 唤醒后重新建立 ESP-NOW 链路的正确顺序
电池供电的采集节点,设计上几乎都会用 Deep Sleep,每隔几分钟醒来一次,采集数据、发送、再睡。这里有个很多人踩过的坑:从 Deep Sleep 醒来后,Wi-Fi 和 ESP-NOW 是需要重新初始化的,不能直接调用esp_now_send。如果省略初始化,大概率得到ESP_ERR_ESPNOW_NOT_INIT之类的错误。
正确的初始化顺序是:
esp_wifi_init(&wifi_config); // 配置 STA 模式 esp_wifi_set_storage(WIFI_STORAGE_RAM); esp_wifi_start(); esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE); esp_now_init(); esp_now_add_peer(&peer);先把 Wi-Fi 拉起来,再初始化 ESP-NOW,再添加对端。有的例程里是先用esp_wifi_start、再用esp_now_init,顺序反了就会 init 失败。这个顺序问题在 ESP-IDF 里尤其严格,Arduino 框架因为封装了esp_now_begin()内部帮你处理了一部分,但我们做低功耗程序时经常直接操作底层 API,习惯了严格顺序能少踩很多坑。
4.2 先发数据再等 ACK:最省功耗的收发时序设计
低功耗节点的黄金法则是:能睡就睡,醒来越短越好。所以唤醒后的流程在设计上要尽量精简。我优化过无数遍的流程是:
- 唤醒,初始化 Wi-Fi 和 ESP-NOW
- 立刻发送上报帧
- 等待 ACK,超时重试 1 到 3 次
- 无论成败,直接进入 Deep Sleep
注意,等待 ACK 不能用delay死等,那样会浪费大部分唤醒时间。正确做法是用状态机配合超时计时,让 ACK 回调在中断上下文之外第一时间处理。如果在等待 ACK 期间没有收到,超时后立即重发,重试两三次仍然没有 ACK,说明对端不在线或信号太差,继续耗着只会耗电,果断入睡等下一个周期。
这里我实测过的功耗数据可以给大家参考。ESP32 系列在 Deep Sleep 模式下电流大约 5 到 10 微安,取决于外围电路。唤醒后 Wi-Fi 初始化加发送一帧数据,整个过程大约 30 到 80 毫秒,期间平均电流可能在 80 到 120 毫安。把这些数字放到一个 5 分钟上报一次的节点上,平均电流可以压到 20 到 30 微安左右,这已经是电池能撑很久的水平了。
4.3 错峰唤醒:RTC 内存里存一个随机偏移
上一节提到上报错峰,对于低功耗节点还有一个专门的做法:把随机偏移存在 RTC 内存里,而不是每次唤醒后临时计算。
为什么?因为 Deep Sleep 唤醒后如果直接调用random(),种子如果没有正确保存,每次重启的随机序列可能相同,或者差异很小,反而达不到错峰的效果。把偏移在首次启动时生成并存进 RTC 内存,之后每次醒来读取,就绕开了随机种子的问题。
RTC_DATA_ATTR uint16_t wake_offset = 0; RTC_DATA_ATTR bool offset_initialized = false; void setup() { if (!offset_initialized) { wake_offset = random(0, 1000); offset_initialized = true; } // 唤醒后先等自己的偏移时间 delay(wake_offset); // 再开始初始化 Wi-Fi 和 ESP-NOW }这个方案的巧妙之处在于,节点错峰不仅发生在"上报时刻",还发生在"唤醒时刻"。所有节点即使定时器同时触发,也会错开醒来的时间窗口,从根源上减少无线信道的碰撞窗口。
4.4 功耗账本:瞬间电流比平均电流更值得担心
上面算了平均电流,但实际做电池供电项目时,真正的敌人是瞬间浪涌电流。Wi-Fi 启停的瞬间电流尖峰可以超过 300 毫安,如果电池内阻大或者供电线路细,这个尖峰瞬间拉低电压,可能造成设备直接复位,表现为"一唤醒就死循环重启"。
排查方法很土但有效:用示波器同时抓电源轨和 GPIO 唤醒引脚,看唤醒瞬间电压跌落幅度。如果一个 2000mAh 的锂电池在唤醒瞬间电压掉了 0.5V 以上,就要考虑在电源输入端并一个大电容,比如 470uF 到 1000uF,或者改用低内阻的锂电池。
电容并上之后,唤醒时先由电容放电维持电压,电池慢慢补充,整个系统的稳定性会有质的改善。这个经验,我用 ESP-NOW 项目里至少遇到 5 次设备莫名重启,最后全是供电问题,没有一次是协议问题。
5. 排查链路:把"时好时坏"变成可定位的技术问题
5.1 初始化失败的常见原因:Wi-Fi 模式不对,其他都是借口
每次看到有人在社区问esp_now_init返回错误,十有八九是 Wi-Fi 模式没设置对。ESP-NOW 需要设备处于 STA 模式或 AP 模式下才能初始化,如果 WiFi 处于关闭状态就调用esp_now_init,返回值必然报错。
另外注意一点:在 Arduino 框架里,WiFi.mode(WIFI_STA)本身会有一个短暂的过程,极端情况下立刻接着调esp_now_begin可能还没就绪。我会在初始化和对端添加之间加一个小延时,或者用循环等待的方式确保 Wi-Fi 状态到位。
WiFi.mode(WIFI_STA); while (!WiFi.STA.started()) { delay(10); } esp_now_begin();5.2 单向通反向不通:peer 表是双向的,不是单向好友
第二个高频问题:A 能发到 B,B 发回 A 就失败。原因往往特别智障但特别容易犯——A 添加了 B 作为 peer,但 B 没有添加 A 作为 peer。ESP-NOW 的 peer 表是每个设备各自维护的,不是一个全局的"好友关系"。A 有 B 的条目,不代表 B 有 A 的条目。
排查方向:打印两个设备的 peer 表。ESP-IDF 可以用esp_now_get_peer遍历,Arduino 框架也有对应的esp_now_peer_num等接口。发现谁缺了,就补上esp_now_add_peer。这个问题在双向通信里是 90% 的"反向不通"根因。
5.3 信道漂移导致的间歇性失联:固定信道是最直接的解法
这个问题前面提过一次,但值得从排错角度再说一遍。现象表现为:设备刚上电时通信正常,过一段时间突然收不到数据了,重启设备又好。如果你在日志里看不到任何报错,大概率是信道漂了。
如果节点连着路由器做 Wi-Fi 数据中转,路由器切换信道后,节点跟随路由器切到了新信道,而另一端还停在初始的信道。排查方法很简单:把两边设备的当前信道打出来,esp_wifi_get_channel(&primary, &secondary),如果不一致,那就是信道漂了。
根治办法就是固定信道,或者更彻底一点,设备完全不连路由器,只跑 ESP-NOW,这样信道完全由你自己掌控。
5.4 用接收信息和 RSSI 做现场诊断
ESP-NOW 的接收回调有个容易被忽略的信息:RSSI(接收信号强度指示)。在 Arduino-ESP32 的新版本里,esp_now_recv_cb的第一个参数是esp_now_recv_info_t,里面包含rx_ctrl,可以拿到 RSSI。
void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data, int data_len) { Serial.print("RSSI: "); Serial.println(info->rx_ctrl->rssi); // 注意:旧版本回调是 (mac_addr, data, len),拿不到 RSSI,使用时注意区分 }RSSI 在排错时特别有用。比如某个设备时好时坏,打出来的 RSSI 稳定在 -70dBm 以下,那大概率是信号弱;如果 RSSI 波动特别大,一下 -40 一下 -80,那多半是周围又移动物体或干扰源,需要调整天线位置或换信道。
我平时调试 ESP-NOW 都有一个习惯:接收端日志只输出两行,一是发送端 MAC 的前三个字节,二是 RSSI。这两条信息足够快速判断现场大部分问题了。
写到这里,ESP-NOW 的 Part 2 也差不多收尾了。最后再分享一个小习惯:我在把任何一套双向通信方案固化到代码库之前,都会用两块板子加一个衰减器模拟弱信号环境,人为制造几十个百分点的丢包率,然后把重传、去重、错峰这三件事全部打开,观察整个系统能不能自我恢复。花半天时间做这个压力测试,到了正式部署之后能省下好几天的现场调试时间,很划算。
