Skip to content

esp32: Add ESP-NOW - #2472

Open
gabrielmancini wants to merge 2 commits into
atomvm:release-0.7from
iobee-io:esp32-espnow
Open

gabrielmancini wants to merge 2 commits into
atomvm:release-0.7from
iobee-io:esp32-espnow

Conversation

@gabrielmancini

@gabrielmancini gabrielmancini commented Nov 9, 2026 •

Copy link
Copy Markdown

Adds an espnow port driver on ESP32, behind CONFIG_AVM_ENABLE_ESPNOW (off by default), and the espnow module in avm_esp32.

  • Frames of up to 250 bytes go to a peer or to broadcast. They arrive as {espnow, rx, Mac, Data, #{rssi := Rssi}}, and each send reports back as {espnow, sent, Mac, ok | fail}.
  • Calls: open/1 ({channel, 1..14}), close/1, add_peer/2,3 (with an optional LMK), del_peer/2, send/3 (a MAC or broadcast), set_channel/2, get_channel/1, stats/1.
  • Events go through the VM listener, queued 8 deep (~2 KB). A frame that does not fit is dropped and counted in stats/1.

The Wi-Fi radio gets one owner, the network driver. ESP-NOW holds it through network_wifi_hold/1 and network_wifi_release/0 (include/network_wifi.h), and does not call esp_wifi_init or esp_wifi_start itself.

  • With no network started, the radio comes up as a bare STA that does not connect, on the ESP-NOW channel.
  • network:start/1 and network:stop/0 can be called while ESP-NOW is open. network:stop/0 leaves the radio on and back on the ESP-NOW channel, so ESP-NOW survives the network going down and up again.
  • While the STA is connected, frames go on the access point's channel.

The API follows harmon25/atomvm_espnow (Apache-2.0) by @harmon25: open, send, add_peer, del_peer and 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 the network driver and survive network:start/1 and network: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):

mode throughput frames/s lost
unicast, 1 in flight ~23.6 KB/s ~95 0
unicast, 4 in flight ~31.7 KB/s ~127 0
broadcast ~24.9 KB/s ~100 0

The tests also covered:

  • network:start/1 and network:stop/0 cycles with ESP-NOW open;
  • a device using ESP-NOW next to a full application (TLS MQTT, Modbus), which cost about 4 KB of free heap;
  • this driver in a small fleet since 2026-11-04.

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

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>
@github-advanced-security

Copy link
Copy Markdown

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:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@gabrielmancini

Copy link
Copy Markdown
Author

The failing esp-idf (esp32, v5.5.5, true) job looks like a QEMU flake, not this change: in the same job the "with memory checks" build ran all 26 tests and passed, and the second run hung in test_uart_mode with QEMU looping on Unhandled interrupt 5 on cpu 1 until the 180 s timeout. The same test passes on #2471 with the same configuration. Could a maintainer re-run the failed job? I don't have the rights to. Thanks!

@gabrielmancini

Copy link
Copy Markdown
Author

The second red job, build-and-test (riscv64-linux-gnu-gcc, 28, ...), is not this change either. test_gen_tcp:test_connect_parameters connects to www.atomvm.org from the runner and timed out ({connect,110}, ETIMEDOUT). This PR only touches the ESP32 platform, libs/avm_esp32, docs and CI. A re-run of both failed jobs should be enough.

@petermm

petermm commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

LLM review, seems like there is v2 of esp-now which allows more data:

Review: ESP-NOW support

Scope and recommendation

Request changes. Reviewed the combined diff HEAD~2..HEAD, including:

  • dc78a8eba2afc34f51732aa8d8e2cbe9cd826554 — esp32: Add ESP-NOW
  • 964b72a4cf2bf6941cb98d2cbd8d11cdc2375482 — espnow: Format with erlfmt

The second commit only reformats the try expression in espnow.erl; no behavioral issue was found in that commit. The feature commit introduces the findings below.

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 state

Location: src/platforms/esp32/components/avm_builtins/espnow_driver.c:234–244,325–327,462–467.

