firmware_check.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  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. "X2D": "x2d",
  41. "H2D Pro": "h2d-pro",
  42. "H2D-Pro": "h2d-pro",
  43. "H2DPRO": "h2d-pro",
  44. # SSDP model codes (DevModel header) — in case raw codes are stored
  45. "O1D": "h2d",
  46. "O1E": "h2d-pro",
  47. "O2D": "h2d-pro",
  48. "O1C": "h2c",
  49. "O1C2": "h2c",
  50. "O1S": "h2s",
  51. "BL-P001": "x1",
  52. "BL-P002": "x1",
  53. "BL-P003": "x1e",
  54. "C11": "p1",
  55. "C12": "p1",
  56. "C13": "p2s",
  57. "N2S": "a1",
  58. "N1": "a1-mini",
  59. "N6": "x2d",
  60. "N7": "p2s",
  61. }
  62. # Reverse mapping: API key to model codes
  63. API_KEY_TO_DEV_MODEL = {
  64. "x1": "BL-P001",
  65. "p1": "C11",
  66. "a1": "N2S",
  67. "a1-mini": "N1",
  68. "h2d": "O1D",
  69. "h2c": "O1C",
  70. "h2s": "O1S",
  71. "p2s": "N7",
  72. "x1e": "C13",
  73. "x2d": "N6",
  74. "h2d-pro": "O1E",
  75. }
  76. # Wiki firmware release history pages (primary version source)
  77. API_KEY_TO_WIKI_PATH = {
  78. "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
  79. "x1e": "/en/x1/manual/X1E-firmware-release-history",
  80. "p1": "/en/p1/manual/p1p-firmware-release-history",
  81. "a1": "/en/a1/manual/a1-firmware-release-history",
  82. "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
  83. "h2d": "/en/h2d/manual/h2d-firmware-release-history",
  84. "h2c": "/en/h2c/manual/h2c-firmware-release-history",
  85. "h2s": "/en/h2s/manual/h2s-firmware-release-history",
  86. "p2s": "/en/p2s/manual/p2s-firmware-release-history",
  87. "x2d": "/en/x2d/manual/x2d-firmware-release-history",
  88. "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
  89. }
  90. @dataclass
  91. class FirmwareVersion:
  92. """Firmware version information."""
  93. version: str
  94. download_url: str
  95. release_notes: str | None = None
  96. release_time: str | None = None
  97. class FirmwareCheckService:
  98. """Service for checking firmware updates from Bambu Lab."""
  99. def __init__(self):
  100. self._build_id: str | None = None
  101. self._build_id_time: float = 0
  102. self._version_cache: dict[str, FirmwareVersion] = {}
  103. self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
  104. self._cache_time: float = 0
  105. self._client = httpx.AsyncClient(
  106. timeout=30.0,
  107. headers={
  108. "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"
  109. },
  110. )
  111. async def _get_build_id(self) -> str | None:
  112. """Fetch the Next.js build ID from Bambu Lab's firmware page."""
  113. # Use cached build ID if still valid (cache for 1 hour)
  114. if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
  115. return self._build_id
  116. try:
  117. response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
  118. if response.status_code == 200:
  119. # Extract buildId from the page
  120. match = re.search(r'"buildId":"([^"]+)"', response.text)
  121. if match:
  122. self._build_id = match.group(1)
  123. self._build_id_time = time.time()
  124. logger.info("Got Bambu Lab build ID: %s", self._build_id)
  125. return self._build_id
  126. logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
  127. except Exception as e:
  128. logger.error("Error fetching Bambu Lab build ID: %s", e)
  129. return self._build_id # Return cached value if available
  130. async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
  131. """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
  132. versions = await self._fetch_all_versions_from_wiki(api_key)
  133. if versions:
  134. logger.debug("Wiki firmware for %s: %s", api_key, versions[0][0])
  135. return versions[0][0]
  136. return None
  137. async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:
  138. """
  139. Fetch all firmware versions from the wiki release history page.
  140. Only extracts versions that appear in section-heading anchors
  141. (e.g. `id="h-01030000-20260303"`) — this excludes version-like
  142. numbers mentioned incidentally in release-note text.
  143. Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
  144. """
  145. wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
  146. if not wiki_path:
  147. return []
  148. try:
  149. url = f"{BAMBU_WIKI_BASE}{wiki_path}"
  150. response = await self._client.get(url, follow_redirects=True)
  151. if response.status_code != 200:
  152. return []
  153. # Primary: heading anchor ids like id="h-01030000-20260303"
  154. anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-(\d{8})"', response.text)
  155. seen: set[str] = set()
  156. versions: list[tuple[str, str | None]] = []
  157. for a, b, c, d, date in anchor_matches:
  158. v = f"{a}.{b}.{c}.{d}"
  159. if v in seen:
  160. continue
  161. seen.add(v)
  162. versions.append((v, date))
  163. if versions:
  164. return versions
  165. # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)"
  166. text_matches = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*\((\d{8})\)", response.text)
  167. for v, date in text_matches:
  168. if v in seen:
  169. continue
  170. seen.add(v)
  171. versions.append((v, date))
  172. return versions
  173. except Exception as e:
  174. logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
  175. return []
  176. async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
  177. """Fetch all firmware versions from Bambu Lab's download page (newest first)."""
  178. build_id = await self._get_build_id()
  179. if not build_id:
  180. return []
  181. try:
  182. url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
  183. response = await self._client.get(url)
  184. if response.status_code == 200:
  185. data = response.json()
  186. page_props = data.get("pageProps", {})
  187. printer_map = page_props.get("printerMap", {})
  188. printer_data = printer_map.get(api_key, {})
  189. versions = printer_data.get("versions", [])
  190. return [
  191. FirmwareVersion(
  192. version=v.get("version", ""),
  193. download_url=v.get("url", ""),
  194. release_notes=v.get("release_notes_en"),
  195. release_time=v.get("release_time"),
  196. )
  197. for v in versions
  198. if v.get("version")
  199. ]
  200. except Exception as e:
  201. logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
  202. return []
  203. async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
  204. """Fetch the latest firmware info from Bambu Lab's download page (has download URLs)."""
  205. versions = await self._fetch_all_versions_from_download_page(api_key)
  206. return versions[0] if versions else None
  207. async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
  208. """Fetch firmware version info, using wiki as primary source and download page as fallback."""
  209. # Try wiki first (always has the latest version)
  210. wiki_version = await self._fetch_version_from_wiki(api_key)
  211. # Try download page (has download URLs, may lag behind wiki)
  212. download_info = await self._fetch_from_download_page(api_key)
  213. if wiki_version:
  214. # Wiki has the latest version — use it, attach download URL if available
  215. download_url = ""
  216. release_notes = None
  217. if download_info and download_info.version == wiki_version:
  218. download_url = download_info.download_url
  219. release_notes = download_info.release_notes
  220. return FirmwareVersion(
  221. version=wiki_version,
  222. download_url=download_url,
  223. release_notes=release_notes,
  224. )
  225. if download_info:
  226. return download_info
  227. logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
  228. return None
  229. async def get_latest_version(self, model: str) -> FirmwareVersion | None:
  230. """
  231. Get the latest firmware version for a printer model.
  232. Args:
  233. model: Bambuddy printer model name (e.g., "X1C", "P1S", "H2D")
  234. Returns:
  235. FirmwareVersion if found, None otherwise
  236. """
  237. # Normalize model name
  238. model_upper = model.upper().replace(" ", "").replace("-", "")
  239. # Find the API key for this model
  240. api_key = None
  241. for model_name, key in MODEL_TO_API_KEY.items():
  242. if model_name.upper().replace(" ", "").replace("-", "") == model_upper:
  243. api_key = key
  244. break
  245. if not api_key:
  246. # Try direct lookup with original model
  247. api_key = MODEL_TO_API_KEY.get(model)
  248. if not api_key:
  249. logger.debug("Unknown printer model: %s", model)
  250. return None
  251. # Check cache
  252. cache_key = api_key
  253. if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:
  254. return self._version_cache[cache_key]
  255. # Fetch from API
  256. version = await self._fetch_firmware_versions(api_key)
  257. if version:
  258. self._version_cache[cache_key] = version
  259. self._cache_time = time.time()
  260. return version
  261. def _resolve_api_key(self, model: str) -> str | None:
  262. """Resolve a model name to its Bambu API key."""
  263. model_upper = model.upper().replace(" ", "").replace("-", "")
  264. for name, key in MODEL_TO_API_KEY.items():
  265. if name.upper().replace(" ", "").replace("-", "") == model_upper:
  266. return key
  267. return MODEL_TO_API_KEY.get(model)
  268. @staticmethod
  269. def _version_tuple(v: str) -> tuple[int, ...]:
  270. parts = [int(x) for x in v.split(".")]
  271. while len(parts) < 4:
  272. parts.append(0)
  273. return tuple(parts)
  274. async def get_available_versions(self, model: str) -> list[FirmwareVersion]:
  275. """
  276. Get all announced firmware versions for a model, newest first.
  277. Merges the wiki release history (list of version strings) with the
  278. download page JSON (which provides download URLs + release notes).
  279. Versions present only on the wiki have an empty download_url and
  280. should be treated as "unavailable" for file-based installation.
  281. """
  282. api_key = self._resolve_api_key(model)
  283. if not api_key:
  284. return []
  285. if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:
  286. return self._versions_list_cache[api_key]
  287. wiki_versions = await self._fetch_all_versions_from_wiki(api_key)
  288. download_versions = await self._fetch_all_versions_from_download_page(api_key)
  289. by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}
  290. merged: list[FirmwareVersion] = []
  291. seen: set[str] = set()
  292. for v, wiki_date in wiki_versions:
  293. if v in seen:
  294. continue
  295. seen.add(v)
  296. if v in by_version:
  297. merged.append(by_version[v])
  298. else:
  299. merged.append(FirmwareVersion(version=v, download_url="", release_time=wiki_date))
  300. for d in download_versions:
  301. if d.version and d.version not in seen:
  302. seen.add(d.version)
  303. merged.append(d)
  304. try:
  305. merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)
  306. except (ValueError, AttributeError):
  307. pass
  308. self._versions_list_cache[api_key] = merged
  309. self._cache_time = time.time()
  310. return merged
  311. async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:
  312. """Find a specific version's info (including download URL) for a model."""
  313. for v in await self.get_available_versions(model):
  314. if v.version == version:
  315. return v
  316. return None
  317. async def check_for_update(self, model: str, current_version: str) -> dict:
  318. """
  319. Check if a firmware update is available for a printer.
  320. Args:
  321. model: Printer model name
  322. current_version: Currently installed firmware version
  323. Returns:
  324. Dict with update info:
  325. - update_available: bool
  326. - current_version: str
  327. - latest_version: str or None
  328. - download_url: str or None
  329. - release_notes: str or None
  330. """
  331. result = {
  332. "update_available": False,
  333. "current_version": current_version,
  334. "latest_version": None,
  335. "download_url": None,
  336. "release_notes": None,
  337. "available_versions": [],
  338. }
  339. available = await self.get_available_versions(model)
  340. result["available_versions"] = [
  341. {
  342. "version": v.version,
  343. "download_url": v.download_url or None,
  344. "file_available": bool(v.download_url),
  345. "release_notes": v.release_notes,
  346. "release_time": v.release_time,
  347. }
  348. for v in available
  349. ]
  350. if not current_version:
  351. return result
  352. latest = available[0] if available else await self.get_latest_version(model)
  353. if not latest:
  354. return result
  355. result["latest_version"] = latest.version
  356. result["download_url"] = latest.download_url or None
  357. result["release_notes"] = latest.release_notes
  358. # Compare versions (format: XX.XX.XX.XX)
  359. try:
  360. current_parts = [int(x) for x in current_version.split(".")]
  361. latest_parts = [int(x) for x in latest.version.split(".")]
  362. # Pad to same length
  363. while len(current_parts) < 4:
  364. current_parts.append(0)
  365. while len(latest_parts) < 4:
  366. latest_parts.append(0)
  367. result["update_available"] = latest_parts > current_parts
  368. except (ValueError, AttributeError):
  369. logger.warning("Could not compare versions: %s vs %s", current_version, latest.version)
  370. return result
  371. async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:
  372. """
  373. Fetch latest firmware versions for all known printer models.
  374. Returns:
  375. Dict mapping API key to FirmwareVersion
  376. """
  377. results = {}
  378. for api_key in API_KEY_TO_DEV_MODEL:
  379. version = await self._fetch_firmware_versions(api_key)
  380. if version:
  381. results[api_key] = version
  382. return results
  383. def _get_firmware_cache_dir(self) -> Path:
  384. """Get the firmware cache directory, creating it if needed."""
  385. cache_dir = _data_dir / "firmware"
  386. cache_dir.mkdir(parents=True, exist_ok=True)
  387. return cache_dir
  388. async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:
  389. """
  390. Get information about a firmware file for a model.
  391. If `version` is provided, returns info for that specific version (must be
  392. available on the download page). Otherwise returns info for the latest version.
  393. """
  394. if version:
  395. target = await self.get_version_info(model, version)
  396. else:
  397. target = await self.get_latest_version(model)
  398. if not target or not target.download_url:
  399. return None
  400. url_parts = target.download_url.split("/")
  401. filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  402. return {
  403. "download_url": target.download_url,
  404. "version": target.version,
  405. "filename": filename,
  406. "release_notes": target.release_notes,
  407. }
  408. async def download_firmware(
  409. self,
  410. model: str,
  411. progress_callback: Callable[[int, int, str], None] | None = None,
  412. version: str | None = None,
  413. ) -> Path | None:
  414. """
  415. Download firmware file for a printer model.
  416. Args:
  417. model: Printer model name (e.g., "X1C", "P1S", "H2D")
  418. progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)
  419. Returns:
  420. Path to downloaded firmware file, or None on failure
  421. """
  422. if version:
  423. latest = await self.get_version_info(model, version)
  424. else:
  425. latest = await self.get_latest_version(model)
  426. if not latest or not latest.download_url:
  427. logger.warning("No firmware download URL available for model %s version %s", model, version)
  428. return None
  429. # Extract original filename from URL (must preserve for SD card update)
  430. url_parts = latest.download_url.split("/")
  431. original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  432. # Check if already cached (using original filename so SD card gets the right name)
  433. cached_path = self._get_firmware_cache_dir() / original_filename
  434. if cached_path.exists():
  435. logger.info("Using cached firmware: %s", cached_path)
  436. return cached_path
  437. # Download to temp file first
  438. temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
  439. try:
  440. logger.info("Downloading firmware from %s", latest.download_url)
  441. if progress_callback:
  442. progress_callback(0, 0, "Starting download...")
  443. async with self._client.stream("GET", latest.download_url) as response:
  444. if response.status_code != 200:
  445. logger.error("Firmware download failed with status %s", response.status_code)
  446. return None
  447. total_size = int(response.headers.get("content-length", 0))
  448. downloaded = 0
  449. with open(temp_path, "wb") as f:
  450. async for chunk in response.aiter_bytes(chunk_size=65536):
  451. f.write(chunk)
  452. downloaded += len(chunk)
  453. if progress_callback:
  454. progress_callback(downloaded, total_size, "Downloading firmware...")
  455. # Move temp to final path, preserving original filename
  456. temp_path.rename(cached_path)
  457. logger.info("Firmware downloaded successfully: %s", cached_path)
  458. if progress_callback:
  459. progress_callback(downloaded, total_size, "Download complete")
  460. return cached_path
  461. except Exception as e:
  462. logger.error("Firmware download failed: %s", e)
  463. if temp_path.exists():
  464. try:
  465. temp_path.unlink()
  466. except OSError:
  467. pass # Best-effort cleanup of failed download temp file
  468. return None
  469. async def close(self):
  470. """Close the HTTP client."""
  471. await self._client.aclose()
  472. # Singleton instance
  473. _firmware_service: FirmwareCheckService | None = None
  474. def get_firmware_service() -> FirmwareCheckService:
  475. """Get the singleton firmware check service instance."""
  476. global _firmware_service
  477. if _firmware_service is None:
  478. _firmware_service = FirmwareCheckService()
  479. return _firmware_service