e089911cfa
Two changes to the yellow Step 1/2 (and red retry-twin) screens, both in service of the locked-phone-scan failure mode where iOS joins the AP but never opens the captive portal: * Promote 'Unlock your phone first' to step 1 of a four-step list (was three steps starting with 'Scan the QR'). Tightens step pitch from 46→38 px to fit the new step. Surfacing the requirement visually beats discovering it by scanning, getting nothing, and giving up. * Bake a manual-link QR into the bottom of the left panel pointing to https://pictureframe.edholm.me/help. Side label 'Need help? / Scan for setup / & troubleshooting'. Static URL → encoded directly into ap_bg.bin via the qrcode Python lib at gen time, no firmware QR-render changes needed. Retry twin (ap_bg_retry.bin) inherits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
400 lines
18 KiB
Python
400 lines
18 KiB
Python
#!/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)
|
||
AP_QR_CELL = 5
|
||
AP_QR_MODS = 37 # version 5, ECC_LOW
|
||
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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 orientation_diagrams(draw, accent, show_active_ls=True):
|
||
"""Draw both orientation diagrams in the centre panel.
|
||
accent = RGB colour for the active / ribbon highlights."""
|
||
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, accent if show_active_ls else BK)
|
||
|
||
ls_border = accent if show_active_ls else BK
|
||
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=ls_border, width=3)
|
||
rib_rgb = accent if show_active_ls else BK
|
||
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=rib_rgb)
|
||
|
||
if show_active_ls:
|
||
# check badge
|
||
bx, by = cx-9, ls_y+ls_h+rib_h+5
|
||
draw.rectangle([bx, by, bx+18, by+18], fill=accent)
|
||
text_center(draw, bx+9, by+3, "✓", F_CHIP, BK)
|
||
|
||
# Thin separator
|
||
sep_y = ls_y + ls_h + rib_h + (30 if show_active_ls else 14)
|
||
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
|
||
|
||
# ── Portrait ──────────────────────────────────────────────────
|
||
pt_x, pt_y = CTR_X+56, sep_y+14
|
||
pt_w, pt_h = 64, 106
|
||
pr_w, pr_h = 10, 106
|
||
|
||
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
|
||
|
||
draw.rectangle([pt_x-pr_w, pt_y, pt_x-1, pt_y+pr_h-1], fill=BK)
|
||
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# 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 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 = [
|
||
("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"),
|
||
]
|
||
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 ──────────────────────────────────────────────
|
||
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
|
||
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)
|
||
|
||
# "Encodes WIFI:..." label below
|
||
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
|
||
|
||
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}")
|