diff --git a/docs/WiFi.md b/docs/WiFi.md index ae0db3d4..df3fa8ac 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e24eabc9..3b639cd9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index d0cf1e72..d48793b6 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -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: diff --git a/src/helpers/WiFiChannelPolicy.h b/src/helpers/WiFiChannelPolicy.h index a6bd4d8b..ab9c2173 100644 --- a/src/helpers/WiFiChannelPolicy.h +++ b/src/helpers/WiFiChannelPolicy.h @@ -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 diff --git a/src/helpers/WiFiSetupPortal.cpp b/src/helpers/WiFiSetupPortal.cpp index d0f947d8..5e2d30ce 100644 --- a/src/helpers/WiFiSetupPortal.cpp +++ b/src/helpers/WiFiSetupPortal.cpp @@ -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) diff --git a/src/helpers/esp32/ESPNOWRadio.cpp b/src/helpers/esp32/ESPNOWRadio.cpp index 8e459461..4911a931 100644 --- a/src/helpers/esp32/ESPNOWRadio.cpp +++ b/src/helpers/esp32/ESPNOWRadio.cpp @@ -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); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 20e7810f..bf3eb0b5 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -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; } diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index 1b314788..db13fe43 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -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(¤t, &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 diff --git a/src/helpers/esp32/WiFiStationPolicy.h b/src/helpers/esp32/WiFiStationPolicy.h index a161adb1..a712d204 100644 --- a/src/helpers/esp32/WiFiStationPolicy.h +++ b/src/helpers/esp32/WiFiStationPolicy.h @@ -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(); diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index b11fd5de..da9d4402 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -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]() { diff --git a/test/test_user_gpio/test_user_gpio.cpp b/test/test_user_gpio/test_user_gpio.cpp index f8135709..82537cc8 100644 --- a/test/test_user_gpio/test_user_gpio.cpp +++ b/test/test_user_gpio/test_user_gpio.cpp @@ -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 #include @@ -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);