Skip to content

WiFi (espos_wifi)

Station connection manager with an explicit status model, a priority list of networks, exponential backoff, DHCP or static addressing and a SoftAP provisioning portal. Configuration is the wifi namespace (see the schema); status is GET /api/v1/wifi/status and the wifi SSE event.

The station is one transport of espos_net: it reports its link there, and "is the network up" is espos_net_is_up(), not this component's state. The device id, the hostname (net.hostname, until 0.7 wifi.hostname) and the mDNS responder are espos_net's. espos_wifi_start() requires espos_net_start() to have run.

State machine

            START (sta_enabled, ≥1 network)
                     │
     ┌───────────────▼────────────────┐   STA_CONNECTED   ┌──────────────┐  GOT_IP  ┌───────────┐
     │          CONNECTING            │──────────────────▶│ OBTAINING_IP │─────────▶│ CONNECTED │
     │  connect(net[i]); connect_tmo  │                   │  dhcp_tmo    │          │           │
     └───────────────┬────────────────┘                   └──────┬───────┘          └─────┬─────┘
        DISCONNECTED │ (reason) / timeout                DHCP timeout / drop         drop / LOST_IP
                     ▼                                          │                         │
          next network in the list? ──yes──▶ CONNECTING(i+1)   ◀────────────────────────┘
                     │ no (round complete)                (a drop from CONNECTED retries the
                     ▼                                     same network once before moving on)
     ┌────────────────────────────────┐
     │            BACKOFF             │  1 s · 2^round, capped at backoff_max_s, ±25 % jitter
     │  timer → CONNECTING(net[0])    │  round resets on success
     └────────────────────────────────┘

  DISABLED      sta_enabled=false: idle (portal may be up)
  UNCONFIGURED  no network has an SSID: idle, portal up immediately

Every driver event carries the raw reason code; the status exposes it as reason: {code, text} with a human explanation:

code text
201 network not in range (NO_AP_FOUND)
202 wrong password (AUTH_FAIL)
2 wrong password (auth expired)
15 wrong password (4-way handshake timeout)
204 auth timed out, weak signal? (HANDSHAKE_TIMEOUT)
200 lost beacon (out of range or AP off?)
1001 associated but no IP address (DHCP timeout) — ours, not a WiFi code
1002 no answer from the driver (connect timeout) — ours
1003 IP address lost — ours
1004 reconnecting after configuration change — ours
1005 station disabled — ours

The full table is in wifi_reason.c. Unknown codes read unknown reason with the number alongside.

Networks and priority

Four slots ssid0..3 / psk0..3 / bssid0..3. Slot order is priority; empty slots are skipped. A round tries every configured network once (no backoff in between); backoff applies between rounds. A live connection is kept when the config changes as long as its network (SSID, password, BSSID pin) still appears in the new list — reordering or adding networks never drops the link; changing the password of the current network reconnects from the top of the list. bssidN pins one access point (aa:bb:cc:dd:ee:ff) and disables roaming for that slot.

Security: with a password set the station refuses open/WEP networks (threshold WPA-PSK; WPA2 and WPA3-SAE/H2E are negotiated when the AP offers them, PMF capable). Passwords must be 8..63 characters — shorter ones are not valid WPA and the slot is skipped with a warning. Empty password = open network.

Addressing

wifi.ip_mode is dhcp (default) or static; with static, wifi.ip and wifi.netmask are required, wifi.gateway, wifi.dns0 and wifi.dns1 optional (empty dns0 = the gateway, empty dns1 = none). The port applies them to the station netif before every esp_wifi_connect() (esp_netif_dhcpc_stop + esp_netif_set_ip_info + esp_netif_set_dns_info, or esp_netif_dhcpc_start back to DHCP), so a change takes effect at the next (re)connection or reboot — the keys are flagged restart-required for that reason. An ip or netmask that does not parse falls back to DHCP with one warning in the log rather than leave the device unreachable. A static address still raises IP_EVENT_STA_GOT_IP on association, so the state machine, espos_net and everything above them see exactly what they see with DHCP.

Portal (SoftAP provisioning)

The station keeps retrying while the portal is up — the portal never replaces the station, it runs alongside (APSTA).

Situation Portal
no network configured up immediately
sta_enabled = false up immediately
retrying without success up after portal_after_s (default 90 s)
connected down
portal_enabled = false never

SSID portal_ssid (default espOS-<id>, the device id being the last 4 hex of the base MAC, net.md), open unless portal_psk (≥ 8 chars) is set. The AP is 192.168.4.1/24 with DHCP; a tiny DNS responder answers every name with that address and the usual OS probe URLs (/generate_204, /hotspot-detect.html, /connecttest.txt, …) redirect to /, so phones pop their "sign in to network" sheet and land on the setup page. The setup page scans, lets you pick a network and PUTs wifi.ssid0/psk0 through the normal API. Once connected the portal drops (the phone loses the AP — expected).

