yangcong-bit/esp-now-tdma

1.2.0

Latest
uploaded 13 hours 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, NFC OOB provisioning support, a network-wide TX suspension gate for sharing the radio with another stack, and automatic PER-based channel hopping between 2.4 GHz channels 1/6/11. Payload-agnostic: the user defines and owns all application data structures.

Readme

# ESP-TDMA-MAC Component for ESP-IDF

An open-source, high-performance, and payload-agnostic TDMA (Time Division Multiple Access) scheduled MAC layer for ESP-NOW on ESP32-S3. Features low-latency deterministic communication with Adaptive Frequency Hopping (AFH), CMAC-authenticated beacons, and NFC Out-Of-Band (OOB) provisioning.

## Features

- **Time Division scheduled (TDMA)**: Master broadcasts a beacon every 10 ms (configurable). Up to 15 slaves transmit in assigned slots, completely eliminating collisions. Note that the real ceiling is the ESP-NOW encrypted-peer capacity (see the note below).
- **Adaptive slot step**: The master derives the per-node slot step from the actual beacon period and the highest node ID in use — `(BEACON_INTERVAL_MS * 1000 - BEACON_TAIL_MARGIN_US) / highest_id`, clamped to a minimum guard interval. Fewer nodes automatically get wider slots; more nodes get tighter ones. The step is broadcast in every beacon and the nodes derive their own offset from it (`node_id * slot_step_us`), so changing the node count never requires re-provisioning the fleet.
- **Adaptive Frequency Hopping (AFH)**: Automatic real-time Packet Error Rate (PER) tracking per node over a sliding window, reported together with the slot-jitter statistics and the mean RSSI of the window. When interference is detected, the master schedules a synchronized hop across channels (1 -> 6 -> 11 -> 1) with a 10-frame warning countdown. The countdown redundancy covers nodes that miss some beacons during the warning window; a node that misses every beacon of the window cannot be recalled and stays on the old channel.
- **Beacon authentication & replay protection**: Beacons carry a 4-byte AES-128-CMAC tag. Nodes provisioned with the shared key drop beacons that fail verification, and reject beacons whose `global_time_us` does not advance (with automatic recovery after a master reboot).
- **24 Mbps PHY rate lock**: ESP-NOW is pinned to `WIFI_PHY_RATE_24M`, and a failure to lock the rate is fatal — ESP-NOW defaults to 1 Mbps, where a 235-byte packet occupies ~1.88 ms of airtime and would overrun a 700 us slot and break the whole frame; at 24 Mbps it is ~78 us.
- **NFC Out-Of-Band Provisioning**: 46-byte dual-key provisioning payload designed to be written to an ST25DV mailbox — node ID, gateway MAC, unicast LMK and beacon CMAC key. Allows nodes to pair instantly with zero radio pollution. The slot geometry is deliberately not provisioned: it comes from the beacon.
- **Network-wide TX suspension**: `esp_tdma_master_set_tx_suspended(true)` makes every node stop transmitting within three beacon periods, so the application can hand the radio to another stack and take it back deterministically. The master keeps beaconing, so the fleet stays synchronised and returns to its slots on the next beacon.
- **Opaque application state relay**: `esp_tdma_master_set_user_state()` sends one application-defined byte in every beacon; nodes receive it through `on_user_state_changed`. The MAC layer never interprets it, so application-level modes (OTA markers, calibration, logging levels) stay out of the link layer.
- **Beacon-loss detection**: A node that stops hearing valid beacons reports `TDMA_STATE_SILENT_ERROR` instead of running forever on a free-running clock, and re-registers by itself once beacons return.
- **Payload Agnostic**: The MAC layer treats the payload as an opaque byte buffer (`void *`). You define your own structures.
- **Thread-safe & Lock-free**: Uses a Single-Producer Single-Consumer (SPSC) ring buffer under the hood, ensuring that real-time sensor processing (e.g., IMU sampling) is never blocked by transmission. Selectable FIFO or discard-stale queueing policy.

