diff --git a/data/waveshare73-v1/ap_bg.bin b/data/waveshare73-v1/ap_bg.bin index c6b04f7..7852d38 100644 Binary files a/data/waveshare73-v1/ap_bg.bin and b/data/waveshare73-v1/ap_bg.bin differ diff --git a/data/waveshare73-v1/ap_bg_preview.png b/data/waveshare73-v1/ap_bg_preview.png index d3caf28..e4b0cc6 100644 Binary files a/data/waveshare73-v1/ap_bg_preview.png and b/data/waveshare73-v1/ap_bg_preview.png differ diff --git a/data/waveshare73-v1/ap_bg_retry.bin b/data/waveshare73-v1/ap_bg_retry.bin index 50514c0..f74b3d4 100644 Binary files a/data/waveshare73-v1/ap_bg_retry.bin and b/data/waveshare73-v1/ap_bg_retry.bin differ diff --git a/data/waveshare73-v1/ap_bg_retry_preview.png b/data/waveshare73-v1/ap_bg_retry_preview.png index 109501b..350ce24 100644 Binary files a/data/waveshare73-v1/ap_bg_retry_preview.png and b/data/waveshare73-v1/ap_bg_retry_preview.png differ diff --git a/scripts/gen_screens.py b/scripts/gen_screens.py index a9e4bb7..dbe8277 100644 --- a/scripts/gen_screens.py +++ b/scripts/gen_screens.py @@ -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 diff --git a/src/config.h b/src/config.h index e98ee3a..d9df0e1 100644 --- a/src/config.h +++ b/src/config.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index b86325d..5f7326e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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);