neiam-portal
Captive-portal provisioning and status web UI for ESP32 firmware, on
esp-idf-svc (std / ESP-IDF, not bare-metal).
Extracted from nfc-522 so the same setup flow can be reused
across devices. The crate owns the mechanism — SoftAP, captive DNS, the
config form, NVS persistence, gzipped asset serving — while each firmware
supplies its own config schema and status payload.
What you get
provision::run |
SoftAP + captive DNS + config form, reboots once saved |
portal::start |
just the provisioning HTTP server |
StatusServer |
read-only status page + JSON endpoints over the station IP |
ConfigStore |
NVS JSON blob load/save, plus the boot-failure counter |
wifi::connect_sta / start_ap |
WiFi bring-up |
wifi::keep_connected |
re-associates when the AP drops the station |
Form |
urlencoded body parsing |
dns::serve |
captive DNS that points every A query at the AP |
Roughly 54 KB of flash for the whole web UI (gzipped CSS + two pages + two woff2 subsets).
The config contract
The crate is generic over your config type. Embed the fragments you want with
#[serde(flatten)] and implement PortalConfig:
use neiam_portal::{Form, PortalConfig, WifiConfig};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Default)]
struct Config {
#[serde(flatten)]
wifi: WifiConfig,
#[serde(default)]
udp_start: u16,
}
impl PortalConfig for Config {
fn from_form(f: &Form) -> Self {
Self {
wifi: WifiConfig::from_form(f),
udp_start: f.parsed_or("udp_start", 7777),
}
}
fn wifi(&self) -> &WifiConfig { &self.wifi }
}
flatten keeps the stored JSON flat ({"wifi_ssid":…,"udp_start":…}), which
is byte-compatible with the blobs nfc-522 wrote before the extraction —
devices in the field keep their config across the upgrade.
Then at boot:
let store = ConfigStore::new(nvs);
let cfg: Config = store.load();
if !cfg.is_provisioned() {
provision::run::<Config, _>(modem, sysloop, store, &ssid, Pages::default())?;
// never returns; reboots once the user submits
}
More than one network
WifiConfig carries a primary network plus up to two alternates
(MAX_NETWORKS), so a device that moves between a bench AP and where it
actually lives does not have to be re-provisioned in between. The portal form
has two optional extra rows; they arrive as wifi_ssid_2/wifi_pass_2 and
wifi_ssid_3/wifi_pass_3 and land in WifiConfig::wifi_alts.
The stored blob is unchanged for a device with one network: wifi_alts is
skipped when empty, so blobs written before this existed load as they did and
new ones are byte-identical until an alternate is actually set.
Bring-up starts the radio, scans, and tries the known SSIDs loudest first — which is what makes a device carried between two of its own networks land on the nearer one rather than on whichever was typed in first. Networks the scan did not see are still tried afterwards in config order, since a hidden SSID never shows up in a scan. If the scan itself fails the whole list is tried in order.
The choice is made once, at bring-up: keep_connected re-associates to the
network that was chosen and does not re-scan. Reaching a different one means a
reboot, which is what a consumer already does when the link stays down.
Staying connected
connect_sta/connect_sta_blocking associate once, to one network. Nothing in
esp-idf-svc, and nothing in ESP-IDF’s station config, reconnects when the AP
later drops you — so any device meant to run for longer than an afternoon wants
wifi::keep_connected as well:
let wifi = wifi::connect_sta_blocking(modem, sysloop.clone(), nvs, cfg.wifi())?;
wifi::disable_power_save()?;
let _reconnect = wifi::keep_connected(&sysloop)?; // hold this
Two things to get right. Call it after the initial connect — those calls
wait on the same events and a handler calling esp_wifi_connect underneath
them races both — and keep the returned subscription alive, since dropping
it unregisters the handler and the station goes back to staying down.
It also logs the disconnect reason, which is carried nowhere else: 201 is
NO_AP_FOUND, 15 a handshake timeout (very nearly always the passphrase), 205
a connection made and lost.
How the omission shows up depends on the device. One that dials out logs
Host is unreachable from every retry forever; one that only listens on a
socket goes quiet with nothing in the log at all, which is harder to find.
Hostname
A device with no hostname reports ESP-IDF’s CONFIG_LWIP_LOCAL_HOSTNAME
default, espressif, in DHCP — so a router’s client list shows every ESP32
on the LAN under the same name. Pass one to the _named variant instead:
let name = format!("lambent-flex-{}", neiam_portal::device_id()); // -> lambent-flex-0a1b2c
let wifi = wifi::connect_sta_blocking_named(modem, sysloop.clone(), nvs, cfg.wifi(), Some(&name))?;
connect_sta_named is the async counterpart; the plain connect_sta /
connect_sta_blocking are unchanged and delegate with None.
The name has to be set inside the helper, not by the caller afterwards: lwip
puts it in the DHCP DISCOVER as soon as the link comes up, so wifi::set_hostname
(public, if you are driving EspWifi yourself) belongs after EspWifi::new —
before that there is no struct netif and it fails with
ESP_ERR_ESP_NETIF_IF_NOT_READY — and before start(). Max 32 bytes
(HOSTNAME_MAX_LEN); over-long is an error, not a truncation.
This is DHCP only. There is no mDNS responder here, so <name>.local resolves
only if the router serves DNS from its leases.
Compile-time credentials (skipping the portal)
Re-provisioning through a captive portal on every dev flash is miserable, so
ConfigStore::load_or_seed takes an optional compile-time config:
let cfg: DeviceConfig = store.load_or_seed(cfg_toml_seed());
The seed applies only to an unprovisioned device, and is written to NVS on
first use. Once anything is stored — seeded or submitted through the portal —
NVS wins and the seed is ignored, so editing it and reflashing will not move
an already-provisioned device (erase NVS to re-seed). The alternative, letting
a stale compile-time value silently override what someone configured on the
device, is the worse failure. A seed that isn’t itself is_provisioned() is
ignored, so an empty cfg.toml leaves the portal to do its job.
The crate takes no toml-cfg dependency — where the seed comes from is the
consumer’s business. lambent-flex-c6 wires it to cfg.toml behind its own
default-on cfg-toml feature; see its cfg_toml_seed().
Features
mqtt— addsMqttConfig(host/port/credentials/TLS/topic root, withurl()andtopic()helpers) and switches the built-in portal page to the variant carrying the broker form section. Config only: it does not pull in or own an MQTT client, so a device can use these settings with whatever client it likes.rebuild-assets— regenerate the CSS/HTML fromweb/with Tailwind at build time instead of using the pre-built bytes inassets/. Requires Node.js + npm (and network on the first run).
Assets are pre-built and checked into assets/ by default, so consuming
firmware needs no JS toolchain at all. Reach for rebuild-assets when
restyling, or when you ship a custom page using classes the shipped CSS was not
purged against. To refresh the checked-in copies:
cargo build --features rebuild-assets
cp target/debug/build/neiam-portal-*/out/{app.css.gz,saved.html.gz,status.html.gz} assets/
# portal.html.gz is the feature-selected variant; regenerate both by hand:
gzip -9 -n -c web/src/portal-wifi.html > assets/portal-wifi.html.gz
gzip -9 -n -c web/src/portal-mqtt.html > assets/portal-mqtt.html.gz
gzip -n omits the timestamp so the committed bytes stay reproducible.
Custom pages
Pages { portal, saved } and StatusServer::new(page) take Assets, so
shipping your own HTML is a pure-Rust override — gzip it in your build script
and pass the bytes. No env-var protocol, no build.rs coupling:
const MY_PAGE: Asset = Asset {
bytes: include_bytes!(concat!(env!("OUT_DIR"), "/my-status.html.gz")),
headers: neiam_portal::assets::H_HTML_GZ,
};
Host testing
esp-idf-svc is a target-scoped dependency, so cargo test on a normal
host compiles and runs the pure modules with no ESP toolchain:
cargo test # 36 tests
cargo test --features mqtt # 46 tests
Everything needing the ESP-IDF sits behind #[cfg(target_os = "espidf")]
(http, portal, provision, status, store, wifi). Note the corollary:
host cargo clippy does not lint those modules — only a real cross build does.
Version skew
This crate deliberately declares no [patch.crates-io]. Patches only apply
from the root manifest, so each consuming binary decides whether esp-idf-*
resolves to crates.io or to git master. The two current consumers differ:
| nfc-522 | lambent-flex-c6 | |
|---|---|---|
ESP_IDF_VERSION |
v5.5.3 | v5.2.3 |
| esp-idf-svc | 0.52.1 patched to git master | 0.52.1 from crates.io |
| MCU / target | esp32c3 / riscv32imc |
esp32c6 / riscv32imac |
The dependency is esp-idf-svc = "0.52" (permissive) for that reason. If the
git-master API drifts far enough from released 0.52.1, one of the two consumers
will stop building before the other — that is the expected failure mode, and
the fix is to align the consumers, not to patch here.
Deliberate changes from the nfc-522 original
Not a pure copy. Three behavioural fixes came along with the extraction:
- Captive DNS answers non-A queries with an empty
NOERROR. The original replied to every query type — AAAA included — with a type-A answer record, which is malformed and can be discarded by a strict resolver. POST /saverejects an over-long body with413instead of silently truncating it and saving a partial config. The original checked the limit after appending, then parsed whatever it had.POST /saverejects an incomplete config with400. The original saved whatever was submitted and rebooted, so a half-filled form produced a device that came straight back to the portal with no explanation.
Also: the checkbox rule is now explicit — mqtt_use_tls=off reads as false
rather than as “present, therefore on” (the original needed a tls_seen flag
to get the absent case right, and had no opinion about the value).