diag(provisioning): instrument captive flow + tighten DHCP/radio behavior

The previous protocol fix (302 → 200 portal HTML) didn't restore iOS
captive-banner reliability under the lock-screen-camera join: the user
joined, accepted the prompt, and got nothing on unlock. We're guessing
without data, so this round adds instrumentation alongside three
high-confidence behavioral fixes that are individually plausible
explanations.

Fixes:

* Force the AP DHCP server to advertise the AP IP as DNS via
  esp_netif_dhcps_option(ESP_NETIF_DOMAIN_NAME_SERVER). Arduino-ESP32's
  softAP doesn't set this explicitly; if a client comes in with cached
  cellular DNS the captive DNS hijack gets bypassed and iOS resolves
  captive.apple.com to real internet — no captive signal ever fires.

* WiFi.setSleep(false) so the AP radio doesn't park between beacons
  and drop probe packets that arrive during a sleep window.

* Cache-Control: no-store on the portal response, so iOS doesn't carry
  a "this SSID was fine last time" determination across forget+rejoin
  cycles.

Diagnostics (logged on serial at 115200, in AP mode only):

* Every HTTP request: method, URI, Host, User-Agent. Tells us whether
  iOS is reaching us and which CNA path it's hitting.
* WiFi AP events: STA-associated, IP-assigned, STA-disconnected.
  Tells us whether the join completed and DHCP succeeded.

Repro: pio device monitor -e waveshare73-v1, forget the network on
the phone, lock + scan + accept, watch the log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:48:24 -04:00
parent 7c7e4745cf
commit 4454e9a8a5
+82
View File
@@ -8,6 +8,7 @@
#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"
@@ -97,11 +98,37 @@ 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
// with method / URI / Host / User-Agent so a serial-monitor repro tells
// us whether the client (iOS in particular) is actually hitting us, and
// which CNA path it's using. Cheap; only runs in AP mode.
static void log_provisioning_request() {
String method = server.method() == HTTP_GET ? "GET"
: server.method() == HTTP_POST ? "POST"
: server.method() == HTTP_HEAD ? "HEAD"
: "OTHER";
Serial.print("[ap-http] ");
Serial.print(method);
Serial.print(" ");
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() {
log_provisioning_request();
// no-store stops iOS from caching a CNA result across joins to the
// same SSID name (e.g. after a forget+rejoin cycle) — we always want
// the captive banner to fire, never the cached "this network was
// fine last time" answer.
server.sendHeader("Cache-Control", "no-store, must-revalidate");
server.send_P(200, "text/html", PORTAL_HTML); server.send_P(200, "text/html", PORTAL_HTML);
} }
static void handle_connect() { static void handle_connect() {
log_provisioning_request();
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) { if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
server.send(400, "text/plain", "Missing ssid"); server.send(400, "text/plain", "Missing ssid");
return; return;
@@ -112,6 +139,25 @@ static void handle_connect() {
server.send_P(200, "text/html", CONNECTING_HTML); server.send_P(200, "text/html", CONNECTING_HTML);
} }
static void on_ap_event(WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_AP_START:
Serial.println("[ap-event] AP started");
break;
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.println("[ap-event] station associated");
break;
case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED:
Serial.println("[ap-event] DHCP lease handed out");
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.println("[ap-event] station disconnected");
break;
default:
break;
}
}
// ── WiFi provisioning ───────────────────────────────────────────────────────── // ── WiFi provisioning ─────────────────────────────────────────────────────────
static void enter_provisioning(const String& mac, bool retry = false) { static void enter_provisioning(const String& mac, bool retry = false) {
@@ -147,10 +193,46 @@ static void enter_provisioning(const String& mac, bool retry = false) {
server.on("/connecttest.txt", handle_root); // Windows 10+ server.on("/connecttest.txt", handle_root); // Windows 10+
server.onNotFound(handle_root); server.onNotFound(handle_root);
// WebServer doesn't capture arbitrary request headers unless told to
const char* tracked_headers[] = { "User-Agent", "Host" };
server.collectHeaders(tracked_headers, 2);
WiFi.onEvent(on_ap_event);
WiFi.disconnect(true); WiFi.disconnect(true);
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
WiFi.softAP(apSsid.c_str()); WiFi.softAP(apSsid.c_str());
// Power-save can park the radio between beacons and drop iOS CNA
// probes that arrive during a sleep window — keep the AP fully awake
// so the captive-detect window is deterministic.
WiFi.setSleep(false);
// Belt-and-braces DHCP DNS advertisement: ESP-IDF's softAP DHCP server
// *should* offer the AP IP as DNS by default, but if the client comes
// in with cached cellular DNS (8.8.8.8 etc) the captive DNS hijack
// gets bypassed entirely and iOS resolves captive.apple.com to the
// real internet. Stopping the DHCP server, setting DNS-info, flipping
// 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);
dns.start(53, "*", WiFi.softAPIP()); dns.start(53, "*", WiFi.softAPIP());