Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
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:
+12
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,7 +34,11 @@
|
||||
href="/frames/{{ f.id }}">
|
||||
<span class="frame-dot" aria-hidden="true"></span>
|
||||
{{ f.name or ("Frame " ~ f.id) }}
|
||||
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
|
||||
{% if f.owner_user_id is none %}
|
||||
<span class="nav-sub">unclaimed</span>
|
||||
{% elif f.battery_percent >= 0 %}
|
||||
<span class="nav-sub battery-badge" title="Battery">🔋{{ f.battery_percent }}%</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not sidebar_frames %}
|
||||
|
||||
@@ -112,6 +112,26 @@
|
||||
disable. Needs SMTP configured by an admin.</p>
|
||||
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||
</section>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Advanced configuration</summary>
|
||||
<p class="sub">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.</p>
|
||||
<div class="palette-grid">
|
||||
{% set current_hex = palette_to_hex(frame.palette_rgb or default_palette_rgb) %}
|
||||
{% for label in palette_labels %}
|
||||
<label class="palette-swatch">
|
||||
<input type="color" id="palette_{{ loop.index0 }}" value="{{ current_hex[loop.index0] }}">
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="secondary" id="palette-save">Save</button>
|
||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,6 +139,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script>
|
||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
</script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user