firmware_check.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. """
  2. Firmware Check Service
  3. Checks for firmware updates by fetching from Bambu Lab's official wiki and firmware
  4. download page. The wiki is used as the primary version source (always up-to-date),
  5. while the download page provides firmware file URLs for offline updates.
  6. """
  7. import logging
  8. import re
  9. import time
  10. from collections.abc import Callable
  11. from dataclasses import dataclass
  12. from pathlib import Path
  13. import httpx
  14. from backend.app.core.config import _data_dir
  15. logger = logging.getLogger(__name__)
  16. # Bambu Lab firmware download page (for download URLs)
  17. BAMBU_FIRMWARE_BASE = "https://bambulab.com"
  18. FIRMWARE_PAGE = "/en/support/firmware-download/all"
  19. # Bambu Lab wiki (primary source for latest version detection)
  20. BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
  21. # Cache TTL in seconds (1 hour)
  22. CACHE_TTL = 3600
  23. # Map Bambuddy model names to Bambu Lab API keys
  24. MODEL_TO_API_KEY = {
  25. "X1": "x1",
  26. "X1C": "x1",
  27. "X1-Carbon": "x1",
  28. "X1 Carbon": "x1",
  29. "P1P": "p1",
  30. "P1S": "p1",
  31. "A1": "a1",
  32. "A1 Mini": "a1-mini",
  33. "A1-Mini": "a1-mini",
  34. "A1mini": "a1-mini",
  35. "H2D": "h2d",
  36. "H2C": "h2c",
  37. "H2S": "h2s",
  38. "P2S": "p2s",
  39. "X1E": "x1e",
  40. "H2D Pro": "h2d-pro",
  41. "H2D-Pro": "h2d-pro",
  42. "H2DPRO": "h2d-pro",
  43. }
  44. # Reverse mapping: API key to model codes
  45. API_KEY_TO_DEV_MODEL = {
  46. "x1": "BL-P001",
  47. "p1": "C11",
  48. "a1": "N2S",
  49. "a1-mini": "N1",
  50. "h2d": "O1D",
  51. "h2c": "O1C",
  52. "h2s": "O1S",
  53. "p2s": "N7",
  54. "x1e": "C13",
  55. "h2d-pro": "O1E",
  56. }
  57. # Wiki firmware release history pages (primary version source)
  58. API_KEY_TO_WIKI_PATH = {
  59. "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
  60. "x1e": "/en/x1/manual/X1E-firmware-release-history",
  61. "p1": "/en/p1/manual/p1p-firmware-release-history",
  62. "a1": "/en/a1/manual/a1-firmware-release-history",
  63. "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
  64. "h2d": "/en/h2d/manual/h2d-firmware-release-history",
  65. "h2c": "/en/h2c/manual/h2c-firmware-release-history",
  66. "h2s": "/en/h2s/manual/h2s-firmware-release-history",
  67. "p2s": "/en/p2s/manual/p2s-firmware-release-history",
  68. "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
  69. }
  70. @dataclass
  71. class FirmwareVersion:
  72. """Firmware version information."""
  73. version: str
  74. download_url: str
  75. release_notes: str | None = None
  76. release_time: str | None = None
  77. class FirmwareCheckService:
  78. """Service for checking firmware updates from Bambu Lab."""
  79. def __init__(self):
  80. self._build_id: str | None = None
  81. self._build_id_time: float = 0
  82. self._version_cache: dict[str, FirmwareVersion] = {}
  83. self._cache_time: float = 0
  84. self._client = httpx.AsyncClient(
  85. timeout=30.0,
  86. headers={
  87. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  88. },
  89. )
  90. async def _get_build_id(self) -> str | None:
  91. """Fetch the Next.js build ID from Bambu Lab's firmware page."""
  92. # Use cached build ID if still valid (cache for 1 hour)
  93. if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
  94. return self._build_id
  95. try:
  96. response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
  97. if response.status_code == 200:
  98. # Extract buildId from the page
  99. match = re.search(r'"buildId":"([^"]+)"', response.text)
  100. if match:
  101. self._build_id = match.group(1)
  102. self._build_id_time = time.time()
  103. logger.info("Got Bambu Lab build ID: %s", self._build_id)
  104. return self._build_id
  105. logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
  106. except Exception as e:
  107. logger.error("Error fetching Bambu Lab build ID: %s", e)
  108. return self._build_id # Return cached value if available
  109. async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
  110. """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
  111. wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
  112. if not wiki_path:
  113. return None
  114. try:
  115. url = f"{BAMBU_WIKI_BASE}{wiki_path}"
  116. response = await self._client.get(url, follow_redirects=True)
  117. if response.status_code == 200:
  118. # Extract version strings (format: XX.XX.XX.XX), first match is the latest
  119. versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
  120. if versions:
  121. logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
  122. return versions[0]
  123. else:
  124. logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
  125. except Exception as e:
  126. logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
  127. return None
  128. async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
  129. """Fetch firmware info from Bambu Lab's download page (has download URLs)."""
  130. build_id = await self._get_build_id()
  131. if not build_id:
  132. return None
  133. try:
  134. url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
  135. response = await self._client.get(url)
  136. if response.status_code == 200:
  137. data = response.json()
  138. page_props = data.get("pageProps", {})
  139. printer_map = page_props.get("printerMap", {})
  140. printer_data = printer_map.get(api_key, {})
  141. versions = printer_data.get("versions", [])
  142. if versions:
  143. latest = versions[0]
  144. return FirmwareVersion(
  145. version=latest.get("version", ""),
  146. download_url=latest.get("url", ""),
  147. release_notes=latest.get("release_notes_en"),
  148. release_time=latest.get("release_time"),
  149. )
  150. except Exception as e:
  151. logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
  152. return None
  153. async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
  154. """Fetch firmware version info, using wiki as primary source and download page as fallback."""
  155. # Try wiki first (always has the latest version)
  156. wiki_version = await self._fetch_version_from_wiki(api_key)
  157. # Try download page (has download URLs, may lag behind wiki)
  158. download_info = await self._fetch_from_download_page(api_key)
  159. if wiki_version:
  160. # Wiki has the latest version — use it, attach download URL if available
  161. download_url = ""
  162. release_notes = None
  163. if download_info and download_info.version == wiki_version:
  164. download_url = download_info.download_url
  165. release_notes = download_info.release_notes
  166. return FirmwareVersion(
  167. version=wiki_version,
  168. download_url=download_url,
  169. release_notes=release_notes,
  170. )
  171. if download_info:
  172. return download_info
  173. logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
  174. return None
  175. async def get_latest_version(self, model: str) -> FirmwareVersion | None:
  176. """
  177. Get the latest firmware version for a printer model.
  178. Args:
  179. model: Bambuddy printer model name (e.g., "X1C", "P1S", "H2D")
  180. Returns:
  181. FirmwareVersion if found, None otherwise
  182. """
  183. # Normalize model name
  184. model_upper = model.upper().replace(" ", "").replace("-", "")
  185. # Find the API key for this model
  186. api_key = None
  187. for model_name, key in MODEL_TO_API_KEY.items():
  188. if model_name.upper().replace(" ", "").replace("-", "") == model_upper:
  189. api_key = key
  190. break
  191. if not api_key:
  192. # Try direct lookup with original model
  193. api_key = MODEL_TO_API_KEY.get(model)
  194. if not api_key:
  195. logger.debug("Unknown printer model: %s", model)
  196. return None
  197. # Check cache
  198. cache_key = api_key
  199. if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:
  200. return self._version_cache[cache_key]
  201. # Fetch from API
  202. version = await self._fetch_firmware_versions(api_key)
  203. if version:
  204. self._version_cache[cache_key] = version
  205. self._cache_time = time.time()
  206. return version
  207. async def check_for_update(self, model: str, current_version: str) -> dict:
  208. """
  209. Check if a firmware update is available for a printer.
  210. Args:
  211. model: Printer model name
  212. current_version: Currently installed firmware version
  213. Returns:
  214. Dict with update info:
  215. - update_available: bool
  216. - current_version: str
  217. - latest_version: str or None
  218. - download_url: str or None
  219. - release_notes: str or None
  220. """
  221. result = {
  222. "update_available": False,
  223. "current_version": current_version,
  224. "latest_version": None,
  225. "download_url": None,
  226. "release_notes": None,
  227. }
  228. if not current_version:
  229. return result
  230. latest = await self.get_latest_version(model)
  231. if not latest:
  232. return result
  233. result["latest_version"] = latest.version
  234. result["download_url"] = latest.download_url
  235. result["release_notes"] = latest.release_notes
  236. # Compare versions (format: XX.XX.XX.XX)
  237. try:
  238. current_parts = [int(x) for x in current_version.split(".")]
  239. latest_parts = [int(x) for x in latest.version.split(".")]
  240. # Pad to same length
  241. while len(current_parts) < 4:
  242. current_parts.append(0)
  243. while len(latest_parts) < 4:
  244. latest_parts.append(0)
  245. result["update_available"] = latest_parts > current_parts
  246. except (ValueError, AttributeError):
  247. logger.warning("Could not compare versions: %s vs %s", current_version, latest.version)
  248. return result
  249. async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:
  250. """
  251. Fetch latest firmware versions for all known printer models.
  252. Returns:
  253. Dict mapping API key to FirmwareVersion
  254. """
  255. results = {}
  256. for api_key in API_KEY_TO_DEV_MODEL:
  257. version = await self._fetch_firmware_versions(api_key)
  258. if version:
  259. results[api_key] = version
  260. return results
  261. def _get_firmware_cache_dir(self) -> Path:
  262. """Get the firmware cache directory, creating it if needed."""
  263. cache_dir = _data_dir / "firmware"
  264. cache_dir.mkdir(parents=True, exist_ok=True)
  265. return cache_dir
  266. def _get_cached_firmware_path(self, model: str, version: str) -> Path:
  267. """Get the path where a firmware file would be cached."""
  268. # Normalize model name for filename
  269. model_safe = model.upper().replace(" ", "-").replace("/", "-")
  270. version_safe = version.replace(".", "_")
  271. filename = f"{model_safe}_{version_safe}.bin"
  272. return self._get_firmware_cache_dir() / filename
  273. async def get_firmware_file_info(self, model: str) -> dict | None:
  274. """
  275. Get information about the firmware file for a model.
  276. Returns:
  277. Dict with download_url, version, filename, and estimated_size (if available)
  278. """
  279. latest = await self.get_latest_version(model)
  280. if not latest or not latest.download_url:
  281. return None
  282. # Extract filename from URL
  283. url_parts = latest.download_url.split("/")
  284. filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  285. return {
  286. "download_url": latest.download_url,
  287. "version": latest.version,
  288. "filename": filename,
  289. "release_notes": latest.release_notes,
  290. }
  291. async def download_firmware(
  292. self,
  293. model: str,
  294. progress_callback: Callable[[int, int, str], None] | None = None,
  295. ) -> Path | None:
  296. """
  297. Download firmware file for a printer model.
  298. Args:
  299. model: Printer model name (e.g., "X1C", "P1S", "H2D")
  300. progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)
  301. Returns:
  302. Path to downloaded firmware file, or None on failure
  303. """
  304. latest = await self.get_latest_version(model)
  305. if not latest or not latest.download_url:
  306. logger.warning("No firmware download URL available for model: %s", model)
  307. return None
  308. # Check if already cached
  309. cached_path = self._get_cached_firmware_path(model, latest.version)
  310. if cached_path.exists():
  311. logger.info("Using cached firmware: %s", cached_path)
  312. return cached_path
  313. # Extract original filename from URL (must preserve for SD card update)
  314. url_parts = latest.download_url.split("/")
  315. original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  316. # Download to temp file first
  317. temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
  318. try:
  319. logger.info("Downloading firmware from %s", latest.download_url)
  320. if progress_callback:
  321. progress_callback(0, 0, "Starting download...")
  322. async with self._client.stream("GET", latest.download_url) as response:
  323. if response.status_code != 200:
  324. logger.error("Firmware download failed with status %s", response.status_code)
  325. return None
  326. total_size = int(response.headers.get("content-length", 0))
  327. downloaded = 0
  328. with open(temp_path, "wb") as f:
  329. async for chunk in response.aiter_bytes(chunk_size=65536):
  330. f.write(chunk)
  331. downloaded += len(chunk)
  332. if progress_callback:
  333. progress_callback(downloaded, total_size, "Downloading firmware...")
  334. # Also save a copy with the original filename for SD card
  335. original_path = self._get_firmware_cache_dir() / original_filename
  336. if original_path.exists():
  337. original_path.unlink()
  338. # Move temp to both cached path and original filename path
  339. import shutil
  340. shutil.copy2(temp_path, cached_path)
  341. temp_path.rename(original_path)
  342. logger.info("Firmware downloaded successfully: %s", original_path)
  343. if progress_callback:
  344. progress_callback(downloaded, total_size, "Download complete")
  345. return original_path
  346. except Exception as e:
  347. logger.error("Firmware download failed: %s", e)
  348. if temp_path.exists():
  349. try:
  350. temp_path.unlink()
  351. except OSError:
  352. pass # Best-effort cleanup of failed download temp file
  353. return None
  354. async def close(self):
  355. """Close the HTTP client."""
  356. await self._client.aclose()
  357. # Singleton instance
  358. _firmware_service: FirmwareCheckService | None = None
  359. def get_firmware_service() -> FirmwareCheckService:
  360. """Get the singleton firmware check service instance."""
  361. global _firmware_service
  362. if _firmware_service is None:
  363. _firmware_service = FirmwareCheckService()
  364. return _firmware_service