bambu_cloud.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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, timezone
  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. # Client identity sent to Bambu Lab's cloud services. We identify honestly as
  12. # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
  13. # distinguish our traffic from impersonators. This is the opposite of what the
  14. # OrcaSlicer fork was called out for in the May 2026 Bambu Lab blog post
  15. # ("Setting the record straight on cloud access and community"): we do not
  16. # introduce ourselves as official Bambu Studio.
  17. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  18. # The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
  19. # parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
  20. # API returns HTTP 400 "field 'version' is not set"; non-matching formats like
  21. # "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's
  22. # server accepts ANY value within that format — it doesn't validate against a
  23. # release manifest. We therefore use a neutral "1.0.0.0" placeholder that does
  24. # not impersonate any real Bambu Studio release. Our client identity is in the
  25. # User-Agent header.
  26. _SLICER_API_VERSION = "1.0.0.0"
  27. class BambuCloudError(Exception):
  28. """Base exception for Bambu Cloud errors."""
  29. pass
  30. class BambuCloudAuthError(BambuCloudError):
  31. """Authentication related errors."""
  32. pass
  33. _shared_http_client: httpx.AsyncClient | None = None
  34. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  35. """Register an app-scoped ``httpx.AsyncClient`` so per-request
  36. ``BambuCloudService`` instances can reuse its connection pool.
  37. Pass ``None`` during shutdown to unregister. The service only holds a
  38. reference (never closes a client it does not own), so region + token
  39. state still stays per-request — this only shares the transport pool.
  40. """
  41. global _shared_http_client
  42. _shared_http_client = client
  43. class BambuCloudService:
  44. """Service for interacting with Bambu Lab Cloud API."""
  45. def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
  46. self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
  47. self.access_token: str | None = None
  48. self.refresh_token: str | None = None
  49. self.token_expiry: datetime | None = None
  50. # Prefer an explicitly-injected client (tests), else fall back to the
  51. # app-scoped shared client (production), and finally create our own so
  52. # scripts / tests that skip the lifespan still get a working service.
  53. if client is not None:
  54. self._client = client
  55. self._owns_client = False
  56. elif _shared_http_client is not None:
  57. self._client = _shared_http_client
  58. self._owns_client = False
  59. else:
  60. self._client = httpx.AsyncClient(timeout=30.0)
  61. self._owns_client = True
  62. @property
  63. def is_authenticated(self) -> bool:
  64. """Check if we have a valid token."""
  65. if not self.access_token:
  66. return False
  67. return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
  68. def _get_headers(self) -> dict:
  69. """Get headers for authenticated requests."""
  70. headers = {
  71. "Content-Type": "application/json",
  72. "User-Agent": _USER_AGENT,
  73. }
  74. if self.access_token:
  75. headers["Authorization"] = f"Bearer {self.access_token}"
  76. return headers
  77. async def login_request(self, email: str, password: str) -> dict:
  78. """
  79. Initiate login - this will trigger either email verification or TOTP prompt.
  80. Returns dict with login status, verification type, and tfaKey if needed.
  81. """
  82. try:
  83. response = await self._client.post(
  84. f"{self.base_url}/v1/user-service/user/login",
  85. headers={"Content-Type": "application/json"},
  86. json={
  87. "account": email,
  88. "password": password,
  89. },
  90. )
  91. data = response.json()
  92. logger.debug(
  93. f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
  94. )
  95. if response.status_code == 200:
  96. login_type = data.get("loginType")
  97. tfa_key = data.get("tfaKey")
  98. # TOTP authentication required
  99. if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
  100. return {
  101. "success": False,
  102. "needs_verification": True,
  103. "verification_type": "totp",
  104. "tfa_key": tfa_key,
  105. "message": "Enter the code from your authenticator app",
  106. }
  107. # Email verification required
  108. if login_type == "verifyCode":
  109. return {
  110. "success": False,
  111. "needs_verification": True,
  112. "verification_type": "email",
  113. "tfa_key": None,
  114. "message": "Verification code sent to email",
  115. }
  116. # Direct login success (rare, usually needs 2FA)
  117. if "accessToken" in data:
  118. self._set_tokens(data)
  119. return {"success": True, "needs_verification": False, "message": "Login successful"}
  120. # Handle specific error codes
  121. error_msg = data.get("message") or data.get("error") or "Login failed"
  122. return {"success": False, "needs_verification": False, "message": error_msg}
  123. except Exception as e:
  124. logger.error("Login request failed: %s", e)
  125. raise BambuCloudAuthError(f"Login request failed: {e}")
  126. async def verify_code(self, email: str, code: str) -> dict:
  127. """
  128. Complete login with email verification code.
  129. """
  130. try:
  131. response = await self._client.post(
  132. f"{self.base_url}/v1/user-service/user/login",
  133. headers={"Content-Type": "application/json"},
  134. json={
  135. "account": email,
  136. "code": code,
  137. },
  138. )
  139. data = response.json()
  140. logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
  141. if response.status_code == 200 and "accessToken" in data:
  142. self._set_tokens(data)
  143. return {"success": True, "message": "Login successful"}
  144. return {"success": False, "message": data.get("message", "Verification failed")}
  145. except Exception as e:
  146. logger.error("Email verification failed: %s", e)
  147. raise BambuCloudAuthError(f"Verification failed: {e}")
  148. async def verify_totp(self, tfa_key: str, code: str) -> dict:
  149. """
  150. Complete login with TOTP code from authenticator app.
  151. Args:
  152. tfa_key: The tfaKey returned from initial login request
  153. code: 6-digit TOTP code from authenticator app
  154. """
  155. try:
  156. # TFA endpoint is on bambulab.com, NOT api.bambulab.com.
  157. # We previously sent a Chrome User-Agent plus Origin/Referer headers
  158. # under the assumption Cloudflare would block bot-identified
  159. # requests. Verified 2026-05-12 via curl that the endpoint accepts
  160. # honest "Bambuddy/X.Y.Z" identification cleanly (HTTP 400 with the
  161. # expected application-level "Login failed" JSON, no Cloudflare
  162. # interstitial). Browser-impersonation removed to stay clearly on
  163. # the right side of Bambu Lab's "no falsified client identity" line.
  164. tfa_url = "https://bambulab.com/api/sign-in/tfa"
  165. if "bambulab.cn" in self.base_url:
  166. tfa_url = "https://bambulab.cn/api/sign-in/tfa"
  167. response = await self._client.post(
  168. tfa_url,
  169. headers={
  170. "Content-Type": "application/json",
  171. "User-Agent": _USER_AGENT,
  172. "Accept": "application/json",
  173. },
  174. json={
  175. "tfaKey": tfa_key,
  176. "tfaCode": code,
  177. },
  178. )
  179. logger.debug(
  180. f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
  181. )
  182. # Handle empty response
  183. if not response.text or not response.text.strip():
  184. logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
  185. return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
  186. try:
  187. data = response.json()
  188. except Exception as json_err:
  189. logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
  190. return {"success": False, "message": "Invalid response from Bambu Cloud"}
  191. # Token might be in accessToken, token field, or cookies
  192. access_token = data.get("accessToken") or data.get("token")
  193. # Also check cookies for token
  194. if not access_token:
  195. for cookie in response.cookies:
  196. if "token" in cookie.lower():
  197. access_token = response.cookies.get(cookie)
  198. break
  199. if response.status_code == 200 and access_token:
  200. self.access_token = access_token
  201. self.refresh_token = data.get("refreshToken")
  202. from datetime import datetime, timedelta, timezone
  203. self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
  204. return {"success": True, "message": "Login successful"}
  205. # Provide helpful error message
  206. error_msg = data.get("message", "")
  207. if "expired" in error_msg.lower():
  208. return {"success": False, "message": "TOTP session expired. Please try logging in again."}
  209. if not error_msg:
  210. error_msg = f"TOTP verification failed (status {response.status_code})"
  211. return {"success": False, "message": error_msg}
  212. except Exception as e:
  213. logger.error("TOTP verification failed: %s", e)
  214. # Return error instead of raising - don't trigger 401/500
  215. return {"success": False, "message": f"TOTP verification error: {e}"}
  216. def _set_tokens(self, data: dict):
  217. """Set tokens from login response."""
  218. self.access_token = data.get("accessToken")
  219. self.refresh_token = data.get("refreshToken")
  220. # Token typically valid for ~3 months, but we'll refresh more often
  221. self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
  222. def set_token(self, access_token: str):
  223. """Set access token directly (for stored tokens)."""
  224. self.access_token = access_token
  225. self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
  226. def logout(self):
  227. """Clear authentication state."""
  228. self.access_token = None
  229. self.refresh_token = None
  230. self.token_expiry = None
  231. async def get_user_profile(self) -> dict:
  232. """Get user profile information."""
  233. if not self.is_authenticated:
  234. raise BambuCloudAuthError("Not authenticated")
  235. try:
  236. response = await self._client.get(
  237. f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
  238. )
  239. if response.status_code == 200:
  240. return response.json()
  241. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  242. except httpx.RequestError as e:
  243. raise BambuCloudError(f"Request failed: {e}")
  244. async def get_slicer_settings(self, version: str = _SLICER_API_VERSION) -> dict:
  245. """
  246. Get all slicer settings (filament, printer, process presets).
  247. Args:
  248. version: Slicer version string. Bambu's API requires the XX.YY.ZZ.WW
  249. format but does not validate against a release manifest — we
  250. default to the neutral _SLICER_API_VERSION placeholder so we
  251. never claim to be a specific Bambu Studio build. Callers should
  252. normally use the default.
  253. """
  254. if not self.is_authenticated:
  255. raise BambuCloudAuthError("Not authenticated")
  256. try:
  257. response = await self._client.get(
  258. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  259. headers=self._get_headers(),
  260. params={"version": version},
  261. )
  262. data = response.json()
  263. if response.status_code == 200:
  264. return data
  265. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  266. except httpx.RequestError as e:
  267. raise BambuCloudError(f"Request failed: {e}")
  268. async def get_setting_detail(self, setting_id: str) -> dict:
  269. """Get detailed information for a specific setting/preset."""
  270. if not self.is_authenticated:
  271. raise BambuCloudAuthError("Not authenticated")
  272. try:
  273. response = await self._client.get(
  274. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  275. )
  276. if response.status_code == 200:
  277. return response.json()
  278. raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
  279. except httpx.RequestError as e:
  280. raise BambuCloudError(f"Request failed: {e}")
  281. async def create_setting(
  282. self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0"
  283. ) -> dict:
  284. """
  285. Create a new slicer preset/setting.
  286. Args:
  287. preset_type: Type of preset - "filament", "print", or "printer"
  288. name: Display name for the preset
  289. base_id: Base preset ID to inherit from (e.g., "GFSA00")
  290. setting: Dict of setting key-value pairs (only modified values from base)
  291. version: Version string for the preset (default: "2.0.0.0")
  292. Returns:
  293. Created preset data including the new setting_id
  294. """
  295. if not self.is_authenticated:
  296. raise BambuCloudAuthError("Not authenticated")
  297. try:
  298. # Add timestamp if not present
  299. import time
  300. if "updated_time" not in setting:
  301. setting["updated_time"] = str(int(time.time()))
  302. payload = {
  303. "type": preset_type,
  304. "name": name,
  305. "version": version,
  306. "base_id": base_id,
  307. "setting": setting,
  308. }
  309. response = await self._client.post(
  310. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  311. )
  312. data = response.json()
  313. if response.status_code in (200, 201):
  314. return data
  315. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  316. raise BambuCloudError(f"Failed to create setting: {error_msg}")
  317. except httpx.RequestError as e:
  318. raise BambuCloudError(f"Request failed: {e}")
  319. async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
  320. """
  321. Update an existing slicer preset/setting.
  322. Note: Bambu Cloud API doesn't support true updates. Instead, we:
  323. 1. Fetch the current setting metadata (type, base_id, version)
  324. 2. Use the provided settings as the new complete settings (NOT merged)
  325. 3. Delete the old setting first (to avoid name conflicts)
  326. 4. Create a new setting via POST
  327. Args:
  328. setting_id: ID of the preset to update
  329. name: New display name (optional)
  330. setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
  331. Returns:
  332. Updated preset data with new setting_id
  333. """
  334. if not self.is_authenticated:
  335. raise BambuCloudAuthError("Not authenticated")
  336. try:
  337. # Fetch current setting to get metadata (type, base_id, version)
  338. current = await self.get_setting_detail(setting_id)
  339. preset_type = current.get("type", "filament")
  340. # Use provided settings directly (complete replacement, not merge)
  341. # This allows the frontend to edit the full settings JSON
  342. if setting is not None:
  343. updated_setting = setting.copy()
  344. else:
  345. updated_setting = current.get("setting", {}).copy()
  346. # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
  347. # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
  348. settings_id_key = {
  349. "filament": "filament_settings_id",
  350. "print": "print_settings_id",
  351. "printer": "printer_settings_id",
  352. }.get(preset_type, "filament_settings_id")
  353. settings_id_value = updated_setting.get(settings_id_key, "")
  354. if settings_id_value:
  355. # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
  356. updated_name = settings_id_value.strip('"')
  357. elif name is not None:
  358. updated_name = name
  359. else:
  360. updated_name = current.get("name", "Untitled")
  361. # Update the timestamp
  362. import time
  363. updated_setting["updated_time"] = str(int(time.time()))
  364. # Ensure settings_id field matches the name
  365. updated_setting[settings_id_key] = f'"{updated_name}"'
  366. # Delete the old setting FIRST to avoid name conflicts
  367. await self.delete_setting(setting_id)
  368. # Create new setting via POST
  369. payload = {
  370. "type": preset_type,
  371. "name": updated_name,
  372. "version": current.get("version", "2.0.0.0"),
  373. "base_id": current.get("base_id", ""),
  374. "setting": updated_setting,
  375. }
  376. response = await self._client.post(
  377. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  378. )
  379. data = response.json()
  380. if response.status_code == 200:
  381. return data
  382. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  383. raise BambuCloudError(f"Failed to update setting: {error_msg}")
  384. except httpx.RequestError as e:
  385. raise BambuCloudError(f"Request failed: {e}")
  386. async def delete_setting(self, setting_id: str) -> dict:
  387. """
  388. Delete a slicer preset/setting.
  389. Args:
  390. setting_id: ID of the preset to delete
  391. Returns:
  392. Deletion confirmation
  393. """
  394. if not self.is_authenticated:
  395. raise BambuCloudAuthError("Not authenticated")
  396. try:
  397. response = await self._client.delete(
  398. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
  399. )
  400. if response.status_code in (200, 204):
  401. return {"success": True, "message": "Setting deleted"}
  402. data = response.json() if response.content else {}
  403. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  404. raise BambuCloudError(f"Failed to delete setting: {error_msg}")
  405. except httpx.RequestError as e:
  406. raise BambuCloudError(f"Request failed: {e}")
  407. async def get_devices(self) -> dict:
  408. """Get list of bound devices."""
  409. if not self.is_authenticated:
  410. raise BambuCloudAuthError("Not authenticated")
  411. try:
  412. response = await self._client.get(
  413. f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
  414. )
  415. if response.status_code == 200:
  416. return response.json()
  417. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  418. except httpx.RequestError as e:
  419. raise BambuCloudError(f"Request failed: {e}")
  420. async def get_firmware_version(self, device_id: str) -> dict:
  421. """
  422. Get firmware version info for a device.
  423. Returns dict with:
  424. - current_version: Installed firmware version
  425. - latest_version: Latest available firmware version
  426. - update_available: Boolean indicating if update is available
  427. - release_notes: Release notes for latest version
  428. """
  429. if not self.is_authenticated:
  430. raise BambuCloudAuthError("Not authenticated")
  431. try:
  432. response = await self._client.get(
  433. f"{self.base_url}/v1/iot-service/api/user/device/version",
  434. headers=self._get_headers(),
  435. params={"device_id": device_id},
  436. )
  437. if response.status_code == 200:
  438. data = response.json()
  439. # API wraps response in 'data' field
  440. return data.get("data", data)
  441. raise BambuCloudError(f"Failed to get firmware version: {response.status_code}")
  442. except httpx.RequestError as e:
  443. raise BambuCloudError(f"Request failed: {e}")
  444. async def close(self):
  445. """Close the HTTP client we own. No-op when sharing an app-scoped client."""
  446. if self._owns_client:
  447. await self._client.aclose()
  448. # Previously this module exposed a process-wide ``_cloud_service`` singleton
  449. # via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked
  450. # region and token state across users (a China-region login would pin the
  451. # singleton to api.bambulab.cn until the next explicit reset), so the singleton
  452. # has been removed. Callers should construct a per-request
  453. # ``BambuCloudService(region=...)`` from the stored region and ``await
  454. # cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``
  455. # for the standard pattern.