From 5b11f2accb3b4077b8545b92db5a5ace48f7d7e5 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Wed, 22 Jul 2026 01:19:06 -0400
Subject: [PATCH] Per-frame palette calibration + sidebar battery indicator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Advanced configuration (Configuration tab, collapsed 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.
---
server/README.md | 17 ++++--
server/app/image_pipeline.py | 79 ++++++++++++++++----------
server/app/migration.py | 8 +++
server/app/models.py | 5 ++
server/app/routers/api_frames.py | 12 ++++
server/app/routers/common.py | 2 +-
server/app/routers/device.py | 3 +
server/app/routers/frame_pages.py | 7 ++-
server/app/static/frame_config.js | 35 ++++++++++++
server/app/static/theme.css | 24 ++++++++
server/app/templates/app_base.html | 6 +-
server/app/templates/frame_config.html | 25 +++++++-
12 files changed, 185 insertions(+), 38 deletions(-)
diff --git a/server/README.md b/server/README.md
index e0a484f..2fd56f4 100644
--- a/server/README.md
+++ b/server/README.md
@@ -153,7 +153,10 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
what `refresh_interval_s` gets handed to the device),
`firmware_update_repo_url`, `firmware_auto_update`,
- `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable).
+ `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
+ `palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
+ blue/green order --, `palette_reset` -- `true` clears back to the
+ default palette).
- `POST .../take-control` -- always succeeds for a linked user.
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
@@ -193,10 +196,14 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
to photos this frame is actually showing or has queued, not any
Immich asset ID someone might guess -- a second layer a leaked device
token alone wouldn't bypass.
-- The 6-color palette RGB values in `app/image_pipeline.py` are
- approximations, 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.
+- The 6-color palette RGB values in `app/image_pipeline.py`
+ (`DEFAULT_PALETTE_RGB`) are approximations, not measured values
+ (Waveshare doesn't publish exact color primaries for this panel).
+ Each frame's Configuration tab has an **Advanced configuration**
+ section (collapsed by default) with a color picker per ink color --
+ tune them once you can compare a rendered photo against the real
+ panel, and "Reset to defaults" to go back. Different panel units can
+ vary enough to be worth calibrating per frame.
## Deploying a pre-built image
diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py
index 268b4f9..aa30356 100644
--- a/server/app/image_pipeline.py
+++ b/server/app/image_pipeline.py
@@ -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 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)
diff --git a/server/app/migration.py b/server/app/migration.py
index 9ac6ada..4a46e76 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -52,10 +52,18 @@ def _migration_3(conn) -> None:
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
+def _migration_4(conn) -> None:
+ """Advanced configuration: a per-frame color palette override. NULL
+ for every existing row -- exactly "use the default", no behavior
+ change until a frame's Configuration tab sets one."""
+ conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
(3, _migration_3),
+ (4, _migration_4),
]
diff --git a/server/app/models.py b/server/app/models.py
index 70b2543..85f3166 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -124,6 +124,11 @@ class Frame(Base):
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
+ # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
+ # blue/green, matching image_pipeline.PANEL_CODES order) overriding
+ # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
+ # default -- most frames never touch this.
+ palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index adec110..c979523 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
+from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
@@ -79,6 +80,8 @@ def api_config_save(
firmware_update_repo_url: str | None = Form(None),
firmware_auto_update: bool | None = Form(None),
battery_alert_threshold_pct: int | None = Form(None),
+ palette: list[str] | None = Form(None),
+ palette_reset: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -124,6 +127,15 @@ def api_config_save(
# A changed threshold should be able to fire again immediately,
# not stay suppressed by a flag set under the old value.
cfg.battery_alert_sent = False
+ if palette_reset:
+ cfg.palette_rgb = None
+ elif palette is not None:
+ if len(palette) != len(PALETTE_LABELS):
+ raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
+ parsed = [hex_to_rgb(h) for h in palette]
+ if any(rgb is None for rgb in parsed):
+ raise HTTPException(400, "Palette colors must be #rrggbb hex values")
+ cfg.palette_rgb = [list(rgb) for rgb in parsed]
cfg.stats_config_saves += 1
return {"status": "saved"}
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index 04ed6e8..d048026 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -85,7 +85,7 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes))
- return render_frame(source, faces=faces, orientation=frame.orientation)
+ return render_frame(source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
def battery_estimate_s(frame: Frame) -> int | None:
diff --git a/server/app/routers/device.py b/server/app/routers/device.py
index 095f86a..257d020 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -54,16 +54,19 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
["This frame isn't claimed yet", "Scan to link it to your account:"],
qr_url=claim_url,
orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb,
)
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index d8f4148..f66b13a 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..db import get_db
+from ..image_pipeline import DEFAULT_PALETTE_RGB, PALETTE_LABELS, palette_to_hex
from ..models import Frame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
@@ -40,7 +41,11 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
- request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES
+ request, db, frame_id, "frame_config.html", "config",
+ timezones=ALL_TIMEZONES,
+ palette_labels=PALETTE_LABELS,
+ default_palette_rgb=DEFAULT_PALETTE_RGB,
+ palette_to_hex=palette_to_hex,
)
diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js
index 416a5b9..bf6fc11 100644
--- a/server/app/static/frame_config.js
+++ b/server/app/static/frame_config.js
@@ -66,6 +66,41 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl);
+// ---- Advanced configuration: color palette ----
+
+function paletteInputs() {
+ return Array.from(document.querySelectorAll('[id^="palette_"]'))
+ .sort((a, b) => Number(a.id.split('_')[1]) - Number(b.id.split('_')[1]));
+}
+
+async function savePalette(body) {
+ try {
+ const resp = await fetch(`${window.FRAME_API}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ showStatus(true, 'Saved.');
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+}
+
+document.getElementById('palette-save').addEventListener('click', () => {
+ const body = new URLSearchParams();
+ for (const input of paletteInputs()) {
+ body.append('palette', input.value);
+ }
+ savePalette(body);
+});
+
+document.getElementById('palette-reset').addEventListener('click', () => {
+ const inputs = paletteInputs();
+ window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; });
+ savePalette(new URLSearchParams({ palette_reset: 'true' }));
+});
+
// ---- Battery alerts card ----
document.getElementById('battery-alert-save').addEventListener('click', async () => {
diff --git a/server/app/static/theme.css b/server/app/static/theme.css
index 0fcfee6..716b160 100644
--- a/server/app/static/theme.css
+++ b/server/app/static/theme.css
@@ -168,6 +168,30 @@ summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
+.palette-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 12px;
+ margin-top: 14px;
+}
+.palette-swatch {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 0;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text);
+}
+.palette-swatch input[type="color"] {
+ width: 34px;
+ height: 34px;
+ padding: 2px;
+ margin-top: 0;
+ border-radius: 8px;
+ cursor: pointer;
+}
+
.card {
background: var(--surface);
border: 1px solid var(--border);
diff --git a/server/app/templates/app_base.html b/server/app/templates/app_base.html
index 29a1fbd..728a022 100644
--- a/server/app/templates/app_base.html
+++ b/server/app/templates/app_base.html
@@ -34,7 +34,11 @@
href="/frames/{{ f.id }}">
{{ f.name or ("Frame " ~ f.id) }}
- {% if f.owner_user_id is none %}unclaimed{% endif %}
+ {% if f.owner_user_id is none %}
+ unclaimed
+ {% elif f.battery_percent >= 0 %}
+ 🔋{{ f.battery_percent }}%
+ {% endif %}
{% endfor %}
{% if not sidebar_frames %}
diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html
index ebafb02..018bea0 100644
--- a/server/app/templates/frame_config.html
+++ b/server/app/templates/frame_config.html
@@ -112,6 +112,26 @@
disable. Needs SMTP configured by an admin.
+
+
+ Advanced configuration
+
Color-quantization values used when dithering photos
+ for this panel -- approximations by default, since exact primaries
+ aren't published. Tune them by comparing a rendered photo against
+ the physical panel; different panel units can vary enough to be
+ worth calibrating per frame.
+
+ {% set current_hex = palette_to_hex(frame.palette_rgb or default_palette_rgb) %}
+ {% for label in palette_labels %}
+
+ {% endfor %}
+