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
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+4
View File
@@ -101,6 +101,10 @@ board_build.flash_mode = opi
board_upload.flash_size = 32MB board_upload.flash_size = 32MB
board_build.arduino.memory_type = opi_opi board_build.arduino.memory_type = opi_opi
board_build.filesystem = littlefs board_build.filesystem = littlefs
; Default partition table reserves ~1.5MB for SPIFFS — not enough for three
; 960 KB setup-screen .bin files (2.88 MB minimum + LittleFS metadata).
; 16MB preset gives ~3.5 MB to the filesystem, tight but works.
board_build.partitions = default_16MB.csv
extra_scripts = pre:scripts/data_dir.py extra_scripts = pre:scripts/data_dir.py
build_src_filter = build_src_filter =
+<main.cpp> +<main.cpp>
+301 -65
View File
@@ -3,17 +3,18 @@
Generate 1200×1600 portrait setup-screen backgrounds for the Waveshare Generate 1200×1600 portrait setup-screen backgrounds for the Waveshare
13.3" Spectra-6 panel. 13.3" Spectra-6 panel.
Layout is a single-column vertical stack much simpler than the 800×480 Layout mirrors the 7.3" gen_screens.py (yellow header + instructions +
three-panel design in gen_screens.py chosen for the end-to-end port QR codes) but adapted for portrait: a single yellow header band on top,
MVP. Polish the visual treatment later once the provisioning flow is then a two-column body instructions + manual QR on the left, the two
proven on real hardware. runtime QR codes on the right. No orientation chooser (13.3" setup is
portrait-only see project memory).
Run from the firmware/ directory: Run from the firmware/ directory:
python3 scripts/gen_screens_13e6.py python3 scripts/gen_screens_13e6.py
Constants used by src/panels/waveshare13e6/v1/epd_driver.cpp: Exports the QR overlay constants the firmware uses to overlay the
AP_QR_X, AP_QR_Y, AP_QR_CELL runtime QR images on the static .bin backgrounds; the driver must hold
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL the same numbers.
""" """
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@@ -24,7 +25,7 @@ MANUAL_URL = "https://pictureframe.edholm.me/help"
W, H = 1200, 1600 W, H = 1200, 1600
# ── Spectra-6 palette ──────────────────────────────────────────────────────── # ── Spectra-6 palette ────────────────────────────────────────────────────────
BLACK = 0x0; BK = (26, 26, 26 ) BLACK = 0x0; BK = (26, 26, 26 )
WHITE = 0x1; WH = (245, 245, 240) WHITE = 0x1; WH = (245, 245, 240)
YELLOW = 0x2; YL = (240, 208, 0 ) YELLOW = 0x2; YL = (240, 208, 0 )
@@ -65,32 +66,42 @@ def ttf(name, size):
return ImageFont.load_default() return ImageFont.load_default()
F_HEADER = ttf("DejaVuSans-Bold.ttf", 44) F_BAR = ttf("DejaVuSans-Bold.ttf", 34) # header band text
F_TITLE = ttf("DejaVuSans-Bold.ttf", 60) F_CHIP = ttf("DejaVuSans-Bold.ttf", 30) # SSID chip
F_LABEL = ttf("DejaVuSans-Bold.ttf", 32) F_HEAD = ttf("DejaVuSans-Bold.ttf", 78) # "Connect to WiFi" heading
F_STEP = ttf("DejaVuSans.ttf", 32) F_TITLE = ttf("DejaVuSans-Bold.ttf", 60) # "Almost ready" on setup screen
F_STEP_B = ttf("DejaVuSans-Bold.ttf", 32) F_LABEL = ttf("DejaVuSans-Bold.ttf", 36) # column section labels
F_FOOT = ttf("DejaVuSans.ttf", 28) F_STEPN = ttf("DejaVuSans-Bold.ttf", 32) # step number inside black box
F_NUM = ttf("DejaVuSans-Bold.ttf", 30) F_STEP_B = ttf("DejaVuSans-Bold.ttf", 30) # bold step body
F_TINY = ttf("DejaVuSans-Bold.ttf", 20) 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 ────────────────────────── # ── Layout constants ─────────────────────────────────────────────────────────
# Cell sizes are chosen so each QR fits comfortably in its vertical band with HEADER_H = 130 # header band height
# room for label + caption. Centered horizontally on a 1200-wide canvas. BODY_Y = HEADER_H
AP_QR_MODS = 37 # version 5, ECC_LOW DIV_X = 600 # vertical divider between left + right columns
AP_QR_CELL = 16 # 37 × 16 = 592 px square 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_PX = AP_QR_MODS * AP_QR_CELL
AP_QR_X = (W - AP_QR_PX) // 2 # 304 AP_QR_X = RIGHT_X + (RIGHT_W - AP_QR_PX) // 2 # 642
AP_QR_Y = 220 AP_QR_Y = BODY_Y + 100 # 230
SETUP_QR_MODS = 41 # version 6, ECC_LOW SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_CELL = 14 # 41 × 14 = 574 px square SETUP_QR_CELL = 16 # 41 × 16 = 656 px
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL
SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 313 SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 272
SETUP_QR_Y = 450 SETUP_QR_Y = BODY_Y + 360 # 490
HEADER_H = 120
def text_center(draw, cx, y, text, font, fill): 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): 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 - 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) draw.rectangle([qx - 4, qy - 4, qx + qp + 3, qy + qp + 3], outline=BK, width=4)
# ── AP SCREEN (Step 1/2 — WiFi join) ────────────────────────────────────────── def draw_header(draw, accent, header_text, ssid_text):
def gen_ap(accent=YL, header_text="SETUP MODE — STEP 1 OF 2", """Yellow/red band along the top with a section title + SSID chip."""
qr_caption="Scan to join PictureFrame"): 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 portraitlandscape is 90° CW (ribbon moves
bottomleft 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) img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Status header band draw_header(draw, accent, header_text, "PictureFrame-91F8")
draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent) draw_divider(draw)
text_center(draw, W // 2, 38, header_text, F_HEADER, BK)
# WiFi QR step # ── LEFT COLUMN ──────────────────────────────────────────────────────────
text_center(draw, W // 2, AP_QR_Y - 60, "STEP 1 — JOIN WIFI", F_LABEL, BK) # 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) 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) 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, # Step 2 — URL QR (static "http://192.168.4.1/", baked into bg). Scanning
# which forces iOS to render the captive portal. # 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( url_qr = qrcode.QRCode(
version=None, version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L, 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.add_data("http://192.168.4.1/")
url_qr.make(fit=True) url_qr.make(fit=True)
url_img = url_qr.make_image(fill_color=BK, back_color=WH).convert("RGB") url_img = url_qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
url_w, url_h = url_img.size uw, uh = url_img.size
url_y = 1180 url_x = rcx - uw // 2
url_x = (W - url_w) // 2 url_y = H - uh - 100
text_center(draw, W // 2, url_y - 60, "STEP 2 OPEN PAGE", F_LABEL, BK) text_center(draw, rcx, url_y - 56, "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], draw.rectangle([url_x - 12, url_y - 12, url_x + uw + 11, url_y + uh + 11],
outline=accent, width=6) 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) outline=BK, width=4)
img.paste(url_img, (url_x, url_y)) 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 return img
def gen_ap_retry(): 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( return gen_ap(
accent=RD, accent=RD,
header_text="CONNECTION FAILED — TRY AGAIN", 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(): def gen_setup():
img = Image.new("RGB", (W, H), WH) img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=GR) draw_header(draw, GR, "WIFI CONNECTED — STEP 2 OF 2", "192.168.x.x")
text_center(draw, W // 2, 38, "WIFI CONNECTED — STEP 2 OF 2", F_HEADER, WH)
text_center(draw, W // 2, 200, "Almost ready", F_TITLE, BK) # Centered heading
text_center(draw, W // 2, 290, "Scan the QR to link this frame", F_STEP, BK) text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK)
text_center(draw, W // 2, 332, "to your pictureframe.edholm.me account.", F_STEP, 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) 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) 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, text_center(draw, W // 2,
SETUP_QR_Y + SETUP_QR_PX + 16, SETUP_QR_Y - 56,
"Your frame ID is encoded in the QR above.", "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) 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 return img
# ── Save ───────────────────────────────────────────────────────────────────── # ── Save ─────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path): def save_bin(img, path, preview_path):
data = pack(img) data = pack(img)
with open(path, "wb") as f: with open(path, "wb") as f:
+66 -17
View File
@@ -115,11 +115,13 @@ void epd_setup_pins() {
digitalWrite(PIN_CS_S, HIGH); digitalWrite(PIN_CS_S, HIGH);
digitalWrite(PIN_RST, HIGH); digitalWrite(PIN_RST, HIGH);
// FSPI on S3, manual CS. 10 MHz is well under the panel's documented // FSPI on S3, manual CS. Matches the 7.3" production speed (4 MHz) —
// 20 MHz ceiling and gives plenty of margin on the long traces between // tried 10 MHz first and the panel showed a yellow cast across the
// the module and the connector. // body, suggesting bit corruption on long bursts (likely PSRAM-DMA
// cache coherency or SI margin issues on the long traces inside the
// all-in-one module).
SPI.begin(PIN_SCK, -1, PIN_MOSI, -1); SPI.begin(PIN_SCK, -1, PIN_MOSI, -1);
SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0)); SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
} }
void epd_init() { void epd_init() {
@@ -193,19 +195,44 @@ void epd_sleep() {
// ── Draw helpers ─────────────────────────────────────────────────────────────── // ── Draw helpers ───────────────────────────────────────────────────────────────
// Push one half's framebuffer slice to its CS line. The slice is the // DMA-safe scratch buffer (internal SRAM). SPI.writeBytes on ESP32-S3 uses
// HALF_BYTES_ROW × H bytes for that half, laid out row-major-contiguous. // GDMA for bulk transfers; DMA can't reliably read from PSRAM-backed
static void push_half(int cs_pin, const uint8_t* half_fb) { // buffers because the CPU's cache may hold writes that haven't reached
// PSRAM yet — symptom is a yellow/random cast across the panel. Pushing
// from internal SRAM in chunks sidesteps that entirely. 8 KB is a
// per-half-row sweet spot (~27 rows of 300 bytes), enough that the
// per-transfer overhead is negligible.
static constexpr size_t DMA_CHUNK = 8192;
static uint8_t* g_dma_scratch = nullptr;
static void ensure_dma_scratch() {
if (!g_dma_scratch) {
g_dma_scratch = (uint8_t*)heap_caps_malloc(
DMA_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
}
}
// Push one half's framebuffer slice to its CS line via DMA-safe chunks.
// half_fb points into PSRAM (the full framebuffer); we copy CHUNK bytes
// into internal SRAM, then SPI.writeBytes that. Repeat until done.
static void push_half(int cs_pin, const uint8_t* half_fb, size_t bytes) {
ensure_dma_scratch();
if (!g_dma_scratch) return;
begin_cmd(cs_pin, 0x10); begin_cmd(cs_pin, 0x10);
send_data_n(half_fb, (size_t)HALF_BYTES_ROW * H); for (size_t off = 0; off < bytes; off += DMA_CHUNK) {
size_t n = (bytes - off < DMA_CHUNK) ? (bytes - off) : DMA_CHUNK;
memcpy(g_dma_scratch, half_fb + off, n);
SPI.writeBytes(g_dma_scratch, n);
}
cs(cs_pin, HIGH); cs(cs_pin, HIGH);
} }
// Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push // Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push
// left-then-right halves. Needs a scratch buffer to deinterleave halves // left-then-right halves. Deinterleaves into a PSRAM scratch slice so
// from the row-major layout — the SPI bus needs contiguous bytes per CS. // each half is row-contiguous, then push_half streams it through the
// DMA-safe internal-SRAM chunk buffer.
static void push_full_frame(const uint8_t* fb) { static void push_full_frame(const uint8_t* fb) {
// Allocate a half-slice scratch buffer in PSRAM. 300 × 1600 = 480 KB.
constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H; constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H;
uint8_t* slice = (uint8_t*)heap_caps_malloc(HALF_BYTES, MALLOC_CAP_SPIRAM); uint8_t* slice = (uint8_t*)heap_caps_malloc(HALF_BYTES, MALLOC_CAP_SPIRAM);
if (!slice) return; if (!slice) return;
@@ -215,14 +242,14 @@ static void push_full_frame(const uint8_t* fb) {
fb + (size_t)y * BYTES_PER_ROW, fb + (size_t)y * BYTES_PER_ROW,
HALF_BYTES_ROW); HALF_BYTES_ROW);
} }
push_half(PIN_CS_M, slice); push_half(PIN_CS_M, slice, HALF_BYTES);
for (uint16_t y = 0; y < H; y++) { for (uint16_t y = 0; y < H; y++) {
memcpy(slice + (size_t)y * HALF_BYTES_ROW, memcpy(slice + (size_t)y * HALF_BYTES_ROW,
fb + (size_t)y * BYTES_PER_ROW + HALF_BYTES_ROW, fb + (size_t)y * BYTES_PER_ROW + HALF_BYTES_ROW,
HALF_BYTES_ROW); HALF_BYTES_ROW);
} }
push_half(PIN_CS_S, slice); push_half(PIN_CS_S, slice, HALF_BYTES);
heap_caps_free(slice); heap_caps_free(slice);
} }
@@ -330,19 +357,41 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
QRCode* qr, int qr_x, int qr_y, int qr_cell) { QRCode* qr, int qr_x, int qr_y, int qr_cell) {
File f = LittleFS.open(path, "r"); File f = LittleFS.open(path, "r");
if (!f || f.size() != FB_BYTES) { if (!f || f.size() != FB_BYTES) {
Serial.printf("[epd13e6] %s: open=%d size=%u -> fill 0x%X\n",
path, (int)(bool)f, f ? (unsigned)f.size() : 0u,
fallback_color);
if (f) f.close(); if (f) f.close();
epd_fill(fallback_color); epd_fill(fallback_color);
return; return;
} }
uint8_t* fb = fb_alloc(); uint8_t* fb = fb_alloc();
if (!fb) { f.close(); epd_fill(fallback_color); return; } if (!fb) {
Serial.printf("[epd13e6] fb_alloc FAILED (PSRAM free=%u)\n",
(unsigned)ESP.getFreePsram());
f.close();
epd_fill(fallback_color);
return;
}
if (f.read(fb, FB_BYTES) != FB_BYTES) { if (f.read(fb, FB_BYTES) != FB_BYTES) {
Serial.println("[epd13e6] read short");
fb_release(fb); f.close(); epd_fill(fallback_color); return; fb_release(fb); f.close(); epd_fill(fallback_color); return;
} }
f.close(); f.close();
// Sample first 8 bytes of each quarter of the framebuffer so we can
// verify what we're about to push matches the on-disk .bin. If the
// panel shows wrong colors with these sample bytes looking right,
// corruption is downstream of the CPU.
Serial.printf("[epd13e6] fb sample: head=%02x%02x%02x%02x%02x%02x%02x%02x ",
fb[0], fb[1], fb[2], fb[3], fb[4], fb[5], fb[6], fb[7]);
size_t q = FB_BYTES / 4;
Serial.printf("q1=%02x%02x%02x%02x q2=%02x%02x%02x%02x q3=%02x%02x%02x%02x\n",
fb[q+0], fb[q+1], fb[q+2], fb[q+3],
fb[2*q+0], fb[2*q+1], fb[2*q+2], fb[2*q+3],
fb[3*q+0], fb[3*q+1], fb[3*q+2], fb[3*q+3]);
const int qr_px = qr->size * qr_cell; const int qr_px = qr->size * qr_cell;
const int y0 = max(qr_y, 0); const int y0 = max(qr_y, 0);
const int y1 = min(qr_y + qr_px, (int)H); const int y1 = min(qr_y + qr_px, (int)H);
@@ -370,13 +419,13 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
// firmware paints the live QR into it. Mismatch = the QR draws over // firmware paints the live QR into it. Mismatch = the QR draws over
// decorative borders or the QR placeholder shows through. // decorative borders or the QR placeholder shows through.
void epd_draw_ap_screen(QRCode* qr) { void epd_draw_ap_screen(QRCode* qr) {
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 304, 220, 16); draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 642, 230, 14);
} }
void epd_draw_ap_screen_retry(QRCode* qr) { void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 304, 220, 16); draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 230, 14);
} }
void epd_draw_setup_screen(QRCode* qr) { void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 450, 14); draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 272, 490, 16);
} }