Suspend failed ESP32 MQTT and NTP uplinks for power saving

Share one bounded WiFi outage and recovery schedule across MQTT, automatic NTP, Companion station ownership and WebConfig. Preserve enabled settings and foreground access while allowing normal sleep after confirmed physical shutdown.

Honor failed SDK client stops before handing off WiFi, protect clean teardown and reconfiguration, and retry transient ESP-NOW channel restoration without repeated disconnects. Add production-method, SDK failure and real sleep regressions to CI.

Validation: native MQTT/NTP/clock/reconnect suites; production power and wrapper failure tests; browser, room HTTP, OTA and combined bridge regressions; Full Heltec V4 repeater, room and companion builds with capability, flash and linked-memory checks.
This commit is contained in:
mikecarper committed 2026-10-12 14:57:01 -07:00
1 parent f62cc7b527
commit 6b3f7df4d0
37 files changed
+3258 -82

No files matched your search

+4 -1
View File
@@ -4721,7 +4721,10 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) {
}
}
if (WiFi.status() == WL_CONNECTED && _mqtt_configured && !_mqtt_started && _mqtt_bridge
// Start the recovery scheduler even when the router is down at boot. The
// Companion still owns association; waiting for GOT_IP here would leave its
// failed station outside MQTT/NTP's shared power budget indefinitely.
if (_mqtt_configured && !_mqtt_started && _mqtt_bridge
#ifdef WITH_WEBCONFIG
&& !isWebConfigSetupActive()
#endif
+182 -18
View File
@@ -100,6 +100,7 @@ MultiSerialInterface interface_manager;
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/CompanionWiFiNtpPolicy.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/IndicatorRenderProfile.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
@@ -1508,6 +1509,12 @@ void halt() {
static bool companion_wifi_active = false;
static bool companion_wifi_disable_in_progress = false;
static bool companion_wifi_services_stopped = false;
static bool companion_wifi_background_stopped = false;
static bool companion_wifi_espnow_restore_pending = false;
static uint32_t companion_wifi_espnow_restore_at = 0;
#ifdef WITH_WEBCONFIG
static bool companion_wifi_webconfig_start_pending = false;
#endif
static volatile bool companion_wifi_setup_requested = false;
static volatile bool companion_wifi_setup_stop_requested = false;
static bool companion_wifi_credential_reload_pending = false;
@@ -1634,6 +1641,10 @@ void halt() {
}
static void serviceCompanionWiFiNtp() {
if (mesh::wifi::networkPower().backgroundSuspended()) {
cancelCompanionWiFiNtp(false);
return;
}
// Configured MQTT builds already perform a fresh startup NTP sync and a
// daily refresh. The common Companion owner covers every non-MQTT or
// runtime-unconfigured WiFi node without racing MQTT's SNTP proof.
@@ -1832,6 +1843,7 @@ void halt() {
void scheduleCompanionWiFiCredentialReload() {
loadCompanionWiFiCredentials();
mesh::wifi::networkPower().requestProbe();
companion_wifi_credential_reload_pending = true;
companion_wifi_credential_reload_at = millis() + 1500UL;
if (companion_wifi_credential_reload_at == 0) {
@@ -1898,6 +1910,7 @@ void halt() {
void requestCompanionWiFiSetup() {
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
mesh::wifi::networkPower().requestProbe();
companion_wifi_setup_stop_requested = false;
companion_wifi_setup_requested = true;
}
@@ -1921,6 +1934,7 @@ void halt() {
return false;
}
companion_wifi_requested = enabled;
if (enabled) mesh::wifi::networkPower().requestProbe();
if (!companion_wifi_requested && companion_wifi_active) {
companion_wifi_disable_in_progress = true;
}
@@ -2230,6 +2244,60 @@ void halt() {
#endif
#if defined(ESP32) && defined(WIFI_SSID)
static bool companionWiFiBackgroundSuspended() {
// MQTT publishes this flag only after its own TLS and SNTP work is quiet.
// A foreground OTA command can acquire the radio before its next pass.
return mesh::wifi::networkPower().backgroundSuspended()
&& !mesh::wifi::networkPower().foregroundActive()
&& !board.isOTAUpdateRunning();
}
static bool companionWiFiAutomaticRecoveryAllowed() {
// MQTT owns one combined outage/cooldown clock. Raising an automatic AP
// during that grace period would create a foreground owner indefinitely.
return !mesh::wifi::networkPower().managed();
}
#ifdef WITH_WEBCONFIG
static void startCompanionWiFiAutoWebConfig() {
companion_wifi_webconfig_start_pending = false;
if (!WebConfigServer::loadEnabled(true)) return;
if (companion_wifi_has_credentials && WiFi.status() != WL_CONNECTED) {
// The Companion owns the initial saved-SSID attempt. Do not ask the
// portal to start its separate fifteen-second AP fallback at boot.
companion_wifi_webconfig_start_pending = true;
return;
}
char web_reply[160];
the_mesh.startWebConfig(!companion_wifi_has_credentials, web_reply);
WIFI_DEBUG_PRINTLN("%s", web_reply);
}
static void serviceCompanionWiFiAutoWebConfig() {
if (!companion_wifi_webconfig_start_pending || !companion_wifi_requested
|| !companion_wifi_active || companionWiFiBackgroundSuspended()) return;
if (!WebConfigServer::loadEnabled(true)) {
companion_wifi_webconfig_start_pending = false;
return;
}
if (WiFi.status() != WL_CONNECTED
|| the_mesh.isWebConfigActiveOrStopping()) return;
startCompanionWiFiAutoWebConfig();
}
#endif
static void serviceCompanionWiFiNetworkPowerHold() {
bool active = wifi_interface.isConnected()
|| companion_wifi_credential_reload_pending
|| companion_wifi_setup_requested
|| board.isOTAUpdateRunning();
#ifdef ENABLE_OTA
active = active || ota_console_client.connected();
#endif
mesh::wifi::networkPower().setHold(
mesh::wifi::NetworkPowerHold::CompanionClient, active);
}
static void resetCompanionWiFiRecoveryState() {
wifi_reconnect_tracker = WiFiReconnectPolicy::Tracker();
wifi_setup_attempted = false;
@@ -2242,6 +2310,7 @@ void halt() {
|| static_cast<int32_t>(millis() - companion_wifi_credential_reload_at) < 0) {
return;
}
if (companionWiFiBackgroundSuspended()) return;
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigActiveOrStopping()) return;
#endif
@@ -2250,6 +2319,7 @@ void halt() {
companion_wifi_credential_reload_pending = false;
companion_wifi_credential_reload_at = 0;
companion_wifi_espnow_restore_pending = false;
loadCompanionWiFiCredentials();
resetCompanionWiFiRecoveryState();
if (!companion_wifi_requested || !companion_wifi_active
@@ -2270,9 +2340,11 @@ void halt() {
static void startCompanionWiFi() {
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
if (companion_wifi_active) return;
if (companionWiFiBackgroundSuspended()) return;
if (!mesh::wifi::stationMutationAllowed()) return;
board.setInhibitSleep(true);
companion_wifi_espnow_restore_pending = false;
mesh::wifi::setStationAutoReconnect(true);
resetCompanionWiFiRecoveryState();
@@ -2290,11 +2362,7 @@ void halt() {
}
#endif
#ifdef WITH_WEBCONFIG
if (WebConfigServer::loadEnabled(true)) {
char web_reply[160];
the_mesh.startWebConfig(!companion_wifi_has_credentials, web_reply);
WIFI_DEBUG_PRINTLN("%s", web_reply);
}
startCompanionWiFiAutoWebConfig();
#endif
applyCompanionWiFiPowerSave();
@@ -2305,6 +2373,7 @@ void halt() {
#endif
companion_wifi_active = true;
companion_wifi_services_stopped = false;
companion_wifi_background_stopped = false;
WIFI_DEBUG_PRINTLN("WiFi enabled by BOOT/GPIO 0 control");
}
@@ -2329,17 +2398,80 @@ void halt() {
static void stopCompanionInfrastructureWiFi() {
WiFi.setAutoReconnect(false);
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// ESP-NOW is the mesh radio on this target. Drop the station/AP services
// without stopping the driver, then restore its fixed LR mesh channel.
WiFi.disconnect(false, false);
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::restoreEspNowChannel();
#else
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
if (mesh::wifi::espNowChannelConstrained()) {
// Primary ESP-NOW and a live optional bridge both share this driver.
// Drop only station association and keep their fixed mesh channel.
WiFi.disconnect(false, false);
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
companion_wifi_espnow_restore_pending =
mesh::wifi::restoreEspNowChannel() != ESP_OK;
companion_wifi_espnow_restore_at = millis() + 1000UL;
} else {
companion_wifi_espnow_restore_pending = false;
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
}
}
static void serviceCompanionEspNowChannelRestore() {
if (!companion_wifi_espnow_restore_pending) return;
if (!mesh::wifi::espNowChannelConstrained()) {
companion_wifi_espnow_restore_pending = false;
return;
}
if (board.isOTAUpdateRunning()
|| mesh::wifi::networkPower().foregroundActive()
|| !mesh::wifi::stationMutationAllowed()) return;
const uint32_t now = millis();
if (static_cast<int32_t>(now - companion_wifi_espnow_restore_at) < 0) return;
// esp_wifi_set_channel may fail until an asynchronous disconnect/scan
// completes. Retry the channel only; repeated disconnects would prevent
// the shared ESP-NOW receiver from recovering during a long cooldown.
companion_wifi_espnow_restore_pending =
mesh::wifi::restoreEspNowChannel() != ESP_OK;
companion_wifi_espnow_restore_at = now + 1000UL;
}
static void serviceCompanionWiFiBackgroundPower() {
serviceCompanionEspNowChannelRestore();
if (!companion_wifi_active || !companion_wifi_requested) return;
if (companionWiFiBackgroundSuspended()) {
if (companion_wifi_background_stopped) return;
// Settings and MQTT's scheduling task stay enabled. These idle socket
// listeners and the common NTP request own no useful foreground work.
cancelCompanionWiFiNtp(false);
wifi_interface.end();
#ifdef ENABLE_OTA
ota_console_stop();
#endif
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(mesh::wifi::espNowChannelConstrained());
companion_wifi_background_stopped = true;
WIFI_DEBUG_PRINTLN("Network unavailable; WiFi suspended until the next recovery probe");
return;
}
if (!companion_wifi_background_stopped) return;
if (!mesh::wifi::stationMutationAllowed()) return;
board.setInhibitSleep(true);
companion_wifi_espnow_restore_pending = false;
resetCompanionWiFiRecoveryState();
if (companion_wifi_has_credentials) {
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::setStationAutoReconnect(true);
wifi_reconnect_tracker.noteDisconnected(millis());
mesh::wifi::beginStation(configured_wifi_ssid, configured_wifi_password);
}
applyCompanionWiFiPowerSave();
wifi_interface.begin(TCP_PORT);
wifi_interface.enable();
#ifdef ENABLE_OTA
ota_console_start();
#endif
companion_wifi_background_stopped = false;
WIFI_DEBUG_PRINTLN("WiFi resumed for a bounded network recovery probe");
}
static bool finishStoppingCompanionWiFi() {
@@ -2368,6 +2500,10 @@ void halt() {
board.setInhibitSleep(false);
resetCompanionWiFiRecoveryState();
companion_wifi_active = false;
companion_wifi_background_stopped = false;
#ifdef WITH_WEBCONFIG
companion_wifi_webconfig_start_pending = false;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
WIFI_DEBUG_PRINTLN(
"Companion WiFi services disabled; ESP-NOW mesh radio remains active");
@@ -2388,6 +2524,10 @@ void halt() {
if (companion_wifi_requested && !companion_wifi_active) {
startCompanionWiFi();
}
serviceCompanionWiFiBackgroundPower();
#ifdef WITH_WEBCONFIG
serviceCompanionWiFiAutoWebConfig();
#endif
}
#endif
@@ -3014,7 +3154,25 @@ static bool companionNativeUsbLightSleepReady() {
// Full compiles every transport, but a compiled driver is not an active
// receiver. Preserve live services and pending replies while permitting
// the same short sleep slice when those services have been turned off.
if (companion_wireless.enabled() != 0 || interface_manager.hasPendingIO()
uint8_t enabled_services = companion_wireless.enabled();
#if defined(ESP32) && defined(WIFI_SSID)
// The user's saved WiFi preference and an idle WebConfig listener remain
// enabled during automatic suspension. Only a confirmed physical power-off
// releases their sleep blocker; foreground work and shared ESP-NOW retain it.
if (companion_wifi_background_stopped
&& mesh::wifi::networkPower().backgroundSuspended()
&& !mesh::wifi::networkPower().foregroundActive()
&& !mesh::wifi::espNowChannelConstrained()) {
wifi_mode_t parked_mode = WIFI_MODE_NULL;
const esp_err_t parked_result = esp_wifi_get_mode(&parked_mode);
if ((parked_result == ESP_OK && parked_mode == WIFI_MODE_NULL)
|| parked_result == ESP_ERR_WIFI_NOT_INIT
|| parked_result == ESP_ERR_WIFI_NOT_STARTED) {
enabled_services &= static_cast<uint8_t>(~mesh::wireless::WiFi);
}
}
#endif
if (enabled_services != 0 || interface_manager.hasPendingIO()
|| board.isOTAUpdateRunning() || board.isRadioTestActive()
|| sensors.gpsUsesSerialUart(1)) return false;
#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6
@@ -3624,6 +3782,9 @@ void loop() {
}
#endif
mesh::wireless::control().service(millis());
#if defined(ESP32) && defined(WIFI_SSID)
serviceCompanionWiFiNetworkPowerHold();
#endif
#if defined(NRF52_PLATFORM)
board.feedWatchdog();
#endif
@@ -3800,8 +3961,11 @@ void loop() {
}
#endif
serviceCompanionWiFiCredentialReload();
serviceCompanionWiFiNetworkPowerHold();
serviceCompanionWiFiState();
if (companion_wifi_requested && companion_wifi_active) {
if (companion_wifi_requested && companion_wifi_active
&& !companionWiFiBackgroundSuspended()
&& !companion_wifi_background_stopped) {
#ifdef ENABLE_OTA
ota_console_loop(); // service the local text console (port 5002)
#endif
@@ -3847,7 +4011,7 @@ void loop() {
}
} else if (configured_wifi_ssid[0]) {
wifi_reconnect_tracker.noteDisconnected(wifi_now);
if (!wifi_setup_recovery_mode
if (!wifi_setup_recovery_mode && companionWiFiAutomaticRecoveryAllowed()
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
+22 -3
View File
@@ -1,6 +1,7 @@
#include "MyMesh.h"
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/BootFileSystem.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
|| defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))
#include <WiFi.h>
@@ -11125,6 +11126,9 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup
return true;
}
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
if (!automatic_setup && !_webconfig->isStopping()) {
_webconfig->requestNetworkAccess();
}
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
return true;
@@ -11163,6 +11167,7 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup
}
}
if (!automatic_setup) _webconfig->requestNetworkAccess();
const bool setup_was_suspended = _unconfigured_setup_espnow_suspended;
#ifdef WITH_ESPNOW_BRIDGE
const bool espnow_was_running = isEspNowBridgeRunning();
@@ -13819,11 +13824,15 @@ void MyMesh::serviceIdleWiFi() {
&& (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \
&& (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO)
if (_cli.getBoard()->isOTAUpdateRunning()) return;
if (mesh::wifi::networkPower().foregroundActive()) return;
#ifdef WITH_WEBCONFIG
if (isWebConfigActive()) return;
if (isWebConfigActive() && (!mesh::wifi::networkPower().backgroundSuspended()
|| _webconfig->isNetworkForegroundActive(millis()))) return;
#endif
#ifdef WITH_MQTT_BRIDGE
if (_ota_update_at) return;
// The MQTT worker owns physical shutdown and resume, including cooldown.
// Never race its Core 0 Arduino/SDK interface transition from this loop.
if (mqtt_bridge && (mqtt_bridge->isRunning() || mqtt_bridge->isStopping())) return;
#endif
#ifdef WITH_ESPNOW_BRIDGE
@@ -13852,8 +13861,12 @@ void MyMesh::serviceIdleWiFi() {
bool MyMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
#if defined(ESP32_PLATFORM)
if (mesh::wifi::networkPower().foregroundActive()) return true;
#endif
#ifdef WITH_WEBCONFIG
if (isWebConfigActive()) return true;
if (isWebConfigActive() && (!mesh::wifi::networkPower().backgroundSuspended()
|| _webconfig->isNetworkForegroundActive(millis()))) return true;
#endif
#if defined(ESP32_PLATFORM) \
&& (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
@@ -13871,7 +13884,13 @@ bool MyMesh::hasPendingWork() const {
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true;
#if defined(WITH_BRIDGE)
const AbstractBridge* active_bridge = activeBridge();
if (active_bridge && active_bridge->isRunning()) return true;
if (active_bridge && active_bridge->isRunning()) {
#ifdef WITH_MQTT_BRIDGE
if (active_bridge != mqtt_bridge || !mqtt_bridge->isNetworkSuspended()) return true;
#else
return true;
#endif
}
#ifdef WITH_MQTT_BRIDGE
if (mqtt_bridge && mqtt_bridge->isStopping()) return true;
#endif
+20 -3
View File
@@ -1,6 +1,7 @@
#include "MyMesh.h"
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/BootFileSystem.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
|| defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))
#include <WiFi.h>
@@ -3119,6 +3120,9 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup
return true;
}
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
if (!automatic_setup && !_webconfig->isStopping()) {
_webconfig->requestNetworkAccess();
}
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
return true;
@@ -3154,6 +3158,7 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup
}
}
if (!automatic_setup) _webconfig->requestNetworkAccess();
const bool setup_was_suspended = _unconfigured_setup_espnow_suspended;
#ifdef WITH_ESPNOW_BRIDGE
const bool espnow_was_running = isEspNowBridgeRunning();
@@ -4198,11 +4203,15 @@ void MyMesh::serviceIdleWiFi() {
&& (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \
&& (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO)
if (_cli.getBoard()->isOTAUpdateRunning()) return;
if (mesh::wifi::networkPower().foregroundActive()) return;
#ifdef WITH_WEBCONFIG
if (isWebConfigActive()) return;
if (isWebConfigActive() && (!mesh::wifi::networkPower().backgroundSuspended()
|| _webconfig->isNetworkForegroundActive(millis()))) return;
#endif
#ifdef WITH_MQTT_BRIDGE
if (_ota_update_at) return;
// The MQTT worker also owns its suspended station: Core 1 must not race
// the worker's shutdown or the next bounded probe on Core 0.
if (bridge && (bridge->isRunning() || bridge->isStopping())) return;
#endif
#ifdef WITH_ESPNOW_BRIDGE
@@ -4226,8 +4235,12 @@ void MyMesh::serviceIdleWiFi() {
bool MyMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
#if defined(ESP32_PLATFORM)
if (mesh::wifi::networkPower().foregroundActive()) return true;
#endif
#ifdef WITH_WEBCONFIG
if (isWebConfigActive()) return true;
if (isWebConfigActive() && (!mesh::wifi::networkPower().backgroundSuspended()
|| _webconfig->isNetworkForegroundActive(millis()))) return true;
#endif
#if defined(ESP32_PLATFORM) \
&& (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \
@@ -4243,7 +4256,11 @@ bool MyMesh::hasPendingWork() const {
if (_local_cli_output.busy()) return true;
#endif
#if defined(WITH_BRIDGE)
if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep
#ifdef WITH_MQTT_BRIDGE
if (bridge && bridge->isRunning() && !bridge->isNetworkSuspended()) return true;
#else
if (bridge && bridge->isRunning()) return true;
#endif
#ifdef WITH_MQTT_BRIDGE
if (bridge && bridge->isStopping()) return true;
#endif