bambu_cloud.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. """
  2. Bambu Lab Cloud API Service
  3. Handles authentication and profile management with Bambu Lab's cloud services.
  4. """
  5. import logging
  6. from datetime import datetime, timedelta
  7. import httpx
  8. logger = logging.getLogger(__name__)
  9. BAMBU_API_BASE = "https://api.bambulab.com"
  10. BAMBU_API_BASE_CN = "https://api.bambulab.cn"
  11. class BambuCloudError(Exception):
  12. """Base exception for Bambu Cloud errors."""
  13. pass
  14. class BambuCloudAuthError(BambuCloudError):
  15. """Authentication related errors."""
  16. pass
  17. class BambuCloudService:
  18. """Service for interacting with Bambu Lab Cloud API."""
  19. def __init__(self, region: str = "global"):
  20. self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
  21. self.access_token: str | None = None
  22. self.refresh_token: str | None = None
  23. self.token_expiry: datetime | None = None
  24. self._client = httpx.AsyncClient(timeout=30.0)
  25. @property
  26. def is_authenticated(self) -> bool:
  27. """Check if we have a valid token."""
  28. if not self.access_token:
  29. return False
  30. return not (self.token_expiry and datetime.now() > self.token_expiry)
  31. def _get_headers(self) -> dict:
  32. """Get headers for authenticated requests."""
  33. headers = {
  34. "Content-Type": "application/json",
  35. "User-Agent": "Bambuddy/1.0",
  36. }
  37. if self.access_token:
  38. headers["Authorization"] = f"Bearer {self.access_token}"
  39. return headers
  40. async def login_request(self, email: str, password: str) -> dict:
  41. """
  42. Initiate login - this will trigger either email verification or TOTP prompt.
  43. Returns dict with login status, verification type, and tfaKey if needed.
  44. """
  45. try:
  46. response = await self._client.post(
  47. f"{self.base_url}/v1/user-service/user/login",
  48. headers={"Content-Type": "application/json"},
  49. json={
  50. "account": email,
  51. "password": password,
  52. },
  53. )
  54. data = response.json()
  55. logger.debug(
  56. f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
  57. )
  58. if response.status_code == 200:
  59. login_type = data.get("loginType")
  60. tfa_key = data.get("tfaKey")
  61. # TOTP authentication required
  62. if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
  63. return {
  64. "success": False,
  65. "needs_verification": True,
  66. "verification_type": "totp",
  67. "tfa_key": tfa_key,
  68. "message": "Enter the code from your authenticator app",
  69. }
  70. # Email verification required
  71. if login_type == "verifyCode":
  72. return {
  73. "success": False,
  74. "needs_verification": True,
  75. "verification_type": "email",
  76. "tfa_key": None,
  77. "message": "Verification code sent to email",
  78. }
  79. # Direct login success (rare, usually needs 2FA)
  80. if "accessToken" in data:
  81. self._set_tokens(data)
  82. return {"success": True, "needs_verification": False, "message": "Login successful"}
  83. # Handle specific error codes
  84. error_msg = data.get("message") or data.get("error") or "Login failed"
  85. return {"success": False, "needs_verification": False, "message": error_msg}
  86. except Exception as e:
  87. logger.error(f"Login request failed: {e}")
  88. raise BambuCloudAuthError(f"Login request failed: {e}")
  89. async def verify_code(self, email: str, code: str) -> dict:
  90. """
  91. Complete login with email verification code.
  92. """
  93. try:
  94. response = await self._client.post(
  95. f"{self.base_url}/v1/user-service/user/login",
  96. headers={"Content-Type": "application/json"},
  97. json={
  98. "account": email,
  99. "code": code,
  100. },
  101. )
  102. data = response.json()
  103. logger.debug(f"Email verify response: status={response.status_code}, hasToken={'accessToken' in data}")
  104. if response.status_code == 200 and "accessToken" in data:
  105. self._set_tokens(data)
  106. return {"success": True, "message": "Login successful"}
  107. return {"success": False, "message": data.get("message", "Verification failed")}
  108. except Exception as e:
  109. logger.error(f"Email verification failed: {e}")
  110. raise BambuCloudAuthError(f"Verification failed: {e}")
  111. async def verify_totp(self, tfa_key: str, code: str) -> dict:
  112. """
  113. Complete login with TOTP code from authenticator app.
  114. Args:
  115. tfa_key: The tfaKey returned from initial login request
  116. code: 6-digit TOTP code from authenticator app
  117. """
  118. try:
  119. # TFA endpoint is on bambulab.com, NOT api.bambulab.com
  120. # Requires browser-like headers to bypass Cloudflare
  121. tfa_url = "https://bambulab.com/api/sign-in/tfa"
  122. if "bambulab.cn" in self.base_url:
  123. tfa_url = "https://bambulab.cn/api/sign-in/tfa"
  124. browser_headers = {
  125. "Content-Type": "application/json",
  126. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  127. "Accept": "application/json, text/plain, */*",
  128. "Accept-Language": "en-US,en;q=0.9",
  129. "Origin": "https://bambulab.com",
  130. "Referer": "https://bambulab.com/",
  131. }
  132. response = await self._client.post(
  133. tfa_url,
  134. headers=browser_headers,
  135. json={
  136. "tfaKey": tfa_key,
  137. "tfaCode": code,
  138. },
  139. )
  140. logger.debug(
  141. f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
  142. )
  143. # Handle empty response
  144. if not response.text or not response.text.strip():
  145. logger.warning(f"TOTP verification returned empty response (status {response.status_code})")
  146. return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
  147. try:
  148. data = response.json()
  149. except Exception as json_err:
  150. logger.error(f"Failed to parse TOTP response: {json_err}, body: {response.text[:500]}")
  151. return {"success": False, "message": "Invalid response from Bambu Cloud"}
  152. # Token might be in accessToken, token field, or cookies
  153. access_token = data.get("accessToken") or data.get("token")
  154. # Also check cookies for token
  155. if not access_token:
  156. for cookie in response.cookies:
  157. if "token" in cookie.lower():
  158. access_token = response.cookies.get(cookie)
  159. break
  160. if response.status_code == 200 and access_token:
  161. self.access_token = access_token
  162. self.refresh_token = data.get("refreshToken")
  163. from datetime import datetime, timedelta
  164. self.token_expiry = datetime.now() + timedelta(days=30)
  165. return {"success": True, "message": "Login successful"}
  166. # Provide helpful error message
  167. error_msg = data.get("message", "")
  168. if "expired" in error_msg.lower():
  169. return {"success": False, "message": "TOTP session expired. Please try logging in again."}
  170. if not error_msg:
  171. error_msg = f"TOTP verification failed (status {response.status_code})"
  172. return {"success": False, "message": error_msg}
  173. except Exception as e:
  174. logger.error(f"TOTP verification failed: {e}")
  175. # Return error instead of raising - don't trigger 401/500
  176. return {"success": False, "message": f"TOTP verification error: {e}"}
  177. def _set_tokens(self, data: dict):
  178. """Set tokens from login response."""
  179. self.access_token = data.get("accessToken")
  180. self.refresh_token = data.get("refreshToken")
  181. # Token typically valid for ~3 months, but we'll refresh more often
  182. self.token_expiry = datetime.now() + timedelta(days=30)
  183. def set_token(self, access_token: str):
  184. """Set access token directly (for stored tokens)."""
  185. self.access_token = access_token
  186. self.token_expiry = datetime.now() + timedelta(days=30)
  187. def logout(self):
  188. """Clear authentication state."""
  189. self.access_token = None
  190. self.refresh_token = None
  191. self.token_expiry = None
  192. async def get_user_profile(self) -> dict:
  193. """Get user profile information."""
  194. if not self.is_authenticated:
  195. raise BambuCloudAuthError("Not authenticated")
  196. try:
  197. response = await self._client.get(
  198. f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
  199. )
  200. if response.status_code == 200:
  201. return response.json()
  202. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  203. except httpx.RequestError as e:
  204. raise BambuCloudError(f"Request failed: {e}")
  205. async def get_slicer_settings(self, version: str = "02.04.00.70") -> dict:
  206. """
  207. Get all slicer settings (filament, printer, process presets).
  208. Args:
  209. version: Slicer version string
  210. """
  211. if not self.is_authenticated:
  212. raise BambuCloudAuthError("Not authenticated")
  213. try:
  214. response = await self._client.get(
  215. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  216. headers=self._get_headers(),
  217. params={"version": version},
  218. )
  219. data = response.json()
  220. if response.status_code == 200:
  221. return data
  222. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  223. except httpx.RequestError as e:
  224. raise BambuCloudError(f"Request failed: {e}")
  225. async def get_setting_detail(self, setting_id: str) -> dict:
  226. """Get detailed information for a specific setting/preset."""
  227. if not self.is_authenticated:
  228. raise BambuCloudAuthError("Not authenticated")
  229. try:
  230. response = await self._client.get(
  231. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  232. )
  233. if response.status_code == 200:
  234. return response.json()
  235. raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
  236. except httpx.RequestError as e:
  237. raise BambuCloudError(f"Request failed: {e}")
  238. async def create_setting(
  239. self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0"
  240. ) -> dict:
  241. """
  242. Create a new slicer preset/setting.
  243. Args:
  244. preset_type: Type of preset - "filament", "print", or "printer"
  245. name: Display name for the preset
  246. base_id: Base preset ID to inherit from (e.g., "GFSA00")
  247. setting: Dict of setting key-value pairs (only modified values from base)
  248. version: Version string for the preset (default: "2.0.0.0")
  249. Returns:
  250. Created preset data including the new setting_id
  251. """
  252. if not self.is_authenticated:
  253. raise BambuCloudAuthError("Not authenticated")
  254. try:
  255. # Add timestamp if not present
  256. import time
  257. if "updated_time" not in setting:
  258. setting["updated_time"] = str(int(time.time()))
  259. payload = {
  260. "type": preset_type,
  261. "name": name,
  262. "version": version,
  263. "base_id": base_id,
  264. "setting": setting,
  265. }
  266. response = await self._client.post(
  267. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  268. )
  269. data = response.json()
  270. if response.status_code in (200, 201):
  271. return data
  272. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  273. raise BambuCloudError(f"Failed to create setting: {error_msg}")
  274. except httpx.RequestError as e:
  275. raise BambuCloudError(f"Request failed: {e}")
  276. async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
  277. """
  278. Update an existing slicer preset/setting.
  279. Note: Bambu Cloud API doesn't support true updates. Instead, we:
  280. 1. Fetch the current setting metadata (type, base_id, version)
  281. 2. Use the provided settings as the new complete settings (NOT merged)
  282. 3. Delete the old setting first (to avoid name conflicts)
  283. 4. Create a new setting via POST
  284. Args:
  285. setting_id: ID of the preset to update
  286. name: New display name (optional)
  287. setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
  288. Returns:
  289. Updated preset data with new setting_id
  290. """
  291. if not self.is_authenticated:
  292. raise BambuCloudAuthError("Not authenticated")
  293. try:
  294. # Fetch current setting to get metadata (type, base_id, version)
  295. current = await self.get_setting_detail(setting_id)
  296. preset_type = current.get("type", "filament")
  297. # Use provided settings directly (complete replacement, not merge)
  298. # This allows the frontend to edit the full settings JSON
  299. if setting is not None:
  300. updated_setting = setting.copy()
  301. else:
  302. updated_setting = current.get("setting", {}).copy()
  303. # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
  304. # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
  305. settings_id_key = {
  306. "filament": "filament_settings_id",
  307. "print": "print_settings_id",
  308. "printer": "printer_settings_id",
  309. }.get(preset_type, "filament_settings_id")
  310. settings_id_value = updated_setting.get(settings_id_key, "")
  311. if settings_id_value:
  312. # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
  313. updated_name = settings_id_value.strip('"')
  314. elif name is not None:
  315. updated_name = name
  316. else:
  317. updated_name = current.get("name", "Untitled")
  318. # Update the timestamp
  319. import time
  320. updated_setting["updated_time"] = str(int(time.time()))
  321. # Ensure settings_id field matches the name
  322. updated_setting[settings_id_key] = f'"{updated_name}"'
  323. # Delete the old setting FIRST to avoid name conflicts
  324. await self.delete_setting(setting_id)
  325. # Create new setting via POST
  326. payload = {
  327. "type": preset_type,
  328. "name": updated_name,
  329. "version": current.get("version", "2.0.0.0"),
  330. "base_id": current.get("base_id", ""),
  331. "setting": updated_setting,
  332. }
  333. response = await self._client.post(
  334. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  335. )
  336. data = response.json()
  337. if response.status_code == 200:
  338. return data
  339. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  340. raise BambuCloudError(f"Failed to update setting: {error_msg}")
  341. except httpx.RequestError as e:
  342. raise BambuCloudError(f"Request failed: {e}")
  343. async def delete_setting(self, setting_id: str) -> dict:
  344. """
  345. Delete a slicer preset/setting.
  346. Args:
  347. setting_id: ID of the preset to delete
  348. Returns:
  349. Deletion confirmation
  350. """
  351. if not self.is_authenticated:
  352. raise BambuCloudAuthError("Not authenticated")
  353. try:
  354. response = await self._client.delete(
  355. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  356. )
  357. if response.status_code in (200, 204):
  358. return {"success": True, "message": "Setting deleted"}
  359. data = response.json() if response.content else {}
  360. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  361. raise BambuCloudError(f"Failed to delete setting: {error_msg}")
  362. except httpx.RequestError as e:
  363. raise BambuCloudError(f"Request failed: {e}")
  364. async def get_devices(self) -> dict:
  365. """Get list of bound devices."""
  366. if not self.is_authenticated:
  367. raise BambuCloudAuthError("Not authenticated")
  368. try:
  369. response = await self._client.get(
  370. f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
  371. )
  372. if response.status_code == 200:
  373. return response.json()
  374. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  375. except httpx.RequestError as e:
  376. raise BambuCloudError(f"Request failed: {e}")
  377. async def get_firmware_version(self, device_id: str) -> dict:
  378. """
  379. Get firmware version info for a device.
  380. Returns dict with:
  381. - current_version: Installed firmware version
  382. - latest_version: Latest available firmware version
  383. - update_available: Boolean indicating if update is available
  384. - release_notes: Release notes for latest version
  385. """
  386. if not self.is_authenticated:
  387. raise BambuCloudAuthError("Not authenticated")
  388. try:
  389. response = await self._client.get(
  390. f"{self.base_url}/v1/iot-service/api/user/device/version",
  391. headers=self._get_headers(),
  392. params={"device_id": device_id},
  393. )
  394. if response.status_code == 200:
  395. data = response.json()
  396. # API wraps response in 'data' field
  397. return data.get("data", data)
  398. raise BambuCloudError(f"Failed to get firmware version: {response.status_code}")
  399. except httpx.RequestError as e:
  400. raise BambuCloudError(f"Request failed: {e}")
  401. async def close(self):
  402. """Close the HTTP client."""
  403. await self._client.aclose()
  404. # Singleton instance
  405. _cloud_service: BambuCloudService | None = None
  406. def get_cloud_service() -> BambuCloudService:
  407. """Get the singleton cloud service instance."""
  408. global _cloud_service
  409. if _cloud_service is None:
  410. _cloud_service = BambuCloudService()
  411. return _cloud_service