Improving the ESPHome captive portal

Disclosure: I work for Apollo Automation. Our devices use ESPHome’s captive portal to get onto WiFi, and that setup flow is the code this post is about.

I’ve been telling people that when they set up a device on a mesh or multi-AP network, they should type their WiFi name in by hand instead of picking it from the scanned list. The idea was that picking from the list might lock the device onto whatever access point it saw first, and if that was the one across the house, you’d have a bad connection. I had it written into a setup note:

If you have multiple access points or a mesh system please manually type in your Wi-Fi network so it will join with the strongest signal!

Before leaving that in, I went to confirm it was true. It’s not, and it never was.

The device only gets the name and password

When you set up an ESPHome device it puts up its own WiFi hotspot and serves a small page: pick your network, type the password, save. Each row in the list does one thing when you tap it:

onclick="document.getElementById('ssid').value = this.innerText"

It copies the network name into the text box. The signal bars and lock icon are images, they don’t go anywhere. The form has two fields, name and password, and the code on the device reads those two and nothing else. Nothing carries which access point you picked, because the device never asks.

So picking from the list and typing the name by hand are the same request. Whatever I thought the typing was avoiding was never being sent. The times it seemed to help were retries, or me typing the name of the nearer AP by chance.

A maintainer remembered it the same way when I brought it up, and the code backed him up.

Then why list the same network three times?

If the name is all that reaches the device, the list shouldn’t show a row per access point. It does anyway. A three-node mesh comes up as three rows, each with its own signal bar.

The list is built straight off a WiFi scan, and a scan returns one result per radio, not per network. The code prints a row for every result and never merges the repeats. So “MyNetwork” appears three times at three strengths, and tapping any of them sends the same thing. The bars are real differences between real access points, but the name that goes out is identical no matter which you pick.

The setup note wasn’t fixing anything. It was a workaround for a screen that looks like it’s asking you to choose an access point when it isn’t.

On the device or in the page

The duplicates can be merged in two spots: on the device before the list is sent, or in the browser after it arrives.

The browser is the safer spot, a few lines of JavaScript that can’t touch the firmware. But the device still sends every duplicate, the page gets a little bigger, and that page has to come through a weak hotspot during setup. A maintainer mentioned he’s seen signal bad enough that the page barely loads, so shaving bytes there counts for something.

The device is the only spot where the duplicates never get sent at all. That costs flash on every chip down to the ESP8266, so I wanted the real number before choosing.

In the browserOn the device
Repoesphome-webserveresphome
Duplicate rows sent over the airStill sentNever sent
Setup page sizeSlightly biggerUnchanged
To ship itTwo PRs, the second vendors the rebuilt page into the firmware repoOne PR, next release

220 bytes

I built both and compared. Each duplicate row costs about 45 bytes of the JSON the device sends, so on a three-node mesh the device fix pays for a chunk of itself the first time the page loads.

BuildBeforeAfterChange
Compressed setup page, browser fix1,110 B1,161 B+51 B
ESP32 firmware flash, device fix736,451 B736,671 B+220 B
ESP32 firmware RAM, device fix43,272 B43,272 Bnone

Cheap enough, and it’s the version that keeps the extra rows off the air during setup, so I did it on the device.

Two things slowed me down. The scan list isn’t sorted by signal, it’s sorted by connection preference, so keeping the first copy of each name can keep a weaker one. I had to compare signal and keep the strongest. And the ESP32 build refused to run under Git Bash (“MSys/Mingw is not supported”) and quit halfway through installing a toolchain. The same command from PowerShell built fine. That one cost me an hour.

What went upstream

It’s a pull request to ESPHome: one row per network, strongest signal, done on the device so the extra rows never leave it.

And the setup note comes out, because there’s nothing left for it to warn about.

The review found something worse

ESPHome’s review bot approved the change, then listed a few notes underneath. One of them wasn’t about my code at all: the captive portal reads the scan results from the web server task while the main loop is rewriting that same list. bdraco read it and said it might even be a crash.

He was right. The main loop can rebuild, sort, or free that list while the portal is in the middle of serializing it into a response, so the portal can end up reading memory that’s already been handed back. Nothing to do with my change. My version walks the list by index where the old one iterated it, and both do that from the web server task while the main loop is free to touch it.

He had a fix open about an hour later: a lock on the scan results, taken by the writers while they rebuild the list and by the portal while it serializes. It only exists for components that ask for it, and on single-threaded chips it compiles away to nothing. That one’s still in progress.

So my PR is parked behind his, which is the right order. Mine changes how many rows you see in a list. His stops a device from reading freed memory while somebody is trying to get it onto WiFi, which is the kind of failure that shows up as a reboot during setup with nothing obvious to blame. It took a cosmetic fix to a setup screen to surface it.

Update, July 25: testing his fix

He asked if I’d test it on an ESP32 before it goes in. Fair trade, since his fix is what’s blocking mine.

I built his branch and current dev side by side and flashed a 4MB ESP32-C3. The lock costs 8 bytes of RAM and 74 bytes of flash on that chip. An ESP8266 build came out byte for byte identical, which is what you’d want, since the lock compiles away to nothing on single-threaded chips.

Then I spent two hours failing to test the thing I was trying to test.

To hit the bug you need two things happening at once: the portal serving the network list, and the main loop rebuilding that same list from a fresh scan. Getting one device to do both was harder than the fix.

First try, point it at a network that doesn’t exist, so it never connects and keeps scanning. It scanned once and stopped. A name that never turns up in a scan gets treated as a hidden network, so the device quits scanning and just tries to connect to it directly, forever.

Second try, point it at my real network with a wrong password, so it’s visible in scans and still fails. That scanned even less, and it kicked my desktop off the setup hotspot every 30 seconds, because every failed handshake drops every client connected to the device’s own hotspot.

Third try, force a scan every ten seconds from the config. That finally kept the list rebuilding, and it made the portal unreachable. One radio can’t scan and host a hotspot at the same time, so my desktop spent most of the test disconnected and the requests timed out.

Which is when I read the part of the WiFi code that explains all three. While the captive portal is up, ESPHome stops scanning on purpose. Scanning blocks the DNS and HTTP the portal needs, so after the one scan that fills the list when the portal starts, it doesn’t scan again while you’re standing there typing your password.

That makes the bug much narrower than it looked. The window is that one scan, right as the portal comes up, before anyone has joined the hotspot to look at the list.

One more thing that ate time: hammering the portal as fast as PowerShell could send requests ran the device’s web server out of sockets, and it started refusing connections. That looked like a crash until I read the error. Pacing it down to four requests a second fixed it, and it had nothing to do with the lock.

So the report I posted says what it can. The portal serves the right list with the lock in place, the two tasks don’t deadlock, nothing crashed or rebooted over about twenty minutes, memory stayed flat, and typing real credentials still gets the device onto WiFi. What it doesn’t say is that the bug is gone, because I couldn’t make the bug happen on purpose. Both halves are in the test report.

His PR is still open. Mine’s still parked behind it, which is still the right order.

← All posts