yangcong-bit/esp-now-tdma

1.6.0

Latest
uploaded 1 week ago
A TDMA MAC layer for ESP-NOW on ESP32-S3 with Adaptive Frequency Hopping (AFH). Provides deterministic time-slotted access, a beacon-broadcast slot step that nodes use to derive their own offsets, CMAC-authenticated beacons with replay protection, acknowledged uplink delivery with stop-and-wait retransmission, message fragmentation and reassembly across slots, a unicast downlink payload channel towards individual nodes, join-by-request so a node can be assigned an ID instead of being provisioned with one, a network-wide TX suspension gate for sharing the radio with another stack, and automatic channel hopping between 2.4 GHz channels 1/6/11 driven by packet error rate with RSSI as a veto. Provisioning stays out of the MAC layer: the application supplies the node ID, gateway MAC and keys through a channel of its own (NVS, NFC mailbox, console). Payload-agnostic: the user defines and owns all application data structures.

Readme (zh)

# ESP-TDMA-MAC 组件 (适用于 ESP-IDF)

一个开源、高性能且与数据格式无关的 TDMA(时分多址)调度 MAC 层组件,专为 ESP32-S3 上的 ESP-NOW 设计。具备超低延迟确定性通信、自适应跳频(AFH)与信标 CMAC 验签能力。

## 功能特性

- **时分多址调度 (TDMA)**:Master 节点每 10 毫秒(可配置)广播一次 Beacon 帧。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。
- **配网不归 MAC 层管**:节点如何得知自己的节点号、网关 MAC、单播密钥与信标验签密钥,组件一概不做主,也不自带任何配网格式。它只通过 `esp_tdma_slave_set_config()`(网关侧则是 `esp_tdma_master_register_node()`)接收这四个值,因此应用可以自由选择 NFC 邮箱、NVS、串口控制台或任何别的方式。时隙几何则完全不参与配网,由信标下发。
- **全网发送暂停**:`esp_tdma_master_set_tx_suspended(true)` 可让所有节点在三个信标周期内停止发送,应用可据此把射频让给其他协议栈,再确定性地收回。暂停期间网关仍持续广播信标,因此全网保持同步,恢复后下一个信标即回到各自时隙。
- **应用状态透传**:`esp_tdma_master_set_user_state()` 会在每个信标中携带一个应用自定义字节,节点通过 `on_user_state_changed` 收到。MAC 层从不解释该字节,因此 OTA 标记、标定模式、日志等级等业务状态不会渗入链路层。
- **下行载荷通道**:`esp_tdma_master_send_downlink()` 可向指定节点单播一段载荷,网关不再被那个状态字节卡住。它从帧尾余量发出(每个信标周期一包,默认间隔下 100 包/秒),因此不可能与时隙上行冲突;节点若以单播密钥注册,该包即为加密;又因为走单播,ESP-NOW 的链路层 ACK 能给出真实送达数据(`esp_tdma_master_get_downlink_sent_ok()` / `_fail()`)。
- **信标丢失检测**:节点长时间收不到有效信标会进入 `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` (最大从节点数,默认: 7,与 ESP-NOW 加密配对容量默认值一致;要提高它请先调高 `CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM`,见上方提示)
- `Beacon broadcast interval` (Beacon 广播间隔,默认: 10 ms)
- `Tail margin reserved before the next beacon` (信标帧尾部余量,默认: 2000 µs)
- `Downlink guard after the last slot` (下行保护间隔,默认: 400 µ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%)
- `Mean RSSI floor for attributing loss to a weak link` (判定"弱链路"的均值 RSSI 门限,默认: -85 dBm;低于它则高 PER 判为弱链路、不跳频)
- `Beacons per clock-recovery window` (时钟恢复窗口长度,默认: 1000 个信标;节点每窗发布一次网关时钟偏移与漂移估计)
- `Downlink retransmissions per packet` (下行重传次数,默认: 3;0 关闭。每次重传占一帧,且重传只在应用侧投递一次)
- `Uplink retransmissions per packet` (上行重传次数,默认: 3;0 关闭。节点会保留未确认的载荷、取新数据前先原序号重发;应用侧投递仍严格一次)
- `Packets between two PER / jitter reports` (PER/抖动上报间隔,默认: 100 包)
- `Maximum user payload size per packet` (单包最大用户载荷大小,默认: 220 字节)
- `Maximum fragmented message size` (分片消息最大长度,默认: 1024 字节;网关只为真正用到它的节点分配重组缓冲,最坏 RAM 为 `MAX_NODES × 该值`)
- `Reassembly timeout for a partial message` (部分消息的重组超时,默认: 2000 ms;用于兜住"发送方中途静默")
- `Join window: offset before the next beacon` (加入窗口位置,默认: 下一个信标前 300 µs;未分配 ID 的节点在此发加入请求。若尾部余量同时装不下该窗口与下行,网关会在启动时报错)
- `Automatic Wi-Fi & ESP-NOW initialization` (自动初始化 Wi-Fi 与 ESP-NOW,默认: 开启)
- `Slave TX task core / priority / stack` (从节点发送任务的核心/优先级/栈,默认: core 0 / 优先级 24 / 4096 字节)

