Firmware CI releases + Gitea auto-update in the server
Build and push server image / build-and-push (push) Successful in 39s
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user