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
+9 -6
View File
@@ -91,21 +91,24 @@ class FrameConfig(BaseModel):
battery_log: list = []
# Device liveness/telemetry: last_seen is touched by every /frame/*
# request; device_firmware_version comes from the X-Frame-Version
# header the device sends with its config poll.
# request; device_firmware_version/device_board_variant come from the
# X-Frame-Version/X-Frame-Board headers the device sends with its
# config poll (CONFIG_FRAME_BOARD_NAME on the firmware side).
last_seen: float = 0.0
device_firmware_version: str = ""
device_board_variant: str = "" # "" until a device has ever checked in
# Version parsed out of the most recently uploaded OTA image
# (POST /api/firmware); "" = none uploaded yet.
firmware_available_version: str = ""
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
# repo_url empty = feature off, no Gitea calls made at all.
# board_variant picks which release asset to pull -- must match one of
# the names .gitea/workflows/firmware-release-build.yml publishes
# repo_url empty = feature off, no Gitea calls made at all. Which
# release asset to pull is learned from the device itself
# (device_board_variant below, from its X-Frame-Board header) rather
# than picked by the user -- must match one of the names
# .gitea/workflows/firmware-release-build.yml publishes
# (firmware-<board_variant>.bin).
firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo"
firmware_board_variant: str = "xiao" # "xiao" or "devkit"
firmware_auto_update: bool = False # pull+stage a newer release with no button click
# Optional Gitea PAT (read-only access is enough) for a private repo's
# releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env
+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,
+36 -21
View File
@@ -75,25 +75,6 @@
{% endfor %}
</select>
</label>
<label>Firmware Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ cfg.firmware_update_repo_url }}">
</label>
<label>Frame's board
<select id="firmware_board_variant">
<option value="xiao" {% if cfg.firmware_board_variant == "xiao" %}selected{% endif %}>Seeed XIAO ESP32-C6</option>
<option value="devkit" {% if cfg.firmware_board_variant == "devkit" %}selected{% endif %}>ESP32-C6-DevKitC-1</option>
</select>
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<p class="sub" style="margin-top: 8px;">When a Gitea repo URL is set, the
server periodically checks its releases for a newer build for the
board above. With auto-apply off, an "Update frame" button appears
below when one's found; with it on, the server stages the new
build itself -- the frame still only updates on its own next wake.</p>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
@@ -124,12 +105,30 @@
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<p class="sub" style="margin-top: 16px;">Or check a Gitea repo's releases
automatically -- built by <code>.gitea/workflows/firmware-release-build.yml</code>,
one binary per board. The board above is learned from the frame
itself, never picked by hand.</p>
<label>Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ cfg.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
@@ -167,7 +166,6 @@
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_board_variant: document.getElementById('firmware_board_variant').value,
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch('/api/config', {
@@ -215,6 +213,16 @@
}
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
await saveConfig();
showStatus(true, 'Saved.');
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
@@ -522,19 +530,26 @@
async function loadFirmwareCheck() {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch('/api/firmware/check');
if (!resp.ok) {
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
return;
}
statusEl.style.display = 'block';
if (data.update_available) {
if (!data.board) {
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {