#!/usr/bin/env python3 """ Generate 1200×1600 portrait setup-screen backgrounds for the Waveshare 13.3" Spectra-6 panel. Layout is a single-column vertical stack — much simpler than the 800×480 three-panel design in gen_screens.py — chosen for the end-to-end port MVP. Polish the visual treatment later once the provisioning flow is proven on real hardware. Run from the firmware/ directory: python3 scripts/gen_screens_13e6.py Constants used by src/panels/waveshare13e6/v1/epd_driver.cpp: AP_QR_X, AP_QR_Y, AP_QR_CELL SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL """ from PIL import Image, ImageDraw, ImageFont import qrcode import os, sys MANUAL_URL = "https://pictureframe.edholm.me/help" W, H = 1200, 1600 # ── Spectra-6 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): """RGB PIL → 4bpp packed bytearray, row-major panel-native order.""" 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 Exception: return ImageFont.load_default() F_HEADER = ttf("DejaVuSans-Bold.ttf", 44) F_TITLE = ttf("DejaVuSans-Bold.ttf", 60) F_LABEL = ttf("DejaVuSans-Bold.ttf", 32) F_STEP = ttf("DejaVuSans.ttf", 32) F_STEP_B = ttf("DejaVuSans-Bold.ttf", 32) F_FOOT = ttf("DejaVuSans.ttf", 28) F_NUM = ttf("DejaVuSans-Bold.ttf", 30) F_TINY = ttf("DejaVuSans-Bold.ttf", 20) # ── QR overlay regions — must match the panel driver ────────────────────────── # Cell sizes are chosen so each QR fits comfortably in its vertical band with # room for label + caption. Centered horizontally on a 1200-wide canvas. AP_QR_MODS = 37 # version 5, ECC_LOW AP_QR_CELL = 16 # 37 × 16 = 592 px square AP_QR_PX = AP_QR_MODS * AP_QR_CELL AP_QR_X = (W - AP_QR_PX) // 2 # 304 AP_QR_Y = 220 SETUP_QR_MODS = 41 # version 6, ECC_LOW SETUP_QR_CELL = 14 # 41 × 14 = 574 px square SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 313 SETUP_QR_Y = 450 HEADER_H = 120 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 leave_qr_white(draw, qr_x, qr_y, qr_px): draw.rectangle([qr_x, qr_y, qr_x + qr_px - 1, qr_y + qr_px - 1], fill=WH) def draw_qr_frame(draw, qx, qy, qp, accent): """Two-layer decorative border around a QR placeholder.""" draw.rectangle([qx - 12, qy - 12, qx + qp + 11, qy + qp + 11], outline=accent, width=6) draw.rectangle([qx - 4, qy - 4, qx + qp + 3, qy + qp + 3], outline=BK, width=4) # ── AP SCREEN (Step 1/2 — WiFi join) ────────────────────────────────────────── def gen_ap(accent=YL, header_text="SETUP MODE — STEP 1 OF 2", qr_caption="Scan to join PictureFrame"): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) # Status header band draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent) text_center(draw, W // 2, 38, header_text, F_HEADER, BK) # WiFi QR step text_center(draw, W // 2, AP_QR_Y - 60, "STEP 1 — JOIN WIFI", F_LABEL, BK) draw_qr_frame(draw, AP_QR_X, AP_QR_Y, AP_QR_PX, accent) leave_qr_white(draw, AP_QR_X, AP_QR_Y, AP_QR_PX) text_center(draw, W // 2, AP_QR_Y + AP_QR_PX + 16, qr_caption, F_FOOT, BK) # URL QR step — static, baked into the bg. Scanning it opens Safari, # which forces iOS to render the captive portal. url_qr = qrcode.QRCode( version=None, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, 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_y = 1180 url_x = (W - url_w) // 2 text_center(draw, W // 2, url_y - 60, "STEP 2 — OPEN PAGE", F_LABEL, BK) draw.rectangle([url_x - 12, url_y - 12, url_x + url_w + 11, url_y + url_h + 11], outline=accent, width=6) draw.rectangle([url_x - 4, url_y - 4, url_x + url_w + 3, url_y + url_h + 3], outline=BK, width=4) img.paste(url_img, (url_x, url_y)) text_center(draw, W // 2, url_y + url_h + 16, "http://192.168.4.1/", F_FOOT, BK) return img def gen_ap_retry(): """Step 1/2 with red accent + retry messaging.""" return gen_ap( accent=RD, header_text="CONNECTION FAILED — TRY AGAIN", qr_caption="Connection failed — try again", ) # ── SETUP SCREEN (post-WiFi, scan-to-claim) ─────────────────────────────────── def gen_setup(): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=GR) text_center(draw, W // 2, 38, "WIFI CONNECTED — STEP 2 OF 2", F_HEADER, WH) text_center(draw, W // 2, 200, "Almost ready", F_TITLE, BK) text_center(draw, W // 2, 290, "Scan the QR to link this frame", F_STEP, BK) text_center(draw, W // 2, 332, "to your pictureframe.edholm.me account.", F_STEP, BK) text_center(draw, W // 2, SETUP_QR_Y - 60, "SCAN TO FINISH", F_LABEL, BK) draw_qr_frame(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX, GR) leave_qr_white(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX) # MAC chip below QR — populated at runtime by the firmware would be nicer, # but the firmware doesn't write text yet on this panel; the link-target # URL is what the user actually scans, so the chip stays a static "your # frame ID will appear in the QR" placeholder for now. text_center(draw, W // 2, SETUP_QR_Y + SETUP_QR_PX + 16, "Your frame ID is encoded in the QR above.", F_FOOT, BK) 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)}") 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__": out_dir = os.path.join(os.path.dirname(__file__), "../data/waveshare13e6-v1") os.makedirs(out_dir, exist_ok=True) print("Generating AP screen…") save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png") print() print("Generating AP retry screen…") save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png") print() print("Generating setup screen…") save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png") print() print("QR overlay constants — keep these in sync with epd_driver.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}, " f"SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")