The port stores EspNowData directly in ctx->platform_data, but calls teardown() only for the explicit close command. When a native process is killed, scheduler_run() destroys its context without calling its native handler (src/libAtomVM/scheduler.c:288–299). context_destroy() frees platform_data without unregistering the driver's listener or Wi-Fi callbacks (src/libAtomVM/context.c:301–307).

Trigger: Open ESP-NOW, terminate the port with exit(Port, kill), then deliver an RX/TX callback or traverse the system listeners. The singleton and listener still point into freed memory. The queue, ESP-NOW initialization, and radio hold also remain live; reopening is blocked by the stale singleton.

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 espnow:close/1, while a second device sends frames. Wait for termination, reopen, and verify both unicast and broadcast work. Repeat explicit close under the same load as a control. Run with memory-error detection where practical.

2. P1 — Radio ownership and singleton transitions race on SMP

Location: network_driver.c:1874–1945,1979–1982,2064–2065,2235–2253; espnow_driver.c:257–301, under src/platforms/esp32/components/avm_builtins/.

The new shared flags, holder callback, and espnow_data singleton have no lock. The comment that everything runs in the scheduler does not serialize different native contexts: the scheduler releases schedulers_mutex before executing native handlers. ESP32's CMake configuration enables SMP by default on multicore targets.

Two concrete failing interleavings:

  1. stop_network() observes a holder and begins restarting the bare STA. Concurrent ESP-NOW close calls network_wifi_release(), clears the holder callback, and can stop the radio. stop_network() then calls wifi_holder_restarted() without rechecking it: a NULL function call is possible.
  2. start_wifi() starts Wi-Fi but has not yet set wifi_used_by_network. Concurrent close sees no network owner and deinitializes Wi-Fi. Startup subsequently marks the now-off radio as started and returns success.

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 interface

Location: espnow_driver.c:247–254; network_driver.c:1926–1928,2007–2016.

Every peer, including the automatically added broadcast peer, uses WIFI_IF_STA. AP-only network startup selects WIFI_MODE_AP, disabling STA. Conversely, opening ESP-NOW after AP-only startup just acquires the already-started radio without enabling STA. ESP-IDF requires the peer's selected interface to be enabled; the send API documents ESP_ERR_ESPNOW_IF for an interface mismatch.

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: event_handler() currently invokes esp_wifi_connect() on WIFI_EVENT_STA_START whenever !data->managed, even for an AP-only network.

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 radio

Location: network_driver.c:1977–1988,2000–2065,2235–2253.

Starting a network stops the existing bare STA, but wifi_used_by_network is set only after startup succeeds. If registration, configuration, or esp_wifi_start() then fails, cleanup sees network_had_wifi == false and does not restart the holder's STA. ESP-NOW remains open while the radio it holds is stopped.

Trigger: Open ESP-NOW successfully, then inject a failure after network startup stops the bare STA. Even subsequent stop_network() does not restore it under the current condition.

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 wifi_used_by_network is set. After cleanup, verify the original ESP-NOW channel and an acknowledged frame exchange, not merely that the port still answers calls.

5. P2 — RX silently truncates valid ESP-NOW v2 datagrams

Location: espnow_driver.c:128–135.

ESP-IDF 5.5.5 defines a 1470-byte v2 maximum alongside the 250-byte v1 maximum. The receive callback clamps len to 250 and delivers the prefix as an ordinary complete frame, with neither an error nor a dropped-event increment. A 251-byte datagram and a real 250-byte datagram with that prefix become indistinguishable.

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 forever

Location: espnow_driver.c:195,325–327,436–440.

There are three separate exhaustion paths:

  • context_new() can return NULL, but open immediately dereferences it after registering listeners and initializing ESP-NOW.
  • A successful event-heap allocation does not reserve the separately allocated refcounted binary buffer. term_from_literal_binary() calls term_binary_data() without checking whether binary allocation failed (src/libAtomVM/term.h:2114–2118). Larger RX payloads can therefore crash instead of being dropped.
  • Reply-space allocation failure removes the request without replying or executing it. The Erlang wrappers use port:call/2, whose default timeout is infinite. A caller can hang permanently; a close request is discarded too.

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 suggestions

