firmware_check.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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 json
  8. import logging
  9. import re
  10. import time
  11. from collections.abc import Callable
  12. from dataclasses import dataclass
  13. from pathlib import Path
  14. import httpx
  15. from backend.app.core.config import _data_dir
  16. logger = logging.getLogger(__name__)
  17. # Bambu Lab firmware download page (for download URLs)
  18. BAMBU_FIRMWARE_BASE = "https://bambulab.com"
  19. FIRMWARE_PAGE = "/en/support/firmware-download/all"
  20. # Bambu Lab wiki (primary source for latest version detection)
  21. BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
  22. # Cache TTL in seconds (1 hour)
  23. CACHE_TTL = 3600
  24. # Map Bambuddy model names to Bambu Lab API keys
  25. MODEL_TO_API_KEY = {
  26. "X1": "x1",
  27. "X1C": "x1",
  28. "X1-Carbon": "x1",
  29. "X1 Carbon": "x1",
  30. "P1P": "p1",
  31. "P1S": "p1",
  32. "A1": "a1",
  33. "A1 Mini": "a1-mini",
  34. "A1-Mini": "a1-mini",
  35. "A1mini": "a1-mini",
  36. "H2D": "h2d",
  37. "H2C": "h2c",
  38. "H2S": "h2s",
  39. "P2S": "p2s",
  40. "X1E": "x1e",
  41. "X2D": "x2d",
  42. "H2D Pro": "h2d-pro",
  43. "H2D-Pro": "h2d-pro",
  44. "H2DPRO": "h2d-pro",
  45. # SSDP model codes (DevModel header) — in case raw codes are stored
  46. "O1D": "h2d",
  47. "O1E": "h2d-pro",
  48. "O2D": "h2d-pro",
  49. "O1C": "h2c",
  50. "O1C2": "h2c",
  51. "O1S": "h2s",
  52. "BL-P001": "x1",
  53. "BL-P002": "x1",
  54. "BL-P003": "x1e",
  55. "C11": "p1",
  56. "C12": "p1",
  57. "C13": "p2s",
  58. "N2S": "a1",
  59. "N1": "a1-mini",
  60. "N6": "x2d",
  61. "N7": "p2s",
  62. }
  63. # Reverse mapping: API key to model codes
  64. API_KEY_TO_DEV_MODEL = {
  65. "x1": "BL-P001",
  66. "p1": "C11",
  67. "a1": "N2S",
  68. "a1-mini": "N1",
  69. "h2d": "O1D",
  70. "h2c": "O1C",
  71. "h2s": "O1S",
  72. "p2s": "N7",
  73. "x1e": "C13",
  74. "x2d": "N6",
  75. "h2d-pro": "O1E",
  76. }
  77. # Wiki firmware release history pages (primary version source)
  78. API_KEY_TO_WIKI_PATH = {
  79. "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
  80. "x1e": "/en/x1/manual/X1E-firmware-release-history",
  81. "p1": "/en/p1/manual/p1p-firmware-release-history",
  82. "a1": "/en/a1/manual/a1-firmware-release-history",
  83. "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
  84. "h2d": "/en/h2d/manual/h2d-firmware-release-history",
  85. "h2c": "/en/h2c/manual/h2c-firmware-release-history",
  86. "h2s": "/en/h2s/manual/h2s-firmware-release-history",
  87. "p2s": "/en/p2s/manual/p2s-firmware-release-history",
  88. "x2d": "/en/x2d/manual/x2d-firmware-release-history",
  89. "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
  90. }
  91. @dataclass
  92. class FirmwareVersion:
  93. """Firmware version information."""
  94. version: str
  95. download_url: str
  96. release_notes: str | None = None
  97. release_time: str | None = None
  98. class FirmwareCheckService:
  99. """Service for checking firmware updates from Bambu Lab."""
  100. def __init__(self):
  101. self._build_id: str | None = None
  102. self._build_id_time: float = 0
  103. self._download_page_unreachable: bool = False
  104. self._version_cache: dict[str, FirmwareVersion] = {}
  105. self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
  106. self._cache_time: float = 0
  107. self._client = httpx.AsyncClient(
  108. timeout=30.0,
  109. headers={
  110. # Identify honestly as Bambuddy when scraping the public Bambu
  111. # Lab firmware wiki — verified 2026-05-12 that the wiki serves
  112. # this UA identically to a Chrome UA (same HTML response shape).
  113. # No browser impersonation needed for read-only public pages.
  114. "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
  115. # Some Cloudflare bot rules on bambulab.com 403 requests with a
  116. # bare UA but no browser-like Accept headers (seen on AU IPs in
  117. # #1350). Sending normal Accept hints removes that signal while
  118. # staying honestly identified via the UA above.
  119. "Accept": "text/html,application/json,*/*;q=0.8",
  120. "Accept-Language": "en-US,en;q=0.9",
  121. },
  122. )
  123. def _build_id_cache_path(self) -> Path:
  124. cache_dir = _data_dir / "firmware"
  125. cache_dir.mkdir(parents=True, exist_ok=True)
  126. return cache_dir / "build_id.json"
  127. def _load_build_id_from_disk(self) -> tuple[str | None, float]:
  128. """Load the last-known buildId from disk, returning (build_id, fetched_at)."""
  129. path = self._build_id_cache_path()
  130. try:
  131. if not path.exists():
  132. return None, 0.0
  133. data = json.loads(path.read_text())
  134. build_id = data.get("build_id")
  135. fetched_at = float(data.get("fetched_at", 0))
  136. if isinstance(build_id, str) and build_id:
  137. return build_id, fetched_at
  138. except (OSError, ValueError, TypeError) as e:
  139. logger.debug("Could not read cached buildId: %s", e)
  140. return None, 0.0
  141. def _save_build_id_to_disk(self, build_id: str) -> None:
  142. try:
  143. self._build_id_cache_path().write_text(json.dumps({"build_id": build_id, "fetched_at": time.time()}))
  144. except OSError as e:
  145. logger.debug("Could not persist buildId: %s", e)
  146. async def _get_build_id(self) -> str | None:
  147. """Fetch the Next.js build ID from Bambu Lab's firmware page.
  148. Cache layers (fresh → stale → none):
  149. 1. In-memory (1 hour TTL) — fast path for repeated checks in a session
  150. 2. Disk-cached buildId (any age) — survives restarts, lets us recover
  151. from upstream Cloudflare 403s. The buildId is treated as
  152. "probably still valid" because Bambu rebuilds the page only every
  153. few weeks; if the JSON fetch later fails, the caller falls back.
  154. 3. Live fetch from bambulab.com — only when both caches miss
  155. """
  156. # 1. In-memory cache (fresh)
  157. if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
  158. return self._build_id
  159. # 2. Disk cache: load if we don't have one in memory yet (first call
  160. # after restart). We still try the live fetch below to refresh.
  161. if not self._build_id:
  162. disk_id, disk_time = self._load_build_id_from_disk()
  163. if disk_id:
  164. self._build_id = disk_id
  165. self._build_id_time = disk_time
  166. # 3. Live fetch
  167. try:
  168. response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
  169. if response.status_code == 200:
  170. match = re.search(r'"buildId":"([^"]+)"', response.text)
  171. if match:
  172. new_build_id = match.group(1)
  173. if new_build_id != self._build_id:
  174. logger.info("Got Bambu Lab build ID: %s", new_build_id)
  175. self._build_id = new_build_id
  176. self._build_id_time = time.time()
  177. self._download_page_unreachable = False
  178. self._save_build_id_to_disk(new_build_id)
  179. return self._build_id
  180. else:
  181. # 403/5xx — keep stale cached buildId if we have one (#1350).
  182. logger.warning(
  183. "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
  184. response.status_code,
  185. )
  186. self._download_page_unreachable = True
  187. except Exception as e:
  188. logger.error("Error fetching Bambu Lab build ID: %s", e)
  189. self._download_page_unreachable = True
  190. # Return whatever we have — even a stale buildId beats nothing.
  191. return self._build_id
  192. @property
  193. def download_page_unreachable(self) -> bool:
  194. """True if the most recent attempt to reach bambulab.com firmware page failed.
  195. Used by callers (e.g. the firmware update prepare flow) to render a
  196. clearer error message when a wiki-listed version has no download URL
  197. because we couldn't reach Bambu Lab, vs the version genuinely not
  198. being on the catalog (#1350).
  199. """
  200. return self._download_page_unreachable
  201. async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
  202. """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
  203. versions = await self._fetch_all_versions_from_wiki(api_key)
  204. if versions:
  205. logger.debug("Wiki firmware for %s: %s", api_key, versions[0][0])
  206. return versions[0][0]
  207. return None
  208. async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:
  209. """
  210. Fetch all firmware versions from the wiki release history page.
  211. Only extracts versions that appear in section-heading anchors
  212. (e.g. `id="h-01030000-20260303"` or `id="h-0102000020260409"`) —
  213. this excludes version-like numbers mentioned incidentally in
  214. release-note text. The dash separator between version and date is
  215. optional: H2D/X1/H2C/H2S still use it, but P2S and X2D publish
  216. anchors without the dash.
  217. Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
  218. """
  219. wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
  220. if not wiki_path:
  221. return []
  222. try:
  223. url = f"{BAMBU_WIKI_BASE}{wiki_path}"
  224. response = await self._client.get(url, follow_redirects=True)
  225. if response.status_code != 200:
  226. return []
  227. # Primary: heading anchor ids like id="h-01030000-20260303" (dash)
  228. # or id="h-0102000020260409" (no dash, P2S/X2D-style).
  229. anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-?(\d{8})"', response.text)
  230. seen: set[str] = set()
  231. versions: list[tuple[str, str | None]] = []
  232. for a, b, c, d, date in anchor_matches:
  233. v = f"{a}.{b}.{c}.{d}"
  234. if v in seen:
  235. continue
  236. seen.add(v)
  237. versions.append((v, date))
  238. if versions:
  239. return versions
  240. # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
  241. # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
  242. # which some pages (A1, A1-mini, P2S) use.
  243. text_matches = re.findall(
  244. r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*[(\uff08](\d{8})[)\uff09]",
  245. response.text,
  246. )
  247. for v, date in text_matches:
  248. if v in seen:
  249. continue
  250. seen.add(v)
  251. versions.append((v, date))
  252. return versions
  253. except Exception as e:
  254. logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
  255. return []
  256. async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
  257. """Fetch all firmware versions from Bambu Lab's download page (newest first).
  258. If we have a stale (disk-cached) buildId and it returns 404 (Bambu
  259. rebuilt the page), retry once with a fresh fetch — this only kicks in
  260. when the in-memory cache thinks it's still valid but the upstream has
  261. moved on.
  262. """
  263. build_id = await self._get_build_id()
  264. if not build_id:
  265. return []
  266. for attempt in range(2):
  267. try:
  268. url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
  269. response = await self._client.get(url)
  270. if response.status_code == 200:
  271. data = response.json()
  272. page_props = data.get("pageProps", {})
  273. printer_map = page_props.get("printerMap", {})
  274. printer_data = printer_map.get(api_key, {})
  275. versions = printer_data.get("versions", [])
  276. return [
  277. FirmwareVersion(
  278. version=v.get("version", ""),
  279. download_url=v.get("url", ""),
  280. release_notes=v.get("release_notes_en"),
  281. release_time=v.get("release_time"),
  282. )
  283. for v in versions
  284. if v.get("version")
  285. ]
  286. # 404 with cached buildId → Bambu rebuilt the page; invalidate
  287. # and retry once. Other status codes (403, 5xx) are upstream
  288. # blocks — don't churn.
  289. if response.status_code == 404 and attempt == 0:
  290. logger.info("Cached Bambu buildId stale (404), refreshing")
  291. self._build_id = None
  292. self._build_id_time = 0
  293. build_id = await self._get_build_id()
  294. if not build_id:
  295. return []
  296. continue
  297. # 403 from the JSON endpoint is the same Cloudflare block
  298. # signal as on the index page (#1350).
  299. if response.status_code == 403:
  300. self._download_page_unreachable = True
  301. logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
  302. return []
  303. except Exception as e:
  304. logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
  305. return []
  306. return []
  307. async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
  308. """Fetch the latest firmware info from Bambu Lab's download page (has download URLs)."""
  309. versions = await self._fetch_all_versions_from_download_page(api_key)
  310. return versions[0] if versions else None
  311. async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
  312. """Fetch firmware version info, using wiki as primary source and download page as fallback."""
  313. # Try wiki first (always has the latest version)
  314. wiki_version = await self._fetch_version_from_wiki(api_key)
  315. # Try download page (has download URLs, may lag behind wiki)
  316. download_info = await self._fetch_from_download_page(api_key)
  317. if wiki_version:
  318. # Wiki has the latest version — use it, attach download URL if available
  319. download_url = ""
  320. release_notes = None
  321. if download_info and download_info.version == wiki_version:
  322. download_url = download_info.download_url
  323. release_notes = download_info.release_notes
  324. return FirmwareVersion(
  325. version=wiki_version,
  326. download_url=download_url,
  327. release_notes=release_notes,
  328. )
  329. if download_info:
  330. return download_info
  331. logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
  332. return None
  333. async def get_latest_version(self, model: str) -> FirmwareVersion | None:
  334. """
  335. Get the latest firmware version for a printer model.
  336. Args:
  337. model: Bambuddy printer model name (e.g., "X1C", "P1S", "H2D")
  338. Returns:
  339. FirmwareVersion if found, None otherwise
  340. """
  341. # Normalize model name
  342. model_upper = model.upper().replace(" ", "").replace("-", "")
  343. # Find the API key for this model
  344. api_key = None
  345. for model_name, key in MODEL_TO_API_KEY.items():
  346. if model_name.upper().replace(" ", "").replace("-", "") == model_upper:
  347. api_key = key
  348. break
  349. if not api_key:
  350. # Try direct lookup with original model
  351. api_key = MODEL_TO_API_KEY.get(model)
  352. if not api_key:
  353. logger.debug("Unknown printer model: %s", model)
  354. return None
  355. # Check cache
  356. cache_key = api_key
  357. if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:
  358. return self._version_cache[cache_key]
  359. # Fetch from API
  360. version = await self._fetch_firmware_versions(api_key)
  361. if version:
  362. self._version_cache[cache_key] = version
  363. self._cache_time = time.time()
  364. return version
  365. def _resolve_api_key(self, model: str) -> str | None:
  366. """Resolve a model name to its Bambu API key."""
  367. model_upper = model.upper().replace(" ", "").replace("-", "")
  368. for name, key in MODEL_TO_API_KEY.items():
  369. if name.upper().replace(" ", "").replace("-", "") == model_upper:
  370. return key
  371. return MODEL_TO_API_KEY.get(model)
  372. @staticmethod
  373. def _version_tuple(v: str) -> tuple[int, ...]:
  374. parts = [int(x) for x in v.split(".")]
  375. while len(parts) < 4:
  376. parts.append(0)
  377. return tuple(parts)
  378. async def get_available_versions(self, model: str) -> list[FirmwareVersion]:
  379. """
  380. Get all announced firmware versions for a model, newest first.
  381. Merges the wiki release history (list of version strings) with the
  382. download page JSON (which provides download URLs + release notes).
  383. Versions present only on the wiki have an empty download_url and
  384. should be treated as "unavailable" for file-based installation.
  385. """
  386. api_key = self._resolve_api_key(model)
  387. if not api_key:
  388. return []
  389. if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:
  390. return self._versions_list_cache[api_key]
  391. wiki_versions = await self._fetch_all_versions_from_wiki(api_key)
  392. download_versions = await self._fetch_all_versions_from_download_page(api_key)
  393. by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}
  394. merged: list[FirmwareVersion] = []
  395. seen: set[str] = set()
  396. for v, wiki_date in wiki_versions:
  397. if v in seen:
  398. continue
  399. seen.add(v)
  400. if v in by_version:
  401. merged.append(by_version[v])
  402. else:
  403. merged.append(FirmwareVersion(version=v, download_url="", release_time=wiki_date))
  404. for d in download_versions:
  405. if d.version and d.version not in seen:
  406. seen.add(d.version)
  407. merged.append(d)
  408. try:
  409. merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)
  410. except (ValueError, AttributeError):
  411. pass
  412. self._versions_list_cache[api_key] = merged
  413. self._cache_time = time.time()
  414. return merged
  415. async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:
  416. """Find a specific version's info (including download URL) for a model."""
  417. for v in await self.get_available_versions(model):
  418. if v.version == version:
  419. return v
  420. return None
  421. async def check_for_update(self, model: str, current_version: str) -> dict:
  422. """
  423. Check if a firmware update is available for a printer.
  424. Args:
  425. model: Printer model name
  426. current_version: Currently installed firmware version
  427. Returns:
  428. Dict with update info:
  429. - update_available: bool
  430. - current_version: str
  431. - latest_version: str or None
  432. - download_url: str or None
  433. - release_notes: str or None
  434. """
  435. result = {
  436. "update_available": False,
  437. "current_version": current_version,
  438. "latest_version": None,
  439. "download_url": None,
  440. "release_notes": None,
  441. "available_versions": [],
  442. }
  443. available = await self.get_available_versions(model)
  444. result["available_versions"] = [
  445. {
  446. "version": v.version,
  447. "download_url": v.download_url or None,
  448. "file_available": bool(v.download_url),
  449. "release_notes": v.release_notes,
  450. "release_time": v.release_time,
  451. }
  452. for v in available
  453. ]
  454. if not current_version:
  455. return result
  456. latest = available[0] if available else await self.get_latest_version(model)
  457. if not latest:
  458. return result
  459. result["latest_version"] = latest.version
  460. result["download_url"] = latest.download_url or None
  461. result["release_notes"] = latest.release_notes
  462. # Compare versions (format: XX.XX.XX.XX)
  463. try:
  464. current_parts = [int(x) for x in current_version.split(".")]
  465. latest_parts = [int(x) for x in latest.version.split(".")]
  466. # Pad to same length
  467. while len(current_parts) < 4:
  468. current_parts.append(0)
  469. while len(latest_parts) < 4:
  470. latest_parts.append(0)
  471. result["update_available"] = latest_parts > current_parts
  472. except (ValueError, AttributeError):
  473. logger.warning("Could not compare versions: %s vs %s", current_version, latest.version)
  474. return result
  475. async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:
  476. """
  477. Fetch latest firmware versions for all known printer models.
  478. Returns:
  479. Dict mapping API key to FirmwareVersion
  480. """
  481. results = {}
  482. for api_key in API_KEY_TO_DEV_MODEL:
  483. version = await self._fetch_firmware_versions(api_key)
  484. if version:
  485. results[api_key] = version
  486. return results
  487. def _get_firmware_cache_dir(self) -> Path:
  488. """Get the firmware cache directory, creating it if needed."""
  489. cache_dir = _data_dir / "firmware"
  490. cache_dir.mkdir(parents=True, exist_ok=True)
  491. return cache_dir
  492. async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:
  493. """
  494. Get information about a firmware file for a model.
  495. If `version` is provided, returns info for that specific version (must be
  496. available on the download page). Otherwise returns info for the latest version.
  497. """
  498. if version:
  499. target = await self.get_version_info(model, version)
  500. else:
  501. target = await self.get_latest_version(model)
  502. if not target or not target.download_url:
  503. return None
  504. url_parts = target.download_url.split("/")
  505. filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  506. return {
  507. "download_url": target.download_url,
  508. "version": target.version,
  509. "filename": filename,
  510. "release_notes": target.release_notes,
  511. }
  512. async def download_firmware(
  513. self,
  514. model: str,
  515. progress_callback: Callable[[int, int, str], None] | None = None,
  516. version: str | None = None,
  517. ) -> Path | None:
  518. """
  519. Download firmware file for a printer model.
  520. Args:
  521. model: Printer model name (e.g., "X1C", "P1S", "H2D")
  522. progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)
  523. Returns:
  524. Path to downloaded firmware file, or None on failure
  525. """
  526. if version:
  527. latest = await self.get_version_info(model, version)
  528. else:
  529. latest = await self.get_latest_version(model)
  530. if not latest or not latest.download_url:
  531. logger.warning("No firmware download URL available for model %s version %s", model, version)
  532. return None
  533. # Extract original filename from URL (must preserve for SD card update)
  534. url_parts = latest.download_url.split("/")
  535. original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  536. # Check if already cached (using original filename so SD card gets the right name)
  537. cached_path = self._get_firmware_cache_dir() / original_filename
  538. if cached_path.exists():
  539. logger.info("Using cached firmware: %s", cached_path)
  540. return cached_path
  541. # Download to temp file first
  542. temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
  543. try:
  544. logger.info("Downloading firmware from %s", latest.download_url)
  545. if progress_callback:
  546. progress_callback(0, 0, "Starting download...")
  547. async with self._client.stream("GET", latest.download_url) as response:
  548. if response.status_code != 200:
  549. logger.error("Firmware download failed with status %s", response.status_code)
  550. return None
  551. total_size = int(response.headers.get("content-length", 0))
  552. downloaded = 0
  553. with open(temp_path, "wb") as f:
  554. async for chunk in response.aiter_bytes(chunk_size=65536):
  555. f.write(chunk)
  556. downloaded += len(chunk)
  557. if progress_callback:
  558. progress_callback(downloaded, total_size, "Downloading firmware...")
  559. # Move temp to final path, preserving original filename
  560. temp_path.rename(cached_path)
  561. logger.info("Firmware downloaded successfully: %s", cached_path)
  562. if progress_callback:
  563. progress_callback(downloaded, total_size, "Download complete")
  564. return cached_path
  565. except Exception as e:
  566. logger.error("Firmware download failed: %s", e)
  567. if temp_path.exists():
  568. try:
  569. temp_path.unlink()
  570. except OSError:
  571. pass # Best-effort cleanup of failed download temp file
  572. return None
  573. async def close(self):
  574. """Close the HTTP client."""
  575. await self._client.aclose()
  576. # Singleton instance
  577. _firmware_service: FirmwareCheckService | None = None
  578. def get_firmware_service() -> FirmwareCheckService:
  579. """Get the singleton firmware check service instance."""
  580. global _firmware_service
  581. if _firmware_service is None:
  582. _firmware_service = FirmwareCheckService()
  583. return _firmware_service