firmware_update.py 15 KB

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