Files
pictureFrame-firmware/scripts/gen_screens_13e6.py
T
football2801 b0ea1ce216 feat(setup): Variant B full-bleed hero on 13.3 panel
Adopts the hero treatment Matt picked from the /tmp/setup-mockups
gallery: a 1200×460 harbour photo banner with the WeVisto wordmark at
200pt overlaid centred, then a 70-px accent band carrying the section
title. Replaces the prior 130-tall single band where the 110×110 logo
card couldn't render the Camogli photo recognisably under the 6-colour
palette.

Implementation notes:
- compose_hero_banner() crops from the hi-res IMG_2524.jpg (so we don't
  upsample the 900-square version), composites the SVG black-fade
  gradient, then Floyd-Steinberg dithers to the Spectra-6 palette so the
  photo reads as continuous tone instead of nearest-neighbour colour
  fields. Wordmark composited after the dither to keep text edges crisp.
- Compact orientation diagrams + smaller manual QR (box_size=5) so the
  AP screen's left column still fits the 4 steps + diagrams + help QR
  inside the 1070-px body left below the taller hero.
- Setup QR cell shrunk 16 → 14 (656 → 574 px) so the setup screen fits
  the QR + MAC chip + progress bar below the hero.
- Redundant two-line "Scan the QR to link this frame / to your
  wevisto.com account." dropped from setup screen — heading + label
  above the QR + MAC chip below it cover the same ground without
  crowding the post-hero body.
- epd_driver.cpp QR overlay coords updated to match: AP 230→590,
  setup (272,490,16) → (313,750,14).

compose_logo() (square card) kept for any future use; not currently
called by gen_ap/gen_setup.

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

