| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778 |
- """Board firmware OTA — GitHub release lookup and version comparison.
- Firmware releases are published as GitHub Release assets on the firmware repo
- (`firmware.bin` = the ESP32 app-partition image). The flash itself goes through
- FluidNCClient.upload_firmware (POST /updatefw); this module only knows how to
- find the latest release and compare versions.
- """
- import logging
- import re
- import time
- from typing import Optional
- import requests
- logger = logging.getLogger(__name__)
- FIRMWARE_REPO = "tuanchris/dune-weaver-firmware"
- _CACHE_TTL = 600 # GitHub unauthenticated rate limit is 60/hr - cache lookups
- _cache: dict = {"at": 0.0, "release": None}
- def get_latest_release(force: bool = False) -> Optional[dict]:
- """Latest published firmware release, or None when unreachable / no
- firmware.bin asset. Cached for 10 minutes."""
- now = time.time()
- if not force and _cache["release"] and now - _cache["at"] < _CACHE_TTL:
- return _cache["release"]
- try:
- r = requests.get(
- f"https://api.github.com/repos/{FIRMWARE_REPO}/releases/latest",
- headers={"Accept": "application/vnd.github+json"},
- timeout=10.0,
- )
- r.raise_for_status()
- data = r.json()
- asset = next((a for a in data.get("assets", []) if a.get("name") == "firmware.bin"), None)
- if not asset:
- logger.warning("Latest firmware release has no firmware.bin asset")
- return None
- release = {
- "version": data.get("tag_name") or "",
- "download_url": asset.get("browser_download_url"),
- "release_url": data.get("html_url") or f"https://github.com/{FIRMWARE_REPO}/releases",
- "published_at": data.get("published_at"),
- }
- _cache.update(at=now, release=release)
- return release
- except Exception as e:
- logger.warning(f"Firmware release lookup failed: {e}")
- return None
- def parse_version(fw: Optional[str]) -> Optional[tuple]:
- """'v0.1.10 (main-5ce4400d-dirty)' -> (0, 1, 10); None if unparseable."""
- if not fw:
- return None
- m = re.search(r"v?(\d+)\.(\d+)\.(\d+)", fw)
- return tuple(int(g) for g in m.groups()) if m else None
- def is_newer(latest_tag: str, current_fw: Optional[str]) -> bool:
- latest = parse_version(latest_tag)
- current = parse_version(current_fw)
- if not latest or not current:
- return False
- return latest > current
- def download_image(url: str) -> bytes:
- """Download and sanity-check a firmware image (ESP32 magic byte 0xE9)."""
- r = requests.get(url, timeout=60.0)
- r.raise_for_status()
- image = r.content
- if len(image) < 100_000 or image[0] != 0xE9:
- raise ValueError("Downloaded firmware image looks invalid")
- return image
|