|
@@ -4,8 +4,11 @@ Bambu Lab Cloud API Service
|
|
|
Handles authentication and profile management with Bambu Lab's cloud services.
|
|
Handles authentication and profile management with Bambu Lab's cloud services.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+import hashlib
|
|
|
import logging
|
|
import logging
|
|
|
-from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
+import time
|
|
|
|
|
+from collections.abc import Awaitable, Callable
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
@@ -14,6 +17,34 @@ logger = logging.getLogger(__name__)
|
|
|
BAMBU_API_BASE = "https://api.bambulab.com"
|
|
BAMBU_API_BASE = "https://api.bambulab.com"
|
|
|
BAMBU_API_BASE_CN = "https://api.bambulab.cn"
|
|
BAMBU_API_BASE_CN = "https://api.bambulab.cn"
|
|
|
|
|
|
|
|
|
|
+# How long a "Bambu still accepts this token" answer is trusted before we ask
|
|
|
|
|
+# again. ``/cloud/status`` is polled by several components, so validating on
|
|
|
|
|
+# every call would put a Bambu round-trip behind every settings render; a token
|
|
|
|
|
+# does not expire on a five-minute boundary, so caching that long is free.
|
|
|
|
|
+_VALIDATION_TTL_SECONDS = 300
|
|
|
|
|
+
|
|
|
|
|
+# token digest -> (monotonic deadline, accepted?). Keyed by digest so a token
|
|
|
|
|
+# never sits in a process-wide dict in the clear.
|
|
|
|
|
+_validation_cache: dict[str, tuple[float, bool]] = {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _token_digest(token: str) -> str:
|
|
|
|
|
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def invalidate_validation_cache(token: str | None = None) -> None:
|
|
|
|
|
+ """Drop cached validation verdicts.
|
|
|
|
|
+
|
|
|
|
|
+ Called on login/logout so a fresh token isn't judged by the previous one's
|
|
|
|
|
+ cached verdict, and so a re-login clears a cached rejection immediately
|
|
|
|
|
+ rather than leaving the user staring at "sign-in expired" for five minutes.
|
|
|
|
|
+ """
|
|
|
|
|
+ if token is None:
|
|
|
|
|
+ _validation_cache.clear()
|
|
|
|
|
+ else:
|
|
|
|
|
+ _validation_cache.pop(_token_digest(token), None)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# Client identity sent to Bambu Lab's cloud services. We identify honestly as
|
|
# Client identity sent to Bambu Lab's cloud services. We identify honestly as
|
|
|
# Bambuddy — the URL in parens makes the source unambiguous so Bambu can
|
|
# Bambuddy — the URL in parens makes the source unambiguous so Bambu can
|
|
|
# distinguish our traffic from impersonators. This is the opposite of what the
|
|
# distinguish our traffic from impersonators. This is the opposite of what the
|
|
@@ -116,11 +147,23 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
|
|
class BambuCloudService:
|
|
class BambuCloudService:
|
|
|
"""Service for interacting with Bambu Lab Cloud API."""
|
|
"""Service for interacting with Bambu Lab Cloud API."""
|
|
|
|
|
|
|
|
- def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
|
|
|
|
|
|
|
+ def __init__(
|
|
|
|
|
+ self,
|
|
|
|
|
+ region: str = "global",
|
|
|
|
|
+ client: httpx.AsyncClient | None = None,
|
|
|
|
|
+ on_auth_failure: Callable[[], Awaitable[None]] | None = None,
|
|
|
|
|
+ ):
|
|
|
self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
|
|
self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
|
|
|
self.access_token: str | None = None
|
|
self.access_token: str | None = None
|
|
|
self.refresh_token: str | None = None
|
|
self.refresh_token: str | None = None
|
|
|
self.token_expiry: datetime | None = None
|
|
self.token_expiry: datetime | None = None
|
|
|
|
|
+ # Fired once when Bambu answers 401 to a call we made with a stored
|
|
|
|
|
+ # token — the credential is dead and the caller wants to record that.
|
|
|
|
|
+ # ``build_authenticated_cloud`` wires this to the persisted flag, so
|
|
|
|
|
+ # every route that builds a service through it gets invalidation for
|
|
|
|
|
+ # free rather than each one having to notice 401s for itself.
|
|
|
|
|
+ self._on_auth_failure = on_auth_failure
|
|
|
|
|
+ self._auth_failure_reported = False
|
|
|
# Prefer an explicitly-injected client (tests), else fall back to the
|
|
# Prefer an explicitly-injected client (tests), else fall back to the
|
|
|
# app-scoped shared client (production), and finally create our own so
|
|
# app-scoped shared client (production), and finally create our own so
|
|
|
# scripts / tests that skip the lifespan still get a working service.
|
|
# scripts / tests that skip the lifespan still get a working service.
|
|
@@ -136,11 +179,94 @@ class BambuCloudService:
|
|
|
|
|
|
|
|
@property
|
|
@property
|
|
|
def is_authenticated(self) -> bool:
|
|
def is_authenticated(self) -> bool:
|
|
|
- """Check if we have a valid token."""
|
|
|
|
|
|
|
+ """Whether a credential is *loaded* — NOT whether Bambu accepts it.
|
|
|
|
|
+
|
|
|
|
|
+ Bambu's access token is opaque (no JWT claims to read an expiry out
|
|
|
|
|
+ of), so the only authority on whether it still works is Bambu. This
|
|
|
|
|
+ used to pretend otherwise: ``set_token`` stamped ``token_expiry =
|
|
|
|
|
+ now + 30 days`` every time a stored token was loaded, which made the
|
|
|
|
|
+ expiry check reset on every request and this property incapable of
|
|
|
|
|
+ ever returning False. The UI reported "connected" indefinitely while
|
|
|
|
|
+ every cloud call 401'd (#2562 follow-up).
|
|
|
|
|
+
|
|
|
|
|
+ ``token_expiry`` is now only set when we genuinely know it. Callers
|
|
|
|
|
+ that need to know the token still *works* must ask Bambu — see
|
|
|
|
|
+ :meth:`validate_token` — or react to the 401 that surfaces.
|
|
|
|
|
+ """
|
|
|
if not self.access_token:
|
|
if not self.access_token:
|
|
|
return False
|
|
return False
|
|
|
return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
|
|
return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
|
|
|
|
|
|
|
|
|
|
+ async def _note_response(self, response: httpx.Response) -> None:
|
|
|
|
|
+ """Record a 401 from Bambu as "this stored credential is dead".
|
|
|
|
|
+
|
|
|
|
|
+ Bambu answers an expired/revoked token with 401 and a body of
|
|
|
|
|
+ ``{"code":4,"error":"Please login.","message":""}``. Reported at most
|
|
|
|
|
+ once per service instance so a route that makes several calls doesn't
|
|
|
|
|
+ write the flag several times.
|
|
|
|
|
+ """
|
|
|
|
|
+ if response.status_code != 401 or self._on_auth_failure is None or self._auth_failure_reported:
|
|
|
|
|
+ return
|
|
|
|
|
+ self._auth_failure_reported = True
|
|
|
|
|
+ if self.access_token:
|
|
|
|
|
+ _validation_cache[_token_digest(self.access_token)] = (
|
|
|
|
|
+ time.monotonic() + _VALIDATION_TTL_SECONDS,
|
|
|
|
|
+ False,
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ await self._on_auth_failure()
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ # Recording the failure is best-effort — the caller still needs the
|
|
|
|
|
+ # real error (a 401) rather than a bookkeeping exception on top.
|
|
|
|
|
+ logger.exception("Failed to record Bambu Cloud auth failure")
|
|
|
|
|
+
|
|
|
|
|
+ async def validate_token(self) -> bool | None:
|
|
|
|
|
+ """Ask Bambu whether the loaded token is still accepted.
|
|
|
|
|
+
|
|
|
|
|
+ ``True`` accepted, ``False`` rejected (401), ``None`` unknown — Bambu
|
|
|
|
|
+ was unreachable or answered 5xx.
|
|
|
|
|
+
|
|
|
|
|
+ ``None`` must never be treated as "invalid": a Bambu outage or a
|
|
|
|
|
+ Cloudflare interstitial would otherwise sign every user out of a
|
|
|
|
|
+ perfectly good session. Callers report their last known state instead.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not self.access_token:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ digest = _token_digest(self.access_token)
|
|
|
|
|
+ cached = _validation_cache.get(digest)
|
|
|
|
|
+ if cached and cached[0] > time.monotonic():
|
|
|
|
|
+ return cached[1]
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = await self._client.get(
|
|
|
|
|
+ f"{self.base_url}/v1/design-user-service/my/preference",
|
|
|
|
|
+ headers=self._get_headers(),
|
|
|
|
|
+ timeout=15.0,
|
|
|
|
|
+ )
|
|
|
|
|
+ except httpx.HTTPError as exc:
|
|
|
|
|
+ logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ if response.status_code == 401:
|
|
|
|
|
+ await self._note_response(response)
|
|
|
|
|
+ return False
|
|
|
|
|
+ if response.status_code >= 500:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
|
|
+ if response.status_code != 200:
|
|
|
|
|
+ # 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
|
|
|
|
|
+ # itself was not rejected, so don't declare it dead.
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ _validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
def _get_headers(self) -> dict:
|
|
def _get_headers(self) -> dict:
|
|
|
"""Get headers for authenticated requests."""
|
|
"""Get headers for authenticated requests."""
|
|
|
headers = {
|
|
headers = {
|
|
@@ -313,9 +439,10 @@ class BambuCloudService:
|
|
|
if response.status_code == 200 and access_token:
|
|
if response.status_code == 200 and access_token:
|
|
|
self.access_token = access_token
|
|
self.access_token = access_token
|
|
|
self.refresh_token = data.get("refreshToken")
|
|
self.refresh_token = data.get("refreshToken")
|
|
|
- from datetime import datetime, timedelta, timezone
|
|
|
|
|
-
|
|
|
|
|
- self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
|
|
|
|
|
|
|
+ # Expiry left unset: Bambu does not tell us when the token dies
|
|
|
|
|
+ # and the token is opaque, so any value here would be invented.
|
|
|
|
|
+ self.token_expiry = None
|
|
|
|
|
+ invalidate_validation_cache(access_token)
|
|
|
return {"success": True, "message": "Login successful"}
|
|
return {"success": True, "message": "Login successful"}
|
|
|
|
|
|
|
|
# Provide helpful error message
|
|
# Provide helpful error message
|
|
@@ -333,16 +460,30 @@ class BambuCloudService:
|
|
|
return {"success": False, "message": f"TOTP verification error: {e}"}
|
|
return {"success": False, "message": f"TOTP verification error: {e}"}
|
|
|
|
|
|
|
|
def _set_tokens(self, data: dict):
|
|
def _set_tokens(self, data: dict):
|
|
|
- """Set tokens from login response."""
|
|
|
|
|
|
|
+ """Set tokens from a login response.
|
|
|
|
|
+
|
|
|
|
|
+ No expiry is recorded. Bambu's login response carries no expiry, and
|
|
|
|
|
+ the access token is opaque, so the old ``now + 30 days`` was a guess
|
|
|
|
|
+ that outlived its own accuracy — see :attr:`is_authenticated`.
|
|
|
|
|
+ """
|
|
|
self.access_token = data.get("accessToken")
|
|
self.access_token = data.get("accessToken")
|
|
|
self.refresh_token = data.get("refreshToken")
|
|
self.refresh_token = data.get("refreshToken")
|
|
|
- # Token typically valid for ~3 months, but we'll refresh more often
|
|
|
|
|
- self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
|
|
|
|
|
|
|
+ self.token_expiry = None
|
|
|
|
|
+ if self.access_token:
|
|
|
|
|
+ invalidate_validation_cache(self.access_token)
|
|
|
|
|
|
|
|
def set_token(self, access_token: str):
|
|
def set_token(self, access_token: str):
|
|
|
- """Set access token directly (for stored tokens)."""
|
|
|
|
|
|
|
+ """Load a stored access token.
|
|
|
|
|
+
|
|
|
|
|
+ This used to stamp ``token_expiry = now + 30 days`` — re-derived from
|
|
|
|
|
+ *now* on every request, for a token of entirely unknown age. That made
|
|
|
|
|
+ ``is_authenticated`` a permanent True and is why Bambuddy went on
|
|
|
|
|
+ reporting "connected" long after Bambu had stopped accepting the token.
|
|
|
|
|
+ A stored token's remaining life is unknowable from the token alone, so
|
|
|
|
|
+ we record no expiry and let Bambu be the authority.
|
|
|
|
|
+ """
|
|
|
self.access_token = access_token
|
|
self.access_token = access_token
|
|
|
- self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
|
|
|
|
|
|
|
+ self.token_expiry = None
|
|
|
|
|
|
|
|
def logout(self):
|
|
def logout(self):
|
|
|
"""Clear authentication state."""
|
|
"""Clear authentication state."""
|
|
@@ -391,6 +532,7 @@ class BambuCloudService:
|
|
|
|
|
|
|
|
data = response.json()
|
|
data = response.json()
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code == 200:
|
|
if response.status_code == 200:
|
|
|
return data
|
|
return data
|
|
|
|
|
|
|
@@ -411,6 +553,7 @@ class BambuCloudService:
|
|
|
params={"version": _SLICER_API_VERSION},
|
|
params={"version": _SLICER_API_VERSION},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code == 200:
|
|
if response.status_code == 200:
|
|
|
return response.json()
|
|
return response.json()
|
|
|
|
|
|
|
@@ -464,6 +607,7 @@ class BambuCloudService:
|
|
|
|
|
|
|
|
data = response.json()
|
|
data = response.json()
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code in (200, 201):
|
|
if response.status_code in (200, 201):
|
|
|
return data
|
|
return data
|
|
|
|
|
|
|
@@ -549,6 +693,7 @@ class BambuCloudService:
|
|
|
|
|
|
|
|
data = response.json()
|
|
data = response.json()
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code == 200:
|
|
if response.status_code == 200:
|
|
|
return data
|
|
return data
|
|
|
|
|
|
|
@@ -578,6 +723,7 @@ class BambuCloudService:
|
|
|
params={"version": _SLICER_API_VERSION},
|
|
params={"version": _SLICER_API_VERSION},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code in (200, 204):
|
|
if response.status_code in (200, 204):
|
|
|
return {"success": True, "message": "Setting deleted"}
|
|
return {"success": True, "message": "Setting deleted"}
|
|
|
|
|
|
|
@@ -598,6 +744,7 @@ class BambuCloudService:
|
|
|
f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
|
|
f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code == 200:
|
|
if response.status_code == 200:
|
|
|
return response.json()
|
|
return response.json()
|
|
|
|
|
|
|
@@ -626,6 +773,7 @@ class BambuCloudService:
|
|
|
params={"device_id": device_id},
|
|
params={"device_id": device_id},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ await self._note_response(response)
|
|
|
if response.status_code == 200:
|
|
if response.status_code == 200:
|
|
|
data = response.json()
|
|
data = response.json()
|
|
|
# API wraps response in 'data' field
|
|
# API wraps response in 'data' field
|