cloud.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  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.auth import RequirePermissionIfAuthEnabled
  13. from backend.app.core.database import get_db
  14. from backend.app.core.permissions import Permission
  15. from backend.app.models.settings import Settings
  16. from backend.app.models.user import User
  17. from backend.app.schemas.cloud import (
  18. CloudAuthStatus,
  19. CloudDevice,
  20. CloudLoginRequest,
  21. CloudLoginResponse,
  22. CloudTokenRequest,
  23. CloudVerifyRequest,
  24. FirmwareUpdateInfo,
  25. FirmwareUpdatesResponse,
  26. SlicerSetting,
  27. SlicerSettingCreate,
  28. SlicerSettingDeleteResponse,
  29. SlicerSettingsResponse,
  30. SlicerSettingUpdate,
  31. )
  32. from backend.app.services.bambu_cloud import (
  33. BambuCloudAuthError,
  34. BambuCloudError,
  35. get_cloud_service,
  36. )
  37. logger = logging.getLogger(__name__)
  38. router = APIRouter(prefix="/cloud", tags=["cloud"])
  39. # Keys for storing cloud credentials in settings
  40. CLOUD_TOKEN_KEY = "bambu_cloud_token"
  41. CLOUD_EMAIL_KEY = "bambu_cloud_email"
  42. async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
  43. """Get stored cloud token and email from database."""
  44. result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
  45. settings = {s.key: s.value for s in result.scalars().all()}
  46. return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
  47. async def store_token(db: AsyncSession, token: str, email: str) -> None:
  48. """Store cloud token and email in database."""
  49. for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
  50. result = await db.execute(select(Settings).where(Settings.key == key))
  51. setting = result.scalar_one_or_none()
  52. if setting:
  53. setting.value = value
  54. else:
  55. db.add(Settings(key=key, value=value))
  56. await db.commit()
  57. async def clear_token(db: AsyncSession) -> None:
  58. """Clear stored cloud token and email."""
  59. result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
  60. for setting in result.scalars().all():
  61. await db.delete(setting)
  62. await db.commit()
  63. @router.get("/status", response_model=CloudAuthStatus)
  64. async def get_auth_status(
  65. db: AsyncSession = Depends(get_db),
  66. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  67. ):
  68. """Get current cloud authentication status."""
  69. token, email = await get_stored_token(db)
  70. cloud = get_cloud_service()
  71. if token:
  72. cloud.set_token(token)
  73. return CloudAuthStatus(
  74. is_authenticated=cloud.is_authenticated,
  75. email=email if cloud.is_authenticated else None,
  76. )
  77. @router.post("/login", response_model=CloudLoginResponse)
  78. async def login(
  79. request: CloudLoginRequest,
  80. db: AsyncSession = Depends(get_db),
  81. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  82. ):
  83. """
  84. Initiate login to Bambu Cloud.
  85. This will trigger either:
  86. - Email verification: A code is sent to the user's email
  87. - TOTP verification: User enters code from their authenticator app
  88. After receiving/generating the code, call /cloud/verify to complete the login.
  89. For TOTP, include the tfa_key from this response in the verify request.
  90. """
  91. cloud = get_cloud_service()
  92. # Store email temporarily for verification step
  93. await store_token(db, "", request.email)
  94. try:
  95. result = await cloud.login_request(request.email, request.password)
  96. if result.get("success") and cloud.access_token:
  97. # Direct login succeeded (rare)
  98. await store_token(db, cloud.access_token, request.email)
  99. return CloudLoginResponse(
  100. success=result.get("success", False),
  101. needs_verification=result.get("needs_verification", False),
  102. message=result.get("message", "Unknown error"),
  103. verification_type=result.get("verification_type"),
  104. tfa_key=result.get("tfa_key"),
  105. )
  106. except BambuCloudAuthError as e:
  107. raise HTTPException(status_code=401, detail=str(e))
  108. except BambuCloudError as e:
  109. raise HTTPException(status_code=500, detail=str(e))
  110. @router.post("/verify", response_model=CloudLoginResponse)
  111. async def verify_code(
  112. request: CloudVerifyRequest,
  113. db: AsyncSession = Depends(get_db),
  114. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  115. ):
  116. """
  117. Complete login with verification code (email or TOTP).
  118. For email verification:
  119. - After calling /cloud/login, the user receives an email with a 6-digit code
  120. - Submit the code with email address
  121. For TOTP verification:
  122. - The user enters the 6-digit code from their authenticator app
  123. - Include the tfa_key from the /cloud/login response
  124. """
  125. cloud = get_cloud_service()
  126. try:
  127. # Use TOTP verification if tfa_key is provided
  128. if request.tfa_key:
  129. result = await cloud.verify_totp(request.tfa_key, request.code)
  130. else:
  131. result = await cloud.verify_code(request.email, request.code)
  132. if result.get("success") and cloud.access_token:
  133. await store_token(db, cloud.access_token, request.email)
  134. return CloudLoginResponse(
  135. success=result.get("success", False),
  136. needs_verification=False,
  137. message=result.get("message", "Unknown error"),
  138. )
  139. except BambuCloudAuthError as e:
  140. raise HTTPException(status_code=401, detail=str(e))
  141. except BambuCloudError as e:
  142. raise HTTPException(status_code=500, detail=str(e))
  143. @router.post("/token", response_model=CloudAuthStatus)
  144. async def set_token(
  145. request: CloudTokenRequest,
  146. db: AsyncSession = Depends(get_db),
  147. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  148. ):
  149. """
  150. Set access token directly.
  151. For users who already have a token (e.g., from Bambu Studio).
  152. """
  153. cloud = get_cloud_service()
  154. cloud.set_token(request.access_token)
  155. # Verify token works by trying to get profile
  156. try:
  157. await cloud.get_user_profile()
  158. await store_token(db, request.access_token, "token-auth")
  159. return CloudAuthStatus(is_authenticated=True, email="token-auth")
  160. except BambuCloudError:
  161. cloud.logout()
  162. raise HTTPException(status_code=401, detail="Invalid token")
  163. @router.post("/logout")
  164. async def logout(
  165. db: AsyncSession = Depends(get_db),
  166. _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
  167. ):
  168. """Log out of Bambu Cloud."""
  169. cloud = get_cloud_service()
  170. cloud.logout()
  171. await clear_token(db)
  172. return {"success": True}
  173. @router.get("/settings", response_model=SlicerSettingsResponse)
  174. async def get_slicer_settings(
  175. version: str = "02.04.00.70",
  176. db: AsyncSession = Depends(get_db),
  177. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  178. ):
  179. """
  180. Get all slicer settings (filament, printer, process presets).
  181. Requires authentication.
  182. """
  183. token, _ = await get_stored_token(db)
  184. if not token:
  185. raise HTTPException(status_code=401, detail="Not authenticated")
  186. cloud = get_cloud_service()
  187. cloud.set_token(token)
  188. if not cloud.is_authenticated:
  189. raise HTTPException(status_code=401, detail="Not authenticated")
  190. try:
  191. data = await cloud.get_slicer_settings(version)
  192. result = SlicerSettingsResponse()
  193. # Map API keys to our types (API uses 'print' for process presets)
  194. type_mapping = {
  195. "filament": "filament",
  196. "printer": "printer",
  197. "print": "process", # API calls it 'print', we call it 'process'
  198. }
  199. for api_key, our_type in type_mapping.items():
  200. type_data = data.get(api_key, {})
  201. private_settings = type_data.get("private", [])
  202. public_settings = type_data.get("public", [])
  203. parsed = []
  204. # Private (custom) presets first
  205. for s in private_settings:
  206. parsed.append(
  207. SlicerSetting(
  208. setting_id=s.get("setting_id", s.get("id", "")),
  209. name=s.get("name", "Unknown"),
  210. type=our_type,
  211. version=s.get("version"),
  212. user_id=s.get("user_id"),
  213. updated_time=s.get("updated_time"),
  214. is_custom=True,
  215. )
  216. )
  217. # Public (default) presets
  218. for s in public_settings:
  219. parsed.append(
  220. SlicerSetting(
  221. setting_id=s.get("setting_id", s.get("id", "")),
  222. name=s.get("name", "Unknown"),
  223. type=our_type,
  224. version=s.get("version"),
  225. user_id=s.get("user_id"),
  226. updated_time=s.get("updated_time"),
  227. is_custom=False,
  228. )
  229. )
  230. setattr(result, our_type, parsed)
  231. return result
  232. except BambuCloudAuthError:
  233. await clear_token(db)
  234. raise HTTPException(status_code=401, detail="Authentication expired")
  235. except BambuCloudError as e:
  236. raise HTTPException(status_code=500, detail=str(e))
  237. @router.get("/settings/{setting_id}")
  238. async def get_setting_detail(
  239. setting_id: str,
  240. db: AsyncSession = Depends(get_db),
  241. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  242. ):
  243. """
  244. Get detailed information for a specific setting/preset.
  245. Returns the full preset configuration.
  246. """
  247. token, _ = await get_stored_token(db)
  248. if not token:
  249. raise HTTPException(status_code=401, detail="Not authenticated")
  250. cloud = get_cloud_service()
  251. cloud.set_token(token)
  252. if not cloud.is_authenticated:
  253. raise HTTPException(status_code=401, detail="Not authenticated")
  254. try:
  255. data = await cloud.get_setting_detail(setting_id)
  256. return data
  257. except BambuCloudAuthError:
  258. await clear_token(db)
  259. raise HTTPException(status_code=401, detail="Authentication expired")
  260. except BambuCloudError as e:
  261. raise HTTPException(status_code=500, detail=str(e))
  262. @router.get("/filaments", response_model=list[SlicerSetting])
  263. async def get_filament_presets(
  264. version: str = "02.04.00.70",
  265. db: AsyncSession = Depends(get_db),
  266. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  267. ):
  268. """
  269. Get just filament presets (convenience endpoint).
  270. Returns all filament presets with custom presets first.
  271. Uses the same cache as get_slicer_settings.
  272. """
  273. settings = await get_slicer_settings(version=version, db=db)
  274. return settings.filament
  275. # Cache for filament preset info (setting_id -> {name, k})
  276. _filament_cache: dict[str, dict] = {}
  277. _filament_cache_time: float = 0
  278. FILAMENT_CACHE_TTL = 300 # 5 minutes
  279. # Built-in filament ID → name mapping (fallback when cloud API and local profiles
  280. # don't have the entry). Based on Bambu Lab's known filament catalogue.
  281. _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
  282. "GFA00": "Bambu PLA Basic",
  283. "GFA01": "Bambu PLA Matte",
  284. "GFA02": "Bambu PLA Metal",
  285. "GFA05": "Bambu PLA Silk",
  286. "GFA06": "Bambu PLA Silk+",
  287. "GFA07": "Bambu PLA Marble",
  288. "GFA08": "Bambu PLA Sparkle",
  289. "GFA09": "Bambu PLA Tough",
  290. "GFA11": "Bambu PLA Aero",
  291. "GFA12": "Bambu PLA Glow",
  292. "GFA13": "Bambu PLA Dynamic",
  293. "GFA15": "Bambu PLA Galaxy",
  294. "GFA16": "Bambu PLA Wood",
  295. "GFA50": "Bambu PLA-CF",
  296. "GFB00": "Bambu ABS",
  297. "GFB01": "Bambu ASA",
  298. "GFB02": "Bambu ASA-Aero",
  299. "GFB50": "Bambu ABS-GF",
  300. "GFB51": "Bambu ASA-CF",
  301. "GFB60": "PolyLite ABS",
  302. "GFB61": "PolyLite ASA",
  303. "GFB98": "Generic ASA",
  304. "GFB99": "Generic ABS",
  305. "GFC00": "Bambu PC",
  306. "GFC01": "Bambu PC FR",
  307. "GFC99": "Generic PC",
  308. "GFG00": "Bambu PETG Basic",
  309. "GFG01": "Bambu PETG Translucent",
  310. "GFG02": "Bambu PETG HF",
  311. "GFG50": "Bambu PETG-CF",
  312. "GFG60": "PolyLite PETG",
  313. "GFG96": "Generic PETG HF",
  314. "GFG97": "Generic PCTG",
  315. "GFG98": "Generic PETG-CF",
  316. "GFG99": "Generic PETG",
  317. "GFL00": "PolyLite PLA",
  318. "GFL01": "PolyTerra PLA",
  319. "GFL03": "eSUN PLA+",
  320. "GFL04": "Overture PLA",
  321. "GFL05": "Overture Matte PLA",
  322. "GFL06": "Fiberon PETG-ESD",
  323. "GFL50": "Fiberon PA6-CF",
  324. "GFL51": "Fiberon PA6-GF",
  325. "GFL52": "Fiberon PA12-CF",
  326. "GFL53": "Fiberon PA612-CF",
  327. "GFL54": "Fiberon PET-CF",
  328. "GFL55": "Fiberon PETG-rCF",
  329. "GFL95": "Generic PLA High Speed",
  330. "GFL96": "Generic PLA Silk",
  331. "GFL98": "Generic PLA-CF",
  332. "GFL99": "Generic PLA",
  333. "GFN03": "Bambu PA-CF",
  334. "GFN04": "Bambu PAHT-CF",
  335. "GFN05": "Bambu PA6-CF",
  336. "GFN06": "Bambu PPA-CF",
  337. "GFN08": "Bambu PA6-GF",
  338. "GFN96": "Generic PPA-GF",
  339. "GFN97": "Generic PPA-CF",
  340. "GFN98": "Generic PA-CF",
  341. "GFN99": "Generic PA",
  342. "GFP95": "Generic PP-GF",
  343. "GFP96": "Generic PP-CF",
  344. "GFP97": "Generic PP",
  345. "GFP98": "Generic PE-CF",
  346. "GFP99": "Generic PE",
  347. "GFR98": "Generic PHA",
  348. "GFR99": "Generic EVA",
  349. "GFS00": "Bambu Support W",
  350. "GFS01": "Bambu Support G",
  351. "GFS02": "Bambu Support For PLA",
  352. "GFS03": "Bambu Support For PA/PET",
  353. "GFS04": "Bambu PVA",
  354. "GFS05": "Bambu Support For PLA/PETG",
  355. "GFS06": "Bambu Support for ABS",
  356. "GFS97": "Generic BVOH",
  357. "GFS98": "Generic HIPS",
  358. "GFS99": "Generic PVA",
  359. "GFT01": "Bambu PET-CF",
  360. "GFT02": "Bambu PPS-CF",
  361. "GFT97": "Generic PPS",
  362. "GFT98": "Generic PPS-CF",
  363. "GFU00": "Bambu TPU 95A HF",
  364. "GFU01": "Bambu TPU 95A",
  365. "GFU02": "Bambu TPU for AMS",
  366. "GFU98": "Generic TPU for AMS",
  367. "GFU99": "Generic TPU",
  368. }
  369. async def _enrich_from_local_presets(
  370. unresolved_ids: list[str],
  371. result: dict,
  372. db: AsyncSession,
  373. ) -> dict:
  374. """Fall back to local profiles for filament IDs not resolved by cloud.
  375. Matches by checking the setting_id field inside the local preset's
  376. resolved JSON blob (stored in the 'setting' column).
  377. """
  378. from sqlalchemy import text
  379. from backend.app.models.local_preset import LocalPreset
  380. # Build lookup: converted setting_id -> original filament_id
  381. id_map: dict[str, str] = {}
  382. for fid in unresolved_ids:
  383. converted = _filament_id_to_setting_id(fid)
  384. id_map[converted] = fid
  385. # Also map the original in case the JSON uses that form
  386. id_map[fid] = fid
  387. try:
  388. # Query filament presets that have a setting_id matching any of our IDs
  389. # json_extract is supported in SQLite >= 3.9 and all modern Python builds
  390. candidates = await db.execute(
  391. select(LocalPreset).where(
  392. LocalPreset.preset_type == "filament",
  393. text("json_extract(setting, '$.setting_id') IS NOT NULL"),
  394. )
  395. )
  396. for preset in candidates.scalars().all():
  397. try:
  398. setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
  399. preset_setting_id = setting_data.get("setting_id", "")
  400. if preset_setting_id in id_map:
  401. original_id = id_map[preset_setting_id]
  402. info = {"name": preset.name, "k": None}
  403. # Try to extract K value from the local preset
  404. pa = setting_data.get("pressure_advance")
  405. if pa is not None:
  406. try:
  407. k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
  408. info["k"] = k_val
  409. except (ValueError, TypeError, IndexError):
  410. pass
  411. _filament_cache[original_id] = info
  412. result[original_id] = info
  413. except Exception:
  414. continue
  415. except Exception as e:
  416. logger.warning("Failed to search local presets for filament info: %s", e)
  417. # Phase 4: Fall back to built-in filament name table for any still without a name
  418. for fid in unresolved_ids:
  419. if fid not in result or not result[fid].get("name"):
  420. name = _BUILTIN_FILAMENT_NAMES.get(fid, "")
  421. if name:
  422. # Preserve K value from earlier phases if available
  423. existing_k = result.get(fid, {}).get("k")
  424. info = {"name": name, "k": existing_k}
  425. _filament_cache[fid] = info
  426. result[fid] = info
  427. # Fill remaining unresolved with empty entries
  428. for fid in unresolved_ids:
  429. if fid not in result:
  430. _filament_cache[fid] = {"name": "", "k": None}
  431. result[fid] = {"name": "", "k": None}
  432. return result
  433. def _filament_id_to_setting_id(filament_id: str) -> str:
  434. """
  435. Convert filament_id to setting_id format for Bambu Cloud API.
  436. Printers report filament_id (e.g., GFA00, GFG02) but the API expects
  437. setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
  438. User presets (starting with "P") and already-correct IDs are returned unchanged.
  439. """
  440. if not filament_id:
  441. return filament_id
  442. # User presets start with "P" - leave unchanged
  443. if filament_id.startswith("P"):
  444. return filament_id
  445. # Official Bambu presets: GFx## -> GFSx##
  446. # Check if it matches the filament_id pattern (GF followed by letter and digits)
  447. if filament_id.startswith("GF") and len(filament_id) >= 4:
  448. # Check if it's already a setting_id (has S after GF)
  449. if filament_id[2] == "S":
  450. return filament_id
  451. # Insert "S" after "GF": GFA00 -> GFSA00
  452. return f"GFS{filament_id[2:]}"
  453. return filament_id
  454. @router.post("/filament-info")
  455. async def get_filament_info(
  456. setting_ids: list[str] = Body(...),
  457. db: AsyncSession = Depends(get_db),
  458. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  459. ):
  460. """
  461. Get filament preset info (name and K value) for multiple setting IDs.
  462. Used to enrich AMS tray and nozzle rack tooltips with preset data.
  463. Lookup order: cache → cloud → local profiles → built-in table → empty fallback.
  464. """
  465. import time
  466. logger.info("get_filament_info called with %s IDs: %s", len(setting_ids), setting_ids)
  467. global _filament_cache, _filament_cache_time
  468. # Clear stale cache
  469. if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
  470. _filament_cache = {}
  471. _filament_cache_time = time.time()
  472. result = {}
  473. unresolved_ids: list[str] = []
  474. # Phase 1: Check cache
  475. for setting_id in setting_ids:
  476. if not setting_id:
  477. continue
  478. if setting_id in _filament_cache:
  479. result[setting_id] = _filament_cache[setting_id]
  480. else:
  481. unresolved_ids.append(setting_id)
  482. # Phase 2: Try cloud for uncached IDs
  483. if unresolved_ids:
  484. token, _ = await get_stored_token(db)
  485. if token:
  486. cloud = get_cloud_service()
  487. cloud.set_token(token)
  488. if cloud.is_authenticated:
  489. still_unresolved: list[str] = []
  490. for setting_id in unresolved_ids:
  491. try:
  492. api_setting_id = _filament_id_to_setting_id(setting_id)
  493. data = await cloud.get_setting_detail(api_setting_id)
  494. setting = data.get("setting", {})
  495. name = data.get("name", "")
  496. k_value = setting.get("pressure_advance")
  497. if k_value is not None:
  498. try:
  499. k_value = float(k_value)
  500. except (ValueError, TypeError):
  501. k_value = None
  502. info = {"name": name, "k": k_value}
  503. _filament_cache[setting_id] = info
  504. result[setting_id] = info
  505. if not name:
  506. still_unresolved.append(setting_id)
  507. except Exception as e:
  508. logger.warning(
  509. f"Failed to get cloud preset {setting_id} "
  510. f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
  511. )
  512. still_unresolved.append(setting_id)
  513. unresolved_ids = still_unresolved
  514. # Phase 3: Try local profiles for any IDs still without a name
  515. if unresolved_ids:
  516. result = await _enrich_from_local_presets(unresolved_ids, result, db)
  517. return result
  518. @router.get("/devices", response_model=list[CloudDevice])
  519. async def get_devices(
  520. db: AsyncSession = Depends(get_db),
  521. _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  522. ):
  523. """
  524. Get list of bound printer devices.
  525. Returns printers registered to the user's Bambu account.
  526. """
  527. token, _ = await get_stored_token(db)
  528. if not token:
  529. raise HTTPException(status_code=401, detail="Not authenticated")
  530. cloud = get_cloud_service()
  531. cloud.set_token(token)
  532. if not cloud.is_authenticated:
  533. raise HTTPException(status_code=401, detail="Not authenticated")
  534. try:
  535. data = await cloud.get_devices()
  536. devices = data.get("devices", [])
  537. return [
  538. CloudDevice(
  539. dev_id=d.get("dev_id", ""),
  540. name=d.get("name", "Unknown"),
  541. dev_model_name=d.get("dev_model_name"),
  542. dev_product_name=d.get("dev_product_name"),
  543. online=d.get("online", False),
  544. )
  545. for d in devices
  546. ]
  547. except BambuCloudAuthError:
  548. await clear_token(db)
  549. raise HTTPException(status_code=401, detail="Authentication expired")
  550. except BambuCloudError as e:
  551. raise HTTPException(status_code=500, detail=str(e))
  552. @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
  553. async def get_firmware_updates(
  554. db: AsyncSession = Depends(get_db),
  555. _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
  556. ):
  557. """
  558. Check for firmware updates for all bound devices.
  559. Returns firmware version info for each device including:
  560. - Current installed version
  561. - Latest available version
  562. - Whether an update is available
  563. - Release notes for the latest version
  564. Requires cloud authentication.
  565. """
  566. token, _ = await get_stored_token(db)
  567. if not token:
  568. raise HTTPException(status_code=401, detail="Not authenticated")
  569. cloud = get_cloud_service()
  570. cloud.set_token(token)
  571. if not cloud.is_authenticated:
  572. raise HTTPException(status_code=401, detail="Not authenticated")
  573. try:
  574. # First get list of bound devices
  575. devices_data = await cloud.get_devices()
  576. devices = devices_data.get("devices", [])
  577. updates = []
  578. updates_available = 0
  579. # Check firmware for each device
  580. for device in devices:
  581. device_id = device.get("dev_id", "")
  582. device_name = device.get("name", "Unknown")
  583. try:
  584. firmware_info = await cloud.get_firmware_version(device_id)
  585. update_available = firmware_info.get("update_available", False)
  586. if update_available:
  587. updates_available += 1
  588. updates.append(
  589. FirmwareUpdateInfo(
  590. device_id=device_id,
  591. device_name=device_name,
  592. current_version=firmware_info.get("current_version"),
  593. latest_version=firmware_info.get("latest_version"),
  594. update_available=update_available,
  595. release_notes=firmware_info.get("release_notes"),
  596. )
  597. )
  598. except BambuCloudError as e:
  599. logger.warning("Failed to get firmware info for %s: %s", device_name, e)
  600. # Still include device but with unknown firmware status
  601. updates.append(
  602. FirmwareUpdateInfo(
  603. device_id=device_id,
  604. device_name=device_name,
  605. current_version=None,
  606. latest_version=None,
  607. update_available=False,
  608. release_notes=None,
  609. )
  610. )
  611. return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)
  612. except BambuCloudAuthError:
  613. await clear_token(db)
  614. raise HTTPException(status_code=401, detail="Authentication expired")
  615. except BambuCloudError as e:
  616. raise HTTPException(status_code=500, detail=str(e))
  617. @router.post("/settings")
  618. async def create_setting(
  619. request: SlicerSettingCreate,
  620. db: AsyncSession = Depends(get_db),
  621. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  622. ):
  623. """
  624. Create a new slicer preset/setting.
  625. Creates a new preset on Bambu Cloud. The preset inherits from a base preset
  626. and only stores the delta (modified values).
  627. Type should be: 'filament', 'print', or 'printer'
  628. """
  629. token, _ = await get_stored_token(db)
  630. if not token:
  631. raise HTTPException(status_code=401, detail="Not authenticated")
  632. cloud = get_cloud_service()
  633. cloud.set_token(token)
  634. if not cloud.is_authenticated:
  635. raise HTTPException(status_code=401, detail="Not authenticated")
  636. try:
  637. data = await cloud.create_setting(
  638. preset_type=request.type,
  639. name=request.name,
  640. base_id=request.base_id,
  641. setting=request.setting,
  642. version=request.version,
  643. )
  644. return data
  645. except BambuCloudAuthError:
  646. await clear_token(db)
  647. raise HTTPException(status_code=401, detail="Authentication expired")
  648. except BambuCloudError as e:
  649. raise HTTPException(status_code=500, detail=str(e))
  650. @router.put("/settings/{setting_id}")
  651. async def update_setting(
  652. setting_id: str,
  653. request: SlicerSettingUpdate,
  654. db: AsyncSession = Depends(get_db),
  655. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  656. ):
  657. """
  658. Update an existing slicer preset/setting.
  659. Updates the preset's name and/or settings on Bambu Cloud.
  660. """
  661. token, _ = await get_stored_token(db)
  662. if not token:
  663. raise HTTPException(status_code=401, detail="Not authenticated")
  664. cloud = get_cloud_service()
  665. cloud.set_token(token)
  666. if not cloud.is_authenticated:
  667. raise HTTPException(status_code=401, detail="Not authenticated")
  668. try:
  669. data = await cloud.update_setting(
  670. setting_id=setting_id,
  671. name=request.name,
  672. setting=request.setting,
  673. )
  674. return data
  675. except BambuCloudAuthError:
  676. await clear_token(db)
  677. raise HTTPException(status_code=401, detail="Authentication expired")
  678. except BambuCloudError as e:
  679. raise HTTPException(status_code=500, detail=str(e))
  680. @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
  681. async def delete_setting(
  682. setting_id: str,
  683. db: AsyncSession = Depends(get_db),
  684. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  685. ):
  686. """
  687. Delete a slicer preset/setting.
  688. Removes the preset from Bambu Cloud. This cannot be undone.
  689. """
  690. token, _ = await get_stored_token(db)
  691. if not token:
  692. raise HTTPException(status_code=401, detail="Not authenticated")
  693. cloud = get_cloud_service()
  694. cloud.set_token(token)
  695. if not cloud.is_authenticated:
  696. raise HTTPException(status_code=401, detail="Not authenticated")
  697. try:
  698. result = await cloud.delete_setting(setting_id)
  699. return SlicerSettingDeleteResponse(
  700. success=result.get("success", True),
  701. message=result.get("message", "Setting deleted"),
  702. )
  703. except BambuCloudAuthError:
  704. await clear_token(db)
  705. raise HTTPException(status_code=401, detail="Authentication expired")
  706. except BambuCloudError as e:
  707. raise HTTPException(status_code=500, detail=str(e))
  708. # Path to field definition files
  709. FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
  710. # Cache for field definitions (loaded once)
  711. _fields_cache: dict[str, dict] = {}
  712. def _load_fields(preset_type: str) -> dict:
  713. """Load field definitions from JSON file."""
  714. if preset_type in _fields_cache:
  715. return _fields_cache[preset_type]
  716. # Map API type names to file names
  717. file_map = {
  718. "filament": "filament_fields.json",
  719. "print": "process_fields.json",
  720. "process": "process_fields.json",
  721. "printer": "printer_fields.json",
  722. }
  723. filename = file_map.get(preset_type)
  724. if not filename:
  725. raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
  726. file_path = FIELDS_DATA_DIR / filename
  727. if not file_path.exists():
  728. raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
  729. with open(file_path) as f:
  730. data = json.load(f)
  731. _fields_cache[preset_type] = data
  732. return data
  733. @router.get("/builtin-filaments")
  734. async def get_builtin_filaments(
  735. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  736. ):
  737. """
  738. Get built-in filament names as a fallback source.
  739. Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
  740. {filament_id, name} objects. Used by the frontend when cloud
  741. and local profiles are unavailable.
  742. """
  743. return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
  744. @router.get("/fields/{preset_type}")
  745. async def get_preset_fields(
  746. preset_type: Literal["filament", "print", "process", "printer"],
  747. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  748. ):
  749. """
  750. Get field definitions for a preset type.
  751. Returns a list of field definitions including:
  752. - key: The setting key name
  753. - label: Human-readable label
  754. - type: Field type (text, number, boolean, select)
  755. - category: Grouping category
  756. - description: Field description
  757. - options: For select fields, available options
  758. - unit: Unit of measurement (if applicable)
  759. - min/max/step: For number fields, validation constraints
  760. """
  761. data = _load_fields(preset_type)
  762. return data
  763. @router.get("/fields")
  764. async def get_all_preset_fields(
  765. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  766. ):
  767. """
  768. Get all field definitions for all preset types.
  769. Returns field definitions organized by type.
  770. """
  771. return {
  772. "filament": _load_fields("filament"),
  773. "process": _load_fields("process"),
  774. "printer": _load_fields("printer"),
  775. }