feat(13e6): bring up Waveshare 13.3" Spectra-6 end-to-end
Adds a second panel target alongside the 7.3": - src/panels/waveshare13e6/v1/ — full epd.h impl with hardware SPI on FSPI, dual-CS dispatch (CS_M/CS_S split halves), PSRAM framebuffer for image/QR/setup-screen render paths - src/test_display_13e6.cpp + [env:test-display-13e6] — self-contained first-pixels color-bar smoke test, kept as a hardware diagnostic - [env:waveshare13e6-v1] — production env: ESP32-S3-WROOM-2 N32R16V with OPI flash + OPI PSRAM (the WROOM-2 is octal flash; QIO mode crashes at do_core_init startup.c:328) - scripts/gen_screens_13e6.py + data/waveshare13e6-v1/ — 1200x1600 portrait setup screens with QR overlay regions matching the driver - scripts/data_dir.py — extra_scripts shim that routes uploadfs to the right data/ tree based on $PIOENV (PlatformIO ignores per-env data_dir) - src/epd.h: epd_setup_pins() abstraction so each panel driver owns its own pinMode + SPI.begin; main/test_display/sim_border lose all panel-specific GPIO and call epd_setup_pins() once at boot - src/operation.h: report PANEL_ID via X-Panel-Id header on every poll so the server can auto-correct Device.model 7.3" production env stays byte-identical, all 43 native tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import qrcode
|
||||
import os, sys
|
||||
|
||||
MANUAL_URL = "https://pictureframe.edholm.me/help"
|
||||
|
||||
W, H = 1200, 1600
|
||||
|
||||
# ── Spectra-6 palette ─────────────────────────────────────────────────────────
|
||||
BLACK = 0x0; BK = (26, 26, 26 )
|
||||
WHITE = 0x1; WH = (245, 245, 240)
|
||||
YELLOW = 0x2; YL = (240, 208, 0 )
|
||||
RED = 0x3; RD = (192, 48, 32 )
|
||||
BLUE = 0x5; BL = (24, 64, 192)
|
||||
GREEN = 0x6; GR = (16, 160, 64 )
|
||||
|
||||
PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR}
|
||||
|
||||
|
||||
def nearest(r, g, b):
|
||||
best, best_d = WHITE, float("inf")
|
||||
for n, (pr, pg, pb) in PALETTE_RGB.items():
|
||||
d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2
|
||||
if d < best_d:
|
||||
best, best_d = n, d
|
||||
return best
|
||||
|
||||
|
||||
def pack(img):
|
||||
"""RGB PIL → 4bpp packed bytearray, row-major panel-native order."""
|
||||
px = img.load()
|
||||
out = bytearray()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
hi = nearest(*px[x, y])
|
||||
lo = nearest(*px[x + 1, y])
|
||||
out.append((hi << 4) | lo)
|
||||
return out
|
||||
|
||||
|
||||
# ── Fonts ────────────────────────────────────────────────────────────────────
|
||||
FONT_DIR = "/usr/share/fonts/truetype/dejavu"
|
||||
def ttf(name, size):
|
||||
try:
|
||||
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
|
||||
except Exception:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
F_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)
|
||||
|
||||
|
||||
# ── QR overlay regions — must match the panel driver ──────────────────────────
|
||||
# Cell sizes are chosen so each QR fits comfortably in its vertical band with
|
||||
# room for label + caption. Centered horizontally on a 1200-wide canvas.
|
||||
AP_QR_MODS = 37 # version 5, ECC_LOW
|
||||
AP_QR_CELL = 16 # 37 × 16 = 592 px square
|
||||
AP_QR_PX = AP_QR_MODS * AP_QR_CELL
|
||||
AP_QR_X = (W - AP_QR_PX) // 2 # 304
|
||||
AP_QR_Y = 220
|
||||
|
||||
SETUP_QR_MODS = 41 # version 6, ECC_LOW
|
||||
SETUP_QR_CELL = 14 # 41 × 14 = 574 px square
|
||||
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
|
||||
|
||||
|
||||
def text_center(draw, cx, y, text, font, fill):
|
||||
bb = draw.textbbox((0, 0), text, font=font)
|
||||
tw = bb[2] - bb[0]
|
||||
draw.text((cx - tw // 2, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def leave_qr_white(draw, qr_x, qr_y, qr_px):
|
||||
draw.rectangle([qr_x, qr_y, qr_x + qr_px - 1, qr_y + qr_px - 1], fill=WH)
|
||||
|
||||
|
||||
def draw_qr_frame(draw, qx, qy, qp, accent):
|
||||
"""Two-layer decorative border around a QR placeholder."""
|
||||
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"):
|
||||
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)
|
||||
|
||||
# WiFi QR step
|
||||
text_center(draw, W // 2, AP_QR_Y - 60, "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)
|
||||
|
||||
# URL QR step — static, baked into the bg. Scanning it opens Safari,
|
||||
# which forces iOS to render the captive portal.
|
||||
url_qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
url_qr.add_data("http://192.168.4.1/")
|
||||
url_qr.make(fit=True)
|
||||
url_img = url_qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
|
||||
url_w, url_h = url_img.size
|
||||
url_y = 1180
|
||||
url_x = (W - url_w) // 2
|
||||
|
||||
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],
|
||||
outline=accent, width=6)
|
||||
draw.rectangle([url_x - 4, url_y - 4, url_x + url_w + 3, url_y + url_h + 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)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def gen_ap_retry():
|
||||
"""Step 1/2 with red accent + retry messaging."""
|
||||
return gen_ap(
|
||||
accent=RD,
|
||||
header_text="CONNECTION FAILED — TRY AGAIN",
|
||||
qr_caption="Connection failed — try again",
|
||||
)
|
||||
|
||||
|
||||
# ── SETUP SCREEN (post-WiFi, scan-to-claim) ───────────────────────────────────
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
text_center(draw, W // 2, SETUP_QR_Y - 60, "SCAN TO FINISH", F_LABEL, BK)
|
||||
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.",
|
||||
F_FOOT, BK)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ── Save ──────────────────────────────────────────────────────────────────────
|
||||
def save_bin(img, path, preview_path):
|
||||
data = pack(img)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"Written {len(data):,} bytes → {os.path.abspath(path)}")
|
||||
|
||||
prev = Image.new("RGB", (W, H))
|
||||
px = prev.load()
|
||||
for y in range(H):
|
||||
for x in range(0, W, 2):
|
||||
byte = data[y * (W // 2) + x // 2]
|
||||
px[x, y] = PALETTE_RGB.get(byte >> 4, (128, 128, 128))
|
||||
px[x + 1, y] = PALETTE_RGB.get(byte & 0xF, (128, 128, 128))
|
||||
prev.save(preview_path)
|
||||
print(f"Preview → {os.path.abspath(preview_path)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_dir = os.path.join(os.path.dirname(__file__), "../data/waveshare13e6-v1")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
print("Generating AP screen…")
|
||||
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
|
||||
print()
|
||||
print("Generating AP retry screen…")
|
||||
save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png")
|
||||
print()
|
||||
print("Generating setup screen…")
|
||||
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
|
||||
print()
|
||||
print("QR overlay constants — keep these in sync with epd_driver.cpp:")
|
||||
print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}")
|
||||
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, "
|
||||
f"SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")
|
||||
Reference in New Issue
Block a user