slicer_presets.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  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 re
  15. import time
  16. from fastapi import APIRouter, Depends, HTTPException, Query
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
  20. from backend.app.api.routes.orca_cloud import (
  21. _ORCA_TYPE_TO_BAMBU,
  22. _build_authenticated_service as _build_orca_service,
  23. _load_credentials as _load_orca_credentials,
  24. )
  25. from backend.app.core.auth import (
  26. ApiKeyActor,
  27. RequestPrinterScope,
  28. RequirePermissionIfAuthEnabled,
  29. is_auth_enabled,
  30. require_ownership_permission,
  31. )
  32. from backend.app.core.config import settings as app_settings
  33. from backend.app.core.database import get_db
  34. from backend.app.core.permissions import Permission
  35. from backend.app.core.printer_scope import PrinterScope
  36. from backend.app.models.local_preset import LocalPreset
  37. from backend.app.models.printer import Printer
  38. from backend.app.models.slot_preset import SlotPresetMapping
  39. from backend.app.models.user import User
  40. from backend.app.schemas.slicer import PresetRef
  41. from backend.app.schemas.slicer_presets import (
  42. LoadedSpoolPreset,
  43. LoadedSpoolPrinter,
  44. LoadedSpoolsResponse,
  45. LoadedSpoolTray,
  46. LoadedSpoolUnit,
  47. UnifiedPreset,
  48. UnifiedPresetsBySlot,
  49. UnifiedPresetsResponse,
  50. )
  51. from backend.app.services.bambu_cloud import (
  52. BambuCloudAuthError,
  53. BambuCloudError,
  54. BambuCloudService,
  55. )
  56. from backend.app.services.bambu_cloud_credentials import get_stored_token
  57. from backend.app.services.orca_cloud import (
  58. OrcaCloudAuthError,
  59. OrcaCloudError,
  60. )
  61. from backend.app.services.preset_resolver import resolve_preset_ref
  62. from backend.app.services.printer_manager import printer_manager
  63. from backend.app.services.slicer_api import (
  64. SlicerApiError,
  65. SlicerApiService,
  66. SlicerApiUnavailableError,
  67. )
  68. from backend.app.utils.printer_models import (
  69. PRINTER_MODEL_MAP,
  70. normalize_printer_model,
  71. normalize_printer_model_id,
  72. )
  73. from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings
  74. from backend.app.utils.tray_ids import external_slot_index, is_ams_ht_id
  75. logger = logging.getLogger(__name__)
  76. router = APIRouter(prefix="/slicer", tags=["Slicer Presets"])
  77. # In-process cache for the bundled-profile list. The slicer sidecar walks a
  78. # read-only filesystem inside its own container, so the list only changes
  79. # across sidecar rebuilds — a long TTL is safe and avoids a sidecar round-trip
  80. # on every modal open. Per-user cache is unnecessary because bundled profiles
  81. # are global.
  82. _BUNDLED_TTL_S = 3600.0
  83. _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
  84. # Per-user cache for the cloud preset list. Cache key is (user_id, token_hash):
  85. # keying on the token hash means a logout/login or token-change automatically
  86. # invalidates the entry without needing the cloud-auth route handlers to call
  87. # back into this module. 5 minutes balances "users see their freshly-saved
  88. # presets quickly" against "a busy install doesn't hit the cloud once per
  89. # modal open per user".
  90. _CLOUD_TTL_S = 300.0
  91. _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
  92. # Same shape for Orca Cloud — keyed on (user_id, access_token-fingerprint).
  93. _orca_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
  94. def _token_fingerprint(token: str) -> str:
  95. """Short stable hash of the cloud token for use as a cache-key component.
  96. Storing only the hash means we can safely keep multiple per-(user, token)
  97. entries without leaking the token via the in-process dict."""
  98. return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
  99. _CLOUD_TYPE_TO_SLOT = {
  100. "filament": "filament",
  101. "printer": "printer",
  102. "print": "process", # Bambu Cloud calls process presets "print"
  103. }
  104. def _empty_slots() -> dict[str, list[UnifiedPreset]]:
  105. return {"printer": [], "process": [], "filament": []}
  106. async def _fetch_cloud_presets(
  107. db: AsyncSession, user: User | None, *, refresh: bool = False
  108. ) -> tuple[dict[str, list[UnifiedPreset]], str]:
  109. """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
  110. Defence-in-depth: even if a stored cloud_token survived a permission
  111. revocation (admin reset, legacy state), users without ``CLOUD_AUTH`` are
  112. treated as not-authenticated for this endpoint — the cloud tier never
  113. surfaces for them. This keeps the per-tier visibility consistent with the
  114. /cloud/* endpoint suite that already gates on CLOUD_AUTH.
  115. ``refresh=True`` skips the in-process cache for this call (used by the
  116. SliceModal's manual Refresh button so a user who just deleted a preset
  117. in Bambu Studio / Handy can pick up the change without waiting for the
  118. 5-minute TTL to expire). The fresh result is still written back to the
  119. cache so subsequent non-refresh callers benefit.
  120. """
  121. if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
  122. return _empty_slots(), "not_authenticated"
  123. # The sign-in stored without a user is the auth-off install's, not one for
  124. # a caller who has none while auth is on.
  125. if user is None and await is_auth_enabled(db):
  126. return _empty_slots(), "not_authenticated"
  127. token, _email, region = await get_stored_token(db, user)
  128. if not token:
  129. return _empty_slots(), "not_authenticated"
  130. user_key = user.id if user is not None else 0
  131. cache_key = (user_key, _token_fingerprint(token))
  132. now = time.monotonic()
  133. if not refresh:
  134. cached = _cloud_cache.get(cache_key)
  135. if cached and now - cached[0] < _CLOUD_TTL_S:
  136. return cached[1], "ok"
  137. cloud = BambuCloudService(region=region)
  138. cloud.set_token(token)
  139. try:
  140. try:
  141. raw = await cloud.get_slicer_settings()
  142. except BambuCloudAuthError:
  143. # Don't clear the token here — the cloud-status endpoint owns that
  144. # lifecycle. Just report expired so the UI can prompt re-auth.
  145. return _empty_slots(), "expired"
  146. except BambuCloudError as e:
  147. logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
  148. return _empty_slots(), "unreachable"
  149. except Exception as e: # noqa: BLE001 — defensive: never crash the modal
  150. logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
  151. return _empty_slots(), "unreachable"
  152. slots = _empty_slots()
  153. for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
  154. type_data = raw.get(cloud_type, {})
  155. # The cloud splits presets into "private" (the user's own) and "public"
  156. # (Bambu's stock cloud presets). Both are valid choices — surface them
  157. # in the natural order private → public so a user's customisations
  158. # appear above the stock entries with the same names. Stock entries
  159. # that share names with private ones get deduped out within the cloud
  160. # tier itself.
  161. seen_names: set[str] = set()
  162. for stock, entries in ((False, type_data.get("private", [])), (True, type_data.get("public", []))):
  163. for entry in entries:
  164. name = entry.get("name")
  165. setting_id = entry.get("setting_id") or entry.get("id")
  166. if not name or not setting_id or name in seen_names:
  167. continue
  168. seen_names.add(name)
  169. slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud", stock=stock))
  170. # Cloud filament presets carry no metadata in this response on
  171. # purpose: the per-preset detail endpoint
  172. # (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly
  173. # 10/sec per token, so fetching N filament presets to enrich them
  174. # one-by-one trips Bambu's limiter and returns 429 on every request
  175. # for users with large preset libraries (#1150 follow-up).
  176. #
  177. # The metadata-enrich pass (see _enrich_cloud_metadata) compensates:
  178. # a Bambu Cloud entry without its own filament_type/colour inherits
  179. # those values from a same-named local / orca_cloud / standard entry
  180. # so it can still score for type/colour matches in pickFilamentForSlot.
  181. _cloud_cache[cache_key] = (now, slots)
  182. return slots, "ok"
  183. finally:
  184. await cloud.close()
  185. async def _fetch_orca_cloud_presets(
  186. db: AsyncSession, user: User | None, *, refresh: bool = False
  187. ) -> tuple[dict[str, list[UnifiedPreset]], str]:
  188. """Mirror of :func:`_fetch_cloud_presets` but for Orca Cloud. Same status
  189. vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``),
  190. same caching shape, same defence-in-depth permission gate
  191. (``orca_cloud:auth`` rather than ``cloud:auth``).
  192. Filament metadata (``filament_type`` / ``filament_colour``) is extracted
  193. from the profile's inline ``content`` dict — unlike Bambu Cloud where
  194. we'd have to fetch each setting separately and hit a rate limit, Orca's
  195. ``/sync/pull`` already returns full content per profile, so the metadata
  196. enrichment is free here.
  197. """
  198. if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
  199. return _empty_slots(), "not_authenticated"
  200. if user is None and await is_auth_enabled(db):
  201. return _empty_slots(), "not_authenticated"
  202. creds = await _load_orca_credentials(db, user)
  203. if not creds.token:
  204. return _empty_slots(), "not_authenticated"
  205. user_key = user.id if user is not None else 0
  206. cache_key = (user_key, _token_fingerprint(creds.token))
  207. now = time.monotonic()
  208. if not refresh:
  209. cached = _orca_cloud_cache.get(cache_key)
  210. if cached and now - cached[0] < _CLOUD_TTL_S:
  211. return cached[1], "ok"
  212. try:
  213. svc = await _build_orca_service(db, user)
  214. except HTTPException as e:
  215. # ``_build_orca_service`` raises 401 when the token is missing,
  216. # the refresh-token rotation failed, or the JIT refresh hit Orca's
  217. # backend and got rejected; 502 when Orca is unreachable. Translate
  218. # to the status vocabulary the SliceModal expects.
  219. if e.status_code == 401:
  220. return _empty_slots(), "expired"
  221. return _empty_slots(), "unreachable"
  222. try:
  223. try:
  224. raw_profiles = await svc.list_profiles()
  225. except OrcaCloudAuthError:
  226. return _empty_slots(), "expired"
  227. except OrcaCloudError as e:
  228. logger.warning("Orca Cloud preset fetch failed for user %s: %s", user_key, e)
  229. return _empty_slots(), "unreachable"
  230. except Exception as e: # noqa: BLE001 — defensive: never crash the modal
  231. logger.warning("Orca Cloud preset fetch unexpected error for user %s: %s", user_key, e)
  232. return _empty_slots(), "unreachable"
  233. slots = _empty_slots()
  234. for entry in raw_profiles:
  235. content = entry.get("content") if isinstance(entry, dict) else None
  236. if not isinstance(content, dict):
  237. continue
  238. slot = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
  239. if slot is None:
  240. continue
  241. preset_id = entry.get("id")
  242. name = entry.get("name") or preset_id
  243. if not preset_id or not name:
  244. continue
  245. filament_type: str | None = None
  246. filament_colour: str | None = None
  247. if slot == "filament":
  248. # Bambu/Orca filament profiles store these as single-element
  249. # lists (the historical multi-extruder shape) — a JSON array or
  250. # an INI-style string. Take the first non-empty element.
  251. filament_type = first_slicer_string(content.get("filament_type"))
  252. filament_colour = first_slicer_string(content.get("default_filament_colour"))
  253. preset = UnifiedPreset(
  254. id=str(preset_id),
  255. name=str(name),
  256. source="orca_cloud",
  257. filament_type=filament_type,
  258. filament_colour=filament_colour,
  259. )
  260. if slot in ("process", "filament"):
  261. # The profile's own compatible-printer list, straight out of
  262. # the content Orca already hands us (#2628). Without it the
  263. # SliceModal falls back to reading the printer out of the
  264. # profile NAME — and a profile whose name carries no model
  265. # ("Overture PLA Matte @0.2") then reads as "can't tell",
  266. # which the picker treats as usable and auto-picks for a
  267. # printer the profile was never built for.
  268. preset.compatible_printers = _content_compatible_printers(content)
  269. if slot in ("printer", "filament"):
  270. # A filament's parent gives it the material its own content
  271. # leaves out (#3403); see _enrich_cloud_metadata.
  272. preset.inherits = _content_inherits(content)
  273. slots[slot].append(preset)
  274. _orca_cloud_cache[cache_key] = (now, slots)
  275. return slots, "ok"
  276. finally:
  277. await svc.close()
  278. async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
  279. """Local imports — no caching needed, single indexed DB read."""
  280. result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
  281. presets = result.scalars().all()
  282. slots = _empty_slots()
  283. type_to_slot = {"filament": "filament", "printer": "printer", "process": "process"}
  284. for p in presets:
  285. slot = type_to_slot.get(p.preset_type)
  286. if slot is None:
  287. continue
  288. preset = UnifiedPreset(id=str(p.id), name=p.name, source="local")
  289. if slot == "filament":
  290. preset.filament_type, preset.filament_colour = _parse_filament_metadata(p.setting)
  291. if slot in ("process", "filament"):
  292. # Precise compatibility link — the slicer's own compatible_printers
  293. # list, captured at import time. Lets the SliceModal filter the
  294. # process / filament dropdowns by the selected printer without
  295. # falling back to the @BBL name matcher.
  296. preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
  297. elif slot == "printer":
  298. # The preset it was saved from (#3250); see UnifiedPreset.inherits.
  299. preset.inherits = (p.inherits or "").strip() or None
  300. slots[slot].append(preset)
  301. return slots
  302. def _content_inherits(content: dict) -> str | None:
  303. """The preset a profile was saved from, out of its content dict (#3250)."""
  304. raw = content.get("inherits") if isinstance(content, dict) else None
  305. if not isinstance(raw, str):
  306. return None
  307. return raw.strip() or None
  308. def _content_compatible_printers(content: dict) -> list[str] | None:
  309. """Pull ``compatible_printers`` out of a profile content dict.
  310. Serves both callers that have one: an Orca Cloud profile's inline
  311. ``content``, and an entry of the sidecar's bundled listing.
  312. Orca profiles carry it as a list of printer-preset names (the same shape
  313. ``orca_profiles.py`` stores on import) or as one INI-style string — see
  314. :func:`slicer_strings`. Returns ``None`` for missing / empty / malformed
  315. values so the caller leaves the field unset and the SliceModal falls back
  316. to the name-based matcher, rather than treating "no data" as "compatible
  317. with nothing".
  318. """
  319. return slicer_strings(content.get("compatible_printers")) or None
  320. def _parse_compatible_printers(raw: str | None) -> list[str] | None:
  321. """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
  322. names. Return the parsed list, or ``None`` on missing / malformed data so
  323. the SliceModal falls back to the name-based matcher for that preset."""
  324. if not raw:
  325. return None
  326. try:
  327. data = json.loads(raw)
  328. except (ValueError, TypeError):
  329. return None
  330. if not isinstance(data, list):
  331. return None
  332. return slicer_strings(data) or None
  333. def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
  334. """Extract first-slot ``filament_type`` and ``filament_colour`` from a
  335. stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
  336. take the first entry since pre-pick matching is one-slot-at-a-time.
  337. Defensive parse: any error returns (None, None) so a corrupt row never
  338. breaks the listing."""
  339. if not setting_json:
  340. return None, None
  341. try:
  342. data = json.loads(setting_json)
  343. except (ValueError, TypeError):
  344. return None, None
  345. if not isinstance(data, dict):
  346. return None, None
  347. return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour"))
  348. def _first_scalar(value: object) -> str | None:
  349. if isinstance(value, list) and value:
  350. return value[0] if isinstance(value[0], str) else None
  351. if isinstance(value, str) and value:
  352. return value
  353. return None
  354. async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]:
  355. """Standard slicer-bundled profiles via the sidecar's /profiles/bundled.
  356. ``refresh=True`` skips the in-process cache; see _fetch_cloud_presets for
  357. the same shape and rationale.
  358. """
  359. global _bundled_cache
  360. now = time.monotonic()
  361. if not refresh and _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
  362. return _bundled_cache[1]
  363. api_url = await _resolve_slicer_api_url(db)
  364. if not api_url:
  365. # No sidecar configured at all — return empty rather than caching, so
  366. # users who configure one mid-session see results on next open.
  367. return _empty_slots()
  368. try:
  369. async with SlicerApiService(base_url=api_url) as svc:
  370. raw = await svc.list_bundled_profiles()
  371. except SlicerApiError as e:
  372. logger.info("Bundled preset fetch from sidecar at %s failed: %s", api_url, e)
  373. return _empty_slots()
  374. except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
  375. logger.warning("Bundled preset fetch unexpected error: %s", e)
  376. return _empty_slots()
  377. slots = _empty_slots()
  378. for slot in ("printer", "process", "filament"):
  379. for entry in raw.get(slot, []) or []:
  380. name = entry.get("name")
  381. if not name:
  382. continue
  383. # Bundled presets are addressed by name (the slicer resolves them
  384. # by name during the `inherits:` walk), so name doubles as id.
  385. preset = UnifiedPreset(id=name, name=name, source="standard")
  386. if slot == "filament":
  387. preset.filament_type = entry.get("filament_type")
  388. preset.filament_colour = entry.get("filament_colour")
  389. if slot in ("process", "filament"):
  390. # The slicer's own compatible-printer list, and the only
  391. # truthful answer for several Bambu printers: the bundle ships
  392. # no process preset named after a P1S, an X1, an X1E or an H2D
  393. # Pro -- each one is served by another model's preset that
  394. # names it here. Inferring the printer from the preset NAME
  395. # instead read all 198 as belonging to the model in their
  396. # `@BBL` tag, so a P1S had zero compatible processes, the
  397. # dropdown hid every one of them, and the auto-pick landed on
  398. # an A1 0.2-nozzle process the CLI then refused (#2982).
  399. #
  400. # Older sidecars don't report the field. They return None here,
  401. # which leaves the SliceModal on the name matcher for the
  402. # standard tier -- degraded exactly as before, not broken.
  403. preset.compatible_printers = _content_compatible_printers(entry)
  404. slots[slot].append(preset)
  405. _bundled_cache = (now, slots)
  406. return slots
  407. async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
  408. """Pick the sidecar URL the bundled-listing fetch should hit.
  409. Mirrors the slice route's resolution at ``library.py:_run_slicer_with_fallback``:
  410. the user's ``preferred_slicer`` setting decides which sidecar Bambuddy
  411. talks to, and the per-install URL setting overrides the env default.
  412. A user who prefers Bambu Studio gets the *bambu-studio-api* sidecar's
  413. bundled list; a user who prefers OrcaSlicer gets the *orca-slicer-api*
  414. sidecar's bundled list. Without this branch the listing would always
  415. hit OrcaSlicer (port 3003) even for BambuStudio installs (port 3001),
  416. leaving the Standard tier permanently empty for them.
  417. """
  418. from backend.app.api.routes.settings import get_setting
  419. preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
  420. if preferred == "orcaslicer":
  421. configured = await get_setting(db, "orcaslicer_api_url")
  422. url = (configured or app_settings.slicer_api_url).strip()
  423. elif preferred == "bambu_studio":
  424. configured = await get_setting(db, "bambu_studio_api_url")
  425. url = (configured or app_settings.bambu_studio_api_url).strip()
  426. else:
  427. # Unknown preference — return None so the bundled tier is empty
  428. # rather than crashing the modal. The slice route raises 400 here;
  429. # we degrade silently because the modal's listing is informational.
  430. logger.warning("Unknown preferred_slicer setting: %r — bundled tier disabled", preferred)
  431. return None
  432. return url or None
  433. def _enrich_cloud_metadata(
  434. orca_cloud: dict[str, list[UnifiedPreset]],
  435. cloud: dict[str, list[UnifiedPreset]],
  436. local: dict[str, list[UnifiedPreset]],
  437. standard: dict[str, list[UnifiedPreset]],
  438. ) -> tuple[
  439. dict[str, list[UnifiedPreset]],
  440. dict[str, list[UnifiedPreset]],
  441. dict[str, list[UnifiedPreset]],
  442. dict[str, list[UnifiedPreset]],
  443. ]:
  444. """Backfill Bambu Cloud filament metadata; do NOT dedup tiers.
  445. Every tier surfaces its full list — a name that exists in both ``local``
  446. and ``orca_cloud`` shows up in BOTH dropdown groups so the user can pick
  447. either source. Tier ORDER (``local > orca_cloud > cloud > standard``)
  448. is communicated by the SliceModal's group rendering and by the
  449. name-collision fallback in ``findPresetByName``; this function does not
  450. enforce it.
  451. Filament metadata merge: a Bambu Cloud entry without its own
  452. ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
  453. these in the list response for rate-limiting reasons — see
  454. :func:`_fetch_cloud_presets`) inherits values from a same-named entry
  455. in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
  456. this function exists post-#1712 — without the enrich the Bambu Cloud
  457. tier can't score in ``pickFilamentForSlot``.
  458. A filament with no same-named twin still gets a material when one can be
  459. told for sure (#3403), for both cloud tiers:
  460. - an Orca Cloud profile saved with only the keys the user changed takes
  461. it from the profile it inherits from;
  462. - a stock ``<filament> @BBL <printer>`` profile takes it from the bundled
  463. profiles of the same filament for other printers, when they all agree.
  464. With OrcaSlicer preferred the bundle has no H2C profiles at all, so
  465. without this every Bambu Cloud "@BBL H2C" filament is untyped and a
  466. PETG-CF slot can pick Generic PETG. Only ``@BBL`` names count: Orca's
  467. "Generic PETG-CF @System" states PETG.
  468. Only ever fills a gap, and only the material: the colour of another
  469. printer's profile is not this one's.
  470. Compatibility merge (#2628): the same name bridge carries
  471. ``compatible_printers`` onto any process / filament entry that lacks it.
  472. Bambu Cloud never ships the list, so a profile whose NAME carries no
  473. printer model reads as "compatibility unknown" — which the SliceModal
  474. treats as usable and auto-picks for whatever printer is selected. When
  475. the very same profile is also present as a local import or an Orca Cloud
  476. profile, that copy states the truth; borrowing it turns the auto-pick
  477. into a correctly-rejected mismatch. Only ever fills a gap: an entry that
  478. carries its own list keeps it.
  479. """
  480. # Build a name → metadata lookup from the tiers that carry it (local,
  481. # orca_cloud, standard). Bambu cloud is intentionally skipped — it
  482. # doesn't populate filament_type/colour in the list response. Take
  483. # whichever non-empty entry shows up first.
  484. metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
  485. for tier in (local, orca_cloud, standard):
  486. for p in tier["filament"]:
  487. if p.name in metadata_by_name:
  488. continue
  489. if p.filament_type or p.filament_colour:
  490. metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
  491. # Backfill Bambu Cloud entries that don't have their own metadata.
  492. for p in cloud["filament"]:
  493. if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
  494. t, c = metadata_by_name[p.name]
  495. if p.filament_type is None and t is not None:
  496. p.filament_type = t
  497. if p.filament_colour is None and c is not None:
  498. p.filament_colour = c
  499. # Material of the bundled "<filament> @BBL <printer>" profiles, per
  500. # filament, kept only where every printer's profile states the same one.
  501. bundled_types: dict[str, set[str]] = {}
  502. for p in standard["filament"]:
  503. base = _bbl_filament_base(p.name)
  504. if base and p.filament_type:
  505. bundled_types.setdefault(base, set()).add(p.filament_type)
  506. type_by_base = {base: next(iter(types)) for base, types in bundled_types.items() if len(types) == 1}
  507. # The parents an Orca Cloud profile can name. Of the bundle, only "@BBL"
  508. # profiles: Orca's own "Generic PETG-CF @System" states PETG, and a
  509. # user's PETG-CF saved from it would then be left out of PETG-CF slots.
  510. type_by_name = {
  511. p.name: p.filament_type
  512. for tier in (local, orca_cloud, standard)
  513. for p in tier["filament"]
  514. if p.filament_type and (tier is not standard or _bbl_filament_base(p.name))
  515. }
  516. orca_parent = {p.name: p.inherits for p in orca_cloud["filament"] if p.inherits}
  517. def _material_for(name: str) -> str | None:
  518. seen: set[str] = set()
  519. while name and name not in seen:
  520. seen.add(name)
  521. if name in type_by_name:
  522. return type_by_name[name]
  523. base = _bbl_filament_base(name)
  524. if base in type_by_base:
  525. return type_by_base[base]
  526. name = orca_parent.get(name, "")
  527. return None
  528. for tier in (orca_cloud, cloud):
  529. for p in tier["filament"]:
  530. if p.filament_type is None:
  531. p.filament_type = _material_for(p.name)
  532. # Compatibility bridge (#2628). Runs over both slots that carry the
  533. # list, and in both directions between the cloud tiers — whichever copy
  534. # of a profile knows its printers teaches the ones that don't.
  535. for slot in ("process", "filament"):
  536. compat_by_name: dict[str, list[str]] = {}
  537. for tier in (local, orca_cloud, cloud, standard):
  538. for p in tier[slot]:
  539. if p.compatible_printers and p.name not in compat_by_name:
  540. compat_by_name[p.name] = p.compatible_printers
  541. if not compat_by_name:
  542. continue
  543. for tier in (orca_cloud, cloud):
  544. for p in tier[slot]:
  545. if not p.compatible_printers:
  546. borrowed = compat_by_name.get(p.name)
  547. if borrowed:
  548. p.compatible_printers = list(borrowed)
  549. return orca_cloud, cloud, local, standard
  550. # What follows "@BBL " in a stock filament name: the printer, and maybe the
  551. # nozzle ("H2C", "H2C 0.4 nozzle"). True of every name in both bundles.
  552. _BBL_PRINTER_TAIL = re.compile(r"[A-Z0-9]+( \d\.\d nozzle)?")
  553. def _bbl_filament_base(name: str) -> str | None:
  554. """The filament part of a stock ``<filament> @BBL <printer>`` name
  555. ("Generic PETG-CF @BBL H2C 0.4 nozzle" -> "Generic PETG-CF"), else None.
  556. A name with more after the printer is a user's copy ("... @BBL H2C -
  557. Copy"), whose material may have been changed: None."""
  558. base, sep, printer = name.partition(" @BBL ")
  559. if not sep or not _BBL_PRINTER_TAIL.fullmatch(printer):
  560. return None
  561. return base.strip() or None
  562. @router.get("/printer-models")
  563. def list_printer_models() -> dict[str, str]:
  564. """Canonical Bambu printer-model registry, surfaced for the SliceModal.
  565. Returns the backend's ``PRINTER_MODEL_MAP`` unmodified: keys are the long
  566. "Bambu Lab <model>" form that appears in 3MF metadata and in slicer
  567. printer-preset names, values are the normalized short codes used in
  568. BambuStudio's `@BBL <code>` cloud-preset filenames. The frontend uses this
  569. mapping to classify cloud / standard presets against the selected printer,
  570. which carry no ``compatible_printers`` of their own (#1325 follow-up) -
  571. avoiding a second, manually-maintained model table on the
  572. frontend. No auth gate: this is a static reference dictionary, not
  573. user data.
  574. """
  575. return dict(PRINTER_MODEL_MAP)
  576. @router.get("/preset-values")
  577. async def get_preset_values(
  578. source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
  579. id: str = Query(..., description="Preset id within that tier."),
  580. slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
  581. db: AsyncSession = Depends(get_db, scope="function"),
  582. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  583. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  584. ) -> dict:
  585. """Effective values of a preset, with its ``inherits:`` chain flattened.
  586. Drives the slice modal's process-settings panel: without this the panel can
  587. only show the option schema's compiled-in defaults, so a preset that sets a
  588. 0.42mm line width appears as the C++ default of 0.
  589. The flattening is done by the *sidecar*, deliberately. A "Standard" pick is
  590. only a ``{inherits: "<name>"}`` stub on our side, and even local/cloud
  591. presets are deltas — the values live in the profile tree bundled inside the
  592. running sidecar image. Bambuddy's own ``orca_profiles`` resolver walks
  593. OrcaSlicer's published tree instead, which can disagree with what actually
  594. slices; showing numbers from it would be confidently wrong.
  595. Returns ``{"resolved": false, "values": {}, "reason": "..."}`` rather than
  596. an error whenever the values can't be obtained. ``reason`` is what makes
  597. the fallback actionable: a Bambuddy install pulls its sidecar as
  598. ``SIDECAR_TAG:-latest`` regardless of its own release channel, so the
  599. overwhelmingly common cause is a sidecar older than the endpoint — which
  600. the user fixes by pulling a newer image, if we tell them that instead of
  601. "could not read the values".
  602. """
  603. if slot != "process":
  604. raise HTTPException(status_code=400, detail="Only the 'process' slot is supported")
  605. ref = PresetRef(source=source, id=id)
  606. def unresolved(reason: str) -> dict:
  607. return {"resolved": False, "values": {}, "reason": reason}
  608. try:
  609. # A cloud preset resolves as the key's owner for a key with Allow
  610. # Cloud Access, like the listing below.
  611. profile_json = await resolve_preset_ref(db, current_user or api_key_cloud_owner, ref, slot)
  612. except HTTPException:
  613. # A preset the caller can't resolve is not a reason to break the panel;
  614. # the slice itself will report it properly if they go ahead.
  615. logger.info("Could not resolve %s preset %s for value lookup", slot, id)
  616. return unresolved("preset_unresolved")
  617. api_url = await _resolve_slicer_api_url(db)
  618. if not api_url:
  619. return unresolved("not_configured")
  620. service = SlicerApiService(api_url)
  621. try:
  622. resolved = await service.resolve_profile(profile_json, "process")
  623. except SlicerApiUnavailableError:
  624. return unresolved("sidecar_unavailable")
  625. finally:
  626. await service.close()
  627. if resolved.values is None:
  628. return unresolved(resolved.reason)
  629. return {"resolved": True, "values": resolved.values, "reason": "ok"}
  630. @router.get("/presets", response_model=UnifiedPresetsResponse)
  631. async def list_unified_presets(
  632. db: AsyncSession = Depends(get_db, scope="function"),
  633. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
  634. api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
  635. refresh: bool = Query(
  636. False,
  637. description=(
  638. "Bypass the in-process cloud and bundled-preset caches for this "
  639. "request. The SliceModal's Refresh button sets this so users who "
  640. "deleted a preset in Bambu Studio or Bambu Handy don't have to "
  641. "wait for the 5-minute cloud-cache TTL to expire."
  642. ),
  643. ),
  644. ) -> UnifiedPresetsResponse:
  645. """List slicer presets across cloud / local / standard tiers, deduped by name.
  646. Drives the SliceModal preset dropdowns. Permission gate matches the
  647. slice action itself (``LIBRARY_UPLOAD``) so any user who can slice can
  648. see the preset options for the dialog. The cloud branch is independently
  649. gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
  650. only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
  651. access to.
  652. API-keyed callers (which return None from ``current_user``) get the
  653. owner User via ``resolve_api_key_cloud_owner`` when the key has the
  654. cloud-access scope, so the cloud tier surfaces correctly for them
  655. too — matching the slice route (#1182 follow-up).
  656. """
  657. cloud_token_user = current_user or api_key_cloud_owner
  658. orca_cloud, orca_cloud_status = await _fetch_orca_cloud_presets(db, cloud_token_user, refresh=refresh)
  659. cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh)
  660. local = await _fetch_local_presets(db)
  661. standard = await _fetch_bundled_presets(db, refresh=refresh)
  662. orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
  663. stock_aliases = _stock_aliases(cloud, standard)
  664. standard = _drop_aliased(standard, stock_aliases)
  665. return UnifiedPresetsResponse(
  666. orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
  667. cloud=UnifiedPresetsBySlot(**cloud),
  668. local=UnifiedPresetsBySlot(**local),
  669. standard=UnifiedPresetsBySlot(**standard),
  670. cloud_status=cloud_status,
  671. orca_cloud_status=orca_cloud_status,
  672. stock_aliases=stock_aliases,
  673. )
  674. def _stock_aliases(
  675. cloud: dict[str, list[UnifiedPreset]],
  676. standard: dict[str, list[UnifiedPreset]],
  677. ) -> dict[str, PresetRef]:
  678. """Map each Standard preset with a Bambu Cloud stock twin to that twin (#3386).
  679. Bambu's stock cloud presets are the bundled ones again, so a name is
  680. listed once, under Bambu Cloud: Standard is the preferred sidecar's own
  681. bundle (OrcaSlicer's re-authored ``@BBL`` profiles when Orca is
  682. preferred), and the cloud copy is the one signed-in users already slice
  683. with by default. Signed out there is no cloud list, so nothing is
  684. aliased. The keys are ``"standard:<id>"``, so a pipeline or AMS slot
  685. saved with a hidden Standard copy resolves to the cloud one.
  686. """
  687. aliases: dict[str, PresetRef] = {}
  688. for slot, presets in cloud.items():
  689. twins = {p.name: p for p in presets if p.stock}
  690. for p in standard.get(slot, []):
  691. twin = twins.get(p.name)
  692. if twin is not None:
  693. aliases[f"standard:{p.id}"] = PresetRef(source="cloud", id=twin.id)
  694. return aliases
  695. def _drop_aliased(
  696. presets: dict[str, list[UnifiedPreset]], aliases: dict[str, PresetRef]
  697. ) -> dict[str, list[UnifiedPreset]]:
  698. """``presets`` without those :func:`_stock_aliases` maps to a twin.
  699. Runs after :func:`_enrich_cloud_metadata`, so the #2628 bridge has
  700. already seen both copies.
  701. """
  702. return {
  703. slot: [p for p in slot_presets if f"{p.source}:{p.id}" not in aliases] for slot, slot_presets in presets.items()
  704. }
  705. async def load_stock_aliases(db: AsyncSession, user: User | None) -> dict[str, PresetRef]:
  706. """The listing's ``stock_aliases`` for ``user``, from the cached tiers.
  707. For callers that slice from stored refs without the Slice dialog, so a
  708. pipeline slices with the profile the dialog shows for it. Both fetches
  709. degrade to empty rather than raise, which leaves every ref as stored.
  710. """
  711. cloud, _status = await _fetch_cloud_presets(db, user)
  712. standard = await _fetch_bundled_presets(db)
  713. return _stock_aliases(cloud, standard)
  714. def listed_ref(aliases: dict[str, PresetRef], ref: PresetRef) -> PresetRef:
  715. """The ref the listing shows for ``ref``: its cloud twin if hidden, else itself."""
  716. return aliases.get(f"{ref.source}:{ref.id}", ref)
  717. def _loaded_spool_tray(raw: dict, ams_id: int, tray_id: int, saved: SlotPresetMapping | None) -> LoadedSpoolTray:
  718. exists = raw.get("exists")
  719. state = raw.get("state")
  720. return LoadedSpoolTray(
  721. ams_id=ams_id,
  722. tray_id=tray_id,
  723. tray_type=raw.get("tray_type") or None,
  724. tray_sub_brands=raw.get("tray_sub_brands") or None,
  725. tray_color=raw.get("tray_color") or None,
  726. tray_info_idx=raw.get("tray_info_idx") or None,
  727. exists=exists if isinstance(exists, bool) else None,
  728. state=state if isinstance(state, int) else None,
  729. saved_preset=(
  730. LoadedSpoolPreset(
  731. preset_id=saved.preset_id,
  732. preset_name=saved.preset_name,
  733. preset_source=saved.preset_source,
  734. tray_info_idx=saved.tray_info_idx,
  735. )
  736. if saved is not None
  737. else None
  738. ),
  739. )
  740. @router.get("/loaded-spools", response_model=LoadedSpoolsResponse)
  741. async def list_loaded_spools(
  742. db: AsyncSession = Depends(get_db, scope="function"),
  743. _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD, Permission.PRINTERS_READ),
  744. printer_scope: PrinterScope = RequestPrinterScope,
  745. ) -> LoadedSpoolsResponse:
  746. """What is loaded in each connected printer, for the SliceModal's filters (#3172).
  747. One call rather than a status request per printer: the dialog needs every
  748. connected printer at once, and a farm has a hundred of them. Gated on the
  749. slice permission plus printers:read, because it shows what the printer
  750. status does, and limited to the caller's printers (#1727).
  751. Only printers with a live connection are listed: an offline printer can't
  752. say what it has loaded, and its last-known trays may be long gone.
  753. """
  754. query = select(Printer).where(Printer.is_active == True).order_by(Printer.name) # noqa: E712
  755. scope_clause = printer_scope.where_strict(Printer.id)
  756. if scope_clause is not None:
  757. query = query.where(scope_clause)
  758. printers = (await db.execute(query)).scalars().all()
  759. connected: list[tuple[Printer, dict]] = []
  760. for printer in printers:
  761. state = printer_manager.get_status(printer.id)
  762. if state is None or not state.connected:
  763. continue
  764. connected.append((printer, state.raw_data or {}))
  765. if not connected:
  766. return LoadedSpoolsResponse()
  767. mappings = (
  768. (
  769. await db.execute(
  770. select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_([p.id for p, _ in connected]))
  771. )
  772. )
  773. .scalars()
  774. .all()
  775. )
  776. saved = {(m.printer_id, m.ams_id, m.tray_id): m for m in mappings}
  777. out: list[LoadedSpoolPrinter] = []
  778. for printer, raw_data in connected:
  779. units: list[LoadedSpoolUnit] = []
  780. raw_ams = raw_data.get("ams")
  781. for ams_data in raw_ams if isinstance(raw_ams, list) else []:
  782. if not isinstance(ams_data, dict):
  783. continue
  784. ams_id = int(ams_data.get("id", 0))
  785. raw_trays = [t for t in ams_data.get("tray") or [] if isinstance(t, dict)]
  786. trays = [
  787. _loaded_spool_tray(t, ams_id, int(t.get("id", 0)), saved.get((printer.id, ams_id, int(t.get("id", 0)))))
  788. for t in raw_trays
  789. ]
  790. # Same rule as the printer status: an AMS-HT is a unit id 128-135.
  791. units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=is_ams_ht_id(ams_id), trays=trays))
  792. external: list[LoadedSpoolTray] = []
  793. holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
  794. for vt in holders:
  795. if not vt.get("tray_type"):
  796. continue
  797. # The holders are trays 254 / 255; slot presets key them as AMS 255,
  798. # tray 0 / 1, the way the printer card saves them.
  799. tray_id = external_slot_index(int(vt.get("id", 254)))
  800. external.append(_loaded_spool_tray(vt, 255, tray_id, saved.get((printer.id, 255, tray_id))))
  801. out.append(
  802. LoadedSpoolPrinter(
  803. id=printer.id,
  804. name=printer.name,
  805. model=normalize_printer_model_id(printer.model) or normalize_printer_model(printer.model),
  806. ams=units,
  807. external=external,
  808. external_holders=len(holders),
  809. )
  810. )
  811. return LoadedSpoolsResponse(printers=out)
  812. @router.get("/preview-progress/{request_id}")
  813. async def get_preview_slice_progress(
  814. request_id: str,
  815. db: AsyncSession = Depends(get_db, scope="function"),
  816. _: tuple[User | ApiKeyActor | None, bool] = Depends(
  817. require_ownership_permission(
  818. Permission.LIBRARY_READ_ALL,
  819. Permission.LIBRARY_READ_OWN,
  820. )
  821. ),
  822. ):
  823. """Proxy to the sidecar's ``GET /slice/progress/:requestId``.
  824. The SliceModal's filament-requirements call kicks off a real preview
  825. slice on the sidecar to discover which AMS slots the picked plate
  826. actually consumes. That HTTP call holds open for the full slice
  827. duration (multi-second to multi-minute on complex models), and the
  828. browser can't reach the sidecar directly thanks to the same-origin
  829. policy + the sidecar's CORS allowlist. This endpoint forwards the
  830. poll so the modal's inline spinner can show "Generating G-code (45%)"
  831. instead of an opaque elapsed-time counter while the preview runs.
  832. Returns the sidecar's snapshot verbatim, or 404 when the request_id
  833. is unknown / completed and grace-window-expired.
  834. """
  835. import httpx
  836. api_url = await _resolve_slicer_api_url(db)
  837. if not api_url:
  838. raise HTTPException(status_code=503, detail="No slicer sidecar configured")
  839. url = f"{api_url}/slice/progress/{request_id}"
  840. try:
  841. async with httpx.AsyncClient(timeout=5.0) as client:
  842. response = await client.get(url)
  843. except httpx.TimeoutException:
  844. # Busy, not gone: a heavy slice can hold the progress answer past 5 s
  845. # (TO-C14). Still 503, so the poller keeps trying.
  846. raise HTTPException(status_code=503, detail="Slicer sidecar did not answer in time") from None
  847. except httpx.RequestError:
  848. # Sidecar unreachable: surface as 503 instead of 500 so the
  849. # frontend's poller can keep trying without flagging a hard error.
  850. raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
  851. if response.status_code == 404:
  852. raise HTTPException(status_code=404, detail="Progress unavailable")
  853. return response.json()