diff --git a/.gitea/workflows/firmware-release-build.yml b/.gitea/workflows/firmware-release-build.yml
new file mode 100644
index 0000000..ea418ad
--- /dev/null
+++ b/.gitea/workflows/firmware-release-build.yml
@@ -0,0 +1,156 @@
+name: Build and release firmware
+
+# Fires when firmware/version.txt is bumped on main -- that's the deliberate
+# "cut a release" signal (mirrors ESP-IDF's own convention of reading the
+# embedded app version from this file), not every firmware/** push. Also
+# runnable by hand for a one-off rebuild of the current version.
+on:
+ push:
+ branches: [main]
+ paths:
+ - "firmware/version.txt"
+ workflow_dispatch:
+
+jobs:
+ build-and-release:
+ runs-on: ubuntu-latest
+ container:
+ image: espressif/idf:release-v5.3
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Trust the checkout (container user differs from the checkout's owner)
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Read firmware version
+ id: version
+ run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
+
+ # Two board variants, two partition tables/flash sizes (see
+ # firmware/README.md's "Building for the Seeed XIAO ESP32-C6"
+ # section) -- build_for_board.sh gives each its own build dir/
+ # generated sdkconfig so this never fights over shared state.
+ # set-target first since a fresh checkout has no cached sdkconfig
+ # (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
+ - name: Build (devkit -- ESP32-C6-DevKitC-1)
+ run: |
+ . "$IDF_PATH/export.sh"
+ cd firmware
+ ./build_for_board.sh devkit set-target esp32c6
+ ./build_for_board.sh devkit build
+
+ - name: Build (xiao -- Seeed XIAO ESP32-C6)
+ run: |
+ . "$IDF_PATH/export.sh"
+ cd firmware
+ ./build_for_board.sh xiao set-target esp32c6
+ ./build_for_board.sh xiao build
+
+ - name: Collect binaries
+ run: |
+ mkdir -p /tmp/release-assets
+ cp firmware/build/espresso_frame.bin /tmp/release-assets/firmware-devkit.bin
+ cp firmware/build_xiao/espresso_frame.bin /tmp/release-assets/firmware-xiao.bin
+
+ # Plain stdlib urllib rather than `requests` -- not guaranteed to be
+ # pip-installed in the IDF image, and this is simple enough not to
+ # need it. Re-running this workflow for the same version.txt (e.g. a
+ # manual re-dispatch) reuses the existing tag's release and replaces
+ # its assets rather than failing on "release already exists".
+ #
+ # Requires a Gitea PAT with repository read/write (release) scope in
+ # the `RELEASE_TOKEN` secret -- Repo Settings -> Actions -> Secrets.
+ # A token that already has REGISTRY_TOKEN's scope may work too if it
+ # covers repo contents, but keeping it separate keeps each secret's
+ # blast radius obvious.
+ - name: Publish Gitea release
+ env:
+ GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ API_BASE: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
+ TAG: v${{ steps.version.outputs.version }}
+ COMMIT: ${{ gitea.sha }}
+ run: |
+ python3 - <<'PYEOF'
+ import json
+ import os
+ import urllib.error
+ import urllib.request
+
+ api = os.environ["API_BASE"]
+ token = os.environ["GITEA_TOKEN"]
+ tag = os.environ["TAG"]
+ commit = os.environ["COMMIT"]
+
+
+ def req(method, path, body=None):
+ data = json.dumps(body).encode() if body is not None else None
+ headers = {"Authorization": f"token {token}"}
+ if body is not None:
+ headers["Content-Type"] = "application/json"
+ r = urllib.request.Request(f"{api}{path}", data=data, method=method, headers=headers)
+ try:
+ with urllib.request.urlopen(r) as resp:
+ return resp.status, json.loads(resp.read() or b"{}")
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read() or b"{}")
+
+
+ status, release = req("GET", f"/releases/tags/{tag}")
+ if status == 404:
+ status, release = req(
+ "POST",
+ "/releases",
+ {
+ "tag_name": tag,
+ "target_commitish": commit,
+ "name": tag,
+ "body": f"Automated build from commit {commit}.",
+ "draft": False,
+ "prerelease": False,
+ },
+ )
+ if status not in (200, 201):
+ raise SystemExit(f"Failed to create release {tag}: {status} {release}")
+ print(f"Created release {tag} (id={release['id']})")
+ elif status == 200:
+ print(f"Release {tag} already exists (id={release['id']}), reusing it")
+ else:
+ raise SystemExit(f"Failed to look up release {tag}: {status} {release}")
+
+ release_id = release["id"]
+ existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}
+
+ assets = [
+ ("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
+ ("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
+ ]
+ for name, path in assets:
+ if name in existing_assets:
+ del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
+ print(f"Removed existing asset {name} (status {del_status})")
+
+ boundary = "geafirmwareboundary"
+ with open(path, "rb") as f:
+ file_bytes = f.read()
+ body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
+ "Content-Type: application/octet-stream\r\n\r\n"
+ ).encode() + file_bytes + f"\r\n--{boundary}--\r\n".encode()
+
+ r = urllib.request.Request(
+ f"{api}/releases/{release_id}/assets?name={name}",
+ data=body,
+ method="POST",
+ headers={
+ "Authorization": f"token {token}",
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ },
+ )
+ try:
+ with urllib.request.urlopen(r) as resp:
+ print(f"Uploaded {name}: status {resp.status}")
+ except urllib.error.HTTPError as e:
+ raise SystemExit(f"Failed to upload {name}: {e.code} {e.read()}")
+ PYEOF
diff --git a/server/README.md b/server/README.md
index 8b8f5e9..65dbb0f 100644
--- a/server/README.md
+++ b/server/README.md
@@ -44,6 +44,18 @@ algorithm itself -- it just streams the response straight to the panel.
the web UI without a valid token in the URL shows a plain token-entry
prompt instead of the config UI; `/health` stays open regardless
(pure liveness, nothing sensitive in it).
+7. **Optional: auto-update firmware from Gitea releases.** If you're
+ 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).
## Endpoints
@@ -156,6 +168,18 @@ algorithm itself -- it just streams the response straight to the panel.
- `GET /frame/firmware` -- streams back whatever was last uploaded via
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
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
+- `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
- `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 6ad91c2..631d597 100644
--- a/server/app/config.py
+++ b/server/app/config.py
@@ -99,6 +99,22 @@ class FrameConfig(BaseModel):
# (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
+ # (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
+ # var overrides, mirroring MANAGEMENT_TOKEN below -- never exposed to
+ # the web UI template or any JSON response.
+ firmware_update_token: str = ""
+ firmware_update_checked_at: float = 0.0 # throttle bookkeeping, see gitea_releases.UPDATE_CHECK_INTERVAL_S
+ firmware_gitea_latest_version: str = "" # latest release's version, from its tag name
+
stats: FrameStats = FrameStats()
@@ -109,19 +125,22 @@ def load() -> 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.
+ # IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN/GITEA_FIRMWARE_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")
+ env_gitea_token = os.environ.get("GITEA_FIRMWARE_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
+ if env_gitea_token:
+ cfg.firmware_update_token = env_gitea_token
return cfg
diff --git a/server/app/firmware.py b/server/app/firmware.py
new file mode 100644
index 0000000..0035ecc
--- /dev/null
+++ b/server/app/firmware.py
@@ -0,0 +1,39 @@
+"""Local firmware image storage + esp_app_desc_t parsing. Shared by the
+manual upload path (POST /api/firmware) and the Gitea auto-update path
+(see gitea_releases.py) -- both end up writing the same firmware.bin slot
+that GET /frame/firmware streams to the device."""
+
+from __future__ import annotations
+
+from fastapi import HTTPException
+
+from . import config
+
+# ESP-IDF app images embed an esp_app_desc_t at byte offset 32 (24-byte
+# image header + 8-byte first-segment header): magic word, then version
+# (32 bytes, NUL-padded) at +16 and project name (32 bytes) at +48 --
+# verified against this project's real build artifact.
+APP_DESC_OFFSET = 32
+APP_DESC_MAGIC = 0xABCD5432
+EXPECTED_PROJECT_NAME = "espresso_frame"
+
+
+def firmware_path():
+ return config.CONFIG_PATH.parent / "firmware.bin"
+
+
+def parse_app_version(data: bytes) -> str:
+ """Extracts the embedded version from an ESP-IDF app image, raising
+ HTTPException(400) for anything that isn't this project's firmware."""
+ if len(data) < APP_DESC_OFFSET + 80:
+ raise HTTPException(400, "File is too small to be a firmware image")
+ magic = int.from_bytes(data[APP_DESC_OFFSET : APP_DESC_OFFSET + 4], "little")
+ if magic != APP_DESC_MAGIC:
+ raise HTTPException(400, "Not an ESP-IDF application image")
+ version = data[APP_DESC_OFFSET + 16 : APP_DESC_OFFSET + 48].split(b"\x00")[0].decode(errors="replace")
+ project = data[APP_DESC_OFFSET + 48 : APP_DESC_OFFSET + 80].split(b"\x00")[0].decode(errors="replace")
+ if project != EXPECTED_PROJECT_NAME:
+ raise HTTPException(400, f"Image is for project '{project}', not '{EXPECTED_PROJECT_NAME}'")
+ if not version:
+ raise HTTPException(400, "Image has no embedded version")
+ return version
diff --git a/server/app/gitea_releases.py b/server/app/gitea_releases.py
new file mode 100644
index 0000000..94a50b2
--- /dev/null
+++ b/server/app/gitea_releases.py
@@ -0,0 +1,61 @@
+"""Checks a Gitea repo's releases for newer frame firmware, and can pull a
+new release's binary into the local firmware.bin slot -- from the device's
+point of view an auto-applied update is indistinguishable from a manual
+`POST /api/firmware` upload, it self-updates on its next wake either way
+(see firmware/main/ota_update.c).
+
+The release-side counterpart is `.gitea/workflows/firmware-release-build.yml`,
+which builds one binary per supported board and publishes them as release
+assets named `firmware-.bin` -- asset_name_for_board() below
+must keep matching that naming exactly.
+"""
+
+from __future__ import annotations
+
+import httpx
+
+UPDATE_CHECK_INTERVAL_S = 15 * 60 # don't hit the Gitea API more often than this
+HTTP_TIMEOUT_S = 15.0
+DOWNLOAD_TIMEOUT_S = 60.0
+
+
+def asset_name_for_board(board_variant: str) -> str:
+ return f"firmware-{board_variant}.bin"
+
+
+def _api_base(repo_url: str) -> str:
+ """"https://git.example.com/owner/repo" (optionally with a trailing
+ slash or ".git") -> that repo's Gitea REST API base."""
+ url = repo_url.strip().rstrip("/")
+ if url.endswith(".git"):
+ url = url[: -len(".git")]
+ scheme, _, rest = url.partition("://")
+ host, _, path = rest.partition("/")
+ owner, _, repo = path.rpartition("/")
+ return f"{scheme}://{host}/api/v1/repos/{owner}/{repo}"
+
+
+def _headers(token: str) -> dict:
+ return {"Authorization": f"token {token}"} if token else {}
+
+
+def fetch_latest_release(repo_url: str, token: str = "") -> dict | None:
+ """Returns {"version": "1.2.3", "assets": {name: download_url}} for the
+ repo's latest release, or None if it has no releases yet."""
+ resp = httpx.get(
+ f"{_api_base(repo_url)}/releases/latest", headers=_headers(token), timeout=HTTP_TIMEOUT_S
+ )
+ if resp.status_code == 404:
+ return None
+ resp.raise_for_status()
+ data = resp.json()
+ tag = data["tag_name"]
+ version = tag[1:] if tag.startswith("v") else tag
+ assets = {a["name"]: a["browser_download_url"] for a in data.get("assets", [])}
+ return {"version": version, "assets": assets}
+
+
+def download_asset(url: str, token: str = "") -> bytes:
+ resp = httpx.get(url, headers=_headers(token), timeout=DOWNLOAD_TIMEOUT_S, follow_redirects=True)
+ resp.raise_for_status()
+ return resp.content
diff --git a/server/app/main.py b/server/app/main.py
index fc6d7a2..4bec19f 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -16,8 +16,9 @@ from fastapi.templating import Jinja2Templates
from PIL import Image
from pydantic import BaseModel
-from . import config, photo_queue
+from . import config, gitea_releases, photo_queue
from .face_labels import compute_face_labels
+from .firmware import firmware_path, parse_app_version
from .image_pipeline import render_frame
from .immich_client import ImmichClient
@@ -274,11 +275,15 @@ def api_config_save(
quiet_hours_start: str = Form("22:00"),
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 are env-var only (IMMICH_URL/IMMICH_API_KEY, see
- # docker-compose.yml.example) -- config.load() already applies them,
- # and this handler doesn't touch cfg.immich_url/immich_api_key at all,
- # so there's nothing here that could overwrite or clear them.
+ # Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
+ # IMMICH_API_KEY/GITEA_FIRMWARE_TOKEN, see docker-compose.yml.example)
+ # -- config.load() already applies them, and this handler doesn't touch
+ # cfg.immich_url/immich_api_key/firmware_update_token at all, so
+ # there's nothing here that could overwrite or clear them.
with config.locked():
cfg = config.load()
if album_id != cfg.album_id:
@@ -303,6 +308,9 @@ def api_config_save(
cfg.quiet_hours_end = quiet_hours_end
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)
return {"status": "saved"}
@@ -448,36 +456,6 @@ def frame_battery(body: BatteryReport):
return {"status": "saved"}
-# ESP-IDF app images embed an esp_app_desc_t at byte offset 32 (24-byte
-# image header + 8-byte first-segment header): magic word, then version
-# (32 bytes, NUL-padded) at +16 and project name (32 bytes) at +48 --
-# verified against this project's real build artifact.
-APP_DESC_OFFSET = 32
-APP_DESC_MAGIC = 0xABCD5432
-EXPECTED_PROJECT_NAME = "espresso_frame"
-
-
-def _firmware_path():
- return config.CONFIG_PATH.parent / "firmware.bin"
-
-
-def _parse_app_version(data: bytes) -> str:
- """Extracts the embedded version from an ESP-IDF app image, raising
- HTTPException(400) for anything that isn't this project's firmware."""
- if len(data) < APP_DESC_OFFSET + 80:
- raise HTTPException(400, "File is too small to be a firmware image")
- magic = int.from_bytes(data[APP_DESC_OFFSET : APP_DESC_OFFSET + 4], "little")
- if magic != APP_DESC_MAGIC:
- raise HTTPException(400, "Not an ESP-IDF application image")
- version = data[APP_DESC_OFFSET + 16 : APP_DESC_OFFSET + 48].split(b"\x00")[0].decode(errors="replace")
- project = data[APP_DESC_OFFSET + 48 : APP_DESC_OFFSET + 80].split(b"\x00")[0].decode(errors="replace")
- if project != EXPECTED_PROJECT_NAME:
- raise HTTPException(400, f"Image is for project '{project}', not '{EXPECTED_PROJECT_NAME}'")
- if not version:
- raise HTTPException(400, "Image has no embedded version")
- return version
-
-
@app.post("/api/firmware", dependencies=[Depends(require_access_token)])
def api_firmware_upload(file: UploadFile = File(...)):
"""Uploads a firmware image for OTA. The version is parsed out of the
@@ -485,8 +463,8 @@ def api_firmware_upload(file: UploadFile = File(...)):
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
data = file.file.read()
- version = _parse_app_version(data)
- _firmware_path().write_bytes(data)
+ version = parse_app_version(data)
+ firmware_path().write_bytes(data)
with config.locked():
cfg = config.load()
cfg.firmware_available_version = version
@@ -499,12 +477,100 @@ def frame_firmware():
"""The uploaded OTA image, streamed to the device (esp_https_ota).
404 until something has been uploaded."""
_touch_last_seen()
- path = _firmware_path()
+ path = firmware_path()
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
+def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
+ try:
+ return gitea_releases.fetch_latest_release(cfg.firmware_update_repo_url, cfg.firmware_update_token)
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"Could not reach Gitea at {cfg.firmware_update_repo_url}: {e}") from e
+
+
+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())."""
+ 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_url = release["assets"].get(asset_name)
+ if not asset_url:
+ raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
+ try:
+ data = gitea_releases.download_asset(asset_url, cfg.firmware_update_token)
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
+ version = parse_app_version(data) # same validation the manual upload path applies
+ firmware_path().write_bytes(data)
+ with config.locked():
+ cfg = config.load()
+ cfg.firmware_available_version = version
+ cfg.firmware_gitea_latest_version = version
+ cfg.firmware_update_checked_at = time.time()
+ config.save(cfg)
+ return version
+
+
+@app.get("/api/firmware/check", dependencies=[Depends(require_access_token)])
+def api_firmware_check():
+ """Throttled check of the configured Gitea repo's latest release
+ (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."""
+ cfg = config.load()
+ if not cfg.firmware_update_repo_url:
+ return {"enabled": False}
+
+ now = time.time()
+ if now - cfg.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
+ # Deliberately not updated on failure (see below) -- checked_at only
+ # advances on a successful reach, so a Gitea outage gets retried
+ # every poll instead of waiting out the full throttle interval.
+ release = _fetch_latest_release(cfg)
+ with config.locked():
+ cfg = config.load() # re-read: state may have changed since the unlocked read above
+ cfg.firmware_update_checked_at = now
+ if release:
+ cfg.firmware_gitea_latest_version = release["version"]
+ config.save(cfg)
+ cfg = config.load()
+
+ update_available = bool(
+ cfg.firmware_gitea_latest_version
+ ) and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
+ if update_available and cfg.firmware_auto_update:
+ _apply_gitea_update(cfg)
+ cfg = config.load()
+ update_available = False
+
+ return {
+ "enabled": True,
+ "latest_version": cfg.firmware_gitea_latest_version or None,
+ "staged_version": cfg.firmware_available_version or None,
+ "update_available": update_available,
+ }
+
+
+@app.post("/api/firmware/apply-latest", dependencies=[Depends(require_access_token)])
+def api_firmware_apply_latest():
+ """The "Update frame" button: applies the latest Gitea release right
+ now, bypassing the check throttle -- this is an explicit user action,
+ not a background poll."""
+ cfg = config.load()
+ if not cfg.firmware_update_repo_url:
+ raise HTTPException(400, "No Gitea firmware repo configured")
+ version = _apply_gitea_update(cfg)
+ return {"status": "saved", "version": version}
+
+
def _battery_estimate_s(cfg: config.FrameConfig) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
diff --git a/server/app/templates/index.html b/server/app/templates/index.html
index ac4a9cc..19e758b 100644
--- a/server/app/templates/index.html
+++ b/server/app/templates/index.html
@@ -75,6 +75,25 @@
{% 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.
@@ -111,6 +130,8 @@
+
+
@@ -145,6 +166,9 @@
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
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', {
method: 'POST',
@@ -495,6 +519,57 @@
}
});
+ async function loadFirmwareCheck() {
+ const statusEl = document.getElementById('firmware-gitea-status');
+ const btn = document.getElementById('firmware-update-btn');
+ try {
+ const resp = await fetch('/api/firmware/check');
+ if (!resp.ok) {
+ return;
+ }
+ const data = await resp.json();
+ if (!data.enabled) {
+ statusEl.style.display = 'none';
+ btn.style.display = 'none';
+ return;
+ }
+ statusEl.style.display = 'block';
+ if (data.update_available) {
+ statusEl.textContent = `Update available: v${data.latest_version}.`;
+ btn.style.display = 'inline-block';
+ } else if (data.latest_version) {
+ statusEl.textContent = `Up to date (v${data.latest_version}).`;
+ btn.style.display = 'none';
+ } else {
+ statusEl.textContent = 'No releases found yet.';
+ btn.style.display = 'none';
+ }
+ } catch (e) {
+ // A failed check is silent -- the manual upload path still works
+ // regardless, and this just retries on the next poll.
+ }
+ }
+
+ document.getElementById('firmware-update-btn').addEventListener('click', async () => {
+ const btn = document.getElementById('firmware-update-btn');
+ btn.disabled = true;
+ try {
+ const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
+ if (!resp.ok) {
+ throw new Error(await resp.text());
+ }
+ const result = await resp.json();
+ document.getElementById('firmware-available').textContent =
+ `Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
+ showStatus(true, `Firmware v${result.version} staged from Gitea.`);
+ loadFirmwareCheck();
+ } catch (e) {
+ showStatus(false, e.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
let lastDevice = null;
async function loadQueue() {
@@ -672,6 +747,7 @@
loadQueue();
loadBatteryLog();
loadStats();
+ loadFirmwareCheck();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle in the header is used --
@@ -698,5 +774,10 @@
// from elsewhere, battery report, firmware version) without a manual
// refresh. Skipped mid-drag (see loadQueue above).
setInterval(loadQueue, 10000);
+
+ // Separate, slower poll for the Gitea release check -- cheap either
+ // way since the server itself throttles actual Gitea API calls to
+ // once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
+ setInterval(loadFirmwareCheck, 60000);
{% endblock %}
diff --git a/server/docker-compose.yml.example b/server/docker-compose.yml.example
index 362b073..07e4f5a 100644
--- a/server/docker-compose.yml.example
+++ b/server/docker-compose.yml.example
@@ -17,4 +17,9 @@ services:
# (Access Token field) so it's sent on every device request and gets
# embedded automatically in the manage-menu/share QR codes.
- MANAGEMENT_TOKEN=changeme
+ # Optional: only needed if the Gitea repo configured in the web UI's
+ # "Firmware Gitea repo URL" field is private. A read-only PAT is
+ # enough -- it's sent to that repo's releases API/asset downloads
+ # only, never exposed via the web UI or any API response.
+ - GITEA_FIRMWARE_TOKEN=your-gitea-pat-here
restart: unless-stopped