cloud.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  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, HTTPException
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.core.database import get_db
  13. from backend.app.models.settings import Settings
  14. from backend.app.schemas.cloud import (
  15. CloudAuthStatus,
  16. CloudDevice,
  17. CloudLoginRequest,
  18. CloudLoginResponse,
  19. CloudTokenRequest,
  20. CloudVerifyRequest,
  21. SlicerSetting,
  22. SlicerSettingCreate,
  23. SlicerSettingDeleteResponse,
  24. SlicerSettingsResponse,
  25. SlicerSettingUpdate,
  26. )
  27. from backend.app.services.bambu_cloud import (
  28. BambuCloudAuthError,
  29. BambuCloudError,
  30. get_cloud_service,
  31. )
  32. logger = logging.getLogger(__name__)
  33. router = APIRouter(prefix="/cloud", tags=["cloud"])
  34. # Keys for storing cloud credentials in settings
  35. CLOUD_TOKEN_KEY = "bambu_cloud_token"
  36. CLOUD_EMAIL_KEY = "bambu_cloud_email"
  37. async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
  38. """Get stored cloud token and email from database."""
  39. result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
  40. settings = {s.key: s.value for s in result.scalars().all()}
  41. return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
  42. async def store_token(db: AsyncSession, token: str, email: str) -> None:
  43. """Store cloud token and email in database."""
  44. for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
  45. result = await db.execute(select(Settings).where(Settings.key == key))
  46. setting = result.scalar_one_or_none()
  47. if setting:
  48. setting.value = value
  49. else:
  50. db.add(Settings(key=key, value=value))
  51. await db.commit()
  52. async def clear_token(db: AsyncSession) -> None:
  53. """Clear stored cloud token and email."""
  54. result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
  55. for setting in result.scalars().all():
  56. await db.delete(setting)
  57. await db.commit()
  58. @router.get("/status", response_model=CloudAuthStatus)
  59. async def get_auth_status(db: AsyncSession = Depends(get_db)):
  60. """Get current cloud authentication status."""
  61. token, email = await get_stored_token(db)
  62. cloud = get_cloud_service()
  63. if token:
  64. cloud.set_token(token)
  65. return CloudAuthStatus(
  66. is_authenticated=cloud.is_authenticated,
  67. email=email if cloud.is_authenticated else None,
  68. )
  69. @router.post("/login", response_model=CloudLoginResponse)
  70. async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
  71. """
  72. Initiate login to Bambu Cloud.
  73. This will typically trigger a verification code to be sent to the user's email.
  74. After receiving the code, call /cloud/verify to complete the login.
  75. """
  76. cloud = get_cloud_service()
  77. # Store email temporarily for verification step
  78. await store_token(db, "", request.email)
  79. try:
  80. result = await cloud.login_request(request.email, request.password)
  81. if result.get("success") and cloud.access_token:
  82. # Direct login succeeded (rare)
  83. await store_token(db, cloud.access_token, request.email)
  84. return CloudLoginResponse(
  85. success=result.get("success", False),
  86. needs_verification=result.get("needs_verification", False),
  87. message=result.get("message", "Unknown error"),
  88. )
  89. except BambuCloudAuthError as e:
  90. raise HTTPException(status_code=401, detail=str(e))
  91. except BambuCloudError as e:
  92. raise HTTPException(status_code=500, detail=str(e))
  93. @router.post("/verify", response_model=CloudLoginResponse)
  94. async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
  95. """
  96. Complete login with verification code.
  97. After calling /cloud/login, the user will receive an email with a 6-digit code.
  98. Submit that code here to complete authentication.
  99. """
  100. cloud = get_cloud_service()
  101. try:
  102. result = await cloud.verify_code(request.email, request.code)
  103. if result.get("success") and cloud.access_token:
  104. await store_token(db, cloud.access_token, request.email)
  105. return CloudLoginResponse(
  106. success=result.get("success", False),
  107. needs_verification=False,
  108. message=result.get("message", "Unknown error"),
  109. )
  110. except BambuCloudAuthError as e:
  111. raise HTTPException(status_code=401, detail=str(e))
  112. except BambuCloudError as e:
  113. raise HTTPException(status_code=500, detail=str(e))
  114. @router.post("/token", response_model=CloudAuthStatus)
  115. async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
  116. """
  117. Set access token directly.
  118. For users who already have a token (e.g., from Bambu Studio).
  119. """
  120. cloud = get_cloud_service()
  121. cloud.set_token(request.access_token)
  122. # Verify token works by trying to get profile
  123. try:
  124. await cloud.get_user_profile()
  125. await store_token(db, request.access_token, "token-auth")
  126. return CloudAuthStatus(is_authenticated=True, email="token-auth")
  127. except BambuCloudError:
  128. cloud.logout()
  129. raise HTTPException(status_code=401, detail="Invalid token")
  130. @router.post("/logout")
  131. async def logout(db: AsyncSession = Depends(get_db)):
  132. """Log out of Bambu Cloud."""
  133. cloud = get_cloud_service()
  134. cloud.logout()
  135. await clear_token(db)
  136. return {"success": True}
  137. @router.get("/settings", response_model=SlicerSettingsResponse)
  138. async def get_slicer_settings(
  139. version: str = "02.04.00.70",
  140. db: AsyncSession = Depends(get_db),
  141. ):
  142. """
  143. Get all slicer settings (filament, printer, process presets).
  144. Requires authentication.
  145. """
  146. token, _ = await get_stored_token(db)
  147. if not token:
  148. raise HTTPException(status_code=401, detail="Not authenticated")
  149. cloud = get_cloud_service()
  150. cloud.set_token(token)
  151. if not cloud.is_authenticated:
  152. raise HTTPException(status_code=401, detail="Not authenticated")
  153. try:
  154. data = await cloud.get_slicer_settings(version)
  155. result = SlicerSettingsResponse()
  156. # Map API keys to our types (API uses 'print' for process presets)
  157. type_mapping = {
  158. "filament": "filament",
  159. "printer": "printer",
  160. "print": "process", # API calls it 'print', we call it 'process'
  161. }
  162. for api_key, our_type in type_mapping.items():
  163. type_data = data.get(api_key, {})
  164. # Combine public and private presets, private (user's own) first
  165. all_settings = type_data.get("private", []) + type_data.get("public", [])
  166. parsed = []
  167. for s in all_settings:
  168. parsed.append(
  169. SlicerSetting(
  170. setting_id=s.get("setting_id", s.get("id", "")),
  171. name=s.get("name", "Unknown"),
  172. type=our_type,
  173. version=s.get("version"),
  174. user_id=s.get("user_id"),
  175. updated_time=s.get("updated_time"),
  176. )
  177. )
  178. setattr(result, our_type, parsed)
  179. return result
  180. except BambuCloudAuthError:
  181. await clear_token(db)
  182. raise HTTPException(status_code=401, detail="Authentication expired")
  183. except BambuCloudError as e:
  184. raise HTTPException(status_code=500, detail=str(e))
  185. @router.get("/settings/{setting_id}")
  186. async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
  187. """
  188. Get detailed information for a specific setting/preset.
  189. Returns the full preset configuration.
  190. """
  191. token, _ = await get_stored_token(db)
  192. if not token:
  193. raise HTTPException(status_code=401, detail="Not authenticated")
  194. cloud = get_cloud_service()
  195. cloud.set_token(token)
  196. if not cloud.is_authenticated:
  197. raise HTTPException(status_code=401, detail="Not authenticated")
  198. try:
  199. data = await cloud.get_setting_detail(setting_id)
  200. return data
  201. except BambuCloudAuthError:
  202. await clear_token(db)
  203. raise HTTPException(status_code=401, detail="Authentication expired")
  204. except BambuCloudError as e:
  205. raise HTTPException(status_code=500, detail=str(e))
  206. # Cache for filament preset info (setting_id -> {name, k})
  207. _filament_cache: dict[str, dict] = {}
  208. _filament_cache_time: float = 0
  209. FILAMENT_CACHE_TTL = 300 # 5 minutes
  210. @router.post("/filament-info")
  211. async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
  212. """
  213. Get filament preset info (name and K value) for multiple setting IDs.
  214. Used to enrich AMS tray tooltips with cloud preset data.
  215. """
  216. import time
  217. logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}")
  218. global _filament_cache, _filament_cache_time
  219. # Clear stale cache
  220. if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
  221. _filament_cache = {}
  222. _filament_cache_time = time.time()
  223. token, _ = await get_stored_token(db)
  224. if not token:
  225. logger.info("get_filament_info: Not authenticated, returning empty")
  226. # Return empty results if not authenticated (graceful degradation)
  227. return {}
  228. cloud = get_cloud_service()
  229. cloud.set_token(token)
  230. if not cloud.is_authenticated:
  231. return {}
  232. result = {}
  233. for setting_id in setting_ids:
  234. if not setting_id:
  235. continue
  236. # Check cache first
  237. if setting_id in _filament_cache:
  238. result[setting_id] = _filament_cache[setting_id]
  239. continue
  240. try:
  241. data = await cloud.get_setting_detail(setting_id)
  242. setting = data.get("setting", {})
  243. # Extract name (e.g., "Bambu PLA Basic Jade White")
  244. name = data.get("name", "")
  245. # Extract K value (pressure_advance)
  246. k_value = setting.get("pressure_advance")
  247. if k_value is not None:
  248. try:
  249. k_value = float(k_value)
  250. except (ValueError, TypeError):
  251. k_value = None
  252. info = {"name": name, "k": k_value}
  253. _filament_cache[setting_id] = info
  254. result[setting_id] = info
  255. except Exception as e:
  256. logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
  257. # Cache the failure to avoid repeated requests
  258. _filament_cache[setting_id] = {"name": "", "k": None}
  259. result[setting_id] = {"name": "", "k": None}
  260. return result
  261. @router.get("/devices", response_model=list[CloudDevice])
  262. async def get_devices(db: AsyncSession = Depends(get_db)):
  263. """
  264. Get list of bound printer devices.
  265. Returns printers registered to the user's Bambu account.
  266. """
  267. token, _ = await get_stored_token(db)
  268. if not token:
  269. raise HTTPException(status_code=401, detail="Not authenticated")
  270. cloud = get_cloud_service()
  271. cloud.set_token(token)
  272. if not cloud.is_authenticated:
  273. raise HTTPException(status_code=401, detail="Not authenticated")
  274. try:
  275. data = await cloud.get_devices()
  276. devices = data.get("devices", [])
  277. return [
  278. CloudDevice(
  279. dev_id=d.get("dev_id", ""),
  280. name=d.get("name", "Unknown"),
  281. dev_model_name=d.get("dev_model_name"),
  282. dev_product_name=d.get("dev_product_name"),
  283. online=d.get("online", False),
  284. )
  285. for d in devices
  286. ]
  287. except BambuCloudAuthError:
  288. await clear_token(db)
  289. raise HTTPException(status_code=401, detail="Authentication expired")
  290. except BambuCloudError as e:
  291. raise HTTPException(status_code=500, detail=str(e))
  292. @router.post("/settings")
  293. async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
  294. """
  295. Create a new slicer preset/setting.
  296. Creates a new preset on Bambu Cloud. The preset inherits from a base preset
  297. and only stores the delta (modified values).
  298. Type should be: 'filament', 'print', or 'printer'
  299. """
  300. token, _ = await get_stored_token(db)
  301. if not token:
  302. raise HTTPException(status_code=401, detail="Not authenticated")
  303. cloud = get_cloud_service()
  304. cloud.set_token(token)
  305. if not cloud.is_authenticated:
  306. raise HTTPException(status_code=401, detail="Not authenticated")
  307. try:
  308. data = await cloud.create_setting(
  309. preset_type=request.type,
  310. name=request.name,
  311. base_id=request.base_id,
  312. setting=request.setting,
  313. version=request.version,
  314. )
  315. return data
  316. except BambuCloudAuthError:
  317. await clear_token(db)
  318. raise HTTPException(status_code=401, detail="Authentication expired")
  319. except BambuCloudError as e:
  320. raise HTTPException(status_code=500, detail=str(e))
  321. @router.put("/settings/{setting_id}")
  322. async def update_setting(
  323. setting_id: str,
  324. request: SlicerSettingUpdate,
  325. db: AsyncSession = Depends(get_db),
  326. ):
  327. """
  328. Update an existing slicer preset/setting.
  329. Updates the preset's name and/or settings on Bambu Cloud.
  330. """
  331. token, _ = await get_stored_token(db)
  332. if not token:
  333. raise HTTPException(status_code=401, detail="Not authenticated")
  334. cloud = get_cloud_service()
  335. cloud.set_token(token)
  336. if not cloud.is_authenticated:
  337. raise HTTPException(status_code=401, detail="Not authenticated")
  338. try:
  339. data = await cloud.update_setting(
  340. setting_id=setting_id,
  341. name=request.name,
  342. setting=request.setting,
  343. )
  344. return data
  345. except BambuCloudAuthError:
  346. await clear_token(db)
  347. raise HTTPException(status_code=401, detail="Authentication expired")
  348. except BambuCloudError as e:
  349. raise HTTPException(status_code=500, detail=str(e))
  350. @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
  351. async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
  352. """
  353. Delete a slicer preset/setting.
  354. Removes the preset from Bambu Cloud. This cannot be undone.
  355. """
  356. token, _ = await get_stored_token(db)
  357. if not token:
  358. raise HTTPException(status_code=401, detail="Not authenticated")
  359. cloud = get_cloud_service()
  360. cloud.set_token(token)
  361. if not cloud.is_authenticated:
  362. raise HTTPException(status_code=401, detail="Not authenticated")
  363. try:
  364. result = await cloud.delete_setting(setting_id)
  365. return SlicerSettingDeleteResponse(
  366. success=result.get("success", True),
  367. message=result.get("message", "Setting deleted"),
  368. )
  369. except BambuCloudAuthError:
  370. await clear_token(db)
  371. raise HTTPException(status_code=401, detail="Authentication expired")
  372. except BambuCloudError as e:
  373. raise HTTPException(status_code=500, detail=str(e))
  374. # Path to field definition files
  375. FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
  376. # Cache for field definitions (loaded once)
  377. _fields_cache: dict[str, dict] = {}
  378. def _load_fields(preset_type: str) -> dict:
  379. """Load field definitions from JSON file."""
  380. if preset_type in _fields_cache:
  381. return _fields_cache[preset_type]
  382. # Map API type names to file names
  383. file_map = {
  384. "filament": "filament_fields.json",
  385. "print": "process_fields.json",
  386. "process": "process_fields.json",
  387. "printer": "printer_fields.json",
  388. }
  389. filename = file_map.get(preset_type)
  390. if not filename:
  391. raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
  392. file_path = FIELDS_DATA_DIR / filename
  393. if not file_path.exists():
  394. raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
  395. with open(file_path) as f:
  396. data = json.load(f)
  397. _fields_cache[preset_type] = data
  398. return data
  399. @router.get("/fields/{preset_type}")
  400. async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
  401. """
  402. Get field definitions for a preset type.
  403. Returns a list of field definitions including:
  404. - key: The setting key name
  405. - label: Human-readable label
  406. - type: Field type (text, number, boolean, select)
  407. - category: Grouping category
  408. - description: Field description
  409. - options: For select fields, available options
  410. - unit: Unit of measurement (if applicable)
  411. - min/max/step: For number fields, validation constraints
  412. """
  413. data = _load_fields(preset_type)
  414. return data
  415. @router.get("/fields")
  416. async def get_all_preset_fields():
  417. """
  418. Get all field definitions for all preset types.
  419. Returns field definitions organized by type.
  420. """
  421. return {
  422. "filament": _load_fields("filament"),
  423. "process": _load_fields("process"),
  424. "printer": _load_fields("printer"),
  425. }