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
+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