> [!NOTE]
> **"抖动 (jitter)" 这个上报值到底测的是什么。** 网关对每个数据包计算
> `jitter = (收包时刻 - 上一次信标发送时刻) - 节点号 × 时隙步长`,也就是该包**到达时刻**相对其应有时隙的偏差。它是**端到端**量:包含信标自身的空口时长与驱动时延、节点的接收中断与调度时延、以及数据包的空口时长。它**不是**节点时隙定时器的精度,**不能**当作时钟同步精度指标引用。

> [!NOTE]
> **恢复出来的网关时钟是什么。** 节点的 `esp_tdma_slave_get_gateway_time_us()` 等于本地 `esp_timer_get_time()` 加上由信标时间戳估计出的偏移,估计取每个窗口内的**最优**样本。该最优样本仍包含所观测到的最小单向时延(信标空口时长 + 驱动与调度延迟),因此这个值至少滞后真实网关时钟这么多。它适合给样本打时间戳、读取 ppm 漂移;**不是**同步精度指标。该估计刻意不反馈进时隙定时器——时隙时序来自信标到达时刻,这在各节点间自洽。

---

## 极简示例

### 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, const void *payload, uint8_t payload_len) {
    ESP_LOGI(TAG, "从节点 %d 收到载荷, 序列号=%lu, 长度=%d", node_id, (unsigned long)seq, payload_len);
    // 逐包的应用字段(电量、采样计数等)随载荷一起传输,
    // MAC 层无需知道它们的含义:
    // my_sensor_data_t *data = (my_sensor_data_t *)payload;
    (void)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_with_policy(&data, sizeof(data), ESP_TDMA_QUEUE_DISCARD_STALE);
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}
```

信标验签密钥传 `NULL` 即可接受未验签的信标(仅限开发调试)。

> [!NOTE]
> **载荷还是消息?** `esp_tdma_slave_enqueue_with_policy()` 入队**一个包**:最多
> `CONFIG_TDMA_PAYLOAD_SIZE` 字节,原样交给 `on_data_received`。
> `esp_tdma_slave_enqueue_message()` 入队**一条消息**(最多 `CONFIG_TDMA_MSG_SIZE` 字节),组件把它拆成
> 分片、网关重组完成后才调 `on_message_received`。能装进一个包的消息走普通路径,因此小载荷不受影响。
> 每个分片占用 3 字节载荷预算(暴露为 `ESP_TDMA_FRAG_BYTES`),上行重传按分片生效——这意味着丢一个
> 分片只多花一帧,而不是整条消息重来。

> [!WARNING]
> **共享信标密钥不能证明什么。** 信标 CMAC 用的是每个已配网节点都持有的密钥,因此它认证的是**网络**而非
> **网关**:任一节点都能生成可通过验签的信标。目前有两道防线——信标必须来自所配置的网关地址,数据帧必须
> 来自其声称的节点号所登记的地址——并分别通过 `esp_tdma_slave_get_foreign_beacons()` 与
> `esp_tdma_master_get_rejected_frames()` 上报。两者都不是密码学保证。真正的逐节点广播认证要么需要非对称
> 签名(在本类硬件上按 10 ms 信标逐帧验签太慢),要么需要经逐节点加密信道发布的 TESLA 式哈希链(晚一帧
> 验证且需要重新锚定)。两者均未实现。另一条容易被忽略的推论:**派生**的 LMK(节点以 `node_id = 0` 加入)
> 是以共享密钥对该节点 MAC 做的 AES-128-CMAC,因此每个节点都能推导出其他节点的密钥、读取其下行流量。
> 若节点之间不应互相读取,请为每个节点配置各自的 LMK。

---

## 运行时接口
| 接口 | 用途 |
| --- | --- |
| `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_master_start()` / `_stop()` / `_deinit()` | 运行、暂停(保留配置)或彻底拆除后重新初始化。 |
| `esp_tdma_slave_start()` / `_stop()` / `_deinit()` | 从节点侧同样的生命周期;deinit 还会停掉 TX 任务并释放其环形缓冲。 |
| `esp_tdma_master_send_downlink(id, data, len)` | 向指定节点排队一段单播载荷,从帧尾余量发出。 |
| `esp_tdma_master_get_downlink_sent_ok()` / `_fail()` / `_dropped()` / `_pending()` / `_retransmissions()` / `_gave_up()` | 下行投递统计。 |
| `esp_tdma_master_get_duplicate_rx_count()` | 收到并被抑制的上行重传包数。 |
| `esp_tdma_slave_set_config(...)` | 一次性配置节点号、网关 MAC、单播密钥与信标验签密钥。传 `node_id = 0` 且 LMK 为 NULL 则改为由网关分配 ID。 |
| `esp_tdma_slave_get_node_id()` | 已配置或被分配的节点号(未到达前为 0)。 |
| `esp_tdma_master_release_node(id)` | 注销一个节点,释放其 ID 与 peer 表项。 |
| `esp_tdma_slave_enqueue_with_policy(data, len, policy)` | 入队一帧载荷(FIFO 或丢弃旧帧)。 |
| `esp_tdma_slave_enqueue_message(data, len)` | 入队超过单包大小的消息;网关重组后经 `on_message_received` 整条交付。 |
| `esp_tdma_master_get_messages_completed()` / `_messages_dropped()` | 已重组与已放弃的分片消息数。 |
| `esp_tdma_master_get_join_requests()` / `_join_rejections()` | 收到的 ID 申请数,以及被拒绝的数量。 |
| `esp_tdma_master_trigger_afh(ch)` | 强制跳频。当应用不同意 RSSI 门限给出的诊断时,用它覆盖。 |
| `esp_tdma_master_get_rejected_frames()` | 因来源与其声称的节点不符而被丢弃的报文数。 |
| `esp_tdma_slave_get_queue_count()` | 当前排队等待发送的载荷数。 |
| `esp_tdma_slave_get_slot_wakeups()` / `_slot_skips()` | 节点为发送而唤醒的帧数与被跳过为空闲的帧数;两者之比即射频占空比。 |
| `esp_tdma_slave_get_join_attempts()` / `_join_rejections()` | 本节点发出的 ID 申请数,以及未分配 ID 的应答数。 |
| `esp_tdma_slave_get_foreign_beacons()` | 因非本节点网关而被拒绝的信标数。 |
| `esp_tdma_slave_is_suspended()` | 网关当前是否暂停了发送。 |
| `esp_tdma_slave_get_send_ok()` / `_delivered()` / `_retransmissions()` / `_tx_gave_up()` | 驱动接受的帧数、网关确认的载荷数、额外重试次数、放弃数。 |
| `esp_tdma_slave_get_downlink_rx_count()` / `_downlink_duplicates()` | 本节点已接收的下行包数,以及被抑制的重传包数。 |
| `esp_tdma_slave_get_clock_offset_us()` / `_gateway_time_us()` / `_clock_skew_ppm()` / `_clock_samples()` | 恢复出的网关时钟偏移、网关时间、漂移估计与样本数。 |
| `esp_tdma_get_state()` / `esp_tdma_set_state(s)` | 读取或强制唯一的扁平 TDMA 状态。 |

若网关 peer 未能成功安装,`esp_tdma_slave_start()` 会返回 `ESP_ERR_INVALID_STATE`——因为缺少该 peer 的节点根本无法发送任何数据。

---

## 版本兼容性

1.6.0 为信标新增了上行确认字段,信标由 27 字节增至 29 字节,`cmac_tag` 的偏移由 23 前移至 25;同时新增三种包型:`DATA_FRAG`(0x05)、`JOIN_REQ`(0x06)、`JOIN_RESP`(0x07)。由于收发双方都按上述偏移对信标做 CMAC 校验,彼此的校验都会失败并直接丢弃对方信标。**1.6.0 与 1.5.0 互不兼容,必须全网同版本升级。**

1.5.0 删除了逐包的 `battery_pct` 字段,上行包头因此从 8 字节缩到 7 字节,每个数据包里 `packet_seq` 与 `payload` 的偏移随之前移。**1.5.0 与 1.2.0–1.4.0 互不兼容,必须全网同版本升级。**

1.4.0 只新增了下行包型、未改动任何既有布局,因此与 1.2.0/1.3.0 双向互通。空口格式的破坏性变更发生在 1.2.0:信标增至 27 字节(原 `sys_state` 被 `slot_step_us`、`tx_suspended` 与应用自定义的 `user_state` 取代),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`。