Two log lines say how far a client got, and they need opposite fixes:

portal up: join "espOS-2be9" (open) and open http://192.168.4.1/
portal: lease 192.168.4.2 to ce:ee:ba:0d:3f:35

The lease line is the DHCP server answering. A client that associates without one means the association worked and the address did not; no lease line and a client that believes it is connected is the case to investigate. Both together mean the network is fine and whatever is wrong is above it — most often the setup page never opened, which on a display board can simply be a blank screen.

Provisioning without the portal

The store is plain NVS, so a factory image works: put the credentials in a CSV outside the repo (patterns like *.nvs.csv are git-ignored), generate the partition and flash it to the nvs offset:

printf 'key,type,encoding,value\nwifi,namespace,,\nssid0,data,string,MyBoat\npsk0,data,string,secret\n' > wifi.nvs.csv
python -m esp_idf_nvs_partition_gen generate wifi.nvs.csv wifi.nvs.bin 0xc000
esptool.py --port /dev/ttyACM0 write_flash 0x9000 wifi.nvs.bin

config_version need not be present — the store stamps it on first boot. The size argument is the nvs partition's size, 48K (0xc000) in the shipped tables; a project with its own partition table passes its own.

Status document

GET /api/v1/wifi/status:

{
  "state": "connected", "sta_enabled": true, "hostname": "espos-1a2b",
  "network_index": 0, "ssid": "Boat", "bssid": "aa:bb:cc:dd:ee:ff", "channel": 6, "rssi": -59,
  "ip": "192.168.1.23", "netmask": "255.255.255.0", "gateway": "192.168.1.1", "connected_s": 26,
  "reason": {"code": 0, "text": ""},
  "attempt": 0, "round": 0, "connect_count": 1, "disconnect_count": 0,
  "portal": {"active": false, "ssid": "espOS-1a2b"}
}

ssid/bssid/channel/rssi appear from obtaining_ip on, ip/netmask/gateway/ connected_s only in connected, backoff_ms only in backoff, portal.ip and portal.clients only while the portal is active. hostname is net.hostname as espos_net applied it at boot. The same document is pushed as the wifi SSE event on every change (and once on connect). The transport-neutral view — is there a route, on which interface, with which address — is GET /api/v1/net/status (net.md).

On boards where the radio lives on a second chip — ESP32-P4 host with an ESP32-C6 over SDIO, and similar — the host↔co-processor transport can wedge while the WiFi state machine still reports connected. The host log fills with

rpc_core: Timeout waiting for Resp for [0x126](Req_WifiStaGetApInfo)

and nothing else: the RPC channel is gone, so the host cannot even ask whether it is associated. Upstream tracks this as esp-hosted-mcu#197 and #220.

esp_hosted raises ESP_HOSTED_EVENT_TRANSPORT_FAILURE for faults it can detect itself (a dropped SDIO read, an all-ones PKT_LEN), and with CONFIG_ESP_HOSTED_TRANSPORT_RESTART_ON_FAILURE=y — the default, which espOS keeps — that reboots the device. A silently wedged link raises no event at all, because there is nothing to detect, only an absence.

espos_wifi_hosted_watchdog_start() supplies the missing signal. It enables the co-processor heartbeat (20 s) and watches for it. The heartbeat travels over the same RPC channel that dies, so its absence is the fault detector. After three missed beats the device restarts.

It starts automatically from espos_wifi_start(); there is nothing to call. On native-radio and simulator builds it compiles to a stub returning ESP_ERR_NOT_SUPPORTED. A non-OK return means wedges will not be detected — not that WiFi is broken.

Why a restart and not a transport re-init

Upstream's host_hosted_events example recovers with esp_hosted_deinit() → esp_hosted_init() → esp_hosted_connect_to_slave(), which repairs the link in seconds and keeps config and UI state. Do not do this here, however obviously better it looks than a reboot.

That pair asserts rather than returning an error when it cannot re-allocate:

assert failed: sdio_mempool_create sdio_drv.c:258 (buf_mp_g)

sdio_mempool_destroy() runs on deinit and sdio_mempool_create() on init; if the pool cannot be re-allocated — fragmentation, or the old one not fully released — the assert fires on whatever task called it, which for this watchdog is esp_timer. Error handling on the return value cannot help: the assert fires first, so the if (err) branch is unreachable.

