bambu_cloud.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. """
  2. Bambu Lab Cloud API Service
  3. Handles authentication and profile management with Bambu Lab's cloud services.
  4. """
  5. import hashlib
  6. import logging
  7. import time
  8. from collections.abc import Awaitable, Callable
  9. from datetime import datetime, timezone
  10. import httpx
  11. logger = logging.getLogger(__name__)
  12. BAMBU_API_BASE = "https://api.bambulab.com"
  13. BAMBU_API_BASE_CN = "https://api.bambulab.cn"
  14. # How long a "Bambu still accepts this token" answer is trusted before we ask
  15. # again. ``/cloud/status`` is polled by several components, so validating on
  16. # every call would put a Bambu round-trip behind every settings render; a token
  17. # does not expire on a five-minute boundary, so caching that long is free.
  18. _VALIDATION_TTL_SECONDS = 300
  19. # token digest -> (monotonic deadline, accepted?). Keyed by digest so a token
  20. # never sits in a process-wide dict in the clear.
  21. _validation_cache: dict[str, tuple[float, bool]] = {}
  22. def _token_digest(token: str) -> str:
  23. return hashlib.sha256(token.encode("utf-8")).hexdigest()
  24. def invalidate_validation_cache(token: str | None = None) -> None:
  25. """Drop cached validation verdicts.
  26. Called on login/logout so a fresh token isn't judged by the previous one's
  27. cached verdict, and so a re-login clears a cached rejection immediately
  28. rather than leaving the user staring at "sign-in expired" for five minutes.
  29. """
  30. if token is None:
  31. _validation_cache.clear()
  32. else:
  33. _validation_cache.pop(_token_digest(token), None)
  34. # Client identity sent to Bambu Lab's cloud services. We identify honestly as
  35. # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
  36. # distinguish our traffic from impersonators. This is the opposite of what the
  37. # OrcaSlicer fork was called out for in the May 2026 Bambu Lab blog post
  38. # ("Setting the record straight on cloud access and community"): we do not
  39. # introduce ourselves as official Bambu Studio.
  40. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  41. # Cloudflare protection on Bambu Lab's edge intermittently returns interstitials /
  42. # challenges instead of the JSON the API normally produces (issue #1575). The
  43. # parse error that results is opaque — these helpers detect the CF markers so
  44. # we can surface an actionable message instead of "Invalid response from Bambu Cloud".
  45. _CF_INTERSTITIAL_USER_MESSAGE = (
  46. "Bambu Cloud is temporarily blocking automated requests from your network. "
  47. "This is a Cloudflare protection on Bambu Lab's side, not a Bambuddy issue. "
  48. "Please wait a few minutes and try again. If it persists, signing in to "
  49. "bambulab.com once from a browser on the same network usually clears the "
  50. "challenge."
  51. )
  52. def _detect_cloudflare_challenge(response) -> str | None:
  53. """Return a user-actionable message when the response is a Cloudflare
  54. challenge / mitigation page instead of the JSON the API normally returns.
  55. Triggers on any of:
  56. - body contains "Just a moment..." (CF interactive challenge title)
  57. - body contains "challenges.cloudflare.com" (CF turnstile widget src)
  58. - HTTP 403 with a "cf-mitigated" response header (CF blocked)
  59. - HTTP 503 with a "cf-ray" response header (CF Under Attack mode)
  60. Returns None when the response doesn't look like a CF challenge — callers
  61. fall through to their existing error path.
  62. """
  63. try:
  64. body = response.text or ""
  65. except Exception:
  66. body = ""
  67. if "Just a moment..." in body or "challenges.cloudflare.com" in body:
  68. return _CF_INTERSTITIAL_USER_MESSAGE
  69. try:
  70. status = int(getattr(response, "status_code", 0) or 0)
  71. except (TypeError, ValueError):
  72. status = 0
  73. headers = getattr(response, "headers", {}) or {}
  74. if status == 403 and "cf-mitigated" in headers:
  75. return _CF_INTERSTITIAL_USER_MESSAGE
  76. if status == 503 and "cf-ray" in headers:
  77. return _CF_INTERSTITIAL_USER_MESSAGE
  78. return None
  79. # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
  80. # for the list, the singular GET/DELETE for a specific preset by setting_id, and
  81. # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
  82. # format Bambu Studio releases use. Without it the API returns HTTP 400
  83. # "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return
  84. # HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value
  85. # within that format — it doesn't validate against a release manifest. We
  86. # therefore use a neutral "1.0.0.0" placeholder that does not impersonate any
  87. # real Bambu Studio release. Our client identity is in the User-Agent header.
  88. _SLICER_API_VERSION = "1.0.0.0"
  89. class BambuCloudError(Exception):
  90. """Base exception for Bambu Cloud errors.
  91. ``status_code`` carries the upstream HTTP status when the failure came from
  92. a response rather than from the transport, so callers can tell an expected
  93. "this preset isn't in the catalog" 400 apart from an expired token or a
  94. cloud outage. It stays ``None`` for connection-level failures.
  95. """
  96. def __init__(self, message: str, *, status_code: int | None = None):
  97. super().__init__(message)
  98. self.status_code = status_code
  99. class BambuCloudAuthError(BambuCloudError):
  100. """Authentication related errors."""
  101. pass
  102. _shared_http_client: httpx.AsyncClient | None = None
  103. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  104. """Register an app-scoped ``httpx.AsyncClient`` so per-request
  105. ``BambuCloudService`` instances can reuse its connection pool.
  106. Pass ``None`` during shutdown to unregister. The service only holds a
  107. reference (never closes a client it does not own), so region + token
  108. state still stays per-request — this only shares the transport pool.
  109. """
  110. global _shared_http_client
  111. _shared_http_client = client
  112. class BambuCloudService:
  113. """Service for interacting with Bambu Lab Cloud API."""
  114. def __init__(
  115. self,
  116. region: str = "global",
  117. client: httpx.AsyncClient | None = None,
  118. on_auth_failure: Callable[[], Awaitable[None]] | None = None,
  119. ):
  120. self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
  121. self.access_token: str | None = None
  122. self.refresh_token: str | None = None
  123. self.token_expiry: datetime | None = None
  124. # Fired once when Bambu answers 401 to a call we made with a stored
  125. # token — the credential is dead and the caller wants to record that.
  126. # ``build_authenticated_cloud`` wires this to the persisted flag, so
  127. # every route that builds a service through it gets invalidation for
  128. # free rather than each one having to notice 401s for itself.
  129. self._on_auth_failure = on_auth_failure
  130. self._auth_failure_reported = False
  131. # Prefer an explicitly-injected client (tests), else fall back to the
  132. # app-scoped shared client (production), and finally create our own so
  133. # scripts / tests that skip the lifespan still get a working service.
  134. if client is not None:
  135. self._client = client
  136. self._owns_client = False
  137. elif _shared_http_client is not None:
  138. self._client = _shared_http_client
  139. self._owns_client = False
  140. else:
  141. self._client = httpx.AsyncClient(timeout=30.0)
  142. self._owns_client = True
  143. @property
  144. def is_authenticated(self) -> bool:
  145. """Whether a credential is *loaded* — NOT whether Bambu accepts it.
  146. Bambu's access token is opaque (no JWT claims to read an expiry out
  147. of), so the only authority on whether it still works is Bambu. This
  148. used to pretend otherwise: ``set_token`` stamped ``token_expiry =
  149. now + 30 days`` every time a stored token was loaded, which made the
  150. expiry check reset on every request and this property incapable of
  151. ever returning False. The UI reported "connected" indefinitely while
  152. every cloud call 401'd (#2562 follow-up).
  153. ``token_expiry`` is now only set when we genuinely know it. Callers
  154. that need to know the token still *works* must ask Bambu — see
  155. :meth:`validate_token` — or react to the 401 that surfaces.
  156. """
  157. if not self.access_token:
  158. return False
  159. return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
  160. async def _note_response(self, response: httpx.Response) -> None:
  161. """Record a 401 from Bambu as "this stored credential is dead".
  162. Bambu answers an expired/revoked token with 401 and a body of
  163. ``{"code":4,"error":"Please login.","message":""}``. Reported at most
  164. once per service instance so a route that makes several calls doesn't
  165. write the flag several times.
  166. """
  167. if response.status_code != 401 or self._on_auth_failure is None or self._auth_failure_reported:
  168. return
  169. self._auth_failure_reported = True
  170. if self.access_token:
  171. _validation_cache[_token_digest(self.access_token)] = (
  172. time.monotonic() + _VALIDATION_TTL_SECONDS,
  173. False,
  174. )
  175. try:
  176. await self._on_auth_failure()
  177. except Exception:
  178. # Recording the failure is best-effort — the caller still needs the
  179. # real error (a 401) rather than a bookkeeping exception on top.
  180. logger.exception("Failed to record Bambu Cloud auth failure")
  181. async def validate_token(self) -> bool | None:
  182. """Ask Bambu whether the loaded token is still accepted.
  183. ``True`` accepted, ``False`` rejected (401), ``None`` unknown — Bambu
  184. was unreachable or answered 5xx.
  185. ``None`` must never be treated as "invalid": a Bambu outage or a
  186. Cloudflare interstitial would otherwise sign every user out of a
  187. perfectly good session. Callers report their last known state instead.
  188. """
  189. if not self.access_token:
  190. return False
  191. digest = _token_digest(self.access_token)
  192. cached = _validation_cache.get(digest)
  193. if cached and cached[0] > time.monotonic():
  194. return cached[1]
  195. try:
  196. response = await self._client.get(
  197. f"{self.base_url}/v1/design-user-service/my/preference",
  198. headers=self._get_headers(),
  199. timeout=15.0,
  200. )
  201. except httpx.HTTPError as exc:
  202. logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
  203. return None
  204. if response.status_code == 401:
  205. await self._note_response(response)
  206. return False
  207. if response.status_code >= 500:
  208. logger.info(
  209. "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
  210. )
  211. return None
  212. if response.status_code != 200:
  213. # 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
  214. # itself was not rejected, so don't declare it dead.
  215. logger.info(
  216. "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
  217. )
  218. return None
  219. _validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
  220. return True
  221. def _get_headers(self) -> dict:
  222. """Get headers for authenticated requests."""
  223. headers = {
  224. "Content-Type": "application/json",
  225. "User-Agent": _USER_AGENT,
  226. }
  227. if self.access_token:
  228. headers["Authorization"] = f"Bearer {self.access_token}"
  229. return headers
  230. async def login_request(self, email: str, password: str) -> dict:
  231. """
  232. Initiate login - this will trigger either email verification or TOTP prompt.
  233. Returns dict with login status, verification type, and tfaKey if needed.
  234. """
  235. try:
  236. response = await self._client.post(
  237. f"{self.base_url}/v1/user-service/user/login",
  238. headers={"Content-Type": "application/json"},
  239. json={
  240. "account": email,
  241. "password": password,
  242. },
  243. )
  244. try:
  245. data = response.json()
  246. except Exception as json_err:
  247. logger.error("Failed to parse login response: %s, body: %s", json_err, response.text[:500])
  248. cf_message = _detect_cloudflare_challenge(response)
  249. return {
  250. "success": False,
  251. "needs_verification": False,
  252. "message": cf_message or "Invalid response from Bambu Cloud",
  253. }
  254. logger.debug(
  255. f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
  256. )
  257. if response.status_code == 200:
  258. login_type = data.get("loginType")
  259. tfa_key = data.get("tfaKey")
  260. # TOTP authentication required
  261. if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
  262. return {
  263. "success": False,
  264. "needs_verification": True,
  265. "verification_type": "totp",
  266. "tfa_key": tfa_key,
  267. "message": "Enter the code from your authenticator app",
  268. }
  269. # Email verification required
  270. if login_type == "verifyCode":
  271. return {
  272. "success": False,
  273. "needs_verification": True,
  274. "verification_type": "email",
  275. "tfa_key": None,
  276. "message": "Verification code sent to email",
  277. }
  278. # Direct login success (rare, usually needs 2FA)
  279. if "accessToken" in data:
  280. self._set_tokens(data)
  281. return {"success": True, "needs_verification": False, "message": "Login successful"}
  282. # Handle specific error codes
  283. error_msg = data.get("message") or data.get("error") or "Login failed"
  284. return {"success": False, "needs_verification": False, "message": error_msg}
  285. except Exception as e:
  286. logger.error("Login request failed: %s", e)
  287. raise BambuCloudAuthError(f"Login request failed: {e}")
  288. async def verify_code(self, email: str, code: str) -> dict:
  289. """
  290. Complete login with email verification code.
  291. """
  292. try:
  293. response = await self._client.post(
  294. f"{self.base_url}/v1/user-service/user/login",
  295. headers={"Content-Type": "application/json"},
  296. json={
  297. "account": email,
  298. "code": code,
  299. },
  300. )
  301. try:
  302. data = response.json()
  303. except Exception as json_err:
  304. logger.error("Failed to parse email-verify response: %s, body: %s", json_err, response.text[:500])
  305. cf_message = _detect_cloudflare_challenge(response)
  306. return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
  307. logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
  308. if response.status_code == 200 and "accessToken" in data:
  309. self._set_tokens(data)
  310. return {"success": True, "message": "Login successful"}
  311. return {"success": False, "message": data.get("message", "Verification failed")}
  312. except Exception as e:
  313. logger.error("Email verification failed: %s", e)
  314. raise BambuCloudAuthError(f"Verification failed: {e}")
  315. async def verify_totp(self, tfa_key: str, code: str) -> dict:
  316. """
  317. Complete login with TOTP code from authenticator app.
  318. Args:
  319. tfa_key: The tfaKey returned from initial login request
  320. code: 6-digit TOTP code from authenticator app
  321. """
  322. try:
  323. # TFA endpoint is on bambulab.com, NOT api.bambulab.com.
  324. # We previously sent a Chrome User-Agent plus Origin/Referer headers
  325. # under the assumption Cloudflare would block bot-identified
  326. # requests. Verified 2026-05-12 via curl that the endpoint accepts
  327. # honest "Bambuddy/X.Y.Z" identification cleanly (HTTP 400 with the
  328. # expected application-level "Login failed" JSON, no Cloudflare
  329. # interstitial). Browser-impersonation removed to stay clearly on
  330. # the right side of Bambu Lab's "no falsified client identity" line.
  331. tfa_url = "https://bambulab.com/api/sign-in/tfa"
  332. if "bambulab.cn" in self.base_url:
  333. tfa_url = "https://bambulab.cn/api/sign-in/tfa"
  334. response = await self._client.post(
  335. tfa_url,
  336. headers={
  337. "Content-Type": "application/json",
  338. "User-Agent": _USER_AGENT,
  339. "Accept": "application/json",
  340. },
  341. json={
  342. "tfaKey": tfa_key,
  343. "tfaCode": code,
  344. },
  345. )
  346. logger.debug(
  347. f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
  348. )
  349. # Handle empty response
  350. if not response.text or not response.text.strip():
  351. logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
  352. return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
  353. try:
  354. data = response.json()
  355. except Exception as json_err:
  356. logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
  357. cf_message = _detect_cloudflare_challenge(response)
  358. return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
  359. # Token might be in accessToken, token field, or cookies
  360. access_token = data.get("accessToken") or data.get("token")
  361. # Also check cookies for token
  362. if not access_token:
  363. for cookie in response.cookies:
  364. if "token" in cookie.lower():
  365. access_token = response.cookies.get(cookie)
  366. break
  367. if response.status_code == 200 and access_token:
  368. self.access_token = access_token
  369. self.refresh_token = data.get("refreshToken")
  370. # Expiry left unset: Bambu does not tell us when the token dies
  371. # and the token is opaque, so any value here would be invented.
  372. self.token_expiry = None
  373. invalidate_validation_cache(access_token)
  374. return {"success": True, "message": "Login successful"}
  375. # Provide helpful error message
  376. error_msg = data.get("message", "")
  377. if "expired" in error_msg.lower():
  378. return {"success": False, "message": "TOTP session expired. Please try logging in again."}
  379. if not error_msg:
  380. error_msg = f"TOTP verification failed (status {response.status_code})"
  381. return {"success": False, "message": error_msg}
  382. except Exception as e:
  383. logger.error("TOTP verification failed: %s", e)
  384. # Return error instead of raising - don't trigger 401/500
  385. return {"success": False, "message": f"TOTP verification error: {e}"}
  386. def _set_tokens(self, data: dict):
  387. """Set tokens from a login response.
  388. No expiry is recorded. Bambu's login response carries no expiry, and
  389. the access token is opaque, so the old ``now + 30 days`` was a guess
  390. that outlived its own accuracy — see :attr:`is_authenticated`.
  391. """
  392. self.access_token = data.get("accessToken")
  393. self.refresh_token = data.get("refreshToken")
  394. self.token_expiry = None
  395. if self.access_token:
  396. invalidate_validation_cache(self.access_token)
  397. def set_token(self, access_token: str):
  398. """Load a stored access token.
  399. This used to stamp ``token_expiry = now + 30 days`` — re-derived from
  400. *now* on every request, for a token of entirely unknown age. That made
  401. ``is_authenticated`` a permanent True and is why Bambuddy went on
  402. reporting "connected" long after Bambu had stopped accepting the token.
  403. A stored token's remaining life is unknowable from the token alone, so
  404. we record no expiry and let Bambu be the authority.
  405. """
  406. self.access_token = access_token
  407. self.token_expiry = None
  408. def logout(self):
  409. """Clear authentication state."""
  410. self.access_token = None
  411. self.refresh_token = None
  412. self.token_expiry = None
  413. async def get_user_profile(self) -> dict:
  414. """Get user profile information."""
  415. if not self.is_authenticated:
  416. raise BambuCloudAuthError("Not authenticated")
  417. try:
  418. response = await self._client.get(
  419. f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
  420. )
  421. if response.status_code == 200:
  422. return response.json()
  423. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  424. except httpx.RequestError as e:
  425. raise BambuCloudError(f"Request failed: {e}")
  426. async def get_slicer_settings(self, version: str = _SLICER_API_VERSION) -> dict:
  427. """
  428. Get all slicer settings (filament, printer, process presets).
  429. Args:
  430. version: Slicer version string. Bambu's API requires the XX.YY.ZZ.WW
  431. format but does not validate against a release manifest — we
  432. default to the neutral _SLICER_API_VERSION placeholder so we
  433. never claim to be a specific Bambu Studio build. Callers should
  434. normally use the default.
  435. """
  436. if not self.is_authenticated:
  437. raise BambuCloudAuthError("Not authenticated")
  438. try:
  439. response = await self._client.get(
  440. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  441. headers=self._get_headers(),
  442. params={"version": version},
  443. )
  444. data = response.json()
  445. await self._note_response(response)
  446. if response.status_code == 200:
  447. return data
  448. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  449. except httpx.RequestError as e:
  450. raise BambuCloudError(f"Request failed: {e}")
  451. async def get_setting_detail(self, setting_id: str) -> dict:
  452. """Get detailed information for a specific setting/preset."""
  453. if not self.is_authenticated:
  454. raise BambuCloudAuthError("Not authenticated")
  455. try:
  456. response = await self._client.get(
  457. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
  458. headers=self._get_headers(),
  459. params={"version": _SLICER_API_VERSION},
  460. )
  461. await self._note_response(response)
  462. if response.status_code == 200:
  463. return response.json()
  464. # Include body so a future contract change is self-diagnostic from logs.
  465. body = (response.text or "")[:200]
  466. raise BambuCloudError(
  467. f"Failed to get setting detail: {response.status_code} {body}",
  468. status_code=response.status_code,
  469. )
  470. except httpx.RequestError as e:
  471. raise BambuCloudError(f"Request failed: {e}")
  472. async def create_setting(
  473. self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0"
  474. ) -> dict:
  475. """
  476. Create a new slicer preset/setting.
  477. Args:
  478. preset_type: Type of preset - "filament", "print", or "printer"
  479. name: Display name for the preset
  480. base_id: Base preset ID to inherit from (e.g., "GFSA00")
  481. setting: Dict of setting key-value pairs (only modified values from base)
  482. version: Version string for the preset (default: "2.0.0.0")
  483. Returns:
  484. Created preset data including the new setting_id
  485. """
  486. if not self.is_authenticated:
  487. raise BambuCloudAuthError("Not authenticated")
  488. try:
  489. # Add timestamp if not present
  490. import time
  491. if "updated_time" not in setting:
  492. setting["updated_time"] = str(int(time.time()))
  493. payload = {
  494. "type": preset_type,
  495. "name": name,
  496. "version": version,
  497. "base_id": base_id,
  498. "setting": setting,
  499. }
  500. response = await self._client.post(
  501. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  502. )
  503. data = response.json()
  504. await self._note_response(response)
  505. if response.status_code in (200, 201):
  506. return data
  507. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  508. raise BambuCloudError(f"Failed to create setting: {error_msg}")
  509. except httpx.RequestError as e:
  510. raise BambuCloudError(f"Request failed: {e}")
  511. async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
  512. """
  513. Update an existing slicer preset/setting.
  514. Note: Bambu Cloud API doesn't support true updates. Instead, we:
  515. 1. Fetch the current setting metadata (type, base_id, version)
  516. 2. Use the provided settings as the new complete settings (NOT merged)
  517. 3. Delete the old setting first (to avoid name conflicts)
  518. 4. Create a new setting via POST
  519. Args:
  520. setting_id: ID of the preset to update
  521. name: New display name (optional)
  522. setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
  523. Returns:
  524. Updated preset data with new setting_id
  525. """
  526. if not self.is_authenticated:
  527. raise BambuCloudAuthError("Not authenticated")
  528. try:
  529. # Fetch current setting to get metadata (type, base_id, version)
  530. current = await self.get_setting_detail(setting_id)
  531. preset_type = current.get("type", "filament")
  532. # Use provided settings directly (complete replacement, not merge)
  533. # This allows the frontend to edit the full settings JSON
  534. if setting is not None:
  535. updated_setting = setting.copy()
  536. else:
  537. updated_setting = current.get("setting", {}).copy()
  538. # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
  539. # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
  540. settings_id_key = {
  541. "filament": "filament_settings_id",
  542. "print": "print_settings_id",
  543. "printer": "printer_settings_id",
  544. }.get(preset_type, "filament_settings_id")
  545. settings_id_value = updated_setting.get(settings_id_key, "")
  546. if settings_id_value:
  547. # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
  548. updated_name = settings_id_value.strip('"')
  549. elif name is not None:
  550. updated_name = name
  551. else:
  552. updated_name = current.get("name", "Untitled")
  553. # Update the timestamp
  554. import time
  555. updated_setting["updated_time"] = str(int(time.time()))
  556. # Ensure settings_id field matches the name
  557. updated_setting[settings_id_key] = f'"{updated_name}"'
  558. # Delete the old setting FIRST to avoid name conflicts
  559. await self.delete_setting(setting_id)
  560. # Create new setting via POST
  561. payload = {
  562. "type": preset_type,
  563. "name": updated_name,
  564. "version": current.get("version", "2.0.0.0"),
  565. "base_id": current.get("base_id", ""),
  566. "setting": updated_setting,
  567. }
  568. response = await self._client.post(
  569. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  570. )
  571. data = response.json()
  572. await self._note_response(response)
  573. if response.status_code == 200:
  574. return data
  575. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  576. raise BambuCloudError(f"Failed to update setting: {error_msg}")
  577. except httpx.RequestError as e:
  578. raise BambuCloudError(f"Request failed: {e}")
  579. async def delete_setting(self, setting_id: str) -> dict:
  580. """
  581. Delete a slicer preset/setting.
  582. Args:
  583. setting_id: ID of the preset to delete
  584. Returns:
  585. Deletion confirmation
  586. """
  587. if not self.is_authenticated:
  588. raise BambuCloudAuthError("Not authenticated")
  589. try:
  590. response = await self._client.delete(
  591. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
  592. headers=self._get_headers(),
  593. params={"version": _SLICER_API_VERSION},
  594. )
  595. await self._note_response(response)
  596. if response.status_code in (200, 204):
  597. return {"success": True, "message": "Setting deleted"}
  598. data = response.json() if response.content else {}
  599. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  600. raise BambuCloudError(f"Failed to delete setting: {error_msg}")
  601. except httpx.RequestError as e:
  602. raise BambuCloudError(f"Request failed: {e}")
  603. async def get_devices(self) -> dict:
  604. """Get list of bound devices."""
  605. if not self.is_authenticated:
  606. raise BambuCloudAuthError("Not authenticated")
  607. try:
  608. response = await self._client.get(
  609. f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
  610. )
  611. await self._note_response(response)
  612. if response.status_code == 200:
  613. return response.json()
  614. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  615. except httpx.RequestError as e:
  616. raise BambuCloudError(f"Request failed: {e}")
  617. async def get_firmware_version(self, device_id: str) -> dict:
  618. """
  619. Get firmware version info for a device.
  620. Returns dict with:
  621. - current_version: Installed firmware version
  622. - latest_version: Latest available firmware version
  623. - update_available: Boolean indicating if update is available
  624. - release_notes: Release notes for latest version
  625. """
  626. if not self.is_authenticated:
  627. raise BambuCloudAuthError("Not authenticated")
  628. try:
  629. response = await self._client.get(
  630. f"{self.base_url}/v1/iot-service/api/user/device/version",
  631. headers=self._get_headers(),
  632. params={"device_id": device_id},
  633. )
  634. await self._note_response(response)
  635. if response.status_code == 200:
  636. data = response.json()
  637. # API wraps response in 'data' field
  638. return data.get("data", data)
  639. raise BambuCloudError(f"Failed to get firmware version: {response.status_code}")
  640. except httpx.RequestError as e:
  641. raise BambuCloudError(f"Request failed: {e}")
  642. async def close(self):
  643. """Close the HTTP client we own. No-op when sharing an app-scoped client."""
  644. if self._owns_client:
  645. await self._client.aclose()
  646. # Previously this module exposed a process-wide ``_cloud_service`` singleton
  647. # via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked
  648. # region and token state across users (a China-region login would pin the
  649. # singleton to api.bambulab.cn until the next explicit reset), so the singleton
  650. # has been removed. Callers should construct a per-request
  651. # ``BambuCloudService(region=...)`` from the stored region and ``await
  652. # cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``
  653. # for the standard pattern.