firmware.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. """
  2. Firmware Update API Routes
  3. Check for firmware updates from Bambu Lab.
  4. Also provides endpoints for uploading firmware to printers via SD card.
  5. """
  6. import logging
  7. from fastapi import APIRouter, Depends, HTTPException
  8. from pydantic import BaseModel, Field
  9. from sqlalchemy import select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.auth import (
  12. RequestPrinterScope,
  13. RequirePermissionIfAuthEnabled,
  14. RequirePrinterPermissionIfAuthEnabled,
  15. )
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.printer_scope import PrinterScope
  19. from backend.app.models.printer import Printer
  20. from backend.app.models.settings import Settings
  21. from backend.app.models.user import User
  22. from backend.app.services.firmware_check import get_firmware_service
  23. from backend.app.services.firmware_update import (
  24. FirmwareUploadStatus,
  25. get_firmware_update_service,
  26. get_upload_state,
  27. )
  28. from backend.app.services.printer_manager import printer_manager
  29. logger = logging.getLogger(__name__)
  30. router = APIRouter(prefix="/firmware", tags=["firmware"])
  31. class AvailableFirmwareVersion(BaseModel):
  32. """A single firmware version announced by Bambu Lab."""
  33. version: str
  34. file_available: bool
  35. download_url: str | None = None
  36. release_notes: str | None = None
  37. release_time: str | None = None
  38. class FirmwareUpdateInfo(BaseModel):
  39. """Firmware update information for a printer."""
  40. printer_id: int
  41. printer_name: str
  42. model: str | None
  43. current_version: str | None
  44. latest_version: str | None
  45. update_available: bool
  46. download_url: str | None = None
  47. release_notes: str | None = None
  48. available_versions: list[AvailableFirmwareVersion] = Field(default_factory=list)
  49. class FirmwareUpdatesResponse(BaseModel):
  50. """Response containing firmware updates for all printers."""
  51. updates: list[FirmwareUpdateInfo] = Field(default_factory=list)
  52. updates_available: int = Field(0, description="Number of printers with updates available")
  53. class LatestFirmwareInfo(BaseModel):
  54. """Latest firmware version info for a model."""
  55. model_key: str
  56. version: str
  57. download_url: str
  58. release_notes: str | None = None
  59. async def _checks_enabled(db: AsyncSession) -> bool:
  60. """The "Check printer firmware" setting (default on).
  61. Enforced here and not only in the UI: the printers page asks before its
  62. settings have loaded, so a check switched off still went out to bambulab.com
  63. once per printer on every page load.
  64. """
  65. value = (
  66. await db.execute(select(Settings.value).where(Settings.key == "check_printer_firmware"))
  67. ).scalar_one_or_none()
  68. return (value or "true").lower() != "false"
  69. def _current_version(printer_id: int) -> str | None:
  70. mqtt_client = printer_manager.get_client(printer_id)
  71. if mqtt_client and mqtt_client.state:
  72. return mqtt_client.state.firmware_version
  73. return None
  74. def _not_checked(printer: Printer) -> FirmwareUpdateInfo:
  75. return FirmwareUpdateInfo(
  76. printer_id=printer.id,
  77. printer_name=printer.name,
  78. model=printer.model or "Unknown",
  79. current_version=_current_version(printer.id),
  80. latest_version=None,
  81. update_available=False,
  82. )
  83. @router.get("/updates", response_model=FirmwareUpdatesResponse)
  84. async def check_firmware_updates(
  85. db: AsyncSession = Depends(get_db),
  86. _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  87. printer_scope: PrinterScope = RequestPrinterScope,
  88. ):
  89. """
  90. Check for firmware updates for the connected printers the caller may see.
  91. Compares each printer's current firmware version against the latest
  92. available version from Bambu Lab's official firmware download page.
  93. Note: This does not require cloud authentication - it uses public
  94. firmware information from bambulab.com.
  95. """
  96. firmware_service = get_firmware_service()
  97. # Get all printers from database
  98. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  99. printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  100. if not await _checks_enabled(db):
  101. return FirmwareUpdatesResponse(updates=[_not_checked(p) for p in printers], updates_available=0)
  102. updates = []
  103. updates_available = 0
  104. for printer in printers:
  105. # Get current firmware version from MQTT state
  106. current_version = None
  107. mqtt_client = printer_manager.get_client(printer.id)
  108. if mqtt_client and mqtt_client.state:
  109. current_version = mqtt_client.state.firmware_version
  110. # Check for update
  111. model = printer.model or "Unknown"
  112. update_info = await firmware_service.check_for_update(model, current_version or "")
  113. if update_info["update_available"]:
  114. updates_available += 1
  115. updates.append(
  116. FirmwareUpdateInfo(
  117. printer_id=printer.id,
  118. printer_name=printer.name,
  119. model=model,
  120. current_version=current_version,
  121. latest_version=update_info["latest_version"],
  122. update_available=update_info["update_available"],
  123. download_url=update_info["download_url"],
  124. release_notes=update_info["release_notes"],
  125. available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
  126. )
  127. )
  128. return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
  129. @router.get("/updates/{printer_id}", response_model=FirmwareUpdateInfo)
  130. async def check_printer_firmware(
  131. printer_id: int,
  132. db: AsyncSession = Depends(get_db),
  133. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  134. ):
  135. """
  136. Check for firmware update for a specific printer.
  137. """
  138. firmware_service = get_firmware_service()
  139. # Get printer from database
  140. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  141. printer = result.scalar_one_or_none()
  142. if not printer:
  143. raise HTTPException(status_code=404, detail="Printer not found")
  144. if not await _checks_enabled(db):
  145. return _not_checked(printer)
  146. current_version = _current_version(printer.id)
  147. # Check for update
  148. model = printer.model or "Unknown"
  149. update_info = await firmware_service.check_for_update(model, current_version or "")
  150. return FirmwareUpdateInfo(
  151. printer_id=printer.id,
  152. printer_name=printer.name,
  153. model=model,
  154. current_version=current_version,
  155. latest_version=update_info["latest_version"],
  156. update_available=update_info["update_available"],
  157. download_url=update_info["download_url"],
  158. release_notes=update_info["release_notes"],
  159. available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get("available_versions", [])],
  160. )
  161. @router.get("/latest", response_model=list[LatestFirmwareInfo])
  162. async def get_all_latest_firmware(
  163. _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  164. ):
  165. """
  166. Get the latest firmware versions for all Bambu Lab printer models.
  167. This endpoint fetches the latest available firmware versions from
  168. Bambu Lab's official firmware download page.
  169. """
  170. firmware_service = get_firmware_service()
  171. versions = await firmware_service.get_all_latest_versions()
  172. return [
  173. LatestFirmwareInfo(
  174. model_key=key,
  175. version=info.version,
  176. download_url=info.download_url,
  177. release_notes=info.release_notes,
  178. )
  179. for key, info in versions.items()
  180. ]
  181. # ============================================================================
  182. # Firmware Upload Endpoints (for LAN-only firmware updates)
  183. # ============================================================================
  184. class FirmwareUploadPrepareResponse(BaseModel):
  185. """Response from firmware upload preparation check."""
  186. can_proceed: bool
  187. sd_card_present: bool
  188. sd_card_free_space: int = Field(-1, description="Free space in bytes, -1 if unknown")
  189. firmware_size: int = Field(0, description="Estimated firmware size in bytes")
  190. space_sufficient: bool
  191. update_available: bool
  192. current_version: str | None = None
  193. latest_version: str | None = None
  194. target_version: str | None = None
  195. firmware_filename: str | None = None
  196. errors: list[str] = Field(default_factory=list)
  197. class FirmwareUploadStatusResponse(BaseModel):
  198. """Response containing firmware upload status."""
  199. status: str # idle, preparing, downloading, uploading, complete, error
  200. progress: int = Field(0, ge=0, le=100)
  201. message: str = ""
  202. error: str | None = None
  203. firmware_filename: str | None = None
  204. firmware_version: str | None = None
  205. class FirmwareUploadStartResponse(BaseModel):
  206. """Response when starting a firmware upload."""
  207. started: bool
  208. message: str
  209. @router.get("/updates/{printer_id}/prepare", response_model=FirmwareUploadPrepareResponse)
  210. async def prepare_firmware_upload(
  211. printer_id: int,
  212. version: str | None = None,
  213. db: AsyncSession = Depends(get_db),
  214. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  215. ):
  216. """
  217. Check prerequisites for uploading firmware to a printer.
  218. This performs pre-flight checks including:
  219. - SD card presence
  220. - Available storage space
  221. - Update availability
  222. Call this before starting a firmware upload to ensure the operation
  223. can succeed.
  224. """
  225. update_service = get_firmware_update_service()
  226. result = await update_service.prepare_update(printer_id, db, target_version=version)
  227. return FirmwareUploadPrepareResponse(**result)
  228. @router.post("/updates/{printer_id}/upload", response_model=FirmwareUploadStartResponse)
  229. async def start_firmware_upload(
  230. printer_id: int,
  231. version: str | None = None,
  232. db: AsyncSession = Depends(get_db),
  233. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
  234. ):
  235. """
  236. Start uploading firmware to a printer's SD card.
  237. This initiates a background process that:
  238. 1. Downloads the firmware from Bambu Lab
  239. 2. Uploads it to the printer's SD card via FTP
  240. Progress is broadcast via WebSocket with type "firmware_upload_progress".
  241. Use GET /firmware/updates/{printer_id}/upload/status for polling fallback.
  242. After upload completes, the user must trigger the update from the
  243. printer's screen (Settings > Firmware).
  244. """
  245. # First check prerequisites
  246. update_service = get_firmware_update_service()
  247. prepare_result = await update_service.prepare_update(printer_id, db, target_version=version)
  248. if not prepare_result["can_proceed"]:
  249. errors = prepare_result.get("errors", ["Cannot proceed with firmware upload"])
  250. raise HTTPException(
  251. status_code=400,
  252. detail="; ".join(errors),
  253. )
  254. # Start the upload
  255. started = await update_service.start_upload(printer_id, db, target_version=version)
  256. if not started:
  257. state = get_upload_state(printer_id)
  258. if state.status == FirmwareUploadStatus.DOWNLOADING:
  259. return FirmwareUploadStartResponse(
  260. started=False,
  261. message="Firmware upload already in progress",
  262. )
  263. raise HTTPException(
  264. status_code=500,
  265. detail=state.error or "Failed to start firmware upload",
  266. )
  267. return FirmwareUploadStartResponse(
  268. started=True,
  269. message="Firmware upload started. Progress will be broadcast via WebSocket.",
  270. )
  271. @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
  272. async def get_firmware_upload_status(
  273. printer_id: int,
  274. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  275. ):
  276. """
  277. Get the current status of a firmware upload operation.
  278. This is a polling fallback for clients that don't use WebSocket.
  279. For real-time updates, connect to WebSocket and listen for
  280. "firmware_upload_progress" messages.
  281. """
  282. state = get_upload_state(printer_id)
  283. return FirmwareUploadStatusResponse(
  284. status=state.status.value,
  285. progress=state.progress,
  286. message=state.message,
  287. error=state.error,
  288. firmware_filename=state.firmware_filename,
  289. firmware_version=state.firmware_version,
  290. )