Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url), and the captive portal's success page became a redirect that hands the user's browser to <server>/claim?device_id=... after ~7s -- enough time for the phone to drop the provisioning AP while the device reboots. The server pushes a per-frame device token through /frame/config during a one-time handshake; the firmware persists it to NVS (a dedicated single-key write that deliberately doesn't reset the connected-once flag or WiFi cache) and prefers it over the provisioned shared token from the next request on. Config response buffer grows 256->512. Both board variants compile clean; new firmware also works against an old server (which ignores ?id=) and old firmware against this server (the phase A legacy mapping), so either deploy order survives. Server: /claim lands the captive-portal redirect -- claim-gated signup (a valid unclaimed/unregistered device id IS the enrollment invitation), pending claims for the user-beats-the-frame race (auto-attached at self-registration, 24h expiry), and a waiting page that refreshes until the frame checks in. Unclaimed/unconfigured frames get a rendered instruction placeholder with a QR from /frame/image (200, never an error loop) -- new qrcode dep, placeholder shares the exact quantize/pack path photos use. The on-frame manage QR now resolves to a limited no-login page: scans of / carrying device credentials (new ?id&token or the legacy shared token) 303 to /m/<manage_token>, which allows exactly view queue, show-next, advance, back, and scoped thumbnails -- no settings, no removal, no other frames. Full control means logging in. One real protocol hole found by simulating full wake cycles: after self-registration the device could never authenticate again (the wake cycle fetches the image BEFORE /frame/config delivers its token). require_device now treats the id itself as the credential until the first authenticated request flips device_token_ack -- the same trust level as open registration, closing permanently once the handshake completes.
This commit is contained in:
@@ -174,7 +174,15 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
else:
|
||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||
|
||||
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
return _quantize_and_pack(fitted, orientation)
|
||||
|
||||
|
||||
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
||||
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
||||
quantization, rotation into native panel space, and 2-pixels/byte
|
||||
packing. Takes an RGB image already composed at logical_render_size()
|
||||
for the orientation."""
|
||||
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(transpose)
|
||||
@@ -190,3 +198,52 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
i += 1
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape") -> bytes:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
same packed format as render_frame -- what /frame/image serves for a
|
||||
frame that isn't claimed or configured yet, so a fresh device shows
|
||||
instructions instead of an error screen and never error-loops."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
title_font = ImageFont.load_default(size=34)
|
||||
body_font = ImageFont.load_default(size=24)
|
||||
|
||||
qr_img = None
|
||||
if qr_url:
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(border=1, box_size=1)
|
||||
qr.add_data(qr_url)
|
||||
qr.make(fit=True)
|
||||
raw = qr.make_image().get_image().convert("RGB")
|
||||
# Integer upscale with NEAREST keeps modules crisp on the panel.
|
||||
target = 220
|
||||
scale = max(1, target // raw.width)
|
||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||
|
||||
# Vertical layout: text block, then QR under it, centered as a group.
|
||||
line_heights = []
|
||||
for i, line in enumerate(lines):
|
||||
font = title_font if i == 0 else body_font
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||
gap = 14
|
||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
||||
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||
y = max(20, (logical_h - total_h) // 2)
|
||||
|
||||
for line, font, w, h in line_heights:
|
||||
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
|
||||
y += h + gap
|
||||
|
||||
if qr_img:
|
||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
||||
|
||||
return _quantize_and_pack(img, orientation)
|
||||
|
||||
Reference in New Issue
Block a user