"""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