Fix ESP32 Full idle radio teardown and compact AP startup

This commit is contained in:
mikecarper committed 2026-10-06 11:46:10 -07:00
1 parent f1ffe53e8a
commit d0859f925d
29 files changed
+1724 -144

No files matched your search

+50 -21
View File
@@ -773,7 +773,14 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
// contain its appended byte, so they safely inherit the enabled default.
_prefs->system_watchdog_enabled = 1;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
#if defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE)
// Full carries diagnostics as an optional runtime service. Merely adding
// that capability must not hold an otherwise idle battery node awake.
// A saved logging preference below still takes precedence.
_prefs->usb_logging_enabled = 0;
#else
_prefs->usb_logging_enabled = 1;
#endif
_prefs->usb_debug_enabled = 0;
_prefs->trace_when_repeat_off = 0;
_prefs->ota_channel = 0;
@@ -2941,6 +2948,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "ERR: usage start ota [ap]");
return;
}
#if defined(WITH_ESPNOW_BRIDGE)
const bool ota_was_running = _board->isOTAUpdateRunning();
// Keep an outstanding pause if an earlier resume failed. Only a
// successful resume or an explicitly disabled saved intent clears it.
#endif
bool webconfig_stopped = false;
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (_callbacks->isWebConfigActive()) {
@@ -2951,7 +2963,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
webconfig_stopped = true;
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
#if defined(WITH_ESPNOW_BRIDGE)
// Browser OTA owns the shared WiFi driver. An independent UART bridge
// can keep running throughout the upload.
const bool espnow_paused = _callbacks->isEspNowBridgeRunning();
@@ -2959,21 +2971,28 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "ERR: could not pause ESP-NOW for OTA");
return;
}
_wifi_ota_resume_espnow = _wifi_ota_resume_espnow || espnow_paused;
#endif
reply[0] = 0;
if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) {
if (!reply[0]) strcpy(reply, "Error");
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
if (espnow_paused && !_callbacks->setEspNowBridgeState(true)) {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; ESP-NOW resume failed");
#if defined(WITH_ESPNOW_BRIDGE)
if (espnow_paused && !ota_was_running) {
if (_callbacks->setEspNowBridgeState(true)) {
_wifi_ota_resume_espnow = false;
} else {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; ESP-NOW resume failed");
}
}
#endif
}
#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA)
else {
// Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs.
_callbacks->setBridgeState(false);
const bool mqtt_paused = _callbacks->isMqttBridgeRunning();
_wifi_ota_resume_mqtt = _wifi_ota_resume_mqtt || mqtt_paused;
if (mqtt_paused) _callbacks->setMqttBridgeState(false);
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
@@ -2987,30 +3006,40 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
snprintf(reply + used, 160 - used, "; WebConfig stopped");
}
} else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) {
if (!_board->stopOTAUpdate(reply)) {
const bool ota_stopped = _board->stopOTAUpdate(reply);
if (!ota_stopped) {
if (!reply[0]) strcpy(reply, "Error");
}
#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA)
else if (_prefs->bridge_enabled
#ifdef WITH_ESPNOW_BRIDGE
|| _prefs->espnow_bridge_enabled
#endif
) {
_callbacks->setBridgeState(true);
else {
if (_wifi_ota_resume_mqtt) {
if (!_prefs->bridge_enabled || _callbacks->setMqttBridgeState(true)) {
_wifi_ota_resume_mqtt = false;
} else {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; MQTT resume failed");
}
}
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
else if (
#if defined(WITH_RS232_BRIDGE) || defined(ESPNOW_BRIDGE_MERGED)
#if defined(WITH_ESPNOW_BRIDGE)
if (ota_stopped && !_board->isOTAUpdateRunning() && _wifi_ota_resume_espnow) {
const bool still_enabled =
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(ESPNOW_BRIDGE_MERGED)
_prefs->espnow_bridge_enabled
#else
_prefs->bridge_enabled
#endif
) {
const bool resumed = _callbacks->setEspNowBridgeState(true);
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, resumed
? "; ESP-NOW resumed" : "; ESP-NOW resume failed");
!= 0;
if (!still_enabled) {
_wifi_ota_resume_espnow = false;
} else {
const bool resumed = _callbacks->setEspNowBridgeState(true);
if (resumed) _wifi_ota_resume_espnow = false;
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, resumed
? "; ESP-NOW resumed" : "; ESP-NOW resume failed");
}
}
#endif
} else if (memcmp(command, "clock", 5) == 0) {
+7
View File
@@ -793,6 +793,13 @@ class CommonCLI {
NodePrefs* _prefs;
CommonCLICallbacks* _callbacks;
mesh::MainBoard* _board;
#if defined(WITH_ESPNOW_BRIDGE) \
|| (defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA))
// Browser OTA restores only transports it actually paused, not saved
// defaults suspended by an unconfigured first-boot setup session.
bool _wifi_ota_resume_mqtt = false;
bool _wifi_ota_resume_espnow = false;
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
UserGpio _user_gpio;
#endif
+34 -26
View File
@@ -60,6 +60,7 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const {
(defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL))
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiAccessPointPolicy.h>
#include <helpers/esp32/StaticHtml.h>
#include <Update.h>
#include <esp_ota_ops.h>
@@ -298,22 +299,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
if (!use_ap) {
ip = WiFi.localIP();
} else {
if (!ota_started_ap || !(WiFi.getMode() & WIFI_AP)
|| static_cast<uint32_t>(WiFi.softAPIP()) == 0) {
const IPAddress ap_ip(192, 168, 4, 1);
const IPAddress ap_mask(255, 255, 255, 0);
ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask)
&& WiFi.softAP("MeshCore-OTA", nullptr,
mesh::wifi::accessPointChannel());
}
// A valid IP does not imply a discoverable hotspot. An LR protocol bit
// left by ESP-NOW makes AP beacons incompatible with ordinary clients.
if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) {
WiFi.softAPdisconnect(true);
ota_started_ap = false;
}
ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF;
ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap);
if (!ota_started_ap) {
inhibit_sleep = ota_server != nullptr;
if (!inhibit_sleep) {
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
}
strcpy(reply, "ERR: OTA WiFi failed");
return false;
}
@@ -324,6 +317,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
if (!lightweight_ota_server.begin(this)) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "ERR: OTA server failed");
return false;
@@ -344,6 +339,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
bool ESP32Board::stopOTAUpdate(char reply[]) {
if (ota_server == nullptr) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "OK - OTA not running");
return true;
}
@@ -355,6 +355,8 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
ota_server = nullptr;
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "OK - OTA stopped");
MESH_DEBUG_PRINTLN("stopOTAUpdate: %s", reply);
@@ -364,6 +366,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiAccessPointPolicy.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include "../../arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h"
@@ -394,20 +397,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
if (!use_ap) {
ip = WiFi.localIP();
} else {
if (!ota_started_ap || !(WiFi.getMode() & WIFI_AP)
|| static_cast<uint32_t>(WiFi.softAPIP()) == 0) {
const IPAddress ap_ip(192, 168, 4, 1);
const IPAddress ap_mask(255, 255, 255, 0);
ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask)
&& WiFi.softAP("MeshCore-OTA", NULL,
mesh::wifi::accessPointChannel());
}
if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) {
WiFi.softAPdisconnect(true);
ota_started_ap = false;
}
ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF;
ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap);
if (!ota_started_ap) {
inhibit_sleep = ota_server != nullptr;
if (!inhibit_sleep) {
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
}
strcpy(reply, "ERR: OTA WiFi failed");
return false;
}
@@ -439,6 +436,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
if (async_ota_host == nullptr) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "ERR: OTA server allocation failed");
return false;
@@ -462,6 +461,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
ota_server = nullptr;
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "ERR: OTA server failed; retry start ota");
return false;
@@ -472,6 +473,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
bool ESP32Board::stopOTAUpdate(char reply[]) {
if (ota_server == nullptr) {
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "OK - OTA not running");
return true;
}
@@ -484,6 +490,8 @@ bool ESP32Board::stopOTAUpdate(char reply[]) {
ota_server = nullptr;
if (ota_started_ap) WiFi.softAPdisconnect(true);
ota_started_ap = false;
mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio);
ota_started_radio = false;
inhibit_sleep = false;
strcpy(reply, "OK - OTA stopped");
+15 -9
View File
@@ -40,6 +40,7 @@ protected:
uint8_t startup_reason;
bool inhibit_sleep = false;
bool ota_started_ap = false;
bool ota_started_radio = false;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
mesh::UsbHostSleepPolicy usb_host_sleep_policy;
#endif
@@ -117,8 +118,8 @@ public:
// after a Pi closes its serial port). Guard every caller here.
// Sample even when another blocker is active, so an open console/OTA does
// not leave the last positive native USB host signal stale.
const bool usb_host_connected = isUsbHostConnected();
bool keep_awake = inhibit_sleep || usb_host_connected || isRadioTestActive();
const bool usb_sleep_held = isUsbSleepHeld();
bool keep_awake = inhibit_sleep || usb_sleep_held || isRadioTestActive();
#if !defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT
// External USB-to-UART chips cannot report host attachment here. Light
// sleep loses their first input bytes; UART wake loses the wake character
@@ -126,13 +127,6 @@ public:
// Keep an enabled console available even before its first command.
keep_awake = keep_awake || static_cast<bool>(Serial);
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
// HWCDC loses its SOF host signal after about 5 ms. A warm Pi reboot,
// especially on a USB 1.1 bus, must not put us in a 30-second light sleep
// while the host is returning and trying to enumerate this same radio.
keep_awake = keep_awake || usb_host_sleep_policy.shouldKeepAwake(
millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS);
#endif
#if MESH_USB_LOGGING_AVAILABLE
// A live logging stream must also remain available before a host opens
// it and across host disconnects. Compiled-out logging is not a blocker.
@@ -295,6 +289,18 @@ public:
inhibit_sleep = inhibit;
}
bool isUsbSleepHeld() {
// Share the native host-loss grace with short Companion sleep slices.
// Sample even when another inhibitor is active, preserving reconnects.
const bool host_connected = isUsbHostConnected();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
return host_connected || usb_host_sleep_policy.shouldKeepAwake(
millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS);
#else
return host_connected;
#endif
}
uint32_t getResetReason() const override {
return esp_reset_reason();
}
+3 -1
View File
@@ -72,10 +72,12 @@
namespace mesh {
#if defined(ENABLE_USB_INTERFACE)
#if defined(ENABLE_USB_INTERFACE) \
|| (defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE))
// A USB Companion owns its primary stream for framed traffic until saved
// preferences are loaded. Starting disabled prevents early boot diagnostics
// from corrupting that stream before single-TTY builds can enter terminal mode.
// Full infrastructure likewise waits for its saved runtime logging preference.
static std::atomic<bool> usb_logging_enabled{false};
#else
static std::atomic<bool> usb_logging_enabled{true};
+12 -10
View File
@@ -13,13 +13,13 @@
#include <helpers/CLICommandUtils.h>
#include <helpers/UsbLogging.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiAccessPointPolicy.h>
#include <helpers/WirelessControl.h>
#include <helpers/esp32/WiFiStationPolicy.h>
namespace {
static const IPAddress SETUP_IP(192, 168, 4, 1);
static const IPAddress SETUP_MASK(255, 255, 255, 0);
static const uint32_t CONNECT_TIMEOUT_MS = 20000;
// The rendered success page remains in the browser after the setup SSID
// disappears; allow one second for the HTTP response to flush first.
@@ -29,6 +29,7 @@ struct PortalImpl {
WiFiServer server{80};
DNSServer dns;
TaskHandle_t task = nullptr;
bool started_radio = false;
WiFiSetupPortal::SaveCallback save_callback = nullptr;
void* callback_context = nullptr;
volatile bool* active = nullptr;
@@ -302,6 +303,8 @@ static void portalTask(void* arg) {
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
impl->started_radio = false;
if (impl->active) *impl->active = false;
impl->task = nullptr;
vTaskDelete(nullptr);
@@ -333,20 +336,17 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
sizeof(impl->ap_name) - 1);
impl->ap_name[sizeof(impl->ap_name) - 1] = 0;
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr,
mesh::wifi::accessPointChannel())
|| mesh::wifi::applyAccessPointProtocolMask() != ESP_OK
|| esp_wifi_set_protocol(
WIFI_IF_STA,
mesh::wifi::kProtocolMask)
!= ESP_OK) {
impl->started_radio = WiFi.getMode() == WIFI_OFF;
if (!mesh::wifi::startOpenAccessPoint(impl->ap_name, false)) {
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
impl->started_radio = false;
return false;
}
if (!impl->dns.start(53, "*", SETUP_IP)) {
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
impl->started_radio = false;
return false;
}
impl->server.begin();
@@ -358,6 +358,8 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio);
impl->started_radio = false;
impl->task = nullptr;
return false;
}
+5 -18
View File
@@ -41,6 +41,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#include "helpers/WebConfigKeys.h"
#include "helpers/WiFiPowerSave.h"
#include "helpers/esp32/WiFiRadioPolicy.h"
#include "helpers/esp32/WiFiAccessPointPolicy.h"
#include "helpers/esp32/WiFiStationPolicy.h"
// ESPAsyncWebServer closes every ordinary response, so splitting the UI into
@@ -819,28 +820,14 @@ static bool setupAccessPointReady(const char* ssid, int channel) {
static bool scanSetupNetworks() {
// Populate the picker before the AP starts, while off-channel scanning
// cannot interrupt discovery or a client's first connection.
WiFi.scanDelete();
const uint8_t channel = mesh::wifi::stationScanChannel();
const int16_t scan_result = WiFi.scanNetworks(true, false, false, 200, channel);
const uint32_t scan_started = millis();
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING
&& millis() - scan_started < 3600) delay(20);
const bool prepared = mesh::wifi::scanBeforeAccessPoint();
mesh::usbDebugPort().printf(
"WebConfig pre-AP scan: channel=%u start=%d result=%d elapsed=%u\n",
(unsigned)channel, (int)scan_result, (int)WiFi.scanComplete(),
"WebConfig pre-AP scan: channel=%u result=%d elapsed=%u\n",
(unsigned)channel, (int)WiFi.scanComplete(),
(unsigned)(millis() - scan_started));
// Arduino's scanComplete() can clear its running flag on a facade timeout
// before the SDK scanner stops. Also cancel FAILED scans before enabling AP.
if (WiFi.scanComplete() < 0) {
if (esp_wifi_scan_stop() != ESP_OK) return false;
const uint32_t stop_started = millis();
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING
&& millis() - stop_started < 100) delay(20);
WiFi.scanDelete();
}
// A failed scan still allows manual SSID entry and a browser rescan. An
// active scanner must stop before we enable the AP interface.
return WiFi.scanComplete() != WIFI_SCAN_RUNNING;
return prepared;
}
bool WebConfigServer::startSetupMode(char reply[]) {
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include <WiFi.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <string.h>
namespace mesh {
namespace wifi {
inline bool accessPointStarted() {
#if ESP_ARDUINO_VERSION_MAJOR >= 3
return WiFi.AP.started();
#else
return (WiFi.getStatusBits() & AP_STARTED_BIT) != 0;
#endif
}
// Arduino setters and a configured netif IP can outlive the AP_START event.
// Read the live driver before telling an operator that the hotspot is ready.
inline bool openAccessPointReady(const char* ssid, int channel) {
wifi_mode_t mode = WIFI_MODE_NULL;
wifi_config_t config = {};
uint8_t protocol = 0;
const size_t length = strlen(ssid);
return accessPointStarted()
&& esp_wifi_get_mode(&mode) == ESP_OK && (mode & WIFI_MODE_AP)
&& esp_wifi_get_config(WIFI_IF_AP, &config) == ESP_OK
&& config.ap.ssid_len == length
&& memcmp(config.ap.ssid, ssid, length) == 0
&& config.ap.ssid_hidden == 0 && config.ap.authmode == WIFI_AUTH_OPEN
&& config.ap.channel == channel
&& esp_wifi_get_protocol(WIFI_IF_AP, &protocol) == ESP_OK
&& protocol == kAccessPointProtocolMask
&& static_cast<uint32_t>(WiFi.softAPIP()) != 0;
}
inline bool finishAccessPointScan() {
// A failed Arduino scan can still have an active SDK scanner. scanDelete()
// only releases results; cancel that scanner before enabling AP beacons.
if (WiFi.scanComplete() < 0) {
if (esp_wifi_scan_stop() != ESP_OK) return false;
const uint32_t started = millis();
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING
&& millis() - started < 100) delay(20);
WiFi.scanDelete();
}
return WiFi.scanComplete() != WIFI_SCAN_RUNNING;
}
inline bool scanBeforeAccessPoint() {
// Use the same STA-only scan-first startup as WebConfig, including compact
// OTA images without WebConfig. Shared ESP-NOW stays on its home channel.
WiFi.scanDelete();
WiFi.scanNetworks(true, false, false, 200, stationScanChannel());
const uint32_t started = millis();
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING
&& millis() - started < 3600) delay(20);
return finishAccessPointScan();
}
inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) {
const int channel = accessPointChannel();
// Repeated OTA commands retain joined clients when the AP is already live.
if (reuse_existing && applyAccessPointProtocolMask() == ESP_OK
&& openAccessPointReady(ssid, channel)) return true;
const bool connected = WiFi.status() == WL_CONNECTED
&& static_cast<uint32_t>(WiFi.localIP()) != 0;
// Removing only AP keeps a station association and any ESP-NOW owner. It
// also initializes a cold, previously disabled radio through Arduino.
if (!WiFi.mode(WIFI_STA)) return false;
const uint32_t stopped = millis();
while (accessPointStarted() && millis() - stopped < 300) delay(20);
if (accessPointStarted() || applyProtocolMask(WIFI_IF_STA) != ESP_OK)
return false;
// Do not scan away from a live LAN connection. An offline radio needs the
// scan-first preparation before its first AP, rather than AP-only startup.
if (!(connected ? finishAccessPointScan() : scanBeforeAccessPoint()))
return false;
const IPAddress ip(192, 168, 4, 1);
const IPAddress mask(255, 255, 255, 0);
bool ready = WiFi.softAPConfig(ip, ip, mask)
&& WiFi.softAP(ssid, nullptr, channel)
&& applyAccessPointProtocolMask() == ESP_OK;
if (ready) {
ready = false;
const uint32_t started = millis();
do {
ready = openAccessPointReady(ssid, channel);
if (!ready) delay(20);
} while (!ready && millis() - started < 300);
}
if (!ready) WiFi.softAPdisconnect(true);
return ready;
}
inline void stopTemporaryAccessPointRadio(bool started_radio) {
// Only restore OFF when this AP initialized the radio. A station inherited
// from WebConfig/MQTT belongs to the caller's service ownership policy.
if (started_radio && !espNowChannelConstrained()
&& WiFi.status() != WL_CONNECTED) WiFi.mode(WIFI_OFF);
}
} // namespace wifi
} // namespace mesh