fix(13e6): SPI corruption fix + setup-screen polish

- SPI corruption: lower clock to 4 MHz (matches 7.3" prod) and push from
  internal SRAM in 8 KB chunks instead of streaming directly from a PSRAM
  scratch buffer. On the S3, Arduino's SPI DMA reads RAM directly — the
  CPU's cache can hold writes to PSRAM that the DMA never sees, painting
  the panel yellow/garbage. Internal-SRAM chunks are DMA-coherent.
- LittleFS partition: switch the env to default_16MB.csv. The stock
  partition table for esp32-s3-devkitc-1 reserves ~1.5 MB for SPIFFS;
  three 960 KB setup-screen .bin files need ~2.9 MB + LittleFS metadata.
- Setup screens: redesigned to match the 7.3" information density —
  yellow header band, two-column body with vertical divider, "Connect to
  WiFi" heading + 5 numbered steps + manual QR + side label on the left,
  Step 1 / Step 2 QRs on the right.
- Orientation diagrams: PORTRAIT drawn upright (ribbon-bottom, up-arrow);
  LANDSCAPE drawn pre-rotated 90° CCW so it snaps to upright landscape
  when the user rotates the frame 90° CW (ribbon-bottom + left-arrow in
  portrait view → ribbon-left + up-arrow after rotation). "LANDSCAPE"
  label runs vertically up the long edge so it reads horizontally once
  the frame is mounted landscape.
- New helper paste_rotated_text() — PIL's text() can't rotate, so render
  → rotate → paste-with-alpha. Used for the vertical LANDSCAPE label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 19:45:58 -04:00
parent 569bec322f
commit 8eec4bd5fa
9 changed files with 374 additions and 85 deletions
+301 -65
View File
@@ -3,17 +3,18 @@
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.
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
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
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
@@ -24,7 +25,7 @@ MANUAL_URL = "https://pictureframe.edholm.me/help"
W, H = 1200, 1600
# ── Spectra-6 palette ────────────────────────────────────────────────────────
# ── Spectra-6 palette ────────────────────────────────────────────────────────
BLACK = 0x0; BK = (26, 26, 26 )
WHITE = 0x1; WH = (245, 245, 240)
YELLOW = 0x2; YL = (240, 208, 0 )
@@ -65,32 +66,42 @@ def ttf(name, size):
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)
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
# ── 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
# ── 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 = (W - AP_QR_PX) // 2 # 304
AP_QR_Y = 220
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 = 14 # 41 × 14 = 574 px square
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 # 313
SETUP_QR_Y = 450
HEADER_H = 120
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):
@@ -104,29 +115,220 @@ def leave_qr_white(draw, qr_x, qr_y, qr_px):
def draw_qr_frame(draw, qx, qy, qp, accent):
"""Two-layer decorative border around a QR placeholder."""
"""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)
# ── 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"):
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)
# 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)
draw_header(draw, accent, header_text, "PictureFrame-91F8")
draw_divider(draw)
# WiFi QR step
text_center(draw, W // 2, AP_QR_Y - 60, "STEP 1 — JOIN WIFI", F_LABEL, BK)
# ── 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, W // 2, AP_QR_Y + AP_QR_PX + 16, qr_caption, F_FOOT, BK)
text_center(draw, rcx, AP_QR_Y + AP_QR_PX + 16, qr_label, F_FOOT, BK)
# URL QR step — static, baked into the bg. Scanning it opens Safari,
# which forces iOS to render the captive portal.
# 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,
@@ -136,59 +338,93 @@ def gen_ap(accent=YL, header_text="SETUP MODE — STEP 1 OF 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
uw, uh = url_img.size
url_x = rcx - uw // 2
url_y = H - uh - 100
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],
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 + url_w + 3, url_y + url_h + 3],
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, W // 2, url_y + url_h + 16, "http://192.168.4.1/", F_FOOT, BK)
text_center(draw, rcx, url_y + uh + 12, "http://192.168.4.1/", F_FOOT, BK)
return img
def gen_ap_retry():
"""Step 1/2 with red accent + retry messaging."""
"""Red-accented retry screen, served after a failed WiFi-join attempt."""
return gen_ap(
accent=RD,
header_text="CONNECTION FAILED — TRY AGAIN",
qr_caption="Connection failed — try again",
qr_label="Connection failed — try again",
)
# ── SETUP SCREEN (post-WiFi, scan-to-claim) ───────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
# 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.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)
draw_header(draw, GR, "WIFI CONNECTED — STEP 2 OF 2", "192.168.x.x")
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)
# 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, SETUP_QR_Y - 60, "SCAN TO FINISH", F_LABEL, BK)
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 pictureframe.edholm.me 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)
# 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.",
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 ─────────────────────────────────────────────────────────────────────
# ── Save ─────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path):
data = pack(img)
with open(path, "wb") as f: