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:
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 |
@@ -101,6 +101,10 @@ board_build.flash_mode = opi
|
||||
board_upload.flash_size = 32MB
|
||||
board_build.arduino.memory_type = opi_opi
|
||||
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
|
||||
build_src_filter =
|
||||
+<main.cpp>
|
||||
|
||||
+299
-63
@@ -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.
|
||||
# ── 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 = 16 # 37 × 16 = 592 px square
|
||||
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_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:
|
||||
|
||||
@@ -115,11 +115,13 @@ void epd_setup_pins() {
|
||||
digitalWrite(PIN_CS_S, HIGH);
|
||||
digitalWrite(PIN_RST, HIGH);
|
||||
|
||||
// FSPI on S3, manual CS. 10 MHz is well under the panel's documented
|
||||
// 20 MHz ceiling and gives plenty of margin on the long traces between
|
||||
// the module and the connector.
|
||||
// FSPI on S3, manual CS. Matches the 7.3" production speed (4 MHz) —
|
||||
// tried 10 MHz first and the panel showed a yellow cast across the
|
||||
// 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.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0));
|
||||
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
}
|
||||
|
||||
void epd_init() {
|
||||
@@ -193,19 +195,44 @@ void epd_sleep() {
|
||||
|
||||
// ── Draw helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Push one half's framebuffer slice to its CS line. The slice is the
|
||||
// HALF_BYTES_ROW × H bytes for that half, laid out row-major-contiguous.
|
||||
static void push_half(int cs_pin, const uint8_t* half_fb) {
|
||||
// DMA-safe scratch buffer (internal SRAM). SPI.writeBytes on ESP32-S3 uses
|
||||
// GDMA for bulk transfers; DMA can't reliably read from PSRAM-backed
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
|
||||
// Take a full-frame row-major framebuffer (BYTES_PER_ROW × H) and push
|
||||
// left-then-right halves. Needs a scratch buffer to deinterleave halves
|
||||
// from the row-major layout — the SPI bus needs contiguous bytes per CS.
|
||||
// left-then-right halves. Deinterleaves into a PSRAM scratch slice so
|
||||
// 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) {
|
||||
// Allocate a half-slice scratch buffer in PSRAM. 300 × 1600 = 480 KB.
|
||||
constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H;
|
||||
uint8_t* slice = (uint8_t*)heap_caps_malloc(HALF_BYTES, MALLOC_CAP_SPIRAM);
|
||||
if (!slice) return;
|
||||
@@ -215,14 +242,14 @@ static void push_full_frame(const uint8_t* fb) {
|
||||
fb + (size_t)y * BYTES_PER_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++) {
|
||||
memcpy(slice + (size_t)y * HALF_BYTES_ROW,
|
||||
fb + (size_t)y * BYTES_PER_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);
|
||||
}
|
||||
@@ -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) {
|
||||
File f = LittleFS.open(path, "r");
|
||||
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();
|
||||
epd_fill(fallback_color);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
Serial.println("[epd13e6] read short");
|
||||
fb_release(fb); f.close(); epd_fill(fallback_color); return;
|
||||
}
|
||||
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 y0 = max(qr_y, 0);
|
||||
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
|
||||
// decorative borders or the QR placeholder shows through.
|
||||
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) {
|
||||
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) {
|
||||
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 450, 14);
|
||||
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 272, 490, 16);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user