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