fix: stabilize primary ESP-NOW WiFi coexistence

This commit is contained in:
mikecarper committed 2026-08-28 21:01:17 -07:00
1 parent fefd1a9577
commit 11e70a5b32
11 files changed
+77 -21

No files matched your search

+3 -1
View File
@@ -256,7 +256,9 @@ distinct from `bridge.channel`, which applies only to the separate ESP-NOW
bridge feature. WiFi power saving does not let ESP-NOW and infrastructure WiFi
operate on different channels. On an ESP32 Full build whose primary radio is
ESP-NOW, `wifi.powersave max` is unavailable because a station using maximum
modem sleep can miss ESP-NOW broadcasts; use `min` for coexistence.
modem sleep can miss ESP-NOW broadcasts; use `min` for coexistence. The primary
mesh transport holds the driver's RF wake reference continuously, so `min`
does not suspend its ESP-NOW receiver.
ESP-NOW compatibility also depends on the bytes and PHY used above the common
Espressif transport. The historical `*_repeater_bridge_espnow` firmware wraps
+6 -2
View File
@@ -272,7 +272,10 @@ Companion whose primary mesh radio is ESP-NOW, `max` is also unavailable: a
station using maximum modem sleep can miss ESP-NOW broadcasts, which the access
point does not buffer for it. A previously saved `max` value is capped to and
reported as `min`, and a new `max` selection is rejected. These
WiFi/BLE/primary-ESP-NOW builds therefore use `min`.
WiFi/BLE/primary-ESP-NOW builds therefore report `min`. The primary mesh radio
also holds the ESP-IDF WiFi wake reference continuously so unsolicited ESP-NOW
frames remain receivable; selecting `min` does not put that primary receiver to
sleep.
#### View or change the primary ESP-NOW/WiFi channel
@@ -299,7 +302,8 @@ this node, plus the configured router's 2.4 GHz radio, must use the same fixed
channel. WiFi power saving does not allow the transports to use different
channels. On an ESP32 Full build with primary ESP-NOW, `wifi.powersave max` is
unavailable because maximum modem sleep can miss ESP-NOW broadcasts; use `min`
for coexistence.
for coexistence. The firmware keeps the primary ESP-NOW receiver awake while
still using the `min` WiFi/Bluetooth coexistence policy.
`espnow.channel` is the channel of the primary ESP-NOW mesh transport. It is
separate from `bridge.channel`, which configures only an ESP-NOW bridge on a
+3 -1
View File
@@ -70,7 +70,9 @@ backward-compatible default is the distinct wrapped format.
WiFi power saving cannot put infrastructure WiFi and ESP-NOW on different
channels. On these two primary-ESP-NOW Full targets, `max` is unavailable
because maximum modem sleep can make the station miss ESP-NOW broadcasts; use
`min` for WiFi/BLE/ESP-NOW coexistence.
`min` for WiFi/BLE/ESP-NOW coexistence. The firmware also holds the ESP-IDF RF
wake reference for the primary mesh radio, so the ESP-NOW receiver remains
continuous even though the reported coexistence setting is `min`.
List the available targets:
+8
View File
@@ -42,5 +42,13 @@ inline uint8_t validEspNowChannelOrDefault(uint8_t stored,
return isValidEspNowChannel(stored) ? stored : fallback;
}
// Rewriting an ESP32's current WiFi channel is not a harmless no-op. The
// driver briefly reconfigures the shared RF path, which can interrupt SoftAP
// beacons and ESP-NOW receive when a caller repeats the write from a fast main
// loop. Only request a restore when the primary channel actually moved.
inline bool espNowChannelRestoreRequired(uint8_t current, uint8_t target) {
return current != target;
}
} // namespace wifi
} // namespace mesh
+1 -4
View File
@@ -334,10 +334,7 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr,
mesh::wifi::accessPointChannel())
|| esp_wifi_set_protocol(
WIFI_IF_AP,
mesh::wifi::kProtocolMask)
!= ESP_OK
|| mesh::wifi::applyAccessPointProtocolMask() != ESP_OK
|| esp_wifi_set_protocol(
WIFI_IF_STA,
mesh::wifi::kProtocolMask)
+13
View File
@@ -129,6 +129,19 @@ void ESPNOWRadio::init() {
return;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// Modem sleep is still required when BLE and infrastructure WiFi coexist,
// but unicast/broadcast ESP-NOW frames are not buffered by an access point.
// Hold the driver's documented wake reference for the lifetime of this
// primary mesh radio so receive stays continuous without disabling the
// WiFi/Bluetooth coexistence policy globally.
if (esp_wifi_force_wakeup_acquire() != ESP_OK) {
ESPNOW_DEBUG_PRINTLN("Error keeping primary ESP-NOW receive awake");
esp_now_deinit();
return;
}
#endif
esp_wifi_set_max_tx_power(80); // should be 20dBm
esp_now_register_send_cb(OnDataSent);
+8 -4
View File
@@ -774,8 +774,8 @@ bool WebConfigServer::startSetupMode(char reply[]) {
// LR bit set. The driver then reports a healthy SoftAP, but ordinary
// phones and laptops cannot discover its beacon. A WiFi companion must
// advertise using the interoperable 2.4 GHz protocol set.
const esp_err_t ap_protocol_result = esp_wifi_set_protocol(
WIFI_IF_AP, mesh::wifi::kProtocolMask);
const esp_err_t ap_protocol_result =
mesh::wifi::applyAccessPointProtocolMask();
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
WIFI_IF_STA, mesh::wifi::kProtocolMask);
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
@@ -812,7 +812,10 @@ bool WebConfigServer::startSetupMode(char reply[]) {
_initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0;
_setup_started_at = millis();
_last_activity = _setup_started_at;
WiFi.scanNetworks(true); // pre-populate the SSID picker
// A primary ESP-NOW radio cannot leave its selected channel while scanning.
// Ordinary WiFi targets retain the zero-channel all-band scan.
WiFi.scanNetworks(true, false, false, 300,
mesh::wifi::stationScanChannel());
sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str());
return true;
@@ -2329,7 +2332,8 @@ void WebConfigServer::handleScan(AsyncWebServerRequest* req) {
n = WIFI_SCAN_FAILED;
}
if (n == WIFI_SCAN_FAILED) {
WiFi.scanNetworks(true);
WiFi.scanNetworks(true, false, false, 300,
mesh::wifi::stationScanChannel());
req->send(200, "application/json", "{\"state\":\"scanning\"}");
return;
}
+17 -2
View File
@@ -40,6 +40,11 @@ static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B
| WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N;
#endif
// LR is private to ESP-NOW on the station interface. A setup SoftAP must keep
// an ordinary b/g/n protocol bitmap so phones and laptops can discover it.
static constexpr uint8_t kAccessPointProtocolMask = WIFI_PROTOCOL_11B
| WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
struct EspNowBootChannelState {
bool loaded;
@@ -98,6 +103,10 @@ inline esp_err_t applyProtocolMask(wifi_interface_t interface_id) {
return esp_wifi_set_protocol(interface_id, kProtocolMask);
}
inline esp_err_t applyAccessPointProtocolMask() {
return esp_wifi_set_protocol(WIFI_IF_AP, kAccessPointProtocolMask);
}
// An ESP-NOW radio and an associated station cannot occupy different channels.
// Full Companion therefore keeps both its setup AP and any infrastructure-WiFi
// association on the mesh channel. Passing zero on ordinary targets preserves
@@ -118,12 +127,18 @@ inline uint8_t stationScanChannel() {
inline esp_err_t restoreEspNowChannel() {
if (!kKeepEspNowRadioRunning) return ESP_OK;
const uint8_t target = activeEspNowChannel();
uint8_t current = 0;
wifi_second_chan_t secondary = WIFI_SECOND_CHAN_NONE;
if (esp_wifi_get_channel(&current, &secondary) == ESP_OK
&& !espNowChannelRestoreRequired(current, target)) {
return ESP_OK;
}
// Fail closed instead of silently moving to the build default. A failure can
// be transient (scan/connect in progress), and changing the cached boot
// channel here would split this node from peers which still use the selected
// channel. Callers retry through their normal reconnect/tick paths.
return esp_wifi_set_channel(
activeEspNowChannel(), WIFI_SECOND_CHAN_NONE);
return esp_wifi_set_channel(target, WIFI_SECOND_CHAN_NONE);
}
} // namespace wifi
+4 -7
View File
@@ -76,13 +76,10 @@ inline wl_status_t beginStation(const char* ssid, const char* password) {
// follow their existing disconnected/retry path.
inline bool enforceStationChannel() {
if (!kPrimaryEspNowRadio) return true;
if (WiFi.status() != WL_CONNECTED) {
// A disconnect and scan/association completion are asynchronous. Keep
// reasserting the boot channel so a first ESP_ERR_WIFI_STATE cannot leave
// the mesh parked on the router's former channel.
restoreEspNowChannel();
return true;
}
// beginStation(), disconnect handling, and setup-AP startup already restore
// the selected channel. Avoid polling the driver and attempting a channel
// write from every WebConfig and Companion loop while it is disconnected.
if (WiFi.status() != WL_CONNECTED) return true;
if (WiFi.channel() == activeEspNowChannel()) return true;
WiFi.disconnect(false, false);
restoreEspNowChannel();
@@ -755,6 +755,13 @@ TEST(WiFiChannelPolicy, FallsBackOnlyWhenTheStoredChannelIsInvalid) {
EXPECT_EQ(1, mesh::wifi::validEspNowChannelOrDefault(14, 1));
}
TEST(WiFiChannelPolicy, RestoresOnlyWhenTheRadioActuallyMoved) {
EXPECT_FALSE(mesh::wifi::espNowChannelRestoreRequired(1, 1));
EXPECT_FALSE(mesh::wifi::espNowChannelRestoreRequired(6, 6));
EXPECT_TRUE(mesh::wifi::espNowChannelRestoreRequired(1, 6));
EXPECT_TRUE(mesh::wifi::espNowChannelRestoreRequired(13, 1));
}
TEST(CLICommandUtils, ObserverSettingsNeverStartADisabledBridge) {
int calls = 0;
EXPECT_TRUE(mesh::cli::restartBridgeIfEnabled(false, [&calls]() {
+7
View File
@@ -4,6 +4,7 @@
#define P_LORA_HF_PA_POWER 3
#define ST7789_CS 16
#define ST7789_RS 15
#define USER_GPIO_RESERVED_PINS 40, 41
#include <Arduino.h>
#include <helpers/UserGpio.h>
@@ -52,6 +53,12 @@ TEST(UserGpioPinPolicyTest, ReservesRadioPaAndSt7789ControlAliases) {
EXPECT_FALSE(UserGpioPinPolicy::isFirmwareReserved(22));
}
TEST(UserGpioPinPolicyTest, ReservesBoardSpecificDirectIoPins) {
EXPECT_TRUE(UserGpioPinPolicy::isFirmwareReserved(40));
EXPECT_TRUE(UserGpioPinPolicy::isFirmwareReserved(41));
EXPECT_FALSE(UserGpioPinPolicy::isFirmwareReserved(39));
}
TEST_F(UserGpioTest, ListsOnlyBoardApprovedPins) {
UserGpio gpio(board);