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:
2026-05-09 12:48:11 -04:00
parent 6d3dee7659
commit d1599a726d
7 changed files with 49 additions and 15 deletions
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+1 -1
View File
@@ -276,7 +276,7 @@ def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO C
leave_qr_white(draw, qx, qy, qp)
# "Encodes WIFI:..." label below
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
text_center(draw, cx, qy+qp+10, "WIFI:T:WPA;S:PictureFrame-91F8;P:pictureframe;;", F_FOOT, (100,100,95))
return img
+9
View File
@@ -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
View File
@@ -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);