# 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) and CMAC-authenticated beacons.
## Features
- **Time Division scheduled (TDMA)**: Master broadcasts a beacon every 10 ms (configurable). Slaves transmit in assigned slots, completely eliminating collisions. The node ceiling is the ESP-NOW encrypted-peer capacity, not a scheduling limit (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.
- **Provisioning stays out of the MAC layer**: the component never decides how a node learns its node ID, the gateway MAC, its unicast LMK and the beacon CMAC key, and it defines no provisioning format of its own. It receives those four values through `esp_tdma_slave_set_config()` (or `esp_tdma_master_register_node()` on the gateway side), so an application is free to use an NFC mailbox, NVS, a serial console or anything else. The slot geometry is deliberately not provisioned at all: 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.
- **Downlink payload channel**: `esp_tdma_master_send_downlink()` unicasts a payload to one node, so the gateway is not limited to that one state byte. It is sent from the tail margin of the frame (one packet per beacon period, 100 packets/s at the default interval), where it cannot collide with uplink traffic; it is encrypted whenever the node registered with a unicast LMK; and because it is unicast, ESP-NOW's link-layer ACK gives a real delivery figure (`esp_tdma_master_get_downlink_sent_ok()` / `_fail()`).
- **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: 7, matching the ESP-NOW encrypted-peer capacity; raise `CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM` before raising this, see the note above)
- `Beacon broadcast interval` (Default: 10 ms)
- `Tail margin reserved before the next beacon` (Default: 2000 µs)
- `Downlink guard after the last slot` (Default: 400 µs; the tail margin must exceed it, or downlink traffic is refused with an error at startup)
- `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%)
- `Mean RSSI floor for attributing loss to a weak link` (Default: -85 dBm; below it a high PER is diagnosed as a weak link and no hop is taken)
- `Beacons per clock-recovery window` (Default: 1000; the node publishes a gateway-clock offset and drift estimate once per window)
- `Downlink retransmissions per packet` (Default: 3; 0 disables. Retries cost one frame each, and a retransmission is delivered to the application only once)
- `Uplink retransmissions per packet` (Default: 3; 0 disables. A node holds an unacknowledged payload and resends it, with its original sequence number, before taking fresh data; delivery to the application stays exactly-once)
- `Packets between two PER / jitter reports` (Default: 100)
- `Maximum user payload size per packet` (Default: 220 bytes)
- `Maximum fragmented message size` (Default: 1024 bytes; the gateway allocates one reassembly buffer per node that actually uses it, so the worst case RAM is `MAX_NODES x this`)
- `Reassembly timeout for a partial message` (Default: 2000 ms; a backstop for a sender that goes silent mid-message)
- `Join window: offset before the next beacon` (Default: 300 µs; where a node with no assigned ID sends its join request. The gateway logs an error at startup if the tail margin cannot host both this window and the downlink)
- `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.
> [!NOTE]
> **What the recovered gateway clock means.** A node's `esp_tdma_slave_get_gateway_time_us()` is its local `esp_timer_get_time()` plus an offset estimated from the beacon timestamps, keeping the best sample of each window. That best sample still contains the smallest one-way latency observed (beacon airtime plus driver and scheduler delay), so the value runs behind the real gateway clock by at least that much. It is suitable for stamping samples and for reading the drift in ppm; it is **not** a synchronisation-accuracy figure. The estimator is deliberately not fed back into the slot timer, because slot timing derives from beacon arrival, which is self-consistent across nodes.
---
## 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, const void *payload, uint8_t payload_len) {
ESP_LOGI(TAG, "Received payload from node %d, seq=%lu, len=%d", node_id, (unsigned long)seq, payload_len);
// Per-packet application fields (a battery level, a sample counter, ...) travel
// inside the payload, so the MAC layer never has to know what they mean.
// my_sensor_data_t *data = (my_sensor_data_t *)payload;
(void)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_with_policy(&data, sizeof(data), ESP_TDMA_QUEUE_DISCARD_STALE);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
```
Pass `NULL` as the beacon CMAC key to accept unauthenticated beacons (development only).
> [!NOTE]
> **Payload or message?** `esp_tdma_slave_enqueue_with_policy()` queues one packet: at most
> `CONFIG_TDMA_PAYLOAD_SIZE` bytes, delivered to `on_data_received` as it stands.
> `esp_tdma_slave_enqueue_message()` queues a message of up to `CONFIG_TDMA_MSG_SIZE` bytes, which the
> component splits into fragments and the gateway reassembles before calling `on_message_received`.
> A message that fits in one packet takes the plain path, so small payloads are unaffected. Fragments
> cost three bytes of payload budget each, exposed as `ESP_TDMA_FRAG_BYTES`, and the uplink
> retransmission applies per fragment — which means one lost fragment costs one extra frame, not a
> whole message.
> [!WARNING]
> **What the shared beacon key does not prove.** The beacon's CMAC is computed with a key every
> provisioned node holds, so it authenticates *the network*, not *the gateway*: any node can produce
> a beacon that verifies. Two checks stand in the way — beacons must come from the configured
> gateway's address, and data frames must come from the address registered for the node ID they
> claim — and both are reported through `esp_tdma_slave_get_foreign_beacons()` and
> `esp_tdma_master_get_rejected_frames()`. Neither is a cryptographic guarantee. Genuinely per-node
> broadcast authentication needs either asymmetric signatures (too slow to verify once per 10 ms
> beacon on this class of hardware) or a TESLA-style hash chain announced over the per-node
> encrypted channel, which verifies one frame late and needs a re-anchoring scheme. Neither is
> implemented. Related, and easy to miss: a **derived** LMK (node joined with `node_id = 0`) is
> AES-128-CMAC of that node's MAC under the shared key, so every node can derive every other node's
> key and read its downlink traffic. Provision each node with its own LMK when they must not be able
> to read each other.
---
## 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_master_start()` / `_stop()` / `_deinit()` | Run, halt (keeping the configuration), or tear down completely and re-initialize. |
| `esp_tdma_slave_start()` / `_stop()` / `_deinit()` | Same lifecycle on the node side; deinit also stops the TX task and frees its ring buffer. |
| `esp_tdma_master_send_downlink(id, data, len)` | Queue a payload for unicast delivery to one node; sent from the frame's tail margin. |
| `esp_tdma_master_get_downlink_sent_ok()` / `_fail()` / `_dropped()` / `_pending()` / `_retransmissions()` / `_gave_up()` | Downlink delivery statistics. |
| `esp_tdma_master_get_duplicate_rx_count()` | Uplink retransmissions that were received again and suppressed. |
| `esp_tdma_slave_set_config(...)` | Provision node ID, gateway MAC, unicast LMK and beacon CMAC key. Pass `node_id = 0` and a NULL LMK to have the gateway assign an ID instead. |
| `esp_tdma_slave_get_node_id()` | The provisioned ID, or the assigned one (0 until it arrives). |
| `esp_tdma_master_release_node(id)` | Forget a node and free its ID and peer entry. |
| `esp_tdma_slave_enqueue_with_policy(data, len, policy)` | Queue a payload (FIFO or discard-stale). |
| `esp_tdma_slave_enqueue_message(data, len)` | Queue a message larger than one packet; the gateway reassembles it and calls `on_message_received`. |
| `esp_tdma_master_get_messages_completed()` / `_messages_dropped()` | Reassembled and abandoned fragmented messages. |
| `esp_tdma_master_get_join_requests()` / `_join_rejections()` | ID requests received, and how many were refused. |
| `esp_tdma_master_trigger_afh(ch)` | Force a channel hop. The way to override the RSSI floor when the application disagrees with the diagnosis. |
| `esp_tdma_master_get_rejected_frames()` | Frames dropped because their source did not match the node they claimed. |
| `esp_tdma_slave_get_queue_count()` | Payloads currently queued for transmission. |
| `esp_tdma_slave_get_slot_wakeups()` / `_slot_skips()` | Frames the node woke for, and frames it skipped as idle; the ratio is its radio duty cycle. |
| `esp_tdma_slave_get_join_attempts()` / `_join_rejections()` | ID requests this node sent, and answers that assigned none. |
| `esp_tdma_slave_get_foreign_beacons()` | Beacons rejected as not coming from this node's gateway. |
| `esp_tdma_slave_is_suspended()` | Whether the master currently has TX suspended. |
| `esp_tdma_slave_get_send_ok()` / `_delivered()` / `_retransmissions()` / `_tx_gave_up()` | Frames the driver accepted, payloads the gateway acknowledged, extra attempts, and payloads abandoned. |
| `esp_tdma_slave_get_downlink_rx_count()` / `_downlink_duplicates()` | Downlink packets this node accepted, and how many retransmissions it suppressed. |
| `esp_tdma_slave_get_clock_offset_us()` / `_gateway_time_us()` / `_clock_skew_ppm()` / `_clock_samples()` | Recovered gateway-clock offset, gateway time, drift estimate and sample count. |
| `esp_tdma_get_state()` / `esp_tdma_set_state(s)` | Read or force the single flat TDMA state. |
`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
1.6.0 adds the uplink acknowledgement field to the beacon, growing it from 27 to 29 bytes and
shifting `cmac_tag` from offset 23 to 25, and introduces three packet types: `DATA_FRAG`
(0x05), `JOIN_REQ` (0x06) and `JOIN_RESP` (0x07). Because every receiver verifies the beacon
CMAC over the bytes at those offsets, each side rejects the other's beacons outright. **A
1.6.0 device is not interoperable with a 1.5.0 device: update the whole fleet together.**
1.5.0 removes the per-packet `battery_pct` field, which shortens the uplink header from 8
bytes to 7 and therefore shifts `packet_seq` and `payload` inside every data packet. **A 1.5.0
device is not interoperable with a 1.2.0–1.4.0 device: update the whole fleet together.**
1.4.0 only added the downlink packet type and touched no existing layout, so it interoperated
with 1.2.0/1.3.0 in both directions. 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`) and the
REG_ACK shrank to 18 bytes. **A 1.2.0-or-later master is not interoperable with a 1.1.x slave,
and vice versa.**
The C API 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`.
The C API changed again in 1.3.0, when the leftover compatibility surface was removed:
| Removed in 1.3.0 | Replacement |
| --- | --- |
| `esp_tdma_link_state_t`, `on_link_state_changed` | `esp_tdma_state_t` (they were a redundant projection of it) |
| `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()` | none — the component no longer knows about OTA |
| `TDMA_PKT_LOG`, `tdma_log_pkt_t` | none — they were never sent or parsed |
| `tdma_payload_t`, `tdma_ringbuf_t`, `tdma_ringbuf_*()` | none — moved into a private header |
The master now enters `TDMA_STATE_RUNNING` from `esp_tdma_master_start()` instead of staying
wherever initialization left it, which is what makes the PER window (and therefore AFH)
engage without the application setting the state by hand.
---
## License
This project is licensed under the Apache License 2.0 - see the `LICENSE` file for details.
b00458bf06562f6a69e20cecbef44f229849442c
idf.py add-dependency "yangcong-bit/esp-now-tdma^1.6.0"