Skip to content

About

A reusable, modular web-configuration server for ESP32 and ESP8266 (Arduino).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

AsyncConfigPortal

A reusable, modular web-configuration server for ESP32 and ESP8266 (Arduino). Provides HTTP digest auth, a dynamic menu, a Status page, an Other page (OTA with firmware-marker validation, restart, factory reset, password change), a Backup page that components register sections on, and a composition API for project-specific pages. Ships an optional, opt-in network-config module that adds no mandatory dependency.

See docs/DESIGN.md for the architecture, the design decisions behind the boundary, and the pitfalls worth knowing before you extend it, and docs/CSS.md if you want the pages to look like yours.

Dependencies

Both manifests are shipped, and they name the same libraries differently: library.json (PlatformIO) takes an owner/name, library.properties (Arduino Library Manager) takes the library's own name= field. Both are listed, because installing the wrong fork is the likeliest way to have this not build.

PlatformIO Arduino Library Manager Notes
ESP32Async/AsyncTCP Async TCP ESP32 only
ESP32Async/ESPAsyncTCP ESP Async TCP ESP8266 only
ESP32Async/ESPAsyncWebServer ESP Async WebServer the async HTTP server, both platforms
soosp/HttpDigestAuth HttpDigestAuth credential storage + digest auth

Pin the ESP32Async forks. Several incompatible async-TCP libraries sit in the Library Manager under names a glance cannot tell apart — Async TCP, ESP Async TCP, ESP AsyncTCP — and the web server declares no depends= of its own, so nothing installs the right one for you. This library's library.properties names both TCP layers for that reason; the one your board does not use is installed but never compiled, since nothing includes its header.

The optional net module additionally needs the NetworkProfile family — but only if you use it (see below).

Minimal usage

#include <AsyncConfigPortal.h>
#include <HttpDigestAuth.h>

AsyncConfigPortal web(80);
HttpDigestAuth    auth;

void setup() {
    // bring up ETH / Wi-Fi first, then:
    if (!auth.restore()) { auth.setCredentials("admin", "esp32", "admin"); auth.save(); }
    web.setProject("MyProject", "1.0.0", "desc", "2026", "author");
    web.setLogger([](AsyncConfigPortal::LogLevel lvl, const char* tag, const char* msg){
        // tag names the subsystem ("ota", ...) and can be passed straight to
        // ESP_LOGx, which accepts a runtime tag — so the log stays filterable
        // per subsystem with esp_log_level_set().
        ESP_LOGI(tag, "%s", msg);   // map lvl to ESP_LOGE/W/I/D as you like
    });
    web.begin(auth);
}

The pages carry no inline styling: they use the shared class names, so your page looks like the built-in ones for free. A house style is one call — setCssExtra(MY_CSS) appends your snippet to the stylesheet every page loads, the built-in ones included, so ten redefined colour variables restyle the whole portal; setCss() replaces the sheet outright if you want that far. docs/CSS.md documents both, and the class contract.

A form can be posted two ways.

  • In the page: onsubmit="return pfSubmit(this, {map: MAP})" (from /common.js) posts the form without leaving, and the handler answers with sendResult(req, code, "ok") or the name of what it refused — with the item's index, a message and a restart flag where they apply. The answer appears in a <span class="pmsg"> below the button, a refused field is outlined where map names it, and what the user typed stays. A restart flag covers the page until the device is back, then opens it again (or restartUrl); a url in the answer (a new static address) is offered as a link to try. A file upload shows its progress. pfPost(url, body, opts, ctx) does the same for an action that has no form of its own: ctx is the box holding its answer line, and opts.button the button to disable while it runs (an action button inside another form is not a submit button, or Enter in that form would press it). The built-in pages (Network, Syslog, MQTT, Backup, Other) all work this way. sendResult() builds the answer in the buffer the JSON endpoints share; a message longer than RESULT_MESSAGE_LEN goes with a buffer of the application's own, sized with resultJsonLen().
  • As a plain post, answered with the shared result pages CONFIG_PORTAL_SAVED_HTML and CONFIG_PORTAL_SAVE_FAILED_HTML: both return to the page the POST came from, so a component serves them as they are and does not name its own path. (A factory reset answers with its own page: the credentials are gone and the address may be, so there is nowhere to go back to.) pfSubmit() understands these answers too, so a handler can be moved over later.

