Web app: new entities (Image, RenderedAsset, SharedImage, Token, DeviceImageHistory), enums, repositories, controllers, message handlers, migrations, tests, frontend upload/library/sticker UI, Vue components. Firmware: EPD background screen binaries + gen scripts, setup_bg header. Infra: ddev config, test bundle, gitignore coverage dir. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
#!/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 os, sys
|
||||
|
||||
# ── 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)
|
||||
AP_QR_CELL = 5
|
||||
AP_QR_MODS = 37 # version 5, ECC_LOW
|
||||
AP_QR_PX = AP_QR_MODS * AP_QR_CELL # 185
|
||||
|
||||
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
|
||||
|
||||
AP_QR_X = RIGHT_CX - AP_QR_PX // 2 # 563
|
||||
AP_QR_Y = BODY_CY - AP_QR_PX // 2 # 174 (will nudge down below label)
|
||||
AP_QR_Y = 185 # nudge down a bit for "SCAN TO CONNECT" label above
|
||||
|
||||
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 orientation_diagrams(draw, accent, show_active_ls=True):
|
||||
"""Draw both orientation diagrams in the centre panel.
|
||||
accent = RGB colour for the active / ribbon highlights."""
|
||||
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, accent if show_active_ls else BK)
|
||||
|
||||
ls_border = accent if show_active_ls else BK
|
||||
draw.rectangle([ls_x, ls_y, ls_x+ls_w-1, ls_y+ls_h-1], outline=ls_border, width=3)
|
||||
rib_rgb = accent if show_active_ls else BK
|
||||
draw.rectangle([ls_x, ls_y+ls_h, ls_x+rib_w-1, ls_y+ls_h+rib_h-1], fill=rib_rgb)
|
||||
|
||||
if show_active_ls:
|
||||
# check badge
|
||||
bx, by = cx-9, ls_y+ls_h+rib_h+5
|
||||
draw.rectangle([bx, by, bx+18, by+18], fill=accent)
|
||||
text_center(draw, bx+9, by+3, "✓", F_CHIP, BK)
|
||||
|
||||
# Thin separator
|
||||
sep_y = ls_y + ls_h + rib_h + (30 if show_active_ls else 14)
|
||||
draw.rectangle([CTR_X+10, sep_y, CTR_X+CTR_W-10, sep_y], fill=(180,180,175))
|
||||
|
||||
# ── Portrait ──────────────────────────────────────────────────
|
||||
pt_x, pt_y = CTR_X+56, sep_y+14
|
||||
pt_w, pt_h = 64, 106
|
||||
pr_w, pr_h = 10, 106
|
||||
|
||||
text_center(draw, cx, pt_y-14, "PORTRAIT", F_LABEL, BK)
|
||||
|
||||
draw.rectangle([pt_x-pr_w, pt_y, pt_x-1, pt_y+pr_h-1], fill=BK)
|
||||
draw.rectangle([pt_x, pt_y, pt_x+pt_w-1, pt_y+pt_h-1], outline=BK, width=3)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# AP SCREEN — yellow accent, WiFi credentials
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def gen_ap():
|
||||
img = Image.new("RGB", (W, H), WH)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────
|
||||
draw.rectangle([0, 0, W-1, BAR_H-1], fill=YL)
|
||||
draw.text((24, 18), "SETUP MODE — STEP 1 OF 2", 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=YL)
|
||||
|
||||
# ── 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=YL)
|
||||
|
||||
# Steps
|
||||
steps = [
|
||||
("Scan the QR code →", "Phone joins PictureFrame-91F8"),
|
||||
("Browser opens — enter", "your home WiFi password"),
|
||||
("Tap Connect and watch", "for the QR code to change"),
|
||||
]
|
||||
sy = BODY_Y + 105
|
||||
for i, (l1, l2) in enumerate(steps):
|
||||
bx, by = 28, sy + i*46
|
||||
draw.rectangle([bx, by, bx+24, by+24], fill=BK)
|
||||
text_center(draw, bx+12, by+6, str(i+1), F_STEPN, YL)
|
||||
draw.text((62, by+3), l1, font=F_STEP, fill=BK)
|
||||
draw.text((62, by+17), l2, font=F_STEP, fill=BK)
|
||||
|
||||
# Divider + footnote
|
||||
draw.rectangle([28, BODY_Y+254, 283, BODY_Y+255], fill=BK)
|
||||
draw.text((28, BODY_Y+262), "Page didn't open?", font=F_FOOT, fill=BK)
|
||||
draw.text((28, BODY_Y+276), "Go to 192.168.4.1", font=F_FOOT, fill=BK)
|
||||
|
||||
# ── Centre panel ─────────────────────────────────────────────
|
||||
orientation_diagrams(draw, YL, show_active_ls=True)
|
||||
|
||||
# ── Right panel ──────────────────────────────────────────────
|
||||
cx = RIGHT_CX
|
||||
|
||||
# "SCAN TO CONNECT" label
|
||||
text_center(draw, cx, AP_QR_Y - 26, "SCAN TO CONNECT", F_BIG, BK)
|
||||
|
||||
# QR border: yellow outer, black inner
|
||||
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=YL, width=3)
|
||||
draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3)
|
||||
|
||||
# Leave QR area white for firmware overlay
|
||||
leave_qr_white(draw, qx, qy, qp)
|
||||
|
||||
# "Encodes WIFI:..." label below
|
||||
text_center(draw, cx, qy+qp+10, "WIFI:S:PictureFrame-91F8;T:nopass;;", F_FOOT, (100,100,95))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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__":
|
||||
out_dir = os.path.join(os.path.dirname(__file__), "../data")
|
||||
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 setup screen…")
|
||||
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}")
|
||||
Reference in New Issue
Block a user