fix(provisioning): rip out racy DHCP-option dance, add /log endpoint

The previous "force DHCP DNS offer" code (esp_netif_dhcps_stop / set
DNS / set option / dhcps_start) ran AFTER softAP was already serving
beacons. A fast iOS join — and CNA probes follow DHCP within 1-2s —
could land in the middle of the dance and either get a stale lease
or be racing the server start. ESP-IDF's softAP DHCP server already
advertises the AP IP as DNS by default, so the dance was at best
redundant. Strip it. Also drop the WiFi.disconnect(true) call before
mode-switching to AP — there's nothing to disconnect from on a cold
boot, and disconnect(true) cycles the radio for no benefit.

Add /log: in-memory ring buffer (32 entries, FIFO) of HTTP requests
and AP-state events served as plain text at http://192.168.4.1/log.
Lets the user diagnose without USB serial — join the AP, browse to
the URL, see exactly which CNA paths iOS hit (or didn't).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 12:41:08 -04:00
parent 9c911b36b6
commit 6d3dee7659
+43 -43
View File
@@ -8,7 +8,6 @@
#include <Preferences.h> #include <Preferences.h>
#include <LittleFS.h> #include <LittleFS.h>
#include <qrcode.h> #include <qrcode.h>
#include "esp_netif.h"
#include "config.h" #include "config.h"
#include "epd.h" #include "epd.h"
#include "operation.h" #include "operation.h"
@@ -98,23 +97,30 @@ min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
// ── Web server handlers ─────────────────────────────────────────────────────── // ── Web server handlers ───────────────────────────────────────────────────────
// Diagnostic — every probe we receive during provisioning gets logged // In-memory ring buffer of provisioning events (HTTP requests + WiFi
// with method / URI / Host / User-Agent so a serial-monitor repro tells // events) so the user can browse them at http://192.168.4.1/log
// us whether the client (iOS in particular) is actually hitting us, and // without needing serial. 32 entries is plenty for one provisioning
// which CNA path it's using. Cheap; only runs in AP mode. // session and stays well under the ESP32's heap budget.
static constexpr size_t LOG_CAP = 32;
static String g_log[LOG_CAP];
static size_t g_log_head = 0;
static size_t g_log_count = 0;
static void log_event(const String& line) {
Serial.println(line);
g_log[g_log_head] = String(millis()) + "ms " + line;
g_log_head = (g_log_head + 1) % LOG_CAP;
if (g_log_count < LOG_CAP) g_log_count++;
}
static void log_provisioning_request() { static void log_provisioning_request() {
String method = server.method() == HTTP_GET ? "GET" String method = server.method() == HTTP_GET ? "GET"
: server.method() == HTTP_POST ? "POST" : server.method() == HTTP_POST ? "POST"
: server.method() == HTTP_HEAD ? "HEAD" : server.method() == HTTP_HEAD ? "HEAD"
: "OTHER"; : "OTHER";
Serial.print("[ap-http] "); log_event("[http] " + method + " " + server.uri() +
Serial.print(method); " host=" + server.hostHeader() +
Serial.print(" "); " ua=" + server.header("User-Agent"));
Serial.print(server.uri());
Serial.print(" host=");
Serial.print(server.hostHeader());
Serial.print(" ua=");
Serial.println(server.header("User-Agent"));
} }
static void handle_root() { static void handle_root() {
@@ -139,19 +145,33 @@ static void handle_connect() {
server.send_P(200, "text/html", CONNECTING_HTML); server.send_P(200, "text/html", CONNECTING_HTML);
} }
// Diagnostic page — plain text dump of the ring buffer, oldest first.
// User browses to http://192.168.4.1/log after joining the AP. Tells
// us whether iOS hit us at all and which CNA paths it tried.
static void handle_log() {
String body = "pictureFrame provisioning log\n";
body += "uptime=" + String(millis()) + "ms entries=" + String(g_log_count) + "/" + String(LOG_CAP) + "\n\n";
size_t start = (g_log_count < LOG_CAP) ? 0 : g_log_head;
for (size_t i = 0; i < g_log_count; i++) {
body += g_log[(start + i) % LOG_CAP] + "\n";
}
server.sendHeader("Cache-Control", "no-store");
server.send(200, "text/plain", body);
}
static void on_ap_event(WiFiEvent_t event, WiFiEventInfo_t info) { static void on_ap_event(WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) { switch (event) {
case ARDUINO_EVENT_WIFI_AP_START: case ARDUINO_EVENT_WIFI_AP_START:
Serial.println("[ap-event] AP started"); log_event("[wifi] AP started");
break; break;
case ARDUINO_EVENT_WIFI_AP_STACONNECTED: case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.println("[ap-event] station associated"); log_event("[wifi] station associated");
break; break;
case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED: case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED:
Serial.println("[ap-event] DHCP lease handed out"); log_event("[wifi] DHCP lease handed out");
break; break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED: case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.println("[ap-event] station disconnected"); log_event("[wifi] station disconnected");
break; break;
default: default:
break; break;
@@ -185,6 +205,7 @@ static void enter_provisioning(const String& mac, bool retry = false) {
// canonical signal that this is a captive network. // canonical signal that this is a captive network.
server.on("/", HTTP_GET, handle_root); server.on("/", HTTP_GET, handle_root);
server.on("/connect", HTTP_POST, handle_connect); server.on("/connect", HTTP_POST, handle_connect);
server.on("/log", HTTP_GET, handle_log);
server.on("/hotspot-detect.html", handle_root); // iOS / macOS server.on("/hotspot-detect.html", handle_root); // iOS / macOS
server.on("/library/test/success.html", handle_root); // iOS legacy server.on("/library/test/success.html", handle_root); // iOS legacy
server.on("/generate_204", handle_root); // Android server.on("/generate_204", handle_root); // Android
@@ -198,7 +219,6 @@ static void enter_provisioning(const String& mac, bool retry = false) {
server.collectHeaders(tracked_headers, 2); server.collectHeaders(tracked_headers, 2);
WiFi.onEvent(on_ap_event); WiFi.onEvent(on_ap_event);
WiFi.disconnect(true);
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
WiFi.softAP(apSsid.c_str()); WiFi.softAP(apSsid.c_str());
@@ -207,31 +227,11 @@ static void enter_provisioning(const String& mac, bool retry = false) {
// so the captive-detect window is deterministic. // so the captive-detect window is deterministic.
WiFi.setSleep(false); WiFi.setSleep(false);
// Belt-and-braces DHCP DNS advertisement: ESP-IDF's softAP DHCP server // Trust the default ESP-IDF softAP DHCP server: it offers the AP IP
// *should* offer the AP IP as DNS by default, but if the client comes // as DNS automatically. We previously did a stop / set-options /
// in with cached cellular DNS (8.8.8.8 etc) the captive DNS hijack // restart dance on top of softAP to "force" the DNS offer — but
// gets bypassed entirely and iOS resolves captive.apple.com to the // that races with iOS's DHCP request (a fast join can hit DHCP-stop
// real internet. Stopping the DHCP server, setting DNS-info, flipping // mid-handshake) and likely caused more failures than it fixed.
// the offer-DNS flag, then restarting forces every lease to carry our
// address as the primary resolver.
esp_netif_t *ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
if (ap_netif) {
esp_netif_dhcps_stop(ap_netif);
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(ap_netif, &ip);
esp_netif_dns_info_t dns_info = {};
dns_info.ip.u_addr.ip4.addr = ip.ip.addr;
dns_info.ip.type = ESP_IPADDR_TYPE_V4;
esp_netif_set_dns_info(ap_netif, ESP_NETIF_DNS_MAIN, &dns_info);
uint8_t offer_dns = 1;
esp_netif_dhcps_option(ap_netif, ESP_NETIF_OP_SET,
ESP_NETIF_DOMAIN_NAME_SERVER,
&offer_dns, sizeof(offer_dns));
esp_netif_dhcps_start(ap_netif);
}
server.begin(); server.begin();
dns.setErrorReplyCode(DNSReplyCode::NoError); dns.setErrorReplyCode(DNSReplyCode::NoError);