f1d867c659
Frames will ship with the battery disconnected to preserve shelf life — the setup screen polling is non-trivial draw on e-ink, and a unit that sits on a shelf for weeks before unboxing would arrive flat. Surface "plug in power" as the prerequisite step ahead of unlock and scan, so the recipient can't miss it. Step list grows from 4 to 5; pitch shrinks from 38 → 32 px to keep the manual-help QR clear of the bottom edge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
21 KiB
Python
462 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Generate ap_bg.bin and setup_bg.bin — 800×480 4bpp backgrounds for the
|
||
pictureFrame e-ink device. QR overlay areas are left WHITE so the
|
||
firmware can render the actual QR code at runtime.
|
||
|
||
Run from the firmware/ directory:
|
||
python3 scripts/gen_screens.py
|
||
|
||
Constants exported (copy to epd.cpp):
|
||
AP_QR_X, AP_QR_Y, AP_QR_CELL, AP_QR_PX
|
||
SETUP_QR_X, SETUP_QR_Y, SETUP_QR_CELL, SETUP_QR_PX
|
||
"""
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
import qrcode
|
||
import os, sys
|
||
|
||
# URL for the user manual QR baked into Step 1/2. Static on purpose —
|
||
# changing it requires regenerating the bg images + uploadfs.
|
||
MANUAL_URL = "https://pictureframe.edholm.me/help"
|
||
|
||
# ── Display ──────────────────────────────────────────────────────────────────
|
||
W, H = 800, 480
|
||
|
||
# ── EPD 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):
|
||
"""Convert RGB PIL image → 4bpp packed bytearray."""
|
||
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: return ImageFont.load_default()
|
||
|
||
F_HEAD = ttf("DejaVuSans-Bold.ttf", 26)
|
||
F_BAR = ttf("DejaVuSans-Bold.ttf", 13)
|
||
F_STEP = ttf("DejaVuSans.ttf", 13)
|
||
F_STEP_B= ttf("DejaVuSans-Bold.ttf", 13)
|
||
F_STEPN = ttf("DejaVuSans-Bold.ttf", 13)
|
||
F_LABEL = ttf("DejaVuSans-Bold.ttf", 11)
|
||
F_TINY = ttf("DejaVuSans-Bold.ttf", 10)
|
||
F_FOOT = ttf("DejaVuSans.ttf", 12)
|
||
F_CHIP = ttf("DejaVuSans-Bold.ttf", 12)
|
||
F_SUB = ttf("DejaVuSans.ttf", 14)
|
||
F_BIG = ttf("DejaVuSans-Bold.ttf", 14)
|
||
|
||
# ── Layout constants ──────────────────────────────────────────────────────────
|
||
BAR_H = 52
|
||
BODY_Y = BAR_H # 52
|
||
|
||
LEFT_X = 0; LEFT_W = 310
|
||
DIV1_X = 310; DIV_W = 2
|
||
CTR_X = 312; CTR_W = 196
|
||
DIV2_X = 508
|
||
RIGHT_X = 510; RIGHT_W = 290 # 800-510
|
||
|
||
# QR positions (MUST match epd.cpp constants)
|
||
# WiFi-join QR — drawn at runtime by firmware. Cell shrunk from 5 to 4
|
||
# (148 px instead of 185 px) to leave room for a second QR below it that
|
||
# opens Safari → forces iOS captive UI.
|
||
AP_QR_CELL = 4
|
||
AP_QR_MODS = 37 # version 5, ECC_LOW
|
||
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 148
|
||
|
||
SETUP_QR_CELL = 5
|
||
SETUP_QR_MODS = 41 # version 6, ECC_LOW
|
||
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL # 205
|
||
|
||
# Centre of right panel
|
||
RIGHT_CX = RIGHT_X + RIGHT_W // 2 # 655
|
||
BODY_CY = BODY_Y + (H - BODY_Y) // 2 # 266
|
||
|
||
# Stacked layout: WiFi QR (top) + URL QR (bottom). Each has a label
|
||
# above it. AP_QR is dynamic — firmware overlays it at runtime. URL QR
|
||
# is static (always http://192.168.4.1/) and baked into the bg image.
|
||
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # centered horizontally
|
||
AP_QR_Y = 100 # below STEP 1 label
|
||
URL_QR_BOX = 4 # cell size in px
|
||
URL_QR_TARGET_Y = 320 # below STEP 2 label
|
||
|
||
SETUP_QR_X = RIGHT_CX - SETUP_QR_PX // 2 # 553
|
||
SETUP_QR_Y = BODY_CY - SETUP_QR_PX // 2 # 164
|
||
SETUP_QR_Y = 175 # nudge for label
|
||
|
||
def leave_qr_white(draw, qr_x, qr_y, qr_px):
|
||
"""Blank the QR overlay region so firmware can write the real QR."""
|
||
draw.rectangle([qr_x, qr_y, qr_x+qr_px-1, qr_y+qr_px-1], fill=WH)
|
||
|
||
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 up_arrow(draw, cx, cy, half_w=12, h=22, color=BK):
|
||
"""Solid filled triangle pointing up, centered on (cx, cy)."""
|
||
draw.polygon([
|
||
(cx, cy - h // 2), # tip
|
||
(cx - half_w, cy + h // 2), # base left
|
||
(cx + half_w, cy + h // 2), # base right
|
||
], fill=color)
|
||
|
||
|
||
def left_arrow(draw, cx, cy, half_h=12, w=22, color=BK):
|
||
"""Solid filled triangle pointing left, centered on (cx, cy)."""
|
||
draw.polygon([
|
||
(cx - w // 2, cy), # tip
|
||
(cx + w // 2, cy - half_h), # base top
|
||
(cx + w // 2, cy + half_h), # base bottom
|
||
], fill=color)
|
||
|
||
|
||
def orientation_diagrams(draw, accent, show_active_ls=True):
|
||
"""Draw both orientation diagrams in the centre panel.
|
||
|
||
The accent / show_active_ls parameters are kept for call-site
|
||
compatibility but no longer drive any colour decisions — both
|
||
diagrams render in black so neither orientation looks privileged.
|
||
The supported orientation is communicated by the up-arrow inside
|
||
the landscape screen instead."""
|
||
cx = CTR_X + CTR_W // 2 # 410
|
||
|
||
# ── Section title ─────────────────────────────────────────────
|
||
text_center(draw, cx, BODY_Y+15, "FRAME", F_TINY, BK)
|
||
text_center(draw, cx, BODY_Y+27, "ORIENTATION", F_TINY, BK)
|
||
|
||
# ── Landscape ──────────────────────────────────────────────────
|
||
ls_x, ls_y, ls_w, ls_h = CTR_X+43, BODY_Y+52, 110, 66
|
||
rib_w, rib_h = 110, 10
|
||
|
||
text_center(draw, cx, ls_y-14, "LANDSCAPE", F_LABEL, BK)
|
||
|
||
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=BK, width=3)
|
||
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=BK)
|
||
|
||
# Up arrow inside both diagrams — shows the user which edge is "up"
|
||
# when they hang the frame, regardless of orientation.
|
||
up_arrow(draw, ls_x + ls_w // 2, ls_y + ls_h // 2, half_w=14, h=26)
|
||
|
||
# Thin separator
|
||
sep_y = ls_y + ls_h + rib_h + 14
|
||
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
|
||
|
||
# ── Portrait — drawn 90° CCW from upright so it rotates back to a
|
||
# correct portrait view (tall rect, ribbon on left, arrow pointing
|
||
# up) when the user tilts the frame 90° CW from landscape. In the
|
||
# source/landscape view it appears as a wide rect with the ribbon
|
||
# on the bottom and the arrow pointing left — looks "rotated"
|
||
# because it is, on purpose.
|
||
pt_w, pt_h = 106, 64
|
||
pr_w, pr_h = 106, 10
|
||
pt_x = CTR_X + (CTR_W - pt_w) // 2
|
||
pt_y = sep_y + 14
|
||
|
||
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
|
||
|
||
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
|
||
draw.rectangle([pt_x, pt_y+pt_h, pt_x+pr_w-1, pt_y+pt_h+pr_h-1], fill=BK)
|
||
|
||
left_arrow(draw, pt_x + pt_w // 2, pt_y + pt_h // 2, half_h=12, w=22)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# AP SCREEN — accent-colored, WiFi credentials
|
||
# Pass accent=YL/header="SETUP MODE — STEP 1 OF 2"/qr_label="SCAN TO CONNECT"
|
||
# for the normal first-attempt screen, or accent=RD/header="CONNECTION FAILED
|
||
# — TRY AGAIN"/qr_label="Connection Failed — try again" for the post-WiFi-fail
|
||
# retry screen. Same layout either way so the panel diff is just color +
|
||
# header/label text.
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO CONNECT"):
|
||
img = Image.new("RGB", (W, H), WH)
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# ── Status bar ────────────────────────────────────────────────
|
||
draw.rectangle([0, 0, W-1, BAR_H-1], fill=accent)
|
||
draw.text((24, 18), header, font=F_BAR, fill=BK)
|
||
|
||
# Right chip: black box with device SSID
|
||
chip_x, chip_y = 498, 11
|
||
chip_text = "PictureFrame-91F8"
|
||
bb = draw.textbbox((0,0), chip_text, font=F_CHIP)
|
||
chip_w = bb[2]-bb[0] + 22
|
||
chip_x2 = chip_x + chip_w
|
||
draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK)
|
||
draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=accent)
|
||
|
||
# ── Panel dividers ────────────────────────────────────────────
|
||
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
|
||
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
|
||
|
||
# ── Left panel ────────────────────────────────────────────────
|
||
# Heading
|
||
draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK)
|
||
draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK)
|
||
bb = draw.textbbox((0,0), "WiFi", font=F_HEAD)
|
||
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=accent)
|
||
|
||
# Steps — frames ship with battery unplugged to preserve shelf life
|
||
# (idle setup-screen polling is non-trivial draw on e-ink), so the
|
||
# very first prompt is "Plug in power". Step 2 is unlock-first (iOS
|
||
# won't fire the captive UI from a locked-phone scan). Two-QR flow
|
||
# because iOS in recent versions doesn't auto-open the captive
|
||
# portal even after CNA detects it; scanning the second QR opens
|
||
# Safari which forces the portal to render.
|
||
steps = [
|
||
("Plug in power", ""),
|
||
("Unlock your phone first", ""),
|
||
("Scan QR 1 →", "joins PictureFrame WiFi"),
|
||
("Scan QR 2 →", "page opens in Safari"),
|
||
("Enter your WiFi password", "and tap Connect"),
|
||
]
|
||
sy = BODY_Y + 95
|
||
step_pitch = 32
|
||
for i, (l1, l2) in enumerate(steps):
|
||
bx, by = 28, sy + i*step_pitch
|
||
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
|
||
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, accent)
|
||
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
|
||
if l2:
|
||
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
|
||
|
||
# Manual QR + side label — bottom of left panel.
|
||
# The manual covers the captive-portal-didn't-open fallback (192.168.4.1)
|
||
# plus general setup/troubleshooting, so we drop the inline footnote
|
||
# and rely on the QR/manual to deliver the longer story.
|
||
qr_bottom_y = BODY_Y + 95 + len(steps)*step_pitch + 12 # below last step
|
||
qr = qrcode.QRCode(
|
||
version=None,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||
box_size=4,
|
||
border=2,
|
||
)
|
||
qr.add_data(MANUAL_URL)
|
||
qr.make(fit=True)
|
||
qr_img = qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
|
||
qr_w, qr_h = qr_img.size
|
||
img.paste(qr_img, (28, qr_bottom_y))
|
||
|
||
# Side label, vertically centered against the QR
|
||
label_x = 28 + qr_w + 14
|
||
label_y = qr_bottom_y + (qr_h - 56) // 2
|
||
draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK)
|
||
draw.text((label_x, label_y + 18), "Scan for setup", font=F_STEP, fill=BK)
|
||
draw.text((label_x, label_y + 32), "& troubleshooting", font=F_STEP, fill=BK)
|
||
|
||
# ── Centre panel ─────────────────────────────────────────────
|
||
orientation_diagrams(draw, accent, show_active_ls=True)
|
||
|
||
# ── Right panel ──────────────────────────────────────────────
|
||
# Stacked: STEP 1 (WiFi join QR, dynamic) above STEP 2 (URL QR, static
|
||
# → http://192.168.4.1/, baked into bg). The URL QR is the trick that
|
||
# forces iOS to open the captive portal: scanning a URL QR launches
|
||
# Safari, Safari hits 192.168.4.1, iOS sees the request go to a
|
||
# captive network and renders the portal instead of fighting whether
|
||
# to auto-show CNA.
|
||
cx = RIGHT_CX
|
||
label_color = accent if accent != YL else BK
|
||
|
||
# Step 1 — WiFi join (dynamic QR, overlaid by firmware)
|
||
text_center(draw, cx, AP_QR_Y - 22, "STEP 1 — JOIN WIFI", F_BIG, label_color)
|
||
qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX
|
||
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=accent, width=3)
|
||
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
|
||
leave_qr_white(draw, qx, qy, qp)
|
||
text_center(draw, cx, qy+qp+8, qr_label, F_FOOT, label_color)
|
||
|
||
# Step 2 — URL QR (static, baked here so we don't need a second
|
||
# firmware render path; URL is fixed at http://192.168.4.1/).
|
||
url_qr = qrcode.QRCode(
|
||
version=None,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||
box_size=URL_QR_BOX,
|
||
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_x = cx - url_w // 2
|
||
url_y = URL_QR_TARGET_Y
|
||
|
||
text_center(draw, cx, url_y - 22, "STEP 2 — OPEN PAGE", F_BIG, label_color)
|
||
# Decorative border around the static URL QR — same accent treatment
|
||
# as the WiFi QR for visual consistency.
|
||
draw.rectangle([url_x-6, url_y-6, url_x+url_w+5, url_y+url_h+5], outline=accent, width=3)
|
||
draw.rectangle([url_x-3, url_y-3, url_x+url_w+2, url_y+url_h+2], outline=BK, width=3)
|
||
img.paste(url_img, (url_x, url_y))
|
||
|
||
text_center(draw, cx, url_y + url_h + 8, "http://192.168.4.1/", F_FOOT, label_color)
|
||
|
||
return img
|
||
|
||
|
||
def gen_ap_retry():
|
||
"""Step 1/2 with red accents + 'Connection Failed — try again' label,
|
||
served after a failed WiFi connection attempt."""
|
||
return gen_ap(
|
||
accent=RD,
|
||
header="CONNECTION FAILED — TRY AGAIN",
|
||
qr_label="Connection Failed — try again",
|
||
)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# SETUP SCREEN — green accent, account link
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
def gen_setup():
|
||
img = Image.new("RGB", (W, H), WH)
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# ── Status bar ────────────────────────────────────────────────
|
||
draw.rectangle([0, 0, W-1, BAR_H-1], fill=GR)
|
||
|
||
# WiFi bars icon
|
||
bars = [(0, 8), (0, 13), (0, 18), (0, 22)]
|
||
bx = 24
|
||
for i, (_, bh) in enumerate(bars):
|
||
draw.rectangle([bx + i*8, BAR_H//2 - bh//2, bx+i*8+5, BAR_H//2 + bh//2], fill=WH)
|
||
draw.text((bx+38, 18), "WIFI CONNECTED — STEP 2 OF 2", font=F_BAR, fill=WH)
|
||
|
||
# Right IP chip
|
||
ip_text = "192.168.x.x"
|
||
bb = draw.textbbox((0,0), ip_text, font=F_CHIP)
|
||
chip_w = bb[2]-bb[0] + 22
|
||
chip_x = W - chip_w - 20
|
||
draw.rectangle([chip_x, 11, chip_x+chip_w, BAR_H-12], fill=WH)
|
||
draw.text((chip_x+11, 18), ip_text, font=F_CHIP, fill=GR)
|
||
|
||
# ── Panel dividers ────────────────────────────────────────────
|
||
draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK)
|
||
draw.rectangle([DIV2_X, BODY_Y, DIV2_X+1, H-1], fill=BK)
|
||
|
||
# ── Left panel ────────────────────────────────────────────────
|
||
draw.text((28, BODY_Y+20), "Almost", font=F_HEAD, fill=BK)
|
||
draw.text((28, BODY_Y+52), "ready.", font=F_HEAD, fill=BK)
|
||
bb = draw.textbbox((0,0), "ready.", font=F_HEAD)
|
||
draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=GR)
|
||
|
||
draw.text((28, BODY_Y+96), "Scan to name this frame and", font=F_STEP, fill=(80,80,75))
|
||
draw.text((28, BODY_Y+110), "link it to your account.", font=F_STEP, fill=(80,80,75))
|
||
|
||
steps = [
|
||
("Scan the QR with your phone", "camera or QR app"),
|
||
("Sign in at pictureframe", ".edholm.me"),
|
||
("Name the frame, choose", "orientation — done."),
|
||
]
|
||
sy = BODY_Y + 136
|
||
for i, (l1, l2) in enumerate(steps):
|
||
bx, by = 28, sy + i*46
|
||
draw.rectangle([bx, by, bx+24, by+24], fill=GR)
|
||
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, WH)
|
||
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
|
||
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
|
||
|
||
# URL bar
|
||
url_y = BODY_Y + 278
|
||
draw.rectangle([28, url_y, 284, url_y+32], fill=BK)
|
||
draw.text((38, url_y+4), "URL", font=F_TINY, fill=GR)
|
||
draw.text((38, url_y+16), "pictureframe.edholm.me/setup/...", font=ttf("DejaVuSans.ttf", 10), fill=WH)
|
||
|
||
# Progress track
|
||
prog_y = BODY_Y + 328
|
||
draw.text((28, prog_y), "SETUP PROGRESS", font=F_TINY, fill=(140,140,135))
|
||
seg_y = prog_y + 14
|
||
segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200,200,195))]
|
||
seg_w = (284 - 28 - 8) // 3 # ~82px each
|
||
for i, (label, color) in enumerate(segs):
|
||
sx = 28 + i*(seg_w+4)
|
||
draw.rectangle([sx, seg_y, sx+seg_w, seg_y+6], fill=color)
|
||
text_center(draw, sx+seg_w//2, seg_y+10, label, ttf("DejaVuSans.ttf", 9), BK)
|
||
|
||
# ── Centre panel ─────────────────────────────────────────────
|
||
orientation_diagrams(draw, GR, show_active_ls=True)
|
||
|
||
# ── Right panel ──────────────────────────────────────────────
|
||
cx = RIGHT_CX
|
||
|
||
text_center(draw, cx, SETUP_QR_Y - 26, "SCAN TO FINISH", F_BIG, BK)
|
||
|
||
qx, qy, qp = SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX
|
||
draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=GR, width=3)
|
||
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
|
||
|
||
leave_qr_white(draw, qx, qy, qp)
|
||
|
||
# MAC chip below QR
|
||
mac = "1C:C3:AB:D1:91:F8"
|
||
bb = draw.textbbox((0,0), mac, font=F_CHIP)
|
||
mw = bb[2]-bb[0]+20
|
||
mx = cx - mw//2
|
||
draw.rectangle([mx, qy+qp+8, mx+mw, qy+qp+26], fill=BK)
|
||
draw.text((mx+10, qy+qp+11), mac, font=ttf("DejaVuSans-Bold.ttf", 10), fill=WH)
|
||
|
||
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)}")
|
||
|
||
# Reconstruct preview from packed data for verification
|
||
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__":
|
||
# Per-panel output directory: data/{vendor}-v{N}/. Defaults to the V1
|
||
# 7.3" panel since that's the only one in production; pass --panel to
|
||
# target a different one once new panels exist.
|
||
panel = "waveshare73-v1"
|
||
if "--panel" in sys.argv:
|
||
panel = sys.argv[sys.argv.index("--panel") + 1]
|
||
|
||
out_dir = os.path.join(os.path.dirname(__file__), f"../data/{panel}")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
|
||
print(f"Generating AP screen for {panel}…")
|
||
save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png")
|
||
print()
|
||
print(f"Generating AP retry screen for {panel}…")
|
||
save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png")
|
||
print()
|
||
print(f"Generating setup screen for {panel}…")
|
||
save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png")
|
||
print()
|
||
print("QR overlay constants for epd.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}, SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")
|