slicer_api.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. """HTTP client for an OrcaSlicer / BambuStudio API sidecar.
  2. Bambuddy stores user printer/process/filament profiles itself (cloud-synced
  3. or locally imported), so the slice flow always sends the model file plus an
  4. explicit JSON profile triplet to the sidecar's `/slice` endpoint. The sidecar
  5. shape mirrors `AFKFelix/orca-slicer-api` (multipart upload, `--load-settings`
  6. under the hood, response body is raw G-code or 3MF with metadata in the
  7. `X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
  8. """
  9. import logging
  10. from typing import NamedTuple
  11. import httpx
  12. logger = logging.getLogger(__name__)
  13. class SlicerApiError(Exception):
  14. """Base error from the slicer API sidecar."""
  15. class SlicerApiUnavailableError(SlicerApiError):
  16. """Sidecar is unreachable (connection error, no response)."""
  17. class SlicerApiServerError(SlicerApiError):
  18. """Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
  19. non-zero (range-validation reject, segfault on complex models, etc.).
  20. Distinguished from `SlicerApiUnavailableError` so the caller can decide
  21. whether to retry with a different request shape (e.g. a 3MF embedded-
  22. settings fallback)."""
  23. class SlicerInputError(SlicerApiError):
  24. """Sidecar rejected the input as invalid (4xx)."""
  25. class SliceResult(NamedTuple):
  26. """Result of a slice operation."""
  27. content: bytes
  28. print_time_seconds: int
  29. filament_used_g: float
  30. filament_used_mm: float
  31. _shared_http_client: httpx.AsyncClient | None = None
  32. def _format_sidecar_error(response: httpx.Response) -> str:
  33. """Build a human-readable error string from a sidecar 4xx/5xx response.
  34. The sidecar's `AppError` middleware emits a JSON body of the shape
  35. ``{"message": "...", "details": "..."}``. Earlier versions of this
  36. client only read ``message``, which left every CLI failure surfaced
  37. as the generic ``Failed to slice the model`` because the *actual*
  38. CLI stderr / `error_string` lives in ``details``. Including both
  39. means ``bambuddy.log`` carries the real reason a slice rejected
  40. the supplied profiles instead of an unhelpful generic line.
  41. """
  42. try:
  43. payload = response.json()
  44. except Exception:
  45. return response.text[:500]
  46. if not isinstance(payload, dict):
  47. return str(payload)[:500]
  48. message = payload.get("message") or ""
  49. details = payload.get("details") or ""
  50. if message and details:
  51. return f"{message}: {details}"[:500]
  52. return (message or details or response.text)[:500]
  53. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  54. """Register an app-scoped client so per-request services can pool transport."""
  55. global _shared_http_client
  56. _shared_http_client = client
  57. def _guess_model_content_type(filename: str) -> str:
  58. lower = filename.lower()
  59. if lower.endswith(".stl"):
  60. return "model/stl"
  61. if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
  62. return "model/3mf"
  63. if lower.endswith(".step") or lower.endswith(".stp"):
  64. return "model/step"
  65. return "application/octet-stream"
  66. class SlicerApiService:
  67. """Talks to an OrcaSlicer / BambuStudio API sidecar."""
  68. def __init__(
  69. self,
  70. base_url: str,
  71. *,
  72. client: httpx.AsyncClient | None = None,
  73. timeout_seconds: float = 300.0,
  74. ) -> None:
  75. self.base_url = base_url.rstrip("/")
  76. self.timeout_seconds = timeout_seconds
  77. if client is not None:
  78. self._client = client
  79. self._owns_client = False
  80. elif _shared_http_client is not None:
  81. self._client = _shared_http_client
  82. self._owns_client = False
  83. else:
  84. self._client = httpx.AsyncClient(timeout=timeout_seconds)
  85. self._owns_client = True
  86. async def close(self) -> None:
  87. if self._owns_client:
  88. await self._client.aclose()
  89. async def __aenter__(self) -> "SlicerApiService":
  90. return self
  91. async def __aexit__(self, *_: object) -> None:
  92. await self.close()
  93. async def health(self) -> dict:
  94. """GET /health — used to surface a clear "sidecar offline" error before
  95. accepting a slice request from the user."""
  96. try:
  97. response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
  98. except httpx.RequestError as exc:
  99. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  100. if response.status_code >= 400:
  101. raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
  102. return response.json()
  103. async def list_bundled_profiles(self) -> dict:
  104. """GET /profiles/bundled — return the slicer's stock profiles by slot.
  105. Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
  106. The sidecar walks the slicer's read-only `resources/profiles/BBL/`
  107. tree and returns ``{printer, process, filament}`` arrays of
  108. ``{name, base_id}`` (alphabetised, instantiable presets only — abstract
  109. bases like `fdm_filament_pla` are filtered out by the sidecar).
  110. Returns an empty-shaped dict when the sidecar is unreachable so the
  111. unified-presets endpoint can degrade to "no standard tier" without
  112. crashing the modal — cloud + local-imported profiles still render.
  113. """
  114. try:
  115. response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
  116. except httpx.RequestError as exc:
  117. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  118. if response.status_code >= 400:
  119. raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
  120. return response.json()
  121. async def slice_with_profiles(
  122. self,
  123. *,
  124. model_bytes: bytes,
  125. model_filename: str,
  126. printer_profile_json: str,
  127. process_profile_json: str,
  128. filament_profile_jsons: list[str],
  129. plate: int | None = None,
  130. export_3mf: bool = False,
  131. ) -> SliceResult:
  132. """POST /slice with model + printer/process/filament profiles.
  133. ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
  134. profile for slot 1, etc. Single-color callers pass a one-element
  135. list. Multiple ``filamentProfile`` parts are sent as a repeated form
  136. field — the sidecar's route declares ``maxCount: 16`` and the
  137. slicing service joins them as semicolon-separated
  138. ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
  139. Raises:
  140. SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
  141. SlicerApiUnavailableError: connection error or 5xx from sidecar.
  142. """
  143. # httpx supports repeated multipart fields when files is a list of
  144. # tuples — using the dict form would silently overwrite duplicate
  145. # keys and ship only the last filament profile.
  146. files: list[tuple[str, tuple[str, bytes, str]]] = [
  147. ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
  148. ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
  149. ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
  150. ]
  151. for idx, fjson in enumerate(filament_profile_jsons):
  152. files.append(
  153. (
  154. "filamentProfile",
  155. (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
  156. )
  157. )
  158. data: dict[str, str] = {}
  159. if plate is not None:
  160. data["plate"] = str(plate)
  161. if export_3mf:
  162. data["exportType"] = "3mf"
  163. try:
  164. response = await self._client.post(
  165. f"{self.base_url}/slice",
  166. files=files,
  167. data=data,
  168. timeout=self.timeout_seconds,
  169. )
  170. except httpx.RequestError as exc:
  171. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  172. if response.status_code >= 500:
  173. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  174. if response.status_code >= 400:
  175. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  176. return SliceResult(
  177. content=response.content,
  178. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  179. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  180. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  181. )
  182. async def slice_without_profiles(
  183. self,
  184. *,
  185. model_bytes: bytes,
  186. model_filename: str,
  187. plate: int | None = None,
  188. export_3mf: bool = False,
  189. ) -> SliceResult:
  190. """POST /slice with only the model file and no profile triplet.
  191. For 3MF inputs this lets the slicer fall back on the file's embedded
  192. `Metadata/project_settings.config`. Used as a fallback when
  193. `slice_with_profiles` triggers a CLI segfault or other 5xx —
  194. complex H2D / multi-extruder models hit upstream bugs in both the
  195. OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
  196. """
  197. files = {
  198. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  199. }
  200. data: dict[str, str] = {}
  201. if plate is not None:
  202. data["plate"] = str(plate)
  203. if export_3mf:
  204. data["exportType"] = "3mf"
  205. try:
  206. response = await self._client.post(
  207. f"{self.base_url}/slice",
  208. files=files,
  209. data=data,
  210. timeout=self.timeout_seconds,
  211. )
  212. except httpx.RequestError as exc:
  213. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  214. if response.status_code >= 500:
  215. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  216. if response.status_code >= 400:
  217. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  218. return SliceResult(
  219. content=response.content,
  220. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  221. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  222. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  223. )
  224. def _safe_int(value: str | None) -> int:
  225. if not value:
  226. return 0
  227. try:
  228. return int(float(value))
  229. except (TypeError, ValueError):
  230. return 0
  231. def _safe_float(value: str | None) -> float:
  232. if not value:
  233. return 0.0
  234. try:
  235. return float(value)
  236. except (TypeError, ValueError):
  237. return 0.0