cloud.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. """
  2. Bambu Lab Cloud API Routes
  3. Handles authentication and profile management with Bambu Cloud.
  4. """
  5. import json
  6. import logging
  7. from pathlib import Path
  8. from typing import Literal
  9. from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
  10. from fastapi.security import HTTPAuthorizationCredentials
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import (
  14. RequirePermissionIfAuthEnabled,
  15. _user_from_api_key,
  16. _validate_api_key,
  17. require_permission_if_auth_enabled,
  18. security,
  19. )
  20. from backend.app.core.database import get_db
  21. from backend.app.core.permissions import Permission
  22. from backend.app.models.api_key import APIKey
  23. from backend.app.models.settings import Settings
  24. from backend.app.models.user import User
  25. from backend.app.schemas.cloud import (
  26. CloudAuthStatus,
  27. CloudDevice,
  28. CloudLoginRequest,
  29. CloudLoginResponse,
  30. CloudTokenRequest,
  31. CloudVerifyRequest,
  32. FirmwareUpdateInfo,
  33. FirmwareUpdatesResponse,
  34. SlicerSetting,
  35. SlicerSettingCreate,
  36. SlicerSettingDeleteResponse,
  37. SlicerSettingsResponse,
  38. SlicerSettingUpdate,
  39. )
  40. from backend.app.services.bambu_cloud import (
  41. BambuCloudAuthError,
  42. BambuCloudError,
  43. BambuCloudService,
  44. )
  45. from backend.app.utils.filament_ids import filament_id_to_setting_id
  46. logger = logging.getLogger(__name__)
  47. async def _cloud_api_key_gate(
  48. request: Request,
  49. credentials: HTTPAuthorizationCredentials | None = Depends(security),
  50. x_api_key: str | None = Header(default=None, alias="X-API-Key"),
  51. db: AsyncSession = Depends(get_db),
  52. ) -> None:
  53. """Router-level dependency: enforce API-key cloud-access fences (#1182).
  54. Runs before every /cloud/* handler. JWT-authed and anonymous callers are
  55. no-ops — their access is gated by the per-route ``Permission.CLOUD_AUTH``
  56. / ``Permission.FILAMENTS_READ`` / etc. dependency. API-keyed callers
  57. must have an owner and ``can_access_cloud=True``; legacy ownerless keys
  58. and keys without the cloud scope are rejected here.
  59. On a successful API-keyed request the owner User is stashed on
  60. ``request.state.api_key_owner`` so route handlers can resolve it via
  61. ``cloud_caller`` (the auth gate returns None for API keys to avoid a
  62. wider behaviour change in non-cloud routes — see auth.py).
  63. The dep duplicates the API-key validation done by the regular auth gate
  64. (which runs as a route-level dep, *after* router-level deps). The cost
  65. is one extra ``SELECT FROM api_keys`` per /cloud/* request — bounded and
  66. cheap (key_prefix is indexed).
  67. """
  68. api_key_value: str | None = None
  69. if x_api_key:
  70. api_key_value = x_api_key
  71. elif credentials and credentials.credentials.startswith("bb_"):
  72. api_key_value = credentials.credentials
  73. if api_key_value is None:
  74. return # JWT or anonymous — no-op
  75. api_key = await _validate_api_key(db, api_key_value)
  76. if api_key is None:
  77. # Invalid key — let the route-level auth gate produce the 401 so the
  78. # error matches what every other route returns for a bad key.
  79. return
  80. _assert_api_key_can_access_cloud(api_key)
  81. # All fences passed. Stash the owner so cloud routes can resolve their
  82. # caller User without going through the auth gate (which intentionally
  83. # returns None for API keys to keep #1182 surface-bounded to /cloud/*).
  84. request.state.api_key_owner = await _user_from_api_key(db, api_key)
  85. def cloud_caller(*permissions: Permission):
  86. """Route-level dep factory for /cloud/* handlers.
  87. Returns a Depends that resolves to:
  88. - the JWT-authenticated User (when a JWT is present and the route's
  89. permission set is satisfied), OR
  90. - the API-key owner User stashed by the router-level gate
  91. (``request.state.api_key_owner``), OR
  92. - None when auth is disabled.
  93. Replaces the direct ``RequirePermissionIfAuthEnabled(...)`` dep on cloud
  94. routes so API-keyed callers get the *owner* in ``current_user`` rather
  95. than None — without that the route falls back to the global Settings
  96. cloud_token, which is empty in auth-enabled deployments.
  97. """
  98. base_dep = require_permission_if_auth_enabled(*permissions)
  99. async def resolved(
  100. request: Request,
  101. base_user: User | None = Depends(base_dep),
  102. ) -> User | None:
  103. if base_user is not None:
  104. return base_user
  105. return getattr(request.state, "api_key_owner", None)
  106. return Depends(resolved)
  107. router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
  108. # Keys for storing cloud credentials in settings
  109. CLOUD_TOKEN_KEY = "bambu_cloud_token"
  110. CLOUD_EMAIL_KEY = "bambu_cloud_email"
  111. CLOUD_REGION_KEY = "bambu_cloud_region"
  112. def _normalise_region(region: str | None) -> str:
  113. """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
  114. return region if region in ("global", "china") else "global"
  115. async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
  116. """Get stored cloud token, email, and region.
  117. When a user is provided (auth enabled), returns that user's per-user credentials.
  118. When user is None (auth disabled), falls back to global Settings table.
  119. Region defaults to ``"global"`` when unset (including for rows that predate
  120. the ``cloud_region`` column).
  121. """
  122. if user is not None:
  123. return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
  124. # Fallback: global storage (auth disabled)
  125. result = await db.execute(
  126. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  127. )
  128. settings = {s.key: s.value for s in result.scalars().all()}
  129. return (
  130. settings.get(CLOUD_TOKEN_KEY),
  131. settings.get(CLOUD_EMAIL_KEY),
  132. _normalise_region(settings.get(CLOUD_REGION_KEY)),
  133. )
  134. async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
  135. """Store cloud token, email, and region.
  136. When a user is provided (auth enabled), stores on the user record.
  137. When user is None (auth disabled), stores in global Settings table.
  138. """
  139. region = _normalise_region(region)
  140. if user is not None:
  141. # User object is from the auth dependency's session (detached),
  142. # so use a direct UPDATE via the route's db session.
  143. from sqlalchemy import update
  144. await db.execute(
  145. update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
  146. )
  147. await db.commit()
  148. return
  149. # Fallback: global storage (auth disabled)
  150. for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:
  151. result = await db.execute(select(Settings).where(Settings.key == key))
  152. setting = result.scalar_one_or_none()
  153. if setting:
  154. setting.value = value
  155. else:
  156. db.add(Settings(key=key, value=value))
  157. await db.commit()
  158. async def clear_token(db: AsyncSession, user: User | None = None) -> None:
  159. """Clear stored cloud token, email, and region.
  160. When a user is provided (auth enabled), clears that user's credentials.
  161. When user is None (auth disabled), clears from global Settings table.
  162. """
  163. if user is not None:
  164. from sqlalchemy import update
  165. await db.execute(
  166. update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
  167. )
  168. await db.commit()
  169. return
  170. # Fallback: global storage (auth disabled)
  171. result = await db.execute(
  172. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  173. )
  174. for setting in result.scalars().all():
  175. await db.delete(setting)
  176. await db.commit()
  177. def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
  178. """Reject API keys that aren't authorised to read cloud data.
  179. Three independent fences for API keys (#1182):
  180. 1. user_id IS NOT NULL — legacy keys created before per-user ownership
  181. have no owner whose cloud_token we could read; force recreate.
  182. 2. can_access_cloud=True — opt-in scope so existing automation doesn't
  183. start reading cloud data without the operator explicitly enabling it.
  184. 3. owner has stored cloud_token — enforced separately at the route
  185. level via ``build_authenticated_cloud`` returning None.
  186. """
  187. if api_key.user_id is None:
  188. raise HTTPException(
  189. status_code=401,
  190. detail=(
  191. "This API key was created before per-user cloud access was supported. "
  192. "Recreate it from Settings → API Keys to use /cloud/* endpoints."
  193. ),
  194. )
  195. if not api_key.can_access_cloud:
  196. raise HTTPException(
  197. status_code=403,
  198. detail=(
  199. "This API key is not authorised to access Bambu Cloud data. "
  200. "Enable 'Allow cloud access' on the key in Settings → API Keys."
  201. ),
  202. )
  203. async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
  204. """Build a per-request cloud service seeded with the caller's stored token + region.
  205. Returns ``None`` when no token is stored, so callers can 401 without constructing
  206. (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
  207. """
  208. token, _email, region = await get_stored_token(db, user)
  209. if not token:
  210. return None
  211. cloud = BambuCloudService(region=region)
  212. cloud.set_token(token)
  213. return cloud
  214. @router.get("/status", response_model=CloudAuthStatus)
  215. async def get_auth_status(
  216. db: AsyncSession = Depends(get_db),
  217. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  218. ):
  219. """Get current cloud authentication status.
  220. Reads the stored credentials in one DB round-trip (we used to call
  221. ``get_stored_token`` twice — once here and once inside
  222. ``build_authenticated_cloud``). ``region`` is exposed so the frontend can
  223. show "Connected (China)" after a reload without relying on local state.
  224. """
  225. token, email, region = await get_stored_token(db, current_user)
  226. if not token:
  227. return CloudAuthStatus(is_authenticated=False, email=None, region=None)
  228. cloud = BambuCloudService(region=region)
  229. cloud.set_token(token)
  230. try:
  231. authenticated = cloud.is_authenticated
  232. return CloudAuthStatus(
  233. is_authenticated=authenticated,
  234. email=email if authenticated else None,
  235. region=region if authenticated else None,
  236. )
  237. finally:
  238. await cloud.close()
  239. @router.post("/login", response_model=CloudLoginResponse)
  240. async def login(
  241. request: CloudLoginRequest,
  242. db: AsyncSession = Depends(get_db),
  243. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  244. ):
  245. """
  246. Initiate login to Bambu Cloud.
  247. This will trigger either:
  248. - Email verification: A code is sent to the user's email
  249. - TOTP verification: User enters code from their authenticator app
  250. After receiving/generating the code, call /cloud/verify to complete the login.
  251. For TOTP, include the tfa_key from this response in the verify request.
  252. """
  253. cloud = BambuCloudService(region=request.region)
  254. try:
  255. result = await cloud.login_request(request.email, request.password)
  256. if result.get("success") and cloud.access_token:
  257. # Direct login succeeded (rare)
  258. await store_token(db, cloud.access_token, request.email, request.region, current_user)
  259. return CloudLoginResponse(
  260. success=result.get("success", False),
  261. needs_verification=result.get("needs_verification", False),
  262. message=result.get("message", "Unknown error"),
  263. verification_type=result.get("verification_type"),
  264. tfa_key=result.get("tfa_key"),
  265. )
  266. except BambuCloudAuthError as e:
  267. raise HTTPException(status_code=401, detail=str(e))
  268. except BambuCloudError as e:
  269. raise HTTPException(status_code=500, detail=str(e))
  270. finally:
  271. await cloud.close()
  272. @router.post("/verify", response_model=CloudLoginResponse)
  273. async def verify_code(
  274. request: CloudVerifyRequest,
  275. db: AsyncSession = Depends(get_db),
  276. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  277. ):
  278. """
  279. Complete login with verification code (email or TOTP).
  280. For email verification:
  281. - After calling /cloud/login, the user receives an email with a 6-digit code
  282. - Submit the code with email address
  283. For TOTP verification:
  284. - The user enters the 6-digit code from their authenticator app
  285. - Include the tfa_key from the /cloud/login response
  286. ``request.region`` must match the region used in /cloud/login so that the
  287. TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).
  288. """
  289. cloud = BambuCloudService(region=request.region)
  290. try:
  291. # Use TOTP verification if tfa_key is provided
  292. if request.tfa_key:
  293. result = await cloud.verify_totp(request.tfa_key, request.code)
  294. else:
  295. result = await cloud.verify_code(request.email, request.code)
  296. if result.get("success") and cloud.access_token:
  297. await store_token(db, cloud.access_token, request.email, request.region, current_user)
  298. return CloudLoginResponse(
  299. success=result.get("success", False),
  300. needs_verification=False,
  301. message=result.get("message", "Unknown error"),
  302. )
  303. except BambuCloudAuthError as e:
  304. raise HTTPException(status_code=401, detail=str(e))
  305. except BambuCloudError as e:
  306. raise HTTPException(status_code=500, detail=str(e))
  307. finally:
  308. await cloud.close()
  309. @router.post("/token", response_model=CloudAuthStatus)
  310. async def set_token(
  311. request: CloudTokenRequest,
  312. db: AsyncSession = Depends(get_db),
  313. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  314. ):
  315. """
  316. Set access token directly.
  317. For users who already have a token (e.g., from Bambu Studio). The
  318. selected ``region`` is persisted alongside the token so every subsequent
  319. request hits the right Bambu API endpoint, including after a restart.
  320. """
  321. cloud = BambuCloudService(region=request.region)
  322. cloud.set_token(request.access_token)
  323. try:
  324. # Verify token works by trying to get profile
  325. await cloud.get_user_profile()
  326. await store_token(db, request.access_token, "token-auth", request.region, current_user)
  327. return CloudAuthStatus(is_authenticated=True, email="token-auth")
  328. except BambuCloudError:
  329. raise HTTPException(status_code=401, detail="Invalid token")
  330. finally:
  331. await cloud.close()
  332. @router.post("/logout")
  333. async def logout(
  334. db: AsyncSession = Depends(get_db),
  335. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  336. ):
  337. """Log out of Bambu Cloud."""
  338. await clear_token(db, current_user)
  339. return {"success": True}
  340. @router.get("/settings", response_model=SlicerSettingsResponse)
  341. async def get_slicer_settings(
  342. version: str = "02.04.00.70",
  343. db: AsyncSession = Depends(get_db),
  344. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  345. ):
  346. """
  347. Get all slicer settings (filament, printer, process presets).
  348. Requires authentication.
  349. """
  350. cloud = await build_authenticated_cloud(db, current_user)
  351. if cloud is None or not cloud.is_authenticated:
  352. raise HTTPException(status_code=401, detail="Not authenticated")
  353. try:
  354. data = await cloud.get_slicer_settings(version)
  355. result = SlicerSettingsResponse()
  356. # Map API keys to our types (API uses 'print' for process presets)
  357. type_mapping = {
  358. "filament": "filament",
  359. "printer": "printer",
  360. "print": "process", # API calls it 'print', we call it 'process'
  361. }
  362. for api_key, our_type in type_mapping.items():
  363. type_data = data.get(api_key, {})
  364. private_settings = type_data.get("private", [])
  365. public_settings = type_data.get("public", [])
  366. parsed = []
  367. # Private (custom) presets first
  368. for s in private_settings:
  369. parsed.append(
  370. SlicerSetting(
  371. setting_id=s.get("setting_id", s.get("id", "")),
  372. name=s.get("name", "Unknown"),
  373. type=our_type,
  374. version=s.get("version"),
  375. user_id=s.get("user_id"),
  376. updated_time=s.get("updated_time"),
  377. is_custom=True,
  378. )
  379. )
  380. # Public (default) presets
  381. for s in public_settings:
  382. parsed.append(
  383. SlicerSetting(
  384. setting_id=s.get("setting_id", s.get("id", "")),
  385. name=s.get("name", "Unknown"),
  386. type=our_type,
  387. version=s.get("version"),
  388. user_id=s.get("user_id"),
  389. updated_time=s.get("updated_time"),
  390. is_custom=False,
  391. )
  392. )
  393. setattr(result, our_type, parsed)
  394. return result
  395. except BambuCloudAuthError:
  396. await clear_token(db, current_user)
  397. raise HTTPException(status_code=401, detail="Authentication expired")
  398. except BambuCloudError as e:
  399. raise HTTPException(status_code=500, detail=str(e))
  400. finally:
  401. await cloud.close()
  402. @router.get("/settings/{setting_id}")
  403. async def get_setting_detail(
  404. setting_id: str,
  405. db: AsyncSession = Depends(get_db),
  406. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  407. ):
  408. """
  409. Get detailed information for a specific setting/preset.
  410. Returns the full preset configuration.
  411. """
  412. cloud = await build_authenticated_cloud(db, current_user)
  413. if cloud is None or not cloud.is_authenticated:
  414. raise HTTPException(status_code=401, detail="Not authenticated")
  415. try:
  416. data = await cloud.get_setting_detail(setting_id)
  417. return data
  418. except BambuCloudAuthError:
  419. await clear_token(db, current_user)
  420. raise HTTPException(status_code=401, detail="Authentication expired")
  421. except BambuCloudError as e:
  422. raise HTTPException(status_code=500, detail=str(e))
  423. finally:
  424. await cloud.close()
  425. @router.get("/filaments", response_model=list[SlicerSetting])
  426. async def get_filament_presets(
  427. version: str = "02.04.00.70",
  428. db: AsyncSession = Depends(get_db),
  429. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  430. ):
  431. """
  432. Get just filament presets (convenience endpoint).
  433. Returns all filament presets with custom presets first.
  434. Uses the same cache as get_slicer_settings.
  435. """
  436. settings = await get_slicer_settings(version=version, db=db, current_user=current_user)
  437. return settings.filament
  438. # Cache for filament preset info (setting_id -> {name, k})
  439. _filament_cache: dict[str, dict] = {}
  440. _filament_cache_time: float = 0
  441. FILAMENT_CACHE_TTL = 300 # 5 minutes
  442. # Built-in filament ID → name mapping (fallback when cloud API and local profiles
  443. # don't have the entry). Based on Bambu Lab's known filament catalogue.
  444. _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
  445. "GFA00": "Bambu PLA Basic",
  446. "GFA01": "Bambu PLA Matte",
  447. "GFA02": "Bambu PLA Metal",
  448. "GFA05": "Bambu PLA Silk",
  449. "GFA06": "Bambu PLA Silk+",
  450. "GFA07": "Bambu PLA Marble",
  451. "GFA08": "Bambu PLA Sparkle",
  452. "GFA09": "Bambu PLA Tough",
  453. "GFA11": "Bambu PLA Aero",
  454. "GFA12": "Bambu PLA Glow",
  455. "GFA13": "Bambu PLA Dynamic",
  456. "GFA15": "Bambu PLA Galaxy",
  457. "GFA16": "Bambu PLA Wood",
  458. "GFA50": "Bambu PLA-CF",
  459. "GFB00": "Bambu ABS",
  460. "GFB01": "Bambu ASA",
  461. "GFB02": "Bambu ASA-Aero",
  462. "GFB50": "Bambu ABS-GF",
  463. "GFB51": "Bambu ASA-CF",
  464. "GFB60": "PolyLite ABS",
  465. "GFB61": "PolyLite ASA",
  466. "GFB98": "Generic ASA",
  467. "GFB99": "Generic ABS",
  468. "GFC00": "Bambu PC",
  469. "GFC01": "Bambu PC FR",
  470. "GFC99": "Generic PC",
  471. "GFG00": "Bambu PETG Basic",
  472. "GFG01": "Bambu PETG Translucent",
  473. "GFG02": "Bambu PETG HF",
  474. "GFG50": "Bambu PETG-CF",
  475. "GFG60": "PolyLite PETG",
  476. "GFG96": "Generic PETG HF",
  477. "GFG97": "Generic PCTG",
  478. "GFG98": "Generic PETG-CF",
  479. "GFG99": "Generic PETG",
  480. "GFL00": "PolyLite PLA",
  481. "GFL01": "PolyTerra PLA",
  482. "GFL03": "eSUN PLA+",
  483. "GFL04": "Overture PLA",
  484. "GFL05": "Overture Matte PLA",
  485. "GFL06": "Fiberon PETG-ESD",
  486. "GFL50": "Fiberon PA6-CF",
  487. "GFL51": "Fiberon PA6-GF",
  488. "GFL52": "Fiberon PA12-CF",
  489. "GFL53": "Fiberon PA612-CF",
  490. "GFL54": "Fiberon PET-CF",
  491. "GFL55": "Fiberon PETG-rCF",
  492. "GFL95": "Generic PLA High Speed",
  493. "GFL96": "Generic PLA Silk",
  494. "GFL98": "Generic PLA-CF",
  495. "GFL99": "Generic PLA",
  496. "GFN03": "Bambu PA-CF",
  497. "GFN04": "Bambu PAHT-CF",
  498. "GFN05": "Bambu PA6-CF",
  499. "GFN06": "Bambu PPA-CF",
  500. "GFN08": "Bambu PA6-GF",
  501. "GFN96": "Generic PPA-GF",
  502. "GFN97": "Generic PPA-CF",
  503. "GFN98": "Generic PA-CF",
  504. "GFN99": "Generic PA",
  505. "GFP95": "Generic PP-GF",
  506. "GFP96": "Generic PP-CF",
  507. "GFP97": "Generic PP",
  508. "GFP98": "Generic PE-CF",
  509. "GFP99": "Generic PE",
  510. "GFR98": "Generic PHA",
  511. "GFR99": "Generic EVA",
  512. "GFS00": "Bambu Support W",
  513. "GFS01": "Bambu Support G",
  514. "GFS02": "Bambu Support For PLA",
  515. "GFS03": "Bambu Support For PA/PET",
  516. "GFS04": "Bambu PVA",
  517. "GFS05": "Bambu Support For PLA/PETG",
  518. "GFS06": "Bambu Support for ABS",
  519. "GFS97": "Generic BVOH",
  520. "GFS98": "Generic HIPS",
  521. "GFS99": "Generic PVA",
  522. "GFT01": "Bambu PET-CF",
  523. "GFT02": "Bambu PPS-CF",
  524. "GFT97": "Generic PPS",
  525. "GFT98": "Generic PPS-CF",
  526. "GFU00": "Bambu TPU 95A HF",
  527. "GFU01": "Bambu TPU 95A",
  528. "GFU02": "Bambu TPU for AMS",
  529. "GFU98": "Generic TPU for AMS",
  530. "GFU99": "Generic TPU",
  531. }
  532. async def _enrich_from_local_presets(
  533. unresolved_ids: list[str],
  534. result: dict,
  535. db: AsyncSession,
  536. ) -> dict:
  537. """Fall back to local profiles for filament IDs not resolved by cloud.
  538. Matches by checking the setting_id field inside the local preset's
  539. resolved JSON blob (stored in the 'setting' column).
  540. """
  541. from sqlalchemy import text
  542. from backend.app.models.local_preset import LocalPreset
  543. # Build lookup: converted setting_id -> original filament_id
  544. id_map: dict[str, str] = {}
  545. for fid in unresolved_ids:
  546. converted = _filament_id_to_setting_id(fid)
  547. id_map[converted] = fid
  548. # Also map the original in case the JSON uses that form
  549. id_map[fid] = fid
  550. try:
  551. # Query filament presets that have a setting_id matching any of our IDs
  552. from backend.app.core.db_dialect import is_sqlite
  553. if is_sqlite():
  554. json_filter = text("json_extract(setting, '$.setting_id') IS NOT NULL")
  555. else:
  556. json_filter = text("(setting::jsonb->>'setting_id') IS NOT NULL")
  557. candidates = await db.execute(
  558. select(LocalPreset).where(
  559. LocalPreset.preset_type == "filament",
  560. json_filter,
  561. )
  562. )
  563. for preset in candidates.scalars().all():
  564. try:
  565. setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
  566. preset_setting_id = setting_data.get("setting_id", "")
  567. if preset_setting_id in id_map:
  568. original_id = id_map[preset_setting_id]
  569. info = {"name": preset.name, "k": None}
  570. # Try to extract K value from the local preset
  571. pa = setting_data.get("pressure_advance")
  572. if pa is not None:
  573. try:
  574. k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
  575. info["k"] = k_val
  576. except (ValueError, TypeError, IndexError):
  577. pass
  578. _filament_cache[original_id] = info
  579. result[original_id] = info
  580. except Exception:
  581. continue
  582. except Exception as e:
  583. logger.warning("Failed to search local presets for filament info: %s", e)
  584. # Phase 4: Fall back to built-in filament name table for any still without a name
  585. for fid in unresolved_ids:
  586. if fid not in result or not result[fid].get("name"):
  587. name = _BUILTIN_FILAMENT_NAMES.get(fid, "")
  588. if name:
  589. # Preserve K value from earlier phases if available
  590. existing_k = result.get(fid, {}).get("k")
  591. info = {"name": name, "k": existing_k}
  592. _filament_cache[fid] = info
  593. result[fid] = info
  594. # Fill remaining unresolved with empty entries
  595. for fid in unresolved_ids:
  596. if fid not in result:
  597. _filament_cache[fid] = {"name": "", "k": None}
  598. result[fid] = {"name": "", "k": None}
  599. return result
  600. # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
  601. _filament_id_to_setting_id = filament_id_to_setting_id
  602. @router.post("/filament-info")
  603. async def get_filament_info(
  604. setting_ids: list[str] = Body(...),
  605. db: AsyncSession = Depends(get_db),
  606. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  607. ):
  608. """
  609. Get filament preset info (name and K value) for multiple setting IDs.
  610. Used to enrich AMS tray and nozzle rack tooltips with preset data.
  611. Lookup order: cache → cloud → local profiles → built-in table → empty fallback.
  612. """
  613. import time
  614. logger.info("get_filament_info called with %s IDs: %s", len(setting_ids), setting_ids)
  615. global _filament_cache, _filament_cache_time
  616. # Clear stale cache
  617. if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
  618. _filament_cache = {}
  619. _filament_cache_time = time.time()
  620. result = {}
  621. unresolved_ids: list[str] = []
  622. # Phase 1: Check cache
  623. for setting_id in setting_ids:
  624. if not setting_id:
  625. continue
  626. if setting_id in _filament_cache:
  627. result[setting_id] = _filament_cache[setting_id]
  628. else:
  629. unresolved_ids.append(setting_id)
  630. # Phase 2: Try cloud for uncached IDs
  631. if unresolved_ids:
  632. cloud = await build_authenticated_cloud(db, current_user)
  633. if cloud is not None and cloud.is_authenticated:
  634. try:
  635. still_unresolved: list[str] = []
  636. for setting_id in unresolved_ids:
  637. try:
  638. api_setting_id = _filament_id_to_setting_id(setting_id)
  639. data = await cloud.get_setting_detail(api_setting_id)
  640. setting = data.get("setting", {})
  641. name = data.get("name", "")
  642. k_value = setting.get("pressure_advance")
  643. if k_value is not None:
  644. try:
  645. k_value = float(k_value)
  646. except (ValueError, TypeError):
  647. k_value = None
  648. info = {"name": name, "k": k_value}
  649. _filament_cache[setting_id] = info
  650. result[setting_id] = info
  651. if not name:
  652. still_unresolved.append(setting_id)
  653. except Exception as e:
  654. logger.warning(
  655. f"Failed to get cloud preset {setting_id} "
  656. f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
  657. )
  658. still_unresolved.append(setting_id)
  659. unresolved_ids = still_unresolved
  660. finally:
  661. await cloud.close()
  662. elif cloud is not None:
  663. await cloud.close()
  664. # Phase 3: Try local profiles for any IDs still without a name
  665. if unresolved_ids:
  666. result = await _enrich_from_local_presets(unresolved_ids, result, db)
  667. return result
  668. @router.get("/devices", response_model=list[CloudDevice])
  669. async def get_devices(
  670. db: AsyncSession = Depends(get_db),
  671. current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
  672. ):
  673. """
  674. Get list of bound printer devices.
  675. Returns printers registered to the user's Bambu account.
  676. """
  677. cloud = await build_authenticated_cloud(db, current_user)
  678. if cloud is None or not cloud.is_authenticated:
  679. raise HTTPException(status_code=401, detail="Not authenticated")
  680. try:
  681. data = await cloud.get_devices()
  682. devices = data.get("devices", [])
  683. return [
  684. CloudDevice(
  685. dev_id=d.get("dev_id", ""),
  686. name=d.get("name", "Unknown"),
  687. dev_model_name=d.get("dev_model_name"),
  688. dev_product_name=d.get("dev_product_name"),
  689. online=d.get("online", False),
  690. )
  691. for d in devices
  692. ]
  693. except BambuCloudAuthError:
  694. await clear_token(db, current_user)
  695. raise HTTPException(status_code=401, detail="Authentication expired")
  696. except BambuCloudError as e:
  697. raise HTTPException(status_code=500, detail=str(e))
  698. finally:
  699. await cloud.close()
  700. @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
  701. async def get_firmware_updates(
  702. db: AsyncSession = Depends(get_db),
  703. current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
  704. ):
  705. """
  706. Check for firmware updates for all bound devices.
  707. Returns firmware version info for each device including:
  708. - Current installed version
  709. - Latest available version
  710. - Whether an update is available
  711. - Release notes for the latest version
  712. Requires cloud authentication.
  713. """
  714. cloud = await build_authenticated_cloud(db, current_user)
  715. if cloud is None or not cloud.is_authenticated:
  716. raise HTTPException(status_code=401, detail="Not authenticated")
  717. try:
  718. # First get list of bound devices
  719. devices_data = await cloud.get_devices()
  720. devices = devices_data.get("devices", [])
  721. updates = []
  722. updates_available = 0
  723. # Check firmware for each device
  724. for device in devices:
  725. device_id = device.get("dev_id", "")
  726. device_name = device.get("name", "Unknown")
  727. try:
  728. firmware_info = await cloud.get_firmware_version(device_id)
  729. update_available = firmware_info.get("update_available", False)
  730. if update_available:
  731. updates_available += 1
  732. updates.append(
  733. FirmwareUpdateInfo(
  734. device_id=device_id,
  735. device_name=device_name,
  736. current_version=firmware_info.get("current_version"),
  737. latest_version=firmware_info.get("latest_version"),
  738. update_available=update_available,
  739. release_notes=firmware_info.get("release_notes"),
  740. )
  741. )
  742. except BambuCloudError as e:
  743. logger.warning("Failed to get firmware info for %s: %s", device_name, e)
  744. # Still include device but with unknown firmware status
  745. updates.append(
  746. FirmwareUpdateInfo(
  747. device_id=device_id,
  748. device_name=device_name,
  749. current_version=None,
  750. latest_version=None,
  751. update_available=False,
  752. release_notes=None,
  753. )
  754. )
  755. return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
  756. except BambuCloudAuthError:
  757. await clear_token(db, current_user)
  758. raise HTTPException(status_code=401, detail="Authentication expired")
  759. except BambuCloudError as e:
  760. raise HTTPException(status_code=500, detail=str(e))
  761. finally:
  762. await cloud.close()
  763. @router.post("/settings")
  764. async def create_setting(
  765. request: SlicerSettingCreate,
  766. db: AsyncSession = Depends(get_db),
  767. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  768. ):
  769. """
  770. Create a new slicer preset/setting.
  771. Creates a new preset on Bambu Cloud. The preset inherits from a base preset
  772. and only stores the delta (modified values).
  773. Type should be: 'filament', 'print', or 'printer'
  774. """
  775. cloud = await build_authenticated_cloud(db, current_user)
  776. if cloud is None or not cloud.is_authenticated:
  777. raise HTTPException(status_code=401, detail="Not authenticated")
  778. try:
  779. data = await cloud.create_setting(
  780. preset_type=request.type,
  781. name=request.name,
  782. base_id=request.base_id,
  783. setting=request.setting,
  784. version=request.version,
  785. )
  786. return data
  787. except BambuCloudAuthError:
  788. await clear_token(db, current_user)
  789. raise HTTPException(status_code=401, detail="Authentication expired")
  790. except BambuCloudError as e:
  791. raise HTTPException(status_code=500, detail=str(e))
  792. finally:
  793. await cloud.close()
  794. @router.put("/settings/{setting_id}")
  795. async def update_setting(
  796. setting_id: str,
  797. request: SlicerSettingUpdate,
  798. db: AsyncSession = Depends(get_db),
  799. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  800. ):
  801. """
  802. Update an existing slicer preset/setting.
  803. Updates the preset's name and/or settings on Bambu Cloud.
  804. """
  805. cloud = await build_authenticated_cloud(db, current_user)
  806. if cloud is None or not cloud.is_authenticated:
  807. raise HTTPException(status_code=401, detail="Not authenticated")
  808. try:
  809. data = await cloud.update_setting(
  810. setting_id=setting_id,
  811. name=request.name,
  812. setting=request.setting,
  813. )
  814. return data
  815. except BambuCloudAuthError:
  816. await clear_token(db, current_user)
  817. raise HTTPException(status_code=401, detail="Authentication expired")
  818. except BambuCloudError as e:
  819. raise HTTPException(status_code=500, detail=str(e))
  820. finally:
  821. await cloud.close()
  822. @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
  823. async def delete_setting(
  824. setting_id: str,
  825. db: AsyncSession = Depends(get_db),
  826. current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
  827. ):
  828. """
  829. Delete a slicer preset/setting.
  830. Removes the preset from Bambu Cloud. This cannot be undone.
  831. """
  832. cloud = await build_authenticated_cloud(db, current_user)
  833. if cloud is None or not cloud.is_authenticated:
  834. raise HTTPException(status_code=401, detail="Not authenticated")
  835. try:
  836. result = await cloud.delete_setting(setting_id)
  837. return SlicerSettingDeleteResponse(
  838. success=result.get("success", True),
  839. message=result.get("message", "Setting deleted"),
  840. )
  841. except BambuCloudAuthError:
  842. await clear_token(db, current_user)
  843. raise HTTPException(status_code=401, detail="Authentication expired")
  844. except BambuCloudError as e:
  845. raise HTTPException(status_code=500, detail=str(e))
  846. finally:
  847. await cloud.close()
  848. # Path to field definition files
  849. FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
  850. # Cache for field definitions (loaded once)
  851. _fields_cache: dict[str, dict] = {}
  852. def _load_fields(preset_type: str) -> dict:
  853. """Load field definitions from JSON file."""
  854. if preset_type in _fields_cache:
  855. return _fields_cache[preset_type]
  856. # Map API type names to file names
  857. file_map = {
  858. "filament": "filament_fields.json",
  859. "print": "process_fields.json",
  860. "process": "process_fields.json",
  861. "printer": "printer_fields.json",
  862. }
  863. filename = file_map.get(preset_type)
  864. if not filename:
  865. raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
  866. file_path = FIELDS_DATA_DIR / filename
  867. if not file_path.exists():
  868. raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
  869. with open(file_path) as f:
  870. data = json.load(f)
  871. _fields_cache[preset_type] = data
  872. return data
  873. @router.get("/builtin-filaments")
  874. async def get_builtin_filaments(
  875. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  876. ):
  877. """
  878. Get built-in filament names as a fallback source.
  879. Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
  880. {filament_id, name} objects. Used by the frontend when cloud
  881. and local profiles are unavailable.
  882. """
  883. return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
  884. # Cache for filament_id → name mapping (resolved from cloud preset details)
  885. _filament_id_name_cache: dict[str, str] = {}
  886. _filament_id_name_cache_time: float = 0
  887. @router.get("/filament-id-map")
  888. async def get_filament_id_map(
  889. db: AsyncSession = Depends(get_db),
  890. current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
  891. ):
  892. """
  893. Get filament_id → name mapping for user cloud presets.
  894. K-profiles store a filament_id (e.g., "P4d64437") which is different from
  895. the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
  896. fetches details for all custom presets and returns the mapping.
  897. Cached for 5 minutes.
  898. """
  899. import time
  900. global _filament_id_name_cache, _filament_id_name_cache_time
  901. if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
  902. return _filament_id_name_cache
  903. cloud = await build_authenticated_cloud(db, current_user)
  904. if cloud is None or not cloud.is_authenticated:
  905. if cloud is not None:
  906. await cloud.close()
  907. return _filament_id_name_cache or {}
  908. try:
  909. data = await cloud.get_slicer_settings()
  910. custom_presets = data.get("filament", {}).get("private", [])
  911. result: dict[str, str] = {}
  912. for preset in custom_presets:
  913. setting_id = preset.get("setting_id", "")
  914. if not setting_id:
  915. continue
  916. try:
  917. detail = await cloud.get_setting_detail(setting_id)
  918. fid = detail.get("filament_id", "")
  919. name = detail.get("name", "")
  920. if fid and name:
  921. # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
  922. clean_name = name.split(" @")[0].strip() if " @" in name else name
  923. result[fid] = clean_name
  924. except Exception:
  925. pass
  926. _filament_id_name_cache = result
  927. _filament_id_name_cache_time = time.time()
  928. return result
  929. except Exception:
  930. return _filament_id_name_cache or {}
  931. finally:
  932. await cloud.close()
  933. @router.get("/fields/{preset_type}")
  934. async def get_preset_fields(
  935. preset_type: Literal["filament", "print", "process", "printer"],
  936. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  937. ):
  938. """
  939. Get field definitions for a preset type.
  940. Returns a list of field definitions including:
  941. - key: The setting key name
  942. - label: Human-readable label
  943. - type: Field type (text, number, boolean, select)
  944. - category: Grouping category
  945. - description: Field description
  946. - options: For select fields, available options
  947. - unit: Unit of measurement (if applicable)
  948. - min/max/step: For number fields, validation constraints
  949. """
  950. data = _load_fields(preset_type)
  951. return data
  952. @router.get("/fields")
  953. async def get_all_preset_fields(
  954. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  955. ):
  956. """
  957. Get all field definitions for all preset types.
  958. Returns field definitions organized by type.
  959. """
  960. return {
  961. "filament": _load_fields("filament"),
  962. "process": _load_fields("process"),
  963. "printer": _load_fields("printer"),
  964. }