slicer_api.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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 set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  33. """Register an app-scoped client so per-request services can pool transport."""
  34. global _shared_http_client
  35. _shared_http_client = client
  36. def _guess_model_content_type(filename: str) -> str:
  37. lower = filename.lower()
  38. if lower.endswith(".stl"):
  39. return "model/stl"
  40. if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
  41. return "model/3mf"
  42. if lower.endswith(".step") or lower.endswith(".stp"):
  43. return "model/step"
  44. return "application/octet-stream"
  45. class SlicerApiService:
  46. """Talks to an OrcaSlicer / BambuStudio API sidecar."""
  47. def __init__(
  48. self,
  49. base_url: str,
  50. *,
  51. client: httpx.AsyncClient | None = None,
  52. timeout_seconds: float = 300.0,
  53. ) -> None:
  54. self.base_url = base_url.rstrip("/")
  55. self.timeout_seconds = timeout_seconds
  56. if client is not None:
  57. self._client = client
  58. self._owns_client = False
  59. elif _shared_http_client is not None:
  60. self._client = _shared_http_client
  61. self._owns_client = False
  62. else:
  63. self._client = httpx.AsyncClient(timeout=timeout_seconds)
  64. self._owns_client = True
  65. async def close(self) -> None:
  66. if self._owns_client:
  67. await self._client.aclose()
  68. async def __aenter__(self) -> "SlicerApiService":
  69. return self
  70. async def __aexit__(self, *_: object) -> None:
  71. await self.close()
  72. async def health(self) -> dict:
  73. """GET /health — used to surface a clear "sidecar offline" error before
  74. accepting a slice request from the user."""
  75. try:
  76. response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
  77. except httpx.RequestError as exc:
  78. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  79. if response.status_code >= 400:
  80. raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
  81. return response.json()
  82. async def slice_with_profiles(
  83. self,
  84. *,
  85. model_bytes: bytes,
  86. model_filename: str,
  87. printer_profile_json: str,
  88. process_profile_json: str,
  89. filament_profile_json: str,
  90. plate: int | None = None,
  91. export_3mf: bool = False,
  92. ) -> SliceResult:
  93. """POST /slice with model + printer/process/filament profile triplet.
  94. Raises:
  95. SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
  96. SlicerApiUnavailableError: connection error or 5xx from sidecar.
  97. """
  98. files = {
  99. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  100. "printerProfile": ("printer.json", printer_profile_json.encode("utf-8"), "application/json"),
  101. "presetProfile": ("preset.json", process_profile_json.encode("utf-8"), "application/json"),
  102. "filamentProfile": ("filament.json", filament_profile_json.encode("utf-8"), "application/json"),
  103. }
  104. data: dict[str, str] = {}
  105. if plate is not None:
  106. data["plate"] = str(plate)
  107. if export_3mf:
  108. data["exportType"] = "3mf"
  109. try:
  110. response = await self._client.post(
  111. f"{self.base_url}/slice",
  112. files=files,
  113. data=data,
  114. timeout=self.timeout_seconds,
  115. )
  116. except httpx.RequestError as exc:
  117. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  118. if response.status_code >= 500:
  119. try:
  120. msg = response.json().get("message", "")
  121. except Exception:
  122. msg = response.text
  123. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
  124. if response.status_code >= 400:
  125. try:
  126. msg = response.json().get("message", "")
  127. except Exception:
  128. msg = response.text
  129. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
  130. return SliceResult(
  131. content=response.content,
  132. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  133. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  134. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  135. )
  136. async def slice_without_profiles(
  137. self,
  138. *,
  139. model_bytes: bytes,
  140. model_filename: str,
  141. plate: int | None = None,
  142. export_3mf: bool = False,
  143. ) -> SliceResult:
  144. """POST /slice with only the model file and no profile triplet.
  145. For 3MF inputs this lets the slicer fall back on the file's embedded
  146. `Metadata/project_settings.config`. Used as a fallback when
  147. `slice_with_profiles` triggers a CLI segfault or other 5xx —
  148. complex H2D / multi-extruder models hit upstream bugs in both the
  149. OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
  150. """
  151. files = {
  152. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  153. }
  154. data: dict[str, str] = {}
  155. if plate is not None:
  156. data["plate"] = str(plate)
  157. if export_3mf:
  158. data["exportType"] = "3mf"
  159. try:
  160. response = await self._client.post(
  161. f"{self.base_url}/slice",
  162. files=files,
  163. data=data,
  164. timeout=self.timeout_seconds,
  165. )
  166. except httpx.RequestError as exc:
  167. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  168. if response.status_code >= 500:
  169. try:
  170. msg = response.json().get("message", "")
  171. except Exception:
  172. msg = response.text
  173. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {msg[:500]}")
  174. if response.status_code >= 400:
  175. try:
  176. msg = response.json().get("message", "")
  177. except Exception:
  178. msg = response.text
  179. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {msg[:500]}")
  180. return SliceResult(
  181. content=response.content,
  182. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  183. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  184. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  185. )
  186. def _safe_int(value: str | None) -> int:
  187. if not value:
  188. return 0
  189. try:
  190. return int(float(value))
  191. except (TypeError, ValueError):
  192. return 0
  193. def _safe_float(value: str | None) -> float:
  194. if not value:
  195. return 0.0
  196. try:
  197. return float(value)
  198. except (TypeError, ValueError):
  199. return 0.0