slicer_presets.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. """Unified slicer-preset listing for the SliceModal (#wiki / Cloud-aware presets).
  2. Returns the printer/process/filament options grouped by source tier in
  3. priority order — cloud (per-user, live-fetched) > local (DB-backed
  4. imports) > standard (slicer-bundled stock fallback). Name-based dedup is
  5. applied so a preset that exists in multiple tiers only appears in the
  6. highest-priority one. Cloud failure modes (signed out / expired / network)
  7. are surfaced via a status field so the modal can render a precise banner
  8. without faking an "ok with empty list" response.
  9. """
  10. from __future__ import annotations
  11. import hashlib
  12. import json
  13. import logging
  14. import time
  15. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
  19. from backend.app.api.routes.orca_cloud import (
  20. _ORCA_TYPE_TO_BAMBU,
  21. _build_authenticated_service as _build_orca_service,
  22. _load_credentials as _load_orca_credentials,
  23. )
  24. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  25. from backend.app.core.config import settings as app_settings
  26. from backend.app.core.database import get_db
  27. from backend.app.core.permissions import Permission
  28. from backend.app.models.local_preset import LocalPreset
  29. from backend.app.models.user import User
  30. from backend.app.schemas.slicer_presets import (
  31. UnifiedPreset,
  32. UnifiedPresetsBySlot,
  33. UnifiedPresetsResponse,
  34. )
  35. from backend.app.services.bambu_cloud import (
  36. BambuCloudAuthError,
  37. BambuCloudError,
  38. BambuCloudService,
  39. )
  40. from backend.app.services.orca_cloud import (
  41. OrcaCloudAuthError,
  42. OrcaCloudError,
  43. )
  44. from backend.app.services.slicer_api import (
  45. BundleNotFoundError,
  46. BundleSummary,
  47. SlicerApiError,
  48. SlicerApiService,
  49. SlicerApiUnavailableError,
  50. SlicerInputError,
  51. )
  52. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  53. logger = logging.getLogger(__name__)
  54. router = APIRouter(prefix="/slicer", tags=["Slicer Presets"])
  55. # In-process cache for the bundled-profile list. The slicer sidecar walks a
  56. # read-only filesystem inside its own container, so the list only changes
  57. # across sidecar rebuilds — a long TTL is safe and avoids a sidecar round-trip
  58. # on every modal open. Per-user cache is unnecessary because bundled profiles
  59. # are global.
  60. _BUNDLED_TTL_S = 3600.0
  61. _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
  62. # Per-user cache for the cloud preset list. Cache key is (user_id, token_hash):
  63. # keying on the token hash means a logout/login or token-change automatically
  64. # invalidates the entry without needing the cloud-auth route handlers to call
  65. # back into this module. 5 minutes balances "users see their freshly-saved
  66. # presets quickly" against "a busy install doesn't hit the cloud once per
  67. # modal open per user".
  68. _CLOUD_TTL_S = 300.0
  69. _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
  70. # Same shape for Orca Cloud — keyed on (user_id, access_token-fingerprint).
  71. _orca_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
  72. def _token_fingerprint(token: str) -> str:
  73. """Short stable hash of the cloud token for use as a cache-key component.
  74. Storing only the hash means we can safely keep multiple per-(user, token)
  75. entries without leaking the token via the in-process dict."""
  76. return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
  77. _CLOUD_TYPE_TO_SLOT = {
  78. "filament": "filament",
  79. "printer": "printer",
  80. "print": "process", # Bambu Cloud calls process presets "print"
  81. }
  82. def _empty_slots() -> dict[str, list[UnifiedPreset]]:
  83. return {"printer": [], "process": [], "filament": []}
  84. async def _fetch_cloud_presets(
  85. db: AsyncSession, user: User | None, *, refresh: bool = False
  86. ) -> tuple[dict[str, list[UnifiedPreset]], str]:
  87. """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
  88. Defence-in-depth: even if a stored cloud_token survived a permission
  89. revocation (admin reset, legacy state), users without ``CLOUD_AUTH`` are
  90. treated as not-authenticated for this endpoint — the cloud tier never
  91. surfaces for them. This keeps the per-tier visibility consistent with the
  92. /cloud/* endpoint suite that already gates on CLOUD_AUTH.
  93. ``refresh=True`` skips the in-process cache for this call (used by the
  94. SliceModal's manual Refresh button so a user who just deleted a preset
  95. in Bambu Studio / Handy can pick up the change without waiting for the
  96. 5-minute TTL to expire). The fresh result is still written back to the
  97. cache so subsequent non-refresh callers benefit.
  98. """
  99. if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
  100. return _empty_slots(), "not_authenticated"
  101. token, _email, region = await get_stored_token(db, user)
  102. if not token:
  103. return _empty_slots(), "not_authenticated"
  104. user_key = user.id if user is not None else 0
  105. cache_key = (user_key, _token_fingerprint(token))
  106. now = time.monotonic()
  107. if not refresh:
  108. cached = _cloud_cache.get(cache_key)
  109. if cached and now - cached[0] < _CLOUD_TTL_S:
  110. return cached[1], "ok"
  111. cloud = BambuCloudService(region=region)
  112. cloud.set_token(token)
  113. try:
  114. try:
  115. raw = await cloud.get_slicer_settings()
  116. except BambuCloudAuthError:
  117. # Don't clear the token here — the cloud-status endpoint owns that
  118. # lifecycle. Just report expired so the UI can prompt re-auth.
  119. return _empty_slots(), "expired"
  120. except BambuCloudError as e:
  121. logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
  122. return _empty_slots(), "unreachable"
  123. except Exception as e: # noqa: BLE001 — defensive: never crash the modal
  124. logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
  125. return _empty_slots(), "unreachable"
  126. slots = _empty_slots()
  127. for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
  128. type_data = raw.get(cloud_type, {})
  129. # The cloud splits presets into "private" (the user's own) and "public"
  130. # (Bambu's stock cloud presets). Both are valid choices — surface them
  131. # in the natural order private → public so a user's customisations
  132. # appear above the stock entries with the same names. Stock entries
  133. # that share names with private ones get deduped out within the cloud
  134. # tier itself.
  135. seen_names: set[str] = set()
  136. for entry in type_data.get("private", []) + type_data.get("public", []):
  137. name = entry.get("name")
  138. setting_id = entry.get("setting_id") or entry.get("id")
  139. if not name or not setting_id or name in seen_names:
  140. continue
  141. seen_names.add(name)
  142. slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
  143. # Cloud filament presets carry no metadata in this response on
  144. # purpose: the per-preset detail endpoint
  145. # (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly
  146. # 10/sec per token, so fetching N filament presets to enrich them
  147. # one-by-one trips Bambu's limiter and returns 429 on every request
  148. # for users with large preset libraries (#1150 follow-up).
  149. #
  150. # The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
  151. # wins over a same-named local entry, the cloud entry inherits the
  152. # local entry's filament_type / filament_colour. So cloud presets that
  153. # also exist locally still get metadata-aware pre-pick in the
  154. # SliceModal; cloud-only presets fall back to plain priority order.
  155. _cloud_cache[cache_key] = (now, slots)
  156. return slots, "ok"
  157. finally:
  158. await cloud.close()
  159. async def _fetch_orca_cloud_presets(
  160. db: AsyncSession, user: User | None, *, refresh: bool = False
  161. ) -> tuple[dict[str, list[UnifiedPreset]], str]:
  162. """Mirror of :func:`_fetch_cloud_presets` but for Orca Cloud. Same status
  163. vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``),
  164. same caching shape, same defence-in-depth permission gate
  165. (``orca_cloud:auth`` rather than ``cloud:auth``).
  166. Filament metadata (``filament_type`` / ``filament_colour``) is extracted
  167. from the profile's inline ``content`` dict — unlike Bambu Cloud where
  168. we'd have to fetch each setting separately and hit a rate limit, Orca's
  169. ``/sync/pull`` already returns full content per profile, so the metadata
  170. enrichment is free here.
  171. """
  172. if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
  173. return _empty_slots(), "not_authenticated"
  174. creds = await _load_orca_credentials(db, user)
  175. if not creds.token:
  176. return _empty_slots(), "not_authenticated"
  177. user_key = user.id if user is not None else 0
  178. cache_key = (user_key, _token_fingerprint(creds.token))
  179. now = time.monotonic()
  180. if not refresh:
  181. cached = _orca_cloud_cache.get(cache_key)
  182. if cached and now - cached[0] < _CLOUD_TTL_S:
  183. return cached[1], "ok"
  184. try:
  185. svc = await _build_orca_service(db, user)
  186. except HTTPException as e:
  187. # ``_build_orca_service`` raises 401 when the token is missing,
  188. # the refresh-token rotation failed, or the JIT refresh hit Orca's
  189. # backend and got rejected; 502 when Orca is unreachable. Translate
  190. # to the status vocabulary the SliceModal expects.
  191. if e.status_code == 401:
  192. return _empty_slots(), "expired"
  193. return _empty_slots(), "unreachable"
  194. try:
  195. try:
  196. raw_profiles = await svc.list_profiles()
  197. except OrcaCloudAuthError:
  198. return _empty_slots(), "expired"
  199. except OrcaCloudError as e:
  200. logger.warning("Orca Cloud preset fetch failed for user %s: %s", user_key, e)
  201. return _empty_slots(), "unreachable"
  202. except Exception as e: # noqa: BLE001 — defensive: never crash the modal
  203. logger.warning("Orca Cloud preset fetch unexpected error for user %s: %s", user_key, e)
  204. return _empty_slots(), "unreachable"
  205. slots = _empty_slots()
  206. for entry in raw_profiles:
  207. content = entry.get("content") if isinstance(entry, dict) else None
  208. if not isinstance(content, dict):
  209. continue
  210. slot = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
  211. if slot is None:
  212. continue
  213. preset_id = entry.get("id")
  214. name = entry.get("name") or preset_id
  215. if not preset_id or not name:
  216. continue
  217. filament_type: str | None = None
  218. filament_colour: str | None = None
  219. if slot == "filament":
  220. # Bambu/Orca filament profiles store these as single-element
  221. # arrays (the historical multi-extruder shape). Extract the
  222. # first non-empty element for both.
  223. ft = content.get("filament_type")
  224. if isinstance(ft, list) and ft and isinstance(ft[0], str):
  225. filament_type = ft[0]
  226. elif isinstance(ft, str):
  227. filament_type = ft
  228. fc = content.get("default_filament_colour")
  229. if isinstance(fc, list) and fc and isinstance(fc[0], str):
  230. filament_colour = fc[0]
  231. elif isinstance(fc, str):
  232. filament_colour = fc
  233. slots[slot].append(
  234. UnifiedPreset(
  235. id=str(preset_id),
  236. name=str(name),
  237. source="orca_cloud",
  238. filament_type=filament_type,
  239. filament_colour=filament_colour,
  240. )
  241. )
  242. _orca_cloud_cache[cache_key] = (now, slots)
  243. return slots, "ok"
  244. finally:
  245. await svc.close()
  246. async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
  247. """Local imports — no caching needed, single indexed DB read."""
  248. result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
  249. presets = result.scalars().all()
  250. slots = _empty_slots()
  251. type_to_slot = {"filament": "filament", "printer": "printer", "process": "process"}
  252. for p in presets:
  253. slot = type_to_slot.get(p.preset_type)
  254. if slot is None:
  255. continue
  256. preset = UnifiedPreset(id=str(p.id), name=p.name, source="local")
  257. if slot == "filament":
  258. preset.filament_type, preset.filament_colour = _parse_filament_metadata(p.setting)
  259. if slot in ("process", "filament"):
  260. # Precise compatibility link — the slicer's own compatible_printers
  261. # list, captured at import time. Lets the SliceModal filter the
  262. # process / filament dropdowns by the selected printer without
  263. # falling back to the uploaded-bundle index.
  264. preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
  265. slots[slot].append(preset)
  266. return slots
  267. def _parse_compatible_printers(raw: str | None) -> list[str] | None:
  268. """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
  269. names. Return the parsed list, or ``None`` on missing / malformed data so
  270. the SliceModal falls back to the uploaded-bundle index for that preset."""
  271. if not raw:
  272. return None
  273. try:
  274. data = json.loads(raw)
  275. except (ValueError, TypeError):
  276. return None
  277. if not isinstance(data, list):
  278. return None
  279. names = [s for s in data if isinstance(s, str) and s.strip()]
  280. return names or None
  281. def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
  282. """Extract first-slot ``filament_type`` and ``filament_colour`` from a
  283. stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
  284. take the first entry since pre-pick matching is one-slot-at-a-time.
  285. Defensive parse: any error returns (None, None) so a corrupt row never
  286. breaks the listing."""
  287. if not setting_json:
  288. return None, None
  289. try:
  290. data = json.loads(setting_json)
  291. except (ValueError, TypeError):
  292. return None, None
  293. if not isinstance(data, dict):
  294. return None, None
  295. return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour"))
  296. def _first_scalar(value: object) -> str | None:
  297. if isinstance(value, list) and value:
  298. return value[0] if isinstance(value[0], str) else None
  299. if isinstance(value, str) and value:
  300. return value
  301. return None
  302. async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]:
  303. """Standard slicer-bundled profiles via the sidecar's /profiles/bundled.
  304. ``refresh=True`` skips the in-process cache; see _fetch_cloud_presets for
  305. the same shape and rationale.
  306. """
  307. global _bundled_cache
  308. now = time.monotonic()
  309. if not refresh and _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
  310. return _bundled_cache[1]
  311. api_url = await _resolve_slicer_api_url(db)
  312. if not api_url:
  313. # No sidecar configured at all — return empty rather than caching, so
  314. # users who configure one mid-session see results on next open.
  315. return _empty_slots()
  316. try:
  317. async with SlicerApiService(base_url=api_url) as svc:
  318. raw = await svc.list_bundled_profiles()
  319. except SlicerApiError as e:
  320. logger.info("Bundled preset fetch from sidecar at %s failed: %s", api_url, e)
  321. return _empty_slots()
  322. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  323. logger.warning("Bundled preset fetch unexpected error: %s", e)
  324. return _empty_slots()
  325. slots = _empty_slots()
  326. for slot in ("printer", "process", "filament"):
  327. for entry in raw.get(slot, []) or []:
  328. name = entry.get("name")
  329. if not name:
  330. continue
  331. # Bundled presets are addressed by name (the slicer resolves them
  332. # by name during the `inherits:` walk), so name doubles as id.
  333. extra: dict[str, str | None] = {}
  334. if slot == "filament":
  335. extra["filament_type"] = entry.get("filament_type")
  336. extra["filament_colour"] = entry.get("filament_colour")
  337. slots[slot].append(
  338. UnifiedPreset(id=name, name=name, source="standard", **extra),
  339. )
  340. _bundled_cache = (now, slots)
  341. return slots
  342. async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
  343. """Pick the sidecar URL the bundled-listing fetch should hit.
  344. Mirrors the slice route's resolution at ``library.py:_run_slicer_with_fallback``:
  345. the user's ``preferred_slicer`` setting decides which sidecar Bambuddy
  346. talks to, and the per-install URL setting overrides the env default.
  347. A user who prefers Bambu Studio gets the *bambu-studio-api* sidecar's
  348. bundled list; a user who prefers OrcaSlicer gets the *orca-slicer-api*
  349. sidecar's bundled list. Without this branch the listing would always
  350. hit OrcaSlicer (port 3003) even for BambuStudio installs (port 3001),
  351. leaving the Standard tier permanently empty for them.
  352. """
  353. from backend.app.api.routes.settings import get_setting
  354. preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
  355. if preferred == "orcaslicer":
  356. configured = await get_setting(db, "orcaslicer_api_url")
  357. url = (configured or app_settings.slicer_api_url).strip()
  358. elif preferred == "bambu_studio":
  359. configured = await get_setting(db, "bambu_studio_api_url")
  360. url = (configured or app_settings.bambu_studio_api_url).strip()
  361. else:
  362. # Unknown preference — return None so the bundled tier is empty
  363. # rather than crashing the modal. The slice route raises 400 here;
  364. # we degrade silently because the modal's listing is informational.
  365. logger.warning("Unknown preferred_slicer setting: %r — bundled tier disabled", preferred)
  366. return None
  367. return url or None
  368. def _dedupe_by_name(
  369. orca_cloud: dict[str, list[UnifiedPreset]],
  370. cloud: dict[str, list[UnifiedPreset]],
  371. local: dict[str, list[UnifiedPreset]],
  372. standard: dict[str, list[UnifiedPreset]],
  373. ) -> tuple[
  374. dict[str, list[UnifiedPreset]],
  375. dict[str, list[UnifiedPreset]],
  376. dict[str, list[UnifiedPreset]],
  377. dict[str, list[UnifiedPreset]],
  378. ]:
  379. """Filter so each preset name appears in exactly one tier.
  380. Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is
  381. highest because a user who set up Orca sync is explicitly curating
  382. those profiles for use here; Bambu Cloud follows for the same reason
  383. one tier down. Order within each tier is preserved.
  384. Filament metadata merges across tiers: a Bambu Cloud entry without its
  385. own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
  386. these in the list response for rate-limiting reasons — see
  387. :func:`_fetch_cloud_presets`) inherits values from the same-named local
  388. or standard entry. Orca Cloud already carries metadata inline, so no
  389. backfill is needed for it.
  390. """
  391. # Build a name → metadata lookup from the tiers that carry it (orca_cloud,
  392. # local, standard). Bambu cloud is intentionally skipped — it doesn't
  393. # populate filament_type/colour in the list response. Take whichever
  394. # non-empty entry shows up first.
  395. metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
  396. for tier in (orca_cloud, local, standard):
  397. for p in tier["filament"]:
  398. if p.name in metadata_by_name:
  399. continue
  400. if p.filament_type or p.filament_colour:
  401. metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
  402. # Backfill Bambu Cloud entries that don't have their own metadata.
  403. for p in cloud["filament"]:
  404. if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
  405. t, c = metadata_by_name[p.name]
  406. if p.filament_type is None and t is not None:
  407. p.filament_type = t
  408. if p.filament_colour is None and c is not None:
  409. p.filament_colour = c
  410. deduped_cloud = _empty_slots()
  411. deduped_local = _empty_slots()
  412. deduped_standard = _empty_slots()
  413. for slot in ("printer", "process", "filament"):
  414. seen = {p.name for p in orca_cloud[slot]}
  415. for p in cloud[slot]:
  416. if p.name in seen:
  417. continue
  418. deduped_cloud[slot].append(p)
  419. seen.add(p.name)
  420. for p in local[slot]:
  421. if p.name in seen:
  422. continue
  423. deduped_local[slot].append(p)
  424. seen.add(p.name)
  425. for p in standard[slot]:
  426. if p.name in seen:
  427. continue
  428. deduped_standard[slot].append(p)
  429. seen.add(p.name)
  430. return orca_cloud, deduped_cloud, deduped_local, deduped_standard
  431. @router.get("/printer-models")
  432. def list_printer_models() -> dict[str, str]:
  433. """Canonical Bambu printer-model registry, surfaced for the SliceModal.
  434. Returns the backend's ``PRINTER_MODEL_MAP`` unmodified: keys are the long
  435. "Bambu Lab <model>" form that appears in 3MF metadata and in slicer
  436. printer-preset names, values are the normalized short codes used in
  437. BambuStudio's `@BBL <code>` cloud-preset filenames. The frontend uses this
  438. mapping to classify cloud / standard presets against the selected printer
  439. when no slicer bundle has been uploaded that covers the preset (#1325
  440. follow-up) - avoiding a second, manually-maintained model table on the
  441. frontend. No auth gate: this is a static reference dictionary, not
  442. user data.
  443. """
  444. return dict(PRINTER_MODEL_MAP)
  445. @router.get("/presets", response_model=UnifiedPresetsResponse)
  446. async def list_unified_presets(
  447. db: AsyncSession = Depends(get_db),
  448. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  449. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  450. refresh: bool = Query(
  451. False,
  452. description=(
  453. "Bypass the in-process cloud and bundled-preset caches for this "
  454. "request. The SliceModal's Refresh button sets this so users who "
  455. "deleted a preset in Bambu Studio or Bambu Handy don't have to "
  456. "wait for the 5-minute cloud-cache TTL to expire."
  457. ),
  458. ),
  459. ) -> UnifiedPresetsResponse:
  460. """List slicer presets across cloud / local / standard tiers, deduped by name.
  461. Drives the SliceModal preset dropdowns. Permission gate matches the
  462. slice action itself (``LIBRARY_UPLOAD``) so any user who can slice can
  463. see the preset options for the dialog. The cloud branch is independently
  464. gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
  465. only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
  466. access to.
  467. API-keyed callers (which return None from ``current_user``) get the
  468. owner User via ``resolve_api_key_cloud_owner`` when the key has the
  469. cloud-access scope, so the cloud tier surfaces correctly for them
  470. too — matching the slice route (#1182 follow-up).
  471. """
  472. cloud_token_user = current_user or api_key_cloud_owner
  473. orca_cloud, orca_cloud_status = await _fetch_orca_cloud_presets(db, cloud_token_user, refresh=refresh)
  474. cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
  475. local = await _fetch_local_presets(db)
  476. standard = await _fetch_bundled_presets(db, refresh=refresh)
  477. orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard)
  478. return UnifiedPresetsResponse(
  479. orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
  480. cloud=UnifiedPresetsBySlot(**cloud),
  481. local=UnifiedPresetsBySlot(**local),
  482. standard=UnifiedPresetsBySlot(**standard),
  483. cloud_status=cloud_status,
  484. orca_cloud_status=orca_cloud_status,
  485. )
  486. def _bundle_summary_to_dict(b: BundleSummary) -> dict:
  487. """Serialize a BundleSummary for the JSON response. The frontend uses
  488. these arrays to populate the preset dropdowns when a user picks the
  489. bundle as the slice source.
  490. """
  491. return {
  492. "id": b.id,
  493. "printer_preset_name": b.printer_preset_name,
  494. "printer": b.printer,
  495. "process": b.process,
  496. "filament": b.filament,
  497. "version": b.version,
  498. }
  499. @router.post("/bundles", status_code=201)
  500. async def import_slicer_bundle(
  501. file: UploadFile = File(...),
  502. db: AsyncSession = Depends(get_db),
  503. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  504. ):
  505. """Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar.
  506. The user exports their printer's preset bundle from BambuStudio (File
  507. -> Export -> Export Preset Bundle, "Printer preset bundle" option).
  508. Uploading it here unpacks the bundle on the sidecar and exposes its
  509. inner printer / process / filament presets to subsequent slice
  510. requests via the bundle-id selector.
  511. Idempotent: re-uploading the same file yields the same id (sidecar
  512. hashes the zip content), so duplicate uploads collapse rather than
  513. accumulate.
  514. """
  515. api_url = await _resolve_slicer_api_url(db)
  516. if not api_url:
  517. raise HTTPException(status_code=503, detail="No slicer sidecar configured")
  518. # Multer on the sidecar caps bundle uploads at 50MB. We don't enforce
  519. # that here — let the sidecar's filter own the limit so it stays in
  520. # one place — but we do reject empty / huge files at the FastAPI
  521. # layer to avoid pointlessly streaming them to the sidecar first.
  522. contents = await file.read()
  523. if not contents:
  524. raise HTTPException(status_code=400, detail="Bundle file is empty")
  525. filename = file.filename or "bundle.bbscfg"
  526. try:
  527. async with SlicerApiService(base_url=api_url) as svc:
  528. summary = await svc.import_bundle(contents, filename=filename)
  529. except SlicerInputError as e:
  530. # Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip,
  531. # or a path-traversal entry that the manifest validator caught.
  532. # Log the detail so it lands in the support bundle: the FE-only
  533. # toast was leaving us blind during triage (#1312).
  534. logger.warning(
  535. "Bundle import rejected by sidecar (%s, %d bytes): %s",
  536. filename,
  537. len(contents),
  538. e,
  539. )
  540. raise HTTPException(status_code=400, detail=str(e)) from e
  541. except SlicerApiUnavailableError as e:
  542. logger.warning("Bundle import: sidecar unreachable (%s): %s", api_url, e)
  543. raise HTTPException(status_code=503, detail=str(e)) from e
  544. except SlicerApiError as e:
  545. logger.warning(
  546. "Bundle import: sidecar server error (%s, %d bytes): %s",
  547. filename,
  548. len(contents),
  549. e,
  550. )
  551. # 5xx from the sidecar's import path is rare — usually a disk
  552. # write failure inside DATA_PATH/bundles. 502 (bad gateway) is
  553. # closer to the truth than 500 here, since we're proxying.
  554. raise HTTPException(status_code=502, detail=str(e)) from e
  555. return _bundle_summary_to_dict(summary)
  556. @router.get("/bundles")
  557. async def list_slicer_bundles(
  558. db: AsyncSession = Depends(get_db),
  559. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  560. ):
  561. """List every Printer Preset Bundle currently stored on the sidecar.
  562. Drives the SliceModal's "Bundle" tier and a Settings panel where
  563. users can review / delete imported bundles. Returns ``[]`` when the
  564. sidecar has no bundles imported yet.
  565. """
  566. api_url = await _resolve_slicer_api_url(db)
  567. if not api_url:
  568. # No sidecar configured: empty list rather than 503 so the modal
  569. # renders cleanly. Same shape as the bundled-presets fallback.
  570. return []
  571. try:
  572. async with SlicerApiService(base_url=api_url) as svc:
  573. bundles = await svc.list_bundles()
  574. except SlicerApiUnavailableError as e:
  575. # Sidecar offline: surface as 503 so the frontend can show a
  576. # banner. Differs from the bundled-tier behaviour because that
  577. # path also has cloud + local fallbacks; bundles is the only
  578. # source for its tier.
  579. raise HTTPException(status_code=503, detail=str(e)) from e
  580. except SlicerApiError as e:
  581. raise HTTPException(status_code=502, detail=str(e)) from e
  582. return [_bundle_summary_to_dict(b) for b in bundles]
  583. @router.get("/bundles/{bundle_id}")
  584. async def get_slicer_bundle(
  585. bundle_id: str,
  586. db: AsyncSession = Depends(get_db),
  587. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  588. ):
  589. """Return one bundle by id. 404 if it doesn't exist on the sidecar."""
  590. api_url = await _resolve_slicer_api_url(db)
  591. if not api_url:
  592. raise HTTPException(status_code=503, detail="No slicer sidecar configured")
  593. try:
  594. async with SlicerApiService(base_url=api_url) as svc:
  595. summary = await svc.get_bundle(bundle_id)
  596. except BundleNotFoundError as e:
  597. raise HTTPException(status_code=404, detail=str(e)) from e
  598. except SlicerApiUnavailableError as e:
  599. raise HTTPException(status_code=503, detail=str(e)) from e
  600. except SlicerApiError as e:
  601. raise HTTPException(status_code=502, detail=str(e)) from e
  602. return _bundle_summary_to_dict(summary)
  603. @router.delete("/bundles/{bundle_id}", status_code=204)
  604. async def delete_slicer_bundle(
  605. bundle_id: str,
  606. db: AsyncSession = Depends(get_db),
  607. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  608. ):
  609. """Remove a stored bundle from the sidecar. Future slice requests
  610. referencing this id will fail with 404 from the sidecar.
  611. """
  612. api_url = await _resolve_slicer_api_url(db)
  613. if not api_url:
  614. raise HTTPException(status_code=503, detail="No slicer sidecar configured")
  615. try:
  616. async with SlicerApiService(base_url=api_url) as svc:
  617. await svc.delete_bundle(bundle_id)
  618. except BundleNotFoundError as e:
  619. raise HTTPException(status_code=404, detail=str(e)) from e
  620. except SlicerApiUnavailableError as e:
  621. raise HTTPException(status_code=503, detail=str(e)) from e
  622. except SlicerApiError as e:
  623. raise HTTPException(status_code=502, detail=str(e)) from e
  624. @router.get("/preview-progress/{request_id}")
  625. async def get_preview_slice_progress(
  626. request_id: str,
  627. db: AsyncSession = Depends(get_db),
  628. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
  629. ):
  630. """Proxy to the sidecar's ``GET /slice/progress/:requestId``.
  631. The SliceModal's filament-requirements call kicks off a real preview
  632. slice on the sidecar to discover which AMS slots the picked plate
  633. actually consumes. That HTTP call holds open for the full slice
  634. duration (multi-second to multi-minute on complex models), and the
  635. browser can't reach the sidecar directly thanks to the same-origin
  636. policy + the sidecar's CORS allowlist. This endpoint forwards the
  637. poll so the modal's inline spinner can show "Generating G-code (45%)"
  638. instead of an opaque elapsed-time counter while the preview runs.
  639. Returns the sidecar's snapshot verbatim, or 404 when the request_id
  640. is unknown / completed and grace-window-expired.
  641. """
  642. import httpx
  643. api_url = await _resolve_slicer_api_url(db)
  644. if not api_url:
  645. raise HTTPException(status_code=503, detail="No slicer sidecar configured")
  646. url = f"{api_url}/slice/progress/{request_id}"
  647. try:
  648. async with httpx.AsyncClient(timeout=5.0) as client:
  649. response = await client.get(url)
  650. except httpx.RequestError:
  651. # Sidecar unreachable: surface as 503 instead of 500 so the
  652. # frontend's poller can keep trying without flagging a hard error.
  653. raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
  654. if response.status_code == 404:
  655. raise HTTPException(status_code=404, detail="Progress unavailable")
  656. return response.json()