> [!IMPORTANT]
> **Two constraints you will hit.**
> - **Node count** is capped by the ESP-NOW encrypted-peer capacity, because every node is installed as an encrypted peer. The default is **7** (`CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM`, max 17); `esp_tdma_master_init()` refuses a larger `target_node_count`, so raise it before configuring more nodes.
> - **Channel hopping** requires an unassociated station: `esp_wifi_set_channel()` cannot switch channel while the station is associated with an AP. `esp_tdma_master_init()` probes this at startup.

---

## Getting Started

### 1. Installation

Copy the `esp_tdma_mac` folder into your ESP-IDF project's `components` directory or specify it in your `main/CMakeLists.txt`.

The component requires `esp_wifi`, `espressif/esp-now` and `mbedtls` (beacon CMAC):
```cmake
PRIV_REQUIRES esp_wifi espressif__esp-now mbedtls
```

### 2. Configuration (Kconfig)

Run `idf.py menuconfig` and navigate to `ESP TDMA MAC Configuration`:
- `Maximum number of slave nodes` (Default: 15; in practice capped by the ESP-NOW encrypted-peer capacity, see the note above)
- `Beacon broadcast interval` (Default: 10 ms)
- `Tail margin reserved before the next beacon` (Default: 2000 µs)
- `Fallback time slot step per node` (Default: 700 µs, used only before any node is registered)
- `Minimum slot step / guard interval` (Default: 300 µs)
- `Beacon loss timeout before SILENT_ERROR` (Default: 500 ms)
- `PER sliding window size` (Default: 1000 packets)
- `PER threshold to trigger AFH` (Default: 5%)
- `Packets between two PER / jitter reports` (Default: 100)
- `Maximum user payload size per packet` (Default: 220 bytes)
- `Automatic Wi-Fi & ESP-NOW initialization` (Default: enabled)
- `Slave TX task core / priority / stack` (Default: core 0, priority 24, 4096 bytes)

> [!NOTE]
> **What the reported "jitter" means.** For every data packet the master computes
> `jitter = (rx_timestamp - last_beacon_tx_timestamp) - node_id * slot_step`, i.e. the deviation of the packet's **arrival** from its assigned slot. It is an end-to-end figure covering the beacon's airtime and driver latency, the node's receive-interrupt and scheduler latency, and the data packet's airtime. It is **not** the accuracy of the node's slot timer, so it must not be quoted as a clock-synchronisation precision number.

---

## Quick Example

### Master (Gateway) Setup

```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, "Received payload from node %d, seq=%lu, battery=%d%%, len=%d",
             node_id, (unsigned long)seq, battery, payload_len);
    // Cast payload back to your custom struct
    // my_sensor_data_t *data = (my_sensor_data_t *)payload;
}

static void on_node_registered(uint8_t node_id) {
    ESP_LOGI(TAG, "Node %d registered and admitted to the schedule", node_id);
}

void app_main(void) {
    // Wi-Fi (STA mode) and ESP-NOW are automatically initialized by the component on startup.
    // No manual Wi-Fi or ESP-NOW initialization boilerplate required.
    
    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));

    // Optional: sign every beacon so nodes can authenticate it.
    static const uint8_t beacon_cmac_key[16] = { /* shared key from secure storage */ };
    esp_tdma_master_set_beacon_key(beacon_cmac_key);

    ESP_ERROR_CHECK(esp_tdma_master_start());

    // Slot step for 4 nodes over a 10 ms frame with a 2000 us tail margin: 2000 us.
    ESP_LOGI(TAG, "slot step = %lu us", (unsigned long)esp_tdma_master_get_slot_step_us());

    // Change the node count at runtime; the new geometry applies from the next beacon.
    // esp_tdma_master_set_target_node_count(8);

    // Hand the radio to another stack (e.g. a managed OTA session) and take it back.
    // The delay is required: a node can still be mid-transmission when the flag is
    // set, and it needs up to three beacon periods to observe the change.
    // esp_tdma_master_set_tx_suspended(true);
    // vTaskDelay(pdMS_TO_TICKS(3 * CONFIG_TDMA_BEACON_INTERVAL_MS));
    // ... use the radio ...
    // esp_tdma_master_set_tx_suspended(false);
}
```

