bambu_cloud.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079
  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 is_expiry_401(response: httpx.Response) -> bool:
  25. """Whether a 401 is Bambu's genuine "token expired" signal.
  26. Bambu answers an expired/revoked token with ``{"code":4,"error":"Please
  27. login.","message":""}``. Not every 401 means that: individual endpoints
  28. return 401 for resource-, region- or scope-specific reasons, and a working
  29. token still draws the occasional transient 401 (Cloudflare edge, a brief
  30. backend blip). Treating *any* 401 as a dead credential signs the user out on
  31. a single stray rejection — the #2562 follow-up regression. We trust only the
  32. documented expiry body, so a benign 401 no longer nukes the whole cloud
  33. integration. An unparseable / unsigned 401 is deliberately NOT expiry.
  34. Shared by the Bambu Cloud and MakerWorld services — both carry the same
  35. token and see the same expiry body.
  36. """
  37. try:
  38. body = response.json()
  39. except Exception:
  40. return False
  41. if not isinstance(body, dict):
  42. return False
  43. if body.get("code") == 4:
  44. return True
  45. text = f"{body.get('error', '')} {body.get('message', '')}".lower()
  46. return "please login" in text
  47. def invalidate_validation_cache(token: str | None = None) -> None:
  48. """Drop cached validation verdicts.
  49. Called on login/logout so a fresh token isn't judged by the previous one's
  50. cached verdict, and so a re-login clears a cached rejection immediately
  51. rather than leaving the user staring at "sign-in expired" for five minutes.
  52. """
  53. if token is None:
  54. _validation_cache.clear()
  55. else:
  56. _validation_cache.pop(_token_digest(token), None)
  57. # Client identity sent to Bambu Lab's cloud services. We identify honestly as
  58. # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
  59. # distinguish our traffic from impersonators. This is the opposite of what the
  60. # OrcaSlicer fork was called out for in the May 2026 Bambu Lab blog post
  61. # ("Setting the record straight on cloud access and community"): we do not
  62. # introduce ourselves as official Bambu Studio.
  63. _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  64. # Cloudflare protection on Bambu Lab's edge intermittently returns interstitials /
  65. # challenges instead of the JSON the API normally produces (issue #1575). The
  66. # parse error that results is opaque — these helpers detect the CF markers so
  67. # we can surface an actionable message instead of "Invalid response from Bambu Cloud".
  68. _CF_INTERSTITIAL_USER_MESSAGE = (
  69. "Bambu Cloud is temporarily blocking automated requests from your network. "
  70. "This is a Cloudflare protection on Bambu Lab's side, not a Bambuddy issue. "
  71. "Please wait a few minutes and try again. If it persists, signing in to "
  72. "bambulab.com once from a browser on the same network usually clears the "
  73. "challenge."
  74. )
  75. def _detect_cloudflare_challenge(response) -> str | None:
  76. """Return a user-actionable message when the response is a Cloudflare
  77. challenge / mitigation page instead of the JSON the API normally returns.
  78. Triggers on any of:
  79. - body contains "Just a moment..." (CF interactive challenge title)
  80. - body contains "challenges.cloudflare.com" (CF turnstile widget src)
  81. - HTTP 403 with a "cf-mitigated" response header (CF blocked)
  82. - HTTP 503 with a "cf-ray" response header (CF Under Attack mode)
  83. Returns None when the response doesn't look like a CF challenge — callers
  84. fall through to their existing error path.
  85. """
  86. try:
  87. body = response.text or ""
  88. except Exception:
  89. body = ""
  90. if "Just a moment..." in body or "challenges.cloudflare.com" in body:
  91. return _CF_INTERSTITIAL_USER_MESSAGE
  92. try:
  93. status = int(getattr(response, "status_code", 0) or 0)
  94. except (TypeError, ValueError):
  95. status = 0
  96. headers = getattr(response, "headers", {}) or {}
  97. if status == 403 and "cf-mitigated" in headers:
  98. return _CF_INTERSTITIAL_USER_MESSAGE
  99. if status == 503 and "cf-ray" in headers:
  100. return _CF_INTERSTITIAL_USER_MESSAGE
  101. return None
  102. # Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
  103. # answers a request it has flagged with HTTP 418 and a challenge body:
  104. #
  105. # {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
  106. #
  107. # The flag is keyed to the source IP and covers api.bambulab.com as a whole:
  108. # the same 418 turns up on the login endpoint and on the design-service
  109. # endpoints MakerWorld imports use. It clears on its own after a few hours of
  110. # quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
  111. # unanswerable without a real browser, and the challenge id is of no use to us
  112. # because we have nowhere to render the widget.
  113. #
  114. # It reaches ``login_request`` as a perfectly well-formed JSON body, so
  115. # ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
  116. # generic error path then lifted Bambu's sentence out of ``error`` and showed it
  117. # as a bare toast: the reporter saw "We need you to confirm you are not a robot"
  118. # with no challenge, no explanation and nothing to click, and filed it as a
  119. # Bambuddy bug.
  120. _CAPTCHA_HTTP_STATUS = 418
  121. # Markers that identify a 418 as the CAPTCHA challenge rather than some other
  122. # refusal. ``captchaId`` is the reliable one; the wording is matched too because
  123. # Bambu has shipped the challenge under more than one phrasing.
  124. _CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
  125. CAPTCHA_USER_MESSAGE = (
  126. "Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
  127. "and there is no way to answer it from Bambuddy. Your email and password are not the "
  128. "problem. The block is tied to your public IP address and normally clears by itself within "
  129. "a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
  130. "instead' and paste a token taken from a browser session."
  131. )
  132. # How long to stop sending sign-in requests to a Bambu region after it answered
  133. # with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
  134. # seconds, which is exactly the traffic pattern that deepens the block: every
  135. # extra request is more evidence for the thing that flagged us. Five minutes is
  136. # short against the hours the block itself lasts — the point is not to wait it
  137. # out here, only to stop Bambuddy from making it worse while the user reads the
  138. # explanation.
  139. _CAPTCHA_COOLOFF_SECONDS = 300.0
  140. # API base URL -> monotonic time its cool-off expires. Keyed by base URL because
  141. # the block lives at the edge in front of one region: being challenged on
  142. # api.bambulab.com says nothing about api.bambulab.cn.
  143. _captcha_blocked_until: dict[str, float] = {}
  144. def is_captcha_challenge(response) -> bool:
  145. """Whether Bambu answered with an anti-abuse CAPTCHA challenge.
  146. Requires the 418 status *and* a challenge marker in the body, so an
  147. unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
  148. send them looking for a widget that was never there, which is the exact
  149. confusion #2790 is about. Callers that want to say something about a bare
  150. 418 must handle it themselves.
  151. Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
  152. """
  153. try:
  154. status = int(getattr(response, "status_code", 0) or 0)
  155. except (TypeError, ValueError):
  156. return False
  157. if status != _CAPTCHA_HTTP_STATUS:
  158. return False
  159. try:
  160. data = response.json()
  161. except Exception:
  162. data = None
  163. if isinstance(data, dict):
  164. # Field *names* count as well as their text: the challenge is
  165. # identified by carrying a ``captchaId`` at all, whatever it says.
  166. parts = [str(key) for key in data]
  167. parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
  168. haystack = " ".join(parts).lower()
  169. else:
  170. # Not JSON (or not an object) — fall back to the raw body so a
  171. # challenge served as HTML is still recognised rather than reported as
  172. # an unexplained failure.
  173. try:
  174. haystack = (response.text or "").lower()
  175. except Exception:
  176. return False
  177. return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
  178. def captcha_cooloff_active(base_url: str) -> bool:
  179. """Whether sign-in requests to ``base_url`` are still held back after a
  180. CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
  181. cannot grow past one entry per region."""
  182. deadline = _captcha_blocked_until.get(base_url)
  183. if deadline is None:
  184. return False
  185. if time.monotonic() >= deadline:
  186. del _captcha_blocked_until[base_url]
  187. return False
  188. return True
  189. def note_captcha_challenge(base_url: str) -> None:
  190. """Start the cool-off for ``base_url`` after a challenge was seen."""
  191. _captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
  192. # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
  193. # for the list, the singular GET/DELETE for a specific preset by setting_id, and
  194. # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
  195. # format Bambu Studio releases use. Without it the API returns HTTP 400
  196. # "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return
  197. # HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value
  198. # within that format — it doesn't validate against a release manifest. We
  199. # therefore use a neutral "1.0.0.0" placeholder that does not impersonate any
  200. # real Bambu Studio release. Our client identity is in the User-Agent header.
  201. _SLICER_API_VERSION = "1.0.0.0"
  202. class BambuCloudError(Exception):
  203. """Base exception for Bambu Cloud errors.
  204. ``status_code`` carries the upstream HTTP status when the failure came from
  205. a response rather than from the transport, so callers can tell an expected
  206. "this preset isn't in the catalog" 400 apart from an expired token or a
  207. cloud outage. It stays ``None`` for connection-level failures.
  208. """
  209. def __init__(self, message: str, *, status_code: int | None = None):
  210. super().__init__(message)
  211. self.status_code = status_code
  212. class BambuCloudAuthError(BambuCloudError):
  213. """Authentication related errors."""
  214. pass
  215. _shared_http_client: httpx.AsyncClient | None = None
  216. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  217. """Register an app-scoped ``httpx.AsyncClient`` so per-request
  218. ``BambuCloudService`` instances can reuse its connection pool.
  219. Pass ``None`` during shutdown to unregister. The service only holds a
  220. reference (never closes a client it does not own), so region + token
  221. state still stays per-request — this only shares the transport pool.
  222. """
  223. global _shared_http_client
  224. _shared_http_client = client
  225. class BambuCloudService:
  226. """Service for interacting with Bambu Lab Cloud API."""
  227. def __init__(
  228. self,
  229. region: str = "global",
  230. client: httpx.AsyncClient | None = None,
  231. on_auth_failure: Callable[[], Awaitable[None]] | None = None,
  232. ):
  233. self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
  234. self.access_token: str | None = None
  235. self.refresh_token: str | None = None
  236. self.token_expiry: datetime | None = None
  237. # Fired once when Bambu answers 401 to a call we made with a stored
  238. # token — the credential is dead and the caller wants to record that.
  239. # ``build_authenticated_cloud`` wires this to the persisted flag, so
  240. # every route that builds a service through it gets invalidation for
  241. # free rather than each one having to notice 401s for itself.
  242. self._on_auth_failure = on_auth_failure
  243. self._auth_failure_reported = False
  244. # Prefer an explicitly-injected client (tests), else fall back to the
  245. # app-scoped shared client (production), and finally create our own so
  246. # scripts / tests that skip the lifespan still get a working service.
  247. if client is not None:
  248. self._client = client
  249. self._owns_client = False
  250. elif _shared_http_client is not None:
  251. self._client = _shared_http_client
  252. self._owns_client = False
  253. else:
  254. self._client = httpx.AsyncClient(timeout=30.0)
  255. self._owns_client = True
  256. @property
  257. def is_authenticated(self) -> bool:
  258. """Whether a credential is *loaded* — NOT whether Bambu accepts it.
  259. Bambu's access token is opaque (no JWT claims to read an expiry out
  260. of), so the only authority on whether it still works is Bambu. This
  261. used to pretend otherwise: ``set_token`` stamped ``token_expiry =
  262. now + 30 days`` every time a stored token was loaded, which made the
  263. expiry check reset on every request and this property incapable of
  264. ever returning False. The UI reported "connected" indefinitely while
  265. every cloud call 401'd (#2562 follow-up).
  266. ``token_expiry`` is now only set when we genuinely know it. Callers
  267. that need to know the token still *works* must ask Bambu — see
  268. :meth:`validate_token` — or react to the 401 that surfaces.
  269. """
  270. if not self.access_token:
  271. return False
  272. return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
  273. async def _note_response(self, response: httpx.Response) -> bool:
  274. """Record Bambu's genuine token-expiry 401 as "this credential is dead".
  275. Returns ``True`` only for the real expiry signal (see
  276. :meth:`_is_expiry_401`); a plain/transient 401 returns ``False`` and is
  277. left alone so it can't durably sign the user out. The durable flag is
  278. written at most once per service instance so a route making several
  279. calls doesn't write it repeatedly.
  280. """
  281. if response.status_code != 401:
  282. return False
  283. if not is_expiry_401(response):
  284. logger.info(
  285. "Bambu Cloud returned 401 without the expiry signature — treating as transient, "
  286. "not signing the stored token out"
  287. )
  288. return False
  289. if self._on_auth_failure is None or self._auth_failure_reported:
  290. return True
  291. self._auth_failure_reported = True
  292. if self.access_token:
  293. _validation_cache[_token_digest(self.access_token)] = (
  294. time.monotonic() + _VALIDATION_TTL_SECONDS,
  295. False,
  296. )
  297. try:
  298. await self._on_auth_failure()
  299. except Exception:
  300. # Recording the failure is best-effort — the caller still needs the
  301. # real error (a 401) rather than a bookkeeping exception on top.
  302. logger.exception("Failed to record Bambu Cloud auth failure")
  303. return True
  304. async def validate_token(self) -> bool | None:
  305. """Ask Bambu whether the loaded token is still accepted.
  306. ``True`` accepted, ``False`` rejected (401), ``None`` unknown — Bambu
  307. was unreachable or answered 5xx.
  308. ``None`` must never be treated as "invalid": a Bambu outage or a
  309. Cloudflare interstitial would otherwise sign every user out of a
  310. perfectly good session. Callers report their last known state instead.
  311. """
  312. if not self.access_token:
  313. return False
  314. digest = _token_digest(self.access_token)
  315. cached = _validation_cache.get(digest)
  316. if cached and cached[0] > time.monotonic():
  317. return cached[1]
  318. try:
  319. response = await self._client.get(
  320. f"{self.base_url}/v1/design-user-service/my/preference",
  321. headers=self._get_headers(),
  322. timeout=15.0,
  323. )
  324. except httpx.HTTPError as exc:
  325. logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
  326. return None
  327. if response.status_code == 401:
  328. # Only a 401 carrying Bambu's expiry signature is a real sign-out.
  329. # A signature-less 401 here is transient/edge noise — report unknown
  330. # (last-known state) rather than expiring a working session.
  331. expired = await self._note_response(response)
  332. return False if expired else None
  333. if response.status_code >= 500:
  334. logger.info(
  335. "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
  336. )
  337. return None
  338. if response.status_code != 200:
  339. # 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
  340. # itself was not rejected, so don't declare it dead.
  341. logger.info(
  342. "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
  343. )
  344. return None
  345. _validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
  346. return True
  347. def _get_headers(self) -> dict:
  348. """Get headers for authenticated requests."""
  349. headers = {
  350. "Content-Type": "application/json",
  351. "User-Agent": _USER_AGENT,
  352. }
  353. if self.access_token:
  354. headers["Authorization"] = f"Bearer {self.access_token}"
  355. return headers
  356. def _captcha_refusal(self) -> dict:
  357. """The result every sign-in call returns while Bambu is challenging us.
  358. ``reason`` is what lets the UI tell this apart from a wrong password and
  359. render the explanation next to the access-token route, instead of
  360. flashing Bambu's own one-liner as a toast that then disappears (#2790).
  361. """
  362. return {
  363. "success": False,
  364. "needs_verification": False,
  365. "reason": "captcha",
  366. "message": CAPTCHA_USER_MESSAGE,
  367. }
  368. def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
  369. """Whether to refuse a sign-in locally because Bambu just challenged us.
  370. Keyed by the origin the call actually goes to. The TOTP step talks to
  371. ``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
  372. a challenge seen on one must not strand a user halfway through a
  373. two-factor sign-in on the other.
  374. """
  375. origin = origin or self.base_url
  376. if not captcha_cooloff_active(origin):
  377. return False
  378. logger.warning(
  379. "Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
  380. "The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
  381. origin,
  382. )
  383. return True
  384. def _note_captcha(self, response, origin: str | None = None) -> bool:
  385. """Record and log a CAPTCHA challenge. Returns whether it was one."""
  386. if not is_captcha_challenge(response):
  387. return False
  388. origin = origin or self.base_url
  389. logger.warning(
  390. "Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
  391. "complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
  392. "extend the block.",
  393. response.status_code,
  394. origin,
  395. _CAPTCHA_COOLOFF_SECONDS,
  396. )
  397. note_captcha_challenge(origin)
  398. return True
  399. async def login_request(self, email: str, password: str) -> dict:
  400. """
  401. Initiate login - this will trigger either email verification or TOTP prompt.
  402. Returns dict with login status, verification type, and tfaKey if needed.
  403. """
  404. if self._captcha_cooloff_holds():
  405. return self._captcha_refusal()
  406. try:
  407. response = await self._client.post(
  408. f"{self.base_url}/v1/user-service/user/login",
  409. headers={"Content-Type": "application/json"},
  410. json={
  411. "account": email,
  412. "password": password,
  413. },
  414. )
  415. if self._note_captcha(response):
  416. return self._captcha_refusal()
  417. try:
  418. data = response.json()
  419. except Exception as json_err:
  420. logger.error("Failed to parse login response: %s, body: %s", json_err, response.text[:500])
  421. cf_message = _detect_cloudflare_challenge(response)
  422. return {
  423. "success": False,
  424. "needs_verification": False,
  425. "message": cf_message or "Invalid response from Bambu Cloud",
  426. }
  427. logger.debug(
  428. f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
  429. )
  430. if response.status_code == 200:
  431. login_type = data.get("loginType")
  432. tfa_key = data.get("tfaKey")
  433. # TOTP authentication required
  434. if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
  435. return {
  436. "success": False,
  437. "needs_verification": True,
  438. "verification_type": "totp",
  439. "tfa_key": tfa_key,
  440. "message": "Enter the code from your authenticator app",
  441. }
  442. # Email verification required
  443. if login_type == "verifyCode":
  444. return {
  445. "success": False,
  446. "needs_verification": True,
  447. "verification_type": "email",
  448. "tfa_key": None,
  449. "message": "Verification code sent to email",
  450. }
  451. # Direct login success (rare, usually needs 2FA)
  452. if "accessToken" in data:
  453. self._set_tokens(data)
  454. return {"success": True, "needs_verification": False, "message": "Login successful"}
  455. # Handle specific error codes
  456. error_msg = data.get("message") or data.get("error") or "Login failed"
  457. return {"success": False, "needs_verification": False, "message": error_msg}
  458. except Exception as e:
  459. logger.error("Login request failed: %s", e)
  460. raise BambuCloudAuthError(f"Login request failed: {e}")
  461. async def verify_code(self, email: str, code: str) -> dict:
  462. """
  463. Complete login with email verification code.
  464. """
  465. if self._captcha_cooloff_holds():
  466. return self._captcha_refusal()
  467. try:
  468. response = await self._client.post(
  469. f"{self.base_url}/v1/user-service/user/login",
  470. headers={"Content-Type": "application/json"},
  471. json={
  472. "account": email,
  473. "code": code,
  474. },
  475. )
  476. if self._note_captcha(response):
  477. return self._captcha_refusal()
  478. try:
  479. data = response.json()
  480. except Exception as json_err:
  481. logger.error("Failed to parse email-verify response: %s, body: %s", json_err, response.text[:500])
  482. cf_message = _detect_cloudflare_challenge(response)
  483. return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
  484. logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
  485. if response.status_code == 200 and "accessToken" in data:
  486. self._set_tokens(data)
  487. return {"success": True, "message": "Login successful"}
  488. return {"success": False, "message": data.get("message", "Verification failed")}
  489. except Exception as e:
  490. logger.error("Email verification failed: %s", e)
  491. raise BambuCloudAuthError(f"Verification failed: {e}")
  492. async def _fetch_csrf_token(self, web_origin: str) -> str | None:
  493. """Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
  494. Bambu added double-submit CSRF protection to the ``bambulab.com`` web
  495. origin. A POST without the cookie is rejected ``403 {"error": "CSRF
  496. error: missing_cookie"}`` before the request body is looked at; with the
  497. cookie but no matching header it becomes ``missing_header``. Only
  498. ``GET /api/csrf`` mints one — the sign-in *page* sets nothing but
  499. Cloudflare's ``__cf_bm``, so landing there first does not help.
  500. The token is re-fetched per verification rather than cached: the client
  501. is process-wide and long-lived, so a stale cookie could otherwise
  502. disagree with the header we send.
  503. """
  504. try:
  505. response = await self._client.get(
  506. f"{web_origin}/api/csrf",
  507. headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
  508. )
  509. except Exception as e:
  510. logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
  511. return None
  512. # httpx stores the Set-Cookie on the shared jar, which is also what makes
  513. # the cookie ride along on the POST below — we only need the value here
  514. # to echo it back in the header.
  515. try:
  516. token = self._client.cookies.get("bbl_csrf_token")
  517. except Exception: # multiple cookies of the same name across domains
  518. token = None
  519. if not token:
  520. logger.warning(
  521. "Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
  522. response.status_code,
  523. )
  524. return token
  525. async def verify_totp(self, tfa_key: str, code: str) -> dict:
  526. """
  527. Complete login with TOTP code from authenticator app.
  528. Args:
  529. tfa_key: The tfaKey returned from initial login request
  530. code: 6-digit TOTP code from authenticator app
  531. """
  532. try:
  533. # TFA endpoint is on bambulab.com, NOT api.bambulab.com.
  534. # We previously sent a Chrome User-Agent plus Origin/Referer headers
  535. # under the assumption Cloudflare would block bot-identified
  536. # requests. Verified 2026-05-12 via curl that the endpoint accepts
  537. # honest "Bambuddy/X.Y.Z" identification cleanly (HTTP 400 with the
  538. # expected application-level "Login failed" JSON, no Cloudflare
  539. # interstitial). Browser-impersonation removed to stay clearly on
  540. # the right side of Bambu Lab's "no falsified client identity" line.
  541. web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
  542. tfa_url = f"{web_origin}/api/sign-in/tfa"
  543. if self._captcha_cooloff_holds(web_origin):
  544. return self._captcha_refusal()
  545. # #2696: the web origin is CSRF-protected (double submit). Without
  546. # both halves the endpoint 403s before it ever evaluates the code,
  547. # which surfaced to users as a permanent, misleading "Invalid code".
  548. # api.bambulab.com — where every other call in this service goes,
  549. # including the email-code 2FA path — is not gated, which is why
  550. # only TOTP sign-ins broke.
  551. csrf_token = await self._fetch_csrf_token(web_origin)
  552. if not csrf_token:
  553. return {
  554. "success": False,
  555. "message": (
  556. "Could not obtain a security token from Bambu Cloud. "
  557. "Check the server's internet access and try again."
  558. ),
  559. }
  560. response = await self._client.post(
  561. tfa_url,
  562. headers={
  563. "Content-Type": "application/json",
  564. "User-Agent": _USER_AGENT,
  565. "Accept": "application/json",
  566. # Echo of the bbl_csrf_token cookie httpx just stored. Both
  567. # halves are required; the cookie alone yields
  568. # "missing_header".
  569. "x-bbl-csrf-token": csrf_token,
  570. },
  571. json={
  572. "tfaKey": tfa_key,
  573. "tfaCode": code,
  574. },
  575. )
  576. logger.debug(
  577. f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
  578. )
  579. if self._note_captcha(response, web_origin):
  580. return self._captcha_refusal()
  581. # Handle empty response
  582. if not response.text or not response.text.strip():
  583. logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
  584. return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
  585. try:
  586. data = response.json()
  587. except Exception as json_err:
  588. logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
  589. cf_message = _detect_cloudflare_challenge(response)
  590. return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
  591. # Token might be in accessToken, token field, or cookies
  592. access_token = data.get("accessToken") or data.get("token")
  593. # Also check cookies for token
  594. if not access_token:
  595. for cookie in response.cookies:
  596. if "token" in cookie.lower():
  597. access_token = response.cookies.get(cookie)
  598. break
  599. if response.status_code == 200 and access_token:
  600. self.access_token = access_token
  601. self.refresh_token = data.get("refreshToken")
  602. # Expiry left unset: Bambu does not tell us when the token dies
  603. # and the token is opaque, so any value here would be invented.
  604. self.token_expiry = None
  605. invalidate_validation_cache(access_token)
  606. return {"success": True, "message": "Login successful"}
  607. # Provide helpful error message
  608. error_msg = data.get("message", "")
  609. # A CSRF rejection means the code was never evaluated (#2696). It
  610. # used to fall through to the generic path below and read as
  611. # "Invalid code", which sent the reporter chasing clock drift and
  612. # leading-zero parsing for a request Bambu had already refused.
  613. csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
  614. if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
  615. logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
  616. return {
  617. "success": False,
  618. "message": (
  619. "Bambu Cloud rejected the sign-in request before checking your code "
  620. "(security-token error). Your code is fine — please try again."
  621. ),
  622. }
  623. if "expired" in error_msg.lower():
  624. return {"success": False, "message": "TOTP session expired. Please try logging in again."}
  625. if not error_msg:
  626. error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
  627. return {"success": False, "message": error_msg}
  628. except Exception as e:
  629. logger.error("TOTP verification failed: %s", e)
  630. # Return error instead of raising - don't trigger 401/500
  631. return {"success": False, "message": f"TOTP verification error: {e}"}
  632. def _set_tokens(self, data: dict):
  633. """Set tokens from a login response.
  634. No expiry is recorded. Bambu's login response carries no expiry, and
  635. the access token is opaque, so the old ``now + 30 days`` was a guess
  636. that outlived its own accuracy — see :attr:`is_authenticated`.
  637. """
  638. self.access_token = data.get("accessToken")
  639. self.refresh_token = data.get("refreshToken")
  640. self.token_expiry = None
  641. if self.access_token:
  642. invalidate_validation_cache(self.access_token)
  643. def set_token(self, access_token: str):
  644. """Load a stored access token.
  645. This used to stamp ``token_expiry = now + 30 days`` — re-derived from
  646. *now* on every request, for a token of entirely unknown age. That made
  647. ``is_authenticated`` a permanent True and is why Bambuddy went on
  648. reporting "connected" long after Bambu had stopped accepting the token.
  649. A stored token's remaining life is unknowable from the token alone, so
  650. we record no expiry and let Bambu be the authority.
  651. """
  652. self.access_token = access_token
  653. self.token_expiry = None
  654. def logout(self):
  655. """Clear authentication state."""
  656. self.access_token = None
  657. self.refresh_token = None
  658. self.token_expiry = None
  659. async def get_user_profile(self) -> dict:
  660. """Get user profile information."""
  661. if not self.is_authenticated:
  662. raise BambuCloudAuthError("Not authenticated")
  663. try:
  664. response = await self._client.get(
  665. f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
  666. )
  667. if response.status_code == 200:
  668. return response.json()
  669. raise BambuCloudError(f"Failed to get profile: {response.status_code}")
  670. except httpx.RequestError as e:
  671. raise BambuCloudError(f"Request failed: {e}")
  672. async def get_slicer_settings(self, version: str = _SLICER_API_VERSION) -> dict:
  673. """
  674. Get all slicer settings (filament, printer, process presets).
  675. Args:
  676. version: Slicer version string. Bambu's API requires the XX.YY.ZZ.WW
  677. format but does not validate against a release manifest — we
  678. default to the neutral _SLICER_API_VERSION placeholder so we
  679. never claim to be a specific Bambu Studio build. Callers should
  680. normally use the default.
  681. """
  682. if not self.is_authenticated:
  683. raise BambuCloudAuthError("Not authenticated")
  684. try:
  685. response = await self._client.get(
  686. f"{self.base_url}/v1/iot-service/api/slicer/setting",
  687. headers=self._get_headers(),
  688. params={"version": version},
  689. )
  690. data = response.json()
  691. await self._note_response(response)
  692. if response.status_code == 200:
  693. return data
  694. raise BambuCloudError(f"Failed to get settings: {response.status_code}")
  695. except httpx.RequestError as e:
  696. raise BambuCloudError(f"Request failed: {e}")
  697. async def get_setting_detail(self, setting_id: str) -> dict:
  698. """Get detailed information for a specific setting/preset."""
  699. if not self.is_authenticated:
  700. raise BambuCloudAuthError("Not authenticated")
  701. try:
  702. response = await self._client.get(
  703. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
  704. headers=self._get_headers(),
  705. params={"version": _SLICER_API_VERSION},
  706. )
  707. await self._note_response(response)
  708. if response.status_code == 200:
  709. return response.json()
  710. # Include body so a future contract change is self-diagnostic from logs.
  711. body = (response.text or "")[:200]
  712. raise BambuCloudError(
  713. f"Failed to get setting detail: {response.status_code} {body}",
  714. status_code=response.status_code,
  715. )
  716. except httpx.RequestError as e:
  717. raise BambuCloudError(f"Request failed: {e}")
  718. async def create_setting(
  719. self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0"
  720. ) -> dict:
  721. """
  722. Create a new slicer preset/setting.
  723. Args:
  724. preset_type: Type of preset - "filament", "print", or "printer"
  725. name: Display name for the preset
  726. base_id: Base preset ID to inherit from (e.g., "GFSA00")
  727. setting: Dict of setting key-value pairs (only modified values from base)
  728. version: Version string for the preset (default: "2.0.0.0")
  729. Returns:
  730. Created preset data including the new setting_id
  731. """
  732. if not self.is_authenticated:
  733. raise BambuCloudAuthError("Not authenticated")
  734. try:
  735. # Add timestamp if not present
  736. import time
  737. if "updated_time" not in setting:
  738. setting["updated_time"] = str(int(time.time()))
  739. payload = {
  740. "type": preset_type,
  741. "name": name,
  742. "version": version,
  743. "base_id": base_id,
  744. "setting": setting,
  745. }
  746. response = await self._client.post(
  747. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  748. )
  749. data = response.json()
  750. await self._note_response(response)
  751. if response.status_code in (200, 201):
  752. return data
  753. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  754. raise BambuCloudError(f"Failed to create setting: {error_msg}")
  755. except httpx.RequestError as e:
  756. raise BambuCloudError(f"Request failed: {e}")
  757. async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
  758. """
  759. Update an existing slicer preset/setting.
  760. Note: Bambu Cloud API doesn't support true updates. Instead, we:
  761. 1. Fetch the current setting metadata (type, base_id, version)
  762. 2. Use the provided settings as the new complete settings (NOT merged)
  763. 3. Delete the old setting first (to avoid name conflicts)
  764. 4. Create a new setting via POST
  765. Args:
  766. setting_id: ID of the preset to update
  767. name: New display name (optional)
  768. setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
  769. Returns:
  770. Updated preset data with new setting_id
  771. """
  772. if not self.is_authenticated:
  773. raise BambuCloudAuthError("Not authenticated")
  774. try:
  775. # Fetch current setting to get metadata (type, base_id, version)
  776. current = await self.get_setting_detail(setting_id)
  777. preset_type = current.get("type", "filament")
  778. # Use provided settings directly (complete replacement, not merge)
  779. # This allows the frontend to edit the full settings JSON
  780. if setting is not None:
  781. updated_setting = setting.copy()
  782. else:
  783. updated_setting = current.get("setting", {}).copy()
  784. # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
  785. # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
  786. settings_id_key = {
  787. "filament": "filament_settings_id",
  788. "print": "print_settings_id",
  789. "printer": "printer_settings_id",
  790. }.get(preset_type, "filament_settings_id")
  791. settings_id_value = updated_setting.get(settings_id_key, "")
  792. if settings_id_value:
  793. # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
  794. updated_name = settings_id_value.strip('"')
  795. elif name is not None:
  796. updated_name = name
  797. else:
  798. updated_name = current.get("name", "Untitled")
  799. # Update the timestamp
  800. import time
  801. updated_setting["updated_time"] = str(int(time.time()))
  802. # Ensure settings_id field matches the name
  803. updated_setting[settings_id_key] = f'"{updated_name}"'
  804. # Delete the old setting FIRST to avoid name conflicts
  805. await self.delete_setting(setting_id)
  806. # Create new setting via POST
  807. payload = {
  808. "type": preset_type,
  809. "name": updated_name,
  810. "version": current.get("version", "2.0.0.0"),
  811. "base_id": current.get("base_id", ""),
  812. "setting": updated_setting,
  813. }
  814. response = await self._client.post(
  815. f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
  816. )
  817. data = response.json()
  818. await self._note_response(response)
  819. if response.status_code == 200:
  820. return data
  821. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  822. raise BambuCloudError(f"Failed to update setting: {error_msg}")
  823. except httpx.RequestError as e:
  824. raise BambuCloudError(f"Request failed: {e}")
  825. async def delete_setting(self, setting_id: str) -> dict:
  826. """
  827. Delete a slicer preset/setting.
  828. Args:
  829. setting_id: ID of the preset to delete
  830. Returns:
  831. Deletion confirmation
  832. """
  833. if not self.is_authenticated:
  834. raise BambuCloudAuthError("Not authenticated")
  835. try:
  836. response = await self._client.delete(
  837. f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
  838. headers=self._get_headers(),
  839. params={"version": _SLICER_API_VERSION},
  840. )
  841. await self._note_response(response)
  842. if response.status_code in (200, 204):
  843. return {"success": True, "message": "Setting deleted"}
  844. data = response.json() if response.content else {}
  845. error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
  846. raise BambuCloudError(f"Failed to delete setting: {error_msg}")
  847. except httpx.RequestError as e:
  848. raise BambuCloudError(f"Request failed: {e}")
  849. async def get_devices(self) -> dict:
  850. """Get list of bound devices."""
  851. if not self.is_authenticated:
  852. raise BambuCloudAuthError("Not authenticated")
  853. try:
  854. response = await self._client.get(
  855. f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
  856. )
  857. await self._note_response(response)
  858. if response.status_code == 200:
  859. return response.json()
  860. raise BambuCloudError(f"Failed to get devices: {response.status_code}")
  861. except httpx.RequestError as e:
  862. raise BambuCloudError(f"Request failed: {e}")
  863. async def get_firmware_version(self, device_id: str) -> dict:
  864. """
  865. Get firmware version info for a device.
  866. Returns dict with:
  867. - current_version: Installed firmware version
  868. - latest_version: Latest available firmware version
  869. - update_available: Boolean indicating if update is available
  870. - release_notes: Release notes for latest version
  871. """
  872. if not self.is_authenticated:
  873. raise BambuCloudAuthError("Not authenticated")
  874. try:
  875. response = await self._client.get(
  876. f"{self.base_url}/v1/iot-service/api/user/device/version",
  877. headers=self._get_headers(),
  878. params={"device_id": device_id},
  879. )
  880. await self._note_response(response)
  881. if response.status_code == 200:
  882. data = response.json()
  883. # API wraps response in 'data' field
  884. return data.get("data", data)
  885. raise BambuCloudError(f"Failed to get firmware version: {response.status_code}")
  886. except httpx.RequestError as e:
  887. raise BambuCloudError(f"Request failed: {e}")
  888. async def close(self):
  889. """Close the HTTP client we own. No-op when sharing an app-scoped client."""
  890. if self._owns_client:
  891. await self._client.aclose()
  892. # Previously this module exposed a process-wide ``_cloud_service`` singleton
  893. # via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked
  894. # region and token state across users (a China-region login would pin the
  895. # singleton to api.bambulab.cn until the next explicit reset), so the singleton
  896. # has been removed. Callers should construct a per-request
  897. # ``BambuCloudService(region=...)`` from the stored region and ``await
  898. # cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``
  899. # for the standard pattern.