Two things worth knowing when you compose a page:

  • CONFIG_PORTAL_HEADER / CONFIG_PORTAL_FOOTER are fragments. The footer ends after the project block and does not close </body> or </html> — your page adds those, which is what lets you put your own scripts after it.
  • addResetHandler(what, fn) registers one factory-reset target. Without any handler the button still works: the server erases what it owns (the stored credentials) and restarts. It never silently does nothing — but it also never guesses at your application's storage, so register a handler for each namespace you want cleared.

See examples/Minimal for a complete sketch. Extend the server with your own pages via addPage(), addJsonEndpoint(), addPostHandler(), addBackupSection() and addResetHandler(). A form page can build validated fields with the shared builders served at /fields.js — ifRow (text), numRow (number) and selectRow (dropdown) — the same ones the built-in pages use. examples/CustomPage is a complete, hardware-free walkthrough of composing your own page this way.

Requests from other sites

Digest authentication alone does not stop a page on another site from using the portal: once someone has logged in, their browser adds the stored credentials to any request to the device, including a form that some other page submits in the background (cross-site request forgery). That form could restart the device, change its network settings or upload firmware.

The portal therefore checks every state-changing request (anything but GET, HEAD and OPTIONS) before its handler runs: the Origin header, or without one the Referer, must name the host the request was sent to. A page cannot forge either header, and every current browser sends Origin with a POST. A request that fails gets 403 and a log line. The check covers the built-in routes, the components and everything an application adds; uploads are checked chunk by chunk as well, so a refused firmware upload writes nothing.

portal.setOriginCheck(AsyncConfigPortal::OriginCheck::Strict);   // the default
Setting A request with neither header Use
Strict refused The default. A script adds the header: curl -H "Origin: http://192.168.1.50" ...
Lenient let through Old clients or scripts that cannot set a header
Off let through, and no check at all Debugging only

A reverse proxy in front of the device must pass the original Host header, or every request looks cross-site. An upload route a subclass registers directly on server() gets the middleware check, which runs only after the upload: such a handler should call originAllowed(req, false) for each chunk itself.

The same figures in your own log

The Status page's system figures — uptime, reset reason, free heap, its minimum since boot, the largest allocatable block, the fragmentation figure, die temperature, chip, cores, clock and flash size — are read by one public, static call, so a periodic health line or a boot report in your own code shows the same numbers as the page, from the same calls:

AsyncConfigPortal::SystemStats s;
AsyncConfigPortal::systemStats(s);
ESP_LOGD("main", "up=%lu s heap=%lu min=%lu block=%lu reset=%s",
         (unsigned long)s.uptimeS, (unsigned long)s.heapFree,
         (unsigned long)s.heapMin, (unsigned long)s.heapMaxAlloc, s.resetReason);

What a platform cannot provide is flagged (hasHeapMin, hasTemp), not zeroed — on ESP8266 both are false. No portal instance is needed, and the call touches no network state.

Platform differences

