slicer_api.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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 asyncio
  10. import logging
  11. from collections.abc import Callable
  12. from typing import NamedTuple
  13. import httpx
  14. logger = logging.getLogger(__name__)
  15. class SlicerApiError(Exception):
  16. """Base error from the slicer API sidecar."""
  17. class SlicerApiUnavailableError(SlicerApiError):
  18. """Sidecar is unreachable (connection error, no response)."""
  19. class SlicerApiServerError(SlicerApiError):
  20. """Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
  21. non-zero (range-validation reject, segfault on complex models, etc.).
  22. Distinguished from `SlicerApiUnavailableError` so the caller can decide
  23. whether to retry with a different request shape (e.g. a 3MF embedded-
  24. settings fallback)."""
  25. class SlicerInputError(SlicerApiError):
  26. """Sidecar rejected the input as invalid (4xx)."""
  27. class SliceResult(NamedTuple):
  28. """Result of a slice operation."""
  29. content: bytes
  30. print_time_seconds: int
  31. filament_used_g: float
  32. filament_used_mm: float
  33. _shared_http_client: httpx.AsyncClient | None = None
  34. def _format_sidecar_error(response: httpx.Response) -> str:
  35. """Build a human-readable error string from a sidecar 4xx/5xx response.
  36. The sidecar's `AppError` middleware emits a JSON body of the shape
  37. ``{"message": "...", "details": "..."}``. Earlier versions of this
  38. client only read ``message``, which left every CLI failure surfaced
  39. as the generic ``Failed to slice the model`` because the *actual*
  40. CLI stderr / `error_string` lives in ``details``. Including both
  41. means ``bambuddy.log`` carries the real reason a slice rejected
  42. the supplied profiles instead of an unhelpful generic line.
  43. """
  44. try:
  45. payload = response.json()
  46. except Exception:
  47. return response.text[:500]
  48. if not isinstance(payload, dict):
  49. return str(payload)[:500]
  50. message = payload.get("message") or ""
  51. details = payload.get("details") or ""
  52. if message and details:
  53. return f"{message}: {details}"[:500]
  54. return (message or details or response.text)[:500]
  55. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  56. """Register an app-scoped client so per-request services can pool transport."""
  57. global _shared_http_client
  58. _shared_http_client = client
  59. def _guess_model_content_type(filename: str) -> str:
  60. lower = filename.lower()
  61. if lower.endswith(".stl"):
  62. return "model/stl"
  63. if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
  64. return "model/3mf"
  65. if lower.endswith(".step") or lower.endswith(".stp"):
  66. return "model/step"
  67. return "application/octet-stream"
  68. class SlicerApiService:
  69. """Talks to an OrcaSlicer / BambuStudio API sidecar."""
  70. def __init__(
  71. self,
  72. base_url: str,
  73. *,
  74. client: httpx.AsyncClient | None = None,
  75. timeout_seconds: float = 300.0,
  76. ) -> None:
  77. self.base_url = base_url.rstrip("/")
  78. self.timeout_seconds = timeout_seconds
  79. if client is not None:
  80. self._client = client
  81. self._owns_client = False
  82. elif _shared_http_client is not None:
  83. self._client = _shared_http_client
  84. self._owns_client = False
  85. else:
  86. self._client = httpx.AsyncClient(timeout=timeout_seconds)
  87. self._owns_client = True
  88. async def close(self) -> None:
  89. if self._owns_client:
  90. await self._client.aclose()
  91. async def __aenter__(self) -> "SlicerApiService":
  92. return self
  93. async def __aexit__(self, *_: object) -> None:
  94. await self.close()
  95. async def health(self) -> dict:
  96. """GET /health — used to surface a clear "sidecar offline" error before
  97. accepting a slice request from the user."""
  98. try:
  99. response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
  100. except httpx.RequestError as exc:
  101. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  102. if response.status_code >= 400:
  103. raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
  104. return response.json()
  105. async def list_bundled_profiles(self) -> dict:
  106. """GET /profiles/bundled — return the slicer's stock profiles by slot.
  107. Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
  108. The sidecar walks the slicer's read-only `resources/profiles/BBL/`
  109. tree and returns ``{printer, process, filament}`` arrays of
  110. ``{name, base_id}`` (alphabetised, instantiable presets only — abstract
  111. bases like `fdm_filament_pla` are filtered out by the sidecar).
  112. Returns an empty-shaped dict when the sidecar is unreachable so the
  113. unified-presets endpoint can degrade to "no standard tier" without
  114. crashing the modal — cloud + local-imported profiles still render.
  115. """
  116. try:
  117. response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
  118. except httpx.RequestError as exc:
  119. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  120. if response.status_code >= 400:
  121. raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
  122. return response.json()
  123. async def _poll_progress(
  124. self,
  125. request_id: str,
  126. on_progress: Callable[[dict], None],
  127. ) -> None:
  128. """Poll the sidecar's progress endpoint at ~1Hz and forward each
  129. snapshot to ``on_progress``. Runs until cancelled.
  130. 4xx is NOT treated as terminal: the FIRST poll fires the moment
  131. the slice POST is sent, which can be milliseconds before the
  132. request actually lands on the sidecar and `progressStore.start()`
  133. runs — so a fresh request legitimately returns 404 for the first
  134. tick or two. Bailing on the first 404 (the original implementation)
  135. meant we'd quit before progress could ever arrive. The polling
  136. task is cancelled by the outer slice request anyway, so a
  137. sustained 404 (older sidecar without progress support, or post-
  138. slice grace expiry) just costs a few wasted GETs that the cancel
  139. will stop. Network errors and non-JSON 5xx are swallowed; the
  140. next tick retries.
  141. """
  142. url = f"{self.base_url}/slice/progress/{request_id}"
  143. while True:
  144. try:
  145. response = await self._client.get(url, timeout=5.0)
  146. if response.status_code == 200:
  147. payload = response.json()
  148. if isinstance(payload, dict):
  149. on_progress(payload)
  150. # 404 / other 4xx = no progress available (yet, or ever
  151. # for older sidecars). Keep polling — the outer slice
  152. # request will cancel this task on completion.
  153. except (httpx.RequestError, ValueError):
  154. # ValueError covers JSONDecodeError when the sidecar
  155. # returns a non-JSON 5xx. Don't crash the poller.
  156. pass
  157. try:
  158. await asyncio.sleep(1.0)
  159. except asyncio.CancelledError:
  160. return
  161. async def slice_with_profiles(
  162. self,
  163. *,
  164. model_bytes: bytes,
  165. model_filename: str,
  166. printer_profile_json: str,
  167. process_profile_json: str,
  168. filament_profile_jsons: list[str],
  169. plate: int | None = None,
  170. export_3mf: bool = False,
  171. request_id: str | None = None,
  172. on_progress: Callable[[dict], None] | None = None,
  173. ) -> SliceResult:
  174. """POST /slice with model + printer/process/filament profiles.
  175. ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
  176. profile for slot 1, etc. Single-color callers pass a one-element
  177. list. Multiple ``filamentProfile`` parts are sent as a repeated form
  178. field — the sidecar's route declares ``maxCount: 16`` and the
  179. slicing service joins them as semicolon-separated
  180. ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
  181. ``request_id``: when supplied, the sidecar wires --pipe to a
  182. per-request FIFO and publishes structured JSON progress events to
  183. its in-memory ProgressStore under this id. Bambuddy's slice
  184. dispatch polls ``GET /slice/progress/{request_id}`` in parallel
  185. to drive the live-progress toast.
  186. Raises:
  187. SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
  188. SlicerApiUnavailableError: connection error or 5xx from sidecar.
  189. """
  190. # httpx supports repeated multipart fields when files is a list of
  191. # tuples — using the dict form would silently overwrite duplicate
  192. # keys and ship only the last filament profile.
  193. files: list[tuple[str, tuple[str, bytes, str]]] = [
  194. ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
  195. ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
  196. ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
  197. ]
  198. for idx, fjson in enumerate(filament_profile_jsons):
  199. files.append(
  200. (
  201. "filamentProfile",
  202. (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
  203. )
  204. )
  205. data: dict[str, str] = {}
  206. if plate is not None:
  207. data["plate"] = str(plate)
  208. if export_3mf:
  209. data["exportType"] = "3mf"
  210. if request_id is not None:
  211. data["requestId"] = request_id
  212. # When the caller supplied a request_id, kick off a parallel
  213. # poller that reads the sidecar's --pipe-fed progress endpoint
  214. # and surfaces structured updates via on_progress. Uses a
  215. # short-tick poll (1s) since the slicer emits stage changes
  216. # several times per minute on complex models.
  217. progress_task: asyncio.Task | None = None
  218. if request_id is not None and on_progress is not None:
  219. progress_task = asyncio.create_task(
  220. self._poll_progress(request_id, on_progress),
  221. name=f"slicer-progress-{request_id}",
  222. )
  223. try:
  224. response = await self._client.post(
  225. f"{self.base_url}/slice",
  226. files=files,
  227. data=data,
  228. timeout=self.timeout_seconds,
  229. )
  230. except httpx.RequestError as exc:
  231. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  232. finally:
  233. if progress_task is not None:
  234. progress_task.cancel()
  235. try:
  236. await progress_task
  237. except (asyncio.CancelledError, Exception):
  238. pass # Polling errors must not fail the slice.
  239. if response.status_code >= 500:
  240. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  241. if response.status_code >= 400:
  242. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  243. return SliceResult(
  244. content=response.content,
  245. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  246. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  247. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  248. )
  249. async def slice_without_profiles(
  250. self,
  251. *,
  252. model_bytes: bytes,
  253. model_filename: str,
  254. plate: int | None = None,
  255. export_3mf: bool = False,
  256. request_id: str | None = None,
  257. on_progress: Callable[[dict], None] | None = None,
  258. ) -> SliceResult:
  259. """POST /slice with only the model file and no profile triplet.
  260. For 3MF inputs this lets the slicer fall back on the file's embedded
  261. `Metadata/project_settings.config`. Used as a fallback when
  262. `slice_with_profiles` triggers a CLI segfault or other 5xx —
  263. complex H2D / multi-extruder models hit upstream bugs in both the
  264. OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
  265. Also used by the SliceModal's per-plate filament discovery path:
  266. for an unsliced project file we run a real preview slice via the
  267. sidecar to find which AMS slots the picked plate consumes. The
  268. ``request_id`` parameter routes the sidecar's --pipe progress
  269. events to the ProgressStore so the modal's inline spinner +
  270. toast can show "Generating G-code (75%)" for that preview as
  271. well.
  272. """
  273. files = {
  274. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  275. }
  276. data: dict[str, str] = {}
  277. if plate is not None:
  278. data["plate"] = str(plate)
  279. if export_3mf:
  280. data["exportType"] = "3mf"
  281. if request_id is not None:
  282. data["requestId"] = request_id
  283. # Same progress-poller wiring as slice_with_profiles. Used by the
  284. # SliceModal's preview slice (for filament discovery) AND the
  285. # embedded-settings fallback path triggered by an Orca/Bambu CLI
  286. # segfault on complex H2D models — both want to keep updating
  287. # the user's toast through the slow operation.
  288. progress_task: asyncio.Task | None = None
  289. if request_id is not None and on_progress is not None:
  290. progress_task = asyncio.create_task(
  291. self._poll_progress(request_id, on_progress),
  292. name=f"slicer-progress-{request_id}",
  293. )
  294. try:
  295. response = await self._client.post(
  296. f"{self.base_url}/slice",
  297. files=files,
  298. data=data,
  299. timeout=self.timeout_seconds,
  300. )
  301. except httpx.RequestError as exc:
  302. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
  303. finally:
  304. if progress_task is not None:
  305. progress_task.cancel()
  306. try:
  307. await progress_task
  308. except (asyncio.CancelledError, Exception):
  309. pass
  310. if response.status_code >= 500:
  311. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  312. if response.status_code >= 400:
  313. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  314. return SliceResult(
  315. content=response.content,
  316. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  317. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  318. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  319. )
  320. def _safe_int(value: str | None) -> int:
  321. if not value:
  322. return 0
  323. try:
  324. return int(float(value))
  325. except (TypeError, ValueError):
  326. return 0
  327. def _safe_float(value: str | None) -> float:
  328. if not value:
  329. return 0.0
  330. try:
  331. return float(value)
  332. except (TypeError, ValueError):
  333. return 0.0