Files
pictureFrame-firmware/scripts/gen_screens_13e6.py
T
football2801 569bec322f 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>
2026-05-13 15:53:51 -04:00

226 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}")