From 7ea5c9a4fb7373e7a10eb766a74d4b9cdd0a7398 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 21 Jul 2026 21:58:59 -0400 Subject: [PATCH] Consolidate firmware UI; server learns board from the device, not a picker 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. --- firmware/main/Kconfig.projbuild | 15 +++++++++ firmware/main/frame_client.c | 11 +++++-- firmware/sdkconfig.xiao | 2 ++ server/README.md | 45 ++++++++++++++++---------- server/app/config.py | 15 +++++---- server/app/main.py | 43 +++++++++++++++++-------- server/app/templates/index.html | 57 +++++++++++++++++++++------------ 7 files changed, 128 insertions(+), 60 deletions(-) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index 5d9f038..69dfbef 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -1,5 +1,20 @@ menu "ESPresso Frame Configuration" + config FRAME_BOARD_NAME + string "Board variant name, reported to the server" + default "devkit" + help + Sent as the X-Frame-Board request header on every + GET /frame/config poll, so the server can learn which board + this device is and automatically fetch the right OTA build + from a configured Gitea repo's releases -- no manual "which + board" picker in the web UI. Must match one of the asset + names .gitea/workflows/firmware-release-build.yml publishes + (firmware-.bin): "devkit" (this default, for the + plain ESP32-C6-DevKitC-1 build) or "xiao" (set via + sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see + build_for_board.sh). + config FRAME_XIAO_ANTENNA_INIT bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)" default n diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 6dc5e6b..0b25e94 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -342,9 +342,13 @@ static bool json_extract_string(const char *json, const char *key, char *out, si /* GETs the server's /frame/config -- doubles as both the reachability * check (any completed HTTP response means the socket-level connection * succeeded), the source of the server-configurable refresh interval, - * and (via the X-Frame-Version request header / firmware_version - * response field) the device's OTA update check -- piggybacked on a - * request already made every wake, no extra round trip. */ + * and (via the X-Frame-Version/X-Frame-Board request headers and + * firmware_version response field) the device's OTA update check -- + * piggybacked on a request already made every wake, no extra round + * trip. X-Frame-Board lets the server learn which board this device is + * (CONFIG_FRAME_BOARD_NAME) so it can pick the right Gitea release + * asset itself, instead of a user manually selecting a board in the + * web UI. */ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg) { frame_server_config_t result = { @@ -364,6 +368,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg) }; esp_http_client_handle_t client = esp_http_client_init(&config); esp_http_client_set_header(client, "X-Frame-Version", esp_app_get_description()->version); + esp_http_client_set_header(client, "X-Frame-Board", CONFIG_FRAME_BOARD_NAME); esp_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { diff --git a/firmware/sdkconfig.xiao b/firmware/sdkconfig.xiao index 9885989..a15b4ec 100644 --- a/firmware/sdkconfig.xiao +++ b/firmware/sdkconfig.xiao @@ -8,6 +8,8 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv" +CONFIG_FRAME_BOARD_NAME="xiao" + # Powers the XIAO's RF switch and selects its onboard antenna -- without # this the softAP/STA radio doesn't reliably reach the antenna at all. # See the Kconfig help text (FRAME_XIAO_ANTENNA_INIT) for why. diff --git a/server/README.md b/server/README.md index 65dbb0f..250d11b 100644 --- a/server/README.md +++ b/server/README.md @@ -48,14 +48,17 @@ algorithm itself -- it just streams the response straight to the panel. pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` builds both supported boards and publishes them as release assets (`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt` - changes on `main`. In the web UI's Settings form, set **Firmware Gitea - repo URL** to that repo (e.g. `https://git.example.com/owner/repo`) and - pick the frame's **board**; if the repo is private, also set - `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. The - server then periodically checks for a newer release and either shows - an "Update frame" button or, with **Automatically apply updates** - checked, stages it itself -- either way the frame only actually - updates on its own next wake (see `POST /api/firmware` above). + changes on `main`. In the web UI's "Firmware update" card, set the + **Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`); + if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only + PAT) in `docker-compose.yml`. Which board's build to fetch is learned + from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME` + on the firmware side) -- nothing to pick by hand, though the frame + does need to have checked in at least once first. The server then + periodically checks for a newer release and either shows an "Update + frame" button or, with **Automatically apply updates** checked, + stages it itself -- either way the frame only actually updates on its + own next wake (see `POST /api/firmware` above). ## Endpoints @@ -117,7 +120,11 @@ algorithm itself -- it just streams the response straight to the panel. device compares it against its own running version (`esp_app_get_description()->version`, sent as an `X-Frame-Version` request header, stored as `device_firmware_version`) to decide whether - to OTA + to OTA. The device also sends an `X-Frame-Board` header + (`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as + `device_board_variant` -- how the Gitea auto-update feature below + learns which board to fetch a release for, instead of a user picking + it - `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... | null, "location_line2": ... | null, "taken_at": ... | null}` for the current photo (same idempotent current-photo semantics as @@ -170,16 +177,20 @@ algorithm itself -- it just streams the response straight to the panel. been uploaded yet - `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`, 15 min) check of the configured Gitea repo's latest release for the - frame's board variant. `{"enabled": false}` if no repo URL is - configured; otherwise `{"enabled": true, "latest_version": "1.2.3" | null, - "staged_version": "1.2.2" | null, "update_available": bool}`. If - "Automatically apply updates" is on and a newer release is found, this - call also stages it immediately (same effect as a manual upload) -- - otherwise the web UI shows an "Update frame" button + frame's board variant (learned from the device, see `device_board_variant` + below -- not user-configured). `{"enabled": false}` if no repo URL is + configured; otherwise `{"enabled": true, "board": "xiao" | null, + "latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null, + "update_available": bool}`. `update_available` stays false until the + board is known, regardless of what Gitea has. If "Automatically apply + updates" is on and a newer release is found, this call also stages it + immediately (same effect as a manual upload) -- otherwise the web UI + shows an "Update frame" button - `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls and stages the latest Gitea release right now, bypassing the check - throttle. 404 if no repo is configured, has no releases, or the latest - release has no asset for the configured board variant + throttle. 400 if no repo is configured or no device has checked in + yet (board unknown); 404 if the repo has no releases, or the latest + release has no asset for the frame's board - `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...], "device": {"last_seen": ts | null, "overdue": bool, "firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null, diff --git a/server/app/config.py b/server/app/config.py index 631d597..3efd152 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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-.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 diff --git a/server/app/main.py b/server/app/main.py index 4bec19f..cb7ee90 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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, diff --git a/server/app/templates/index.html b/server/app/templates/index.html index 19e758b..b15e105 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -75,25 +75,6 @@ {% endfor %} - - -
- - -
-

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.

@@ -124,12 +105,30 @@

Firmware update

+

+ {% 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 %} +

{% 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 %}

+ +

Or check a Gitea repo's releases + automatically -- built by .gitea/workflows/firmware-release-build.yml, + one binary per board. The board above is learned from the frame + itself, never picked by hand.

+ +
+ + +
+
@@ -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) {