WiFi connection manager + ArduinoOTA + web filesystem browser + status page for ESP8266 and ESP32 (Arduino framework).
One object pumps the whole "keep this device on the network and serviceable" job: connect to the first reachable of up to three SSIDs, auto-reconnect, rotate APs on timeout, (optionally) ping the router and reboot a wedged link, set the clock over NTP, rotate logs, run ArduinoOTA, and serve a SPIFFS/LittleFS/SD file browser with log views — plus connect/disconnect/time-set/log-clear/ping callbacks.
Heads-up — this is opinionated application glue, not a tiny utility. It expects the rest of the Lion* family (LionLogger, LionArray, LionStreams, and LionTask/LionRtosTask) and that you provide three globals. Read the contract below before wiring it in.
PlatformIO:
lib_deps = leva/LionWifiThe Lion* dependencies (LionArray, LionLogger, LionStreams, and LionTask on
ESP8266 / LionRtosTask on ESP32) are resolved automatically. The ESP32 async
web path additionally needs ESPAsyncWebServer + AsyncTCP — add those
yourself, or build with -D NO_ASYNC_WEB_SERVER to use the core WebServer.
LionWifi only declares these; your sketch defines them:
#include <Logger.h>
#include <WifiConnector.h>
MyLogger Logger(ILogger::SerialPort | ILogger::Spiffs, ILogger::LvlDebug); // LionLogger
#ifdef ESP32
AsyncWebServer server(80); // or WebServer with -D NO_ASYNC_WEB_SERVER
#else
ESP8266WebServer server(80);
#endif
WifiConnector *_connector;void setup() {
Serial.begin(115200);
Logger.Setup(); // mounts the filesystem, starts logging
_connector = new WifiConnector("ssid", "pwd" /*, "ssid2","pwd2", "ssid3","pwd3" */);
_connector->RegisterConnectedEvent([]{ /* ... */ });
_connector->Setup();
}
void loop() {
#if !defined(ESP32) || defined(NO_WIFI_TASK)
_connector->Loop(); // ESP8266: pump from loop()
#endif // default ESP32: runs in its own FreeRTOS task
}Then browse http://<device-ip>/. Upload examples/LionWifiBasic/data/index_nosd.html
to the filesystem as /index_nosd.html for a home page, or go straight to
/spiffs/ls.
Register these on the WifiConnector (before or after Setup()):
| Method | Fires |
|---|---|
RegisterConnectedEvent(void()) |
WiFi connected |
RegisterDisconnectedEvent(void()) |
WiFi lost |
RegisterTimeSetEvent(void()) |
NTP time acquired |
RegisterClearLogEvent(void()) |
old logs cleared |
RegisterPingEvent(void()) |
periodic tick (SetPingTime(ms)) |
RegisterOtaStartEvent(void(bool sketchUpload)) |
OTA begins, after LionWifi unmounts the FS |
RegisterOtaProgressEvent(void(unsigned progress, unsigned total)) |
every OTA progress callback (raw bytes) |
RegisterOtaEndEvent(void(bool ok)) |
OTA finished — ok=true success, ok=false error |
The OTA hooks let a consumer quiesce a heavy peripheral (e.g. stop an
ESP32-HUB75 I2S-DMA matrix that would otherwise starve the OTA transfer) for the
duration of an update, while LionWifi keeps owning the FS unmount/remount and
progress/error logging. sketchUpload is true for a sketch (U_FLASH) and
false for a filesystem image (fixed for the whole session). The end hook fires
on success and on error; on error there is no auto-reboot, so restore what
you quiesced. See examples/LionWifiFull.
Two independent mechanisms:
-
ArduinoOTA (always on) — the usual
espota/PlatformIOupload_protocol = espotaflow. Note it's a reverse connection: the host invites the device, then the device connects back to the host. That fails across NAT / separate subnets / VPN segments where the device can't reach the host. -
HTTP OTA (opt-in,
-D LIONWIFI_HTTP_OTA) — a/updateendpoint that accepts a forward firmware POST (host → device), so it works wherever the device's web UI is reachable, including across NAT. Upload from a browser (openhttp://<device>/update, which offers a sketch and a filesystem form) or curl:# sketch curl -u user:pass -F "fw=@.pio/build/<env>/firmware.bin" http://<device>/update # filesystem image (SPIFFS/LittleFS) curl -u user:pass -F "filesystem=@.pio/build/<env>/spiffs.bin" http://<device>/update
Or drive it from PlatformIO with a dedicated env that reuses your normal build but uploads over HTTP (so
espotastays the default for[env:release]):[env:release-http] extends = env:release ; same board / flags / lib_deps as the real build upload_protocol = custom upload_port = 192.168.1.50 ; device IP, exposed to the command as $UPLOAD_PORT upload_command = curl -u user:pass -F "fw=@$SOURCE" http://$UPLOAD_PORT/update
Then
pio run -e release-http -t uploadbuilds and flashes over HTTP; for a filesystem image,pio run -e release-http -t buildfsthen curl the builtspiffs.bin/littlefs.binwith-F "filesystem=@...". Notes:- Bootstrap once over
espota/USB —/updateonly exists after a build with-D LIONWIFI_HTTP_OTAis on the device; after that, HTTP OTA reflashes itself. - PlatformIO warns that an IP
upload_port"looks likeespota" — harmless, thecustomprotocol still runs yourupload_command. (Dropupload_portand hard-code the IP in the command to silence it.)
Hand-rolled on every backend (ESP8266, ESP32-sync, ESP32-async): it drives the
Updateobject directly, so theRegisterOta*hooks fire and progress is logged through the globalLogger(HTTP OTA: start … (sketch|FS)/… OK) everywhere. The FS target is chosen by?fs=1(and, on the sync servers, thefilesystemupload field name); it unmounts the FS and flashes the FS partition. Auth viaWEB_SERVER_AUTH_*(orNO_AUTH); the device reboots on success.Note: sketch OTA is tested; filesystem-image OTA is not yet verified on hardware — treat it as experimental until confirmed.
- Bootstrap once over
Define one of these (a build with zero or both is a compile #error):
build_flags = -D USE_SPIFFS ; or: -D LFS (LittleFS)Override via build_flags. The full list lives in the header banner of
WifiConnector.h; the ones you most likely want:
| Define | Default | Purpose |
|---|---|---|
USE_SPIFFS / LFS |
— | Filesystem (define exactly one) |
WEB_SERVER_AUTH_USER / WEB_SERVER_AUTH_PASSWORD |
admin/admin |
HTTP Basic auth — change these |
NO_AUTH |
off | Disable HTTP auth entirely |
LIONWIFI_HTTP_OTA |
off | Add a /update firmware-upload endpoint (forward POST) |
NTP_TZ_OFFSET_SEC / NTP_SERVER |
7*3600 / pool.ntp.org |
Time zone offset / NTP host |
WIFI_CONNECT_TIMEOUT |
20000 |
Per-AP attempt (ms) before rotating SSID |
WIFI_FATAL_CONNECT_TIMEOUT |
180000 |
Reboot after this long disconnected (ms) |
PING_ROUTER |
off | Router IP/host → enable ping-and-reboot watchdog |
WIFI_BEST_AP |
off | Associate with the strongest point carrying the SSID, not the first one found. For meshes/extenders/a second router; costs a channel sweep (~2 s) per association attempt |
LIONWIFI_GARP_INTERVAL_MS |
0 (off) |
Broadcast a gratuitous ARP on association and every N ms, so the whole L2 domain learns which access point we are on. Also logs a silent roam. 120000 is a sane value |
LIONWIFI_PREFERRED_AP_FILE |
/wifi_ap.cfg |
Where the operator's preferred access point is stored (see /aps). Runtime choice, not a build flag — moving a node must not mean recompiling it |
LIONWIFI_AP_SWITCH_DELAY_MS |
500 |
Grace period between answering /aps/set and actually re-associating, so the answer reaches the browser first |
LIONWIFI_NO_AP_PAGE |
off | Drop /aps (the scan and ~2 KB of markup). The file, /aps/set, /aps/clear and the C++ API stay |
LIONWIFI_NO_PREFERRED_AP |
off | Drop the file and the endpoints too (implies the above). Aiming at a BSSID via SetPreferredAp() is always compiled in |
LIONWIFI_AP_NAMES |
off | Labels for access points, shown next to the BSSID on /aps, in the status line and in the connect/roam log lines. One string: -D LIONWIFI_AP_NAMES=\"aa:bb:cc:dd:ee:ff=Hub;11:22:33:44:55:66=Main\". No spaces, no ;/= inside names, ASCII. Lives in PROGMEM — no DRAM on the ESP8266 |
NO_WIFI_BSSID_IN_STATUS |
off | Drop the AP <bssid> field from the status line |
LIONWIFI_CLOCK_JUMP_SEC |
30 |
Warn when the wall clock moves on its own by more than this many seconds (uptime and clock are compared once a minute). 0 compiles it out |
QUIET_WIFI_LOGS |
off | Suppress connect/reconnect log lines |
NO_WIFI_TASK (ESP32) |
off | Run Loop() from your loop() instead of a task |
NO_ASYNC_WEB_SERVER (ESP32) |
off | Use core WebServer instead of ESPAsyncWebServer |
USE_SD_CARD [+ SDFAT] |
off | Also browse an SD card (SdFat with SDFAT) |
LIONWIFI_NAME_MAX |
31 on SPIFFS w/o SD, else 63 | Max listed filename length (bytes). SPIFFS caps object names at 31, so a longer buffer is padding on every entry; raise for long LittleFS/SD names |
LIONWIFI_LS_EXACT_ALLOC |
off | Count the directory first and allocate the listing array exactly once, instead of growing it by doubling (which keeps both buffers alive and roughly triples the peak). Costs a second directory walk — for heap-tight nodes |
LIONWIFI_NO_ARDUINO_OTA |
off | Compile ArduinoOTA (espota) out — drops it and the mDNS responder it starts, ~34 KB of flash on ESP32. Use with LIONWIFI_HTTP_OTA |
NO_MEMSTAT_IN_STATUS |
off | Drop heap/frag/stack stats from the status page (also the stack low-water tracking) |
NO_WIFI_STAT_IN_STATUS |
off | Drop the WiFi RSSI/quality/channel line from the status page (shown by default) |
FS_BROWSER_CSS |
built-in | Replace the file-browser stylesheet (string literal) |
NO_FS_BROWSER_CSS |
off | Drop the file-browser stylesheet — saves ~2 KB flash |
The status page's WiFi line carries a wifi-status CSS class (alongside
global-status) so you can style it; it is emitted only while connected.
The directory page (/spiffs/ls, /sd/ls) ships a small light/dark stylesheet
that costs ~2.0 KB of flash (the CSS string is ~2012 bytes). Three ways to
change it, cheapest first:
- Re-theme — every color is a CSS custom property (
--fs-bg,--fs-fg,--fs-card,--fs-line,--fs-muted,--fs-accent,--fs-danger; each has a dark value). Prepend a:root{…}override and keep the default rules:(or just definebuild_flags = -D FS_BROWSER_CSS='":root{--fs-accent:#0aa}" DEFAULT_FS_BROWSER_CSS'
FS_BROWSER_CSSin a force-included config header). - Replace the whole sheet with your own —
-D FS_BROWSER_CSS='"…css…"'. Flash cost becomes the length of your string. Keep the class names (.fs-wrap,.fs-table,td.name/.size/.time/.actions,tr.dir,.fs-link,.act,.act-del,.fs-summary,.fs-home,.fs-btn) so the markup matches. - Turn it off —
-D NO_FS_BROWSER_CSSemits no<style>; the page falls back to the browser's plain default table (still fully functional) and you reclaim the full ~2 KB. The full class list is documented inFsBrowser.h.
Copy-paste examples for all three (as #defines or build_flags) are in
examples/LionWifiFull — the macros must be set before <WifiConnector.h>.
/ (home) · /spiffs/ls (list + upload) · /tail/<f> · /download/<f> ·
/spiffs/<f> · /ren/<f>?to=<new> (refuses to overwrite an existing target) ·
/del<f> · /log, /log/tail, /spiffs/log[/tail]
(LionLogger's current-day log) · /restart · /format (ESP8266 / ESP32-sync) ·
/update (firmware upload, with LIONWIFI_HTTP_OTA) ·
/logout · /favicon.ico · /lion-tasks (ESP8266). With USE_SD_CARD:
/sd/ls, /sd/tail/<f>, /sd/download/<f>, /sd/ren/<f>?to=<new>,
/sd/del/<f> (flat — no subfolders).
/aps scans and lists every point in the air (SSID, BSSID, channel, RSSI) and puts a
use link on the rows that belong to a network this node is configured for. Where
several points share one SSID, that is the only practical way to learn their BSSIDs —
routers rarely show them, and the node is standing right there.
| Endpoint | Does |
|---|---|
GET /aps |
The scan page (auth like everything else). Marks the point we are on now (●) and the preferred one (green) |
GET /aps/set?bssid=<mac>[&n=<idx>|&ssid=<name>] |
Prefer that point and re-associate at once. n= indexes the SSID list, ssid= names it, neither = the network in use. Answers plain text (the page calls it with fetch and stays on /aps); 400 with a reason on a bad MAC or index |
GET /aps/clear |
Forget the preference (removes the file too) |
Nothing links to /aps — this library renders no navigation — so add your own link if
you want one, or drive SetPreferredAp() / ClearPreferredAp() / GetPreferredAp()
from your own settings page.
The feature comes in three levels, because the parts cost very different amounts.
Aiming an association at a BSSID is always compiled in — empty by default, and an
empty preference behaves exactly as if the feature did not exist, so a consumer can point
it somewhere from its own config without any flag. LIONWIFI_NO_AP_PAGE drops /aps
alone (the scan and ~2 KB of markup — the expensive part), keeping the file and the
endpoints. LIONWIFI_NO_PREFERRED_AP drops those as well and leaves only the in-RAM
aiming; note that without the file a choice lives until the next reboot, and without the
endpoints nothing re-associates to verify it.
Two things worth knowing. The scan is asynchronous, so a visit takes two loads: the
first starts it and the page reloads itself four seconds later, the second shows the list
and frees it (which is why every visit scans afresh). It has to be asynchronous — a
blocking scan inside an async request handler runs in the AsyncTCP task and resets the
node. The station still goes off-channel for the sweep, so traffic stalls either way:
open the page by hand, do not poll it. And choosing a point
re-associates immediately — the link drops for a few seconds, and that is deliberate:
a BSSID clicked by mistake fails while somebody is watching instead of days later at the
next reconnect. The page calls the endpoint with fetch, so the address stays on /aps,
and reloads itself five seconds later; if the node needs longer than that, refresh once
more. An attempt aimed at a point that does not answer falls back to the normal rule and
is retried after the next successful connect, so a point that has gone away costs one
timeout, not every retry.
Enable with -D USE_SD_CARD (Arduino SD) or -D USE_SD_CARD -D SDFAT (SdFat,
add greiman/SdFat). You initialize the card yourself — LionLogger only
touches the internal filesystem (see the LionWifiSd example). With SDFAT,
define the SdFat SD; global the library extern-declares, build SdFat in its
default mode (FsFile, FAT+exFAT), and add -D USE_UTF8_LONG_NAMES=1 so
non-ASCII filenames aren't shown as ?.
SDFAT is supported only on the ESP32 async server (it streams via a chunked
response; FsFile is not an fs::FS). On ESP8266 or -D NO_ASYNC_WEB_SERVER,
use the Arduino SD library (USE_SD_CARD without SDFAT) — the build #errors
otherwise. SD listings are flat (root only — no subfolder navigation).
- Single-instance, non-copyable. Create one via
newand assign to_connector; it owns the WiFi/HTTP clients, the routes, and (ESP32) a task. - On the ESP32 async path the directory listing is built in one
Stringbefore sending (no per-row chunking yet); fine for typical filesystems. - The
/formatroute is omitted on the ESP32 async path (formatting inside an async callback trips the task watchdog). - State-changing routes use GET (
/del…,/format,/restart) and there is no CSRF token. Keep HTTP Basic auth enabled (don't setNO_AUTH) and/or run the device on a trusted network — treat the web UI as an admin console. tailis for text/logs: it serves the last 8 KB astext/plain, so a binary file's tail is truncated at the first NUL. Use Download for binary files.- Listed filenames longer than
LIONWIFI_NAME_MAXbytes are truncated (and their links then 404); raise the flag for long UTF-8 names.
0BSD — see LICENSE.