600 lines
27 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 ─────────────────────────────────────────────────────────
# Hero treatment: full-bleed harbour photo banner (460 tall) + slim accent
# band (70 tall) carrying the section title. Replaces the prior 130-tall
# single header band — at 110×110 the harbour photo couldn't render
# recognisably under the panel's 6-colour palette, so the brand moment now
# takes the whole top of the screen. 320×320 logo card variant is kept in
# compose_logo() for web/email use.
HERO_BANNER_H = 460
HERO_BAND_H = 70
HERO_H = HERO_BANNER_H + HERO_BAND_H # 530
HEADER_H = HERO_H # alias kept for grep
BODY_Y = HERO_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).
# Coords shifted to accommodate the taller HERO_H=530 (was HEADER_H=130);
# epd_driver.cpp epd_draw_*_screen calls must update to match.
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 + 60 # 590
# Setup QR shrunk from 16 to 14 cells (656 → 574 px) so the post-hero body
# can still fit the QR + MAC chip + caption + progress bar within the
# remaining 1070 px below the hero.
SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_CELL = 14 # 41 × 14 = 574 px
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL
SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 313
SETUP_QR_Y = BODY_Y + 220 # 750
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_hero(img, accent, header_text):
"""Full-bleed photo banner + wordmark + slim accent band carrying the
section title. The brand moment occupies the top 530 px of the panel."""
banner = compose_hero_banner(W, HERO_BANNER_H)
img.paste(banner, (0, 0))
draw = ImageDraw.Draw(img)
band_y = HERO_BANNER_H
draw.rectangle([0, band_y, W - 1, HERO_H - 1], fill=accent)
# F_BAR is 34pt, ~38 px tall — vertically centred in the band.
text_y = band_y + (HERO_BAND_H - 38) // 2
draw.text((LEFT_PAD, text_y), header_text, font=F_BAR, fill=BK)
# ── Brand assets ─────────────────────────────────────────────────────────────
# Source photo (5712×4284 original — gives us latitude for landscape crops
# without having to upscale the 900-square version). The square JPEG is
# used by compose_logo() for the web/email logo card; the hires original
# is used by compose_hero_banner() for the panel hero banner.
LOGO_SRC_SQUARE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524-square900.jpg")
LOGO_SRC_HIRES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524.jpg")
def _gradient_overlay(w, h):
"""Black-fade overlay matching webApp/frontend/public/logo.svg: 0%
opaque at top/bottom, 45% in the middle 42-58% band. Used to anchor
the wordmark legibly against varying photo content underneath."""
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
od = ImageDraw.Draw(layer)
for y in range(h):
t = y / max(1, h - 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), (w, y)], fill=(0, 0, 0, int(a * 255)))
return layer
def _render_wordmark(canvas, cx, cy, font_size, stroke=2):
"""We[white] V[yellow] isto[white] centred at (cx, cy). stroke_width
fakes font-weight 900 — DejaVuSans-Bold is only weight 700."""
draw = ImageDraw.Draw(canvas)
font = ttf("DejaVuSans-Bold.ttf", font_size)
parts = [("We", WH), ("V", YL), ("isto", WH)]
widths = [draw.textbbox((0, 0), t, font=font, stroke_width=stroke)[2] for t, _ in parts]
total_w = sum(widths)
x = cx - total_w // 2
y = cy - font_size // 2 - max(2, font_size // 16)
for (t, fill), w in zip(parts, widths):
draw.text((x, y), t, font=font, fill=fill,
stroke_width=stroke, stroke_fill=fill)
x += w
def compose_logo(size):
"""Square logo card (photo + gradient + wordmark) at `size`×`size`.
Composes at SVG-native 320×320 then LANCZOS-downsamples for clean
anti-aliasing. Kept for any future use that wants the square mark on
the panel; the active hero uses compose_hero_banner() instead."""
SRC = 320
bg = Image.open(LOGO_SRC_SQUARE).convert("RGB").resize((SRC, SRC), Image.LANCZOS)
bg = Image.alpha_composite(bg.convert("RGBA"), _gradient_overlay(SRC, SRC)).convert("RGB")
_render_wordmark(bg, SRC // 2, int(0.547 * SRC), 62, stroke=2)
return bg.resize((size, size), Image.LANCZOS) if size != SRC else bg
def compose_hero_banner(banner_w, banner_h):
"""Wide hero banner: harbour photo cropped from the hi-res original to
the target aspect, gradient overlay for legibility, Floyd-Steinberg
dithered to the Spectra-6 palette (so the photo reads as continuous
tone instead of nearest-neighbour colour blocks), then a big WeVisto
wordmark composited on top in crisp palette-exact colours."""
src = Image.open(LOGO_SRC_HIRES).convert("RGB")
sw, sh = src.size
src_ar = sw / sh
tgt_ar = banner_w / banner_h
if src_ar > tgt_ar:
new_w = int(sh * tgt_ar)
x0 = (sw - new_w) // 2
src = src.crop((x0, 0, x0 + new_w, sh))
else:
new_h = int(sw / tgt_ar)
y0 = int(sh * 0.42 - new_h // 2)
y0 = max(0, min(y0, sh - new_h))
src = src.crop((0, y0, sw, y0 + new_h))
banner = src.resize((banner_w, banner_h), Image.LANCZOS)
banner = Image.alpha_composite(banner.convert("RGBA"),
_gradient_overlay(banner_w, banner_h)).convert("RGB")
# FS-dither the photo + gradient against the Spectra-6 palette. Without
# this, the harbour collapses to blocky nearest-neighbour colour fields
# when pack()'s nearest() runs over the final panel image.
pal = Image.new("P", (1, 1))
pal.putpalette([*BK, *WH, *YL, *RD, *BL, *GR, *([0, 0, 0] * 250)])
banner = banner.quantize(palette=pal, dither=Image.FLOYDSTEINBERG).convert("RGB")
# Wordmark rendered AFTER the dither so text edges stay crisp instead
# of getting stippled. Colours used (WH, YL) are already palette-exact.
_render_wordmark(banner, banner_w // 2, int(0.55 * banner_h), 200, stroke=4)
return banner
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, compact=False):
"""
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). `compact`
# shrinks the diagrams ~35% so they fit alongside the manual QR in the
# AP screen's tighter post-hero body.
if compact:
diag_w, diag_h = 90, 140
ribbon_thick = 10
pair_gap = 70
else:
diag_w, diag_h = 130, 200
ribbon_thick = 14
pair_gap = 100
pair_w = diag_w * 2 + pair_gap
base_y = top_y + 60 if compact else 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_hero(img, accent, header_text)
draw_divider(draw)
# ── LEFT COLUMN ──────────────────────────────────────────────────────────
# Single-line "Connect to WiFi" heading — was a two-line block under the
# old shallow header; the new 530-tall hero swallowed the vertical budget
# so the body collapses to one line at a smaller F_TITLE size.
head_y = BODY_Y + 30
f_head = ttf("DejaVuSans-Bold.ttf", 60)
draw.text((LEFT_PAD, head_y), "Connect to WiFi", font=f_head, fill=BK)
bb = draw.textbbox((0, 0), "Connect to WiFi", font=f_head)
underline_y = head_y + bb[3] + 6
draw.rectangle([LEFT_PAD, underline_y,
LEFT_PAD + bb[2] + 4, underline_y + 6], fill=accent)
# 4 numbered steps. step_pitch tightened from 112 → 108 so the steps +
# orientation diagram + manual QR all fit in the post-hero 1070-px 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 = underline_y + 60
step_pitch = 108
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 — between the steps and the manual QR. Compact
# variant (diag_h=130) so the LEFT column fits inside the smaller body.
orientation_diagrams(img, LEFT_X + LEFT_W // 2,
step_y0 + len(steps) * step_pitch + 30,
compact=True)
# Manual QR bottom-left — covers "captive portal didn't open" + general
# troubleshooting. Shrunk to box_size=5 (~205 px) to share the column
# with the orientation diagrams above it.
manual_qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=5,
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 - 30
img.paste(manual_img, (manual_x, manual_y))
label_x = manual_x + mw + 24
label_y = manual_y + (mh - 64) // 2
draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK)
draw.text((label_x, label_y + 32), "Scan for setup", font=F_STEP, fill=BK)
draw.text((label_x, label_y + 58), "& 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_hero(img, GR, "WIFI CONNECTED — STEP 2 OF 2")
# Centered heading. Two-line instructions that used to sit beneath the
# underline were dropped in the redesign — the hero already establishes
# WeVisto, the "SCAN TO FINISH" label sits right above the QR, and the
# MAC chip below identifies the device. Three labels for one QR was
# redundant under the tighter post-hero body height.
text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK)
bb = draw.textbbox((0, 0), "Almost ready", font=F_HEAD)
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)
# 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 the QR to link your wevisto.com account",
F_STEP_B, 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}")