cloud.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437
  1. """
  2. Bambu Lab Cloud API Routes
  3. Handles authentication and profile management with Bambu Cloud.
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. from pathlib import Path
  9. from typing import Literal
  10. from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
  11. from fastapi.security import HTTPAuthorizationCredentials
  12. from sqlalchemy import select, update
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core import database
  15. from backend.app.core.auth import (
  16. RequirePermissionIfAuthEnabled,
  17. _user_from_api_key,
  18. _validate_api_key,
  19. require_permission_if_auth_enabled,
  20. security,
  21. )
  22. from backend.app.core.database import get_db
  23. from backend.app.core.permissions import Permission
  24. from backend.app.core.tasks import spawn_background_task
  25. from backend.app.models.api_key import APIKey
  26. from backend.app.models.settings import Settings
  27. from backend.app.models.user import User
  28. from backend.app.schemas.cloud import (
  29. CloudAuthStatus,
  30. CloudDevice,
  31. CloudLoginRequest,
  32. CloudLoginResponse,
  33. CloudTokenRequest,
  34. CloudVerifyRequest,
  35. FirmwareUpdateInfo,
  36. FirmwareUpdatesResponse,
  37. SlicerSetting,
  38. SlicerSettingCreate,
  39. SlicerSettingDeleteResponse,
  40. SlicerSettingsResponse,
  41. SlicerSettingUpdate,
  42. )
  43. from backend.app.services.bambu_cloud import (
  44. _SLICER_API_VERSION,
  45. BambuCloudAuthError,
  46. BambuCloudError,
  47. BambuCloudService,
  48. invalidate_validation_cache,
  49. )
  50. # Credential read/write lives in the services layer so feature packages can
  51. # consume it without importing the route layer. Imported here for this
  52. # module's own use; consumers should import from bambu_cloud_credentials
  53. # directly rather than through this route module.
  54. from backend.app.services.bambu_cloud_credentials import (
  55. CLOUD_EMAIL_KEY,
  56. CLOUD_REGION_KEY,
  57. CLOUD_TOKEN_INVALID_KEY,
  58. CLOUD_TOKEN_KEY,
  59. _clear_cloud_token_invalid,
  60. _normalise_region,
  61. get_stored_token,
  62. is_cloud_token_invalid,
  63. mark_cloud_token_invalid,
  64. )
  65. from backend.app.utils.filament_ids import filament_id_to_setting_id
  66. logger = logging.getLogger(__name__)
  67. async def _cloud_api_key_gate(
  68. request: Request,
  69. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  70. x_api_key: str | None = Header(default=None, alias="X-API-Key"),
  71. db: AsyncSession = Depends(get_db),
  72. ) -> None:
  73. """Router-level dependency: enforce API-key cloud-access fences (#1182).
  74. Runs before every /cloud/* handler. JWT-authed and anonymous callers are
  75. no-ops — their access is gated by the per-route ``Permission.CLOUD_AUTH``
  76. / ``Permission.FILAMENTS_READ`` / etc. dependency. API-keyed callers
  77. must have an owner and ``can_access_cloud=True``; legacy ownerless keys
  78. and keys without the cloud scope are rejected here.
  79. On a successful API-keyed request the owner User is stashed on
  80. ``request.state.api_key_owner`` so route handlers can resolve it via
  81. ``cloud_caller`` (the auth gate returns None for API keys to avoid a
  82. wider behaviour change in non-cloud routes — see auth.py).
  83. The dep duplicates the API-key validation done by the regular auth gate
  84. (which runs as a route-level dep, *after* router-level deps). The cost
  85. is one extra ``SELECT FROM api_keys`` per /cloud/* request — bounded and
  86. cheap (key_prefix is indexed).
  87. """
  88. api_key_value: str | None = None
  89. if x_api_key:
  90. api_key_value = x_api_key
  91. elif credentials and credentials.credentials.startswith("bb_"):
  92. api_key_value = credentials.credentials
  93. if api_key_value is None:
  94. return # JWT or anonymous — no-op
  95. api_key = await _validate_api_key(db, api_key_value)
  96. if api_key is None:
  97. # Invalid key — let the route-level auth gate produce the 401 so the
  98. # error matches what every other route returns for a bad key.
  99. return
  100. _assert_api_key_can_access_cloud(api_key)
  101. # All fences passed. Stash the owner so cloud routes can resolve their
  102. # caller User without going through the auth gate (which intentionally
  103. # returns None for API keys to keep #1182 surface-bounded to /cloud/*).
  104. request.state.api_key_owner = await _user_from_api_key(db, api_key)
  105. def cloud_caller(*permissions: Permission):
  106. """Route-level dep factory for /cloud/* handlers.
  107. Returns a Depends that resolves to:
  108. - the JWT-authenticated User (when a JWT is present and the route's
  109. permission set is satisfied), OR
  110. - the API-key owner User stashed by the router-level gate
  111. (``request.state.api_key_owner``), OR
  112. - None when auth is disabled.
  113. Replaces the direct ``RequirePermissionIfAuthEnabled(...)`` dep on cloud
  114. routes so API-keyed callers get the *owner* in ``current_user`` rather
  115. than None — without that the route falls back to the global Settings
  116. cloud_token, which is empty in auth-enabled deployments.
  117. """
  118. base_dep = require_permission_if_auth_enabled(*permissions)
  119. async def resolved(
  120. request: Request,
  121. base_user: User | None = Depends(base_dep),
  122. ) -> User | None:
  123. if base_user is not None:
  124. return base_user
  125. return getattr(request.state, "api_key_owner", None)
  126. return Depends(resolved)
  127. async def resolve_api_key_cloud_owner(
  128. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  129. x_api_key: str | None = Header(default=None, alias="X-API-Key"),
  130. db: AsyncSession = Depends(get_db),
  131. ) -> User | None:
  132. """Route-level dep for non-/cloud/* endpoints that need to read the
  133. caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
  134. presets — #1182 follow-up).
  135. Returns the API key's owner User when the caller is an API-keyed
  136. request *and* the key has ``can_access_cloud=True``; returns None for
  137. JWT, anonymous, or API keys without the cloud scope. The caller is
  138. expected to fall back to the JWT-authed ``current_user`` first and use
  139. this dep's result only when ``current_user`` is None.
  140. Unlike ``_cloud_api_key_gate`` (which 403s legacy/non-cloud keys at the
  141. router level), this dep is permissive: it returns None instead of
  142. raising, so a slice request via an API key without cloud scope still
  143. runs against local presets. The downstream cloud-token check in
  144. ``preset_resolver._resolve_cloud`` produces the right 400 if the
  145. request actually selects a cloud preset.
  146. """
  147. api_key_value: str | None = None
  148. if x_api_key:
  149. api_key_value = x_api_key
  150. elif credentials and credentials.credentials.startswith("bb_"):
  151. api_key_value = credentials.credentials
  152. if api_key_value is None:
  153. return None
  154. api_key = await _validate_api_key(db, api_key_value)
  155. if api_key is None or api_key.user_id is None or not api_key.can_access_cloud:
  156. return None
  157. return await _user_from_api_key(db, api_key)
  158. router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
  159. def _forget_account_presets(account: int | None) -> None:
  160. """Drop what was read with an account's cloud token, once that token changes."""
  161. _filament_id_name_cache.pop(account, None)
  162. # A build still running reads with the old token; it must not be joined.
  163. _filament_id_map_inflight.pop(account, None)
  164. for key in [key for key in _filament_cache if key[0] == account]:
  165. del _filament_cache[key]
  166. def forget_all_cloud_presets() -> None:
  167. """Drop every account's cached presets (the user table was replaced)."""
  168. _filament_id_name_cache.clear()
  169. _filament_id_map_inflight.clear()
  170. _filament_cache.clear()
  171. async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
  172. """Store cloud token, email, and region.
  173. When a user is provided (auth enabled), stores on the user record.
  174. When user is None (auth disabled), stores in global Settings table.
  175. Always clears the rejected-token flag: this is a *fresh* credential, and
  176. leaving the flag set would report the new sign-in as expired.
  177. """
  178. region = _normalise_region(region)
  179. invalidate_validation_cache(token)
  180. # The account may be another one now.
  181. _forget_account_presets(user.id if user is not None else None)
  182. if user is not None:
  183. # User object is from the auth dependency's session (detached),
  184. # so use a direct UPDATE via the route's db session.
  185. await db.execute(
  186. update(User)
  187. .where(User.id == user.id)
  188. .values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
  189. )
  190. await db.commit()
  191. return
  192. # Fallback: global storage (auth disabled)
  193. for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:
  194. result = await db.execute(select(Settings).where(Settings.key == key))
  195. setting = result.scalar_one_or_none()
  196. if setting:
  197. setting.value = value
  198. else:
  199. db.add(Settings(key=key, value=value))
  200. await _clear_cloud_token_invalid(db, None)
  201. await db.commit()
  202. async def clear_token(db: AsyncSession, user: User | None = None) -> None:
  203. """Clear stored cloud token, email, and region.
  204. When a user is provided (auth enabled), clears that user's credentials.
  205. When user is None (auth disabled), clears from global Settings table.
  206. The rejected-token flag goes with the token: once there is no credential,
  207. "the credential is dead" is not a state worth remembering, and leaving it
  208. behind would make the next login look expired the moment it is stored.
  209. """
  210. token, _email, _region = await get_stored_token(db, user)
  211. if token:
  212. invalidate_validation_cache(token)
  213. _forget_account_presets(user.id if user is not None else None)
  214. if user is not None:
  215. await db.execute(
  216. update(User)
  217. .where(User.id == user.id)
  218. .values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
  219. )
  220. await db.commit()
  221. return
  222. # Fallback: global storage (auth disabled)
  223. result = await db.execute(
  224. select(Settings).where(
  225. Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
  226. )
  227. )
  228. for setting in result.scalars().all():
  229. await db.delete(setting)
  230. await db.commit()
  231. async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
  232. """Move a globally-stored cloud token onto ``user`` (auth being enabled).
  233. ``get_stored_token`` reads the global ``Settings`` rows when auth is off and
  234. ``User.cloud_token`` when it's on. Enabling auth therefore switches which
  235. column the cloud routes consult — without this migration the token linked
  236. before setup is stranded in ``Settings``, ``build_authenticated_cloud``
  237. returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
  238. The global rows are deleted after the copy so the credential isn't left at
  239. rest in a table nothing reads any more. Does **not** commit — the caller
  240. owns the transaction. Returns True when a token was actually migrated.
  241. """
  242. token, email, region = await get_stored_token(db, None)
  243. if not token:
  244. return False
  245. user.cloud_token = token
  246. user.cloud_email = email
  247. user.cloud_region = _normalise_region(region)
  248. result = await db.execute(
  249. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  250. )
  251. for setting in result.scalars().all():
  252. await db.delete(setting)
  253. return True
  254. async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
  255. """Move ``user``'s cloud token into global storage (auth being disabled).
  256. The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
  257. ``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
  258. admin who turns auth off would otherwise lose their own cloud link.
  259. Refuses to overwrite an existing global token — a stale row from a previous
  260. no-auth stint is still someone's credential, and clobbering it silently is
  261. worse than leaving this admin to re-link. Does **not** commit. Returns True
  262. when a token was actually migrated.
  263. """
  264. if not user.cloud_token:
  265. return False
  266. existing, _, _ = await get_stored_token(db, None)
  267. if existing:
  268. return False
  269. for key, value in [
  270. (CLOUD_TOKEN_KEY, user.cloud_token),
  271. (CLOUD_EMAIL_KEY, user.cloud_email),
  272. (CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
  273. ]:
  274. if value is None:
  275. continue
  276. db.add(Settings(key=key, value=value))
  277. user.cloud_token = None
  278. user.cloud_email = None
  279. user.cloud_region = None
  280. return True
  281. def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
  282. """Reject API keys that aren't authorised to read cloud data.
  283. Three independent fences for API keys (#1182):
  284. 1. user_id IS NOT NULL — legacy keys created before per-user ownership
  285. have no owner whose cloud_token we could read; force recreate.
  286. 2. can_access_cloud=True — opt-in scope so existing automation doesn't
  287. start reading cloud data without the operator explicitly enabling it.
  288. 3. owner has stored cloud_token — enforced separately at the route
  289. level via ``build_authenticated_cloud`` returning None.
  290. """
  291. if api_key.user_id is None:
  292. raise HTTPException(
  293. status_code=401,
  294. detail=(
  295. "This API key was created before per-user cloud access was supported. "
  296. "Recreate it from Settings → API Keys to use /cloud/* endpoints."
  297. ),
  298. )
  299. if not api_key.can_access_cloud:
  300. raise HTTPException(
  301. status_code=403,
  302. detail=(
  303. "This API key is not authorised to access Bambu Cloud data. "
  304. "Enable 'Allow cloud access' on the key in Settings → API Keys."
  305. ),
  306. )
  307. async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
  308. """Build a per-request cloud service seeded with the caller's stored token + region.
  309. Returns ``None`` when no token is stored, so callers can 401 without constructing
  310. (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
  311. The service is wired to persist a rejected-token flag the moment Bambu
  312. answers 401, so every route that builds a client this way makes the whole
  313. app agree the sign-in is dead — rather than each feature discovering it
  314. separately and reporting Bambu's own opaque "Please login." at the user.
  315. """
  316. token, _email, region = await get_stored_token(db, user)
  317. if not token:
  318. return None
  319. user_id = user.id if user is not None else None
  320. cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
  321. cloud.set_token(token)
  322. return cloud
  323. @router.get("/status", response_model=CloudAuthStatus)
  324. async def get_auth_status(
  325. db: AsyncSession = Depends(get_db),
  326. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  327. ):
  328. """Get current cloud authentication status.
  329. "We hold a token" is not the same claim as "Bambu accepts it", and this
  330. endpoint used to make the former while reporting the latter: it asked
  331. ``cloud.is_authenticated``, which was a string-presence check behind a
  332. self-renewing expiry, so it answered ``true`` for as long as any token
  333. existed — including tokens Bambu had been rejecting for months (#2562
  334. follow-up). It now asks Bambu.
  335. The verdict is cached for five minutes inside the service, so the several
  336. components polling this endpoint don't each pay a round-trip. When Bambu
  337. can't be reached the answer is ``None`` and we report the last known state
  338. rather than signing the user out over a transient outage.
  339. ``region`` is exposed so the frontend can show "Connected (China)" after a
  340. reload without relying on local state.
  341. """
  342. token, email, region = await get_stored_token(db, current_user)
  343. if not token:
  344. return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
  345. known_invalid = await is_cloud_token_invalid(db, current_user)
  346. user_id = current_user.id if current_user is not None else None
  347. cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
  348. cloud.set_token(token)
  349. try:
  350. if known_invalid:
  351. # Already recorded as dead. Don't re-ask Bambu on every poll — only a
  352. # new login can change this, and that clears the flag.
  353. accepted: bool | None = False
  354. else:
  355. accepted = await cloud.validate_token()
  356. finally:
  357. await cloud.close()
  358. if accepted is None:
  359. # Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
  360. # knew — a cloud outage must not present as "your sign-in expired".
  361. accepted = not known_invalid
  362. return CloudAuthStatus(
  363. is_authenticated=bool(accepted),
  364. email=email if accepted else None,
  365. region=region if accepted else None,
  366. # Distinguishes "you were signed in and the token died" from "you never
  367. # signed in" — the UI shows the same login form either way, but only the
  368. # former deserves an explanation for why it reappeared.
  369. sign_in_expired=not accepted,
  370. )
  371. @router.post("/login", response_model=CloudLoginResponse)
  372. async def login(
  373. request: CloudLoginRequest,
  374. db: AsyncSession = Depends(get_db),
  375. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  376. ):
  377. """
  378. Initiate login to Bambu Cloud.
  379. This will trigger either:
  380. - Email verification: A code is sent to the user's email
  381. - TOTP verification: User enters code from their authenticator app
  382. After receiving/generating the code, call /cloud/verify to complete the login.
  383. For TOTP, include the tfa_key from this response in the verify request.
  384. """
  385. cloud = BambuCloudService(region=request.region)
  386. try:
  387. result = await cloud.login_request(request.email, request.password)
  388. if result.get("success") and cloud.access_token:
  389. # Direct login succeeded (rare)
  390. await store_token(db, cloud.access_token, request.email, request.region, current_user)
  391. return CloudLoginResponse(
  392. success=result.get("success", False),
  393. needs_verification=result.get("needs_verification", False),
  394. message=result.get("message", "Unknown error"),
  395. verification_type=result.get("verification_type"),
  396. tfa_key=result.get("tfa_key"),
  397. reason=result.get("reason"),
  398. )
  399. except BambuCloudAuthError as e:
  400. raise HTTPException(status_code=401, detail=str(e))
  401. except BambuCloudError as e:
  402. # Bambu Cloud failed or could not be reached: a bad gateway.
  403. raise HTTPException(status_code=502, detail=str(e))
  404. finally:
  405. await cloud.close()
  406. @router.post("/verify", response_model=CloudLoginResponse)
  407. async def verify_code(
  408. request: CloudVerifyRequest,
  409. db: AsyncSession = Depends(get_db),
  410. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  411. ):
  412. """
  413. Complete login with verification code (email or TOTP).
  414. For email verification:
  415. - After calling /cloud/login, the user receives an email with a 6-digit code
  416. - Submit the code with email address
  417. For TOTP verification:
  418. - The user enters the 6-digit code from their authenticator app
  419. - Include the tfa_key from the /cloud/login response
  420. ``request.region`` must match the region used in /cloud/login so that the
  421. TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).
  422. """
  423. cloud = BambuCloudService(region=request.region)
  424. try:
  425. # Use TOTP verification if tfa_key is provided
  426. if request.tfa_key:
  427. result = await cloud.verify_totp(request.tfa_key, request.code)
  428. else:
  429. result = await cloud.verify_code(request.email, request.code)
  430. if result.get("success") and cloud.access_token:
  431. await store_token(db, cloud.access_token, request.email, request.region, current_user)
  432. return CloudLoginResponse(
  433. success=result.get("success", False),
  434. needs_verification=False,
  435. message=result.get("message", "Unknown error"),
  436. reason=result.get("reason"),
  437. )
  438. except BambuCloudAuthError as e:
  439. raise HTTPException(status_code=401, detail=str(e))
  440. except BambuCloudError as e:
  441. # Bambu Cloud failed or could not be reached: a bad gateway.
  442. raise HTTPException(status_code=502, detail=str(e))
  443. finally:
  444. await cloud.close()
  445. @router.post("/token", response_model=CloudAuthStatus)
  446. async def set_token(
  447. request: CloudTokenRequest,
  448. db: AsyncSession = Depends(get_db),
  449. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  450. ):
  451. """
  452. Set access token directly.
  453. For users who already have a token (e.g., from Bambu Studio). The
  454. selected ``region`` is persisted alongside the token so every subsequent
  455. request hits the right Bambu API endpoint, including after a restart.
  456. """
  457. # A pasted token often carries a stray space or line break. One that still
  458. # cannot be sent as a header (control characters, spaces, non-ASCII) is not
  459. # a token Bambu could accept: refused here, without reaching Bambu, and
  460. # without echoing it back in an HTTP client's error.
  461. token = request.access_token.strip()
  462. if not token or not token.isascii() or not token.isprintable() or " " in token:
  463. raise HTTPException(status_code=401, detail="Invalid token")
  464. cloud = BambuCloudService(region=request.region)
  465. cloud.set_token(token)
  466. try:
  467. # Verify token works by trying to get profile
  468. await cloud.get_user_profile()
  469. await store_token(db, token, "token-auth", request.region, current_user)
  470. return CloudAuthStatus(is_authenticated=True, email="token-auth")
  471. except BambuCloudError as e:
  472. # Invalid only when Bambu Cloud refused it; a cloud that could not be
  473. # reached or failed says nothing about the token.
  474. if isinstance(e, BambuCloudAuthError) or e.status_code in (401, 403):
  475. raise HTTPException(status_code=401, detail="Invalid token")
  476. raise HTTPException(status_code=502, detail=str(e))
  477. finally:
  478. await cloud.close()
  479. @router.post("/logout")
  480. async def logout(
  481. db: AsyncSession = Depends(get_db),
  482. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  483. ):
  484. """Log out of Bambu Cloud."""
  485. await clear_token(db, current_user)
  486. return {"success": True}
  487. @router.get("/settings", response_model=SlicerSettingsResponse)
  488. async def get_slicer_settings(
  489. version: str = _SLICER_API_VERSION,
  490. db: AsyncSession = Depends(get_db),
  491. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  492. ):
  493. """
  494. Get all slicer settings (filament, printer, process presets).
  495. Requires authentication.
  496. """
  497. cloud = await build_authenticated_cloud(db, current_user)
  498. if cloud is None or not cloud.is_authenticated:
  499. raise HTTPException(status_code=401, detail="Not authenticated")
  500. try:
  501. data = await cloud.get_slicer_settings(version)
  502. result = SlicerSettingsResponse()
  503. # Map API keys to our types (API uses 'print' for process presets)
  504. type_mapping = {
  505. "filament": "filament",
  506. "printer": "printer",
  507. "print": "process", # API calls it 'print', we call it 'process'
  508. }
  509. for api_key, our_type in type_mapping.items():
  510. type_data = data.get(api_key, {})
  511. private_settings = type_data.get("private", [])
  512. public_settings = type_data.get("public", [])
  513. parsed = []
  514. # Private (custom) presets first
  515. for s in private_settings:
  516. parsed.append(
  517. SlicerSetting(
  518. setting_id=s.get("setting_id", s.get("id", "")),
  519. name=s.get("name", "Unknown"),
  520. type=our_type,
  521. version=s.get("version"),
  522. user_id=s.get("user_id"),
  523. updated_time=s.get("updated_time"),
  524. is_custom=True,
  525. )
  526. )
  527. # Public (default) presets
  528. for s in public_settings:
  529. parsed.append(
  530. SlicerSetting(
  531. setting_id=s.get("setting_id", s.get("id", "")),
  532. name=s.get("name", "Unknown"),
  533. type=our_type,
  534. version=s.get("version"),
  535. user_id=s.get("user_id"),
  536. updated_time=s.get("updated_time"),
  537. is_custom=False,
  538. )
  539. )
  540. setattr(result, our_type, parsed)
  541. return result
  542. except BambuCloudAuthError:
  543. # The sign-in is kept: /cloud/status shows it as expired from the stored
  544. # token and its invalid flag, and the region carries to the next sign-in.
  545. raise HTTPException(status_code=401, detail="Authentication expired")
  546. except BambuCloudError as e:
  547. # Bambu Cloud failed or could not be reached: a bad gateway.
  548. raise HTTPException(status_code=502, detail=str(e))
  549. finally:
  550. await cloud.close()
  551. @router.get("/settings/{setting_id}")
  552. async def get_setting_detail(
  553. setting_id: str,
  554. db: AsyncSession = Depends(get_db),
  555. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  556. ):
  557. """
  558. Get detailed information for a specific setting/preset.
  559. Returns the full preset configuration.
  560. """
  561. cloud = await build_authenticated_cloud(db, current_user)
  562. if cloud is None or not cloud.is_authenticated:
  563. raise HTTPException(status_code=401, detail="Not authenticated")
  564. try:
  565. data = await cloud.get_setting_detail(setting_id)
  566. return data
  567. except BambuCloudAuthError:
  568. # The sign-in is kept: /cloud/status shows it as expired from the stored
  569. # token and its invalid flag, and the region carries to the next sign-in.
  570. raise HTTPException(status_code=401, detail="Authentication expired")
  571. except BambuCloudError as e:
  572. # Bambu Cloud failed or could not be reached: a bad gateway.
  573. raise HTTPException(status_code=502, detail=str(e))
  574. finally:
  575. await cloud.close()
  576. @router.get("/filaments", response_model=list[SlicerSetting])
  577. async def get_filament_presets(
  578. version: str = _SLICER_API_VERSION,
  579. db: AsyncSession = Depends(get_db),
  580. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  581. ):
  582. """
  583. Get just filament presets (convenience endpoint).
  584. Returns all filament presets with custom presets first.
  585. Uses the same cache as get_slicer_settings.
  586. """
  587. settings = await get_slicer_settings(version=version, db=db, current_user=current_user)
  588. return settings.filament
  589. # Cache for filament preset info ((account, setting_id) -> {name, k}). The
  590. # account is the user whose cloud token resolved it, None for the global one:
  591. # a personal preset read with one account's token is not another account's.
  592. _filament_cache: dict[tuple[int | None, str], dict] = {}
  593. _filament_cache_time: float = 0
  594. FILAMENT_CACHE_TTL = 300 # 5 minutes
  595. # In-flight cloud lookups, keyed by setting_id (#2572). The printer overview
  596. # mounts one filament-info request per printer card, so at farm scale several
  597. # browsers ask for the same uncached preset within the same instant. Without
  598. # coalescing each request issues its own Bambu Cloud round-trip for the same id
  599. # (a thundering herd against a rate-limited API). The first caller to miss a
  600. # given id becomes the leader and resolves it; concurrent callers await its
  601. # future and reuse the result instead of duplicating the call.
  602. _filament_inflight: dict[tuple[int | None, str], asyncio.Future] = {}
  603. async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
  604. """Fetch a single filament preset from Bambu Cloud.
  605. Returns ``{"name", "k"}`` on success (name may be empty when the preset
  606. resolves but carries no display name), or ``None`` when the lookup fails.
  607. Never raises — a 400 is the expected answer for many bare preset IDs and is
  608. logged at DEBUG; anything else is a real fault logged at WARNING.
  609. """
  610. try:
  611. api_setting_id = _filament_id_to_setting_id(setting_id)
  612. data = await cloud.get_setting_detail(api_setting_id)
  613. setting = data.get("setting", {})
  614. name = data.get("name", "")
  615. k_value = setting.get("pressure_advance")
  616. if k_value is not None:
  617. try:
  618. k_value = float(k_value)
  619. except (ValueError, TypeError):
  620. k_value = None
  621. return {"name": name, "k": k_value}
  622. except Exception as e:
  623. # A 400 here is the *expected* answer, not a fault, and the local-preset
  624. # fallback (Phase 3) exists to handle it (#2530). Two routine causes:
  625. # * Many official presets are only addressable with a printer variant
  626. # suffix — "GFSA00" resolves, "GFSL05" does not, only "GFSL05_07"
  627. # (@BBL A1) does. The bare ID is all the AMS reports, so the lookup
  628. # legitimately misses.
  629. # * Personal presets ("P…") belong to the Bambu account that sliced the
  630. # file; another account will never resolve them.
  631. # Logging those at WARNING on every AMS tooltip refresh trains users to
  632. # ignore the log. Anything else — expired token, 5xx, a connection
  633. # failure — stays at WARNING because it is a fault.
  634. expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
  635. logger.log(
  636. logging.DEBUG if expected_miss else logging.WARNING,
  637. "Failed to get cloud preset %s (API ID: %s): %s",
  638. setting_id,
  639. _filament_id_to_setting_id(setting_id),
  640. e,
  641. )
  642. return None
  643. async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService, account: int | None = None) -> dict | None:
  644. """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
  645. Concurrent callers for the same ``setting_id`` share one cloud round-trip:
  646. the first caller resolves it while the rest await the shared future. Returns
  647. the info dict (also populating ``_filament_cache``) or ``None`` on failure.
  648. """
  649. key = (account, setting_id)
  650. if key in _filament_cache:
  651. return _filament_cache[key]
  652. existing = _filament_inflight.get(key)
  653. if existing is not None:
  654. # Another request is already fetching this id — reuse its result.
  655. # shield() so our own cancellation can't cancel the shared leader.
  656. try:
  657. return await asyncio.shield(existing)
  658. except Exception:
  659. return None
  660. fut: asyncio.Future = asyncio.get_event_loop().create_future()
  661. _filament_inflight[key] = fut
  662. info: dict | None = None
  663. try:
  664. info = await _fetch_one_cloud_filament(setting_id, cloud)
  665. return info
  666. finally:
  667. if info is not None:
  668. _filament_cache[key] = info
  669. if not fut.done():
  670. fut.set_result(info)
  671. _filament_inflight.pop(key, None)
  672. # Built-in filament ID → name mapping (fallback when cloud API and local profiles
  673. # don't have the entry). Based on Bambu Lab's known filament catalogue.
  674. _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
  675. "GFA00": "Bambu PLA Basic",
  676. "GFA01": "Bambu PLA Matte",
  677. "GFA02": "Bambu PLA Metal",
  678. "GFA05": "Bambu PLA Silk",
  679. "GFA06": "Bambu PLA Silk+",
  680. "GFA07": "Bambu PLA Marble",
  681. "GFA08": "Bambu PLA Sparkle",
  682. "GFA09": "Bambu PLA Tough",
  683. "GFA11": "Bambu PLA Aero",
  684. "GFA12": "Bambu PLA Glow",
  685. "GFA13": "Bambu PLA Dynamic",
  686. "GFA15": "Bambu PLA Galaxy",
  687. "GFA16": "Bambu PLA Wood",
  688. "GFA50": "Bambu PLA-CF",
  689. "GFB00": "Bambu ABS",
  690. "GFB01": "Bambu ASA",
  691. "GFB02": "Bambu ASA-Aero",
  692. "GFB50": "Bambu ABS-GF",
  693. "GFB51": "Bambu ASA-CF",
  694. "GFB60": "PolyLite ABS",
  695. "GFB61": "PolyLite ASA",
  696. "GFB98": "Generic ASA",
  697. "GFB99": "Generic ABS",
  698. "GFC00": "Bambu PC",
  699. "GFC01": "Bambu PC FR",
  700. "GFC99": "Generic PC",
  701. "GFG00": "Bambu PETG Basic",
  702. "GFG01": "Bambu PETG Translucent",
  703. "GFG02": "Bambu PETG HF",
  704. "GFG50": "Bambu PETG-CF",
  705. "GFG60": "PolyLite PETG",
  706. "GFG96": "Generic PETG HF",
  707. "GFG97": "Generic PCTG",
  708. "GFG98": "Generic PETG-CF",
  709. "GFG99": "Generic PETG",
  710. "GFL00": "PolyLite PLA",
  711. "GFL01": "PolyTerra PLA",
  712. "GFL03": "eSUN PLA+",
  713. "GFL04": "Overture PLA",
  714. "GFL05": "Overture Matte PLA",
  715. "GFL06": "Fiberon PETG-ESD",
  716. "GFL50": "Fiberon PA6-CF",
  717. "GFL51": "Fiberon PA6-GF",
  718. "GFL52": "Fiberon PA12-CF",
  719. "GFL53": "Fiberon PA612-CF",
  720. "GFL54": "Fiberon PET-CF",
  721. "GFL55": "Fiberon PETG-rCF",
  722. "GFL95": "Generic PLA High Speed",
  723. "GFL96": "Generic PLA Silk",
  724. "GFL98": "Generic PLA-CF",
  725. "GFL99": "Generic PLA",
  726. "GFN03": "Bambu PA-CF",
  727. "GFN04": "Bambu PAHT-CF",
  728. "GFN05": "Bambu PA6-CF",
  729. "GFN06": "Bambu PPA-CF",
  730. "GFN08": "Bambu PA6-GF",
  731. "GFN96": "Generic PPA-GF",
  732. "GFN97": "Generic PPA-CF",
  733. "GFN98": "Generic PA-CF",
  734. "GFN99": "Generic PA",
  735. "GFP95": "Generic PP-GF",
  736. "GFP96": "Generic PP-CF",
  737. "GFP97": "Generic PP",
  738. "GFP98": "Generic PE-CF",
  739. "GFP99": "Generic PE",
  740. "GFR98": "Generic PHA",
  741. "GFR99": "Generic EVA",
  742. "GFS00": "Bambu Support W",
  743. "GFS01": "Bambu Support G",
  744. "GFS02": "Bambu Support For PLA",
  745. "GFS03": "Bambu Support For PA/PET",
  746. "GFS04": "Bambu PVA",
  747. "GFS05": "Bambu Support For PLA/PETG",
  748. "GFS06": "Bambu Support for ABS",
  749. "GFS97": "Generic BVOH",
  750. "GFS98": "Generic HIPS",
  751. "GFS99": "Generic PVA",
  752. "GFT01": "Bambu PET-CF",
  753. "GFT02": "Bambu PPS-CF",
  754. "GFT97": "Generic PPS",
  755. "GFT98": "Generic PPS-CF",
  756. "GFU00": "Bambu TPU 95A HF",
  757. "GFU01": "Bambu TPU 95A",
  758. "GFU02": "Bambu TPU for AMS",
  759. "GFU98": "Generic TPU for AMS",
  760. "GFU99": "Generic TPU",
  761. }
  762. async def _enrich_from_local_presets(
  763. unresolved_ids: list[str],
  764. result: dict,
  765. db: AsyncSession,
  766. account: int | None = None,
  767. ) -> dict:
  768. """Fall back to local profiles for filament IDs not resolved by cloud.
  769. Matches by checking the setting_id field inside the local preset's
  770. resolved JSON blob (stored in the 'setting' column).
  771. """
  772. from sqlalchemy import text
  773. from backend.app.models.local_preset import LocalPreset
  774. # Build lookup: converted setting_id -> original filament_id
  775. id_map: dict[str, str] = {}
  776. for fid in unresolved_ids:
  777. converted = _filament_id_to_setting_id(fid)
  778. id_map[converted] = fid
  779. # Also map the original in case the JSON uses that form
  780. id_map[fid] = fid
  781. try:
  782. # Query filament presets that have a setting_id matching any of our IDs
  783. from backend.app.core.db_dialect import is_sqlite
  784. if is_sqlite():
  785. json_filter = text("json_extract(setting, '$.setting_id') IS NOT NULL")
  786. else:
  787. json_filter = text("(setting::jsonb->>'setting_id') IS NOT NULL")
  788. candidates = await db.execute(
  789. select(LocalPreset).where(
  790. LocalPreset.preset_type == "filament",
  791. json_filter,
  792. )
  793. )
  794. for preset in candidates.scalars().all():
  795. try:
  796. setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
  797. preset_setting_id = setting_data.get("setting_id", "")
  798. if preset_setting_id in id_map:
  799. original_id = id_map[preset_setting_id]
  800. info = {"name": preset.name, "k": None}
  801. # Try to extract K value from the local preset
  802. pa = setting_data.get("pressure_advance")
  803. if pa is not None:
  804. try:
  805. k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
  806. info["k"] = k_val
  807. except (ValueError, TypeError, IndexError):
  808. pass
  809. _filament_cache[(account, original_id)] = info
  810. result[original_id] = info
  811. except Exception:
  812. continue
  813. except Exception as e:
  814. logger.warning("Failed to search local presets for filament info: %s", e)
  815. # Phase 4: Fall back to built-in filament name table for any still without a name
  816. for fid in unresolved_ids:
  817. if fid not in result or not result[fid].get("name"):
  818. name = _BUILTIN_FILAMENT_NAMES.get(fid, "")
  819. if name:
  820. # Preserve K value from earlier phases if available
  821. existing_k = result.get(fid, {}).get("k")
  822. info = {"name": name, "k": existing_k}
  823. _filament_cache[(account, fid)] = info
  824. result[fid] = info
  825. # Fill remaining unresolved with empty entries
  826. for fid in unresolved_ids:
  827. if fid not in result:
  828. _filament_cache[(account, fid)] = {"name": "", "k": None}
  829. result[fid] = {"name": "", "k": None}
  830. return result
  831. # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
  832. _filament_id_to_setting_id = filament_id_to_setting_id
  833. @router.post("/filament-info")
  834. async def get_filament_info(
  835. setting_ids: list[str] = Body(...),
  836. db: AsyncSession = Depends(get_db),
  837. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  838. ):
  839. """
  840. Get filament preset info (name and K value) for multiple setting IDs.
  841. Used to enrich AMS tray and nozzle rack tooltips with preset data.
  842. Lookup order: cache → cloud → local profiles → built-in table → empty fallback.
  843. """
  844. import time
  845. logger.info("get_filament_info called with %s IDs: %s", len(setting_ids), setting_ids)
  846. global _filament_cache, _filament_cache_time
  847. # Clear stale cache
  848. if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
  849. _filament_cache = {}
  850. _filament_cache_time = time.time()
  851. result = {}
  852. unresolved_ids: list[str] = []
  853. account = current_user.id if current_user is not None else None
  854. # Phase 1: Check cache
  855. for setting_id in setting_ids:
  856. if not setting_id:
  857. continue
  858. if (account, setting_id) in _filament_cache:
  859. result[setting_id] = _filament_cache[(account, setting_id)]
  860. else:
  861. unresolved_ids.append(setting_id)
  862. # Phase 2: Try cloud for uncached IDs
  863. if unresolved_ids:
  864. cloud = await build_authenticated_cloud(db, current_user)
  865. # Release the request's DB transaction before the sequential Bambu Cloud
  866. # round-trips below (#2572). build_authenticated_cloud has read the
  867. # stored token — the only DB access this phase needs — and nothing until
  868. # Phase 3 touches the DB again. Without this the session sat "idle in
  869. # transaction" for the full duration of N external HTTP calls, pinning a
  870. # pooled connection per in-flight request. Phase 3's read transparently
  871. # opens a fresh transaction on the same still-open session.
  872. await db.rollback()
  873. if cloud is not None and cloud.is_authenticated:
  874. try:
  875. still_unresolved: list[str] = []
  876. for setting_id in unresolved_ids:
  877. info = await _resolve_cloud_filament(setting_id, cloud, account)
  878. if info is not None:
  879. result[setting_id] = info
  880. if info is None or not info.get("name"):
  881. still_unresolved.append(setting_id)
  882. unresolved_ids = still_unresolved
  883. finally:
  884. await cloud.close()
  885. elif cloud is not None:
  886. await cloud.close()
  887. # Phase 3: Try local profiles for any IDs still without a name
  888. if unresolved_ids:
  889. result = await _enrich_from_local_presets(unresolved_ids, result, db, account)
  890. return result
  891. @router.get("/devices", response_model=list[CloudDevice])
  892. async def get_devices(
  893. db: AsyncSession = Depends(get_db),
  894. current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
  895. ):
  896. """
  897. Get list of bound printer devices.
  898. Returns printers registered to the user's Bambu account.
  899. """
  900. cloud = await build_authenticated_cloud(db, current_user)
  901. if cloud is None or not cloud.is_authenticated:
  902. raise HTTPException(status_code=401, detail="Not authenticated")
  903. try:
  904. data = await cloud.get_devices()
  905. devices = data.get("devices", [])
  906. return [
  907. CloudDevice(
  908. dev_id=d.get("dev_id", ""),
  909. name=d.get("name", "Unknown"),
  910. dev_model_name=d.get("dev_model_name"),
  911. dev_product_name=d.get("dev_product_name"),
  912. online=d.get("online", False),
  913. )
  914. for d in devices
  915. ]
  916. except BambuCloudAuthError:
  917. # The sign-in is kept: /cloud/status shows it as expired from the stored
  918. # token and its invalid flag, and the region carries to the next sign-in.
  919. raise HTTPException(status_code=401, detail="Authentication expired")
  920. except BambuCloudError as e:
  921. # Bambu Cloud failed or could not be reached: a bad gateway.
  922. raise HTTPException(status_code=502, detail=str(e))
  923. finally:
  924. await cloud.close()
  925. @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
  926. async def get_firmware_updates(
  927. db: AsyncSession = Depends(get_db),
  928. current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
  929. ):
  930. """
  931. Check for firmware updates for all bound devices.
  932. Returns firmware version info for each device including:
  933. - Current installed version
  934. - Latest available version
  935. - Whether an update is available
  936. - Release notes for the latest version
  937. Requires cloud authentication.
  938. """
  939. cloud = await build_authenticated_cloud(db, current_user)
  940. if cloud is None or not cloud.is_authenticated:
  941. raise HTTPException(status_code=401, detail="Not authenticated")
  942. try:
  943. # First get list of bound devices
  944. devices_data = await cloud.get_devices()
  945. devices = devices_data.get("devices", [])
  946. updates = []
  947. updates_available = 0
  948. # Check firmware for each device
  949. for device in devices:
  950. device_id = device.get("dev_id", "")
  951. device_name = device.get("name", "Unknown")
  952. try:
  953. firmware_info = await cloud.get_firmware_version(device_id)
  954. update_available = firmware_info.get("update_available", False)
  955. if update_available:
  956. updates_available += 1
  957. updates.append(
  958. FirmwareUpdateInfo(
  959. device_id=device_id,
  960. device_name=device_name,
  961. current_version=firmware_info.get("current_version"),
  962. latest_version=firmware_info.get("latest_version"),
  963. update_available=update_available,
  964. release_notes=firmware_info.get("release_notes"),
  965. )
  966. )
  967. except BambuCloudAuthError:
  968. raise # the session expired: not an unknown version per device
  969. except BambuCloudError as e:
  970. logger.warning("Failed to get firmware info for %s: %s", device_name, e)
  971. # Still include device but with unknown firmware status
  972. updates.append(
  973. FirmwareUpdateInfo(
  974. device_id=device_id,
  975. device_name=device_name,
  976. current_version=None,
  977. latest_version=None,
  978. update_available=False,
  979. release_notes=None,
  980. )
  981. )
  982. return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
  983. except BambuCloudAuthError:
  984. # The sign-in is kept: /cloud/status shows it as expired from the stored
  985. # token and its invalid flag, and the region carries to the next sign-in.
  986. raise HTTPException(status_code=401, detail="Authentication expired")
  987. except BambuCloudError as e:
  988. # Bambu Cloud failed or could not be reached: a bad gateway.
  989. raise HTTPException(status_code=502, detail=str(e))
  990. finally:
  991. await cloud.close()
  992. @router.post("/settings")
  993. async def create_setting(
  994. request: SlicerSettingCreate,
  995. db: AsyncSession = Depends(get_db),
  996. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  997. ):
  998. """
  999. Create a new slicer preset/setting.
  1000. Creates a new preset on Bambu Cloud. The preset inherits from a base preset
  1001. and only stores the delta (modified values).
  1002. Type should be: 'filament', 'print', or 'printer'
  1003. """
  1004. cloud = await build_authenticated_cloud(db, current_user)
  1005. if cloud is None or not cloud.is_authenticated:
  1006. raise HTTPException(status_code=401, detail="Not authenticated")
  1007. try:
  1008. data = await cloud.create_setting(
  1009. preset_type=request.type,
  1010. name=request.name,
  1011. base_id=request.base_id,
  1012. setting=request.setting,
  1013. version=request.version,
  1014. )
  1015. return data
  1016. except BambuCloudAuthError:
  1017. # The sign-in is kept: /cloud/status shows it as expired from the stored
  1018. # token and its invalid flag, and the region carries to the next sign-in.
  1019. raise HTTPException(status_code=401, detail="Authentication expired")
  1020. except BambuCloudError as e:
  1021. # Bambu Cloud failed or could not be reached: a bad gateway.
  1022. raise HTTPException(status_code=502, detail=str(e))
  1023. finally:
  1024. await cloud.close()
  1025. @router.put("/settings/{setting_id}")
  1026. async def update_setting(
  1027. setting_id: str,
  1028. request: SlicerSettingUpdate,
  1029. db: AsyncSession = Depends(get_db),
  1030. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  1031. ):
  1032. """
  1033. Update an existing slicer preset/setting.
  1034. Updates the preset's name and/or settings on Bambu Cloud.
  1035. """
  1036. cloud = await build_authenticated_cloud(db, current_user)
  1037. if cloud is None or not cloud.is_authenticated:
  1038. raise HTTPException(status_code=401, detail="Not authenticated")
  1039. try:
  1040. data = await cloud.update_setting(
  1041. setting_id=setting_id,
  1042. name=request.name,
  1043. setting=request.setting,
  1044. )
  1045. return data
  1046. except BambuCloudAuthError:
  1047. # The sign-in is kept: /cloud/status shows it as expired from the stored
  1048. # token and its invalid flag, and the region carries to the next sign-in.
  1049. raise HTTPException(status_code=401, detail="Authentication expired")
  1050. except BambuCloudError as e:
  1051. # Bambu Cloud failed or could not be reached: a bad gateway.
  1052. raise HTTPException(status_code=502, detail=str(e))
  1053. finally:
  1054. await cloud.close()
  1055. @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
  1056. async def delete_setting(
  1057. setting_id: str,
  1058. db: AsyncSession = Depends(get_db),
  1059. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  1060. ):
  1061. """
  1062. Delete a slicer preset/setting.
  1063. Removes the preset from Bambu Cloud. This cannot be undone.
  1064. """
  1065. cloud = await build_authenticated_cloud(db, current_user)
  1066. if cloud is None or not cloud.is_authenticated:
  1067. raise HTTPException(status_code=401, detail="Not authenticated")
  1068. try:
  1069. result = await cloud.delete_setting(setting_id)
  1070. return SlicerSettingDeleteResponse(
  1071. success=result.get("success", True),
  1072. message=result.get("message", "Setting deleted"),
  1073. )
  1074. except BambuCloudAuthError:
  1075. # The sign-in is kept: /cloud/status shows it as expired from the stored
  1076. # token and its invalid flag, and the region carries to the next sign-in.
  1077. raise HTTPException(status_code=401, detail="Authentication expired")
  1078. except BambuCloudError as e:
  1079. # Bambu Cloud failed or could not be reached: a bad gateway.
  1080. raise HTTPException(status_code=502, detail=str(e))
  1081. finally:
  1082. await cloud.close()
  1083. # Path to field definition files
  1084. FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
  1085. # Cache for field definitions (loaded once)
  1086. _fields_cache: dict[str, dict] = {}
  1087. def _load_fields(preset_type: str) -> dict:
  1088. """Load field definitions from JSON file."""
  1089. if preset_type in _fields_cache:
  1090. return _fields_cache[preset_type]
  1091. # Map API type names to file names
  1092. file_map = {
  1093. "filament": "filament_fields.json",
  1094. "print": "process_fields.json",
  1095. "process": "process_fields.json",
  1096. "printer": "printer_fields.json",
  1097. }
  1098. filename = file_map.get(preset_type)
  1099. if not filename:
  1100. raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
  1101. file_path = FIELDS_DATA_DIR / filename
  1102. if not file_path.exists():
  1103. raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
  1104. with open(file_path) as f:
  1105. data = json.load(f)
  1106. _fields_cache[preset_type] = data
  1107. return data
  1108. @router.get("/builtin-filaments")
  1109. async def get_builtin_filaments(
  1110. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  1111. ):
  1112. """
  1113. Get built-in filament names as a fallback source.
  1114. Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
  1115. {filament_id, name} objects. Used by the frontend when cloud
  1116. and local profiles are unavailable.
  1117. """
  1118. return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
  1119. # filament_id → name mappings (resolved from cloud preset details), one per
  1120. # cloud account: the user whose token read it, or None for the global token
  1121. # used when auth is off. Value: (time read, mapping).
  1122. _filament_id_name_cache: dict[int | None, tuple[float, dict[str, str]]] = {}
  1123. # Builds still running, by account (RC-C11).
  1124. _filament_id_map_inflight: dict[int | None, asyncio.Future] = {}
  1125. @router.get("/filament-id-map")
  1126. async def get_filament_id_map(
  1127. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  1128. ):
  1129. """
  1130. Get filament_id → name mapping for user cloud presets.
  1131. K-profiles store a filament_id (e.g., "P4d64437") which is different from
  1132. the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
  1133. fetches details for all custom presets and returns the mapping.
  1134. Cached for 5 minutes per cloud account; a caller is only ever given the
  1135. mapping of their own account's presets.
  1136. """
  1137. import time
  1138. account = current_user.id if current_user is not None else None
  1139. cached = _filament_id_name_cache.get(account)
  1140. if cached and time.time() - cached[0] < FILAMENT_CACHE_TTL:
  1141. return cached[1]
  1142. # One build per account at a time: when the cache expired, every K-profile
  1143. # view asked at once and each ran the full 1 + N fetch (RC-C11). Registered
  1144. # before anything is awaited, so callers arriving together all join it.
  1145. running = _filament_id_map_inflight.get(account)
  1146. if running is None or running.done():
  1147. running = spawn_background_task(_build_filament_id_map(current_user, account), name="cloud-filament-id-map")
  1148. _filament_id_map_inflight[account] = running
  1149. def _forget(done: asyncio.Future, account: int | None = account) -> None:
  1150. if _filament_id_map_inflight.get(account) is done:
  1151. del _filament_id_map_inflight[account]
  1152. running.add_done_callback(_forget)
  1153. try:
  1154. # shield: a caller that goes away must not cancel the build others joined.
  1155. return dict(await asyncio.shield(running))
  1156. except Exception:
  1157. return cached[1] if cached else {}
  1158. async def _build_filament_id_map(user: User | None, account: int | None) -> dict[str, str]:
  1159. """Fetch an account's custom presets and map their filament ids to names.
  1160. Reads the stored token in a session of its own, closed before the Bambu
  1161. Cloud round-trips: no request holds a database connection across them.
  1162. """
  1163. import time
  1164. async with database.async_session() as db:
  1165. cloud = await build_authenticated_cloud(db, user)
  1166. if cloud is None or not cloud.is_authenticated:
  1167. if cloud is not None:
  1168. await cloud.close()
  1169. return {}
  1170. try:
  1171. data = await cloud.get_slicer_settings()
  1172. custom_presets = data.get("filament", {}).get("private", [])
  1173. result: dict[str, str] = {}
  1174. for preset in custom_presets:
  1175. setting_id = preset.get("setting_id", "")
  1176. if not setting_id:
  1177. continue
  1178. try:
  1179. detail = await cloud.get_setting_detail(setting_id)
  1180. # On the envelope normally; in the preset JSON under `setting`
  1181. # when not (the same fallback as slicer_filament_resolver).
  1182. nested = detail.get("setting")
  1183. fid = detail.get("filament_id") or (nested.get("filament_id") if isinstance(nested, dict) else "")
  1184. name = detail.get("name", "")
  1185. if fid and name:
  1186. # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
  1187. clean_name = name.split(" @")[0].strip() if " @" in name else name
  1188. result[fid] = clean_name
  1189. except Exception:
  1190. pass
  1191. # Not when the account signed out meanwhile: its build was dropped,
  1192. # and this map was read with the token that is gone.
  1193. if _filament_id_map_inflight.get(account) is asyncio.current_task():
  1194. _filament_id_name_cache[account] = (time.time(), result)
  1195. return result
  1196. finally:
  1197. await cloud.close()
  1198. @router.get("/fields/{preset_type}")
  1199. async def get_preset_fields(
  1200. preset_type: Literal["filament", "print", "process", "printer"],
  1201. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  1202. ):
  1203. """
  1204. Get field definitions for a preset type.
  1205. Returns a list of field definitions including:
  1206. - key: The setting key name
  1207. - label: Human-readable label
  1208. - type: Field type (text, number, boolean, select)
  1209. - category: Grouping category
  1210. - description: Field description
  1211. - options: For select fields, available options
  1212. - unit: Unit of measurement (if applicable)
  1213. - min/max/step: For number fields, validation constraints
  1214. """
  1215. data = _load_fields(preset_type)
  1216. return data
  1217. @router.get("/fields")
  1218. async def get_all_preset_fields(
  1219. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  1220. ):
  1221. """
  1222. Get all field definitions for all preset types.
  1223. Returns field definitions organized by type.
  1224. """
  1225. return {
  1226. "filament": _load_fields("filament"),
  1227. "process": _load_fields("process"),
  1228. "printer": _load_fields("printer"),
  1229. }