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:
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 |
+53
-21
@@ -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"),
|
||||
("Unlock your phone first", ""),
|
||||
("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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user