Commits
65cab78
.forgejo
assets
scripts
src
web
.gitignore 270 B
build.rs 6.2 KiB
Cargo.toml 1.8 KiB
LICENSE.md 34.1 KiB
README.md 11.2 KiB

neiam-portal

CI status audit status latest release license install neiam-portal (cargo)

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 — adds MqttConfig (host/port/credentials/TLS/topic root, with url() and topic() 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 from web/ with Tailwind at build time instead of using the pre-built bytes in assets/. 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:

  1. 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.
  2. POST /save rejects an over-long body with 413 instead of silently truncating it and saving a partial config. The original checked the limit after appending, then parsed whatever it had.
  3. POST /save rejects an incomplete config with 400. 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).