Skip to main content

WiFi Management

main/wifi_manager.c owns everything radio: provisioning, association, retries, and diagnostics. It is also home to the strangest hardware bug this project hit, whose fix is now a feature.

Association, from zero

Joining WiFi is a fixed conversation: the station hears the network's beacons (or probes for it), sends an authentication frame, then an association request, then performs the four-way handshake that proves both sides know the password and derives the traffic keys. Only after all four steps does DHCP hand out an IP.

Each step fails distinctly, and the ESP-IDF disconnect callback reports a numeric reason. The ones worth memorizing, because the manager logs them at every retry:

ReasonStep that failedUsual truth
201 NO_AP_FOUNDnever heard the networkwrong SSID, out of range, or 5 GHz only
2 AUTH_EXPIREauth frame got no answerthe AP ignored us: MAC filtering, or our frame never physically arrived
15 / 204four-way handshakewrong password
205associationgeneric connect failure, often follows one of the above

The manager also runs a diagnostic scan after final failure and logs every visible network with channel, signal, and auth mode. One log line, Target SSID not visible on 2.4 GHz (5 GHz-only network?), exists because that misconfiguration alone probably burns more hobbyist hours than any other.

Provisioning: the three paths

In order, at every boot: NVS-saved credentials (8 s timeout), then build-time Kconfig credentials (18 s, sized so the TX ladder below can complete), then SoftAP provisioning through the espressif/network_provisioning component (ESP-IDF v6 removed SmartConfig, so this replaced it). The device advertises an open access point named Safi-XXXXXX (suffix from the last three bytes of the WiFi STA MAC) and the Espressif "ESP SoftAP Provisioning" phone app pushes credentials over a protocomm security 2 session: SRP6a authentication plus AES-GCM encryption, anchored by a 16-character proof of possession. Production builds derive the PoP at boot as HMAC-SHA256(factory secret, base MAC) in main/pop_derive.c instead of storing it; tools/nvs_mass_provisioning.py runs the same derivation at the factory, so the label always matches the firmware. Development builds fall back to NVS (provisioning namespace, pop key) and generate a random PoP on first boot, printed once on the serial console. Received credentials are saved to NVS and used immediately; the full user-facing flow is in the user guide. The code adds one wrinkle worth knowing: a deliberate suppress_retry flag wraps the diagnostic scan, because the disconnect that precedes a scan would otherwise race the auto-retry logic into the scan itself.

Why the AP is open rather than passphrase-protected, and why wpa3_compatible_mode is deferred: security chapter.

The TX power ladder

The war story. During bring-up, the board could see every network (scans listed them with strong signal) but join none: every attempt died with reason 2, the auth frame unanswered. Three different access points, including one running on a laptop half a meter away with its own logs proving no auth frame ever arrived. Receive perfect, transmit dead.

The cause: on this board, the radio's default full transmit power (19.5 dBm) drew current spikes the 3.3 V rail could not source cleanly, and every transmitted frame left the antenna corrupted. Receive kept working because receiving costs a fraction of transmitting. The clincher experiment was one line, capping TX power at 2 dBm, after which association succeeded instantly; the subtler finding was that explicitly programming the power right after esp_wifi_start() fixed even full power (the boot-default power table misbehaves on this hardware where an explicit set does not).

The fix shipped as a self-healing ladder rather than a hardcoded number:

static const int8_t tx_power_ladder[] = {78, 60, 44, 34, 20, 8}; // 0.25 dBm units

Every fresh connect cycle starts at full power; each failed retry steps down one rung; whatever rung connects, wins (the connected power is logged). A healthy board pays nothing, a marginal supply self-rescues in a few seconds, and the serial log tells you which board you have. The general lesson: when a radio can hear but not speak, suspect power integrity before software, and make the mitigation observable.

Power save: latency versus milliamps

WiFi modem power save (the radio napping between beacon intervals) saves tens of milliamps and costs 100 to 300 ms of latency on every round trip, because the AP must buffer frames until the radio wakes. Under default power save, TLS handshakes to the device (several round trips plus crypto) measured 10+ seconds during audio playback: the dashboard felt dead.

Safi runs WIFI_PS_NONE. For a mains-or-big-battery audio device whose whole selling point includes a live dashboard, the milliamps buy nothing worth having. The setting is one line in the manager, deliberately adjacent to the TX power call, with the reasoning in a comment; a future aggressive battery profile would revisit both together.

Reconnection

  • Drops publish EVENT_WIFI_DISCONNECTED (the display and dashboard react), and a 30-second timer retries from the top of the ladder.
  • Connects publish EVENT_WIFI_CONNECTED with the IP string; SNTP starts on that event, and anything else that was waiting (a saved stream resume, for example) hooks the same event.
  • The old known gap here (the provisioning fallback never retried stored credentials until reboot) is fixed by a provisioning cycle: a dedicated task alternates a bounded SoftAP provisioning window (SAFI_PROVISIONING_WINDOW_MIN, default 10 minutes) with a stored-credential retry burst, forever, until either path connects. The two phases cannot overlap because the SoftAP and station interfaces share one radio; the alternation is the standard answer for headless devices.
  • The manager also brings up the mDNS responder, so the dashboard answers at https://safi.local (hostname configurable via SAFI_MDNS_HOSTNAME). The espressif/mdns component tracks interface events itself, so provisioning transitions and reconnects need no extra wiring.