ESP32 WiFi manager library for Arduino. Handles multi-credential storage, automatic reconnection, captive portal setup, and intelligent roaming between access points.
- Multi-credential storage — Save up to 10 WiFi networks in NVS, auto-connect to the strongest available
- Fast connect — Caches BSSID + channel for ~200ms reconnect instead of ~3s scan-based
- Captive portal — Web-based setup UI with custom parameters
- Roaming — Automatic BSSID switching (mesh/multi-AP) and cross-SSID handover based on signal strength
- WiFi on/off control —
enable()/disable()to toggle WiFi, with pre-switch callback for graceful cleanup - Non-blocking — All operations use
millis()-based timers, safe for real-timeloop()tasks - Resilient state machine — Timeout guards on every state, exponential backoff, no dead ends
#include <PolyWiFi.h>
PolyWiFi wifi;
void setup() {
Serial.begin(115200);
wifi.setAPName("MyDevice-Setup");
wifi.begin();
}
void loop() {
wifi.loop();
}On first boot (no saved credentials), a captive portal opens automatically. Connect to the "MyDevice-Setup" WiFi, open 192.168.4.1, and configure your network.
Add to platformio.ini:
[env:esp32]
platform = espressif32
framework = arduino
board = esp32dev
lib_deps =
bblanchon/ArduinoJson@^7.4.2All settings have sensible defaults. Call setters before begin().
wifi.setAPName("MyDevice-Setup"); // Portal AP name (default: "PolyWiFi-Setup")
wifi.setAPPassword("secret"); // Portal AP password (default: open)
wifi.setConnectTimeout(15000); // Per-credential connect timeout in ms (default: 15000)
wifi.setPortalTimeout(300000); // Auto-close portal after ms (default: 300000 = 5min)
wifi.setMaxCredentials(10); // Max stored networks (default: 10)
wifi.setMinSignalQuality(10); // Min signal quality 0-100 (default: 10, maps to RSSI)
wifi.setAutoReconnect(true); // Auto-reconnect on disconnect (default: true)
wifi.setFastConnectTimeout(3000); // Fast connect attempt timeout in ms (default: 3000)
wifi.setPortalTitle("My Device"); // Title shown in portal UI
wifi.setDoneMessage("Saved! Rebooting..."); // Message after config saveRoaming is disabled by default. When enabled, it monitors signal strength and switches to a better access point when the current signal degrades. It handles two cases:
- BSSID roaming — Same SSID, different AP (e.g. mesh networks, enterprise WiFi)
- Cross-SSID roaming — Different saved SSID with better signal (e.g. walking from home WiFi into mobile hotspot range)
wifi.setRoaming(true, -75); // Enable roaming, trigger scan below -75 dBm
wifi.setRoamingHysteresis(10); // Candidate must be 10 dB better (default: 10)
wifi.setRoamingCooldown(60000, 30000); // Cooldown: 60s after switch, 30s after no-switchRoaming uses the ESP32's hardware RSSI threshold event — zero CPU cost while signal is good. Scans only trigger when signal drops below the threshold.
Here are some example configurations for different use cases:
Stationary sensor (battery-powered, never moves):
// No roaming needed — save energy
wifi.setRoaming(false);
wifi.setAutoReconnect(true);Stationary device with mesh network (plugged in, multiple APs same SSID):
// Conservative BSSID roaming, no rush
wifi.setRoaming(true, -80); // Only scan when signal is quite bad
wifi.setRoamingHysteresis(15); // Large hysteresis — don't switch for small gains
wifi.setRoamingCooldown(120000, 120000); // 2 min cooldowns — save energyMobile device (robot, vehicle, handheld):
// Aggressive roaming — fast handover is critical
wifi.setRoaming(true, -65); // Scan early while signal is still usable
wifi.setRoamingHysteresis(6); // Switch even for moderate improvements
wifi.setRoamingCooldown(30000, 15000); // Short cooldowns — keep checking
wifi.setFastConnectTimeout(2000); // Shorter fast-connect attemptMulti-zone coverage (home + mobile hotspot + office):
// Cross-SSID is the main feature here
wifi.setRoaming(true, -75); // Default threshold
wifi.setRoamingHysteresis(10); // Switch when new network is clearly better
wifi.setRoamingCooldown(60000, 30000); // Moderate cooldownsSeed WiFi credentials in code. Useful for factory provisioning, fallback networks, or headless devices without portal.
wifi.addCredential("HomeWiFi", "password123");
wifi.addCredential("Office", "secret");
wifi.addCredential("iPhone-Hotspot", "12345678");
wifi.begin(); // Connects to strongest known networkSeeded credentials are stored in NVS like portal-added ones. Duplicates are skipped automatically — safe to call on every boot.
To clear all stored credentials (factory reset):
wifi.resetCredentials();Add custom configuration fields to the portal UI. Values are stored in NVS and persist across reboots.
PolyWiFi wifi;
// Create params — constructor auto-registers with PolyWiFi
PolyWiFiParam header(&wifi, PW_HEADER, "cfg_hdr", "Device Configuration");
PolyWiFiParam nickname(&wifi, PW_INPUT, "nickname", "Device Name", "", "My Device");
PolyWiFiParam token(&wifi, PW_PASSWORD, "api_token", "API Token");
PolyWiFiParam role(&wifi, PW_SELECT, "role", "Role", "sensor", "sensor|actuator|gateway");
PolyWiFiParam debug(&wifi, PW_CHECKBOX, "debug", "Debug Mode", "false");
PolyWiFiParam notes(&wifi, PW_TEXTAREA, "notes", "Notes");
void setup() {
wifi.begin();
}
void loop() {
wifi.loop();
// Read parameter values
String name = wifi.getParam("nickname");
String selectedRole = wifi.getParam("role");
bool debugMode = wifi.getParam("debug") == "true";
}| Type | Description | Default value | Options field |
|---|---|---|---|
PW_INPUT |
Text input | Any string | Placeholder text |
PW_PASSWORD |
Password input | Any string | Placeholder text |
PW_TEXTAREA |
Multi-line text | Any string | Placeholder text |
PW_CHECKBOX |
Checkbox | "true" or "false" |
— |
PW_SELECT |
Dropdown | One of the options | Pipe-separated: "opt1|opt2|opt3" |
PW_HEADER |
Section header (visual only, no value) | — | — |
PW_DIVIDER |
Visual divider (visual only, no value) | — | — |
// Called on every state change
wifi.onStateChange([](pw_state_t state) {
Serial.printf("State: %d\n", state);
});
// Called when credentials are saved from portal
wifi.onCredentialsSaved([]() {
Serial.println("New WiFi saved!");
});
// Called on portal events (started, stopped, timeout, etc.)
wifi.onPortalActivity([](pw_portal_event_t event) {
if (event == PW_EVT_PORTAL_TIMEOUT) {
Serial.println("Portal timed out");
}
});
// Called before WiFi switches AP or disconnects
wifi.onBeforeSwitch([](pw_switch_reason_t reason, const char* newSSID) -> bool {
Serial.printf("WiFi switching (reason=%d)\n", reason);
// Clean up connections before WiFi goes away
ws.closeAll();
return true; // return false to cancel the switch
});| Reason | Description | Cancellable |
|---|---|---|
PW_SWITCH_ROAMING_BSSID |
Better AP found on same SSID | Yes |
PW_SWITCH_ROAMING_SSID |
Better saved network found | Yes |
PW_SWITCH_RECONNECT |
Connection lost | No (already disconnected) |
PW_SWITCH_DISABLE |
disable() called |
Yes |
wifi.disable(); // Turns off WiFi radio, state machine paused
wifi.enable(); // Reconnects to best known network
wifi.isEnabled(); // true if WiFi is not disabledwifi.isConnected(); // true if connected to a network
wifi.isPortalActive(); // true if captive portal is running
wifi.getState(); // Current state (pw_state_t enum)
wifi.localIP(); // IP address as String
wifi.ssid(); // Connected SSID
wifi.rssi(); // Current signal strength in dBmIDLE ──→ SCANNING ──→ CONNECTING ──→ CONNECTED
↑ │ │ │
│ ↓ ↓ ↓
│ PORTAL_ACTIVE ← (all failed) RECONNECTING
│ │ │
│ ↓ │
│ PORTAL_SAVING │
│ │ │
└──────────┴────────────────────────────┘
Any state ──→ DISABLED ──→ (enable) ──→ connect flow
| State | Description |
|---|---|
PW_IDLE |
No connection, retries periodically if credentials exist |
PW_SCANNING |
Scanning for saved networks |
PW_CONNECTING |
Attempting connection to a network |
PW_CONNECTED |
Connected, roaming active if enabled |
PW_RECONNECTING |
Lost connection, scanning for networks |
PW_PORTAL_STARTING |
Portal initializing |
PW_PORTAL_ACTIVE |
Captive portal running |
PW_PORTAL_SAVING |
Testing connection from portal submission |
PW_DISABLED |
WiFi off, state machine paused (disable() / enable()) |
| Event | Description |
|---|---|
PW_EVT_PORTAL_STARTED |
Portal opened |
PW_EVT_PORTAL_STOPPED |
Portal closed |
PW_EVT_PORTAL_TIMEOUT |
Portal auto-closed after timeout |
PW_EVT_SCAN_REQUESTED |
User triggered scan in portal UI |
PW_EVT_CONFIG_SUBMITTED |
User submitted WiFi config |
PW_EVT_CONFIG_SUCCESS |
Connection test succeeded |
PW_EVT_CONFIG_FAILED |
Connection test failed |
PW_EVT_PARAMS_SAVED |
Custom parameters saved (triggers reboot) |
PW_EVT_EXIT_REQUESTED |
User pressed exit in portal UI |
| RSSI | Signal Quality |
|---|---|
| -30 to -50 dBm | Excellent |
| -50 to -60 dBm | Very good |
| -60 to -70 dBm | Good |
| -70 to -80 dBm | Moderate |
| -80 to -90 dBm | Poor |
| Below -90 dBm | Unusable |
// Open portal on button press (toggle)
if (buttonPressed) {
if (wifi.isPortalActive()) {
wifi.stopPortal();
} else {
wifi.startPortal();
}
}-
Boot — Loads saved credentials from NVS. If the last-connected network has cached BSSID+channel, attempts fast connect (~200ms). Otherwise scans for the strongest saved network.
-
Connection failure — Cycles through saved credentials sorted by signal strength. If all fail, opens the captive portal.
-
Connected — Monitors connection. If roaming is enabled, listens for the ESP32 hardware RSSI-low event. When triggered, scans for better APs (same SSID or other saved SSIDs). Switches if a significantly better option exists.
-
Disconnect — Immediately scans and reconnects. If reconnection fails within 30s, opens the portal.
-
Portal — Runs a web server on
192.168.4.1with DNS captive portal redirect. Users can add/remove networks, configure custom parameters, or exit the portal to reconnect.
PolyWiFi adds approximately ~120 KB Flash and ~2 KB RAM on top of the base ESP32 Arduino + WiFi framework.
| Flash | RAM | |
|---|---|---|
| Base (Arduino + WiFi) | 722 KB | 43 KB |
| With PolyWiFi | 844 KB | 46 KB |
| PolyWiFi overhead | ~120 KB | ~2 KB |
Fits comfortably on all ESP32 variants (ESP32, ESP32-S2, S3, C3, C6) with the default 4 MB flash partition layout. Only external dependency is ArduinoJson — the web server uses the built-in WebServer.h.
The committed platformio.ini targets a generic esp32dev board and only compiles the examples/CompileCheck sketch — it exists for CI and quick compilation checks, not for flashing real hardware.
To develop and test on your own board, create a local platformio_dev.ini (git-ignored) tailored to your setup:
[platformio]
src_dir = examples/Basic
[env:myboard]
platform = espressif32@^6.13.0
board = seeed_xiao_esp32s3 ; ← your board
framework = arduino
build_flags = -DARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
bblanchon/ArduinoJson@^7.4.2
; add display libs etc. as needed
monitor_speed = 115200
lib_extra_dirs = .Build and flash with:
pio run -c platformio_dev.ini -t uploadMonitor serial output:
pio run -c platformio_dev.ini -t monitorTip: Keep
platformio_dev.iniout of version control — it's specific to your hardware and wiring.
MIT