The transport is already broken when recovery runs, so the recovery path must not have a failure mode of its own. esp_restart() always works. A device that restarts 60 s after its radio link dies is strictly better than one sitting unreachable until someone power-cycles it, which is the behaviour this replaces — and that, not the in-place repair, was always the valuable half.

mDNS

The responder moved to espos_net with the network seam, API unchanged (espos_mdns.h, espos_mdns_add_service(), ESPOS_EVENT_MDNS_READY): it follows whatever interface carries the default route, and the knobs are CONFIG_ESPOS_NET_MDNS and CONFIG_ESPOS_NET_MDNS_MAX_SERVICES. The .local name is net.hostname.

Design notes

  • The state machine (wifi_sm.c) is pure C over an injected port and runs unchanged on the host (test/host/espos_wifi_test, every transition above). The device port (port_idf.c) is esp_wifi/esp_netif glue; the host port (port_sim.c) scripts a fake driver so the REST/SSE layer is testable too.
  • Driver calls are never made under the state-machine lock. The SM queues actions; espos_wifi_dispatch() runs them after unlocking. On ESP32-P4 the driver is a co-processor behind an RPC that itself delivers events into the SM — calling it under the lock deadlocked within a second of boot.
  • One FreeRTOS timer serves connect/DHCP/backoff timeouts and the portal deadline (whichever is earlier), so there is nothing to keep in sync.
  • ESP32-P4: WiFi is an ESP32-C6 co-processor over SDIO (esp_hosted + esp_wifi_remote, P4-only dependencies in main/idf_component.yml; pinout in sdkconfig.d/espos.defaults.esp32p4). Same esp_wifi_* API; the MAC is read from the driver, not eFuse.

Three settings on that transport are load-bearing, all pinned in sdkconfig.d/espos.defaults.esp32p4:

  • CONFIG_WIFI_RMT_RX_BA_WIN=6. IDF defaults this to 6 but raises it to 16 as soon as a project enables PSRAM (SPIRAM_TRY_ALLOCATE_WIFI_LWIP) — which any board with a display will. At 16 the SDIO Rx path overruns under sustained inbound TCP: the link wedges, H_SDIO_DRV spams "task still writing Rx data to queue!", every RPC to the C6 times out, and WiFi stays dead until the host reboots — while the state machine still reports CONNECTED, because the disconnect event never crosses the jammed link (espressif/esp-hosted-mcu#184). Measured on a Waveshare 7B with repeated ~30 KB HTTP reads: wedged after 85 requests at 16, survived 400 at 6. Note the knob is WIFI_RMT_* — with hosted WiFi the radio is remote, so the local ESP_WIFI_RX_BA_WIN does not reach it.
  • CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE=y must stay on. The C6 slave firmware is fixed in streaming mode and the host has to match, or the transport asserts at boot: "SDIO mode mismatch: slave is in streaming mode, but host is in packet mode. Aborting."
  • CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y. The transport mempool is the transport's large DMA buffer pool; left in internal RAM (the default) it is the biggest MALLOC_CAP_INTERNAL|DMA consumer on the host, and sustained inbound TCP (measured with ~270 KB/s of ACK-paced MJPEG on a Waveshare 7B) drove internal free memory to ~12 KB, SDIO transfers stalled, the heartbeat stopped, and the link watchdog rebooted the host ~90 s into every streaming session. In PSRAM (which the P4's GDMA reaches through cache) the same workload left >200 KB internal free and the link never blinked. Constraint: only valid with 64-byte L2 cache lines — the 1600-byte transport stride is 64-aligned but not 128-aligned, so under CONFIG_CACHE_L2_CACHE_LINE_128B the SDIO driver rejects PSRAM buffers with ESP_ERR_INVALID_ARG (esp-hosted-mcu#219). Keep the mempool internal on 128-byte-line configurations. The option only exists with PSRAM, which the P4 defaults enable (CONFIG_SPIRAM=y); without PSRAM IDF drops it silently, and the board does not boot anyway (hardware.md).

Since a wedged transport cannot be recovered from the host (there is no reconnect API, and the RPC that would carry one is exactly what times out), an application that must survive it unattended should reboot on its own liveness signal — real traffic, not ESPOS_WIFI_ST_CONNECTED, which keeps reporting success. * The state machine posts nothing itself: on every status change espos_wifi.c queues a report for espos_net and delivers it from the drainer, outside the SM lock (espos_net_report() takes its own lock and runs subscriber callbacks). espos_net decides whether the default route changed and posts NETWORK_UP/NETWORK_DOWN; the port's GOT_IP handler only logs the "connected to … web UI" line. * BLE provisioning is a separate optional component, espos_prov: it writes the same wifi keys this component reads and never touches the radio itself. * Not yet: country code.