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).
94 lines
3.5 KiB
Python
94 lines
3.5 KiB
Python
"""JSON-file-backed config: Immich connection, selected album, and cursor
|
|
state (which photo /frame/image serves next)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from threading import RLock
|
|
from typing import Iterator
|
|
|
|
from pydantic import BaseModel
|
|
|
|
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
|
|
|
|
# Reentrant so load()/save() can each take it internally for their own I/O
|
|
# while a caller also holds it for a whole locked() span (see below).
|
|
_lock = RLock()
|
|
|
|
|
|
class FrameConfig(BaseModel):
|
|
immich_url: str = ""
|
|
immich_api_key: str = ""
|
|
management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN
|
|
album_id: str = ""
|
|
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
|
|
# own clock, independent of how/why the device asked for a photo.
|
|
current_asset_id: str = ""
|
|
current_asset_set_at: float = 0.0
|
|
queue: list[str] = []
|
|
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
|
|
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
|
|
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:
|
|
if not CONFIG_PATH.exists():
|
|
cfg = FrameConfig()
|
|
else:
|
|
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
|
|
|
|
# IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN set in the environment
|
|
# (e.g. docker-compose.yml, see docker-compose.yml.example) take
|
|
# precedence over whatever's saved in CONFIG_PATH, so credentials never
|
|
# need to go through the web UI.
|
|
env_url = os.environ.get("IMMICH_URL")
|
|
env_key = os.environ.get("IMMICH_API_KEY")
|
|
env_token = os.environ.get("MANAGEMENT_TOKEN")
|
|
if env_url:
|
|
cfg.immich_url = env_url
|
|
if env_key:
|
|
cfg.immich_api_key = env_key
|
|
if env_token:
|
|
cfg.management_token = env_token
|
|
|
|
return cfg
|
|
|
|
|
|
def save(cfg: FrameConfig) -> None:
|
|
with _lock:
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
|
|
|
|
|
|
@contextmanager
|
|
def locked() -> Iterator[None]:
|
|
"""Serializes an entire load-mutate-save cycle. load()/save() each
|
|
only lock their own I/O, which isn't enough by itself: uvicorn
|
|
dispatches sync routes to a thread pool, so two concurrent requests
|
|
(e.g. the device's own poll landing alongside a web UI edit) can each
|
|
load() the same on-disk state and the second save() silently clobber
|
|
the first's changes. Route handlers that mutate config should wrap
|
|
their whole load/mutate/save span in this."""
|
|
with _lock:
|
|
yield
|