firmware_check.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821
  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. from urllib.parse import urlparse
  15. import httpx
  16. from backend.app.core.config import _data_dir
  17. logger = logging.getLogger(__name__)
  18. # Cloudflare on bambulab.com now gates the firmware-download page behind a
  19. # JA3/TLS-fingerprint challenge (cf-mitigated=challenge) that plain Python
  20. # TLS can't pass (#1666). curl_cffi replays Chrome's actual ClientHello
  21. # bytes so the handshake clears CF; we override the HTTP User-Agent back to
  22. # the honest Bambuddy/1.0 string so the application-layer identity stays
  23. # truthful (TLS fingerprint matches Chrome because Python's TLS is the
  24. # signal CF gates on; everything above TLS is still Bambuddy).
  25. #
  26. # Soft dependency — if curl_cffi isn't importable on the running platform,
  27. # firmware_check degrades to httpx (which will likely 403) and wiki-based
  28. # version detection continues to work for the badge; only the in-app
  29. # firmware-download URL stops resolving.
  30. try:
  31. from curl_cffi.requests import AsyncSession as _CurlCffiAsyncSession
  32. _CURL_CFFI_AVAILABLE = True
  33. except ImportError: # pragma: no cover — exercised only on platforms without wheels
  34. _CurlCffiAsyncSession = None # type: ignore[misc,assignment]
  35. _CURL_CFFI_AVAILABLE = False
  36. # Bambu Lab firmware download page (for download URLs)
  37. BAMBU_FIRMWARE_BASE = "https://bambulab.com"
  38. FIRMWARE_PAGE = "/en/support/firmware-download/all"
  39. # Bambu Lab wiki (primary source for latest version detection)
  40. BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
  41. # Cache TTL in seconds (1 hour)
  42. CACHE_TTL = 3600
  43. # Map Bambuddy model names to Bambu Lab API keys
  44. MODEL_TO_API_KEY = {
  45. "X1": "x1",
  46. "X1C": "x1",
  47. "X1-Carbon": "x1",
  48. "X1 Carbon": "x1",
  49. "P1P": "p1",
  50. "P1S": "p1",
  51. "A1": "a1",
  52. "A1 Mini": "a1-mini",
  53. "A1-Mini": "a1-mini",
  54. "A1mini": "a1-mini",
  55. "A2L": "a2l",
  56. "H2D": "h2d",
  57. "H2C": "h2c",
  58. "H2S": "h2s",
  59. "H3C": "h3c",
  60. "P2S": "p2s",
  61. "X1E": "x1e",
  62. "X2D": "x2d",
  63. "H2D Pro": "h2d-pro",
  64. "H2D-Pro": "h2d-pro",
  65. "H2DPRO": "h2d-pro",
  66. # SSDP model codes (DevModel header) — in case raw codes are stored
  67. "O1D": "h2d",
  68. "O1E": "h2d-pro",
  69. "O2D": "h2d-pro",
  70. "O1C": "h2c",
  71. "O1C2": "h2c",
  72. "O1S": "h2s",
  73. "BL-P001": "x1",
  74. "BL-P002": "x1",
  75. "BL-P003": "x1e",
  76. "C11": "p1",
  77. "C12": "p1",
  78. "C13": "x1e",
  79. "N2S": "a1",
  80. "N1": "a1-mini",
  81. "N6": "x2d",
  82. "N7": "p2s",
  83. "N9": "a2l",
  84. "N10": "h3c",
  85. }
  86. # Reverse mapping: API key to model codes
  87. API_KEY_TO_DEV_MODEL = {
  88. "x1": "BL-P001",
  89. "p1": "C11",
  90. "a1": "N2S",
  91. "a1-mini": "N1",
  92. "h2d": "O1D",
  93. "h2c": "O1C",
  94. "h2s": "O1S",
  95. "p2s": "N7",
  96. "x1e": "C13",
  97. "x2d": "N6",
  98. "h2d-pro": "O1E",
  99. "a2l": "N9",
  100. "h3c": "N10",
  101. }
  102. # Wiki firmware release history pages (primary version source)
  103. API_KEY_TO_WIKI_PATH = {
  104. "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
  105. "x1e": "/en/x1/manual/X1E-firmware-release-history",
  106. "p1": "/en/p1/manual/p1p-firmware-release-history",
  107. "a1": "/en/a1/manual/a1-firmware-release-history",
  108. "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
  109. "h2d": "/en/h2d/manual/h2d-firmware-release-history",
  110. "h2c": "/en/h2c/manual/h2c-firmware-release-history",
  111. "h2s": "/en/h2s/manual/h2s-firmware-release-history",
  112. "p2s": "/en/p2s/manual/p2s-firmware-release-history",
  113. "x2d": "/en/x2d/manual/x2d-firmware-release-history",
  114. "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
  115. # A2L wiki path follows the established pattern but isn't yet published —
  116. # _fetch_all_versions_from_wiki silently returns [] on 404 so this is safe
  117. # to ship before Bambu publishes the page.
  118. "a2l": "/en/a2l/manual/a2l-firmware-release-history",
  119. # H3C: no model prefix in the page name, like the H2D Pro.
  120. "h3c": "/en/h3c/manual/firmware-release-history",
  121. }
  122. @dataclass
  123. class FirmwareVersion:
  124. """Firmware version information."""
  125. version: str
  126. download_url: str
  127. release_notes: str | None = None
  128. release_time: str | None = None
  129. class FirmwareCheckService:
  130. """Service for checking firmware updates from Bambu Lab."""
  131. def __init__(self):
  132. self._build_id: str | None = None
  133. self._build_id_time: float = 0
  134. self._download_page_unreachable: bool = False
  135. self._version_cache: dict[str, FirmwareVersion] = {}
  136. self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
  137. self._cache_time: float = 0
  138. # Plain httpx client for the Bambu Lab wiki (no CF fingerprint check)
  139. # and other endpoints. Honest UA throughout.
  140. self._client = httpx.AsyncClient(
  141. timeout=30.0,
  142. headers={
  143. "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
  144. "Accept": "text/html,application/json,*/*;q=0.8",
  145. "Accept-Language": "en-US,en;q=0.9",
  146. },
  147. )
  148. # Bambu Lab hosts known to be unreachable (no internet, a blocked
  149. # host): each outage is logged once, not on every fetch of every check.
  150. # Per host, as the wiki and bambulab.com can fail independently.
  151. self._unreachable_hosts: set[str] = set()
  152. # curl_cffi session for bambulab.com (lazily initialised on first use).
  153. # See module-level note on why this is needed.
  154. self._bambulab_client: object | None = None
  155. if not _CURL_CFFI_AVAILABLE:
  156. logger.warning(
  157. "curl_cffi not installed — bambulab.com firmware-download page "
  158. "will likely return Cloudflare 403 (#1666). Wiki-based version "
  159. "detection still works; install curl_cffi to also resolve "
  160. "in-app firmware download URLs."
  161. )
  162. def _get_bambulab_client(self) -> object | None:
  163. """Lazy-init curl_cffi async session for bambulab.com.
  164. Chrome TLS impersonation is required to pass Cloudflare's JA3
  165. challenge. The HTTP `User-Agent` is overridden back to the honest
  166. Bambuddy string so application-layer identity stays truthful.
  167. Returns None when curl_cffi is unavailable.
  168. """
  169. if not _CURL_CFFI_AVAILABLE:
  170. return None
  171. if self._bambulab_client is None:
  172. assert _CurlCffiAsyncSession is not None # type-narrow for mypy
  173. self._bambulab_client = _CurlCffiAsyncSession(
  174. impersonate="chrome",
  175. headers={
  176. "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
  177. "Accept": "text/html,application/json,*/*;q=0.8",
  178. "Accept-Language": "en-US,en;q=0.9",
  179. },
  180. timeout=30,
  181. )
  182. return self._bambulab_client
  183. def _note_unreachable(self, url: str, error: Exception) -> None:
  184. """A fetch failed at the transport: log the host's outage once, then quietly."""
  185. host = urlparse(url).hostname or url
  186. if host in self._unreachable_hosts:
  187. logger.debug("%s still unreachable (%s): %s", host, url, error)
  188. return
  189. self._unreachable_hosts.add(host)
  190. logger.warning(
  191. "%s unreachable (%s: %s); firmware checks keep retrying, further failures "
  192. "are logged at debug level until it is reachable again",
  193. host,
  194. url,
  195. error,
  196. )
  197. def _note_reachable(self, url: str) -> None:
  198. host = urlparse(url).hostname or url
  199. if host in self._unreachable_hosts:
  200. self._unreachable_hosts.discard(host)
  201. logger.info("%s reachable again", host)
  202. async def _bambulab_get(self, url: str) -> tuple[int, str]:
  203. """GET against bambulab.com. Returns (status_code, body_text).
  204. Routes through curl_cffi when available (passes the CF JA3 gate);
  205. falls back to httpx otherwise (will likely 403 but worth the try
  206. in case CF eases the check). status 0 indicates a transport error.
  207. """
  208. client = self._get_bambulab_client()
  209. if client is not None:
  210. try:
  211. response = await client.get(url) # type: ignore[attr-defined]
  212. except Exception as e:
  213. self._note_unreachable(url, e)
  214. return 0, ""
  215. self._note_reachable(url)
  216. return response.status_code, response.text
  217. try:
  218. response = await self._client.get(url)
  219. except Exception as e:
  220. self._note_unreachable(url, e)
  221. return 0, ""
  222. self._note_reachable(url)
  223. return response.status_code, response.text
  224. def _build_id_cache_path(self) -> Path:
  225. cache_dir = _data_dir / "firmware"
  226. cache_dir.mkdir(parents=True, exist_ok=True)
  227. return cache_dir / "build_id.json"
  228. def _load_build_id_from_disk(self) -> tuple[str | None, float]:
  229. """Load the last-known buildId from disk, returning (build_id, fetched_at)."""
  230. path = self._build_id_cache_path()
  231. try:
  232. if not path.exists():
  233. return None, 0.0
  234. data = json.loads(path.read_text())
  235. build_id = data.get("build_id")
  236. fetched_at = float(data.get("fetched_at", 0))
  237. if isinstance(build_id, str) and build_id:
  238. return build_id, fetched_at
  239. except (OSError, ValueError, TypeError) as e:
  240. logger.debug("Could not read cached buildId: %s", e)
  241. return None, 0.0
  242. def _save_build_id_to_disk(self, build_id: str) -> None:
  243. try:
  244. self._build_id_cache_path().write_text(json.dumps({"build_id": build_id, "fetched_at": time.time()}))
  245. except OSError as e:
  246. logger.debug("Could not persist buildId: %s", e)
  247. async def _get_build_id(self) -> str | None:
  248. """Fetch the Next.js build ID from Bambu Lab's firmware page.
  249. Cache layers (fresh → stale → none):
  250. 1. In-memory (1 hour TTL) — fast path for repeated checks in a session
  251. 2. Disk-cached buildId (any age) — survives restarts, lets us recover
  252. from upstream Cloudflare 403s. The buildId is treated as
  253. "probably still valid" because Bambu rebuilds the page only every
  254. few weeks; if the JSON fetch later fails, the caller falls back.
  255. 3. Live fetch from bambulab.com — only when both caches miss
  256. """
  257. # 1. In-memory cache (fresh)
  258. if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
  259. return self._build_id
  260. # 2. Disk cache: load if we don't have one in memory yet (first call
  261. # after restart). We still try the live fetch below to refresh.
  262. if not self._build_id:
  263. disk_id, disk_time = self._load_build_id_from_disk()
  264. if disk_id:
  265. self._build_id = disk_id
  266. self._build_id_time = disk_time
  267. # 3. Live fetch
  268. status, body = await self._bambulab_get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
  269. if status == 200:
  270. match = re.search(r'"buildId":"([^"]+)"', body)
  271. if match:
  272. new_build_id = match.group(1)
  273. if new_build_id != self._build_id:
  274. logger.info("Got Bambu Lab build ID: %s", new_build_id)
  275. self._build_id = new_build_id
  276. self._build_id_time = time.time()
  277. self._download_page_unreachable = False
  278. self._save_build_id_to_disk(new_build_id)
  279. return self._build_id
  280. elif status == 0:
  281. # Transport-level error already logged by _bambulab_get.
  282. self._download_page_unreachable = True
  283. else:
  284. # 403/5xx — keep stale cached buildId if we have one (#1350).
  285. # Without curl_cffi this is the expected outcome on Cloudflare
  286. # JA3-gated zones (#1666).
  287. logger.warning(
  288. "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
  289. status,
  290. )
  291. self._download_page_unreachable = True
  292. # Return whatever we have — even a stale buildId beats nothing.
  293. return self._build_id
  294. @property
  295. def download_page_unreachable(self) -> bool:
  296. """True if the most recent attempt to reach bambulab.com firmware page failed.
  297. Used by callers (e.g. the firmware update prepare flow) to render a
  298. clearer error message when a wiki-listed version has no download URL
  299. because we couldn't reach Bambu Lab, vs the version genuinely not
  300. being on the catalog (#1350).
  301. """
  302. return self._download_page_unreachable
  303. async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
  304. """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
  305. versions = await self._fetch_all_versions_from_wiki(api_key)
  306. if versions:
  307. logger.debug("Wiki firmware for %s: %s", api_key, versions[0][0])
  308. return versions[0][0]
  309. return None
  310. async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:
  311. """
  312. Fetch all firmware versions from the wiki release history page.
  313. Only extracts versions that appear in section-heading anchors
  314. (e.g. `id="h-01030000-20260303"` or `id="h-0102000020260409"`) —
  315. this excludes version-like numbers mentioned incidentally in
  316. release-note text. The dash separator between version and date is
  317. optional: H2D/X1/H2C/H2S still use it, but P2S and X2D publish
  318. anchors without the dash.
  319. Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
  320. """
  321. wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
  322. if not wiki_path:
  323. return []
  324. url = f"{BAMBU_WIKI_BASE}{wiki_path}"
  325. try:
  326. response = await self._client.get(url, follow_redirects=True)
  327. except httpx.TransportError as e:
  328. self._note_unreachable(url, e)
  329. return []
  330. except Exception as e:
  331. # Reached, but no usable answer (a redirect loop, a broken encoding).
  332. logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
  333. return []
  334. self._note_reachable(url)
  335. try:
  336. if response.status_code != 200:
  337. return []
  338. # Primary: heading anchor ids like id="h-01030000-20260303" (dash)
  339. # or id="h-0102000020260409" (no dash, P2S/X2D-style).
  340. anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-?(\d{8})"', response.text)
  341. seen: set[str] = set()
  342. versions: list[tuple[str, str | None]] = []
  343. for a, b, c, d, date in anchor_matches:
  344. v = f"{a}.{b}.{c}.{d}"
  345. if v in seen:
  346. continue
  347. seen.add(v)
  348. versions.append((v, date))
  349. if versions:
  350. return versions
  351. # H3C-style headings, "OTA 01.01.03.00" with id="ota-01010300":
  352. # no release date on the page.
  353. for a, b, c, d in re.findall(r'id="ota-(\d{2})(\d{2})(\d{2})(\d{2})"', response.text):
  354. v = f"{a}.{b}.{c}.{d}"
  355. if v not in seen:
  356. seen.add(v)
  357. versions.append((v, None))
  358. if versions:
  359. return versions
  360. # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
  361. # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
  362. # which some pages (A1, A1-mini, P2S) use.
  363. text_matches = re.findall(
  364. r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*[(\uff08](\d{8})[)\uff09]",
  365. response.text,
  366. )
  367. for v, date in text_matches:
  368. if v in seen:
  369. continue
  370. seen.add(v)
  371. versions.append((v, date))
  372. return versions
  373. except Exception as e:
  374. logger.debug("Error fetching wiki firmware list for %s: %s", api_key, e)
  375. return []
  376. async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
  377. """Fetch all firmware versions from Bambu Lab's download page (newest first).
  378. If we have a stale (disk-cached) buildId and it returns 404 (Bambu
  379. rebuilt the page), retry once with a fresh fetch — this only kicks in
  380. when the in-memory cache thinks it's still valid but the upstream has
  381. moved on.
  382. """
  383. build_id = await self._get_build_id()
  384. if not build_id:
  385. return []
  386. for attempt in range(2):
  387. url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
  388. status, body = await self._bambulab_get(url)
  389. if status == 200:
  390. try:
  391. data = json.loads(body)
  392. except ValueError as e:
  393. logger.debug("Download-page JSON for %s parse error: %s", api_key, e)
  394. return []
  395. page_props = data.get("pageProps", {})
  396. printer_map = page_props.get("printerMap", {})
  397. printer_data = printer_map.get(api_key, {})
  398. versions = printer_data.get("versions", [])
  399. return [
  400. FirmwareVersion(
  401. version=v.get("version", ""),
  402. download_url=v.get("url", ""),
  403. release_notes=v.get("release_notes_en"),
  404. release_time=v.get("release_time"),
  405. )
  406. for v in versions
  407. if v.get("version")
  408. ]
  409. # 404 with cached buildId → Bambu rebuilt the page; invalidate
  410. # and retry once. Other status codes (403, 5xx) are upstream
  411. # blocks — don't churn.
  412. if status == 404 and attempt == 0:
  413. logger.info("Cached Bambu buildId stale (404), refreshing")
  414. self._build_id = None
  415. self._build_id_time = 0
  416. build_id = await self._get_build_id()
  417. if not build_id:
  418. return []
  419. continue
  420. # 403 from the JSON endpoint is the same Cloudflare block
  421. # signal as on the index page (#1350, #1666).
  422. if status == 403:
  423. self._download_page_unreachable = True
  424. logger.debug("Download-page JSON for %s returned status %s", api_key, status)
  425. return []
  426. return []
  427. async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
  428. """Fetch the latest firmware info from Bambu Lab's download page (has download URLs)."""
  429. versions = await self._fetch_all_versions_from_download_page(api_key)
  430. return versions[0] if versions else None
  431. async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
  432. """Fetch firmware version info, using wiki as primary source and download page as fallback."""
  433. # Try wiki first (always has the latest version)
  434. wiki_version = await self._fetch_version_from_wiki(api_key)
  435. # Try download page (has download URLs, may lag behind wiki)
  436. download_info = await self._fetch_from_download_page(api_key)
  437. if wiki_version:
  438. # Wiki has the latest version — use it, attach download URL if available
  439. download_url = ""
  440. release_notes = None
  441. if download_info and download_info.version == wiki_version:
  442. download_url = download_info.download_url
  443. release_notes = download_info.release_notes
  444. return FirmwareVersion(
  445. version=wiki_version,
  446. download_url=download_url,
  447. release_notes=release_notes,
  448. )
  449. if download_info:
  450. return download_info
  451. # Already reported once as an outage when the sites are unreachable.
  452. log = logger.debug if self._unreachable_hosts else logger.warning
  453. log("Could not fetch firmware info for %s from wiki or download page", api_key)
  454. return None
  455. async def get_latest_version(self, model: str) -> FirmwareVersion | None:
  456. """
  457. Get the latest firmware version for a printer model.
  458. Args:
  459. model: Bambuddy printer model name (e.g., "X1C", "P1S", "H2D")
  460. Returns:
  461. FirmwareVersion if found, None otherwise
  462. """
  463. # Normalize model name
  464. model_upper = model.upper().replace(" ", "").replace("-", "")
  465. # Find the API key for this model
  466. api_key = None
  467. for model_name, key in MODEL_TO_API_KEY.items():
  468. if model_name.upper().replace(" ", "").replace("-", "") == model_upper:
  469. api_key = key
  470. break
  471. if not api_key:
  472. # Try direct lookup with original model
  473. api_key = MODEL_TO_API_KEY.get(model)
  474. if not api_key:
  475. logger.debug("Unknown printer model: %s", model)
  476. return None
  477. # Check cache
  478. cache_key = api_key
  479. if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:
  480. return self._version_cache[cache_key]
  481. # Fetch from API
  482. version = await self._fetch_firmware_versions(api_key)
  483. if version:
  484. self._version_cache[cache_key] = version
  485. self._cache_time = time.time()
  486. return version
  487. def _resolve_api_key(self, model: str) -> str | None:
  488. """Resolve a model name to its Bambu API key."""
  489. model_upper = model.upper().replace(" ", "").replace("-", "")
  490. for name, key in MODEL_TO_API_KEY.items():
  491. if name.upper().replace(" ", "").replace("-", "") == model_upper:
  492. return key
  493. return MODEL_TO_API_KEY.get(model)
  494. @staticmethod
  495. def _version_tuple(v: str) -> tuple[int, ...]:
  496. parts = [int(x) for x in v.split(".")]
  497. while len(parts) < 4:
  498. parts.append(0)
  499. return tuple(parts)
  500. async def get_available_versions(self, model: str) -> list[FirmwareVersion]:
  501. """
  502. Get all announced firmware versions for a model, newest first.
  503. Merges the wiki release history (list of version strings) with the
  504. download page JSON (which provides download URLs + release notes).
  505. Versions present only on the wiki have an empty download_url and
  506. should be treated as "unavailable" for file-based installation.
  507. """
  508. api_key = self._resolve_api_key(model)
  509. if not api_key:
  510. return []
  511. if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:
  512. return self._versions_list_cache[api_key]
  513. wiki_versions = await self._fetch_all_versions_from_wiki(api_key)
  514. download_versions = await self._fetch_all_versions_from_download_page(api_key)
  515. by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}
  516. merged: list[FirmwareVersion] = []
  517. seen: set[str] = set()
  518. for v, wiki_date in wiki_versions:
  519. if v in seen:
  520. continue
  521. seen.add(v)
  522. if v in by_version:
  523. merged.append(by_version[v])
  524. else:
  525. merged.append(FirmwareVersion(version=v, download_url="", release_time=wiki_date))
  526. for d in download_versions:
  527. if d.version and d.version not in seen:
  528. seen.add(d.version)
  529. merged.append(d)
  530. try:
  531. merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)
  532. except (ValueError, AttributeError):
  533. pass
  534. self._versions_list_cache[api_key] = merged
  535. self._cache_time = time.time()
  536. return merged
  537. async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:
  538. """Find a specific version's info (including download URL) for a model."""
  539. for v in await self.get_available_versions(model):
  540. if v.version == version:
  541. return v
  542. return None
  543. async def check_for_update(self, model: str, current_version: str) -> dict:
  544. """
  545. Check if a firmware update is available for a printer.
  546. Args:
  547. model: Printer model name
  548. current_version: Currently installed firmware version
  549. Returns:
  550. Dict with update info:
  551. - update_available: bool
  552. - current_version: str
  553. - latest_version: str or None
  554. - download_url: str or None
  555. - release_notes: str or None
  556. """
  557. result = {
  558. "update_available": False,
  559. "current_version": current_version,
  560. "latest_version": None,
  561. "download_url": None,
  562. "release_notes": None,
  563. "available_versions": [],
  564. }
  565. available = await self.get_available_versions(model)
  566. result["available_versions"] = [
  567. {
  568. "version": v.version,
  569. "download_url": v.download_url or None,
  570. "file_available": bool(v.download_url),
  571. "release_notes": v.release_notes,
  572. "release_time": v.release_time,
  573. }
  574. for v in available
  575. ]
  576. if not current_version:
  577. return result
  578. latest = available[0] if available else await self.get_latest_version(model)
  579. if not latest:
  580. return result
  581. result["latest_version"] = latest.version
  582. result["download_url"] = latest.download_url or None
  583. result["release_notes"] = latest.release_notes
  584. # Compare versions (format: XX.XX.XX.XX)
  585. try:
  586. current_parts = [int(x) for x in current_version.split(".")]
  587. latest_parts = [int(x) for x in latest.version.split(".")]
  588. # Pad to same length
  589. while len(current_parts) < 4:
  590. current_parts.append(0)
  591. while len(latest_parts) < 4:
  592. latest_parts.append(0)
  593. result["update_available"] = latest_parts > current_parts
  594. except (ValueError, AttributeError):
  595. logger.warning("Could not compare versions: %s vs %s", current_version, latest.version)
  596. return result
  597. async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:
  598. """
  599. Fetch latest firmware versions for all known printer models.
  600. Returns:
  601. Dict mapping API key to FirmwareVersion
  602. """
  603. results = {}
  604. for api_key in API_KEY_TO_DEV_MODEL:
  605. version = await self._fetch_firmware_versions(api_key)
  606. if version:
  607. results[api_key] = version
  608. return results
  609. def _get_firmware_cache_dir(self) -> Path:
  610. """Get the firmware cache directory, creating it if needed."""
  611. cache_dir = _data_dir / "firmware"
  612. cache_dir.mkdir(parents=True, exist_ok=True)
  613. return cache_dir
  614. async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:
  615. """
  616. Get information about a firmware file for a model.
  617. If `version` is provided, returns info for that specific version (must be
  618. available on the download page). Otherwise returns info for the latest version.
  619. """
  620. if version:
  621. target = await self.get_version_info(model, version)
  622. else:
  623. target = await self.get_latest_version(model)
  624. if not target or not target.download_url:
  625. return None
  626. url_parts = target.download_url.split("/")
  627. filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  628. return {
  629. "download_url": target.download_url,
  630. "version": target.version,
  631. "filename": filename,
  632. "release_notes": target.release_notes,
  633. }
  634. async def download_firmware(
  635. self,
  636. model: str,
  637. progress_callback: Callable[[int, int, str], None] | None = None,
  638. version: str | None = None,
  639. ) -> Path | None:
  640. """
  641. Download firmware file for a printer model.
  642. Args:
  643. model: Printer model name (e.g., "X1C", "P1S", "H2D")
  644. progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)
  645. Returns:
  646. Path to downloaded firmware file, or None on failure
  647. """
  648. if version:
  649. latest = await self.get_version_info(model, version)
  650. else:
  651. latest = await self.get_latest_version(model)
  652. if not latest or not latest.download_url:
  653. logger.warning("No firmware download URL available for model %s version %s", model, version)
  654. return None
  655. # Extract original filename from URL (must preserve for SD card update)
  656. url_parts = latest.download_url.split("/")
  657. original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
  658. # Check if already cached (using original filename so SD card gets the right name)
  659. cached_path = self._get_firmware_cache_dir() / original_filename
  660. if cached_path.exists():
  661. logger.info("Using cached firmware: %s", cached_path)
  662. return cached_path
  663. # Download to temp file first
  664. temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
  665. try:
  666. logger.info("Downloading firmware from %s", latest.download_url)
  667. if progress_callback:
  668. progress_callback(0, 0, "Starting download...")
  669. async with self._client.stream("GET", latest.download_url) as response:
  670. if response.status_code != 200:
  671. logger.error("Firmware download failed with status %s", response.status_code)
  672. return None
  673. total_size = int(response.headers.get("content-length", 0))
  674. downloaded = 0
  675. with open(temp_path, "wb") as f:
  676. async for chunk in response.aiter_bytes(chunk_size=65536):
  677. f.write(chunk)
  678. downloaded += len(chunk)
  679. if progress_callback:
  680. progress_callback(downloaded, total_size, "Downloading firmware...")
  681. # Move temp to final path, preserving original filename
  682. temp_path.rename(cached_path)
  683. logger.info("Firmware downloaded successfully: %s", cached_path)
  684. if progress_callback:
  685. progress_callback(downloaded, total_size, "Download complete")
  686. return cached_path
  687. except Exception as e:
  688. logger.error("Firmware download failed: %s", e)
  689. if temp_path.exists():
  690. try:
  691. temp_path.unlink()
  692. except OSError:
  693. pass # Best-effort cleanup of failed download temp file
  694. return None
  695. async def close(self):
  696. """Close the HTTP client."""
  697. await self._client.aclose()
  698. # Singleton instance
  699. _firmware_service: FirmwareCheckService | None = None
  700. def get_firmware_service() -> FirmwareCheckService:
  701. """Get the singleton firmware check service instance."""
  702. global _firmware_service
  703. if _firmware_service is None:
  704. _firmware_service = FirmwareCheckService()
  705. return _firmware_service