Consolidate firmware UI; server learns board from the device, not a picker
Build and push server image / build-and-push (push) Successful in 37s

All firmware-related controls (manual upload, Gitea repo URL,
auto-update checkbox, detected board, Update frame button) now live in
one "Firmware update" card instead of being split across the main
Settings form and a separate card.

The board variant used to pick a Gitea release asset was a dropdown
the user had to set by hand and could get wrong. The device now
reports it itself via a new X-Frame-Board header (CONFIG_FRAME_BOARD_NAME,
"devkit" by default, "xiao" in sdkconfig.xiao) on every /frame/config
poll, stored as device_board_variant -- the server learns it instead.
Update checks/applies are gated on the board being known, since there's
nothing to fetch until a device has checked in at least once.
This commit is contained in:
2026-07-21 21:58:59 -04:00
parent ad1fa99064
commit 7ea5c9a4fb
7 changed files with 128 additions and 60 deletions
+30 -13
View File
@@ -207,10 +207,14 @@ def frame_config(request: Request):
reachability check. Always returns 200 with current settings
(defaults if nothing's been saved yet) -- no Immich-configured gate,
since this doubles as the "is the server up" signal. Also captures
the device's running firmware version (X-Frame-Version header) and
advertises the uploaded OTA image's version, so the device's update
check costs zero extra round trips."""
the device's running firmware version and board variant (X-Frame-
Version/X-Frame-Board headers -- the latter is how the Gitea
auto-update feature learns which release asset to fetch, instead of
a user picking it in the web UI) and advertises the uploaded OTA
image's version, so the device's update check costs zero extra
round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
@@ -221,6 +225,8 @@ def frame_config(request: Request):
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
cfg.stats.ota_updates_applied += 1
cfg.device_firmware_version = reported_version
if reported_board:
cfg.device_board_variant = reported_board
config.save(cfg)
return {
"refresh_interval_s": _effective_refresh_interval_s(cfg),
@@ -276,7 +282,6 @@ def api_config_save(
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_board_variant: str = Form("xiao"),
firmware_auto_update: bool = Form(False),
):
# Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
@@ -309,7 +314,6 @@ def api_config_save(
if timezone in ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_board_variant = firmware_board_variant if firmware_board_variant in ("xiao", "devkit") else "xiao"
cfg.firmware_auto_update = firmware_auto_update
cfg.stats.config_saves += 1
config.save(cfg)
@@ -493,13 +497,19 @@ def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
def _apply_gitea_update(cfg: config.FrameConfig) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual
POST /api/firmware upload would. Network I/O happens before the lock
is taken, matching the load/mutate/save concurrency pattern used
elsewhere (see config.locked())."""
POST /api/firmware upload would. The board comes from the device
itself (device_board_variant, learned from its X-Frame-Board header
on GET /frame/config -- see frame_config()), not a user picker, so
there's nothing to fetch until a device has checked in at least
once. Network I/O happens before the lock is taken, matching the
load/mutate/save concurrency pattern used elsewhere (see
config.locked())."""
if not cfg.device_board_variant:
raise HTTPException(400, "No frame has checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(cfg)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(cfg.firmware_board_variant)
asset_name = gitea_releases.asset_name_for_board(cfg.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
@@ -524,7 +534,11 @@ def api_firmware_check():
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button."""
just reports it so the web UI can offer the "Update frame" button.
Applying (auto or manual) needs to know the frame's board, which is
learned from the device's own X-Frame-Board header rather than
picked by the user -- update_available stays false until a device
has checked in at least once, regardless of what Gitea has."""
cfg = config.load()
if not cfg.firmware_update_repo_url:
return {"enabled": False}
@@ -543,9 +557,11 @@ def api_firmware_check():
config.save(cfg)
cfg = config.load()
update_available = bool(
cfg.firmware_gitea_latest_version
) and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
update_available = (
bool(cfg.firmware_gitea_latest_version)
and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
and bool(cfg.device_board_variant)
)
if update_available and cfg.firmware_auto_update:
_apply_gitea_update(cfg)
cfg = config.load()
@@ -553,6 +569,7 @@ def api_firmware_check():
return {
"enabled": True,
"board": cfg.device_board_variant or None,
"latest_version": cfg.firmware_gitea_latest_version or None,
"staged_version": cfg.firmware_available_version or None,
"update_available": update_available,