| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346 |
- """
- Bambu Lab Cloud API Routes
- Handles authentication and profile management with Bambu Cloud.
- """
- import asyncio
- import json
- import logging
- from pathlib import Path
- from typing import Literal
- from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
- from fastapi.security import HTTPAuthorizationCredentials
- from sqlalchemy import select, update
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import (
- RequirePermissionIfAuthEnabled,
- _user_from_api_key,
- _validate_api_key,
- require_permission_if_auth_enabled,
- security,
- )
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.api_key import APIKey
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- from backend.app.schemas.cloud import (
- CloudAuthStatus,
- CloudDevice,
- CloudLoginRequest,
- CloudLoginResponse,
- CloudTokenRequest,
- CloudVerifyRequest,
- FirmwareUpdateInfo,
- FirmwareUpdatesResponse,
- SlicerSetting,
- SlicerSettingCreate,
- SlicerSettingDeleteResponse,
- SlicerSettingsResponse,
- SlicerSettingUpdate,
- )
- from backend.app.services.bambu_cloud import (
- _SLICER_API_VERSION,
- BambuCloudAuthError,
- BambuCloudError,
- BambuCloudService,
- invalidate_validation_cache,
- )
- # Credential read/write lives in the services layer so feature packages can
- # consume it without importing the route layer. Imported here for this
- # module's own use; consumers should import from bambu_cloud_credentials
- # directly rather than through this route module.
- from backend.app.services.bambu_cloud_credentials import (
- CLOUD_EMAIL_KEY,
- CLOUD_REGION_KEY,
- CLOUD_TOKEN_INVALID_KEY,
- CLOUD_TOKEN_KEY,
- _clear_cloud_token_invalid,
- _normalise_region,
- get_stored_token,
- is_cloud_token_invalid,
- mark_cloud_token_invalid,
- )
- from backend.app.utils.filament_ids import filament_id_to_setting_id
- logger = logging.getLogger(__name__)
- async def _cloud_api_key_gate(
- request: Request,
- credentials: HTTPAuthorizationCredentials | None = Depends(security),
- x_api_key: str | None = Header(default=None, alias="X-API-Key"),
- db: AsyncSession = Depends(get_db),
- ) -> None:
- """Router-level dependency: enforce API-key cloud-access fences (#1182).
- Runs before every /cloud/* handler. JWT-authed and anonymous callers are
- no-ops — their access is gated by the per-route ``Permission.CLOUD_AUTH``
- / ``Permission.FILAMENTS_READ`` / etc. dependency. API-keyed callers
- must have an owner and ``can_access_cloud=True``; legacy ownerless keys
- and keys without the cloud scope are rejected here.
- On a successful API-keyed request the owner User is stashed on
- ``request.state.api_key_owner`` so route handlers can resolve it via
- ``cloud_caller`` (the auth gate returns None for API keys to avoid a
- wider behaviour change in non-cloud routes — see auth.py).
- The dep duplicates the API-key validation done by the regular auth gate
- (which runs as a route-level dep, *after* router-level deps). The cost
- is one extra ``SELECT FROM api_keys`` per /cloud/* request — bounded and
- cheap (key_prefix is indexed).
- """
- api_key_value: str | None = None
- if x_api_key:
- api_key_value = x_api_key
- elif credentials and credentials.credentials.startswith("bb_"):
- api_key_value = credentials.credentials
- if api_key_value is None:
- return # JWT or anonymous — no-op
- api_key = await _validate_api_key(db, api_key_value)
- if api_key is None:
- # Invalid key — let the route-level auth gate produce the 401 so the
- # error matches what every other route returns for a bad key.
- return
- _assert_api_key_can_access_cloud(api_key)
- # All fences passed. Stash the owner so cloud routes can resolve their
- # caller User without going through the auth gate (which intentionally
- # returns None for API keys to keep #1182 surface-bounded to /cloud/*).
- request.state.api_key_owner = await _user_from_api_key(db, api_key)
- def cloud_caller(*permissions: Permission):
- """Route-level dep factory for /cloud/* handlers.
- Returns a Depends that resolves to:
- - the JWT-authenticated User (when a JWT is present and the route's
- permission set is satisfied), OR
- - the API-key owner User stashed by the router-level gate
- (``request.state.api_key_owner``), OR
- - None when auth is disabled.
- Replaces the direct ``RequirePermissionIfAuthEnabled(...)`` dep on cloud
- routes so API-keyed callers get the *owner* in ``current_user`` rather
- than None — without that the route falls back to the global Settings
- cloud_token, which is empty in auth-enabled deployments.
- """
- base_dep = require_permission_if_auth_enabled(*permissions)
- async def resolved(
- request: Request,
- base_user: User | None = Depends(base_dep),
- ) -> User | None:
- if base_user is not None:
- return base_user
- return getattr(request.state, "api_key_owner", None)
- return Depends(resolved)
- async def resolve_api_key_cloud_owner(
- credentials: HTTPAuthorizationCredentials | None = Depends(security),
- x_api_key: str | None = Header(default=None, alias="X-API-Key"),
- db: AsyncSession = Depends(get_db),
- ) -> User | None:
- """Route-level dep for non-/cloud/* endpoints that need to read the
- caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
- presets — #1182 follow-up).
- Returns the API key's owner User when the caller is an API-keyed
- request *and* the key has ``can_access_cloud=True``; returns None for
- JWT, anonymous, or API keys without the cloud scope. The caller is
- expected to fall back to the JWT-authed ``current_user`` first and use
- this dep's result only when ``current_user`` is None.
- Unlike ``_cloud_api_key_gate`` (which 403s legacy/non-cloud keys at the
- router level), this dep is permissive: it returns None instead of
- raising, so a slice request via an API key without cloud scope still
- runs against local presets. The downstream cloud-token check in
- ``preset_resolver._resolve_cloud`` produces the right 400 if the
- request actually selects a cloud preset.
- """
- api_key_value: str | None = None
- if x_api_key:
- api_key_value = x_api_key
- elif credentials and credentials.credentials.startswith("bb_"):
- api_key_value = credentials.credentials
- if api_key_value is None:
- return None
- api_key = await _validate_api_key(db, api_key_value)
- if api_key is None or api_key.user_id is None or not api_key.can_access_cloud:
- return None
- return await _user_from_api_key(db, api_key)
- router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
- async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
- """Store cloud token, email, and region.
- When a user is provided (auth enabled), stores on the user record.
- When user is None (auth disabled), stores in global Settings table.
- Always clears the rejected-token flag: this is a *fresh* credential, and
- leaving the flag set would report the new sign-in as expired.
- """
- region = _normalise_region(region)
- invalidate_validation_cache(token)
- if user is not None:
- # User object is from the auth dependency's session (detached),
- # so use a direct UPDATE via the route's db session.
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
- )
- await db.commit()
- return
- # Fallback: global storage (auth disabled)
- for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:
- result = await db.execute(select(Settings).where(Settings.key == key))
- setting = result.scalar_one_or_none()
- if setting:
- setting.value = value
- else:
- db.add(Settings(key=key, value=value))
- await _clear_cloud_token_invalid(db, None)
- await db.commit()
- async def clear_token(db: AsyncSession, user: User | None = None) -> None:
- """Clear stored cloud token, email, and region.
- When a user is provided (auth enabled), clears that user's credentials.
- When user is None (auth disabled), clears from global Settings table.
- The rejected-token flag goes with the token: once there is no credential,
- "the credential is dead" is not a state worth remembering, and leaving it
- behind would make the next login look expired the moment it is stored.
- """
- token, _email, _region = await get_stored_token(db, user)
- if token:
- invalidate_validation_cache(token)
- if user is not None:
- await db.execute(
- update(User)
- .where(User.id == user.id)
- .values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
- )
- await db.commit()
- return
- # Fallback: global storage (auth disabled)
- result = await db.execute(
- select(Settings).where(
- Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
- )
- )
- for setting in result.scalars().all():
- await db.delete(setting)
- await db.commit()
- async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
- """Move a globally-stored cloud token onto ``user`` (auth being enabled).
- ``get_stored_token`` reads the global ``Settings`` rows when auth is off and
- ``User.cloud_token`` when it's on. Enabling auth therefore switches which
- column the cloud routes consult — without this migration the token linked
- before setup is stranded in ``Settings``, ``build_authenticated_cloud``
- returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
- The global rows are deleted after the copy so the credential isn't left at
- rest in a table nothing reads any more. Does **not** commit — the caller
- owns the transaction. Returns True when a token was actually migrated.
- """
- token, email, region = await get_stored_token(db, None)
- if not token:
- return False
- user.cloud_token = token
- user.cloud_email = email
- user.cloud_region = _normalise_region(region)
- result = await db.execute(
- select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
- )
- for setting in result.scalars().all():
- await db.delete(setting)
- return True
- async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
- """Move ``user``'s cloud token into global storage (auth being disabled).
- The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
- ``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
- admin who turns auth off would otherwise lose their own cloud link.
- Refuses to overwrite an existing global token — a stale row from a previous
- no-auth stint is still someone's credential, and clobbering it silently is
- worse than leaving this admin to re-link. Does **not** commit. Returns True
- when a token was actually migrated.
- """
- if not user.cloud_token:
- return False
- existing, _, _ = await get_stored_token(db, None)
- if existing:
- return False
- for key, value in [
- (CLOUD_TOKEN_KEY, user.cloud_token),
- (CLOUD_EMAIL_KEY, user.cloud_email),
- (CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
- ]:
- if value is None:
- continue
- db.add(Settings(key=key, value=value))
- user.cloud_token = None
- user.cloud_email = None
- user.cloud_region = None
- return True
- def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
- """Reject API keys that aren't authorised to read cloud data.
- Three independent fences for API keys (#1182):
- 1. user_id IS NOT NULL — legacy keys created before per-user ownership
- have no owner whose cloud_token we could read; force recreate.
- 2. can_access_cloud=True — opt-in scope so existing automation doesn't
- start reading cloud data without the operator explicitly enabling it.
- 3. owner has stored cloud_token — enforced separately at the route
- level via ``build_authenticated_cloud`` returning None.
- """
- if api_key.user_id is None:
- raise HTTPException(
- status_code=401,
- detail=(
- "This API key was created before per-user cloud access was supported. "
- "Recreate it from Settings → API Keys to use /cloud/* endpoints."
- ),
- )
- if not api_key.can_access_cloud:
- raise HTTPException(
- status_code=403,
- detail=(
- "This API key is not authorised to access Bambu Cloud data. "
- "Enable 'Allow cloud access' on the key in Settings → API Keys."
- ),
- )
- async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
- """Build a per-request cloud service seeded with the caller's stored token + region.
- Returns ``None`` when no token is stored, so callers can 401 without constructing
- (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
- The service is wired to persist a rejected-token flag the moment Bambu
- answers 401, so every route that builds a client this way makes the whole
- app agree the sign-in is dead — rather than each feature discovering it
- separately and reporting Bambu's own opaque "Please login." at the user.
- """
- token, _email, region = await get_stored_token(db, user)
- if not token:
- return None
- user_id = user.id if user is not None else None
- cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
- cloud.set_token(token)
- return cloud
- @router.get("/status", response_model=CloudAuthStatus)
- async def get_auth_status(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """Get current cloud authentication status.
- "We hold a token" is not the same claim as "Bambu accepts it", and this
- endpoint used to make the former while reporting the latter: it asked
- ``cloud.is_authenticated``, which was a string-presence check behind a
- self-renewing expiry, so it answered ``true`` for as long as any token
- existed — including tokens Bambu had been rejecting for months (#2562
- follow-up). It now asks Bambu.
- The verdict is cached for five minutes inside the service, so the several
- components polling this endpoint don't each pay a round-trip. When Bambu
- can't be reached the answer is ``None`` and we report the last known state
- rather than signing the user out over a transient outage.
- ``region`` is exposed so the frontend can show "Connected (China)" after a
- reload without relying on local state.
- """
- token, email, region = await get_stored_token(db, current_user)
- if not token:
- return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
- known_invalid = await is_cloud_token_invalid(db, current_user)
- user_id = current_user.id if current_user is not None else None
- cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
- cloud.set_token(token)
- try:
- if known_invalid:
- # Already recorded as dead. Don't re-ask Bambu on every poll — only a
- # new login can change this, and that clears the flag.
- accepted: bool | None = False
- else:
- accepted = await cloud.validate_token()
- finally:
- await cloud.close()
- if accepted is None:
- # Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
- # knew — a cloud outage must not present as "your sign-in expired".
- accepted = not known_invalid
- return CloudAuthStatus(
- is_authenticated=bool(accepted),
- email=email if accepted else None,
- region=region if accepted else None,
- # Distinguishes "you were signed in and the token died" from "you never
- # signed in" — the UI shows the same login form either way, but only the
- # former deserves an explanation for why it reappeared.
- sign_in_expired=not accepted,
- )
- @router.post("/login", response_model=CloudLoginResponse)
- async def login(
- request: CloudLoginRequest,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Initiate login to Bambu Cloud.
- This will trigger either:
- - Email verification: A code is sent to the user's email
- - TOTP verification: User enters code from their authenticator app
- After receiving/generating the code, call /cloud/verify to complete the login.
- For TOTP, include the tfa_key from this response in the verify request.
- """
- cloud = BambuCloudService(region=request.region)
- try:
- result = await cloud.login_request(request.email, request.password)
- if result.get("success") and cloud.access_token:
- # Direct login succeeded (rare)
- await store_token(db, cloud.access_token, request.email, request.region, current_user)
- return CloudLoginResponse(
- success=result.get("success", False),
- needs_verification=result.get("needs_verification", False),
- message=result.get("message", "Unknown error"),
- verification_type=result.get("verification_type"),
- tfa_key=result.get("tfa_key"),
- reason=result.get("reason"),
- )
- except BambuCloudAuthError as e:
- raise HTTPException(status_code=401, detail=str(e))
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.post("/verify", response_model=CloudLoginResponse)
- async def verify_code(
- request: CloudVerifyRequest,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Complete login with verification code (email or TOTP).
- For email verification:
- - After calling /cloud/login, the user receives an email with a 6-digit code
- - Submit the code with email address
- For TOTP verification:
- - The user enters the 6-digit code from their authenticator app
- - Include the tfa_key from the /cloud/login response
- ``request.region`` must match the region used in /cloud/login so that the
- TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).
- """
- cloud = BambuCloudService(region=request.region)
- try:
- # Use TOTP verification if tfa_key is provided
- if request.tfa_key:
- result = await cloud.verify_totp(request.tfa_key, request.code)
- else:
- result = await cloud.verify_code(request.email, request.code)
- if result.get("success") and cloud.access_token:
- await store_token(db, cloud.access_token, request.email, request.region, current_user)
- return CloudLoginResponse(
- success=result.get("success", False),
- needs_verification=False,
- message=result.get("message", "Unknown error"),
- reason=result.get("reason"),
- )
- except BambuCloudAuthError as e:
- raise HTTPException(status_code=401, detail=str(e))
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.post("/token", response_model=CloudAuthStatus)
- async def set_token(
- request: CloudTokenRequest,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Set access token directly.
- For users who already have a token (e.g., from Bambu Studio). The
- selected ``region`` is persisted alongside the token so every subsequent
- request hits the right Bambu API endpoint, including after a restart.
- """
- cloud = BambuCloudService(region=request.region)
- cloud.set_token(request.access_token)
- try:
- # Verify token works by trying to get profile
- await cloud.get_user_profile()
- await store_token(db, request.access_token, "token-auth", request.region, current_user)
- return CloudAuthStatus(is_authenticated=True, email="token-auth")
- except BambuCloudError:
- raise HTTPException(status_code=401, detail="Invalid token")
- finally:
- await cloud.close()
- @router.post("/logout")
- async def logout(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """Log out of Bambu Cloud."""
- await clear_token(db, current_user)
- return {"success": True}
- @router.get("/settings", response_model=SlicerSettingsResponse)
- async def get_slicer_settings(
- version: str = _SLICER_API_VERSION,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Get all slicer settings (filament, printer, process presets).
- Requires authentication.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- data = await cloud.get_slicer_settings(version)
- result = SlicerSettingsResponse()
- # Map API keys to our types (API uses 'print' for process presets)
- type_mapping = {
- "filament": "filament",
- "printer": "printer",
- "print": "process", # API calls it 'print', we call it 'process'
- }
- for api_key, our_type in type_mapping.items():
- type_data = data.get(api_key, {})
- private_settings = type_data.get("private", [])
- public_settings = type_data.get("public", [])
- parsed = []
- # Private (custom) presets first
- for s in private_settings:
- parsed.append(
- SlicerSetting(
- setting_id=s.get("setting_id", s.get("id", "")),
- name=s.get("name", "Unknown"),
- type=our_type,
- version=s.get("version"),
- user_id=s.get("user_id"),
- updated_time=s.get("updated_time"),
- is_custom=True,
- )
- )
- # Public (default) presets
- for s in public_settings:
- parsed.append(
- SlicerSetting(
- setting_id=s.get("setting_id", s.get("id", "")),
- name=s.get("name", "Unknown"),
- type=our_type,
- version=s.get("version"),
- user_id=s.get("user_id"),
- updated_time=s.get("updated_time"),
- is_custom=False,
- )
- )
- setattr(result, our_type, parsed)
- return result
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.get("/settings/{setting_id}")
- async def get_setting_detail(
- setting_id: str,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Get detailed information for a specific setting/preset.
- Returns the full preset configuration.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- data = await cloud.get_setting_detail(setting_id)
- return data
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.get("/filaments", response_model=list[SlicerSetting])
- async def get_filament_presets(
- version: str = _SLICER_API_VERSION,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
- ):
- """
- Get just filament presets (convenience endpoint).
- Returns all filament presets with custom presets first.
- Uses the same cache as get_slicer_settings.
- """
- settings = await get_slicer_settings(version=version, db=db, current_user=current_user)
- return settings.filament
- # Cache for filament preset info (setting_id -> {name, k})
- _filament_cache: dict[str, dict] = {}
- _filament_cache_time: float = 0
- FILAMENT_CACHE_TTL = 300 # 5 minutes
- # In-flight cloud lookups, keyed by setting_id (#2572). The printer overview
- # mounts one filament-info request per printer card, so at farm scale several
- # browsers ask for the same uncached preset within the same instant. Without
- # coalescing each request issues its own Bambu Cloud round-trip for the same id
- # (a thundering herd against a rate-limited API). The first caller to miss a
- # given id becomes the leader and resolves it; concurrent callers await its
- # future and reuse the result instead of duplicating the call.
- _filament_inflight: dict[str, asyncio.Future] = {}
- async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
- """Fetch a single filament preset from Bambu Cloud.
- Returns ``{"name", "k"}`` on success (name may be empty when the preset
- resolves but carries no display name), or ``None`` when the lookup fails.
- Never raises — a 400 is the expected answer for many bare preset IDs and is
- logged at DEBUG; anything else is a real fault logged at WARNING.
- """
- try:
- api_setting_id = _filament_id_to_setting_id(setting_id)
- data = await cloud.get_setting_detail(api_setting_id)
- setting = data.get("setting", {})
- name = data.get("name", "")
- k_value = setting.get("pressure_advance")
- if k_value is not None:
- try:
- k_value = float(k_value)
- except (ValueError, TypeError):
- k_value = None
- return {"name": name, "k": k_value}
- except Exception as e:
- # A 400 here is the *expected* answer, not a fault, and the local-preset
- # fallback (Phase 3) exists to handle it (#2530). Two routine causes:
- # * Many official presets are only addressable with a printer variant
- # suffix — "GFSA00" resolves, "GFSL05" does not, only "GFSL05_07"
- # (@BBL A1) does. The bare ID is all the AMS reports, so the lookup
- # legitimately misses.
- # * Personal presets ("P…") belong to the Bambu account that sliced the
- # file; another account will never resolve them.
- # Logging those at WARNING on every AMS tooltip refresh trains users to
- # ignore the log. Anything else — expired token, 5xx, a connection
- # failure — stays at WARNING because it is a fault.
- expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
- logger.log(
- logging.DEBUG if expected_miss else logging.WARNING,
- "Failed to get cloud preset %s (API ID: %s): %s",
- setting_id,
- _filament_id_to_setting_id(setting_id),
- e,
- )
- return None
- async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
- """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
- Concurrent callers for the same ``setting_id`` share one cloud round-trip:
- the first caller resolves it while the rest await the shared future. Returns
- the info dict (also populating ``_filament_cache``) or ``None`` on failure.
- """
- if setting_id in _filament_cache:
- return _filament_cache[setting_id]
- existing = _filament_inflight.get(setting_id)
- if existing is not None:
- # Another request is already fetching this id — reuse its result.
- # shield() so our own cancellation can't cancel the shared leader.
- try:
- return await asyncio.shield(existing)
- except Exception:
- return None
- fut: asyncio.Future = asyncio.get_event_loop().create_future()
- _filament_inflight[setting_id] = fut
- info: dict | None = None
- try:
- info = await _fetch_one_cloud_filament(setting_id, cloud)
- return info
- finally:
- if info is not None:
- _filament_cache[setting_id] = info
- if not fut.done():
- fut.set_result(info)
- _filament_inflight.pop(setting_id, None)
- # Built-in filament ID → name mapping (fallback when cloud API and local profiles
- # don't have the entry). Based on Bambu Lab's known filament catalogue.
- _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
- "GFA00": "Bambu PLA Basic",
- "GFA01": "Bambu PLA Matte",
- "GFA02": "Bambu PLA Metal",
- "GFA05": "Bambu PLA Silk",
- "GFA06": "Bambu PLA Silk+",
- "GFA07": "Bambu PLA Marble",
- "GFA08": "Bambu PLA Sparkle",
- "GFA09": "Bambu PLA Tough",
- "GFA11": "Bambu PLA Aero",
- "GFA12": "Bambu PLA Glow",
- "GFA13": "Bambu PLA Dynamic",
- "GFA15": "Bambu PLA Galaxy",
- "GFA16": "Bambu PLA Wood",
- "GFA50": "Bambu PLA-CF",
- "GFB00": "Bambu ABS",
- "GFB01": "Bambu ASA",
- "GFB02": "Bambu ASA-Aero",
- "GFB50": "Bambu ABS-GF",
- "GFB51": "Bambu ASA-CF",
- "GFB60": "PolyLite ABS",
- "GFB61": "PolyLite ASA",
- "GFB98": "Generic ASA",
- "GFB99": "Generic ABS",
- "GFC00": "Bambu PC",
- "GFC01": "Bambu PC FR",
- "GFC99": "Generic PC",
- "GFG00": "Bambu PETG Basic",
- "GFG01": "Bambu PETG Translucent",
- "GFG02": "Bambu PETG HF",
- "GFG50": "Bambu PETG-CF",
- "GFG60": "PolyLite PETG",
- "GFG96": "Generic PETG HF",
- "GFG97": "Generic PCTG",
- "GFG98": "Generic PETG-CF",
- "GFG99": "Generic PETG",
- "GFL00": "PolyLite PLA",
- "GFL01": "PolyTerra PLA",
- "GFL03": "eSUN PLA+",
- "GFL04": "Overture PLA",
- "GFL05": "Overture Matte PLA",
- "GFL06": "Fiberon PETG-ESD",
- "GFL50": "Fiberon PA6-CF",
- "GFL51": "Fiberon PA6-GF",
- "GFL52": "Fiberon PA12-CF",
- "GFL53": "Fiberon PA612-CF",
- "GFL54": "Fiberon PET-CF",
- "GFL55": "Fiberon PETG-rCF",
- "GFL95": "Generic PLA High Speed",
- "GFL96": "Generic PLA Silk",
- "GFL98": "Generic PLA-CF",
- "GFL99": "Generic PLA",
- "GFN03": "Bambu PA-CF",
- "GFN04": "Bambu PAHT-CF",
- "GFN05": "Bambu PA6-CF",
- "GFN06": "Bambu PPA-CF",
- "GFN08": "Bambu PA6-GF",
- "GFN96": "Generic PPA-GF",
- "GFN97": "Generic PPA-CF",
- "GFN98": "Generic PA-CF",
- "GFN99": "Generic PA",
- "GFP95": "Generic PP-GF",
- "GFP96": "Generic PP-CF",
- "GFP97": "Generic PP",
- "GFP98": "Generic PE-CF",
- "GFP99": "Generic PE",
- "GFR98": "Generic PHA",
- "GFR99": "Generic EVA",
- "GFS00": "Bambu Support W",
- "GFS01": "Bambu Support G",
- "GFS02": "Bambu Support For PLA",
- "GFS03": "Bambu Support For PA/PET",
- "GFS04": "Bambu PVA",
- "GFS05": "Bambu Support For PLA/PETG",
- "GFS06": "Bambu Support for ABS",
- "GFS97": "Generic BVOH",
- "GFS98": "Generic HIPS",
- "GFS99": "Generic PVA",
- "GFT01": "Bambu PET-CF",
- "GFT02": "Bambu PPS-CF",
- "GFT97": "Generic PPS",
- "GFT98": "Generic PPS-CF",
- "GFU00": "Bambu TPU 95A HF",
- "GFU01": "Bambu TPU 95A",
- "GFU02": "Bambu TPU for AMS",
- "GFU98": "Generic TPU for AMS",
- "GFU99": "Generic TPU",
- }
- async def _enrich_from_local_presets(
- unresolved_ids: list[str],
- result: dict,
- db: AsyncSession,
- ) -> dict:
- """Fall back to local profiles for filament IDs not resolved by cloud.
- Matches by checking the setting_id field inside the local preset's
- resolved JSON blob (stored in the 'setting' column).
- """
- from sqlalchemy import text
- from backend.app.models.local_preset import LocalPreset
- # Build lookup: converted setting_id -> original filament_id
- id_map: dict[str, str] = {}
- for fid in unresolved_ids:
- converted = _filament_id_to_setting_id(fid)
- id_map[converted] = fid
- # Also map the original in case the JSON uses that form
- id_map[fid] = fid
- try:
- # Query filament presets that have a setting_id matching any of our IDs
- from backend.app.core.db_dialect import is_sqlite
- if is_sqlite():
- json_filter = text("json_extract(setting, '$.setting_id') IS NOT NULL")
- else:
- json_filter = text("(setting::jsonb->>'setting_id') IS NOT NULL")
- candidates = await db.execute(
- select(LocalPreset).where(
- LocalPreset.preset_type == "filament",
- json_filter,
- )
- )
- for preset in candidates.scalars().all():
- try:
- setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
- preset_setting_id = setting_data.get("setting_id", "")
- if preset_setting_id in id_map:
- original_id = id_map[preset_setting_id]
- info = {"name": preset.name, "k": None}
- # Try to extract K value from the local preset
- pa = setting_data.get("pressure_advance")
- if pa is not None:
- try:
- k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
- info["k"] = k_val
- except (ValueError, TypeError, IndexError):
- pass
- _filament_cache[original_id] = info
- result[original_id] = info
- except Exception:
- continue
- except Exception as e:
- logger.warning("Failed to search local presets for filament info: %s", e)
- # Phase 4: Fall back to built-in filament name table for any still without a name
- for fid in unresolved_ids:
- if fid not in result or not result[fid].get("name"):
- name = _BUILTIN_FILAMENT_NAMES.get(fid, "")
- if name:
- # Preserve K value from earlier phases if available
- existing_k = result.get(fid, {}).get("k")
- info = {"name": name, "k": existing_k}
- _filament_cache[fid] = info
- result[fid] = info
- # Fill remaining unresolved with empty entries
- for fid in unresolved_ids:
- if fid not in result:
- _filament_cache[fid] = {"name": "", "k": None}
- result[fid] = {"name": "", "k": None}
- return result
- # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
- _filament_id_to_setting_id = filament_id_to_setting_id
- @router.post("/filament-info")
- async def get_filament_info(
- setting_ids: list[str] = Body(...),
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
- ):
- """
- Get filament preset info (name and K value) for multiple setting IDs.
- Used to enrich AMS tray and nozzle rack tooltips with preset data.
- Lookup order: cache → cloud → local profiles → built-in table → empty fallback.
- """
- import time
- logger.info("get_filament_info called with %s IDs: %s", len(setting_ids), setting_ids)
- global _filament_cache, _filament_cache_time
- # Clear stale cache
- if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
- _filament_cache = {}
- _filament_cache_time = time.time()
- result = {}
- unresolved_ids: list[str] = []
- # Phase 1: Check cache
- for setting_id in setting_ids:
- if not setting_id:
- continue
- if setting_id in _filament_cache:
- result[setting_id] = _filament_cache[setting_id]
- else:
- unresolved_ids.append(setting_id)
- # Phase 2: Try cloud for uncached IDs
- if unresolved_ids:
- cloud = await build_authenticated_cloud(db, current_user)
- # Release the request's DB transaction before the sequential Bambu Cloud
- # round-trips below (#2572). build_authenticated_cloud has read the
- # stored token — the only DB access this phase needs — and nothing until
- # Phase 3 touches the DB again. Without this the session sat "idle in
- # transaction" for the full duration of N external HTTP calls, pinning a
- # pooled connection per in-flight request. Phase 3's read transparently
- # opens a fresh transaction on the same still-open session.
- await db.rollback()
- if cloud is not None and cloud.is_authenticated:
- try:
- still_unresolved: list[str] = []
- for setting_id in unresolved_ids:
- info = await _resolve_cloud_filament(setting_id, cloud)
- if info is not None:
- result[setting_id] = info
- if info is None or not info.get("name"):
- still_unresolved.append(setting_id)
- unresolved_ids = still_unresolved
- finally:
- await cloud.close()
- elif cloud is not None:
- await cloud.close()
- # Phase 3: Try local profiles for any IDs still without a name
- if unresolved_ids:
- result = await _enrich_from_local_presets(unresolved_ids, result, db)
- return result
- @router.get("/devices", response_model=list[CloudDevice])
- async def get_devices(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
- ):
- """
- Get list of bound printer devices.
- Returns printers registered to the user's Bambu account.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- data = await cloud.get_devices()
- devices = data.get("devices", [])
- return [
- CloudDevice(
- dev_id=d.get("dev_id", ""),
- name=d.get("name", "Unknown"),
- dev_model_name=d.get("dev_model_name"),
- dev_product_name=d.get("dev_product_name"),
- online=d.get("online", False),
- )
- for d in devices
- ]
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
- async def get_firmware_updates(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
- ):
- """
- Check for firmware updates for all bound devices.
- Returns firmware version info for each device including:
- - Current installed version
- - Latest available version
- - Whether an update is available
- - Release notes for the latest version
- Requires cloud authentication.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- # First get list of bound devices
- devices_data = await cloud.get_devices()
- devices = devices_data.get("devices", [])
- updates = []
- updates_available = 0
- # Check firmware for each device
- for device in devices:
- device_id = device.get("dev_id", "")
- device_name = device.get("name", "Unknown")
- try:
- firmware_info = await cloud.get_firmware_version(device_id)
- update_available = firmware_info.get("update_available", False)
- if update_available:
- updates_available += 1
- updates.append(
- FirmwareUpdateInfo(
- device_id=device_id,
- device_name=device_name,
- current_version=firmware_info.get("current_version"),
- latest_version=firmware_info.get("latest_version"),
- update_available=update_available,
- release_notes=firmware_info.get("release_notes"),
- )
- )
- except BambuCloudError as e:
- logger.warning("Failed to get firmware info for %s: %s", device_name, e)
- # Still include device but with unknown firmware status
- updates.append(
- FirmwareUpdateInfo(
- device_id=device_id,
- device_name=device_name,
- current_version=None,
- latest_version=None,
- update_available=False,
- release_notes=None,
- )
- )
- return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.post("/settings")
- async def create_setting(
- request: SlicerSettingCreate,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Create a new slicer preset/setting.
- Creates a new preset on Bambu Cloud. The preset inherits from a base preset
- and only stores the delta (modified values).
- Type should be: 'filament', 'print', or 'printer'
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- data = await cloud.create_setting(
- preset_type=request.type,
- name=request.name,
- base_id=request.base_id,
- setting=request.setting,
- version=request.version,
- )
- return data
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.put("/settings/{setting_id}")
- async def update_setting(
- setting_id: str,
- request: SlicerSettingUpdate,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Update an existing slicer preset/setting.
- Updates the preset's name and/or settings on Bambu Cloud.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- data = await cloud.update_setting(
- setting_id=setting_id,
- name=request.name,
- setting=request.setting,
- )
- return data
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
- async def delete_setting(
- setting_id: str,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
- ):
- """
- Delete a slicer preset/setting.
- Removes the preset from Bambu Cloud. This cannot be undone.
- """
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- result = await cloud.delete_setting(setting_id)
- return SlicerSettingDeleteResponse(
- success=result.get("success", True),
- message=result.get("message", "Setting deleted"),
- )
- except BambuCloudAuthError:
- await clear_token(db, current_user)
- raise HTTPException(status_code=401, detail="Authentication expired")
- except BambuCloudError as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- await cloud.close()
- # Path to field definition files
- FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
- # Cache for field definitions (loaded once)
- _fields_cache: dict[str, dict] = {}
- def _load_fields(preset_type: str) -> dict:
- """Load field definitions from JSON file."""
- if preset_type in _fields_cache:
- return _fields_cache[preset_type]
- # Map API type names to file names
- file_map = {
- "filament": "filament_fields.json",
- "print": "process_fields.json",
- "process": "process_fields.json",
- "printer": "printer_fields.json",
- }
- filename = file_map.get(preset_type)
- if not filename:
- raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
- file_path = FIELDS_DATA_DIR / filename
- if not file_path.exists():
- raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
- with open(file_path) as f:
- data = json.load(f)
- _fields_cache[preset_type] = data
- return data
- @router.get("/builtin-filaments")
- async def get_builtin_filaments(
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """
- Get built-in filament names as a fallback source.
- Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
- {filament_id, name} objects. Used by the frontend when cloud
- and local profiles are unavailable.
- """
- return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
- # Cache for filament_id → name mapping (resolved from cloud preset details)
- _filament_id_name_cache: dict[str, str] = {}
- _filament_id_name_cache_time: float = 0
- @router.get("/filament-id-map")
- async def get_filament_id_map(
- db: AsyncSession = Depends(get_db),
- current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
- ):
- """
- Get filament_id → name mapping for user cloud presets.
- K-profiles store a filament_id (e.g., "P4d64437") which is different from
- the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
- fetches details for all custom presets and returns the mapping.
- Cached for 5 minutes.
- """
- import time
- global _filament_id_name_cache, _filament_id_name_cache_time
- if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
- return _filament_id_name_cache
- cloud = await build_authenticated_cloud(db, current_user)
- if cloud is None or not cloud.is_authenticated:
- if cloud is not None:
- await cloud.close()
- return _filament_id_name_cache or {}
- try:
- data = await cloud.get_slicer_settings()
- custom_presets = data.get("filament", {}).get("private", [])
- result: dict[str, str] = {}
- for preset in custom_presets:
- setting_id = preset.get("setting_id", "")
- if not setting_id:
- continue
- try:
- detail = await cloud.get_setting_detail(setting_id)
- fid = detail.get("filament_id", "")
- name = detail.get("name", "")
- if fid and name:
- # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
- clean_name = name.split(" @")[0].strip() if " @" in name else name
- result[fid] = clean_name
- except Exception:
- pass
- _filament_id_name_cache = result
- _filament_id_name_cache_time = time.time()
- return result
- except Exception:
- return _filament_id_name_cache or {}
- finally:
- await cloud.close()
- @router.get("/fields/{preset_type}")
- async def get_preset_fields(
- preset_type: Literal["filament", "print", "process", "printer"],
- _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
- ):
- """
- Get field definitions for a preset type.
- Returns a list of field definitions including:
- - key: The setting key name
- - label: Human-readable label
- - type: Field type (text, number, boolean, select)
- - category: Grouping category
- - description: Field description
- - options: For select fields, available options
- - unit: Unit of measurement (if applicable)
- - min/max/step: For number fields, validation constraints
- """
- data = _load_fields(preset_type)
- return data
- @router.get("/fields")
- async def get_all_preset_fields(
- _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
- ):
- """
- Get all field definitions for all preset types.
- Returns field definitions organized by type.
- """
- return {
- "filament": _load_fields("filament"),
- "process": _load_fields("process"),
- "printer": _load_fields("printer"),
- }
|