The same code runs on both platforms; where the hardware differs, so does the page. Nothing here needs configuring — it is what you will see:

  • Status page. ESP8266 shows no Temperature and no Min heap row: it has neither a die-temperature sensor nor minimum-heap tracking. The rows are hidden rather than filled with zeros, because a reading that does not exist is not the same fact as a reading of zero.

  • Wi-Fi TX power (net module). A drop-down of the radio's discrete levels on ESP32; a bounded number input on ESP8266, whose output power is a continuous range.

  • Storage. Preferences is part of the core on ESP32, backed by the NVS flash partition. On ESP8266 the vshymanskyy/Preferences library provides the same API over the internal flash filesystem, storing each entry as a file under /nvs/; LittleFS is the default driver, so the build needs a filesystem in its flash layout. Its begin() takes no partition_label, and keys are limited to 15 characters.

  • Read the Status page's Max alloc, not its Fragmentation. The largest block that can be allocated in one piece is what decides whether a page is built; the percentage is 100 x (1 - max alloc / free heap) and only compares with itself under similar load. Freeing memory raises free heap without enlarging the largest block, so the figure rises while the heap improves — a reading of 45% at rest can be healthier than 13% just after a page load.

  • TLS on ESP8266 is tight. A BearSSL client reserves about 6 KB the moment it is created and its connection buffers are ~22 KB by default, against roughly 40 KB of heap in total — so a device also serving this portal will not fit both unless all three of these hold: the server agrees to MFLN (setBufferSizes(512, 512) after probeMaxFragmentLength()), HOST_FQDN_LEN is lowered (64 is ample in practice, and it shrinks the portal's largest document), and the build uses the basic SSL cipher set — Arduino IDE: Tools > SSL Support > Basic SSL ciphers; PlatformIO: build_flags = -D BEARSSL_SSL_BASIC.

    Worth knowing because the failure mode is unkind: with TLS up and too little left, pages come up blank, and the setting that caused it can no longer be changed from the portal. An application offering TLS on ESP8266 should measure the largest free block once the connection stands and back out if it is too small — examples/NetConfig shows the check. ESP32 has none of these limits.

  • RAM. ESP8266 has far less of it, and the portal is built to fit: pages stream straight out of flash and large JSON documents are streamed from their buffer rather than copied. Raising NET_CONFIG_MAX_PROFILES or NTP_PROFILE_SERVER_COUNT (1 to 3) grows the largest buffer, so raise them only as far as the device actually needs.

  • -DMQTT_CONFIG_TLS=0 removes TLS from the MQTT page: no row, no mention of 8883 in the port hint, and a submitted tls parameter ignored. A stored flag is left alone, not cleared.

    The point is not the page. On ESP8266, constructing a WiFiClientSecure calls stack_thunk_add_ref(), which reserves BearSSL's call stack from the heap at construction rather than at connect — so a global secure client costs about 6.8 kB from boot whether or not a secured connection is ever opened, and it lands mid-heap, halving the largest free block. Measured on one firmware by removing a client it never used: boot heap 25.0 kB to 31.8 kB, largest block at MQTT connect 11.8 kB to 24.8 kB.

    It is a macro rather than an attach() parameter so that the page and the application cannot disagree: the same name decides whether the row appears and whether the client is compiled in at all.

Optional modules

Four ready-made pages ship with the library. Each is header-only and opt-in: including it is what pulls in the library it needs, and a build that never includes it has that dependency at all.

Include Page Pulls in
<NetConfigComponent.h> Network — addressing, DNS, hostname, Wi-Fi, and the NTP servers NetworkProfile (NtpProfile for the Time group, which needs NTP_PROFILE_ENABLED)
<MqttConfigComponent.h> MQTT — broker, port, TLS, credentials, the base topic NetworkProfile (MqttProfile, 0.10.0+)
<SyslogConfigComponent.h> Syslog — server, port, severity floor, facility NetworkProfile (SyslogProfile, 0.9.0+)
<NetStatusComponent.h> Net status — the live link, read-only NetworkManager

They share the same shape, so knowing one is knowing all four:

component.attach(web, order, label /*, component-specific */);   // returns bool

attach() returns false if a registration failed — the page registry is finite, and a component that could not register would otherwise look attached.

Three registries have a fixed size, each overridable before including the header. They are small on purpose: the arrays are members of the server object, so an unused slot is RAM spent on a page nobody registered.

Macro Default Bounds
CONFIG_PORTAL_MAX_PAGES 16 every built-in page, module page and project page takes one
CONFIG_PORTAL_MAX_BACKUP_SECTIONS 4 addBackupSection()
CONFIG_PORTAL_MAX_RESET_HANDLERS 4 addResetHandler()

Each add*() returns false rather than overwriting when its registry is full, so a build that outgrows one says so instead of losing a page quietly.

Network and MQTT

Both edit a stored profile and take an optional Backup section of their own:

netConfig.addProfile(ethProfile, "eth");    // one or more

// The NTP servers are edited in a Time group on the same page, from a
// device-level NtpProfile — one clock, one set of servers, rather than a copy in
// every interface. Not calling this leaves the group off the page.
//
// One profile, which is the ordinary case. NetworkProfile also allows one per
// interface (NetworkAdapter::setNtpProfile), for a wired LAN with an internal
// time server a fallback cannot reach; this page does not edit those.
netConfig.setNtpProfile(ntpProfile, "ntp");

netConfig.onSaved([](const NetConfigComponent::NetChangeSet& c) {
    if (c.any()) restartPending = true;     // restarted from loop(): the answer goes out first
});
netConfig.setRestartOnSave(true);           // says so to the Backup page
netConfig.attach(web, AsyncConfigPortal::MENU_NET, "Network", /*withBackup=*/true);

mqtt.setProfile(mqttProfile, "mqtt");       // exactly one
mqtt.setDefaultBaseTopic(defaultBase);      // "sensors/<hostname>": the placeholder
mqtt.onSaved([](const MqttConfigComponent::Changed& c) {
    if (c.connection) restartPending = true; // the client is built from these
});
mqtt.setRestartOnSave(true);
mqtt.attach(web, AsyncConfigPortal::MENU_NET - 1, "MQTT", /*withBackup=*/true);

syslogPage.setProfile(syslogProfile, "syslog");
syslogPage.onSaved([](const SyslogConfigComponent::Changed& c) {
    applySyslog();                          // re-read the profile into the sender, in place
});
syslogPage.attach(web, AsyncConfigPortal::MENU_NET - 2, "Syslog", /*withBackup=*/true);

None of them owns a client or a connection: they save, and report what changed. What to do about it is the application's call, since the client belongs to it — a restart for an MQTT client that was built from the settings, an in-place re-read for a syslog sender, which holds no connection.

A restart is best done from loop(), a moment after the callback, so that the answer to the save gets out first. setRestartOnSave(true) tells the Network or MQTT component that the application restarts: a restore that changes something then covers the Backup page with "The device is restarting" until the device answers again, and reloads it. The component cannot know this by itself; without the call the page only says "Restored".

Reaching a wired device that has no address anyone knows

A Network page is only useful if the device can be reached in the first place, which is not a given on a wired network with no DHCP server. examples/EthConfigButton covers the two situations that occur: an unconfigured device — new, or factory reset — settles on a fixed address when no DHCP answers, and a deployed one stays on whatever it was given until somebody presses a button, which moves it to that same address for the length of the visit.

Neither address is ever saved, so a reset always returns to the stored configuration, and the only way into the configured state is saving on this page. The example gives the page its own Ethernet profile (savedProfile), loaded from the same namespace, and puts the fixed address only into the adapter's (runtimeProfile): the page edits and saves whole profiles, so sharing one would show the fixed address as the stored configuration and could save it.

Net status

Read-only, and open by default — checking a signal level is the sort of thing one does standing next to the device. Pass AuthLevel::Required where the SSID, addressing and BSSID should not be readable without a login.

It shows what NetworkManager knows. Anything else is the application's state, so the page takes sections rather than learning about each service:

netStatus.addSection("MQTT", [](char* buf, size_t len) {
    if (!enabled) return false;             // section omitted entirely
    return snprintf(buf, len, "{\"Broker\":\"%s\",\"State\":\"%s\"}",
                    host, mqtt.connected() ? "connected" : "offline") < (int)len;
});
netStatus.attach(web);

Writing your own instead

NetConfigComponent ships in the library as a ready, opt-in managed network-config page. It is header-only and pulls in the NetworkProfile family only when you include it:

#include <NetConfigComponent.h>   // opt in

and add the NetworkProfile library to your lib_deps. If you never include it, there is no NetworkProfile dependency. If you want your own page instead, write it against the composition API — that is what these three are built on.

⚠️ Two constraints keep this dependency-free — see DESIGN.md:

  1. Keep NetConfigComponent header-only.
  2. Use lib_ldf_mode = chain (the PlatformIO default). deep/deep+ scan all headers and would force-resolve NetworkProfile.h even when unused.

Setting build macros

The library's tunables are macros read by library sources, not by your sketch. Under PlatformIO put them in build_flags — one mechanism, both platforms:

build_flags =
    -DNET_CONFIG_MAX_PROFILES=2
    -DNTP_PROFILE_SERVER_COUNT=3

Under the Arduino IDE a #define in the sketch will not reach them: library sources are separate translation units. Each core has its own file for this, and they are not interchangeable:

Core File in the sketch folder Contents
ESP32 build_opt.h compiler flags, one per line, no comments
ESP8266 <sketch>.ino.globals.h a /* @create-file:build.opt@ ... */ block holding the flags
// build_opt.h (ESP32)
-DNET_CONFIG_MAX_PROFILES=2
-DNTP_PROFILE_SERVER_COUNT=3
-DMQTT_CONFIG_TLS=0

The ESP32 examples ship a build_opt.h; the accompanying sketch documents what each flag is for. The ESP8266 counterpart is not shipped, because the IDE hides an example folder that contains a .ino.globals.h — write one yourself when building an example for ESP8266.

Because the two cores read different files, this is also how you give the same sketch different flags per platform under the IDE: each core picks up its own file and ignores the other's.

⚠️ After the first build, editing the file has no effect on its own. The Arduino IDE does not notice the change and reuses the previous build objects and cached core. Force a full rebuild: close the IDE, or change any board menu option (the upload method, say) — either invalidates the cache.

Either way the IDE reads the file only from the sketch folder.

Firmware-marker build flags (OTA)

The OTA path validates an embedded firmware identity so a wrong image is rejected. Set these per build (defaults exist for a first run):

build_flags =
    -DFIRMWARE_PROJECT='"my-project"'   ; <= 23 chars
    -DFIRMWARE_BOARD='"my-board"'       ; <= 23 chars
    -DFIRMWARE_VERSION=100              ; major * 10000 + minor * 100 + patch

Reference the embedded marker once in your app (e.g. log g_fwMarker) so the linker keeps it.

Firmware rollback (ESP32)

The ESP32 bootloader can undo an update that does not run. With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE (set in the Arduino core's sdkconfig) a newly written image is started once, marked pending; if the device resets before that image confirms itself — a crash in setup(), a watchdog, a brownout — the next boot goes back to the firmware in the other OTA slot. Nothing is counted and nothing is timed by the bootloader: one unconfirmed run, then back.

The Arduino core would confirm every pending image before setup() runs, which makes the mechanism useless; the portal defers that (verifyRollbackLater(), defined in the library) and confirms instead 60 seconds after boot, from a timer: the device has run that long without a reset, which is what an update can be judged by at this level. Whether the network came up or the sensors answer is the device's environment, not the firmware's doing, and no reason to go back. The delay is setFirmwareConfirmDelay(seconds); 0 confirms in begin().

An application can confirm by itself instead (setFirmwareConfirmBy(AsyncConfigPortal::FirmwareConfirm::Application)), which ties the confirmation to its main loop running: the same delay, checked from loop():

void loop() {
    portal.confirmFirmwareWhenDue();   // once the uptime reaches the delay
    ...
}

confirmFirmwareWhenDue() does its work once and costs a comparison after that. An application that takes this on must make the call: an image that is never confirmed is rolled back at the next reset, however long it ran — a setup() that waits for something that never comes would do that at the next power cycle. The delay is a minimum uptime, not a deadline: a long setup() is uptime too. confirmFirmware() confirms at once, for an application with a condition of its own.

A deliberate action confirms at once: an authenticated request that changes state — a save, a restore, Restart, Factory reset, a firmware upload, any POST of the application's — confirms a pending image before its handler runs (Boot previous excepted); an upload does so at its first chunk, before any of its data is used. (Without that, an update uploaded in the first minute after another would fail: the bootloader's OTA API refuses to write while the running image is unconfirmed.) The firmware has started and serves its user, which is what the delay waits for; a restart the user asked for is not a crash, and a save may write a record the previous firmware cannot read. An application that restarts by itself (a command over MQTT, a button) calls confirmFirmware() first, for the same reason.

So, after an update, a reset in the first minute that nobody asked for — a power cut, a crash, a watchdog — boots the previous firmware. The log says which: updated firmware, unconfirmed: confirmed in 60 s unless the device resets, then firmware confirmed (or firmware confirmed by a request (/restart)); after a rollback the next boot logs the last update did not confirm and was rolled back, once. firmwarePending() and firmwareRolledBack() report the state for a status line; firmwareJustRolledBack() is true on that first boot after a rollback only, once per rolled-back image (an image uploaded again counts anew), for the application to tell someone (an SMS, an MQTT message) without remembering anything itself:

portal.begin(auth);
if (portal.firmwareJustRolledBack()) notify("firmware update rolled back");

The Other page shows the state (Running: an update, not confirmed yet / confirmed; Other slot: the firmware before the last update / the update that was rolled back / empty) and has a Boot previous button: it boots the other slot's image, which then starts as an unconfirmed update itself — a bad one is rolled back again within the minute. The running image is not marked invalid, so the two can be switched as often as needed until the next update overwrites the other slot.

The ESP8266 has no rollback; there the state reads as confirmed, the form is hidden and confirmFirmware() is a no-op. The same holds on an ESP32 core whose sdkconfig lacks CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE (the bootloader is the core's, prebuilt, so an application cannot turn it on); the build then says so with a #warning. The official Arduino core 3.x has it on.

About

A reusable, modular web-configuration server for ESP32 and ESP8266 (Arduino).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages