#!/usr/bin/env python3 """ Generate 1200×1600 portrait setup-screen backgrounds for the Waveshare 13.3" Spectra-6 panel. Layout mirrors the 7.3" gen_screens.py (yellow header + instructions + QR codes) but adapted for portrait: a single yellow header band on top, then a two-column body — instructions + manual QR on the left, the two runtime QR codes on the right. No orientation chooser (13.3" setup is portrait-only — see project memory). Run from the firmware/ directory: python3 scripts/gen_screens_13e6.py Exports the QR overlay constants the firmware uses to overlay the runtime QR images on the static .bin backgrounds; the driver must hold the same numbers. """ from PIL import Image, ImageDraw, ImageFont import qrcode import os, sys MANUAL_URL = "https://wevisto.com/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_BAR = ttf("DejaVuSans-Bold.ttf", 34) # header band text F_CHIP = ttf("DejaVuSans-Bold.ttf", 30) # SSID chip F_HEAD = ttf("DejaVuSans-Bold.ttf", 78) # "Connect to WiFi" heading F_TITLE = ttf("DejaVuSans-Bold.ttf", 60) # "Almost ready" on setup screen F_LABEL = ttf("DejaVuSans-Bold.ttf", 36) # column section labels F_STEPN = ttf("DejaVuSans-Bold.ttf", 32) # step number inside black box F_STEP_B = ttf("DejaVuSans-Bold.ttf", 30) # bold step body F_STEP = ttf("DejaVuSans.ttf", 30) # step body F_FOOT = ttf("DejaVuSans.ttf", 26) # captions under QR F_TINY = ttf("DejaVuSans-Bold.ttf", 22) # progress-bar labels F_URL = ttf("DejaVuSans.ttf", 22) # URL bar mono # ── Layout constants ───────────────────────────────────────────────────────── HEADER_H = 130 # header band height BODY_Y = HEADER_H DIV_X = 600 # vertical divider between left + right columns LEFT_X = 0; LEFT_W = 600 RIGHT_X = 602; RIGHT_W = W - RIGHT_X # 598 LEFT_PAD = 36 RIGHT_PAD = 36 # ── QR overlay regions — MUST match the panel driver ───────────────────────── # Dynamic QRs (left as WHITE rectangles in the .bin so firmware can overlay). AP_QR_MODS = 37 # version 5, ECC_LOW AP_QR_CELL = 14 # 37 × 14 = 518 px AP_QR_PX = AP_QR_MODS * AP_QR_CELL AP_QR_X = RIGHT_X + (RIGHT_W - AP_QR_PX) // 2 # 642 AP_QR_Y = BODY_Y + 100 # 230 SETUP_QR_MODS = 41 # version 6, ECC_LOW SETUP_QR_CELL = 16 # 41 × 16 = 656 px SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 272 SETUP_QR_Y = BODY_Y + 360 # 490 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 wrap_to_width(draw, text, font, max_w): """Greedy word-wrap so each rendered line stays within max_w pixels. Doesn't split individual words. Returns a list of lines.""" lines = [] current = "" for word in text.split(): candidate = (current + " " + word) if current else word bb = draw.textbbox((0, 0), candidate, font=font) if bb[2] - bb[0] <= max_w: current = candidate else: if current: lines.append(current) current = word if current: lines.append(current) return lines 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 — accent outer + black inner.""" 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) def draw_header(draw, accent, header_text): """Coloured band along the top with section title on the left. Logo placeholder is rendered separately by draw_logo_placeholder() so the brand mark sits in the top right of every setup screen.""" draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent) draw.text((LEFT_PAD, 40), header_text, font=F_BAR, fill=BK) # ── Logo ───────────────────────────────────────────────────────────────────── # Composed mirror of webApp/frontend/public/logo.svg: harbour photo + # black-fade gradient + "WeVisto" wordmark (V in yellow). Rendered in # pure PIL because cairosvg / rsvg / imagemagick aren't installed on the # build host. Source photo lives in webApp/brand/. LOGO_SIDE = 110 # square; fits within HEADER_H=130 with padding LOGO_X = W - LOGO_SIDE - LEFT_PAD # 1054 LOGO_Y = (HEADER_H - LOGO_SIDE) // 2 # 10 LOGO_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "webApp", "brand", "IMG_2524-square900.jpg") def compose_logo(size): """Returns a size×size RGB PIL image of the WeVisto wordmark over the Camogli harbour photo. Matches the SVG layout in webApp/frontend/public/logo.svg (320×320 viewBox).""" bg = Image.open(LOGO_SRC).convert("RGB").resize((size, size), Image.LANCZOS) # Black overlay band: 45% opacity peak at 42-58% height, fading at edges. # Matches the linearGradient stops in the SVG. overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0)) od = ImageDraw.Draw(overlay) for y in range(size): t = y / max(1, size - 1) if t < 0.42: a = 0.45 * (t / 0.42) elif t < 0.58: a = 0.45 else: a = 0.45 * ((1 - t) / 0.42) od.line([(0, y), (size, y)], fill=(0, 0, 0, int(a * 255))) bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB") # Wordmark: "We" white + "V" yellow + "isto" white, centred at y≈0.547. draw = ImageDraw.Draw(bg) font_px = max(10, int(62 * size / 320)) font = ttf("DejaVuSans-Bold.ttf", font_px) parts = [("We", WH), ("V", YL), ("isto", WH)] widths = [draw.textbbox((0, 0), t, font=font)[2] for t, _ in parts] total_w = sum(widths) x = (size - total_w) // 2 y = int(0.547 * size) - font_px // 2 - 4 # nudged up to match SVG for (t, fill), w in zip(parts, widths): draw.text((x, y), t, font=font, fill=fill) x += w return bg def draw_logo_placeholder(img): """Paste the composed WeVisto logo in the top-right of the header.""" img.paste(compose_logo(LOGO_SIDE), (LOGO_X, LOGO_Y)) def draw_divider(draw): """Vertical divider between the two body columns.""" draw.rectangle([DIV_X, BODY_Y + 20, DIV_X + 1, H - 30], fill=BK) def up_arrow(draw, cx, cy, half_w=18, h=34, color=BK): """Solid filled triangle pointing up, centered on (cx, cy).""" draw.polygon([ (cx, cy - h // 2), (cx - half_w, cy + h // 2), (cx + half_w, cy + h // 2), ], fill=color) def left_arrow(draw, cx, cy, half_h=18, w=34, color=BK): """Solid filled triangle pointing left, centered on (cx, cy).""" draw.polygon([ (cx - w // 2, cy), (cx + w // 2, cy - half_h), (cx + w // 2, cy + half_h), ], fill=color) def paste_rotated_text(img, text, font, fill, anchor_xy, ccw_degrees=90): """ Render `text` horizontally onto a transparent overlay, rotate ccw, and paste it onto `img` at `anchor_xy` (top-left of the rotated bounding box). Used for vertical labels on orientation diagrams — PIL's ImageDraw.text() can't rotate, so we render-then-rotate. """ bb = font.getbbox(text) tw, th = bb[2] - bb[0], bb[3] - bb[1] pad = 4 layer = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0)) ImageDraw.Draw(layer).text((pad - bb[0], pad - bb[1]), text, font=font, fill=fill) rotated = layer.rotate(ccw_degrees, expand=True, resample=Image.BILINEAR) img.paste(rotated, anchor_xy, rotated) def orientation_diagrams(img, cx, top_y, label_color=None): """ Side-by-side PORTRAIT / LANDSCAPE diagrams illustrating the two ways the user can hang the frame. Drawn in current portrait-view coords: PORTRAIT = upright tall rect, ribbon along the bottom short edge, up-arrow inside. LANDSCAPE = pre-rotated 90° CCW from upright landscape. The frame rotation portrait→landscape is 90° CW (ribbon moves bottom→left as viewed by the user); the CCW pre-rotation cancels that, so when the user picks the frame up and rotates it 90° CW into landscape the diagram lands upright (wide rect, ribbon-left, up-arrow). In the portrait rendering that means: tall rect, ribbon along bottom edge (was the LEFT edge upright), LEFT- pointing arrow (was UP upright), and the "LANDSCAPE" label rotated 90° CCW so it runs up the long edge — reads horizontally once the frame is mounted landscape. """ if label_color is None: label_color = BK draw = ImageDraw.Draw(img) # Section heading text_center(draw, cx, top_y, "FRAME", F_TINY, label_color) text_center(draw, cx, top_y + 28, "ORIENTATION", F_TINY, label_color) # Same external dimensions for both diagrams (the LANDSCAPE is a 90°-CCW # rotation of an upright wide rect — its bounding box is square'd to # match portrait so the pair sits in a clean two-up grid). diag_w, diag_h = 130, 200 ribbon_thick = 14 pair_gap = 100 # extra room so the rotated label doesn't crowd the divider pair_w = diag_w * 2 + pair_gap base_y = top_y + 100 pt_x = cx - pair_w // 2 ls_x = pt_x + diag_w + pair_gap pt_y = base_y ls_y = base_y diag_bottom = base_y + diag_h # PORTRAIT — upright tall rect, ribbon along bottom short edge. draw.rectangle([pt_x, pt_y, pt_x + diag_w - 1, pt_y + diag_h - 1], outline=BK, width=3) draw.rectangle([pt_x, pt_y + diag_h - ribbon_thick, pt_x + diag_w - 1, pt_y + diag_h - 1], fill=BK) up_arrow(draw, pt_x + diag_w // 2, pt_y + (diag_h - ribbon_thick) // 2) text_center(draw, pt_x + diag_w // 2, diag_bottom + 14, "PORTRAIT", F_TINY, BK) # LANDSCAPE — pre-rotated 90° CCW from upright. # Upright landscape: wide rect, ribbon along LEFT long edge, UP arrow. # After 90° CCW (in portrait rendering): tall rect, ribbon along BOTTOM # short edge, LEFT-pointing arrow. Label runs up the LEFT long edge, # rotated 90° CCW so it reads L→R once the frame is rotated to landscape. draw.rectangle([ls_x, ls_y, ls_x + diag_w - 1, ls_y + diag_h - 1], outline=BK, width=3) draw.rectangle([ls_x, ls_y + diag_h - ribbon_thick, ls_x + diag_w - 1, ls_y + diag_h - 1], fill=BK) left_arrow(draw, ls_x + diag_w // 2, ls_y + (diag_h - ribbon_thick) // 2) # Rotated label, anchored just left of the diagram's left long edge. label_text = "LANDSCAPE" bb = F_TINY.getbbox(label_text) label_w = bb[2] - bb[0] label_h = bb[3] - bb[1] # Rotated label is `label_w` tall, `label_h` wide. Centred vertically # against the rect, sitting just to its left. rotated_x = ls_x - label_h - 16 rotated_y = ls_y + (diag_h - label_w) // 2 paste_rotated_text(img, label_text, F_TINY, BK, (rotated_x, rotated_y), ccw_degrees=90) # ═══════════════════════════════════════════════════════════════════════════════ # AP SCREEN — yellow (or red retry) accent, Step 1 of 2 # ═══════════════════════════════════════════════════════════════════════════════ def gen_ap(accent=YL, header_text="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO CONNECT"): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) draw_header(draw, accent, header_text) draw_logo_placeholder(img) draw_divider(draw) # ── LEFT COLUMN ────────────────────────────────────────────────────────── # Big "Connect to WiFi" heading with accent underline. head_y = BODY_Y + 30 draw.text((LEFT_PAD, head_y), "Connect to", font=F_HEAD, fill=BK) draw.text((LEFT_PAD, head_y + 90), "WiFi", font=F_HEAD, fill=BK) bb = draw.textbbox((0, 0), "WiFi", font=F_HEAD) underline_y = head_y + 90 + bb[3] + 6 draw.rectangle([LEFT_PAD, underline_y, LEFT_PAD + bb[2] + 4, underline_y + 6], fill=accent) # 5 numbered steps — same instructions as the 7.3" but with the larger # type that fits comfortably on a 13.3" portrait body. steps = [ ("Turn on your WeVisto", ""), ("Unlock your phone", ""), ("Scan QR 1", "This will connect your phone to the WeVisto"), ("Scan QR 2", "This will open the WeVisto setup page"), ] step_y0 = head_y + 240 step_pitch = 112 box = 50 # numbered black box size text_x = LEFT_PAD + box + 22 text_max_w = DIV_X - text_x - 18 # don't cross the column divider line_h = 34 # ~F_STEP line height for i, (l1, l2) in enumerate(steps): by = step_y0 + i * step_pitch draw.rectangle([LEFT_PAD, by, LEFT_PAD + box, by + box], fill=BK) text_center(draw, LEFT_PAD + box // 2, by + 8, str(i + 1), F_STEPN, accent) draw.text((text_x, by - 4), l1, font=F_STEP_B, fill=BK) if l2: for j, line in enumerate(wrap_to_width(draw, l2, F_STEP, text_max_w)): draw.text((text_x, by + 32 + j * line_h), line, font=F_STEP, fill=BK) # Orientation diagrams — tucked between the steps and the manual QR so # the user sees both possible hanging positions before they commit. orientation_diagrams(img, LEFT_X + LEFT_W // 2, step_y0 + len(steps) * step_pitch + 60) # Manual QR bottom-left — covers "captive portal didn't open" + general # troubleshooting. Same intent as the 7.3" but bigger box, more room. manual_qr = qrcode.QRCode( version=None, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=7, border=2, ) manual_qr.add_data(MANUAL_URL) manual_qr.make(fit=True) manual_img = manual_qr.make_image(fill_color=BK, back_color=WH).convert("RGB") mw, mh = manual_img.size manual_x = LEFT_PAD manual_y = H - mh - 60 img.paste(manual_img, (manual_x, manual_y)) # Side label aligned with manual QR. label_x = manual_x + mw + 28 label_y = manual_y + (mh - 80) // 2 draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK) draw.text((label_x, label_y + 38), "Scan for setup", font=F_STEP, fill=BK) draw.text((label_x, label_y + 70), "& troubleshoot", font=F_STEP, fill=BK) # ── RIGHT COLUMN ───────────────────────────────────────────────────────── rcx = RIGHT_X + RIGHT_W // 2 # Step 1 — WiFi join QR (dynamic, firmware overlay) text_center(draw, rcx, AP_QR_Y - 56, "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, rcx, AP_QR_Y + AP_QR_PX + 16, qr_label, F_FOOT, BK) # Step 2 — URL QR (static "http://192.168.4.1/", baked into bg). Scanning # this in iOS Safari forces the captive portal to render — works around # iOS's reluctance to auto-launch CNA from a QR-scan WiFi join. 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") uw, uh = url_img.size url_x = rcx - uw // 2 url_y = H - uh - 100 text_center(draw, rcx, url_y - 56, "STEP 2 — OPEN PAGE", F_LABEL, BK) draw.rectangle([url_x - 12, url_y - 12, url_x + uw + 11, url_y + uh + 11], outline=accent, width=6) draw.rectangle([url_x - 4, url_y - 4, url_x + uw + 3, url_y + uh + 3], outline=BK, width=4) img.paste(url_img, (url_x, url_y)) text_center(draw, rcx, url_y + uh + 12, "http://192.168.4.1/", F_FOOT, BK) return img def gen_ap_retry(): """Red-accented retry screen, served after a failed WiFi-join attempt.""" return gen_ap( accent=RD, header_text="CONNECTION FAILED — TRY AGAIN", qr_label="Connection failed — try again", ) # ═══════════════════════════════════════════════════════════════════════════════ # SETUP SCREEN — green accent, post-WiFi setup-claim QR # Single-column centred layout: heading + numbered steps + big setup QR + MAC. # No two-column split here because the QR is much bigger (16-px cells × 41 # modules = 656 px) and a side column would crowd it. # ═══════════════════════════════════════════════════════════════════════════════ def gen_setup(): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) draw_header(draw, GR, "WIFI CONNECTED — STEP 2 OF 2") draw_logo_placeholder(img) # Centered heading text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK) bb = draw.textbbox((0, 0), "Almost ready", font=F_HEAD) underline_w = bb[2] + 4 text_w = bb[2] - bb[0] underline_x = (W - text_w) // 2 underline_y = BODY_Y + 30 + bb[3] + 6 draw.rectangle([underline_x, underline_y, underline_x + text_w, underline_y + 6], fill=GR) text_center(draw, W // 2, BODY_Y + 170, "Scan the QR to link this frame", F_STEP_B, BK) text_center(draw, W // 2, BODY_Y + 210, "to your wevisto.com account.", F_STEP, BK) # Setup QR with decorative border + green/black double frame 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) text_center(draw, W // 2, SETUP_QR_Y - 56, "SCAN TO FINISH", F_LABEL, BK) # MAC chip below QR — frame's identifier so the user knows which device # they're claiming. Static placeholder until firmware writes text here. mac = "1C:C3:AB:D1:91:F8" bb = draw.textbbox((0, 0), mac, font=F_CHIP) chip_w = bb[2] - bb[0] + 32 chip_x = (W - chip_w) // 2 chip_y = SETUP_QR_Y + SETUP_QR_PX + 30 draw.rectangle([chip_x, chip_y, chip_x + chip_w, chip_y + 50], fill=BK) text_center(draw, W // 2, chip_y + 14, mac, F_CHIP, WH) text_center(draw, W // 2, chip_y + 80, "Your frame's MAC — handy for support", F_FOOT, BK) # Progress track at the bottom track_y = H - 90 track_h = 14 seg_pad = 20 segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200, 200, 195))] seg_w = (W - LEFT_PAD * 2 - seg_pad * 2) // 3 text_center(draw, W // 2, track_y - 36, "SETUP PROGRESS", F_TINY, BK) for i, (label, color) in enumerate(segs): sx = LEFT_PAD + i * (seg_w + seg_pad) draw.rectangle([sx, track_y, sx + seg_w, track_y + track_h], fill=color) text_center(draw, sx + seg_w // 2, track_y + track_h + 10, label, 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}")