Repository navigation
esp32: Add ESP-NOW - #2472
esp32: Add ESP-NOW#2472gabrielmancini wants to merge 2 commits into
Conversation
An espnow port driver, behind CONFIG_AVM_ENABLE_ESPNOW (off by default),
and the espnow module: frames of up to 250 bytes to a peer or broadcast,
received as {espnow, rx, Mac, Data, #{rssi := Rssi}}, with the send result
as {espnow, sent, Mac, ok | fail}.
The network driver becomes the one owner of the Wi-Fi radio. ESP-NOW holds
it (network_wifi_hold/1): with no network the radio comes up as a bare
STA, and network:stop/0 leaves it on while ESP-NOW is open, so ESP-NOW
survives the network going down and up again.
Events go through the VM listener, queued 8 deep; a frame that does not
fit is dropped and counted (stats/1).
The API follows harmon25/atomvm_espnow (Apache-2.0).
Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>
Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
|
The failing |
|
The second red job, |
|
LLM review, seems like there is v2 of esp-now which allows more data: Review: ESP-NOW supportScope and recommendationRequest changes. Reviewed the combined diff
The second commit only reformats the The requested oracle review was performed, and its principal findings were cross-checked against the runtime, scheduler, and local ESP-IDF 5.5.5 sources. This is a source review, not a hardware qualification: no firmware build, allocation-fault test, or two-device radio test was run. Proposed diffs below are review suggestions, not applied or tested fixes. Severity: P1 = high-impact correctness issue to fix before merging; P2 = correctness issue under a narrower configuration or failure condition. 1. P1 — Abnormal port termination leaves live references to freed stateLocation: The port stores Trigger: Open ESP-NOW, terminate the port with Required fix: Tie teardown to the port's lifetime, including kill/linked-owner termination, through a runtime-supported resource monitor or a native-context cleanup facility. Make cleanup run exactly once and coordinate it with in-flight callbacks. A global driver-destroy callback alone would not cover individual port death. This is not safely fixed by a one-line change in the close branch. Missing regression: Terminate an open port without 2. P1 — Radio ownership and singleton transitions race on SMPLocation: The new shared flags, holder callback, and Two concrete failing interleavings:
Concurrent opens can also pass both singleton checks before either publishes ownership. Required fix: Serialize complete radio ownership transitions and singleton acquisition/release, including their SDK operations. Atomics on individual flags or a local copy of the callback do not preserve the ownership invariant. Choose one lock/serialized owner and document its order relative to listener and callback synchronization. Missing regression: On a dual-core target, repeatedly overlap open/open, close/network-start, and close/network-stop. Assert only one open succeeds and that the remaining radio user can still transmit after each transition. A single-core-only test cannot cover this finding. 3. P1 — AP-only networking disables ESP-NOW's selected interfaceLocation: Every peer, including the automatically added broadcast peer, uses Trigger: Either startup order of ESP-NOW plus an AP-only network. ESP-NOW cannot reliably send through its configured interface, contrary to the documented coexistence contract. Required fix: Derive the Wi-Fi mode from both owners. AP-only network plus ESP-NOW needs APSTA, whether the holder or the network starts first. Keep the extra STA unassociated. Simply selecting APSTA is insufficient: Missing regression: For both startup orders, start an AP-only network and exchange actual broadcast and acknowledged unicast frames with a second device. Verify the AP remains usable and the extra STA does not attempt an unintended connection. Repeat with STA and APSTA as controls. 4. P2 — Failed network startup does not restore a held radioLocation: Starting a network stops the existing bare STA, but Trigger: Open ESP-NOW successfully, then inject a failure after network startup stops the bare STA. Even subsequent Suggested narrow recovery change, after fixing the ownership synchronization: --- a/src/platforms/esp32/components/avm_builtins/network_driver.c
+++ b/src/platforms/esp32/components/avm_builtins/network_driver.c
@@
// A holder (ESP-NOW) keeps the radio on, as a bare STA again.
if (wifi_holder_restarted != NULL) {
- if (network_had_wifi && wifi_start_bare_sta() == ESP_OK) {
+ if (!wifi_started && wifi_start_bare_sta() == ESP_OK) {
wifi_holder_restarted();
}
} else {This repairs the skipped restart when cleanup runs; it does not replace the need for a defined startup-error rollback path or error handling if the recovery restart itself fails. Missing regression: Fault-inject failure after the held radio is stopped but before 5. P2 — RX silently truncates valid ESP-NOW v2 datagramsLocation: ESP-IDF 5.5.5 defines a 1470-byte v2 maximum alongside the 250-byte v1 maximum. The receive callback clamps The public API intentionally supports only 250 bytes; reject unsupported sizes rather than silently changing datagram contents. Suggested small fix: --- a/src/platforms/esp32/components/avm_builtins/espnow_driver.c
+++ b/src/platforms/esp32/components/avm_builtins/espnow_driver.c
@@
static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *payload, int len)
{
+ if (len > ESP_NOW_MAX_DATA_LEN) {
+ // Use the synchronized drop-counter operation introduced for stats.
+ struct EspNowData *data = espnow_data;
+ if (data != NULL) {
+ data->dropped++;
+ }
+ return;
+ }
struct EspNowEvent event = { .kind = EspNowReceived };
memcpy(event.mac, info->src_addr, MAC_LEN);
event.rssi = info->rx_ctrl->rssi;
- event.len = len < ESP_NOW_MAX_DATA_LEN ? len : ESP_NOW_MAX_DATA_LEN;
+ event.len = len;The counter increment above illustrates the rejection path; it must use the synchronized counter and callback-lifetime scheme described below, rather than introduce another unsynchronized increment. Missing regression: Send distinct 249-, 250-, 251-, and 1470-byte frames from a v2-capable sender. Compare every accepted byte; require oversized frames to be absent from RX messages and counted as dropped. A send-side rejection test alone does not exercise this callback. 6. P2 — Allocation failures can crash open/RX or abandon calls foreverLocation: There are three separate exhaustion paths:
Suggested small fix for context allocation: --- a/src/platforms/esp32/components/avm_builtins/espnow_driver.c
+++ b/src/platforms/esp32/components/avm_builtins/espnow_driver.c
@@
Context *ctx = context_new(global);
+ if (IS_NULL_PTR(ctx)) {
+ teardown(data);
+ return NULL;
+ }
ctx->native_handler = espnow_consume_mailbox;
ctx->platform_data = data;This addresses only the NULL dereference and depends on making teardown safe. For RX, use checked binary construction before accessing the buffer, destroy the temporary heap on failure, and count the drop. For calls, use the runtime's established OOM/error or termination path instead of silently discarding a synchronous request. Missing regression: Inject context-allocation, refcounted-binary-allocation, and reply-heap failures separately. Assert failed open leaves no singleton/radio hold; failed RX is dropped without a crash; synchronous calls resolve or fail rather than hang. Additional issues and small suggestionsPending queue teardown violates the FreeRTOS queue-set contract
This is a confirmed API-precondition violation, but not independently proven to cause an immediate crash: AtomVM compares unmatched sender handles rather than dereferencing them. Queue-handle reuse and close/reopen under traffic need targeted testing. Treat this as part of the teardown fix, not as evidence that SDK callback unregistration necessarily waits for every in-flight callback. Dropped statistics are neither atomic nor valid across the full counter range
Use atomic updates/a synchronized snapshot and the existing boxed-integer construction API, reserving the additional heap words. Test concurrent increments and the values immediately below, at, and above the small-integer boundary. Do not create a new integer-conversion wrapper if an existing helper suffices. Describe the actual queue limit, not receiver-side backpressureThe eight-entry queue bounds Wi-Fi-task-to-VM backlog. Once the listener runs, it forwards events into the receiver's mailbox without checking whether that receiver consumes them. The receiver mailbox is not bounded by eight events. Documentation currently attributes drops to a slow receiver and can imply a stronger memory bound than implemented. --- a/libs/avm_esp32/src/espnow.erl
+++ b/libs/avm_esp32/src/espnow.erl
@@
-%% @doc `dropped': frames and send results lost because the receiver did not
-%% keep up (the driver queues 8).
+%% @doc `dropped': frames and send results lost by the driver, including
+%% callback-queue overflow. The driver queues 8 events before VM delivery;
+%% this does not bound the receiver's mailbox.Update the equivalent paragraph in Compile both callback signatures in CIThe only ESP-NOW-enabled CI configuration uses ESP32/IDF 5.5.5. The pre-5.5 send-callback branch is never compiled. Add a small enabled configuration on a pre-5.5 SDK and a single-core target, preserving existing disabled builds: --- a/.github/workflows/esp32-build.yaml
+++ b/.github/workflows/esp32-build.yaml
@@
include:
+ - esp-idf-target: "esp32c3"
+ idf-version: 'v5.2.8'
+ espnow: 'ON'
+ jit: false
- esp-idf-target: "esp32p4"Check the GitHub matrix expansion when applying this: the added entry augments the matching base combination; it need not create a duplicate build. Suggested missing public-API smoke testNo tests were added by these commits. The following is a small proposed ESP32-only test module, to register in the appropriate opt-in device harness once ESP-NOW is enabled. It checks outbound size validation and explicit close/reopen without relying on receipt of radio acknowledgments. It does not replace the lifecycle, SMP, AP coexistence, RX, and fault-injection scenarios above. --- /dev/null
+++ b/tests/libs/eavmlib/test_espnow.erl
@@
+-module(test_espnow).
+-export([test/0]).
+
+test() ->
+ {ok, Port} = espnow:open([{channel, 1}]),
+ try
+ {error, badarg} = espnow:send(Port, broadcast, <<>>),
+ {error, badarg} = espnow:send(Port, broadcast, binary:copy(<<16#A5>>, 251)),
+ {error, badarg} = espnow:set_channel(Port, 0),
+ ok = espnow:send(Port, broadcast, binary:copy(<<16#5A>>, 250)),
+ {ok, 1} = espnow:get_channel(Port)
+ after
+ ok = espnow:close(Port)
+ end,
+ {ok, Reopened} = espnow:open([{channel, 6}]),
+ try
+ {ok, 6} = espnow:get_channel(Reopened)
+ after
+ ok = espnow:close(Reopened)
+ end,
+ ok.This test sketch needs the standard source license header and device-harness integration before inclusion; it has not been compiled or executed. The asymmetric channel choices ensure reopening cannot pass by accidentally reporting the previous port's channel. Evidence and merge gate
|
bettio
left a comment
There was a problem hiding this comment.
I still have to do an API review. Some initial findings.
|
|
||
| // A holder (ESP-NOW) keeps the radio on, as a bare STA again. | ||
| if (wifi_holder_restarted != NULL) { | ||
| if (network_had_wifi && wifi_start_bare_sta() == ESP_OK) { |
There was a problem hiding this comment.
start_wifi stops the holder's bare STA at line 1979 before wifi_used_by_network is set at line 2065, so a failure in between (handler registration, set_mode, set_config, esp_wifi_start) leaves ESP-NOW open with the radio off. Change the condition to:
if (!wifi_started && wifi_start_bare_sta() == ESP_OK) {
| return ESP_OK; | ||
| } | ||
|
|
||
| esp_err_t network_wifi_hold(void (*restarted)(void)) |
There was a problem hiding this comment.
network:start/1 returns before the network port runs start_wifi (it is a handle_continue), so plain network:start(Config) followed by espnow:open(Opts) runs start_wifi and network_wifi_hold concurrently on dual-core targets, both calling esp_wifi_init/esp_wifi_start on unsynchronized flags. Please take one mutex (an smp_mutex created in network_driver_init is enough; it is a no-op on single-core builds, where the scheduler already serializes them) in network_wifi_hold, network_wifi_release, around the start_wifi call in start_network (line 2153) and around the radio section of stop_network (lines 2235-2257).
bettio
left a comment
There was a problem hiding this comment.
Few additional comments:
- Port death: see the inline comment at espnow_driver.c:327 for a fix that needs no runtime change.
- AP-only network: every peer is on WIFI_IF_STA while an
ap-only config runs WIFI_MODE_AP, so every send fails with ESP_ERR_ESPNOW_IF in both start orders. Either run APSTA while a holder exists and keep the STA unassociated (event_handler must then not call esp_wifi_connect on STA_START for an AP-only config), or refuse the combination with a clear error and document it. - RX frames above the limit (espnow_driver.c:133): drop and count them in
droppedinstead of truncating. - Check
context_newfor NULL (espnow_driver.c:325) andteardownon failure. - Drain the queue after unregistering the callbacks and before
xQueueRemoveFromSet(espnow_driver.c:241), and check its result: it fails on a non-empty queue. - Drops are not caused by a slow receiver: they happen when the 8-slot queue between the Wi-Fi task and the scheduler fills, and the receiver's mailbox is unbounded. Reword espnow.erl:121 and the guide at line 560.
- CI: only the 5.5.5 build compiles the driver, so the pre-5.5
espnow_send_cbis never built. Addespnow: 'ON'to a pre-5.5 matrix entry too, for example esp32c3 v5.2.8.
Two questions on the hardware tests, since the code relies on them: did espnow:set_channel/2 fail while the STA was connected (inline comment at espnow_driver.c:387), and did encrypted peers keep working across network:start/1 and network:stop/0?
|
|
||
| Context *ctx = context_new(global); | ||
| ctx->native_handler = espnow_consume_mailbox; | ||
| ctx->platform_data = data; |
There was a problem hiding this comment.
Building on petermm's first point: exit(Port, kill), or an exit signal from a process that linked the port, destroys the context without running this handler (scheduler.c:287-296), and context_destroy frees data (context.c:307) while espnow_data, the listener in the global list, the two registered callbacks and the radio hold still refer to it; line 259 then refuses any reopen until reboot. This can be fixed without a runtime change: leave ctx->platform_data NULL so a kill frees nothing the Wi-Fi task can reach, keep the state in espnow_data together with the port's process id, and in espnow_create_port treat an espnow_data whose port is gone (globalcontext_process_exists) as closed: teardown it and continue.
| open(Options) -> | ||
| Receiver = proplists:get_value(receiver, Options, self()), | ||
| try | ||
| open_port({spawn, "espnow"}, [{receiver, Receiver} | proplists:delete(receiver, Options)]) |
There was a problem hiding this comment.
open_port does not link in AtomVM and ports cannot monitor, so when the receiver crashes the port stays open, the radio stays held and open/1 keeps returning {error, badarg}, which breaks a supervised owner. Once a kill is safe (see espnow_driver.c:327), add link(Port) here so that the owner's crash takes the port down and the next open/1 can reopen.
| of | ||
| Port -> {ok, Port} | ||
| catch | ||
| error:Reason -> {error, Reason} |
There was a problem hiding this comment.
open_port raises badarg whenever espnow_create_port returns NULL, so already open, no radio, esp_now_init failure, a bad pmk and out of memory are indistinguishable. Do what the network driver does: create the port cheaply and run the radio hold and the ESP-NOW init in a first port:call that replies {error, already_open} or {error, Err}. At minimum validate the options here so that badarg means a bad argument.
| %% @doc Queues `Data' (1 to 250 bytes) for `Mac', a peer, or `broadcast'. The | ||
| %% result comes to the receiver as `{espnow, sent, Mac, ok | fail}'. | ||
| -spec send(port(), mac() | broadcast, binary()) -> ok | {error, term()}. | ||
| send(Port, Mac, Data) -> |
There was a problem hiding this comment.
send/3 returns before the frame leaves, and with several sends in flight to the same peer the {espnow, sent, Mac, _} results cannot be told apart; a broadcast also reports <<255,255,255,255,255,255>> rather than broadcast. I would rather have send/3 wait for the send callback and return ok | {error, fail}, with the driver keeping a FIFO of pending callers; that assumes callbacks arrive in send order, which the current message format already assumes. If the async path matters for throughput, return a ref from send/3 and include it in the result.
| if (channel <= 0) { | ||
| return port_create_error_tuple(ctx, BADARG_ATOM); | ||
| } | ||
| esp_err_t err = esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); |
There was a problem hiding this comment.
espnow.erl:111 and the guide say this fails while the STA is connected, and line 298 ignores the result at open on the same assumption, but esp_wifi_set_channel documents only not-initialized, not-started, bad-interface and bad-argument errors, and its notes just say not to call it while scanning or connecting. Did a hardware test show the failure? If it succeeds it moves a connected STA off the access point's channel, so unless it does fail, refuse it here while the STA is associated (esp_wifi_sta_get_ap_info() == ESP_OK) and return an error.
| static term do_del_peer(Context *ctx, term req) | ||
| { | ||
| uint8_t mac[MAC_LEN]; | ||
| if (!term_is_tuple(req) || term_get_tuple_arity(req) != 2 || !get_mac(term_get_tuple_element(req, 1), ctx->global, mac)) { |
There was a problem hiding this comment.
get_mac accepts the broadcast atom here as well, so espnow:del_peer(Port, broadcast) removes the peer added at line 319 and every later broadcast send fails with ESP_ERR_ESPNOW_NOT_FOUND. Reject the atom in del_peer, and in add_peer too, where it can only yield ESP_ERR_ESPNOW_EXIST.
| enum espnow_cmd cmd = term_is_atom(cmd_term) ? interop_atom_term_select_int(cmd_table, cmd_term, ctx->global) : EspNowInvalidCmd; | ||
|
|
||
| // The largest reply: {Ref, {ok, #{dropped => N}}}, or an error tuple. | ||
| if (UNLIKELY(memory_ensure_free(ctx, PORT_REPLY_SIZE + TUPLE_SIZE(2) + term_map_size_in_terms(1)) != MEMORY_GC_OK)) { |
There was a problem hiding this comment.
When this fails the request is dropped without a reply and the caller stays in port:call/2 forever, as its default timeout is infinity. Every reply here fits in a few words: build it with BEGIN_WITH_STACK_HEAP, port_heap_create_error_tuple or port_heap_create_ok_tuple and port_heap_create_reply, and send it with port_send_message as port_send_reply does, as the network driver already does for its replies; this failure path then disappears.
|
|
||
| static term error_tuple(Context *ctx, esp_err_t err) | ||
| { | ||
| return port_create_error_tuple(ctx, term_from_int(err)); |
There was a problem hiding this comment.
ESP_ERR_ESPNOW_NOT_FOUND, _EXIST, _FULL, _IF, _CHAN and _NO_MEM are returned as raw numbers, and esp_err_to_term does not cover them either. A small table to atoms such as not_found, peer_exists, peer_list_full and channel_mismatch would make {error, not_found} from send/3 matchable. The network driver returns numbers too, so I would accept either, but the atoms are the better API.
| int8_t rssi; | ||
| uint8_t ok; | ||
| uint8_t len; | ||
| uint8_t data[ESP_NOW_MAX_DATA_LEN]; |
There was a problem hiding this comment.
ESP-IDF versions that define ESP_NOW_MAX_DATA_LEN_V2 deliver frames of up to 1470 bytes to the receive callback. Make the limit a Kconfig option, v1 250 by default since 8 slots of 1470 bytes cost 12 KB, make len a uint16_t, and derive the send/3 check at line 374 and the 250 in the docs from the same constant.
| %% @doc Queues `Data' (1 to 250 bytes) for `Mac', a peer, or `broadcast'. The | ||
| %% result comes to the receiver as `{espnow, sent, Mac, ok | fail}'. | ||
| -spec send(port(), mac() | broadcast, binary()) -> ok | {error, term()}. | ||
| send(Port, Mac, Data) -> |
There was a problem hiding this comment.
Correction to the FIFO idea above: the ESP-NOW guide says that too short an interval between two sends "may lead to disorder of sending callback function" and recommends sending the next frame only after the previous callback has returned, and nothing in the callback identifies the send (before 5.5 only the MAC, in 5.5 a frame pointer valid only during the callback). So the driver should keep one frame in flight: queue the sends, call esp_now_send only when none is pending, and reply to that caller from its callback. The four-in-flight measurement in the description runs outside that recommendation, and its results can be attributed to the wrong frame.
|
This can be a follow-up commit / change, so an additional commit on this PR history. This supersedes my inline comment about the v2 frame size. Two parts: a different way to stage frames between the Wi-Fi task and the VM, which removes the fixed queue and the need for a slot-size option, and a teardown handshake that the driver needs regardless of which staging it uses. WhyOn a 32-bit target every binary of 32 bytes or more is a refc binary, so
One helper in libAtomVM(Let's implement this as a separated commit, to make review easier). The non-const branch of term term_from_refc_binary(struct RefcBinary *refc, Heap *heap, GlobalContext *glb)
{
term *boxed_value = memory_heap_alloc(heap, TERM_BOXED_REFC_BINARY_SIZE);
boxed_value[0] = ((TERM_BOXED_REFC_BINARY_SIZE - 1) << 6) | TERM_BOXED_REFC_BINARY;
boxed_value[1] = (term) refc->size;
boxed_value[2] = RefcNoFlags;
boxed_value[3] = (term) refc;
term ret = ((term) boxed_value) | TERM_PRIMARY_BOXED;
refc->ref_count = 1; // this heap's MSO list owns it from here
heap->root->mso_list = term_list_init_prepend(boxed_value + REFC_BINARY_CONS_OFFSET, ret, heap->root->mso_list);
synclist_append(&glb->refc_binaries, &refc->head);
return ret;
}Driver state#include <stdatomic.h>
#ifdef ESP_NOW_MAX_DATA_LEN_V2
#define ESPNOW_MAX_FRAME ESP_NOW_MAX_DATA_LEN_V2
#else
#define ESPNOW_MAX_FRAME ESP_NOW_MAX_DATA_LEN
#endif
#define ESPNOW_QUEUE_LEN 16
struct EspNowEvent
{
uint8_t kind;
uint8_t mac[MAC_LEN];
int8_t rssi;
uint8_t ok;
struct RefcBinary *frame; // EspNowReceived only; never listed while staged
};
struct EspNowData
{
QueueHandle_t queue;
EventListener listener;
GlobalContext *global;
int32_t receiver;
_Atomic uint32_t rx_bytes; // staged bytes, callback to delivery
_Atomic uint32_t dropped;
};
static _Atomic(struct EspNowData *) espnow_data; // stored before the callbacks are registered
static _Atomic uint32_t espnow_in_callback;
static _Atomic uint8_t espnow_channel; // read by espnow_wifi_restarted, which must not touch dataCallbacks, in the Wi-Fi taskThey take no VM lock: malloc, memcpy, atomics and a zero-timeout queue send. // NULL means the driver is gone: do nothing.
static struct EspNowData *callback_enter(void)
{
atomic_fetch_add(&espnow_in_callback, 1); // seq_cst, ordered before the load
struct EspNowData *data = atomic_load(&espnow_data);
if (data == NULL) {
atomic_fetch_sub(&espnow_in_callback, 1);
}
return data;
}
static void callback_leave(void)
{
atomic_fetch_sub(&espnow_in_callback, 1);
}
static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *payload, int len)
{
struct EspNowData *data = callback_enter();
if (data == NULL) {
return;
}
if (len <= 0 || len > ESPNOW_MAX_FRAME) {
atomic_fetch_add(&data->dropped, 1);
goto leave;
}
if (atomic_fetch_add(&data->rx_bytes, len) + len > CONFIG_AVM_ESPNOW_RX_BUDGET) {
goto drop;
}
struct RefcBinary *frame = refc_binary_create_refc(len);
if (UNLIKELY(frame == NULL)) {
goto drop;
}
memcpy(frame->data, payload, len);
struct EspNowEvent event = { .kind = EspNowReceived, .rssi = info->rx_ctrl->rssi, .frame = frame };
memcpy(event.mac, info->src_addr, MAC_LEN);
if (LIKELY(xQueueSend(data->queue, &event, 0) == pdTRUE)) {
goto leave;
}
free(frame); // never listed, count still 0
drop:
atomic_fetch_sub(&data->rx_bytes, len);
atomic_fetch_add(&data->dropped, 1);
leave:
callback_leave();
}
Listener, in the schedulerstatic EventListener *espnow_event_callback(GlobalContext *glb, EventListener *listener)
{
struct EspNowData *data = GET_LIST_ENTRY(listener, struct EspNowData, listener);
struct EspNowEvent event;
if (xQueueReceive(data->queue, &event, 0) != pdTRUE) {
return listener;
}
bool received = event.kind == EspNowReceived;
size_t staged = received ? event.frame->size : 0; // read now: the frame may be freed below
size_t size = term_binary_heap_size(MAC_LEN)
+ (received ? TUPLE_SIZE(5) + TERM_BOXED_REFC_BINARY_SIZE + term_map_size_in_terms(1) : TUPLE_SIZE(4));
Heap heap;
if (UNLIKELY(memory_init_heap(&heap, size) != MEMORY_GC_OK)) {
if (received) {
free(event.frame); // still unlisted
atomic_fetch_sub(&data->rx_bytes, staged);
}
atomic_fetch_add(&data->dropped, 1);
return listener;
}
term message;
if (received) {
term payload = term_from_refc_binary(event.frame, &heap, glb); // listed, count 1
// {espnow, rx, Mac, Payload, #{rssi => Rssi}} built as today, with payload in place of the copy
} else {
// {espnow, sent, Mac, ok | fail} as today
}
globalcontext_send_message(glb, data->receiver, message); // the copy takes the count to 2
memory_destroy_heap(&heap, glb); // the sweep takes it back to 1, the receiver's
if (received) {
atomic_fetch_sub(&data->rx_bytes, staged);
}
return listener;
}If the receiver no longer exists the send copies nothing and the sweep frees the frame, so a dead receiver leaks nothing. TeardownThis order is needed with the current staging too. On a dual-core target the Wi-Fi task can be inside a callback on the other core while static void teardown(struct EspNowData *data)
{
esp_now_unregister_recv_cb();
esp_now_unregister_send_cb();
esp_now_deinit();
atomic_store(&espnow_data, NULL); // no new callback touches data from here
while (atomic_load(&espnow_in_callback) != 0) { // one already running finishes first
vTaskDelay(1);
}
atomic_store(&espnow_channel, 0);
sys_unregister_listener(data->global, &data->listener); // returns only after a running dispatch
struct EspNowEvent event;
while (xQueueReceive(data->queue, &event, 0) == pdTRUE) {
if (event.kind == EspNowReceived) {
free(event.frame); // staged only, never listed
}
}
xQueueRemoveFromSet(data->queue, event_set); // succeeds: the queue is empty
vQueueDelete(data->queue);
network_wifi_release();
free(data);
}
|
Adds an
espnowport driver on ESP32, behindCONFIG_AVM_ENABLE_ESPNOW(off by default), and theespnowmodule inavm_esp32.{espnow, rx, Mac, Data, #{rssi := Rssi}}, and each send reports back as{espnow, sent, Mac, ok | fail}.open/1({channel, 1..14}),close/1,add_peer/2,3(with an optional LMK),del_peer/2,send/3(a MAC orbroadcast),set_channel/2,get_channel/1,stats/1.stats/1.The Wi-Fi radio gets one owner, the network driver. ESP-NOW holds it through
network_wifi_hold/1andnetwork_wifi_release/0(include/network_wifi.h), and does not callesp_wifi_initoresp_wifi_startitself.network:start/1andnetwork:stop/0can be called while ESP-NOW is open.network:stop/0leaves the radio on and back on the ESP-NOW channel, so ESP-NOW survives the network going down and up again.The API follows harmon25/atomvm_espnow (Apache-2.0) by @harmon25:
open,send,add_peer,del_peerand the{espnow, rx, Mac, Data}message. Its driver is what our first range tests ran on. This one is new code, because ESP-NOW here has to share the Wi-Fi radio with thenetworkdriver and survivenetwork:start/1andnetwork:stop/0. Thanks to @harmon25 for it.The ESP32 v5.5.5 PPP build in the CI matrix also enables ESP-NOW, so the two network options are built together. That build has 11% of the app partition free, and the default one 14%. The network programming guide gets a section.
Tested on hardware: ESP32 (T-PCIE, WROVER with the VM not using PSRAM) and ESP32 without PSRAM (T-Call). Side by side (−26 to −30 dBm, 250-byte frames):
The tests also covered:
network:start/1andnetwork:stop/0cycles with ESP-NOW open;No QEMU test: QEMU has no Wi-Fi radio.
These changes are made under both the "Apache 2.0" and the "GNU Lesser General Public License 2.1 or later" license terms (dual license).
SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later