Pending queue teardown violates the FreeRTOS queue-set contract

espnow_driver.c:240–242 ignores xQueueRemoveFromSet() failure and deletes the queue anyway. ESP-IDF's FreeRTOS-Kernel/queue.c:3287–3313 explicitly refuses removal of a nonempty queue because pending queue-set notifications remain. Quiesce callbacks and coordinate draining both the member and its set notifications before deletion. Merely resetting the member queue is not sufficient.

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

dropped is a volatile uint32_t, updated from both the Wi-Fi task and event-consumer task (espnow_driver.c:123,181); volatile does not make read-modify-write increments atomic. Lost increments are possible. do_stats() also encodes it with term_from_int(), which only encodes small integers. On 32-bit ESP32, 134,217,728 is outside the positive small-integer range and is reported incorrectly.

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 backpressure

The 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 doc/src/network-programming-guide.md too. Receiver-side backpressure would be an API/design change, not a necessary implementation addition for this review.

Compile both callback signatures in CI

The 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 test

No 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

  • Inspected both commits, surrounding runtime/native-process destruction, scheduler locking, listener dispatch, and ESP-IDF 5.5.5 ESP-NOW and FreeRTOS contracts.
  • git diff HEAD~2 HEAD --check passed.
  • No source changes were applied. No firmware or hardware test results are claimed.
  • Fix the three P1 findings before merge, including lifetime-safe callback/queue teardown. Address failed-start rollback, datagram integrity, and allocation failures, and provide device-level regression evidence for the documented coexistence promise.
  • Still unverified: callback quiescence guarantees of SDK unregister/deinit; encrypted-peer continuity across Wi-Fi stop/start; and whether SDK channel-setting behavior enforces every connected-STA restriction promised by the Erlang docs. These are validation requirements, not additional confirmed defects.

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dropped instead of truncating.
  • Check context_new for NULL (espnow_driver.c:325) and teardown on 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_cb is never built. Add espnow: '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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bettio

bettio commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Why

On a 32-bit target every binary of 32 bytes or more is a refc binary, so espnow_event_callback already mallocs and copies almost every frame a second time through term_from_literal_binary. The fixed slot avoids no allocation; it only moves it out of the Wi-Fi task, at the price of permanent RAM:

staging idle RAM per 1470-byte frame pending copies per frame
8 fixed slots, as in the PR 2,164 at v1, 11,844 at v2 0 staged, then 1,490 in the VM 2
refc binary filled in the Wi-Fi task about 340 1,490 1

refc_binary_create_refc is a plain malloc that touches no VM state, so the callback can allocate the binary the receiver will eventually see, copy the payload once, and queue a 16-byte item. The listener wraps it into a term, sends the message, and destroys its temporary heap; the receiver's mailbox copy then owns the binary, and it is freed when the receiver's GC drops the message. An atomic bytes-in-flight budget bounds what the Wi-Fi task may stage, so the "no more memory in a burst" property stays, as a configurable number rather than a slot count. The maximum frame length follows the ESP-IDF constant and nothing else needs an option. ESP-IDF's own driver allocates its dynamic RX buffers in that task, so the malloc is in good company.

One helper in libAtomVM

(Let's implement this as a separated commit, to make review easier).

The non-const branch of term_alloc_refc_binary without the allocation, next to it in term.c:

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 data

Callbacks, in the Wi-Fi task

They 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();
}

espnow_send_cb goes through the same callback_enter/callback_leave pair and queues an item without a frame, as it does today.

Listener, in the scheduler

static 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.

Teardown

This 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 teardown runs, and whether esp_now_unregister_recv_cb or esp_now_deinit waits for it is undocumented. The handshake makes it explicit; the wait is bounded by one callback, since callbacks never block, and on a single core it never triggers because the heap lock is a critical section, so the Wi-Fi task cannot be preempted mid-callback by the scheduler.

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);
}

espnow_wifi_restarted reads espnow_channel and dereferences nothing: today it reads data->channel from the network port's context and is safe only because network_wifi_release precedes free(data) under the mutex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants