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
|
RIGHT_X = 510; RIGHT_W = 290 # 800-510
|
||||||
|
|
||||||
# QR positions (MUST match epd.cpp constants)
|
# 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_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_CELL = 5
|
||||||
SETUP_QR_MODS = 41 # version 6, ECC_LOW
|
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
|
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
|
||||||
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
|
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
|
||||||
|
|
||||||
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
|
# Stacked layout: WiFi QR (top) + URL QR (bottom). Each has a label
|
||||||
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
|
# above it. AP_QR is dynamic — firmware overlays it at runtime. URL QR
|
||||||
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
|
# 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_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
|
||||||
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
|
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)
|
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
|
||||||
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=accent)
|
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
|
# Steps — step 1 is the unlock-first prompt (iOS won't fire the
|
||||||
# the captive portal from a locked-phone scan, and we'd rather
|
# captive UI from a locked-phone scan). Two-QR flow because iOS
|
||||||
# surface that requirement up front than have the user discover it
|
# in recent versions doesn't auto-open the captive portal even
|
||||||
# by scanning, getting nothing, and giving up.
|
# after CNA detects it; scanning the second QR opens Safari
|
||||||
|
# which forces the portal to render.
|
||||||
steps = [
|
steps = [
|
||||||
("Unlock your phone first", ""),
|
("Unlock your phone first", ""),
|
||||||
("Scan the QR code →", "Phone joins PictureFrame-91F8"),
|
("Scan QR 1 →", "joins PictureFrame WiFi"),
|
||||||
("Browser opens — enter", "your home WiFi password"),
|
("Scan QR 2 →", "page opens in Safari"),
|
||||||
("Tap Connect and watch", "for the QR code to change"),
|
("Enter your WiFi password", "and tap Connect"),
|
||||||
]
|
]
|
||||||
sy = BODY_Y + 95
|
sy = BODY_Y + 95
|
||||||
step_pitch = 38
|
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)
|
orientation_diagrams(draw, accent, show_active_ls=True)
|
||||||
|
|
||||||
# ── Right panel ──────────────────────────────────────────────
|
# ── 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
|
cx = RIGHT_CX
|
||||||
|
|
||||||
# QR label — accent-colored on retry so the failure is unmistakable.
|
|
||||||
label_color = accent if accent != YL else BK
|
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
|
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-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)
|
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)
|
leave_qr_white(draw, qx, qy, qp)
|
||||||
|
text_center(draw, cx, qy+qp+8, qr_label, F_FOOT, label_color)
|
||||||
|
|
||||||
# "Encodes WIFI:..." label below
|
# Step 2 — URL QR (static, baked here so we don't need a second
|
||||||
text_center(draw, cx, qy+qp+10, "WIFI:T:WPA;S:PictureFrame-91F8;P:pictureframe;;", F_FOOT, (100,100,95))
|
# 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
|
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) {
|
void epd_draw_ap_screen(QRCode* qr) {
|
||||||
// AP_QR_X=563, AP_QR_Y=185, AP_QR_CELL=5 (must match gen_screens.py)
|
// 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, 563, 185, 5);
|
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 581, 100, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
void epd_draw_ap_screen_retry(QRCode* qr) {
|
void epd_draw_ap_screen_retry(QRCode* qr) {
|
||||||
// Same QR coordinates — only the bg .bin differs (red accents,
|
// Same QR coordinates — only the bg .bin differs (red accents,
|
||||||
// "Connection Failed — try again" label).
|
// "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) {
|
void epd_draw_setup_screen(QRCode* qr) {
|
||||||
|
|||||||
Reference in New Issue
Block a user