Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s

Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.

Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.

Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
This commit is contained in:
2026-07-22 01:19:06 -04:00
parent c1c803b497
commit 5b11f2accb
12 changed files with 185 additions and 38 deletions
+50 -29
View File
@@ -45,39 +45,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
return int(logical_h - 1 - y), int(x)
return int(x), int(y)
# Approximate sRGB for each of the panel's 6 ink colors. These are
# reasonable placeholders, not measured values -- Waveshare doesn't publish
# exact color primaries for this panel. Tune them once you can compare a
# rendered test image against the real panel.
PALETTE_RGB = [
# (0, 0, 0), # BLACK
# (255, 255, 255), # WHITE
# (255, 219, 0), # YELLOW
# (207, 0, 15), # RED
# (0, 39, 133), # BLUE
# (0, 133, 55), # GREEN
(0, 0, 0),
(255, 255, 255),
(255, 243, 56),
(191, 0, 0),
(100, 64, 255),
(67, 138, 28)
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
# placeholders, not measured values (Waveshare doesn't publish exact
# color primaries for this panel). This is the fallback for any frame
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
# Configuration tab -- "Advanced configuration" -- once you can compare
# a rendered test image against the real panel; different panel units
# can vary enough to be worth calibrating per frame).
DEFAULT_PALETTE_RGB = [
(0, 0, 0), # BLACK
(255, 255, 255), # WHITE
(255, 243, 56), # YELLOW
(191, 0, 0), # RED
(100, 64, 255), # BLUE
(67, 138, 28), # GREEN
]
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
# hardware protocol, never user-configurable. 0x4 is intentionally unused
# upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
def _build_palette_image() -> Image.Image:
def palette_to_hex(palette_rgb: list) -> list[str]:
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
configuration color pickers."""
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
a 6-hex-digit color (what <input type="color"> always sends, but a
direct API call might not)."""
hex_str = hex_str.strip().lstrip("#")
if len(hex_str) != 6:
return None
try:
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
except ValueError:
return None
def _build_palette_image(palette_rgb: list) -> Image.Image:
pal_img = Image.new("P", (1, 1))
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
return pal_img
_PALETTE_IMAGE = _build_palette_image()
def _plain_center_crop_box(
img_width: int, img_height: int, target_width: int, target_height: int
) -> tuple[float, float, int, int]:
@@ -153,7 +170,7 @@ def _face_aware_crop_box(
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape") -> bytes:
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
@@ -164,6 +181,9 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
the frame physically hangs, then rotates into native panel space --
the output byte layout is identical either way.
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
see Frame.palette_rgb) -- None uses the default.
"""
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
@@ -174,15 +194,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
else:
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
return _quantize_and_pack(fitted, orientation)
return _quantize_and_pack(fitted, orientation, palette_rgb)
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: list | None = None) -> 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)
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
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)
@@ -201,7 +222,7 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape") -> bytes:
orientation: str = "landscape", palette_rgb: list | None = None) -> 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
@@ -246,4 +267,4 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
return _quantize_and_pack(img, orientation)
return _quantize_and_pack(img, orientation, palette_rgb)