Chapter 38: WiFi Roaming Techniques
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the concept of WiFi roaming and its importance for mobile devices.
- Identify triggers for initiating a roaming process (e.g., low RSSI).
- Implement logic to monitor the current connection’s signal strength.
- Perform targeted scans for alternative Access Points (APs) with the same SSID.
- Evaluate potential roaming candidate APs based on signal strength.
- Implement a basic roaming mechanism to switch between APs.
- Understand the limitations and considerations of simple roaming techniques.
Introduction
Previous chapters focused on establishing and maintaining a connection to a single WiFi network or choosing from multiple different networks. However, many IoT applications involve devices that move within an area covered by multiple Access Points belonging to the same network (identified by the same SSID). Examples include asset trackers in a warehouse, environmental sensors on a moving vehicle within a campus, or portable medical devices in a hospital.
In such scenarios, simply staying connected to the initial AP might lead to poor performance or complete disconnection as the device moves further away. WiFi Roaming is the process by which a client device seamlessly transitions its connection from one AP to another within the same network (same SSID) to maintain optimal connectivity. This chapter explores how to implement basic roaming logic on the ESP32 using ESP-IDF, enabling your devices to intelligently switch APs based on signal quality.
Theory
What is WiFi Roaming?
WiFi roaming refers to the mechanism that allows a wireless client device (like an ESP32) to move between the coverage areas of different Access Points (APs) that are part of the same Extended Service Set (ESS) without losing the network connection. An ESS is essentially a network composed of multiple APs sharing the same Service Set Identifier (SSID) and connected to the same underlying wired network infrastructure. To the client device, the entire ESS appears as a single network.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans, sans-serif', 'primaryColor': '#DBEAFE', /* Process Nodes BG */ 'primaryTextColor': '#1E40AF', /* Process Nodes Text */ 'primaryBorderColor': '#2563EB',/* Process Nodes Border */ 'lineColor': '#4B5563', /* Arrow color */ 'textColor': '#1F2937', /* Default text color for labels */ 'mainBkg': 'transparent' /* Diagram background */ }} }%% graph LR subgraph Network_Infrastructure ["Wired Network Infrastructure"] RTR(("Router<br>(DHCP, Gateway)")); SW(("Switch")); RTR --- SW; end subgraph ESS direction TB Label["Extended Service Set (ESS)<br>SSID: CorporateNet"]:::titleStyle; AP1["<br><b>AP1</b><br>(BSSID: AA:BB:CC:00:00:01)"]:::apNode; AP2["<br><b>AP2</b><br>(BSSID: AA:BB:CC:00:00:02)"]:::apNode; end classDef titleStyle fill:transparent,color:#1E40AF,font-weight:bold; Client_Initial["ESP32 (Position 1)"]:::clientNode; Client_Roaming["ESP32 (Position 2)"]:::clientNode; SW --- AP1; SW --- AP2; Client_Initial -.->|Connected to AP1<br>RSSI: -55dBm| AP1; Client_Roaming -.->|Roamed to AP2<br>RSSI: -50dBm| AP2; Client_Initial -- Movement --> Client_Roaming; subgraph Coverage_AP1 ["AP1 Coverage Area"] direction LR C1_Dummy(" "); style C1_Dummy fill:transparent,stroke:transparent; Client_Initial; end style Coverage_AP1 fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,stroke-dasharray:5 5,color:#1E40AF,border-radius:15px; subgraph Coverage_AP2 ["AP2 Coverage Area"] direction LR C2_Dummy(" "); style C2_Dummy fill:transparent,stroke:transparent; Client_Roaming; end style Coverage_AP2 fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,stroke-dasharray:5 5,color:#1E40AF,border-radius:15px; classDef apNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6,padding:10px; classDef clientNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E,padding:10px; classDef infrastructureNode fill:#E0E7FF,stroke:#4338CA,stroke-width:1px,color:#3730A3; class RTR,SW infrastructureNode; class AP1,AP2 apNode; class Client_Initial,Client_Roaming clientNode;
The goal of roaming is to maintain a stable and performant connection by proactively switching to a closer or stronger AP before the signal from the current AP becomes unusable.
Roaming Triggers
A client device needs criteria to decide when to initiate a roam. Common triggers include:
Trigger Type | Description | Typical ESP-IDF Implementation Detail | Considerations |
---|---|---|---|
Low RSSI | The signal strength from the currently connected AP drops below a predefined threshold. | Periodically call esp_wifi_sta_get_ap_info() and check the rssi field against a constant like ROAMING_RSSI_THRESHOLD (e.g., -70 dBm, -75 dBm). |
Most common and straightforward trigger. Threshold needs tuning for the environment to avoid too frequent or too late roaming. Hysteresis is important. |
High Packet Error Rate (PER) | The device experiences an increasing number of failed data transmissions or receptions, even if RSSI appears acceptable. | More complex to implement directly. ESP-IDF doesn’t provide a simple high-level PER API. Could be inferred from application-level failures (e.g., repeated MQTT publish failures, TCP timeouts) if correlated with WiFi. | Indicates poor link quality due to interference or hidden node problems. Less common as a primary trigger in simple client-driven roaming due to implementation complexity. |
Beacon Loss | The ESP32 misses a certain number of consecutive beacon frames from the current AP. | Handled by the WiFi driver. Results in a WIFI_EVENT_STA_DISCONNECTED event with reason WIFI_REASON_BEACON_TIMEOUT . |
This is a reactive trigger (link is already lost). Proactive roaming (based on low RSSI) aims to switch *before* this occurs. If it happens, the device needs to reconnect, which might involve scanning and finding a new AP. |
AP Load / Performance Metrics | (Advanced) The AP signals high load or poor performance via 802.11k/v. | Requires 802.11k/v support on both AP and ESP32 (not standard in basic ESP-IDF station mode). | Typically found in enterprise networks. Not a common trigger for simple ESP32 roaming logic. |
Basic Roaming Process (Client-Driven Scan-Based)
While advanced enterprise networks utilize protocols like 802.11k, 802.11v, and 802.11r (Fast BSS Transition) to assist clients in making faster and more informed roaming decisions, implementing these on low-power devices like the ESP32 can be complex and requires specific support from the network infrastructure.
A more straightforward and commonly implemented approach on devices like the ESP32 is client-driven, scan-based roaming. The ESP32 itself takes responsibility for monitoring the connection and deciding when and where to roam. The typical steps are:
- Monitor Current Connection: Periodically check the RSSI of the currently connected AP using
esp_wifi_sta_get_ap_info()
. - Detect Roaming Trigger: If the RSSI falls below a predefined
ROAMING_THRESHOLD
. - Scan for Alternatives: Initiate a WiFi scan (
esp_wifi_scan_start()
), specifically looking for APs broadcasting the same SSID as the current network. This is crucial – roaming only happens within the same logical network. The scan should ideally be targeted (e.g., scan specific channels if known, or useWIFI_SCAN_TYPE_ACTIVE
) to minimize time and power consumption. - Evaluate Candidates: Analyze the scan results:
- Filter the results to include only APs with the correct SSID.
- Identify potential candidates whose RSSI is significantly better than the current AP’s RSSI (e.g., at least
RSSI_IMPROVEMENT_THRESHOLD
dBm stronger, like 5 or 10 dBm). This prevents unnecessary roaming between APs with similar signal strengths (hysteresis). - Select the best candidate (usually the one with the highest RSSI among the suitable alternatives).
- Initiate Roam: If a suitable better candidate AP is found:
- Optionally, explicitly disconnect from the current AP using
esp_wifi_disconnect()
. (Whileesp_wifi_connect()
might handle switching implicitly if the configuration changes, explicitly disconnecting can sometimes provide a cleaner transition, though potentially slower). - Configure the station to connect to the specific BSSID (MAC address) of the chosen candidate AP. This is done by setting the
bssid_set
flag and thebssid
field in thewifi_config_t
structure. Targeting the BSSID ensures connection to the desired physical AP, not just any AP with the same SSID. - Call
esp_wifi_set_config(WIFI_IF_STA, &wifi_config)
to update the target. - Call
esp_wifi_connect()
to initiate the connection to the new AP.
- Optionally, explicitly disconnect from the current AP using
- Verify Connection: Monitor WiFi events (
WIFI_EVENT_STA_CONNECTED
,IP_EVENT_STA_GOT_IP
,WIFI_EVENT_STA_DISCONNECTED
) to confirm successful roaming or handle failures.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans, sans-serif', 'primaryColor': '#DBEAFE', /* Process Nodes BG */ 'primaryTextColor': '#1E40AF', /* Process Nodes Text */ 'primaryBorderColor': '#2563EB',/* Process Nodes Border */ 'lineColor': '#4B5563', /* Arrow color */ 'textColor': '#1F2937', /* Default text color for labels */ 'mainBkg': 'transparent' /* Diagram background */ }} }%% graph TD A[/"Start: Device Connected to AP_current"/]:::startNode --> B{"Monitor RSSI from AP_current<br>(<code>esp_wifi_sta_get_ap_info</code>)"}; B --> C{"RSSI < ROAMING_THRESHOLD?"}; C -- "No (Signal OK)" --> B; C -- "Yes (Signal Weak)" --> D["Initiate WiFi Scan<br>for <b>same SSID</b><br>(<code>esp_wifi_scan_start</code>)"]:::processNode; D --> E{"Analyze Scan Results:<br>Find AP_candidate with<br>SSID_current AND<br>RSSI_candidate > RSSI_current + IMPROVEMENT_THRESHOLD?"}; E -- "No Suitable Candidate Found" --> B; E -- "Yes, Better AP_candidate Found" --> F["Configure Connection to<br><b>AP_candidate's BSSID</b><br>(<code>wifi_config_t.bssid_set=true</code>,<br><code>wifi_config_t.bssid=AP_candidate.bssid</code>)"]:::processNode; F --> G["Set New WiFi Configuration<br>(<code>esp_wifi_set_config</code>)"]:::processNode; G --> H["Initiate Connection to AP_candidate<br>(<code>esp_wifi_connect</code>)"]:::processNode; H --> I{"Verify Connection to AP_candidate<br>(<code>WIFI_EVENT_STA_CONNECTED</code>,<br><code>IP_EVENT_STA_GOT_IP</code>)"}; I -- "Connection Successful" --> J[/"Roamed to AP_candidate!<br>AP_current = AP_candidate"/]:::successNode; J --> B; I -- "Connection Failed<br>(<code>WIFI_EVENT_STA_DISCONNECTED</code>,<br>Timeout)" --> K["Handle Roaming Failure<br>(e.g., Revert to old AP if possible,<br>Retry scan, or Full Reconnect)"]:::checkNode; K --> B; classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6; classDef successNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E; classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef checkNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; class A startNode; class B,D,F,G,H processNode; class C,E,I decisionNode; class J successNode; class K checkNode;
ESP-IDF Configuration for Roaming
When configuring the initial connection or preparing for roaming, certain fields in wifi_config_t
(wifi_sta_config_t
) are relevant:
Field in wifi_sta_config_t |
Description | Relevance to Roaming |
---|---|---|
ssid[32] |
The Service Set Identifier (name) of the network. | Fundamental. Roaming occurs between APs sharing the same SSID. This field must match the target network. |
password[64] |
The password (pre-shared key) for the network. | Must be the same for all APs in the ESS for seamless roaming. |
scan_method |
Specifies scan type: WIFI_FAST_SCAN or WIFI_ALL_CHANNEL_SCAN . |
For roaming scans, WIFI_FAST_SCAN can be quicker if channels are known or limited. WIFI_ALL_CHANNEL_SCAN is more comprehensive but slower. Often, roaming scans are targeted to the current SSID, making channel scanning implicit. |
bssid_set |
A boolean flag. If true, the ESP32 will attempt to connect only to the AP specified by the bssid field. |
Crucial for roaming. After identifying a better candidate AP (via its BSSID from a scan), set this to true to target that specific AP. For initial connection, it’s usually false . |
bssid[6] |
The 6-byte MAC address (BSSID) of the target Access Point. Used only if bssid_set is true. |
Crucial for roaming. This field must be populated with the MAC address of the chosen candidate AP to ensure connection to that specific physical device. |
channel |
If non-zero, specifies a specific channel to connect on. If zero, all channels are scanned. | Usually set to 0 for initial connection to allow finding the AP. For roaming, if the candidate AP’s channel is known from the scan, setting this might speed up connection, but often targeting BSSID is sufficient. |
sort_method |
Determines how APs are sorted if multiple are found with the same SSID (when bssid_set is false). E.g., WIFI_CONNECT_AP_BY_SIGNAL . |
Less directly used in proactive client-driven roaming logic because the client explicitly chooses the BSSID. However, it influences initial connection behavior. |
threshold.authmode |
Minimum security mode required (e.g., WIFI_AUTH_WPA2_PSK ). |
Must match the security configuration of all APs in the ESS. |
threshold.rssi |
(Driver internal) If set, the ESP32 will not attempt to connect to an AP if its RSSI is below this value during initial connection. | Client-driven roaming logic typically uses its own, more explicit RSSI thresholds (ROAMING_RSSI_THRESHOLD ) for more control over the roaming decision process rather than relying solely on this driver-level threshold. |
Practical Examples
Let’s implement a basic roaming manager task that monitors RSSI and attempts to roam to a stronger AP with the same SSID.
1. Project Setup
- Start with a working WiFi station project (e.g., from Chapter 27 or 37).
- Ensure necessary components (
nvs_flash
,esp_wifi
,esp_event
,esp_netif
,esp_log
,freertos
) are included. - We’ll need the WiFi event handler and event group (
s_wifi_event_group
,WIFI_CONNECTED_BIT
,WIFI_FAIL_BIT
) from previous examples.
2. Roaming Configuration and State
Define constants and variables needed for roaming logic.
// main.c (or wifi_manager.c)
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
static const char *TAG = "WIFI_ROAM";
// --- Roaming Configuration ---
#define ROAMING_CHECK_INTERVAL_MS (10000) // Check RSSI every 10 seconds
#define ROAMING_RSSI_THRESHOLD (-75) // Trigger roam scan if RSSI drops below -75 dBm
#define ROAMING_MIN_RSSI_IMPROVEMENT (5) // New AP must be at least 5 dBm stronger
// Assume s_wifi_event_group and bits are defined elsewhere and wifi_init_sta is called
extern EventGroupHandle_t s_wifi_event_group;
extern const int WIFI_CONNECTED_BIT;
extern const int WIFI_FAIL_BIT;
// Global state (use mutex/semaphore if accessed by multiple tasks)
static char current_ssid[33] = {0}; // Store the SSID we are connected to
static bool is_connected = false; // Track connection state
static int8_t current_rssi = -127; // Store last known RSSI
static uint8_t current_bssid[6] = {0}; // Store BSSID of current AP
// Forward declaration
static void wifi_event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data);
static void wifi_roaming_task(void *pvParameters);
static void wifi_init_sta(const char* ssid, const char* password); // Modified to take creds
3. Updated Event Handler
Modify the event handler to update the connection state and store current network details.
// main.c (or wifi_manager.c)
// Modified WiFi Event Handler
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
ESP_LOGI(TAG, "WIFI_EVENT_STA_START: Ready.");
// Initiate connection (example: called from app_main after init)
// esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) {
wifi_event_sta_connected_t* event = (wifi_event_sta_connected_t*) event_data;
ESP_LOGI(TAG, "WIFI_EVENT_STA_CONNECTED: SSID '%.*s', Channel %d",
event->ssid_len, event->ssid, event->channel);
// Store current connection details
strncpy(current_ssid, (char*)event->ssid, sizeof(current_ssid) - 1);
current_ssid[event->ssid_len] = '\0'; // Ensure null termination
memcpy(current_bssid, event->bssid, sizeof(current_bssid));
// Waiting for IP
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t* event = (wifi_event_sta_disconnected_t*) event_data;
ESP_LOGW(TAG, "WIFI_EVENT_STA_DISCONNECTED: SSID '%.*s', Reason: %d",
event->ssid_len, event->ssid, event->reason);
is_connected = false;
current_rssi = -127;
memset(current_bssid, 0, sizeof(current_bssid)); // Clear current BSSID
if (s_wifi_event_group != NULL) {
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
// Trigger reconnection logic if needed (outside the scope of basic roaming task)
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "IP_EVENT_STA_GOT_IP: Got IP:" IPSTR, IP2STR(&event->ip_info.ip));
is_connected = true;
if (s_wifi_event_group != NULL) {
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
xEventGroupClearBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
// Roaming task will start monitoring RSSI now
}
}
4. Roaming Task Implementation
This task periodically checks RSSI and initiates roaming if necessary.
// main.c (or wifi_manager.c)
// Function to attempt roaming to a specific BSSID
static bool attempt_roam_to_ap(const uint8_t *new_bssid) {
ESP_LOGI(TAG, "Attempting to roam to BSSID: " MACSTR, MAC2STR(new_bssid));
// 1. Get current configuration to preserve password etc.
wifi_config_t current_config;
esp_err_t err = esp_wifi_get_config(WIFI_IF_STA, ¤t_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to get current WiFi config: %s", esp_err_to_name(err));
return false;
}
// 2. Update config to target the new BSSID
current_config.sta.bssid_set = true;
memcpy(current_config.sta.bssid, new_bssid, sizeof(current_config.sta.bssid));
// 3. Set the new configuration
err = esp_wifi_set_config(WIFI_IF_STA, ¤t_config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to set WiFi config for roaming: %s", esp_err_to_name(err));
// Optional: Restore bssid_set = false if needed
return false;
}
// 4. Initiate connection (implicitly disconnects from old AP if needed)
ESP_LOGI(TAG, "Calling esp_wifi_connect() to roam...");
err = esp_wifi_connect();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_wifi_connect failed during roam: %s", esp_err_to_name(err));
return false;
}
// 5. Wait for connection result (using event group)
// Clear bits before waiting
xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT);
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE, // Don't clear on exit
pdFALSE, // Wait for either bit
pdMS_TO_TICKS(20000)); // Roaming timeout (e.g., 20s)
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Successfully roamed to BSSID: " MACSTR, MAC2STR(new_bssid));
// Event handler would have set is_connected = true and updated current_bssid
return true;
} else if (bits & WIFI_FAIL_BIT) {
ESP_LOGW(TAG, "Roaming attempt failed (disconnect event) for BSSID: " MACSTR, MAC2STR(new_bssid));
// Event handler set is_connected = false
// Might need to trigger a full reconnect to the original SSID without BSSID target
// esp_wifi_disconnect(); // Ensure disconnect
// current_config.sta.bssid_set = false;
// esp_wifi_set_config(WIFI_IF_STA, ¤t_config);
// esp_wifi_connect();
return false;
} else {
ESP_LOGW(TAG, "Roaming attempt timed out for BSSID: " MACSTR, MAC2STR(new_bssid));
// Handle timeout, maybe trigger full reconnect
return false;
}
}
// The core roaming logic task
static void wifi_roaming_task(void *pvParameters) {
wifi_ap_record_t current_ap_info;
uint16_t scan_ap_count = 0;
wifi_ap_record_t *scan_ap_list = NULL;
ESP_LOGI(TAG, "Roaming Task Started. Waiting for connection...");
while (1) {
// Wait until connected to WiFi
if (s_wifi_event_group != NULL) {
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT, pdFALSE, pdTRUE, portMAX_DELAY);
} else {
vTaskDelay(pdMS_TO_TICKS(1000)); // Should not happen if initialized correctly
continue;
}
if (!is_connected) {
ESP_LOGD(TAG, "Roaming check skipped: Not connected.");
vTaskDelay(pdMS_TO_TICKS(ROAMING_CHECK_INTERVAL_MS));
continue;
}
// 1. Monitor Current Connection RSSI
if (esp_wifi_sta_get_ap_info(¤t_ap_info) == ESP_OK) {
current_rssi = current_ap_info.rssi;
ESP_LOGI(TAG, "Current AP: SSID '%s', BSSID: " MACSTR ", RSSI: %d dBm",
current_ap_info.ssid, MAC2STR(current_ap_info.bssid), current_rssi);
// Store current BSSID in case event handler missed it or for comparison
memcpy(current_bssid, current_ap_info.bssid, sizeof(current_bssid));
// 2. Detect Roaming Trigger
if (current_rssi < ROAMING_RSSI_THRESHOLD) {
ESP_LOGW(TAG, "RSSI (%d dBm) below threshold (%d dBm). Scanning for better APs...",
current_rssi, ROAMING_RSSI_THRESHOLD);
// 3. Scan for Alternatives (same SSID)
wifi_scan_config_t scan_config = {
.ssid = (uint8_t*)current_ssid, // Scan ONLY for our current network SSID
.bssid = NULL,
.channel = 0, // Scan all channels
.show_hidden = false,
.scan_type = WIFI_SCAN_TYPE_ACTIVE,
.scan_time.active.min = 100, // ms
.scan_time.active.max = 150, // ms
};
// Perform blocking scan
esp_err_t scan_err = esp_wifi_scan_start(&scan_config, true);
if (scan_err == ESP_OK) {
esp_wifi_scan_get_ap_num(&scan_ap_count);
ESP_LOGI(TAG, "Scan found %d APs for SSID '%s'", scan_ap_count, current_ssid);
if (scan_ap_count > 0) {
// Allocate memory for scan results
scan_ap_list = (wifi_ap_record_t *)malloc(scan_ap_count * sizeof(wifi_ap_record_t));
if (scan_ap_list == NULL) {
ESP_LOGE(TAG, "Failed to allocate memory for scan results");
scan_ap_count = 0; // Prevent further processing
} else {
ESP_ERROR_CHECK(esp_wifi_scan_get_ap_records(&scan_ap_count, scan_ap_list));
// 4. Evaluate Candidates
int8_t best_candidate_rssi = -127;
uint8_t *best_candidate_bssid = NULL;
for (uint16_t i = 0; i < scan_ap_count; i++) {
// Check if it's a different AP and significantly stronger
if (memcmp(scan_ap_list[i].bssid, current_bssid, 6) != 0 &&
scan_ap_list[i].rssi > current_rssi + ROAMING_MIN_RSSI_IMPROVEMENT)
{
ESP_LOGI(TAG, " Candidate AP: BSSID " MACSTR ", RSSI: %d dBm",
MAC2STR(scan_ap_list[i].bssid), scan_ap_list[i].rssi);
// Check if this candidate is better than the current best *candidate*
if (scan_ap_list[i].rssi > best_candidate_rssi) {
best_candidate_rssi = scan_ap_list[i].rssi;
best_candidate_bssid = scan_ap_list[i].bssid;
}
}
}
// 5. Initiate Roam (if a suitable candidate was found)
if (best_candidate_bssid != NULL) {
ESP_LOGI(TAG, "Found better AP (BSSID: " MACSTR ", RSSI: %d dBm). Initiating roam.",
MAC2STR(best_candidate_bssid), best_candidate_rssi);
// Attempt the roam - function handles connect/wait logic
if (attempt_roam_to_ap(best_candidate_bssid)) {
// Roam successful, next loop iteration will monitor the new AP
ESP_LOGI(TAG, "Roaming successful.");
} else {
ESP_LOGE(TAG, "Roaming failed. Will continue monitoring current AP or retry connection.");
// If roam fails, we might still be connected to the old AP,
// or disconnected. The event handler manages the is_connected state.
// Consider adding logic here to force a reconnect to the original SSID
// without a specific BSSID if the roaming attempt left us disconnected.
}
} else {
ESP_LOGI(TAG, "No significantly better AP found in scan results.");
}
// Free scan results memory
free(scan_ap_list);
scan_ap_list = NULL;
} // end else (malloc success)
} // end if (scan_ap_count > 0)
} else {
ESP_LOGE(TAG, "WiFi scan failed: %s", esp_err_to_name(scan_err));
}
} // end if (current_rssi < ROAMING_RSSI_THRESHOLD)
} else {
ESP_LOGW(TAG, "Failed to get current AP info. Maybe disconnected?");
current_rssi = -127; // Reset RSSI
// is_connected should be false if disconnected, handled by event handler
}
// Wait before next check
vTaskDelay(pdMS_TO_TICKS(ROAMING_CHECK_INTERVAL_MS));
} // end while(1)
}
5. Initialization and Task Creation (app_main
)
// main.c
// --- WiFi Credentials ---
#define EXAMPLE_ESP_WIFI_SSID "YOUR_NETWORK_SSID" // <<< CHANGE THIS
#define EXAMPLE_ESP_WIFI_PASS "YOUR_NETWORK_PASSWORD" // <<< CHANGE THIS
// Event group handle defined globally or passed appropriately
EventGroupHandle_t s_wifi_event_group;
const int WIFI_CONNECTED_BIT = BIT0;
const int WIFI_FAIL_BIT = BIT1;
// Basic WiFi Init (modified slightly)
static void wifi_init_sta(const char* ssid, const char* password) {
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
// Register event handlers
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&wifi_event_handler,
NULL,
&instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&wifi_event_handler,
NULL,
&instance_got_ip));
// Set WiFi configuration (SSID/Password, but initially connect to any BSSID)
wifi_config_t wifi_config = {
.sta = {
//.ssid = EXAMPLE_ESP_WIFI_SSID, // Set below
//.password = EXAMPLE_ESP_WIFI_PASS, // Set below
.scan_method = WIFI_FAST_SCAN, // Or WIFI_ALL_CHANNEL_SCAN
.sort_method = WIFI_CONNECT_AP_BY_SIGNAL,
.threshold.authmode = WIFI_AUTH_WPA2_PSK, // Adjust as needed
.bssid_set = false, // Connect to any AP with the SSID initially
},
};
strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid) -1);
strncpy((char *)wifi_config.sta.password, password, sizeof(wifi_config.sta.password) -1);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "wifi_init_sta finished.");
ESP_LOGI(TAG, "Connecting to SSID: %s", ssid);
esp_wifi_connect(); // Start initial connection attempt
}
void app_main(void)
{
// Initialize NVS
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_LOGI(TAG, "ESP32 WiFi Roaming Example");
// Initialize WiFi and start connection attempt
wifi_init_sta(EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS);
// Create the roaming task
xTaskCreate(&wifi_roaming_task, "wifi_roam_task", 4096, NULL, 5, NULL); // Increased stack size
// Main task can do other things or just idle
while(1) {
vTaskDelay(pdMS_TO_TICKS(60000)); // Idle
}
}
6. Build, Flash, and Observe
- Environment: You need at least two Access Points configured with the exact same SSID, security type (e.g., WPA2-PSK), and password, connected to the same network. Place them such that the ESP32 can be moved between their coverage areas.
- Configure: Change
EXAMPLE_ESP_WIFI_SSID
andEXAMPLE_ESP_WIFI_PASS
in the code to match your network. AdjustROAMING_RSSI_THRESHOLD
(e.g., -70, -75) andROAMING_MIN_RSSI_IMPROVEMENT
(e.g., 5, 10) if needed. - Build: Use
ESP-IDF: Build your project
. - Flash: Use
ESP-IDF: Flash your project
. - Monitor: Open the Serial Monitor (
ESP-IDF: Monitor your device
). - Test:
- Place the ESP32 close to AP1. Observe the initial connection, the BSSID of AP1, and the reported RSSI in the roaming task logs.
- Slowly move the ESP32 away from AP1 and towards AP2.
- Observe the RSSI values decreasing in the logs.
- When the RSSI drops below
ROAMING_RSSI_THRESHOLD
, observe the scan initiation. - The logs should show candidate APs found (including AP2).
- If AP2’s signal is sufficiently stronger than AP1’s current signal (by at least
ROAMING_MIN_RSSI_IMPROVEMENT
), observe the “Initiating roam” message. - Watch for the connection events as the ESP32 attempts to connect to AP2’s BSSID.
- If successful, the roaming task logs should start reporting connection to AP2’s BSSID with a better RSSI.
Tip: Use a WiFi analyzer app on your phone to visualize the coverage areas and signal strengths of your APs to better understand the test environment.
Variant Notes
The core ESP-IDF APIs used for monitoring (esp_wifi_sta_get_ap_info
), scanning (esp_wifi_scan_start
), and connection control (esp_wifi_set_config
, esp_wifi_connect
) are consistent across the ESP32 variants supporting WiFi station mode:
- ESP32
- ESP32-S2
- ESP32-S3
- ESP32-C3
- ESP32-C6
- ESP32-C5
- ESP32-C61
Therefore, the scan-based roaming technique described here should work similarly across these platforms. Performance aspects like scan duration or connection time might vary slightly due to hardware differences, but the fundamental logic remains applicable. This basic roaming method does not rely on specific hardware acceleration features for advanced roaming protocols (802.11k/v/r).
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
Incorrect Roaming Thresholds | Device roams too often (“ping-ponging”) or stays on a weak AP for too long. ROAMING_RSSI_THRESHOLD too high/low, or ROAMING_MIN_RSSI_IMPROVEMENT too small. |
Tune thresholds empirically. Start with ROAMING_RSSI_THRESHOLD ~-70 to -75dBm and ROAMING_MIN_RSSI_IMPROVEMENT ~5 to 10dB. Implement hysteresis (require RSSI below threshold for multiple checks). |
Scanning Issues in Roaming | Roaming scans are too frequent (power drain), too slow (misses roaming window), or don’t find the target APs. Scan results not filtered for the current SSID. | Adjust ROAMING_CHECK_INTERVAL_MS . Ensure wifi_scan_config_t.ssid is set to the current network’s SSID to target the scan. Use active scan type. For blocking scans, ensure the task can afford to block. |
Not Targeting BSSID During Roam | ESP32 attempts to roam but reconnects to the same weak AP it was trying to leave, or another undesired AP with the same SSID. | When initiating a roam to a chosen candidate AP, ensure wifi_config.sta.bssid_set = true and wifi_config.sta.bssid is correctly populated with the candidate AP’s MAC address before calling esp_wifi_set_config() and esp_wifi_connect() . |
Poor Handling of Roaming Connection Failures | If connection to the new candidate AP fails, the device gets stuck in a disconnected state or behaves unpredictably. | In attempt_roam_to_ap , check event group bits for WIFI_FAIL_BIT or timeout. If roam fails, implement fallback: try reconnecting to the original SSID without a BSSID target, or revert to the previous AP’s BSSID if known and potentially still in range. |
Inconsistent AP Configurations | Roaming fails or is unreliable because APs in the ESS do not have the exact same SSID, security type (e.g. WPA2-PSK), and password. Or, APs are on different IP subnets. | Meticulously verify all AP configurations. Ensure they form a true ESS. Test individual connectivity and DHCP to each AP separately first. |
State Management Errors | Global variables for current_ssid , is_connected , current_rssi , current_bssid are not updated correctly in the event handler or roaming task, leading to flawed logic. |
Ensure these state variables are accurately updated upon connection, disconnection, and successful roaming. Use mutexes if these variables are accessed from multiple tasks concurrently (though the example uses a single roaming task and event handler context). |
Scan While Not Fully Disconnected (Implicit Disconnect) | Initiating a scan while still technically associated can sometimes be slower or less reliable. esp_wifi_connect() to a new BSSID should handle the disconnect from the old one. |
The provided example relies on esp_wifi_connect() to handle the switch. Explicitly calling esp_wifi_disconnect() before setting new config and connecting to new BSSID is an alternative but might add slight delay. Test both approaches if issues arise. |
Exercises
- RSSI Hysteresis: Modify the roaming logic to add hysteresis. Instead of just checking
current_rssi < ROAMING_RSSI_THRESHOLD
, require the RSSI to be below this threshold for, say, 2 or 3 consecutive checks before initiating a scan. This prevents triggering scans due to brief signal fluctuations. - Failed Roam Handling: Improve the
attempt_roam_to_ap
function’s failure handling. If the roaming connection fails (timeout or disconnect event), explicitly setwifi_config.sta.bssid_set = false
, callesp_wifi_set_config()
, and then callesp_wifi_connect()
to try and reconnect to any available AP with the correct SSID, rather than potentially being stuck in a disconnected state. - Scan Parameter Tuning: Experiment with different values for
scan_time.active.min
andscan_time.active.max
in thewifi_scan_config_t
used during roaming scans. Observe the impact on scan duration (using timestamps in logs) and the reliability of finding candidate APs. TryWIFI_ALL_CHANNEL_SCAN
vsWIFI_FAST_SCAN
if your AP channels are known.
Summary
- WiFi Roaming allows devices to switch between Access Points within the same network (same SSID, ESS) to maintain connectivity.
- Client-driven, scan-based roaming is a practical approach for ESP32.
- Roaming is typically triggered by low RSSI from the current AP.
- The process involves monitoring RSSI, scanning for alternative APs (same SSID), evaluating candidates (stronger signal), and connecting to the best alternative (targeting its BSSID).
- Key ESP-IDF functions include
esp_wifi_sta_get_ap_info
,esp_wifi_scan_start
,esp_wifi_set_config
(withbssid_set=true
), andesp_wifi_connect
. - Careful selection of thresholds and handling of scan results and connection failures are crucial for robust roaming.
- Roaming requires a properly configured network environment with multiple APs sharing the same credentials and network infrastructure.
Further Reading
- ESP-IDF WiFi Library Documentation: https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32/api-reference/network/esp_wifi.html
- ESP-IDF WiFi Scan Example:
$IDF_PATH/examples/wifi/scan
- 802.11 Roaming Concepts (General): Search for articles explaining WiFi roaming, ESS, BSSID, and protocols like 802.11k/v/r for broader context (though advanced protocols are not implemented here).
