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_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
@@ -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 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)
|
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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user