1
0

firmware_updater.py 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. """Board firmware OTA — GitHub release lookup and version comparison.
  2. Firmware releases are published as GitHub Release assets on the firmware repo
  3. (`firmware.bin` = the ESP32 app-partition image). The flash itself goes through
  4. FluidNCClient.upload_firmware (POST /updatefw); this module only knows how to
  5. find the latest release and compare versions.
  6. """
  7. import logging
  8. import re
  9. import time
  10. from typing import Optional
  11. import requests
  12. logger = logging.getLogger(__name__)
  13. FIRMWARE_REPO = "tuanchris/dune-weaver-firmware"
  14. _CACHE_TTL = 600 # GitHub unauthenticated rate limit is 60/hr - cache lookups
  15. _cache: dict = {"at": 0.0, "release": None}
  16. def get_latest_release(force: bool = False) -> Optional[dict]:
  17. """Latest published firmware release, or None when unreachable / no
  18. firmware.bin asset. Cached for 10 minutes."""
  19. now = time.time()
  20. if not force and _cache["release"] and now - _cache["at"] < _CACHE_TTL:
  21. return _cache["release"]
  22. try:
  23. r = requests.get(
  24. f"https://api.github.com/repos/{FIRMWARE_REPO}/releases/latest",
  25. headers={"Accept": "application/vnd.github+json"},
  26. timeout=10.0,
  27. )
  28. r.raise_for_status()
  29. data = r.json()
  30. asset = next((a for a in data.get("assets", []) if a.get("name") == "firmware.bin"), None)
  31. if not asset:
  32. logger.warning("Latest firmware release has no firmware.bin asset")
  33. return None
  34. release = {
  35. "version": data.get("tag_name") or "",
  36. "download_url": asset.get("browser_download_url"),
  37. "release_url": data.get("html_url") or f"https://github.com/{FIRMWARE_REPO}/releases",
  38. "published_at": data.get("published_at"),
  39. }
  40. _cache.update(at=now, release=release)
  41. return release
  42. except Exception as e:
  43. logger.warning(f"Firmware release lookup failed: {e}")
  44. return None
  45. def parse_version(fw: Optional[str]) -> Optional[tuple]:
  46. """'v0.1.10 (main-5ce4400d-dirty)' -> (0, 1, 10); None if unparseable."""
  47. if not fw:
  48. return None
  49. m = re.search(r"v?(\d+)\.(\d+)\.(\d+)", fw)
  50. return tuple(int(g) for g in m.groups()) if m else None
  51. def is_newer(latest_tag: str, current_fw: Optional[str]) -> bool:
  52. latest = parse_version(latest_tag)
  53. current = parse_version(current_fw)
  54. if not latest or not current:
  55. return False
  56. return latest > current
  57. def download_image(url: str) -> bytes:
  58. """Download and sanity-check a firmware image (ESP32 magic byte 0xE9)."""
  59. r = requests.get(url, timeout=60.0)
  60. r.raise_for_status()
  61. image = r.content
  62. if len(image) < 100_000 or image[0] != 0xE9:
  63. raise ValueError("Downloaded firmware image looks invalid")
  64. return image