bambu_cloud.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 a verification code email.
  43. Returns dict with login status and whether verification is 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. if response.status_code == 200:
  56. # Check if we need verification code
  57. # Bambu API returns loginType or may require tfaKey
  58. if data.get("loginType") == "verifyCode" or "tfaKey" in data:
  59. return {"success": False, "needs_verification": True, "message": "Verification code sent to email"}
  60. # Direct login success (rare, usually needs 2FA)
  61. if "accessToken" in data:
  62. self._set_tokens(data)
  63. return {"success": True, "needs_verification": False, "message": "Login successful"}
  64. # Handle specific error codes
  65. error_msg = data.get("message") or data.get("error") or "Login failed"
  66. return {"success": False, "needs_verification": False, "message": error_msg}
  67. except Exception as e:
  68. logger.error(f"Login request failed: {e}")
  69. raise BambuCloudAuthError(f"Login request failed: {e}")
  70. async def verify_code(self, email: str, code: str) -> dict:
  71. """
  72. Complete login with verification code.
  73. """
  74. try:
  75. response = await self._client.post(
  76. f"{self.base_url}/v1/user-service/user/login",
  77. headers={"Content-Type": "application/json"},
  78. json={
  79. "account": email,
  80. "code": code,
  81. },
  82. )
  83. data = response.json()
  84. if response.status_code == 200 and "accessToken" in data:
  85. self._set_tokens(data)
  86. return {"success": True, "message": "Login successful"}
  87. return {"success": False, "message": data.get("message", "Verification failed")}
  88. except Exception as e:
  89. logger.error(f"Verification failed: {e}")
  90. raise BambuCloudAuthError(f"Verification failed: {e}")
  91. def _set_tokens(self, data: dict):
  92. """Set tokens from login response."""
  93. self.access_token = data.get("accessToken")
  94. self.refresh_token = data.get("refreshToken")
  95. # Token typically valid for ~3 months, but we'll refresh more often
  96. self.token_expiry = datetime.now() + timedelta(days=30)
  97. def set_token(self, access_token: str):
  98. """Set access token directly (for stored tokens)."""
  99. self.access_token = access_token
  100. self.token_expiry = datetime.now() + timedelta(days=30)
  101. def logout(self):
  102. """Clear authentication state."""
  103. self.access_token = None
  104. self.refresh_token = None
  105. self.token_expiry = None
  106. async def get_user_profile(self) -> dict:
  107. """Get user profile information."""
  108. if not self.is_authenticated:
  109. raise BambuCloudAuthError("Not authenticated")
  110. try:
  111. response = await self._client.get(
  112. f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
  113. )
  114. if response.status_code == 200:
  115. return response.json()
  116. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  117. except httpx.RequestError as e:
  118. raise BambuCloudError(f"Request failed: {e}")
  119. async def get_slicer_settings(self, version: str = "02.04.00.70") -> dict:
  120. """
  121. Get all slicer settings (filament, printer, process presets).
  122. Args:
  123. version: Slicer version string
  124. """
  125. if not self.is_authenticated:
  126. raise BambuCloudAuthError("Not authenticated")
  127. try:
  128. response = await self._client.get(
  129. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  130. headers=self._get_headers(),
  131. params={"version": version},
  132. )
  133. data = response.json()
  134. if response.status_code == 200:
  135. return data
  136. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  137. except httpx.RequestError as e:
  138. raise BambuCloudError(f"Request failed: {e}")
  139. async def get_setting_detail(self, setting_id: str) -> dict:
  140. """Get detailed information for a specific setting/preset."""
  141. if not self.is_authenticated:
  142. raise BambuCloudAuthError("Not authenticated")
  143. try:
  144. response = await self._client.get(
  145. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  146. )
  147. if response.status_code == 200:
  148. return response.json()
  149. raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
  150. except httpx.RequestError as e:
  151. raise BambuCloudError(f"Request failed: {e}")
  152. async def create_setting(
  153. self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0"
  154. ) -> dict:
  155. """
  156. Create a new slicer preset/setting.
  157. Args:
  158. preset_type: Type of preset - "filament", "print", or "printer"
  159. name: Display name for the preset
  160. base_id: Base preset ID to inherit from (e.g., "GFSA00")
  161. setting: Dict of setting key-value pairs (only modified values from base)
  162. version: Version string for the preset (default: "2.0.0.0")
  163. Returns:
  164. Created preset data including the new setting_id
  165. """
  166. if not self.is_authenticated:
  167. raise BambuCloudAuthError("Not authenticated")
  168. try:
  169. # Add timestamp if not present
  170. import time
  171. if "updated_time" not in setting:
  172. setting["updated_time"] = str(int(time.time()))
  173. payload = {
  174. "type": preset_type,
  175. "name": name,
  176. "version": version,
  177. "base_id": base_id,
  178. "setting": setting,
  179. }
  180. response = await self._client.post(
  181. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  182. )
  183. data = response.json()
  184. if response.status_code in (200, 201):
  185. return data
  186. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  187. raise BambuCloudError(f"Failed to create setting: {error_msg}")
  188. except httpx.RequestError as e:
  189. raise BambuCloudError(f"Request failed: {e}")
  190. async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
  191. """
  192. Update an existing slicer preset/setting.
  193. Note: Bambu Cloud API doesn't support true updates. Instead, we:
  194. 1. Fetch the current setting metadata (type, base_id, version)
  195. 2. Use the provided settings as the new complete settings (NOT merged)
  196. 3. Delete the old setting first (to avoid name conflicts)
  197. 4. Create a new setting via POST
  198. Args:
  199. setting_id: ID of the preset to update
  200. name: New display name (optional)
  201. setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
  202. Returns:
  203. Updated preset data with new setting_id
  204. """
  205. if not self.is_authenticated:
  206. raise BambuCloudAuthError("Not authenticated")
  207. try:
  208. # Fetch current setting to get metadata (type, base_id, version)
  209. current = await self.get_setting_detail(setting_id)
  210. preset_type = current.get("type", "filament")
  211. # Use provided settings directly (complete replacement, not merge)
  212. # This allows the frontend to edit the full settings JSON
  213. if setting is not None:
  214. updated_setting = setting.copy()
  215. else:
  216. updated_setting = current.get("setting", {}).copy()
  217. # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
  218. # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
  219. settings_id_key = {
  220. "filament": "filament_settings_id",
  221. "print": "print_settings_id",
  222. "printer": "printer_settings_id",
  223. }.get(preset_type, "filament_settings_id")
  224. settings_id_value = updated_setting.get(settings_id_key, "")
  225. if settings_id_value:
  226. # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
  227. updated_name = settings_id_value.strip('"')
  228. elif name is not None:
  229. updated_name = name
  230. else:
  231. updated_name = current.get("name", "Untitled")
  232. # Update the timestamp
  233. import time
  234. updated_setting["updated_time"] = str(int(time.time()))
  235. # Ensure settings_id field matches the name
  236. updated_setting[settings_id_key] = f'"{updated_name}"'
  237. # Delete the old setting FIRST to avoid name conflicts
  238. await self.delete_setting(setting_id)
  239. # Create new setting via POST
  240. payload = {
  241. "type": preset_type,
  242. "name": updated_name,
  243. "version": current.get("version", "2.0.0.0"),
  244. "base_id": current.get("base_id", ""),
  245. "setting": updated_setting,
  246. }
  247. response = await self._client.post(
  248. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  249. )
  250. data = response.json()
  251. if response.status_code == 200:
  252. return data
  253. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  254. raise BambuCloudError(f"Failed to update setting: {error_msg}")
  255. except httpx.RequestError as e:
  256. raise BambuCloudError(f"Request failed: {e}")
  257. async def delete_setting(self, setting_id: str) -> dict:
  258. """
  259. Delete a slicer preset/setting.
  260. Args:
  261. setting_id: ID of the preset to delete
  262. Returns:
  263. Deletion confirmation
  264. """
  265. if not self.is_authenticated:
  266. raise BambuCloudAuthError("Not authenticated")
  267. try:
  268. response = await self._client.delete(
  269. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  270. )
  271. if response.status_code in (200, 204):
  272. return {"success": True, "message": "Setting deleted"}
  273. data = response.json() if response.content else {}
  274. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  275. raise BambuCloudError(f"Failed to delete setting: {error_msg}")
  276. except httpx.RequestError as e:
  277. raise BambuCloudError(f"Request failed: {e}")
  278. async def get_devices(self) -> dict:
  279. """Get list of bound devices."""
  280. if not self.is_authenticated:
  281. raise BambuCloudAuthError("Not authenticated")
  282. try:
  283. response = await self._client.get(
  284. f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
  285. )
  286. if response.status_code == 200:
  287. return response.json()
  288. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  289. except httpx.RequestError as e:
  290. raise BambuCloudError(f"Request failed: {e}")
  291. async def close(self):
  292. """Close the HTTP client."""
  293. await self._client.aclose()
  294. # Singleton instance
  295. _cloud_service: BambuCloudService | None = None
  296. def get_cloud_service() -> BambuCloudService:
  297. """Get the singleton cloud service instance."""
  298. global _cloud_service
  299. if _cloud_service is None:
  300. _cloud_service = BambuCloudService()
  301. return _cloud_service