slicer_api.py 19 KB

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