firmware_update.py 13 KB

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