Files
pictureFrame-firmware/scripts/gen_screens_13e6.py
T
football2801 8cbd035708 chore(branding): point firmware at wevisto.com
Flip APP_BASE_URL and the on-screen "go to <domain>/setup/..." text in
the rendered setup_bg images from pictureframe.edholm.me to wevisto.com.
Per the dual-domain migration plan (Option C — server keeps both alive
indefinitely), this only affects newly-flashed units; field devices on
the old URL keep working against the same backend.

Regenerated both panels' setup_bg.bin via gen_screens*.py so the
embedded URL in the on-screen QR overlay text matches the firmware's
runtime poll URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:30:38 -04:00

465 lines
20 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 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 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, ssid_text):
"""Yellow/red band along the top with a section title + SSID chip."""
draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent)
draw.text((LEFT_PAD, 40), header_text, font=F_BAR, fill=BK)
if ssid_text:
bb = draw.textbbox((0, 0), ssid_text, font=F_CHIP)
chip_w = bb[2] - bb[0] + 36
chip_x = W - chip_w - LEFT_PAD
draw.rectangle([chip_x, 25, chip_x + chip_w, HEADER_H - 25], fill=BK)
draw.text((chip_x + 18, 38), ssid_text, font=F_CHIP, fill=accent)
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)
# Universal brand chip — firmware doesn't write text into static .bin
# assets, so leaving a per-device SSID placeholder here would lie on
# every other unit. Same image for every frame.
draw_header(draw, accent, header_text, "PICTUREFRAME")
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 = [
("Plug in the frame", ""),
("Unlock your phone", ""),
("Scan QR 1", "joins your phone to PictureFrame"),
("Scan QR 2", "opens the setup page"),
("Type your home WiFi", "password and tap Connect"),
]
step_y0 = head_y + 240
step_pitch = 92
box = 50 # numbered black box size
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((LEFT_PAD + box + 22, by - 4), l1, font=F_STEP_B, fill=BK)
if l2:
draw.text((LEFT_PAD + box + 22, by + 32), l2, 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", "192.168.x.x")
# 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}")