1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! ESP-NOW receive pool that bypasses esp-radio's heap-allocating dispatcher.
//!
//! ## Why
//!
//! `esp_radio::esp_now::rcv_cb` (the C-level dispatcher esp-radio registers
//! during `EspNow::new_internal`) does two heap operations per ESP-NOW vendor
//! action frame: `Box::from(slice)` for the payload (~250 B) and
//! `push_back` into a `VecDeque<ReceivedData>` that grows from 0 → 4 → 8 → 16
//! capacity (384 B / 768 B / 1536 B grow allocations) on demand.
//!
//! Our sync CSI logger CPU-spins UART for ~11 ms per line inside the WiFi
//! task. While that spin runs, no other code on the same core can run —
//! including `rcv_cb`. ESP-NOW vendor frames pile up at lower layers and
//! `rcv_cb` then fires for them in burst once the spin returns. That burst
//! does many `Box::from`s in rapid succession, fragmenting the heap. By the
//! time the VecDeque needs to grow, the allocator can no longer find a
//! contiguous chunk of the requested size → `handle_alloc_error` → panic.
//!
//! ## What this module does
//!
//! Re-registers a custom `rcv_cb` *over* esp-radio's via the C FFI
//! `esp_now_register_recv_cb`. Our callback copies the payload into one of
//! [`POOL_CAPACITY`] fixed-size BSS slots and pushes the slot to a lock-free
//! `MpMcQueue`. **No heap allocation, ever**, regardless of how many frames
//! arrive while the CSI callback is spinning UART.
//!
//! User code (`peripheral::esp_now`, `central::esp_now`) calls [`receive`]
//! and [`receive_async`] in place of `EspNow::receive` / `receive_async`.
//! The returned [`PoolFrame`] mirrors the small subset of `ReceivedData`'s
//! API the rest of the crate actually consumes (`info.src_address`,
//! `data()`).
//!
//! Once [`install`] is called, esp-radio's `EspNow::receive*` methods
//! return `None` — they read a queue that's no longer being written to.
//! That's intentional: any code path that still calls them is broken and
//! should switch to this module.
use poll_fn;
use Poll;
use AtomicWaker;
use Q16;
/// ESP-NOW maximum data payload (matches `esp_radio::esp_now::ESP_NOW_MAX_DATA_LEN`).
const ESP_NOW_MAX_DATA_LEN: usize = 250;
/// Fixed pool capacity. Mirrors esp-radio's `RECEIVE_QUEUE_SIZE` so behavior
/// matches drop-front-on-full. Heapless `Q16` provides 16-slot MPMC.
pub const POOL_CAPACITY: usize = 16;
/// Subset of `esp_radio::esp_now::ReceiveInfo` that the rest of the crate
/// actually reads. Keeping this minimal avoids copying the ~80 B
/// `RxControlInfo` per frame in the C callback.
/// Drop-in replacement for `ReceivedData` carrying only the fields used by
/// `peripheral::esp_now` and `central::esp_now`.
/// FFI mirror of `esp_now_recv_info_t` (matches the C-side layout). We only
/// dereference `src_addr` — `des_addr` and `rx_ctrl` are unused so they can
/// stay opaque.
unsafe extern "C"
/// Lock-free 16-slot MPMC queue holding pre-formatted frames. `Q16` stores
/// `PoolFrame`s by value, so enqueue/dequeue copy ~256 B — negligible vs.
/// the UART time the original Box-allocating path costs.
static QUEUE: = Q16new;
/// Single waker for `receive_async` consumers. We only have one consumer in
/// the codebase (the responder/handler task), so a single-slot waker is fine.
static WAKER: AtomicWaker = new;
/// Custom receive callback installed via `esp_now_register_recv_cb`. Runs on
/// the WiFi task in C-FFI context; must do no heap operations and finish
/// quickly so the WiFi RX path keeps moving.
unsafe extern "C"
/// Install the static-pool `rcv_cb`, replacing esp-radio's heap-allocating
/// dispatcher. Must be called *after* `wifi::new` has constructed the
/// `EspNow` (which performs the initial registration) and *before* any
/// real ESP-NOW traffic begins. Idempotent: subsequent calls just re-bind
/// to the same callback.
/// Non-blocking receive. Returns `Some(frame)` if a frame is queued,
/// `None` otherwise. Drop-in replacement for `EspNow::receive`.
/// Async receive. Resolves when the next frame arrives. Drop-in replacement
/// for `EspNow::receive_async`.
pub async