Firmware CI releases + Gitea auto-update in the server
Build and push server image / build-and-push (push) Successful in 39s

New Gitea Actions workflow builds both board variants and publishes
them as release assets whenever firmware/version.txt is bumped. The
server can now poll that repo's releases (next to the existing manual
upload) and either surface an "Update frame" button or, with
"Automatically apply updates" checked, stage the new build itself --
the frame still only updates on its own next wake either way.
This commit is contained in:
2026-07-21 19:25:08 -04:00
parent 4cf75b4b11
commit 94d67f7767
8 changed files with 493 additions and 42 deletions
+156
View File
@@ -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
+24
View File
@@ -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,
+23 -4
View File
@@ -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-<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
# 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
+39
View File
@@ -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
+61
View File
@@ -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-<board_variant>.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
+104 -38
View File
@@ -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
+81
View File
@@ -75,6 +75,25 @@
{% 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>
@@ -111,6 +130,8 @@
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</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>
</div>
</div>
@@ -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);
</script>
{% endblock %}
+5
View File
@@ -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