slicer_api.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  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 json
  12. import logging
  13. import time
  14. import zipfile
  15. from collections.abc import Callable
  16. from typing import NamedTuple
  17. import httpx
  18. logger = logging.getLogger(__name__)
  19. class SlicerApiError(Exception):
  20. """Base error from the slicer API sidecar."""
  21. class SlicerApiUnavailableError(SlicerApiError):
  22. """Sidecar is unreachable (connection error, no response)."""
  23. class SlicerApiServerError(SlicerApiError):
  24. """Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
  25. non-zero (range-validation reject, segfault on complex models, etc.).
  26. Distinguished from `SlicerApiUnavailableError` so the caller can decide
  27. whether to retry with a different request shape (e.g. a 3MF embedded-
  28. settings fallback)."""
  29. class SlicerInputError(SlicerApiError):
  30. """Sidecar rejected the input as invalid (4xx)."""
  31. class SlicerTimeoutError(SlicerApiError):
  32. """We gave up waiting on a slice that never finished.
  33. Kept apart from ``SlicerApiUnavailableError`` because they call for
  34. opposite reactions and used to be reported as the same thing: an
  35. ``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
  36. simply took a long time surfaced as "Slicer sidecar unreachable" — sending
  37. the reporter of #2730 off to check a sidecar that was reachable throughout
  38. and still slicing when we hung up on it.
  39. """
  40. class ResolvedProfile(NamedTuple):
  41. """A preset's effective values, or why they are unavailable.
  42. ``reason`` is one of ``ok`` / ``sidecar_outdated`` / ``sidecar_unavailable``
  43. / ``preset_unresolved``. It exists so the UI can say something actionable
  44. instead of one generic "could not read the values" for four causes.
  45. """
  46. values: dict | None
  47. reason: str
  48. class SliceResult(NamedTuple):
  49. """Result of a slice operation."""
  50. content: bytes
  51. print_time_seconds: int
  52. filament_used_g: float
  53. filament_used_mm: float
  54. _shared_http_client: httpx.AsyncClient | None = None
  55. # Fallback for callers that don't pass one (tests, and any path that runs
  56. # without a DB session to read the setting from). The user-facing value is
  57. # ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
  58. DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
  59. # How often the progress poller ticks. Also the granularity of the stall check,
  60. # since a missed tick is what the stall clock is counting.
  61. _PROGRESS_POLL_INTERVAL = 1.0
  62. async def get_stall_timeout_seconds(db) -> float:
  63. """Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
  64. Falls back to the default on anything unparseable rather than failing the
  65. slice — a bad settings row must not be the reason a print doesn't happen.
  66. """
  67. from backend.app.api.routes.settings import get_setting
  68. try:
  69. raw = await get_setting(db, "slicer_stall_timeout_minutes")
  70. except Exception:
  71. return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
  72. try:
  73. minutes = int(str(raw).strip())
  74. except (TypeError, ValueError):
  75. return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
  76. if minutes < 1:
  77. return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
  78. return float(minutes) * 60.0
  79. def _format_sidecar_error(response: httpx.Response) -> str:
  80. """Build a human-readable error string from a sidecar 4xx/5xx response.
  81. The sidecar's `AppError` middleware emits a JSON body of the shape
  82. ``{"message": "...", "details": "..."}``. Earlier versions of this
  83. client only read ``message``, which left every CLI failure surfaced
  84. as the generic ``Failed to slice the model`` because the *actual*
  85. CLI stderr / `error_string` lives in ``details``. Including both
  86. means ``bambuddy.log`` carries the real reason a slice rejected
  87. the supplied profiles instead of an unhelpful generic line.
  88. """
  89. try:
  90. payload = response.json()
  91. except Exception:
  92. return response.text[:500]
  93. if not isinstance(payload, dict):
  94. return str(payload)[:500]
  95. message = payload.get("message") or ""
  96. details = payload.get("details") or ""
  97. if message and details:
  98. return f"{message}: {details}"[:500]
  99. return (message or details or response.text)[:500]
  100. def _transport_error_reason(exc: httpx.RequestError) -> str:
  101. """Describe a transport failure, even when the exception carries no message.
  102. Several ``httpx.RequestError`` subclasses are raised with no args, so
  103. ``str(exc)`` is the empty string — which is how three lines of the #2802
  104. reporter's support package came to read ``Slicer sidecar unreachable:``
  105. with nothing after the colon. The class name is not much, but it
  106. distinguishes a refused connection from a protocol error, and a log line
  107. that names nothing is worth less than one that names the exception type.
  108. """
  109. return str(exc) or type(exc).__name__
  110. # How the sidecar says "your model is bigger than my cap", across versions.
  111. # Images built before the cap became configurable answer with multer's raw
  112. # ``LIMIT_FILE_SIZE`` text under a **500** — ``MulterError`` is not the
  113. # sidecar's ``AppError``, so its handler falls through to the default status —
  114. # while current ones send a 413 naming the limit and the env var that raises
  115. # it. Matching on text rather than status covers both, and matters because a
  116. # 500 otherwise reads as a slicer crash and sends people off tuning reverse
  117. # proxies that were never in the path (#2802).
  118. #
  119. # Deliberately specific: a proxy's own "413 Request Entity Too Large" must NOT
  120. # match, because that one really is fixed at the proxy and gets its own advice.
  121. _UPLOAD_TOO_LARGE_MARKERS = (
  122. "file too large",
  123. "upload limit",
  124. "max_model_upload_mb",
  125. )
  126. # A sidecar that says which knob raises the cap is new enough to have one.
  127. # Older ones only ever emit multer's bare "File too large", and for those the
  128. # advice has to be "update the image" — there is no env var to set.
  129. _CONFIGURABLE_CAP_MARKERS = ("upload limit", "max_model_upload_mb")
  130. def _upload_size_rejection(response: httpx.Response, model_size_bytes: int | None) -> str | None:
  131. """Return an explanation if the sidecar refused the upload as oversized.
  132. The 500 case is matched strictly — the body has to be *only* multer's
  133. message — because a 500 is also how a genuine CLI failure arrives, and
  134. those must keep reaching the embedded-settings fallback. A CLI failure
  135. always carries the slicer's stderr in ``details``, so it never reduces to
  136. the bare string on its own.
  137. """
  138. detail = _format_sidecar_error(response)
  139. lowered = detail.lower()
  140. if response.status_code >= 500:
  141. if lowered.strip() != "file too large":
  142. return None
  143. elif not any(marker in lowered for marker in _UPLOAD_TOO_LARGE_MARKERS):
  144. return None
  145. size = f"{model_size_bytes / (1024 * 1024):.0f} MB " if model_size_bytes else ""
  146. # Shared preamble: both variants must rule out the layers people reach for
  147. # first, because those are the ones that look like they should apply.
  148. common = (
  149. f"The slicer sidecar refused the {size}model file as too large. The limit lives inside "
  150. "the sidecar container, so it is neither a Bambuddy setting nor a reverse-proxy one — "
  151. "raising 'client_max_body_size' or a proxy body limit will not change it."
  152. )
  153. if any(marker in lowered for marker in _CONFIGURABLE_CAP_MARKERS):
  154. return (
  155. f"{common} Raise it by setting MAX_MODEL_UPLOAD_MB on the slicer-api service and "
  156. f"restarting it. Sidecar said: {detail}"
  157. )
  158. # Naming the service in the compose commands is not a style choice. The
  159. # Bambu Studio sidecar sits behind `profiles: [bambu]`, and a bare
  160. # `docker compose pull` silently skips every profile-gated service — so the
  161. # update this message asks for was a no-op for exactly the users who need
  162. # it, and `restart: unless-stopped` kept the old container serving (#2802,
  163. # second round). Naming a service enables its profile implicitly, for both
  164. # pull and up. `--profile bambu` would also work, but on an OrcaSlicer-only
  165. # host it downloads the 220 MB Bambu image and then *starts* a sidecar the
  166. # user never asked for.
  167. return (
  168. f"{common} This sidecar image predates the configurable cap and is fixed at 100 MB. "
  169. "Update it with 'cd slicer-api/ && docker compose pull orca-slicer-api && "
  170. "docker compose up -d orca-slicer-api', substituting 'bambu-studio-api' if that is the "
  171. "sidecar you slice with. Name the service in both commands — a bare 'docker compose pull' "
  172. "skips the Bambu Studio sidecar, because it sits behind a compose profile. The new image "
  173. "defaults to 512 MB and adds MAX_MODEL_UPLOAD_MB for going higher still. "
  174. f"Sidecar said: {detail}"
  175. )
  176. def _handle_slice_response(
  177. response: httpx.Response, *, export_3mf: bool, model_size_bytes: int | None = None
  178. ) -> SliceResult:
  179. """Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
  180. Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
  181. handling and output validation live in one place.
  182. Beyond the status check, this guards against the sidecar (or a reverse proxy
  183. in front of it) returning **HTTP 200 with a body that isn't a real slice**
  184. (#2671): a stock/misconfigured sidecar, a proxy interstitial or truncated
  185. response, or an OrcaSlicer/BambuStudio CLI crash that produces empty output.
  186. Without this check Bambuddy would store that tiny blob as a ``.gcode.3mf``,
  187. let it be queued, and FTP it to the printer — a silently-broken print. When
  188. a 3MF export was requested the body must be a valid ZIP (3MF container);
  189. anything else is treated as a sidecar failure.
  190. Raises:
  191. SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
  192. SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
  193. """
  194. # Checked ahead of the status branches because the same rejection arrives
  195. # as a 500 from older sidecars and a 413 from newer ones, and because
  196. # raising SlicerInputError (rather than SlicerApiServerError) is what stops
  197. # the library route retrying the identical oversized upload with embedded
  198. # settings — a second 25-second conversion for a guaranteed same answer.
  199. oversized = _upload_size_rejection(response, model_size_bytes)
  200. if oversized:
  201. raise SlicerInputError(oversized)
  202. if response.status_code == 413:
  203. # A 413 almost never comes from the slicer itself — it's a reverse proxy
  204. # (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
  205. # profiles). Name the real fix so the user doesn't tweak the wrong layer.
  206. raise SlicerInputError(
  207. "The slice request was rejected as too large (HTTP 413). A reverse proxy "
  208. "in front of the slicer sidecar is capping the request body — raise "
  209. "'client_max_body_size' (nginx/SWAG) or the equivalent on the proxy that "
  210. "sits directly in front of the sidecar, then reload it. If the sidecar is "
  211. "behind Cloudflare, note its request-size cap."
  212. )
  213. if response.status_code >= 500:
  214. raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
  215. if response.status_code >= 400:
  216. raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
  217. content = response.content
  218. if export_3mf and not zipfile.is_zipfile(io.BytesIO(content)):
  219. # 200 OK but the body is not a 3MF zip → the sidecar did not produce a
  220. # usable slice. Surface it loudly instead of persisting a corrupt file.
  221. detail = _format_sidecar_error(response) if len(content) <= 500 else ""
  222. raise SlicerApiServerError(
  223. f"Slicer sidecar returned HTTP {response.status_code} but the body is not a valid "
  224. f"3MF ({len(content)} bytes). This usually means a misconfigured sidecar, an "
  225. f"OrcaSlicer/BambuStudio CLI crash producing no output, or a reverse proxy returning "
  226. f"an error page or truncating the response — verify the sidecar URL and any proxy in "
  227. f"front of it." + (f" Body: {detail}" if detail else "")
  228. )
  229. return SliceResult(
  230. content=content,
  231. print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
  232. filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
  233. filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
  234. )
  235. def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
  236. """Register an app-scoped client so per-request services can pool transport."""
  237. global _shared_http_client
  238. _shared_http_client = client
  239. def _guess_model_content_type(filename: str) -> str:
  240. lower = filename.lower()
  241. if lower.endswith(".stl"):
  242. return "model/stl"
  243. if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
  244. return "model/3mf"
  245. if lower.endswith(".step") or lower.endswith(".stp"):
  246. return "model/step"
  247. return "application/octet-stream"
  248. class _Liveness:
  249. """Tracks when the slicer last showed a sign of life.
  250. ``deadline`` is what the slice waits against, and it moves forward on every
  251. genuine progress update. A slice therefore fails only after the configured
  252. window of *silence*, however long the whole thing has been running (#2730).
  253. ``progress_supported`` stays False for sidecars that never answer the
  254. progress endpoint. Those give us nothing to judge liveness by, so the caller
  255. treats the same window as a total-elapsed ceiling rather than pretending a
  256. stall can be detected.
  257. """
  258. def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
  259. # Liveness can only be observed as often as the poller ticks, so a
  260. # window shorter than a few ticks would expire in the gap between two
  261. # polls and fail every slice instantly, however healthy. The settings
  262. # schema already floors the user-facing value at a minute; this guards
  263. # the constructor, which tests and any future caller can pass anything.
  264. self.window_seconds = max(window_seconds, poll_interval * 3)
  265. self.progress_supported = False
  266. self.started_at = time.monotonic()
  267. self._last_alive = self.started_at
  268. def saw_progress_endpoint(self) -> None:
  269. self.progress_supported = True
  270. def mark_alive(self) -> None:
  271. self._last_alive = time.monotonic()
  272. @property
  273. def deadline(self) -> float:
  274. """Monotonic time at which we stop waiting."""
  275. base = self._last_alive if self.progress_supported else self.started_at
  276. return base + self.window_seconds
  277. def silent_for(self) -> float:
  278. return time.monotonic() - self._last_alive
  279. def elapsed(self) -> float:
  280. return time.monotonic() - self.started_at
  281. def timeout_message(self) -> str:
  282. minutes = self.window_seconds / 60
  283. if self.progress_supported:
  284. return (
  285. f"The slicer stopped reporting progress for {minutes:.0f} minutes "
  286. f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
  287. "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
  288. "legitimately needs longer between progress updates."
  289. )
  290. return (
  291. f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
  292. "report progress, so there was no way to tell a slow model from a stalled one. "
  293. "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
  294. "sidecar to a version that reports progress."
  295. )
  296. class SlicerApiService:
  297. """Talks to an OrcaSlicer / BambuStudio API sidecar."""
  298. def __init__(
  299. self,
  300. base_url: str,
  301. *,
  302. client: httpx.AsyncClient | None = None,
  303. timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
  304. ) -> None:
  305. """``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
  306. While a slice is running Bambuddy polls the sidecar's progress channel
  307. once a second, so it can tell a model that is merely slow from one that
  308. has stopped: the clock is reset by every progress update, and only runs
  309. out when the slicer has said nothing for this long. A heavy model that
  310. keeps reporting will run to completion however long it takes.
  311. Sidecars too old to report progress have no liveness signal to offer, so
  312. for those the same number bounds total elapsed time — the pre-#2730
  313. behaviour, but configurable and no longer five minutes flat.
  314. """
  315. self.base_url = base_url.rstrip("/")
  316. self.timeout_seconds = timeout_seconds
  317. # Instance-level so tests can compress the timing; production always
  318. # uses the module default.
  319. self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
  320. if client is not None:
  321. self._client = client
  322. self._owns_client = False
  323. elif _shared_http_client is not None:
  324. self._client = _shared_http_client
  325. self._owns_client = False
  326. else:
  327. self._client = httpx.AsyncClient(timeout=timeout_seconds)
  328. self._owns_client = True
  329. async def close(self) -> None:
  330. if self._owns_client:
  331. await self._client.aclose()
  332. async def __aenter__(self) -> "SlicerApiService":
  333. return self
  334. async def __aexit__(self, *_: object) -> None:
  335. await self.close()
  336. async def health(self) -> dict:
  337. """GET /health — used to surface a clear "sidecar offline" error before
  338. accepting a slice request from the user."""
  339. try:
  340. response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
  341. except httpx.RequestError as exc:
  342. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
  343. if response.status_code >= 400:
  344. raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
  345. return response.json()
  346. async def resolve_profile(self, profile_json: str, category: str) -> "ResolvedProfile":
  347. """POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
  348. Returns the effective key/value map the slicer would actually use, so
  349. the slice modal's settings panel can show a preset's real values rather
  350. than the option schema's compiled-in defaults (a "Standard" pick is
  351. only a ``{inherits: ...}`` stub on our side; everything else it sets
  352. lives in the sidecar's bundled profiles).
  353. This deliberately asks the sidecar rather than resolving locally.
  354. Bambuddy has its own ``inherits:`` resolver in ``orca_profiles``, but it
  355. walks OrcaSlicer's *published* profile tree, which is not necessarily
  356. the one baked into the running sidecar image — values from it would look
  357. authoritative and could quietly disagree with what gets sliced.
  358. Returns a :class:`ResolvedProfile` whose ``reason`` distinguishes *why*
  359. values are missing. That matters more than it looks: the common case in
  360. practice is a sidecar older than this endpoint, because a Bambuddy
  361. install pulls ``SIDECAR_TAG:-latest`` independently of its own release
  362. channel. "Could not read the values" sends that user hunting; "your
  363. sidecar image is older than this feature" is a one-line fix. Genuine
  364. transport failures still raise.
  365. """
  366. try:
  367. payload = json.loads(profile_json)
  368. except json.JSONDecodeError:
  369. logger.warning("Cannot resolve %s preset: content is not valid JSON", category)
  370. return ResolvedProfile(None, "preset_unresolved")
  371. try:
  372. response = await self._client.post(
  373. f"{self.base_url}/profiles/resolve",
  374. json={"category": category, "profile": payload},
  375. timeout=15.0,
  376. )
  377. except httpx.RequestError as exc:
  378. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
  379. if response.status_code == 404:
  380. # Sidecar predates the endpoint. Not an error, and specifically not
  381. # the same as a broken one — this is the case that has a fix the
  382. # user can act on.
  383. logger.info("Slicer sidecar has no /profiles/resolve; falling back to schema defaults")
  384. return ResolvedProfile(None, "sidecar_outdated")
  385. if response.status_code >= 400:
  386. logger.warning(
  387. "Slicer sidecar /profiles/resolve returned %s: %s",
  388. response.status_code,
  389. _format_sidecar_error(response),
  390. )
  391. return ResolvedProfile(None, "sidecar_unavailable")
  392. body = response.json()
  393. resolved = body.get("profile") if isinstance(body, dict) else None
  394. if not isinstance(resolved, dict):
  395. logger.warning("Slicer sidecar /profiles/resolve returned no profile object")
  396. return ResolvedProfile(None, "sidecar_unavailable")
  397. return ResolvedProfile(resolved, "ok")
  398. async def list_bundled_profiles(self) -> dict:
  399. """GET /profiles/bundled — return the slicer's stock profiles by slot.
  400. Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
  401. The sidecar walks the slicer's read-only `resources/profiles/BBL/`
  402. tree and returns ``{printer, process, filament}`` arrays of
  403. ``{name, base_id}`` (alphabetised, instantiable presets only — abstract
  404. bases like `fdm_filament_pla` are filtered out by the sidecar).
  405. Returns an empty-shaped dict when the sidecar is unreachable so the
  406. unified-presets endpoint can degrade to "no standard tier" without
  407. crashing the modal — cloud + local-imported profiles still render.
  408. """
  409. try:
  410. response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
  411. except httpx.RequestError as exc:
  412. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
  413. if response.status_code >= 400:
  414. raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
  415. return response.json()
  416. async def _poll_progress(
  417. self,
  418. request_id: str,
  419. on_progress: Callable[[dict], None],
  420. *,
  421. liveness: "_Liveness | None" = None,
  422. ) -> None:
  423. """Poll the sidecar's progress endpoint at ~1Hz and forward each
  424. snapshot to ``on_progress``. Runs until cancelled.
  425. 4xx is NOT treated as terminal: the FIRST poll fires the moment
  426. the slice POST is sent, which can be milliseconds before the
  427. request actually lands on the sidecar and `progressStore.start()`
  428. runs — so a fresh request legitimately returns 404 for the first
  429. tick or two. Bailing on the first 404 (the original implementation)
  430. meant we'd quit before progress could ever arrive. The polling
  431. task is cancelled by the outer slice request anyway, so a
  432. sustained 404 (older sidecar without progress support, or post-
  433. slice grace expiry) just costs a few wasted GETs that the cancel
  434. will stop. Network errors and non-JSON 5xx are swallowed; the
  435. next tick retries.
  436. When ``liveness`` is supplied this doubles as the stall watchdog: every
  437. 200 carrying a *changed* payload marks the slicer alive, which is what
  438. keeps the slice's deadline moving (#2730). An unchanged payload
  439. deliberately does not count — the sidecar re-serves its last snapshot on
  440. every poll, so treating a repeat as progress would leave the watchdog
  441. unable to detect a stall at all.
  442. """
  443. url = f"{self.base_url}/slice/progress/{request_id}"
  444. last_payload: dict | None = None
  445. while True:
  446. try:
  447. response = await self._client.get(url, timeout=5.0)
  448. if response.status_code == 200:
  449. payload = response.json()
  450. if isinstance(payload, dict):
  451. if liveness is not None:
  452. liveness.saw_progress_endpoint()
  453. if payload != last_payload:
  454. liveness.mark_alive()
  455. last_payload = payload
  456. on_progress(payload)
  457. # 404 / other 4xx = no progress available (yet, or ever
  458. # for older sidecars). Keep polling — the outer slice
  459. # request will cancel this task on completion.
  460. except (httpx.RequestError, ValueError):
  461. # ValueError covers JSONDecodeError when the sidecar
  462. # returns a non-JSON 5xx. Don't crash the poller.
  463. pass
  464. try:
  465. await asyncio.sleep(self.progress_poll_interval)
  466. except asyncio.CancelledError:
  467. return
  468. async def _post_slice(
  469. self,
  470. *,
  471. files: list | dict,
  472. data: dict,
  473. request_id: str | None,
  474. on_progress: Callable[[dict], None] | None,
  475. ) -> httpx.Response:
  476. """POST /slice, supervised by the progress channel rather than a clock.
  477. Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
  478. every phase. A genuinely heavy model — the reporter's was a MakerWorld
  479. model that Bambu Studio also took a long time over — hit the ceiling
  480. while it was still slicing perfectly happily, and because
  481. ``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
  482. sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
  483. progress endpoint once a second and could see the thing working.
  484. So the read timeout comes off the HTTP call and the poller supervises
  485. instead: the deadline is pushed forward by every progress update, and
  486. only a genuine silence ends the wait. Connect and pool keep short
  487. timeouts — a sidecar that won't accept the connection at all is
  488. unreachable, and should still say so quickly.
  489. """
  490. liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
  491. # Poll whenever we have a request_id, even if the caller wants no
  492. # progress callbacks: the poll is what makes stall detection possible,
  493. # and one GET per second is cheaper than a wrongly-cancelled slice.
  494. progress_task: asyncio.Task | None = None
  495. if request_id is not None:
  496. progress_task = asyncio.create_task(
  497. self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
  498. name=f"slicer-progress-{request_id}",
  499. )
  500. post_task = asyncio.create_task(
  501. self._client.post(
  502. f"{self.base_url}/slice",
  503. files=files,
  504. data=data,
  505. timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
  506. ),
  507. name="slicer-slice-post",
  508. )
  509. try:
  510. while True:
  511. remaining = liveness.deadline - time.monotonic()
  512. if remaining <= 0:
  513. post_task.cancel()
  514. logger.warning(
  515. "Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
  516. liveness.elapsed(),
  517. liveness.silent_for(),
  518. "available" if liveness.progress_supported else "unavailable",
  519. )
  520. raise SlicerTimeoutError(liveness.timeout_message())
  521. # Re-check at poll granularity so a progress update that lands
  522. # mid-wait extends the deadline promptly.
  523. done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
  524. if post_task in done:
  525. break
  526. finally:
  527. if progress_task is not None:
  528. progress_task.cancel()
  529. # Await both so neither is left pending — a cancelled POST still
  530. # needs its connection released back to the pool.
  531. await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
  532. try:
  533. return post_task.result()
  534. except httpx.RequestError as exc:
  535. raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
  536. async def slice_with_profiles(
  537. self,
  538. *,
  539. model_bytes: bytes,
  540. model_filename: str,
  541. printer_profile_json: str,
  542. process_profile_json: str,
  543. filament_profile_jsons: list[str],
  544. plate: int | None = None,
  545. export_3mf: bool = False,
  546. arrange: bool = False,
  547. orient: bool = False,
  548. request_id: str | None = None,
  549. on_progress: Callable[[dict], None] | None = None,
  550. ) -> SliceResult:
  551. """POST /slice with model + printer/process/filament profiles.
  552. ``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
  553. profile for slot 1, etc. Single-color callers pass a one-element
  554. list. Multiple ``filamentProfile`` parts are sent as a repeated form
  555. field — the sidecar's route declares ``maxCount: 16`` and the
  556. slicing service joins them as semicolon-separated
  557. ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
  558. ``arrange`` forwards the sidecar's ``--arrange`` flag to BambuStudio.
  559. When True the slicer auto-repositions objects on the target bed,
  560. which Bambuddy uses for cross-nozzle-class re-slices (#1493) where
  561. the source's X1C-coordinate layout would otherwise drop into an H2D
  562. dead zone or trigger the multi-extruder geometry pipeline's polygon
  563. clipping crash. Default off so single-printer slices preserve the
  564. user's deliberate layout. Also settable per-slice by the user
  565. (#2548).
  566. ``orient`` forwards ``--orient``, the CLI's auto-orientation pass:
  567. the slicer scores candidate rotations (overhang area, contour,
  568. unprintability) and rotates each object onto the best one before
  569. slicing. User-driven only — nothing in Bambuddy turns it on by
  570. itself, since rotating a deliberately-laid-out model is not a
  571. change to make silently.
  572. ``request_id``: when supplied, the sidecar wires --pipe to a
  573. per-request FIFO and publishes structured JSON progress events to
  574. its in-memory ProgressStore under this id. Bambuddy's slice
  575. dispatch polls ``GET /slice/progress/{request_id}`` in parallel
  576. to drive the live-progress toast.
  577. Raises:
  578. SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
  579. SlicerApiUnavailableError: connection error or 5xx from sidecar.
  580. """
  581. # httpx supports repeated multipart fields when files is a list of
  582. # tuples — using the dict form would silently overwrite duplicate
  583. # keys and ship only the last filament profile.
  584. files: list[tuple[str, tuple[str, bytes, str]]] = [
  585. ("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
  586. ("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
  587. ("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
  588. ]
  589. for idx, fjson in enumerate(filament_profile_jsons):
  590. files.append(
  591. (
  592. "filamentProfile",
  593. (f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
  594. )
  595. )
  596. data: dict[str, str] = {}
  597. if plate is not None:
  598. data["plate"] = str(plate)
  599. if export_3mf:
  600. data["exportType"] = "3mf"
  601. _add_layout_flags(data, arrange=arrange, orient=orient)
  602. if request_id is not None:
  603. data["requestId"] = request_id
  604. # When the caller supplied a request_id, kick off a parallel
  605. # poller that reads the sidecar's --pipe-fed progress endpoint
  606. # and surfaces structured updates via on_progress. Uses a
  607. # short-tick poll (1s) since the slicer emits stage changes
  608. # several times per minute on complex models.
  609. _log_slice_request(model_filename, model_bytes, plate=plate, profiles=len(filament_profile_jsons) + 2)
  610. response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
  611. return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
  612. async def slice_without_profiles(
  613. self,
  614. *,
  615. model_bytes: bytes,
  616. model_filename: str,
  617. plate: int | None = None,
  618. export_3mf: bool = False,
  619. arrange: bool = False,
  620. orient: bool = False,
  621. request_id: str | None = None,
  622. on_progress: Callable[[dict], None] | None = None,
  623. ) -> SliceResult:
  624. """POST /slice with only the model file and no profile triplet.
  625. For 3MF inputs this lets the slicer fall back on the file's embedded
  626. `Metadata/project_settings.config`. Used as a fallback when
  627. `slice_with_profiles` triggers a CLI segfault or other 5xx —
  628. complex H2D / multi-extruder models hit upstream bugs in both the
  629. OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
  630. Also used by the SliceModal's per-plate filament discovery path:
  631. for an unsliced project file we run a real preview slice via the
  632. sidecar to find which AMS slots the picked plate consumes. The
  633. ``request_id`` parameter routes the sidecar's --pipe progress
  634. events to the ProgressStore so the modal's inline spinner +
  635. toast can show "Generating G-code (75%)" for that preview as
  636. well.
  637. ``arrange`` / ``orient`` mean the same as on
  638. ``slice_with_profiles``: they are CLI actions applied to the loaded
  639. geometry, independent of where the print config came from. Both
  640. paths accept them so a user's per-slice choice survives the
  641. embedded-settings route and the segfault fallback — the filament-
  642. discovery preview leaves them off, since moving objects there
  643. would change nothing about which slots the plate consumes.
  644. """
  645. files = {
  646. "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
  647. }
  648. data: dict[str, str] = {}
  649. if plate is not None:
  650. data["plate"] = str(plate)
  651. if export_3mf:
  652. data["exportType"] = "3mf"
  653. _add_layout_flags(data, arrange=arrange, orient=orient)
  654. if request_id is not None:
  655. data["requestId"] = request_id
  656. # Same progress-poller wiring as slice_with_profiles. Used by the
  657. # SliceModal's preview slice (for filament discovery) AND the
  658. # embedded-settings fallback path triggered by an Orca/Bambu CLI
  659. # segfault on complex H2D models — both want to keep updating
  660. # the user's toast through the slow operation.
  661. _log_slice_request(model_filename, model_bytes, plate=plate, profiles=0)
  662. response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
  663. return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
  664. def _log_slice_request(filename: str, model_bytes: bytes, *, plate: int | None, profiles: int) -> None:
  665. """Record what is being sent to the sidecar, size included.
  666. Nothing used to log the payload size, so a support package from a slice
  667. that failed on an upload cap looked identical to one that failed on a bad
  668. profile — #2802 had to be sized by probing a sidecar by hand. One line per
  669. slice is cheap next to the operation it describes.
  670. """
  671. logger.info(
  672. "Slicing %s (%.1f MB) plate=%s with %d profile(s)",
  673. filename,
  674. len(model_bytes) / (1024 * 1024),
  675. "all" if plate is None else plate,
  676. profiles,
  677. )
  678. def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
  679. """Set the sidecar's ``arrange`` / ``orient`` form fields, but only when on.
  680. The sidecar branches on ``settings.arrange !== undefined`` and forwards
  681. ``--arrange 1`` / ``--arrange 0`` accordingly — but multipart fields
  682. arrive as *strings*, and ``"false"`` is truthy in JavaScript. Sending
  683. ``"false"`` would therefore turn the flag ON. So an off flag is
  684. expressed by omitting the field entirely, which also keeps the wire
  685. payload of default-off callers byte-identical to before these
  686. parameters existed.
  687. """
  688. if arrange:
  689. data["arrange"] = "true"
  690. if orient:
  691. data["orient"] = "true"
  692. def _safe_int(value: str | None) -> int:
  693. if not value:
  694. return 0
  695. try:
  696. return int(float(value))
  697. except (TypeError, ValueError):
  698. return 0
  699. def _safe_float(value: str | None) -> float:
  700. if not value:
  701. return 0.0
  702. try:
  703. return float(value)
  704. except (TypeError, ValueError):
  705. return 0.0