fix(provisioning): two-QR flow — WiFi join + open-browser trigger

User report: iOS sees the captive network correctly (the captive UI
fires the moment any app makes an HTTP request), but won't auto-pop
it from a QR-scan join. This is recent-iOS hardening — Apple no
longer aggressively opens CNA on QR-initiated joins.

Workaround: a single QR can only encode one action, but two QRs
side-by-side close the loop —

  STEP 1 — WiFi-join QR (WIFI:T:WPA;S:NAME;P:pass;;)
           Phone joins PictureFrame.
  STEP 2 — URL QR (http://192.168.4.1/)
           Phone opens Safari → Safari hits 192.168.4.1 → that HTTP
           request is the "any app" trigger that fires the captive
           UI deterministically.

Implementation:
- WiFi QR shrinks from cell 5 (185 px) to cell 4 (148 px) to make
  room for the URL QR below.
- URL QR is static, baked into ap_bg.bin via Python qrcode at gen
  time — no firmware QR-render changes needed for it.
- epd_draw_ap_screen / _retry overlay coords updated to match the
  new WiFi QR position (581, 100, 4).
- Left-panel step list now reads "1. Unlock / 2. Scan QR 1 / 3. Scan
  QR 2 / 4. Enter password and tap Connect".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:30:39 -04:00
parent d1599a726d
commit 1399cc3756
6 changed files with 56 additions and 24 deletions
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+52 -20
View File
@@ -80,9 +80,12 @@ DIV2_X = 508
RIGHT_X = 510; RIGHT_W = 290 # 800-510
# QR positions (MUST match epd.cpp constants)
AP_QR_CELL = 5
# WiFi-join QR — drawn at runtime by firmware. Cell shrunk from 5 to 4
# (148 px instead of 185 px) to leave room for a second QR below it that
# opens Safari → forces iOS captive UI.
AP_QR_CELL = 4
AP_QR_MODS = 37 # version 5, ECC_LOW
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 148
SETUP_QR_CELL = 5
SETUP_QR_MODS = 41 # version 6, ECC_LOW
@@ -92,9 +95,13 @@ SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
# Stacked layout: WiFi QR (top) + URL QR (bottom). Each has a label
# above it. AP_QR is dynamic — firmware overlays it at runtime. URL QR
# is static (always http://192.168.4.1/) and baked into the bg image.
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # centered horizontally
AP_QR_Y = 100 # below STEP 1 label
URL_QR_BOX = 4 # cell size in px
URL_QR_TARGET_Y = 320 # below STEP 2 label
SETUP_QR_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
@@ -213,15 +220,16 @@ def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO C
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=accent)
# Steps — step 1 is the unlock-first prompt because iOS won't open
# the captive portal from a locked-phone scan, and we'd rather
# surface that requirement up front than have the user discover it
# by scanning, getting nothing, and giving up.
# Steps — step 1 is the unlock-first prompt (iOS won't fire the
# captive UI from a locked-phone scan). Two-QR flow because iOS
# in recent versions doesn't auto-open the captive portal even
# after CNA detects it; scanning the second QR opens Safari
# which forces the portal to render.
steps = [
("Unlock your phone first", ""),
("Scan the QR code", "Phone joins PictureFrame-91F8"),
("Browser opens — enter", "your home WiFi password"),
("Tap Connect and watch", "for the QR code to change"),
("Scan QR 1", "joins PictureFrame WiFi"),
("Scan QR 2 →", "page opens in Safari"),
("Enter your WiFi password", "and tap Connect"),
]
sy = BODY_Y + 95
step_pitch = 38
@@ -261,22 +269,46 @@ def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO C
orientation_diagrams(draw, accent, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
# Stacked: STEP 1 (WiFi join QR, dynamic) above STEP 2 (URL QR, static
# → http://192.168.4.1/, baked into bg). The URL QR is the trick that
# forces iOS to open the captive portal: scanning a URL QR launches
# Safari, Safari hits 192.168.4.1, iOS sees the request go to a
# captive network and renders the portal instead of fighting whether
# to auto-show CNA.
cx = RIGHT_CX
# QR label — accent-colored on retry so the failure is unmistakable.
label_color = accent if accent != YL else BK
text_center(draw, cx, AP_QR_Y - 26, qr_label, F_BIG, label_color)
# QR border: accent outer, black inner
# Step 1 — WiFi join (dynamic QR, overlaid by firmware)
text_center(draw, cx, AP_QR_Y - 22, "STEP 1 — JOIN WIFI", F_BIG, label_color)
qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=accent, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
# Leave QR area white for firmware overlay
leave_qr_white(draw, qx, qy, qp)
text_center(draw, cx, qy+qp+8, qr_label, F_FOOT, label_color)
# "Encodes WIFI:..." label below
text_center(draw, cx, qy+qp+10, "WIFI:T:WPA;S:PictureFrame-91F8;P:pictureframe;;", F_FOOT, (100,100,95))
# Step 2 — URL QR (static, baked here so we don't need a second
# firmware render path; URL is fixed at http://192.168.4.1/).
url_qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=URL_QR_BOX,
border=2,
)
url_qr.add_data("http://" + "192.168.4.1" + "/")
url_qr.make(fit=True)
url_img = url_qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
url_w, url_h = url_img.size
url_x = cx - url_w // 2
url_y = URL_QR_TARGET_Y
text_center(draw, cx, url_y - 22, "STEP 2 — OPEN PAGE", F_BIG, label_color)
# Decorative border around the static URL QR — same accent treatment
# as the WiFi QR for visual consistency.
draw.rectangle([url_x-6, url_y-6, url_x+url_w+5, url_y+url_h+5], outline=accent, width=3)
draw.rectangle([url_x-3, url_y-3, url_x+url_w+2, url_y+url_h+2], outline=BK, width=3)
img.paste(url_img, (url_x, url_y))
text_center(draw, cx, url_y + url_h + 8, "http://192.168.4.1/", F_FOOT, label_color)
return img
+3 -3
View File
@@ -175,14 +175,14 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
}
void epd_draw_ap_screen(QRCode* qr) {
// AP_QR_X=563, AP_QR_Y=185, AP_QR_CELL=5 (must match gen_screens.py)
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 563, 185, 5);
// AP_QR_X=581, AP_QR_Y=100, AP_QR_CELL=4 (must match gen_screens.py)
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 581, 100, 4);
}
void epd_draw_ap_screen_retry(QRCode* qr) {
// Same QR coordinates — only the bg .bin differs (red accents,
// "Connection Failed — try again" label).
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 563, 185, 5);
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 581, 100, 4);
}
void epd_draw_setup_screen(QRCode* qr) {