Files
pictureFrame-firmware/scripts/gen_screens.py
T
football2801 1399cc3756 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>
2026-05-09 13:30:39 -04:00

459 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Generate ap_bg.bin and setup_bg.bin — 800×480 4bpp backgrounds for the
pictureFrame e-ink device. QR overlay areas are left WHITE so the
firmware can render the actual QR code at runtime.
Run from the firmware/ directory:
python3 scripts/gen_screens.py
Constants exported (copy to epd.cpp):
AP_QR_X, AP_QR_Y, AP_QR_CELL, AP_QR_PX
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL, SETUP_QR_PX
"""
from PIL import Image, ImageDraw, ImageFont
import qrcode
import os, sys
# URL for the user manual QR baked into Step 1/2. Static on purpose —
# changing it requires regenerating the bg images + uploadfs.
MANUAL_URL = "https://pictureframe.edholm.me/help"
# ── Display ──────────────────────────────────────────────────────────────────
W, H = 800, 480
# ── EPD palette ───────────────────────────────────────────────────────────────
BLACK = 0x0; BK = (26, 26, 26 )
WHITE = 0x1; WH = (245, 245, 240)
YELLOW = 0x2; YL = (240, 208, 0 )
RED = 0x3; RD = (192, 48, 32 )
BLUE = 0x5; BL = (24, 64, 192)
GREEN = 0x6; GR = (16, 160, 64 )
PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR}
def nearest(r, g, b):
best, best_d = WHITE, float("inf")
for n, (pr, pg, pb) in PALETTE_RGB.items():
d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2
if d < best_d: best, best_d = n, d
return best
def pack(img):
"""Convert RGB PIL image → 4bpp packed bytearray."""
px = img.load()
out = bytearray()
for y in range(H):
for x in range(0, W, 2):
hi = nearest(*px[x, y])
lo = nearest(*px[x+1, y])
out.append((hi << 4) | lo)
return out
# ── Fonts ─────────────────────────────────────────────────────────────────────
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
def ttf(name, size):
try: return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except: return ImageFont.load_default()
F_HEAD = ttf("DejaVuSans-Bold.ttf", 26)
F_BAR = ttf("DejaVuSans-Bold.ttf", 13)
F_STEP = ttf("DejaVuSans.ttf", 13)
F_STEP_B= ttf("DejaVuSans-Bold.ttf", 13)
F_STEPN = ttf("DejaVuSans-Bold.ttf", 13)
F_LABEL = ttf("DejaVuSans-Bold.ttf", 11)
F_TINY = ttf("DejaVuSans-Bold.ttf", 10)
F_FOOT = ttf("DejaVuSans.ttf", 12)
F_CHIP = ttf("DejaVuSans-Bold.ttf", 12)
F_SUB = ttf("DejaVuSans.ttf", 14)
F_BIG = ttf("DejaVuSans-Bold.ttf", 14)
# ── Layout constants ──────────────────────────────────────────────────────────
BAR_H = 52
BODY_Y = BAR_H # 52
LEFT_X = 0; LEFT_W = 310
DIV1_X = 310; DIV_W = 2
CTR_X = 312; CTR_W = 196
DIV2_X = 508
RIGHT_X = 510; RIGHT_W = 290 # 800-510
# QR positions (MUST match epd.cpp constants)
# 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 # 148
SETUP_QR_CELL = 5
SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
# Centre of right panel
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
# 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
SETUP_QR_Y = 175 # nudge for label
def leave_qr_white(draw, qr_x, qr_y, qr_px):
"""Blank the QR overlay region so firmware can write the real QR."""
draw.rectangle([qr_x, qr_y, qr_x+qr_px-1, qr_y+qr_px-1], fill=WH)
def text_center(draw, cx, y, text, font, fill):
bb = draw.textbbox((0,0), text, font=font)
tw = bb[2]-bb[0]
draw.text((cx - tw//2, y), text, font=font, fill=fill)
def up_arrow(draw, cx, cy, half_w=12, h=22, color=BK):
"""Solid filled triangle pointing up, centered on (cx, cy)."""
draw.polygon([
(cx, cy - h // 2), # tip
(cx - half_w, cy + h // 2), # base left
(cx + half_w, cy + h // 2), # base right
], fill=color)
def left_arrow(draw, cx, cy, half_h=12, w=22, color=BK):
"""Solid filled triangle pointing left, centered on (cx, cy)."""
draw.polygon([
(cx - w // 2, cy), # tip
(cx + w // 2, cy - half_h), # base top
(cx + w // 2, cy + half_h), # base bottom
], fill=color)
def orientation_diagrams(draw, accent, show_active_ls=True):
"""Draw both orientation diagrams in the centre panel.
The accent / show_active_ls parameters are kept for call-site
compatibility but no longer drive any colour decisions — both
diagrams render in black so neither orientation looks privileged.
The supported orientation is communicated by the up-arrow inside
the landscape screen instead."""
cx = CTR_X + CTR_W // 2 # 410
# ── Section title ─────────────────────────────────────────────
text_center(draw, cx, BODY_Y+15, "FRAME", F_TINY, BK)
text_center(draw, cx, BODY_Y+27, "ORIENTATION", F_TINY, BK)
# ── Landscape ──────────────────────────────────────────────────
ls_x, ls_y, ls_w, ls_h = CTR_X+43, BODY_Y+52, 110, 66
rib_w, rib_h = 110, 10
text_center(draw, cx, ls_y-14, "LANDSCAPE", F_LABEL, BK)
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=BK, width=3)
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=BK)
# Up arrow inside both diagrams — shows the user which edge is "up"
# when they hang the frame, regardless of orientation.
up_arrow(draw, ls_x + ls_w // 2, ls_y + ls_h // 2, half_w=14, h=26)
# Thin separator
sep_y = ls_y + ls_h + rib_h + 14
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
# ── Portrait — drawn 90° CCW from upright so it rotates back to a
# correct portrait view (tall rect, ribbon on left, arrow pointing
# up) when the user tilts the frame 90° CW from landscape. In the
# source/landscape view it appears as a wide rect with the ribbon
# on the bottom and the arrow pointing left — looks "rotated"
# because it is, on purpose.
pt_w, pt_h = 106, 64
pr_w, pr_h = 106, 10
pt_x = CTR_X + (CTR_W - pt_w) // 2
pt_y = sep_y + 14
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
draw.rectangle([pt_x, pt_y+pt_h, pt_x+pr_w-1, pt_y+pt_h+pr_h-1], fill=BK)
left_arrow(draw, pt_x + pt_w // 2, pt_y + pt_h // 2, half_h=12, w=22)
# ═══════════════════════════════════════════════════════════════════════════════
# AP SCREEN — accent-colored, WiFi credentials
# Pass accent=YL/header="SETUP MODE — STEP 1 OF 2"/qr_label="SCAN TO CONNECT"
# for the normal first-attempt screen, or accent=RD/header="CONNECTION FAILED
# — TRY AGAIN"/qr_label="Connection Failed — try again" for the post-WiFi-fail
# retry screen. Same layout either way so the panel diff is just color +
# header/label text.
# ═══════════════════════════════════════════════════════════════════════════════
def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO CONNECT"):
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=accent)
draw.text((24, 18), header, font=F_BAR, fill=BK)
# Right chip: black box with device SSID
chip_x, chip_y = 498, 11
chip_text = "PictureFrame-91F8"
bb = draw.textbbox((0,0), chip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x2 = chip_x + chip_w
draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK)
draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=accent)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
# Heading
draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK)
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 (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 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
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*step_pitch
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, accent)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
if l2:
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# Manual QR + side label — bottom of left panel.
# The manual covers the captive-portal-didn't-open fallback (192.168.4.1)
# plus general setup/troubleshooting, so we drop the inline footnote
# and rely on the QR/manual to deliver the longer story.
qr_bottom_y = BODY_Y + 95 + len(steps)*step_pitch + 12 # below last step
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=4,
border=2,
)
qr.add_data(MANUAL_URL)
qr.make(fit=True)
qr_img = qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
qr_w, qr_h = qr_img.size
img.paste(qr_img, (28, qr_bottom_y))
# Side label, vertically centered against the QR
label_x = 28 + qr_w + 14
label_y = qr_bottom_y + (qr_h - 56) // 2
draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK)
draw.text((label_x, label_y + 18), "Scan for setup", font=F_STEP, fill=BK)
draw.text((label_x, label_y + 32), "& troubleshooting", font=F_STEP, fill=BK)
# ── Centre panel ─────────────────────────────────────────────
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
label_color = accent if accent != YL else BK
# 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_white(draw, qx, qy, qp)
text_center(draw, cx, qy+qp+8, qr_label, F_FOOT, label_color)
# 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
def gen_ap_retry():
"""Step 1/2 with red accents + 'Connection Failed — try again' label,
served after a failed WiFi connection attempt."""
return gen_ap(
accent=RD,
header="CONNECTION FAILED — TRY AGAIN",
qr_label="Connection Failed — try again",
)
# ═══════════════════════════════════════════════════════════════════════════════
# SETUP SCREEN — green accent, account link
# ═══════════════════════════════════════════════════════════════════════════════
def gen_setup():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
# ── Status bar ────────────────────────────────────────────────
draw.rectangle([0, 0, W-1, BAR_H-1], fill=GR)
# WiFi bars icon
bars = [(0, 8), (0, 13), (0, 18), (0, 22)]
bx = 24
for i, (_, bh) in enumerate(bars):
draw.rectangle([bx + i*8, BAR_H//2 - bh//2, bx+i*8+5, BAR_H//2 + bh//2], fill=WH)
draw.text((bx+38, 18), "WIFI CONNECTED — STEP 2 OF 2", font=F_BAR, fill=WH)
# Right IP chip
ip_text = "192.168.x.x"
bb = draw.textbbox((0,0), ip_text, font=F_CHIP)
chip_w = bb[2]-bb[0] + 22
chip_x = W - chip_w - 20
draw.rectangle([chip_x, 11, chip_x+chip_w, BAR_H-12], fill=WH)
draw.text((chip_x+11, 18), ip_text, font=F_CHIP, fill=GR)
# ── Panel dividers ────────────────────────────────────────────
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
# ── Left panel ────────────────────────────────────────────────
draw.text((28, BODY_Y+20), "Almost", font=F_HEAD, fill=BK)
draw.text((28, BODY_Y+52), "ready.", font=F_HEAD, fill=BK)
bb = draw.textbbox((0,0), "ready.", font=F_HEAD)
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=GR)
draw.text((28, BODY_Y+96), "Scan to name this frame and", font=F_STEP, fill=(80,80,75))
draw.text((28, BODY_Y+110), "link it to your account.", font=F_STEP, fill=(80,80,75))
steps = [
("Scan the QR with your phone", "camera or QR app"),
("Sign in at pictureframe", ".edholm.me"),
("Name the frame, choose", "orientation — done."),
]
sy = BODY_Y + 136
for i, (l1, l2) in enumerate(steps):
bx, by = 28, sy + i*46
draw.rectangle([bx, by, bx+24, by+24], fill=GR)
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, WH)
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
# URL bar
url_y = BODY_Y + 278
draw.rectangle([28, url_y, 284, url_y+32], fill=BK)
draw.text((38, url_y+4), "URL", font=F_TINY, fill=GR)
draw.text((38, url_y+16), "pictureframe.edholm.me/setup/...", font=ttf("DejaVuSans.ttf", 10), fill=WH)
# Progress track
prog_y = BODY_Y + 328
draw.text((28, prog_y), "SETUP PROGRESS", font=F_TINY, fill=(140,140,135))
seg_y = prog_y + 14
segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200,200,195))]
seg_w = (284 - 28 - 8) // 3 # ~82px each
for i, (label, color) in enumerate(segs):
sx = 28 + i*(seg_w+4)
draw.rectangle([sx, seg_y, sx+seg_w, seg_y+6], fill=color)
text_center(draw, sx+seg_w//2, seg_y+10, label, ttf("DejaVuSans.ttf", 9), BK)
# ── Centre panel ─────────────────────────────────────────────
orientation_diagrams(draw, GR, show_active_ls=True)
# ── Right panel ──────────────────────────────────────────────
cx = RIGHT_CX
text_center(draw, cx, SETUP_QR_Y - 26, "SCAN TO FINISH", F_BIG, BK)
qx, qy, qp = SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=GR, width=3)
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
leave_qr_white(draw, qx, qy, qp)
# MAC chip below QR
mac = "1C:C3:AB:D1:91:F8"
bb = draw.textbbox((0,0), mac, font=F_CHIP)
mw = bb[2]-bb[0]+20
mx = cx - mw//2
draw.rectangle([mx, qy+qp+8, mx+mw, qy+qp+26], fill=BK)
draw.text((mx+10, qy+qp+11), mac, font=ttf("DejaVuSans-Bold.ttf", 10), fill=WH)
return img
# ── Save ──────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path):
data = pack(img)
with open(path, "wb") as f: f.write(data)
print(f"Written {len(data):,} bytes → {os.path.abspath(path)}")
# Reconstruct preview from packed data for verification
prev = Image.new("RGB", (W, H))
px = prev.load()
for y in range(H):
for x in range(0, W, 2):
byte = data[y*(W//2) + x//2]
px[x, y] = PALETTE_RGB.get(byte >> 4, (128,128,128))
px[x+1, y] = PALETTE_RGB.get(byte & 0xF, (128,128,128))
prev.save(preview_path)
print(f"Preview → {os.path.abspath(preview_path)}")
if __name__ == "__main__":
# Per-panel output directory: data/{vendor}-v{N}/. Defaults to the V1
# 7.3" panel since that's the only one in production; pass --panel to
# target a different one once new panels exist.
panel = "waveshare73-v1"
if "--panel" in sys.argv:
panel = sys.argv[sys.argv.index("--panel") + 1]
out_dir = os.path.join(os.path.dirname(__file__), f"../data/{panel}")
os.makedirs(out_dir, exist_ok=True)
print(f"Generating AP screen for {panel}")
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
print()
print(f"Generating AP retry screen for {panel}")
save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png")
print()
print(f"Generating setup screen for {panel}")
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
print()
print("QR overlay constants for epd.cpp:")
print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}")
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")