mirror of
https://github.com/esphome/esphome.git
synced 2026-02-18 15:35:59 -07:00
Merge branch 'wifi_roam' into integration
This commit is contained in:
@@ -64,6 +64,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4]
|
||||
CONF_SAVE = "save"
|
||||
CONF_MIN_AUTH_MODE = "min_auth_mode"
|
||||
CONF_POST_CONNECT_ROAMING = "post_connect_roaming"
|
||||
|
||||
# Maximum number of WiFi networks that can be configured
|
||||
# Limited to 127 because selected_sta_index_ is int8_t in C++
|
||||
@@ -349,6 +350,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_POST_CONNECT_ROAMING, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
|
||||
single=True
|
||||
@@ -491,6 +493,15 @@ async def to_code(config):
|
||||
if not config[CONF_ENABLE_ON_BOOT]:
|
||||
cg.add(var.set_enable_on_boot(False))
|
||||
|
||||
# post_connect_roaming defaults to true in C++ - disable if user disabled it
|
||||
# or if 802.11k/v is enabled (driver handles roaming natively)
|
||||
if (
|
||||
not config[CONF_POST_CONNECT_ROAMING]
|
||||
or config.get(CONF_ENABLE_BTM)
|
||||
or config.get(CONF_ENABLE_RRM)
|
||||
):
|
||||
cg.add(var.set_post_connect_roaming(False))
|
||||
|
||||
if CORE.is_esp8266:
|
||||
cg.add_library("ESP8266WiFi", None)
|
||||
elif CORE.is_rp2040:
|
||||
|
||||
@@ -143,6 +143,54 @@ static const char *const TAG = "wifi";
|
||||
/// - Networks not in scan results → Tried in RETRY_HIDDEN phase
|
||||
/// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase
|
||||
/// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID
|
||||
///
|
||||
/// ┌──────────────────────────────────────────────────────────────────────┐
|
||||
/// │ Post-Connect Roaming (for stationary devices) │
|
||||
/// ├──────────────────────────────────────────────────────────────────────┤
|
||||
/// │ Purpose: Handle AP reboot or power loss scenarios where device │
|
||||
/// │ connects to suboptimal AP and never switches back │
|
||||
/// │ │
|
||||
/// │ ┌─────────────────┐ │
|
||||
/// │ │ STA_CONNECTED │ (non-hidden network, roaming enabled, │
|
||||
/// │ │ │ not already scanning/roaming) │
|
||||
/// │ └────────┬────────┘ │
|
||||
/// │ ↓ │
|
||||
/// │ ┌─────────────────┐ Every 5 minutes, up to 3 times │
|
||||
/// │ │ check_roaming_ │───────────────────────────────────────┐ │
|
||||
/// │ └────────┬────────┘ │ │
|
||||
/// │ ↓ │ │
|
||||
/// │ ┌─────────────────┐ │ │
|
||||
/// │ │ Start scan │ (same as normal scan) │ │
|
||||
/// │ └────────┬────────┘ │ │
|
||||
/// │ ↓ │ │
|
||||
/// │ ┌─────────────────────────┐ │ │
|
||||
/// │ │ process_roaming_scan_ │ roaming_attempts_++ │ │
|
||||
/// │ └────────┬───────────────┘ │ │
|
||||
/// │ ↓ │ │
|
||||
/// │ ┌─────────────────┐ No ┌───────────────┐ │ │
|
||||
/// │ │ +10 dB better AP├────────→│ Stay connected│─────────────┤ │
|
||||
/// │ └────────┬────────┘ └───────────────┘ │ │
|
||||
/// │ │ Yes │ │
|
||||
/// │ ↓ │ │
|
||||
/// │ ┌─────────────────┐ │ │
|
||||
/// │ │ start_connecting│ (roaming_connect_active_ = true) │ │
|
||||
/// │ └────────┬────────┘ │ │
|
||||
/// │ ↓ │ │
|
||||
/// │ ┌────┴────┐ │ │
|
||||
/// │ ↓ ↓ │ │
|
||||
/// │ ┌───────┐ ┌───────┐ │ │
|
||||
/// │ │SUCCESS│ │FAILED │ │ │
|
||||
/// │ └───┬───┘ └───┬───┘ │ │
|
||||
/// │ ↓ ↓ │ │
|
||||
/// │ Keep counter Keep counter │ │
|
||||
/// │ (no reset) retry_connect() │ │
|
||||
/// │ │ │ │ │
|
||||
/// │ └──────────────┴──────────────────────────────────────┘ │
|
||||
/// │ │
|
||||
/// │ After 3 scans: roaming_attempts_ >= 3, stop checking │
|
||||
/// │ Non-roaming disconnect: clear_roaming_state_() resets counter │
|
||||
/// │ Roaming success: counter preserved (prevents ping-pong) │
|
||||
/// └──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
|
||||
switch (phase) {
|
||||
@@ -489,7 +537,7 @@ void WiFiComponent::loop() {
|
||||
// Skip cooldown if new credentials were provided while connecting
|
||||
if (this->skip_cooldown_next_cycle_) {
|
||||
this->skip_cooldown_next_cycle_ = false;
|
||||
this->check_connecting_finished();
|
||||
this->check_connecting_finished(now);
|
||||
break;
|
||||
}
|
||||
// Use longer cooldown when captive portal/improv is active to avoid disrupting user config
|
||||
@@ -500,7 +548,7 @@ void WiFiComponent::loop() {
|
||||
// a failure, or something tried to connect over and over
|
||||
// so we entered cooldown. In both cases we call
|
||||
// check_connecting_finished to continue the state machine.
|
||||
this->check_connecting_finished();
|
||||
this->check_connecting_finished(now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -511,7 +559,7 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
case WIFI_COMPONENT_STATE_STA_CONNECTING: {
|
||||
this->status_set_warning(LOG_STR("associating to network"));
|
||||
this->check_connecting_finished();
|
||||
this->check_connecting_finished(now);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -525,6 +573,10 @@ void WiFiComponent::loop() {
|
||||
} else {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = now;
|
||||
|
||||
// Post-connect roaming: check for better AP
|
||||
this->check_roaming_(now);
|
||||
this->process_roaming_scan_(now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -684,8 +736,14 @@ float WiFiComponent::get_loop_priority() const {
|
||||
|
||||
void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
|
||||
void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
|
||||
void WiFiComponent::clear_sta() {
|
||||
// Clear roaming state - no more configured networks
|
||||
this->clear_roaming_state_();
|
||||
this->sta_.clear();
|
||||
this->selected_sta_index_ = -1;
|
||||
}
|
||||
void WiFiComponent::set_sta(const WiFiAP &ap) {
|
||||
this->clear_sta();
|
||||
this->clear_sta(); // Also clears roaming state
|
||||
this->init_sta(1);
|
||||
this->add_sta(ap);
|
||||
this->selected_sta_index_ = 0;
|
||||
@@ -1172,7 +1230,7 @@ void WiFiComponent::dump_config() {
|
||||
this->print_connect_params_();
|
||||
}
|
||||
|
||||
void WiFiComponent::check_connecting_finished() {
|
||||
void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
auto status = this->wifi_sta_connect_status_();
|
||||
|
||||
if (status == WiFiSTAConnectStatus::CONNECTED) {
|
||||
@@ -1219,23 +1277,29 @@ void WiFiComponent::check_connecting_finished() {
|
||||
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
|
||||
this->num_retried_ = 0;
|
||||
|
||||
// Clear priority tracking if all priorities are at minimum
|
||||
this->clear_priorities_if_all_min_();
|
||||
// Reset roaming state on successful connection
|
||||
this->roaming_last_check_ = now;
|
||||
// Only reset attempts if this wasn't a roaming-triggered connection
|
||||
// (prevents ping-pong between APs)
|
||||
if (!this->roaming_connect_active_) {
|
||||
this->roaming_attempts_ = 0;
|
||||
}
|
||||
this->roaming_connect_active_ = false;
|
||||
|
||||
// Clear all priority penalties - successful connection forgives past failures
|
||||
if (!this->sta_priorities_.empty()) {
|
||||
decltype(this->sta_priorities_)().swap(this->sta_priorities_);
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
this->save_fast_connect_settings_();
|
||||
#endif
|
||||
|
||||
// Free scan results memory unless a component needs them
|
||||
if (!this->keep_scan_results_) {
|
||||
this->scan_result_.clear();
|
||||
this->scan_result_.shrink_to_fit();
|
||||
}
|
||||
this->release_scan_results_();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
|
||||
this->wifi_disconnect_();
|
||||
@@ -1503,8 +1567,7 @@ void WiFiComponent::clear_priorities_if_all_min_() {
|
||||
|
||||
// All priorities are at minimum - clear the vector to save memory and reset
|
||||
ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
|
||||
this->sta_priorities_.clear();
|
||||
this->sta_priorities_.shrink_to_fit();
|
||||
decltype(this->sta_priorities_)().swap(this->sta_priorities_);
|
||||
}
|
||||
|
||||
/// Log failed connection attempt and decrease BSSID priority to avoid repeated failures
|
||||
@@ -1643,6 +1706,17 @@ void WiFiComponent::advance_to_next_target_or_increment_retry_() {
|
||||
}
|
||||
|
||||
void WiFiComponent::retry_connect() {
|
||||
// If this was a roaming attempt, preserve roaming_attempts_ count
|
||||
// (so we stop roaming after ROAMING_MAX_ATTEMPTS failures)
|
||||
// Otherwise reset all roaming state
|
||||
if (this->roaming_connect_active_) {
|
||||
this->roaming_connect_active_ = false;
|
||||
this->roaming_scan_active_ = false;
|
||||
// Keep roaming_attempts_ - will prevent further roaming after max failures
|
||||
} else {
|
||||
this->clear_roaming_state_();
|
||||
}
|
||||
|
||||
this->log_and_adjust_priority_for_failed_connect_();
|
||||
|
||||
// Determine next retry phase based on current state
|
||||
@@ -1885,6 +1959,124 @@ bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; }
|
||||
|
||||
bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }
|
||||
|
||||
void WiFiComponent::clear_roaming_state_() {
|
||||
this->roaming_attempts_ = 0;
|
||||
this->roaming_last_check_ = 0;
|
||||
this->roaming_scan_active_ = false;
|
||||
this->roaming_connect_active_ = false;
|
||||
}
|
||||
|
||||
void WiFiComponent::check_roaming_(uint32_t now) {
|
||||
// Guard: feature enabled
|
||||
if (!this->post_connect_roaming_)
|
||||
return;
|
||||
|
||||
// Guard: not for hidden networks (may not appear in scan)
|
||||
const WiFiAP *selected = this->get_selected_sta_();
|
||||
if (selected == nullptr || selected->get_hidden())
|
||||
return;
|
||||
|
||||
// Guard: attempt limit
|
||||
if (this->roaming_attempts_ >= ROAMING_MAX_ATTEMPTS)
|
||||
return;
|
||||
|
||||
// Guard: scan not already active
|
||||
if (this->roaming_scan_active_)
|
||||
return;
|
||||
|
||||
// Guard: interval check
|
||||
if (now - this->roaming_last_check_ < ROAMING_CHECK_INTERVAL)
|
||||
return;
|
||||
|
||||
// Guard: must have valid RSSI reading
|
||||
int8_t current_rssi = this->wifi_rssi();
|
||||
if (current_rssi == WIFI_RSSI_DISCONNECTED)
|
||||
return;
|
||||
|
||||
this->roaming_last_check_ = now;
|
||||
ESP_LOGD(TAG, "Roaming: scanning for better AP (current RSSI %d dBm)", current_rssi);
|
||||
this->roaming_scan_active_ = true;
|
||||
this->wifi_scan_start_(this->passive_scan_);
|
||||
}
|
||||
|
||||
void WiFiComponent::process_roaming_scan_(uint32_t now) {
|
||||
// Not our scan
|
||||
if (!this->roaming_scan_active_)
|
||||
return;
|
||||
|
||||
// Scan not done yet
|
||||
if (!this->scan_done_)
|
||||
return;
|
||||
|
||||
this->scan_done_ = false;
|
||||
this->roaming_scan_active_ = false;
|
||||
this->roaming_attempts_++;
|
||||
|
||||
// Get current connection info
|
||||
bssid_t current_bssid = this->wifi_bssid();
|
||||
int8_t current_rssi = this->wifi_rssi();
|
||||
std::string current_ssid = this->wifi_ssid();
|
||||
|
||||
// Find best candidate: same SSID, different BSSID
|
||||
bssid_t best_bssid{};
|
||||
uint8_t best_channel = 0;
|
||||
int8_t best_rssi = WIFI_RSSI_DISCONNECTED;
|
||||
|
||||
for (const auto &result : this->scan_result_) {
|
||||
// Must be same SSID as current connection
|
||||
if (result.get_ssid() != current_ssid)
|
||||
continue;
|
||||
|
||||
// Must be different BSSID
|
||||
if (result.get_bssid() == current_bssid)
|
||||
continue;
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
{
|
||||
char bssid_buf[18];
|
||||
format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
|
||||
ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dBm", bssid_buf, result.get_rssi());
|
||||
}
|
||||
#endif
|
||||
|
||||
// Track the best candidate
|
||||
if (result.get_rssi() > best_rssi) {
|
||||
best_rssi = result.get_rssi();
|
||||
best_bssid = result.get_bssid();
|
||||
best_channel = result.get_channel();
|
||||
}
|
||||
}
|
||||
|
||||
this->release_scan_results_();
|
||||
|
||||
// Check if best candidate meets minimum improvement threshold
|
||||
int8_t improvement = (best_rssi == WIFI_RSSI_DISCONNECTED) ? 0 : best_rssi - current_rssi;
|
||||
if (improvement < ROAMING_MIN_IMPROVEMENT) {
|
||||
ESP_LOGV(TAG, "Roaming: best candidate %+d dB (need +%d dB)", improvement, ROAMING_MIN_IMPROVEMENT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Found better AP - initiate roam
|
||||
const WiFiAP *selected = this->get_selected_sta_();
|
||||
if (selected == nullptr)
|
||||
return; // Defensive: shouldn't happen since clear_sta() clears roaming_scan_active_
|
||||
|
||||
char bssid_s[18];
|
||||
format_mac_addr_upper(best_bssid.data(), bssid_s);
|
||||
ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_s, improvement);
|
||||
|
||||
WiFiAP roam_params = *selected;
|
||||
roam_params.set_bssid(best_bssid);
|
||||
roam_params.set_channel(best_channel);
|
||||
|
||||
// Mark as roaming attempt - affects retry behavior if connection fails
|
||||
this->roaming_connect_active_ = true;
|
||||
|
||||
// Connect directly - wifi_sta_connect_ handles disconnect internally
|
||||
this->error_from_callback_ = false;
|
||||
this->start_connecting(roam_params);
|
||||
}
|
||||
|
||||
WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::wifi
|
||||
|
||||
@@ -297,10 +297,7 @@ class WiFiComponent : public Component {
|
||||
WiFiAP get_sta() const;
|
||||
void init_sta(size_t count);
|
||||
void add_sta(const WiFiAP &ap);
|
||||
void clear_sta() {
|
||||
this->sta_.clear();
|
||||
this->selected_sta_index_ = -1;
|
||||
}
|
||||
void clear_sta();
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
/** Setup an Access Point that should be created if no connection to a station can be made.
|
||||
@@ -324,7 +321,7 @@ class WiFiComponent : public Component {
|
||||
// Backward compatibility overload - ignores 'two' parameter
|
||||
void start_connecting(const WiFiAP &ap, bool /* two */) { this->start_connecting(ap); }
|
||||
|
||||
void check_connecting_finished();
|
||||
void check_connecting_finished(uint32_t now);
|
||||
|
||||
void retry_connect();
|
||||
|
||||
@@ -414,6 +411,7 @@ class WiFiComponent : public Component {
|
||||
|
||||
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
|
||||
void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; }
|
||||
void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; }
|
||||
|
||||
Trigger<> *get_connect_trigger() const { return this->connect_trigger_; };
|
||||
Trigger<> *get_disconnect_trigger() const { return this->disconnect_trigger_; };
|
||||
@@ -566,6 +564,24 @@ class WiFiComponent : public Component {
|
||||
void save_fast_connect_settings_();
|
||||
#endif
|
||||
|
||||
// Post-connect roaming methods
|
||||
void check_roaming_(uint32_t now);
|
||||
void process_roaming_scan_(uint32_t now);
|
||||
void clear_roaming_state_();
|
||||
|
||||
/// Free scan results memory unless a component needs them
|
||||
void release_scan_results_() {
|
||||
if (!this->keep_scan_results_) {
|
||||
#ifdef USE_RP2040
|
||||
// std::vector - use swap trick since shrink_to_fit is non-binding
|
||||
decltype(this->scan_result_)().swap(this->scan_result_);
|
||||
#else
|
||||
// FixedVector::shrink_to_fit() actually frees all memory
|
||||
this->scan_result_.shrink_to_fit();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
static void wifi_event_callback(System_Event_t *event);
|
||||
void wifi_scan_done_callback_(void *arg, STATUS status);
|
||||
@@ -604,10 +620,16 @@ class WiFiComponent : public Component {
|
||||
ESPPreferenceObject fast_connect_pref_;
|
||||
#endif
|
||||
|
||||
// Post-connect roaming constants
|
||||
static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB
|
||||
static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3;
|
||||
|
||||
// Group all 32-bit integers together
|
||||
uint32_t action_started_;
|
||||
uint32_t last_connected_{0};
|
||||
uint32_t reboot_timeout_{};
|
||||
uint32_t roaming_last_check_{0};
|
||||
#ifdef USE_WIFI_AP
|
||||
uint32_t ap_timeout_{};
|
||||
#endif
|
||||
@@ -622,6 +644,7 @@ class WiFiComponent : public Component {
|
||||
// Used to access password, manual_ip, priority, EAP settings, and hidden flag
|
||||
// int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS)
|
||||
int8_t selected_sta_index_{-1};
|
||||
uint8_t roaming_attempts_{0};
|
||||
|
||||
#if USE_NETWORK_IPV6
|
||||
uint8_t num_ipv6_addresses_{0};
|
||||
@@ -645,6 +668,9 @@ class WiFiComponent : public Component {
|
||||
bool keep_scan_results_{false};
|
||||
bool did_scan_this_cycle_{false};
|
||||
bool skip_cooldown_next_cycle_{false};
|
||||
bool post_connect_roaming_{true}; // Enabled by default
|
||||
bool roaming_scan_active_{false};
|
||||
bool roaming_connect_active_{false}; // True during roaming connection attempt (skip priority decrease on fail)
|
||||
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
|
||||
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
|
||||
bool is_high_performance_mode_{false};
|
||||
|
||||
@@ -14,6 +14,7 @@ esphome:
|
||||
wifi:
|
||||
use_psram: true
|
||||
min_auth_mode: WPA
|
||||
post_connect_roaming: false
|
||||
manual_ip:
|
||||
static_ip: 192.168.1.100
|
||||
gateway: 192.168.1.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
wifi:
|
||||
min_auth_mode: WPA2
|
||||
post_connect_roaming: true
|
||||
|
||||
packages:
|
||||
- !include common.yaml
|
||||
|
||||
Reference in New Issue
Block a user