Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s

Battery (firmware + server, disabled by default): new battery.c reads
a 2x200k voltage divider via ADC oneshot with curve-fitting calibration
(the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve,
and restores the pin to button duty after each read -- the settled
XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per
wake. Skipped entirely when on mains (a 2x100k VBUS divider into a
spare digital pin -- the 5V pin is dead on battery power, so presence =
mains, where the charging voltage would read misleadingly full) or when
the reading is implausible. The manage overlay gains a battery region
(static outline glyph + "NN%", below the manage QR, all menu levels),
and the device POSTs to the new /frame/battery endpoint after a
successful fetch; the server stores percent + as-of timestamp, exposed
via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO /
FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board);
compile-verified both disabled and enabled, hardware bring-up deferred
until the ordered XIAO + batteries arrive.

Orientation (server-side only): new config setting + web UI dropdown
(landscape / portrait / landscape_flipped / portrait_flipped). Photos
are composed/cropped at the logical hanging shape (portrait crops at
480x800, so face-aware crops match how the frame actually hangs), then
rotated losslessly into the panel's native 800x480 byte layout after
dithering -- the device never knows. Face-label anchors are transformed
through the same rotation (logical_to_native()) so they stay attached
to faces on rotated frames. Known documented limitation: the on-device
manage overlay still renders in native orientation, so it appears
sideways on a portrait-hung frame (QRs scan at any rotation; text reads
sideways).
This commit is contained in:
2026-07-19 22:11:14 -04:00
parent 86d5852f8e
commit 015993af00
17 changed files with 593 additions and 42 deletions
+18 -3
View File
@@ -51,7 +51,16 @@ algorithm itself -- it just streams the response straight to the panel.
toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI)
- `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces/queue_target_len
- `POST /api/config` -- saves album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len.
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
`portrait_flipped`) matches how the frame is physically hung: photos
are composed/cropped for that shape (portrait crops at 480x800), then
rotated into the panel's native 800x480 byte layout server-side --
the device never knows. Note the device-side manage-menu overlay
(QRs, text, battery indicator, face labels) still renders in native
panel orientation, so on a portrait-hung frame it appears rotated
90° to the viewer -- QR codes scan fine at any rotation, but the text
reads sideways. A known limitation, not planned to change soon
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
@@ -99,8 +108,14 @@ algorithm itself -- it just streams the response straight to the panel.
detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each
entry an asset id + thumbnail URL; used by the config UI
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
battery reading, stored with a timestamp. Only sent when the device
is actually running on battery (see `firmware/README.md`'s Battery
section) -- a frame on mains power never reports
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
"battery": {"percent": N, "as_of": ts} | null}`, each queue entry an
asset id + thumbnail URL; used by the config UI (which shows the
battery line under "Now displaying" when present)
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
+10
View File
@@ -27,6 +27,10 @@ class FrameConfig(BaseModel):
order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600
smart_crop_faces: bool = True
# How the physical frame is hung: landscape (native), portrait,
# landscape_flipped, portrait_flipped. Purely a server-side render
# decision -- the device always receives native 800x480 bytes.
orientation: str = "landscape"
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
# is what lets the server decide "has it been long enough to advance" on its
@@ -39,6 +43,12 @@ class FrameConfig(BaseModel):
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich)
# Last battery report from the device (POST /frame/battery); -1 = never
# reported / not battery-powered. battery_as_of mirrors the
# current_asset_set_at timestamp pattern.
battery_percent: int = -1
battery_as_of: float = 0.0
def load() -> FrameConfig:
with _lock:
+26 -13
View File
@@ -15,7 +15,12 @@ import io
from PIL import Image, ImageOps
from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _face_aware_crop_box, _plain_center_crop_box
from .image_pipeline import (
_face_aware_crop_box,
_plain_center_crop_box,
logical_render_size,
logical_to_native,
)
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
@@ -26,26 +31,33 @@ MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in final
800x480 frame pixel space at each named face's bottom-center point.
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and smart_crop_faces must match the
setting that was active then -- otherwise the crop box computed here
won't match what's actually on screen.
the currently-displayed frame, and smart_crop_faces/orientation must
match the settings that were active then -- otherwise the crop box and
rotation computed here won't match what's actually on screen.
The crop math runs in logical (pre-rotation) space, matching
render_frame()'s composition step; each anchor is then rotated into
native panel coordinates via logical_to_native(), since the firmware
draws labels in native space.
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
return []
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
if smart_crop_faces and faces:
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces)
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
crop_w, crop_h = right - left, bottom - top
else:
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT)
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
labels = []
for face in named[:MAX_LABELED_FACES]:
@@ -57,16 +69,17 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y
frame_x = (center_x - left) * (EPD_WIDTH / crop_w)
frame_y = (bottom_y - top) * (EPD_HEIGHT / crop_h)
frame_x = (center_x - left) * (logical_w / crop_w)
frame_y = (bottom_y - top) * (logical_h / crop_h)
if not (0 <= frame_x <= EPD_WIDTH and 0 <= frame_y <= EPD_HEIGHT):
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
labels.append({"name": name, "x": int(frame_x), "y": int(frame_y)})
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
return labels
+51 -4
View File
@@ -7,6 +7,44 @@ from PIL import Image, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
# How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native
# space afterwards -- rotation happens after dithering, which is lossless
# (a pure pixel permutation). Which of 90/270 is "portrait" vs
# "portrait_flipped" is a convention pick; whichever way the frame is
# hung, one of the two is right.
ORIENTATION_TRANSPOSE = {
"landscape": None,
"landscape_flipped": Image.Transpose.ROTATE_180,
"portrait": Image.Transpose.ROTATE_90,
"portrait_flipped": Image.Transpose.ROTATE_270,
}
def logical_render_size(orientation: str) -> tuple[int, int]:
"""(width, height) the photo is composed/cropped at for this
orientation, before rotating into native panel space."""
if orientation in ("portrait", "portrait_flipped"):
return EPD_HEIGHT, EPD_WIDTH
return EPD_WIDTH, EPD_HEIGHT
def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
"""Maps a point in logical (pre-rotation) frame space to native
800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE
applies to the pixels -- anything positioned in logical coordinates
(e.g. face labels) needs this to stay attached to the rotated
content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
logical_w, logical_h = logical_render_size(orientation)
if orientation == "landscape_flipped":
return int(logical_w - 1 - x), int(logical_h - 1 - y)
if orientation == "portrait": # ROTATE_90 (CCW)
return int(y), int(logical_w - 1 - x)
if orientation == "portrait_flipped": # ROTATE_270 (CW)
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
@@ -108,23 +146,32 @@ def _face_aware_crop_box(
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
def render_frame(source: Image.Image, faces: list[dict] | None = None) -> bytes:
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape") -> 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.
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
toward keeping them on screen instead of a plain center-crop.
`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.
"""
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if faces:
box = _face_aware_crop_box(fitted.width, fitted.height, EPD_WIDTH, EPD_HEIGHT, faces)
fitted = fitted.crop(box).resize((EPD_WIDTH, EPD_HEIGHT), Image.LANCZOS)
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
else:
fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS)
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
pixels = quantized.load()
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
+32 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import io
import logging
import time
from datetime import datetime
import httpx
@@ -29,6 +30,8 @@ MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
@@ -117,6 +120,7 @@ def api_config_save(
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
):
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
# docker-compose.yml.example) -- config.load() already applies them,
@@ -138,6 +142,7 @@ def api_config_save(
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
config.save(cfg)
return {"status": "saved"}
@@ -175,7 +180,7 @@ def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str)
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)
return render_frame(source, faces=faces, orientation=cfg.orientation)
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
@@ -240,6 +245,26 @@ def frame_back():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@app.post("/frame/battery", dependencies=[Depends(require_access_token)])
def frame_battery(body: BatteryReport):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp so the web UI can show
both the level and how stale it is."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
with config.locked():
cfg = config.load()
cfg.battery_percent = body.percent
cfg.battery_as_of = time.time()
config.save(cfg)
return {"status": "saved"}
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
@@ -412,7 +437,7 @@ def frame_face_labels():
logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces)
labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces, cfg.orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
@@ -444,6 +469,11 @@ def api_queue():
return {
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
"battery": (
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
if cfg.battery_percent >= 0
else None
),
}
+16
View File
@@ -83,6 +83,14 @@
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
@@ -125,6 +133,7 @@
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value,
@@ -424,6 +433,13 @@
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
if (data.battery) {
const batteryLine = document.createElement('p');
batteryLine.className = 'sub';
const asOf = new Date(data.battery.as_of * 1000).toLocaleString();
batteryLine.textContent = `Battery: ${data.battery.percent}% (as of ${asOf})`;
currentEl.appendChild(batteryLine);
}
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';