C API 在 1.3.0 再次变更,清掉了遗留的兼容面:

| 1.3.0 删除项 | 替代 |
| --- | --- |
| `esp_tdma_link_state_t`、`on_link_state_changed` | `esp_tdma_state_t`(前者只是它的冗余投影) |
| `esp_tdma_master_get_state()` / `_set_state()` | `esp_tdma_get_state()` / `esp_tdma_set_state()` |
| `esp_tdma_slave_get_state()` / `_set_state()` | `esp_tdma_get_state()` / `esp_tdma_set_state()` |
| `esp_tdma_slave_enqueue()` | `esp_tdma_slave_enqueue_with_policy(data, len, ESP_TDMA_QUEUE_DISCARD_STALE)` |
| `esp_tdma_restore_recv_cb()` | 无——组件不再知道 OTA 的存在 |
| `TDMA_PKT_LOG`、`tdma_log_pkt_t` | 无——从来没有被发送或解析过 |
| `tdma_payload_t`、`tdma_ringbuf_t`、`tdma_ringbuf_*()` | 无——已移入私有头文件 |

网关现在由 `esp_tdma_master_start()` 直接进入 `TDMA_STATE_RUNNING`,而不是停在初始化留下的状态上——这正是让 PER 窗口(进而 AFH)无需应用手工设状态就能生效的原因。

---

## 许可协议

本项目基于 Apache License 2.0 许可证开源 - 详情见 `LICENSE` 文件。

Links

Target

To add this component to your project, run:

idf.py add-dependency "yangcong-bit/esp-now-tdma^1.6.0"

download archive

Stats

  • Archive size
    Archive size ~ 137.76 KB
  • Downloaded in total
    Downloaded in total 32 times
  • Weekly Downloads Weekly Downloads (All Versions)
  • Downloaded this version
    This version: 0 times

Badge

yangcong-bit/esp-now-tdma version: 1.6.0
|