# ESP-TDMA-MAC 组件 (适用于 ESP-IDF)
一个开源、高性能且与数据格式无关的 TDMA(时分多址)调度 MAC 层组件,专为 ESP32-S3 上的 ESP-NOW 设计。具备超低延迟确定性通信、自适应跳频(AFH)、信标 CMAC 验签和 NFC 频外(OOB)配网功能。
## 功能特性
- **时分多址调度 (TDMA)**:Master 节点每 10 毫秒(可配置)广播一次 Beacon 帧。最多支持 15 个 Slave 节点在分配的时隙内进行发送,完全避免了空口碰撞。注意真实上限由 ESP-NOW 的**加密配对数**决定(见下方提示)。
- **自适应时隙步长**:网关根据实际信标周期与当前占用的最高节点号推导每个节点的时隙步长 —— `(信标周期ms × 1000 - 尾部余量µs) / 最高节点号`,并钳位到最小保护带。节点少时隙自动拉宽,节点多时隙自动收窄;时隙步长随每个信标广播,节点自行推导偏移(`节点号 × 时隙步长`),因此增删节点**永远不需要重新配网**。
- **自适应跳频 (AFH)**:基于滑动窗口实时追踪每个节点的包错误率(PER),并连同时隙抖动统计与窗口内的平均 RSSI 一并上报。检测到干扰时,Master 会以 10 帧预警倒计时调度全网同步跳频(1 -> 6 -> 11 -> 1)。倒计时冗余可覆盖"预警期内漏收部分信标"的节点;但**整段预警窗一帧信标都没收到的节点无法被召回**,会滞留在旧信道。
- **信标验签与重放保护**:信标携带 4 字节 AES-128-CMAC 标签。配置了共享密钥的节点会丢弃验签失败的信标,并拒绝时间戳(`global_time_us`)不前进的信标;网关重启后节点自动重新对齐基线。
- **24 Mbps 物理速率锁定**:组件将 ESP-NOW 锁定在 `WIFI_PHY_RATE_24M`,且**锁定失败即中止初始化**——ESP-NOW 默认速率是 1 Mbps,此时 235 字节单包空口时长约 1.88 ms,会贯穿 700 µs 的时隙并破坏整个帧;24 Mbps 下压缩至约 78 µs。
- **NFC 频外配网**:设计了 46 字节双密钥配网包,可写入 ST25DV 动态标签邮箱(Mailbox),携带节点 ID、网关 MAC、单播密钥与信标验签密钥,实现节点与网关瞬间配对、零空口污染配网。**时隙几何刻意不参与配网**,由信标下发。
- **全网发送暂停**:`esp_tdma_master_set_tx_suspended(true)` 可让所有节点在三个信标周期内停止发送,应用可据此把射频让给其他协议栈,再确定性地收回。暂停期间网关仍持续广播信标,因此全网保持同步,恢复后下一个信标即回到各自时隙。
- **应用状态透传**:`esp_tdma_master_set_user_state()` 会在每个信标中携带一个应用自定义字节,节点通过 `on_user_state_changed` 收到。MAC 层从不解释该字节,因此 OTA 标记、标定模式、日志等级等业务状态不会渗入链路层。
- **信标丢失检测**:节点长时间收不到有效信标会进入 `TDMA_STATE_SILENT_ERROR`,而不是在自由运行的时钟上继续假装在线;信标恢复后节点自动重新注册。
- **与应用载荷无关 (Payload Agnostic)**:MAC 层将应用数据视为不透明的字节缓冲区 (`void *`),由用户自行定义数据结构。
- **线程安全与无锁设计**:底层采用单生产者单消费者(SPSC)环形缓冲区,确保传感器实时采集任务(如 IMU 高频采样)永远不会被无线发送任务阻塞;支持 FIFO 与"丢弃旧帧"两种排队策略。
> [!IMPORTANT]
> **两条你一定会遇到的约束。**
> - **节点数**受 ESP-NOW 加密配对容量限制——因为每个节点都会被安装成一个加密 peer。该项默认 **7**(`CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM`,最大 17);`esp_tdma_master_init()` 会拒绝超过该容量的 `target_node_count`,所以配更多节点前必须先调高它。
> - **跳频要求 station 未关联 AP**:station 关联 AP 后 `esp_wifi_set_channel()` 无法切换信道。`esp_tdma_master_init()` 会在启动时探测这一点。
---
## 快速上手
### 1. 安装组件
将 `esp_tdma_mac` 文件夹复制到您 ESP-IDF 项目的 `components` 目录下,或者在您项目的 `main/CMakeLists.txt` 中指定该路径。
组件依赖 `esp_wifi`、`espressif/esp-now` 与 `mbedtls`(用于信标 CMAC):
```cmake
PRIV_REQUIRES esp_wifi espressif__esp-now mbedtls
```
### 2. 菜单配置 (Kconfig)
运行 `idf.py menuconfig` 并导航至 `ESP TDMA MAC Configuration` 进行参数配置:
- `Maximum number of slave nodes` (最大从节点数,默认: 15;实际上限受 ESP-NOW 加密配对容量约束,见上方提示)
- `Beacon broadcast interval` (Beacon 广播间隔,默认: 10 ms)
- `Tail margin reserved before the next beacon` (信标帧尾部余量,默认: 2000 µs)
- `Fallback time slot step per node` (兜底单节点时隙跨度,默认: 700 µs,仅在任何节点注册前使用)
- `Minimum slot step / guard interval` (时隙步长下限/保护带,默认: 300 µs)
- `Beacon loss timeout before SILENT_ERROR` (判定信标丢失的超时,默认: 500 ms)
- `PER sliding window size` (PER 滑动窗口大小,默认: 1000 包)
- `PER threshold to trigger AFH` (触发跳频的 PER 阈值,默认: 5%)
- `Packets between two PER / jitter reports` (PER/抖动上报间隔,默认: 100 包)
- `Maximum user payload size per packet` (单包最大用户载荷大小,默认: 220 字节)
- `Automatic Wi-Fi & ESP-NOW initialization` (自动初始化 Wi-Fi 与 ESP-NOW,默认: 开启)
- `Slave TX task core / priority / stack` (从节点发送任务的核心/优先级/栈,默认: core 0 / 优先级 24 / 4096 字节)
> [!NOTE]
> **"抖动 (jitter)" 这个上报值到底测的是什么。** 网关对每个数据包计算
> `jitter = (收包时刻 - 上一次信标发送时刻) - 节点号 × 时隙步长`,也就是该包**到达时刻**相对其应有时隙的偏差。它是**端到端**量:包含信标自身的空口时长与驱动时延、节点的接收中断与调度时延、以及数据包的空口时长。它**不是**节点时隙定时器的精度,**不能**当作时钟同步精度指标引用。
---
## 极简示例
### 1. Master (网关端) 设置
```c
#include "esp_tdma_mac.h"
#include "esp_log.h"
static const char *TAG = "gateway";
// 接收到从节点数据的回调函数
static void on_data_received(uint8_t node_id, uint32_t seq, uint8_t battery,
const void *payload, uint8_t payload_len) {
ESP_LOGI(TAG, "从节点 %d 收到载荷, 序列号=%lu, 电量=%d%%, 长度=%d",
node_id, (unsigned long)seq, battery, payload_len);
// 可在此处将 payload 强转回您自定义的结构体
// my_sensor_data_t *data = (my_sensor_data_t *)payload;
}
// 节点成功注册并进入调度集的回调函数
static void on_node_registered(uint8_t node_id) {
ESP_LOGI(TAG, "节点 %d 注册成功并已纳入调度", node_id);
}
void app_main(void) {
// Wi-Fi(STA模式)和 ESP-NOW 将由组件在启动时自动初始化。
// 无需在应用层编写繁琐的 Wi-Fi/ESP-NOW 初始化样板代码。
esp_tdma_master_cfg_t cfg = {
.target_node_count = 4,
.on_data_received = on_data_received,
.on_node_registered = on_node_registered,
.on_state_changed = NULL
};
ESP_ERROR_CHECK(esp_tdma_master_init(&cfg));
// 可选:为每个信标签名,节点可据此验签
static const uint8_t beacon_cmac_key[16] = { /* 从安全存储读取的共享密钥 */ };
esp_tdma_master_set_beacon_key(beacon_cmac_key);
ESP_ERROR_CHECK(esp_tdma_master_start());
// 默认配置下(10 ms 帧、2000 µs 尾部余量)4 个节点的时隙步长为 2000 µs
ESP_LOGI(TAG, "时隙步长 = %lu us", (unsigned long)esp_tdma_master_get_slot_step_us());
// 运行时修改节点数,新帧结构从下一个信标生效
// esp_tdma_master_set_target_node_count(8);
// 把射频让给其他协议栈(例如托管的 OTA 会话)后收回。
// 这里的延时是必须的:置位瞬间可能仍有节点正在发送,节点最多需要三个
// 信标周期才能观察到该标志。
// esp_tdma_master_set_tx_suspended(true);
// vTaskDelay(pdMS_TO_TICKS(3 * CONFIG_TDMA_BEACON_INTERVAL_MS));
// ... 使用射频 ...
// esp_tdma_master_set_tx_suspended(false);
}
```
### 2. Slave (从节点端) 设置
```c
#include "esp_tdma_mac.h"
#include "esp_log.h"
static const char *TAG = "node";
// 网关的 user_state 字节会原样透传到此处
static void on_user_state_changed(uint8_t user_state) {
ESP_LOGI(TAG, "网关 user_state 变更为 %d", user_state);
}
void app_main(void) {
// Wi-Fi(STA模式)和 ESP-NOW 将由组件在启动时自动初始化。
// 无需在应用层编写繁琐的 Wi-Fi/ESP-NOW 初始化样板代码。
esp_tdma_slave_cfg_t cfg = {
.on_state_changed = NULL,
.on_user_state_changed = on_user_state_changed
};
ESP_ERROR_CHECK(esp_tdma_slave_init(&cfg));
// 配置信息(可通过 NFC 频外获取或从 NVS 中加载):
uint8_t node_id = 1;
uint8_t gateway_mac[6] = {0x28, 0x84, 0x85, 0x52, 0xC4, 0x0C};
uint8_t node_unicast_lmk[16] = {0}; // 用于加密 ESP-NOW 的单播局部主密钥
uint8_t beacon_cmac_key[16] = {0}; // 必须与网关的信标密钥一致
// 不再需要计算时隙偏移:节点从每个信标携带的时隙步长自行推导,
// 因此网关改变帧结构时本地不会有任何需要同步的陈旧参数。
esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk, beacon_cmac_key);
ESP_ERROR_CHECK(esp_tdma_slave_start());
// 在您的传感器采样任务中:
while (1) {
my_sensor_data_t data = read_sensor();
// 将采集到的数据压入发送队列(非阻塞)
esp_tdma_slave_enqueue(&data, sizeof(data));
vTaskDelay(pdMS_TO_TICKS(10));
}
}
```
信标验签密钥传 `NULL` 即可接受未验签的信标(仅限开发调试)。
---
## 运行时接口
| 接口 | 用途 |
| --- | --- |
| `esp_tdma_master_set_target_node_count(n)` | 运行时按新的节点数重算时隙步长。 |
| `esp_tdma_master_get_target_node_count()` | 当前期望的节点数。 |
| `esp_tdma_master_get_slot_step_us()` | 当前时隙步长,即信标中广播的值。 |
| `esp_tdma_master_set_user_state(b)` / `get_user_state()` | 应用自定义字节,透传给所有节点。 |
| `esp_tdma_master_set_tx_suspended(b)` / `is_tx_suspended()` | 全网发送闸门;暂停期间网关仍持续广播信标。 |
| `esp_tdma_master_set_beacon_key(key)` | 开启信标 CMAC 签名。 |
| `esp_tdma_master_get_node_mac(id, out)` | 查询已登记节点的 MAC。 |
| `esp_tdma_master_get_node_lmk(id, out)` | 重新配网时复用已存储的单播密钥。 |
| `esp_tdma_master_is_node_online(id)` | 基于心跳的在线判定(6 秒超时)。 |
| `esp_tdma_restore_recv_cb()` | 其他协议栈覆盖了 ESP-NOW 原始回调后,重新装载 TDMA 收包分发。 |
| `esp_tdma_slave_set_config(...)` | 一次性配置节点号、网关 MAC、单播密钥与信标验签密钥。 |
| `esp_tdma_slave_is_suspended()` | 网关当前是否暂停了发送。 |
若网关 peer 未能成功安装,`esp_tdma_slave_start()` 会返回 `ESP_ERR_INVALID_STATE`——因为缺少该 peer 的节点根本无法发送任何数据。
---
## 版本兼容性
1.2.0 变更了空口格式:信标增至 27 字节(原 `sys_state` 被 `slot_step_us`、`tx_suspended` 与应用自定义的 `user_state` 取代),NFC 配网包缩减为 46 字节(时隙偏移改为由信标推导),REG_ACK 缩减为 18 字节。**1.2.0 的网关与 1.1.x 的节点互不兼容,反之亦然,必须全网同版本升级。**
C API 同样在 1.2.0 变更:`esp_tdma_slave_set_config_ex()` 与旧的 4 参数 `esp_tdma_slave_set_config()` 合并为单一的 `esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk, beacon_cmac_key)`;`on_node_registered` / `on_sys_state_changed` 被替换为 `on_node_registered(node_id)` 与 `on_user_state_changed`。现有调用点需要一行改动。
---
## 许可协议
本项目基于 Apache License 2.0 许可证开源 - 详情见 `LICENSE` 文件。
17cd166f456fb2f55a0d8265482f374004ab7cc9
idf.py add-dependency "yangcong-bit/esp-now-tdma^1.2.0"