fix(provisioning): mirror aqua-iq's working AP pattern
After repeated 200-OK / DHCP-option / DNS-hijack permutations failed to
make the iOS captive banner fire reliably, port the configuration that
empirically works on the aqua-iq Pi to the ESP32:
* WPA2-PSK secured AP (was open). iOS handles secured-network
captive-portal detection more aggressively than open networks. The
PSK ('pictureframe') is baked into both firmware and the WIFI: QR so
the user never types it — there's no real secret value here.
* Explicit channel 6 (was the softAP default of channel 1). Channel 6
is the "middle" 2.4 GHz channel and tends to be less contested in
dense environments; aqua-iq picks it for the same reason.
* 1.5 s settle delay after softAP. The radio + DHCP server need a
beat before they're ready to handle a phone that joins the moment
the SSID is broadcast (aqua-iq sleeps 3 s for NetworkManager+dnsmasq
to fully initialize; the ESP softAP stack is faster but a small pad
still kills race conditions).
* CNA paths revert to 302 redirect → "/". This is what aqua-iq does
and what WiFiManager does. Serving the portal HTML inline at 200 on
these endpoints (the previous attempt) didn't reliably trigger the
iOS banner. The redirect is what iOS / Android / Windows look for.
QR string format updates from WIFI:S:NAME;T:nopass;; to
WIFI:T:WPA;S:NAME;P:pictureframe;; — phones consume both, but the
WPA variant is needed now that the AP requires a password.
Bg image's format-string footnote regenerated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,15 @@
|
||||
// ── Network ──────────────────────────────────────────────────────────────
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define AP_IP "192.168.4.1"
|
||||
// AP security — WPA2-PSK with a fixed password baked into both the
|
||||
// firmware and the WIFI: QR. Open networks have flakier iOS captive-
|
||||
// portal behavior (esp. on lock-screen-camera join paths); a secured
|
||||
// network is the pattern aqua-iq uses successfully on the same iOS
|
||||
// versions. The password is embedded in the QR so the user never types
|
||||
// it — there's no real secret value.
|
||||
#define AP_PASSWORD "pictureframe"
|
||||
#define AP_CHANNEL 6
|
||||
#define AP_SETTLE_MS 1500
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
// Server's X-Interval-Ms is the primary schedule — driven by the user's
|
||||
// rotationIntervalMinutes / wakeTimes settings. The constants below are
|
||||
|
||||
+39
-14
@@ -26,7 +26,10 @@ static String g_req_pass;
|
||||
// ── QR helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void show_ap_qr(const String& apSsid, bool retry = false) {
|
||||
String content = "WIFI:S:" + apSsid + ";T:nopass;;";
|
||||
// WIFI: QR with embedded WPA2 PSK so the phone joins silently after
|
||||
// the user accepts the join prompt — no password entry. T:WPA;P:...
|
||||
// is the standard format consumed by iOS Camera and most QR apps.
|
||||
String content = "WIFI:T:WPA;S:" + apSsid + ";P:" + String(AP_PASSWORD) + ";;";
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(5)];
|
||||
@@ -145,6 +148,18 @@ static void handle_connect() {
|
||||
server.send_P(200, "text/html", CONNECTING_HTML);
|
||||
}
|
||||
|
||||
// CNA probe handler — log the hit and 302-redirect to "/". The redirect
|
||||
// is the trigger iOS / Android / Windows actually look for to raise the
|
||||
// captive banner. Serving the portal HTML inline (200 OK) didn't work
|
||||
// reliably for our user; aqua-iq uses 302 on the same paths and the
|
||||
// captive UI fires every time, so match that.
|
||||
static void handle_captive() {
|
||||
log_provisioning_request();
|
||||
server.sendHeader("Location", "http://" AP_IP "/", true);
|
||||
server.sendHeader("Cache-Control", "no-store, must-revalidate");
|
||||
server.send(302, "text/plain", "");
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -206,13 +221,17 @@ static void enter_provisioning(const String& mac, bool retry = false) {
|
||||
server.on("/", HTTP_GET, handle_root);
|
||||
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("/library/test/success.html", handle_root); // iOS legacy
|
||||
server.on("/generate_204", handle_root); // Android
|
||||
server.on("/gen_204", handle_root); // Android variant
|
||||
server.on("/ncsi.txt", handle_root); // Windows
|
||||
server.on("/connecttest.txt", handle_root); // Windows 10+
|
||||
server.onNotFound(handle_root);
|
||||
// CNA probe paths — return 302 redirect to "/" rather than 200 + portal
|
||||
// body. This is the aqua-iq / WiFiManager pattern that empirically fires
|
||||
// the iOS captive banner reliably; serving the portal body inline at
|
||||
// these endpoints didn't.
|
||||
server.on("/hotspot-detect.html", handle_captive); // iOS / macOS
|
||||
server.on("/library/test/success.html", handle_captive); // iOS legacy
|
||||
server.on("/generate_204", handle_captive); // Android
|
||||
server.on("/gen_204", handle_captive); // Android variant
|
||||
server.on("/ncsi.txt", handle_captive); // Windows
|
||||
server.on("/connecttest.txt", handle_captive); // Windows 10+
|
||||
server.onNotFound(handle_captive);
|
||||
|
||||
// WebServer doesn't capture arbitrary request headers unless told to
|
||||
const char* tracked_headers[] = { "User-Agent", "Host" };
|
||||
@@ -220,18 +239,24 @@ static void enter_provisioning(const String& mac, bool retry = false) {
|
||||
|
||||
WiFi.onEvent(on_ap_event);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(apSsid.c_str());
|
||||
// WPA2-PSK secured AP on channel 6 — matches aqua-iq's working
|
||||
// configuration. softAP signature: ssid, psk, channel, ssid_hidden,
|
||||
// max_connection. Channel 6 is the "middle" 2.4 GHz channel, less
|
||||
// contested than the default 1 in dense WiFi areas.
|
||||
WiFi.softAP(apSsid.c_str(), AP_PASSWORD, AP_CHANNEL);
|
||||
|
||||
// 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);
|
||||
|
||||
// Trust the default ESP-IDF softAP DHCP server: it offers the AP IP
|
||||
// as DNS automatically. We previously did a stop / set-options /
|
||||
// restart dance on top of softAP to "force" the DNS offer — but
|
||||
// that races with iOS's DHCP request (a fast join can hit DHCP-stop
|
||||
// mid-handshake) and likely caused more failures than it fixed.
|
||||
// Settle delay — softAPIP is valid immediately, but DHCP server and
|
||||
// beacon stability take a moment to come up cleanly. aqua-iq's
|
||||
// NetworkManager+dnsmasq pattern needs ~3 s here; the ESP softAP
|
||||
// stack is faster, but a 1.5 s pad still saves us from edge-case
|
||||
// races where a phone associates and starts DHCP before the AP
|
||||
// stack is fully ready.
|
||||
delay(AP_SETTLE_MS);
|
||||
|
||||
server.begin();
|
||||
dns.setErrorReplyCode(DNSReplyCode::NoError);
|
||||
|
||||
Reference in New Issue
Block a user