### Slave (Node) Setup

```c
#include "esp_tdma_mac.h"
#include "esp_log.h"

static const char *TAG = "node";

// The master's user_state byte is relayed here unchanged.
static void on_user_state_changed(uint8_t user_state) {
    ESP_LOGI(TAG, "Gateway user_state is now %d", user_state);
}

void app_main(void) {
    // Wi-Fi (STA mode) and ESP-NOW are automatically initialized by the component on startup.
    // No manual Wi-Fi or ESP-NOW initialization boilerplate required.
    
    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));
    
    // Config received via NFC or loaded from NVS:
    uint8_t node_id = 1;
    uint8_t gateway_mac[6] = {0x28, 0x84, 0x85, 0x52, 0xC4, 0x0C};
    uint8_t node_unicast_lmk[16] = {0}; // LMK for encrypted ESP-NOW
    uint8_t beacon_cmac_key[16] = {0};  // must match the master's beacon key

    // No slot offset to compute: the node derives it from the slot step carried in
    // every beacon, so nothing here goes stale when the gateway changes its frame.
    esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk, beacon_cmac_key);
    ESP_ERROR_CHECK(esp_tdma_slave_start());
    
    // In your sensor sampling task:
    while (1) {
        my_sensor_data_t data = read_sensor();
        esp_tdma_slave_enqueue(&data, sizeof(data));
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}
```

Pass `NULL` as the beacon CMAC key to accept unauthenticated beacons (development only).

---

## Runtime API

| Function | Purpose |
| --- | --- |
| `esp_tdma_master_set_target_node_count(n)` | Recompute the slot step for a new node count at runtime. |
| `esp_tdma_master_get_target_node_count()` | Current expected node count. |
| `esp_tdma_master_get_slot_step_us()` | Current slot step, as broadcast in the beacon. |
| `esp_tdma_master_set_user_state(b)` / `get_user_state()` | Application-owned byte relayed to every node. |
| `esp_tdma_master_set_tx_suspended(b)` / `is_tx_suspended()` | Network-wide TX gate; the master keeps beaconing. |
| `esp_tdma_master_set_beacon_key(key)` | Enable CMAC signing of beacons. |
| `esp_tdma_master_get_node_mac(id, out)` | Look up a registered node's MAC. |
| `esp_tdma_master_get_node_lmk(id, out)` | Reuse the stored unicast LMK when re-provisioning a node. |
| `esp_tdma_master_is_node_online(id)` | Heartbeat-based liveness (6 s timeout). |
| `esp_tdma_restore_recv_cb()` | Re-install the packet dispatcher after another stack overwrote the raw ESP-NOW callback. |
| `esp_tdma_slave_set_config(...)` | Provision node ID, gateway MAC, unicast LMK and beacon CMAC key. |
| `esp_tdma_slave_is_suspended()` | Whether the master currently has TX suspended. |

`esp_tdma_slave_start()` returns `ESP_ERR_INVALID_STATE` if no gateway peer was installed successfully, since a node without that peer cannot transmit at all.

---

## Compatibility

The on-air format changed in 1.2.0: the beacon grew to 27 bytes (`slot_step_us`,
`tx_suspended` and the opaque `user_state` replaced `sys_state`), the NFC provisioning
payload shrank to 46 bytes (the slot offset is now derived from the beacon) and the
REG_ACK shrank to 18 bytes. **A 1.2.0 master is not interoperable with a 1.1.x slave, and
vice versa — update the whole fleet together.**

The C API also changed in 1.2.0: `esp_tdma_slave_set_config_ex()` and the legacy
4-argument `esp_tdma_slave_set_config()` were merged into a single
`esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk, beacon_cmac_key)`, and
`on_node_registered` / `on_sys_state_changed` were replaced by `on_node_registered(node_id)`
and `on_user_state_changed`. Existing call sites need a one-line update.

---

## License

This project is licensed under the Apache License 2.0 - see the `LICENSE` file for details.

Links

Target

To add this component to your project, run:

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

download archive

Stats

  • Archive size
    Archive size ~ 63.05 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.2.0
|