bambu_cloud.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. """
  2. Bambu Lab Cloud API Service
  3. Handles authentication and profile management with Bambu Lab's cloud services.
  4. """
  5. import httpx
  6. import json
  7. import logging
  8. from typing import Optional
  9. from pathlib import Path
  10. from datetime import datetime, timedelta
  11. logger = logging.getLogger(__name__)
  12. BAMBU_API_BASE = "https://api.bambulab.com"
  13. BAMBU_API_BASE_CN = "https://api.bambulab.cn"
  14. class BambuCloudError(Exception):
  15. """Base exception for Bambu Cloud errors."""
  16. pass
  17. class BambuCloudAuthError(BambuCloudError):
  18. """Authentication related errors."""
  19. pass
  20. class BambuCloudService:
  21. """Service for interacting with Bambu Lab Cloud API."""
  22. def __init__(self, region: str = "global"):
  23. self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
  24. self.access_token: Optional[str] = None
  25. self.refresh_token: Optional[str] = None
  26. self.token_expiry: Optional[datetime] = None
  27. self._client = httpx.AsyncClient(timeout=30.0)
  28. @property
  29. def is_authenticated(self) -> bool:
  30. """Check if we have a valid token."""
  31. if not self.access_token:
  32. return False
  33. if self.token_expiry and datetime.now() > self.token_expiry:
  34. return False
  35. return True
  36. def _get_headers(self) -> dict:
  37. """Get headers for authenticated requests."""
  38. headers = {
  39. "Content-Type": "application/json",
  40. "User-Agent": "BambuTrack/1.0",
  41. }
  42. if self.access_token:
  43. headers["Authorization"] = f"Bearer {self.access_token}"
  44. return headers
  45. async def login_request(self, email: str, password: str) -> dict:
  46. """
  47. Initiate login - this will trigger a verification code email.
  48. Returns dict with login status and whether verification is needed.
  49. """
  50. try:
  51. response = await self._client.post(
  52. f"{self.base_url}/v1/user-service/user/login",
  53. headers={"Content-Type": "application/json"},
  54. json={
  55. "account": email,
  56. "password": password,
  57. }
  58. )
  59. data = response.json()
  60. if response.status_code == 200:
  61. # Check if we need verification code
  62. # Bambu API returns loginType or may require tfaKey
  63. if data.get("loginType") == "verifyCode" or "tfaKey" in data:
  64. return {
  65. "success": False,
  66. "needs_verification": True,
  67. "message": "Verification code sent to email"
  68. }
  69. # Direct login success (rare, usually needs 2FA)
  70. if "accessToken" in data:
  71. self._set_tokens(data)
  72. return {
  73. "success": True,
  74. "needs_verification": False,
  75. "message": "Login successful"
  76. }
  77. # Handle specific error codes
  78. error_msg = data.get("message") or data.get("error") or "Login failed"
  79. return {
  80. "success": False,
  81. "needs_verification": False,
  82. "message": error_msg
  83. }
  84. except Exception as e:
  85. logger.error(f"Login request failed: {e}")
  86. raise BambuCloudAuthError(f"Login request failed: {e}")
  87. async def verify_code(self, email: str, code: str) -> dict:
  88. """
  89. Complete login with verification code.
  90. """
  91. try:
  92. response = await self._client.post(
  93. f"{self.base_url}/v1/user-service/user/login",
  94. headers={"Content-Type": "application/json"},
  95. json={
  96. "account": email,
  97. "code": code,
  98. }
  99. )
  100. data = response.json()
  101. if response.status_code == 200 and "accessToken" in data:
  102. self._set_tokens(data)
  103. return {
  104. "success": True,
  105. "message": "Login successful"
  106. }
  107. return {
  108. "success": False,
  109. "message": data.get("message", "Verification failed")
  110. }
  111. except Exception as e:
  112. logger.error(f"Verification failed: {e}")
  113. raise BambuCloudAuthError(f"Verification failed: {e}")
  114. def _set_tokens(self, data: dict):
  115. """Set tokens from login response."""
  116. self.access_token = data.get("accessToken")
  117. self.refresh_token = data.get("refreshToken")
  118. # Token typically valid for ~3 months, but we'll refresh more often
  119. self.token_expiry = datetime.now() + timedelta(days=30)
  120. def set_token(self, access_token: str):
  121. """Set access token directly (for stored tokens)."""
  122. self.access_token = access_token
  123. self.token_expiry = datetime.now() + timedelta(days=30)
  124. def logout(self):
  125. """Clear authentication state."""
  126. self.access_token = None
  127. self.refresh_token = None
  128. self.token_expiry = None
  129. async def get_user_profile(self) -> dict:
  130. """Get user profile information."""
  131. if not self.is_authenticated:
  132. raise BambuCloudAuthError("Not authenticated")
  133. try:
  134. response = await self._client.get(
  135. f"{self.base_url}/v1/design-user-service/my/preference",
  136. headers=self._get_headers()
  137. )
  138. if response.status_code == 200:
  139. return response.json()
  140. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  141. except httpx.RequestError as e:
  142. raise BambuCloudError(f"Request failed: {e}")
  143. async def get_slicer_settings(self, version: str = "01.09.00.00") -> dict:
  144. """
  145. Get all slicer settings (filament, printer, process presets).
  146. Args:
  147. version: Slicer version string
  148. """
  149. if not self.is_authenticated:
  150. raise BambuCloudAuthError("Not authenticated")
  151. try:
  152. response = await self._client.get(
  153. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  154. headers=self._get_headers(),
  155. params={"version": version}
  156. )
  157. data = response.json()
  158. if response.status_code == 200:
  159. return data
  160. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  161. except httpx.RequestError as e:
  162. raise BambuCloudError(f"Request failed: {e}")
  163. async def get_setting_detail(self, setting_id: str) -> dict:
  164. """Get detailed information for a specific setting/preset."""
  165. if not self.is_authenticated:
  166. raise BambuCloudAuthError("Not authenticated")
  167. try:
  168. response = await self._client.get(
  169. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
  170. headers=self._get_headers()
  171. )
  172. if response.status_code == 200:
  173. return response.json()
  174. raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
  175. except httpx.RequestError as e:
  176. raise BambuCloudError(f"Request failed: {e}")
  177. async def get_devices(self) -> dict:
  178. """Get list of bound devices."""
  179. if not self.is_authenticated:
  180. raise BambuCloudAuthError("Not authenticated")
  181. try:
  182. response = await self._client.get(
  183. f"{self.base_url}/v1/iot-service/api/user/bind",
  184. headers=self._get_headers()
  185. )
  186. if response.status_code == 200:
  187. return response.json()
  188. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  189. except httpx.RequestError as e:
  190. raise BambuCloudError(f"Request failed: {e}")
  191. async def close(self):
  192. """Close the HTTP client."""
  193. await self._client.aclose()
  194. # Singleton instance
  195. _cloud_service: Optional[BambuCloudService] = None
  196. def get_cloud_service() -> BambuCloudService:
  197. """Get the singleton cloud service instance."""
  198. global _cloud_service
  199. if _cloud_service is None:
  200. _cloud_service = BambuCloudService()
  201. return _cloud_service