firmware_update.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. """
  2. Firmware Update Service
  3. Orchestrates firmware updates for Bambu Lab printers:
  4. 1. Check prerequisites (SD card, space, update available)
  5. 2. Download firmware from Bambu Lab
  6. 3. Upload to printer's SD card via FTP
  7. 4. Notify user to trigger update from printer screen
  8. """
  9. import logging
  10. from dataclasses import dataclass
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.compat import StrEnum
  14. from backend.app.core.tasks import spawn_background_task
  15. from backend.app.models.printer import Printer
  16. from backend.app.services.bambu_ftp import (
  17. get_ftp_retry_settings,
  18. get_storage_info_async,
  19. upload_file_async,
  20. with_ftp_retry,
  21. )
  22. from backend.app.services.firmware_check import get_firmware_service
  23. from backend.app.services.printer_manager import printer_manager
  24. logger = logging.getLogger(__name__)
  25. # States with no print loaded. Firmware goes onto the SD card the printer
  26. # prints from, so only then.
  27. IDLE_STATES = frozenset({"IDLE", "FINISH", "FAILED"})
  28. PRINTER_BUSY_ERROR = "The printer is busy; firmware can only be uploaded while it is idle"
  29. def _printer_idle(printer_id: int) -> bool:
  30. client = printer_manager.get_client(printer_id)
  31. state = client.state if client else None
  32. return bool(state) and state.state in IDLE_STATES
  33. class FirmwareUploadStatus(StrEnum):
  34. """Status of a firmware upload operation."""
  35. IDLE = "idle"
  36. PREPARING = "preparing"
  37. DOWNLOADING = "downloading"
  38. UPLOADING = "uploading"
  39. COMPLETE = "complete"
  40. ERROR = "error"
  41. @dataclass
  42. class FirmwareUploadState:
  43. """State of a firmware upload operation for a printer."""
  44. status: FirmwareUploadStatus = FirmwareUploadStatus.IDLE
  45. progress: int = 0 # 0-100
  46. message: str = ""
  47. error: str | None = None
  48. firmware_filename: str | None = None
  49. firmware_version: str | None = None
  50. # Track upload state per printer
  51. _upload_states: dict[int, FirmwareUploadState] = {}
  52. def upload_in_progress(printer_id: int) -> bool:
  53. """Firmware is being fetched for, or written to, this printer's SD card."""
  54. state = _upload_states.get(printer_id)
  55. return state is not None and state.status in (
  56. FirmwareUploadStatus.PREPARING,
  57. FirmwareUploadStatus.DOWNLOADING,
  58. FirmwareUploadStatus.UPLOADING,
  59. )
  60. def get_upload_state(printer_id: int) -> FirmwareUploadState:
  61. """Get the current upload state for a printer."""
  62. if printer_id not in _upload_states:
  63. _upload_states[printer_id] = FirmwareUploadState()
  64. return _upload_states[printer_id]
  65. def reset_upload_state(printer_id: int):
  66. """Reset the upload state for a printer."""
  67. _upload_states[printer_id] = FirmwareUploadState()
  68. class FirmwareUpdateService:
  69. """Service for managing firmware updates."""
  70. # Minimum free space required (100MB buffer)
  71. MIN_FREE_SPACE_BYTES = 100 * 1024 * 1024
  72. async def prepare_update(
  73. self,
  74. printer_id: int,
  75. db: AsyncSession,
  76. target_version: str | None = None,
  77. ) -> dict:
  78. """
  79. Check prerequisites for firmware update.
  80. Returns:
  81. Dict with:
  82. - can_proceed: bool
  83. - sd_card_present: bool
  84. - sd_card_free_space: int (bytes, -1 if unknown)
  85. - firmware_size: int (bytes, estimated)
  86. - space_sufficient: bool
  87. - update_available: bool
  88. - current_version: str | None
  89. - latest_version: str | None
  90. - firmware_filename: str | None
  91. - errors: list[str]
  92. """
  93. result = {
  94. "can_proceed": False,
  95. "sd_card_present": False,
  96. "sd_card_free_space": -1,
  97. "firmware_size": 0,
  98. "space_sufficient": False,
  99. "update_available": False,
  100. "current_version": None,
  101. "latest_version": None,
  102. "target_version": target_version,
  103. "firmware_filename": None,
  104. "errors": [],
  105. }
  106. # Get printer from database
  107. stmt = select(Printer).where(Printer.id == printer_id)
  108. db_result = await db.execute(stmt)
  109. printer = db_result.scalar_one_or_none()
  110. if not printer:
  111. result["errors"].append("Printer not found")
  112. return result
  113. # Check printer is connected
  114. mqtt_client = printer_manager.get_client(printer_id)
  115. if not mqtt_client or not mqtt_client.state:
  116. result["errors"].append("Printer not connected")
  117. return result
  118. state = mqtt_client.state
  119. if state.state not in IDLE_STATES:
  120. result["errors"].append(PRINTER_BUSY_ERROR)
  121. # Get current firmware version
  122. result["current_version"] = state.firmware_version
  123. # Check SD card
  124. result["sd_card_present"] = state.sdcard
  125. if not state.sdcard:
  126. result["errors"].append("No SD card inserted in printer")
  127. # Get storage info via FTP
  128. if state.sdcard:
  129. try:
  130. storage_info = await get_storage_info_async(
  131. printer.ip_address,
  132. printer.access_code,
  133. printer_model=printer.model,
  134. )
  135. if storage_info and "free_bytes" in storage_info:
  136. result["sd_card_free_space"] = storage_info["free_bytes"]
  137. except Exception as e:
  138. logger.warning("Could not get storage info: %s", e)
  139. # Check for firmware update
  140. firmware_service = get_firmware_service()
  141. model = printer.model or "Unknown"
  142. if state.firmware_version:
  143. update_info = await firmware_service.check_for_update(model, state.firmware_version)
  144. result["update_available"] = update_info["update_available"]
  145. result["latest_version"] = update_info["latest_version"]
  146. else:
  147. # If we don't know current version, just get latest
  148. latest = await firmware_service.get_latest_version(model)
  149. if latest:
  150. result["latest_version"] = latest.version
  151. result["update_available"] = True # Assume update needed
  152. # Get firmware file info (for target_version if specified, else latest)
  153. file_info = await firmware_service.get_firmware_file_info(model, version=target_version)
  154. if file_info:
  155. result["firmware_filename"] = file_info["filename"]
  156. # Estimate size (typical firmware is 50-150MB)
  157. # We'll get actual size during download
  158. result["firmware_size"] = 100 * 1024 * 1024 # 100MB estimate
  159. elif target_version:
  160. # Requested specific version has no download URL. Distinguish
  161. # "Bambu doesn't list this file" from "we couldn't reach Bambu's
  162. # download page" (Cloudflare 403 reported in #1350) so users in
  163. # affected regions get an actionable error instead of believing
  164. # the firmware doesn't exist.
  165. if firmware_service.download_page_unreachable:
  166. result["errors"].append(
  167. f"Could not reach Bambu Lab's firmware download page to fetch the file URL for "
  168. f"{target_version}. Version is listed on the Bambu wiki but the download endpoint "
  169. f"is unreachable from this network. Try again later, or download the firmware "
  170. f"manually from bambulab.com and copy it to the printer's SD card."
  171. )
  172. else:
  173. result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
  174. # If a target version is requested, allow proceeding even if it equals or
  175. # is older than the current version (explicit downgrade/reinstall).
  176. if target_version:
  177. result["update_available"] = bool(file_info)
  178. elif not result["update_available"]:
  179. result["errors"].append("Firmware is already up to date")
  180. # Check space
  181. if result["sd_card_free_space"] > 0:
  182. # Need firmware size + buffer
  183. required = result["firmware_size"] + self.MIN_FREE_SPACE_BYTES
  184. result["space_sufficient"] = result["sd_card_free_space"] >= required
  185. if not result["space_sufficient"]:
  186. result["errors"].append(
  187. f"Insufficient SD card space. Need {required // (1024 * 1024)}MB, "
  188. f"have {result['sd_card_free_space'] // (1024 * 1024)}MB"
  189. )
  190. elif result["sd_card_present"]:
  191. # Couldn't determine space, assume sufficient
  192. result["space_sufficient"] = True
  193. # Final check
  194. result["can_proceed"] = (
  195. result["sd_card_present"]
  196. and result["space_sufficient"]
  197. and result["update_available"]
  198. and len(result["errors"]) == 0
  199. )
  200. return result
  201. async def start_upload(
  202. self,
  203. printer_id: int,
  204. db: AsyncSession,
  205. target_version: str | None = None,
  206. ) -> bool:
  207. """
  208. Start the firmware upload process.
  209. This runs asynchronously and broadcasts progress via WebSocket.
  210. Returns True if upload started successfully.
  211. """
  212. state = get_upload_state(printer_id)
  213. # Check if already in progress
  214. if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):
  215. logger.warning("Firmware upload already in progress for printer %s", printer_id)
  216. return False
  217. # Get printer
  218. stmt = select(Printer).where(Printer.id == printer_id)
  219. db_result = await db.execute(stmt)
  220. printer = db_result.scalar_one_or_none()
  221. if not printer:
  222. state.status = FirmwareUploadStatus.ERROR
  223. state.error = "Printer not found"
  224. return False
  225. # Get printer model
  226. model = printer.model or "Unknown"
  227. # Reset state
  228. reset_upload_state(printer_id)
  229. state = get_upload_state(printer_id)
  230. state.status = FirmwareUploadStatus.PREPARING
  231. state.message = "Preparing firmware update..."
  232. # Run the upload in background
  233. spawn_background_task(
  234. self._do_upload(
  235. printer_id=printer_id,
  236. ip_address=printer.ip_address,
  237. access_code=printer.access_code,
  238. model=model,
  239. target_version=target_version,
  240. ),
  241. name=f"firmware-upload-{printer_id}",
  242. )
  243. return True
  244. async def _do_upload(
  245. self,
  246. printer_id: int,
  247. ip_address: str,
  248. access_code: str,
  249. model: str,
  250. target_version: str | None = None,
  251. ):
  252. """Perform the actual firmware download and upload."""
  253. state = get_upload_state(printer_id)
  254. firmware_service = get_firmware_service()
  255. try:
  256. # Download firmware (quick, usually cached)
  257. state.status = FirmwareUploadStatus.DOWNLOADING
  258. state.progress = 0
  259. state.message = "Preparing firmware..."
  260. firmware_path = await firmware_service.download_firmware(model, version=target_version)
  261. if not firmware_path:
  262. raise Exception("Failed to download firmware")
  263. state.firmware_filename = firmware_path.name
  264. # Get firmware version for state
  265. if target_version:
  266. state.firmware_version = target_version
  267. else:
  268. latest = await firmware_service.get_latest_version(model)
  269. if latest:
  270. state.firmware_version = latest.version
  271. # The download can take minutes; a print may have started since.
  272. if not _printer_idle(printer_id):
  273. raise Exception(PRINTER_BUSY_ERROR)
  274. # Upload to printer (0-100% progress shown here)
  275. state.status = FirmwareUploadStatus.UPLOADING
  276. state.progress = 0
  277. state.message = f"Uploading {firmware_path.name} to printer..."
  278. # Upload to root of SD card (where printer expects firmware)
  279. remote_path = f"/{firmware_path.name}"
  280. logger.info("Uploading firmware to printer %s: %s", printer_id, remote_path)
  281. # Track real progress via FTP callback; the UI polls the upload status
  282. last_progress = 0
  283. def on_upload_progress(uploaded: int, total: int):
  284. nonlocal last_progress
  285. if total > 0:
  286. progress = int((uploaded / total) * 100)
  287. if progress > last_progress:
  288. last_progress = progress
  289. state.progress = min(99, progress) # Cap at 99 until complete
  290. # Get FTP retry settings
  291. ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
  292. if ftp_retry_enabled:
  293. success = await with_ftp_retry(
  294. upload_file_async,
  295. ip_address,
  296. access_code,
  297. firmware_path,
  298. remote_path,
  299. progress_callback=on_upload_progress,
  300. socket_timeout=ftp_timeout,
  301. printer_model=model,
  302. # Someone pressed "update firmware" and is watching a
  303. # progress bar. Bounded and user-initiated, like a print
  304. # dispatch, so it does not spend its retries on a cool-off
  305. # meant for the background sweeps (#2898).
  306. respect_handshake_cooloff=False,
  307. max_retries=ftp_retry_count,
  308. retry_delay=ftp_retry_delay,
  309. operation_name=f"Upload firmware to printer {printer_id}",
  310. )
  311. else:
  312. success = await upload_file_async(
  313. ip_address,
  314. access_code,
  315. firmware_path,
  316. remote_path,
  317. progress_callback=on_upload_progress,
  318. socket_timeout=ftp_timeout,
  319. printer_model=model,
  320. respect_handshake_cooloff=False,
  321. )
  322. if not success:
  323. raise Exception("Failed to upload firmware to printer")
  324. # Complete
  325. state.status = FirmwareUploadStatus.COMPLETE
  326. state.progress = 100
  327. state.message = (
  328. f"Firmware {state.firmware_version or ''} uploaded successfully! "
  329. "Please go to printer screen and trigger the update from Settings > Firmware."
  330. )
  331. logger.info("Firmware upload complete for printer %s", printer_id)
  332. except Exception as e:
  333. logger.error("Firmware upload failed for printer %s: %s", printer_id, e)
  334. state.status = FirmwareUploadStatus.ERROR
  335. state.error = str(e)
  336. state.message = f"Firmware upload failed: {e}"
  337. # Singleton instance
  338. _firmware_update_service: FirmwareUpdateService | None = None
  339. def get_firmware_update_service() -> FirmwareUpdateService:
  340. """Get the singleton firmware update service instance."""
  341. global _firmware_update_service
  342. if _firmware_update_service is None:
  343. _firmware_update_service = FirmwareUpdateService()
  344. return _firmware_update_service