b0ea1ce216
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>
600 lines
27 KiB
Python
600 lines
27 KiB
Python
#!/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}")
|