mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
strips system processes/filaments, so importing a bundle left users without
process presets and slicing fell back to embedded settings on STL. Bundle
mode also hid the standard tier behind a constrained dropdown, the actual
trap reported here.
Removed end-to-end:
- backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
SliceBundleSpec, dispatch fork in library.py, bundle-context params on
the filament-requirements endpoints, bundle-fingerprint cache key in
slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
slice_with_bundle, BundleSummary / BundleNotFoundError.
- frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
loses its bundle path; presetCompatibility keeps compatible_printers
plus the @BBL fallback.
- SlicerBundlesPanel turns into a permanent static notice explaining the
removal, alternative import paths, and the new slice-time lookup order
(Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
- i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
translated across all 11 locales; slice.bundle*, slicerBundles.* keys
removed.
Fixed (surfaced by removing bundle mode):
- _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
from: "system" on the payload before json.dumps. Bambu Cloud ships
type as "printer"/"print" and routinely empty `from`; the BS CLI's
--load-settings parser rejects both with return -5 / "input preset
file invalid". Standard tier already did this; cloud paths now match.
404 lines
16 KiB
Python
404 lines
16 KiB
Python
"""HTTP client for an OrcaSlicer / BambuStudio API sidecar.
|
|
|
|
Bambuddy stores user printer/process/filament profiles itself (cloud-synced
|
|
or locally imported), so the slice flow always sends the model file plus an
|
|
explicit JSON profile triplet to the sidecar's `/slice` endpoint. The sidecar
|
|
shape mirrors `AFKFelix/orca-slicer-api` (multipart upload, `--load-settings`
|
|
under the hood, response body is raw G-code or 3MF with metadata in the
|
|
`X-Print-Time-Seconds` / `X-Filament-Used-G` / `X-Filament-Used-Mm` headers).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Callable
|
|
from typing import NamedTuple
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SlicerApiError(Exception):
|
|
"""Base error from the slicer API sidecar."""
|
|
|
|
|
|
class SlicerApiUnavailableError(SlicerApiError):
|
|
"""Sidecar is unreachable (connection error, no response)."""
|
|
|
|
|
|
class SlicerApiServerError(SlicerApiError):
|
|
"""Sidecar responded with a 5xx — usually the wrapped slicer CLI exited
|
|
non-zero (range-validation reject, segfault on complex models, etc.).
|
|
Distinguished from `SlicerApiUnavailableError` so the caller can decide
|
|
whether to retry with a different request shape (e.g. a 3MF embedded-
|
|
settings fallback)."""
|
|
|
|
|
|
class SlicerInputError(SlicerApiError):
|
|
"""Sidecar rejected the input as invalid (4xx)."""
|
|
|
|
|
|
class SliceResult(NamedTuple):
|
|
"""Result of a slice operation."""
|
|
|
|
content: bytes
|
|
print_time_seconds: int
|
|
filament_used_g: float
|
|
filament_used_mm: float
|
|
|
|
|
|
_shared_http_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def _format_sidecar_error(response: httpx.Response) -> str:
|
|
"""Build a human-readable error string from a sidecar 4xx/5xx response.
|
|
|
|
The sidecar's `AppError` middleware emits a JSON body of the shape
|
|
``{"message": "...", "details": "..."}``. Earlier versions of this
|
|
client only read ``message``, which left every CLI failure surfaced
|
|
as the generic ``Failed to slice the model`` because the *actual*
|
|
CLI stderr / `error_string` lives in ``details``. Including both
|
|
means ``bambuddy.log`` carries the real reason a slice rejected
|
|
the supplied profiles instead of an unhelpful generic line.
|
|
"""
|
|
try:
|
|
payload = response.json()
|
|
except Exception:
|
|
return response.text[:500]
|
|
if not isinstance(payload, dict):
|
|
return str(payload)[:500]
|
|
message = payload.get("message") or ""
|
|
details = payload.get("details") or ""
|
|
if message and details:
|
|
return f"{message}: {details}"[:500]
|
|
return (message or details or response.text)[:500]
|
|
|
|
|
|
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
|
"""Register an app-scoped client so per-request services can pool transport."""
|
|
global _shared_http_client
|
|
_shared_http_client = client
|
|
|
|
|
|
def _guess_model_content_type(filename: str) -> str:
|
|
lower = filename.lower()
|
|
if lower.endswith(".stl"):
|
|
return "model/stl"
|
|
if lower.endswith(".3mf") or lower.endswith(".gcode.3mf"):
|
|
return "model/3mf"
|
|
if lower.endswith(".step") or lower.endswith(".stp"):
|
|
return "model/step"
|
|
return "application/octet-stream"
|
|
|
|
|
|
class SlicerApiService:
|
|
"""Talks to an OrcaSlicer / BambuStudio API sidecar."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
*,
|
|
client: httpx.AsyncClient | None = None,
|
|
timeout_seconds: float = 300.0,
|
|
) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout_seconds = timeout_seconds
|
|
if client is not None:
|
|
self._client = client
|
|
self._owns_client = False
|
|
elif _shared_http_client is not None:
|
|
self._client = _shared_http_client
|
|
self._owns_client = False
|
|
else:
|
|
self._client = httpx.AsyncClient(timeout=timeout_seconds)
|
|
self._owns_client = True
|
|
|
|
async def close(self) -> None:
|
|
if self._owns_client:
|
|
await self._client.aclose()
|
|
|
|
async def __aenter__(self) -> "SlicerApiService":
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
await self.close()
|
|
|
|
async def health(self) -> dict:
|
|
"""GET /health — used to surface a clear "sidecar offline" error before
|
|
accepting a slice request from the user."""
|
|
try:
|
|
response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
|
|
except httpx.RequestError as exc:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
|
|
return response.json()
|
|
|
|
async def list_bundled_profiles(self) -> dict:
|
|
"""GET /profiles/bundled — return the slicer's stock profiles by slot.
|
|
|
|
Powers the "Standard" tier of Bambuddy's SliceModal preset dropdowns.
|
|
The sidecar walks the slicer's read-only `resources/profiles/BBL/`
|
|
tree and returns ``{printer, process, filament}`` arrays of
|
|
``{name, base_id}`` (alphabetised, instantiable presets only — abstract
|
|
bases like `fdm_filament_pla` are filtered out by the sidecar).
|
|
|
|
Returns an empty-shaped dict when the sidecar is unreachable so the
|
|
unified-presets endpoint can degrade to "no standard tier" without
|
|
crashing the modal — cloud + local-imported profiles still render.
|
|
"""
|
|
try:
|
|
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
|
|
except httpx.RequestError as exc:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
|
|
return response.json()
|
|
|
|
async def _poll_progress(
|
|
self,
|
|
request_id: str,
|
|
on_progress: Callable[[dict], None],
|
|
) -> None:
|
|
"""Poll the sidecar's progress endpoint at ~1Hz and forward each
|
|
snapshot to ``on_progress``. Runs until cancelled.
|
|
|
|
4xx is NOT treated as terminal: the FIRST poll fires the moment
|
|
the slice POST is sent, which can be milliseconds before the
|
|
request actually lands on the sidecar and `progressStore.start()`
|
|
runs — so a fresh request legitimately returns 404 for the first
|
|
tick or two. Bailing on the first 404 (the original implementation)
|
|
meant we'd quit before progress could ever arrive. The polling
|
|
task is cancelled by the outer slice request anyway, so a
|
|
sustained 404 (older sidecar without progress support, or post-
|
|
slice grace expiry) just costs a few wasted GETs that the cancel
|
|
will stop. Network errors and non-JSON 5xx are swallowed; the
|
|
next tick retries.
|
|
"""
|
|
url = f"{self.base_url}/slice/progress/{request_id}"
|
|
while True:
|
|
try:
|
|
response = await self._client.get(url, timeout=5.0)
|
|
if response.status_code == 200:
|
|
payload = response.json()
|
|
if isinstance(payload, dict):
|
|
on_progress(payload)
|
|
# 404 / other 4xx = no progress available (yet, or ever
|
|
# for older sidecars). Keep polling — the outer slice
|
|
# request will cancel this task on completion.
|
|
except (httpx.RequestError, ValueError):
|
|
# ValueError covers JSONDecodeError when the sidecar
|
|
# returns a non-JSON 5xx. Don't crash the poller.
|
|
pass
|
|
try:
|
|
await asyncio.sleep(1.0)
|
|
except asyncio.CancelledError:
|
|
return
|
|
|
|
async def slice_with_profiles(
|
|
self,
|
|
*,
|
|
model_bytes: bytes,
|
|
model_filename: str,
|
|
printer_profile_json: str,
|
|
process_profile_json: str,
|
|
filament_profile_jsons: list[str],
|
|
plate: int | None = None,
|
|
export_3mf: bool = False,
|
|
arrange: bool = False,
|
|
request_id: str | None = None,
|
|
on_progress: Callable[[dict], None] | None = None,
|
|
) -> SliceResult:
|
|
"""POST /slice with model + printer/process/filament profiles.
|
|
|
|
``filament_profile_jsons`` is plate-slot-ordered: index 0 is the
|
|
profile for slot 1, etc. Single-color callers pass a one-element
|
|
list. Multiple ``filamentProfile`` parts are sent as a repeated form
|
|
field — the sidecar's route declares ``maxCount: 16`` and the
|
|
slicing service joins them as semicolon-separated
|
|
``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
|
|
|
|
``arrange`` forwards the sidecar's ``--arrange`` flag to BambuStudio.
|
|
When True the slicer auto-repositions objects on the target bed,
|
|
which Bambuddy uses for cross-nozzle-class re-slices (#1493) where
|
|
the source's X1C-coordinate layout would otherwise drop into an H2D
|
|
dead zone or trigger the multi-extruder geometry pipeline's polygon
|
|
clipping crash. Default off so single-printer slices preserve the
|
|
user's deliberate layout.
|
|
|
|
``request_id``: when supplied, the sidecar wires --pipe to a
|
|
per-request FIFO and publishes structured JSON progress events to
|
|
its in-memory ProgressStore under this id. Bambuddy's slice
|
|
dispatch polls ``GET /slice/progress/{request_id}`` in parallel
|
|
to drive the live-progress toast.
|
|
|
|
Raises:
|
|
SlicerInputError: 4xx from sidecar (caller-supplied input is bad).
|
|
SlicerApiUnavailableError: connection error or 5xx from sidecar.
|
|
"""
|
|
# httpx supports repeated multipart fields when files is a list of
|
|
# tuples — using the dict form would silently overwrite duplicate
|
|
# keys and ship only the last filament profile.
|
|
files: list[tuple[str, tuple[str, bytes, str]]] = [
|
|
("file", (model_filename, model_bytes, _guess_model_content_type(model_filename))),
|
|
("printerProfile", ("printer.json", printer_profile_json.encode("utf-8"), "application/json")),
|
|
("presetProfile", ("preset.json", process_profile_json.encode("utf-8"), "application/json")),
|
|
]
|
|
for idx, fjson in enumerate(filament_profile_jsons):
|
|
files.append(
|
|
(
|
|
"filamentProfile",
|
|
(f"filament_{idx + 1}.json", fjson.encode("utf-8"), "application/json"),
|
|
)
|
|
)
|
|
|
|
data: dict[str, str] = {}
|
|
if plate is not None:
|
|
data["plate"] = str(plate)
|
|
if export_3mf:
|
|
data["exportType"] = "3mf"
|
|
if arrange:
|
|
# Sidecar reads non-empty truthy strings as True; only send the
|
|
# field when we want the flag on, so default-off callers exactly
|
|
# match the previous wire payload.
|
|
data["arrange"] = "true"
|
|
if request_id is not None:
|
|
data["requestId"] = request_id
|
|
|
|
# When the caller supplied a request_id, kick off a parallel
|
|
# poller that reads the sidecar's --pipe-fed progress endpoint
|
|
# and surfaces structured updates via on_progress. Uses a
|
|
# short-tick poll (1s) since the slicer emits stage changes
|
|
# several times per minute on complex models.
|
|
progress_task: asyncio.Task | None = None
|
|
if request_id is not None and on_progress is not None:
|
|
progress_task = asyncio.create_task(
|
|
self._poll_progress(request_id, on_progress),
|
|
name=f"slicer-progress-{request_id}",
|
|
)
|
|
|
|
try:
|
|
response = await self._client.post(
|
|
f"{self.base_url}/slice",
|
|
files=files,
|
|
data=data,
|
|
timeout=self.timeout_seconds,
|
|
)
|
|
except httpx.RequestError as exc:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
finally:
|
|
if progress_task is not None:
|
|
progress_task.cancel()
|
|
try:
|
|
await progress_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass # Polling errors must not fail the slice.
|
|
|
|
if response.status_code >= 500:
|
|
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
|
|
if response.status_code >= 400:
|
|
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
|
|
|
|
return SliceResult(
|
|
content=response.content,
|
|
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
|
|
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
|
|
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
|
|
)
|
|
|
|
async def slice_without_profiles(
|
|
self,
|
|
*,
|
|
model_bytes: bytes,
|
|
model_filename: str,
|
|
plate: int | None = None,
|
|
export_3mf: bool = False,
|
|
request_id: str | None = None,
|
|
on_progress: Callable[[dict], None] | None = None,
|
|
) -> SliceResult:
|
|
"""POST /slice with only the model file and no profile triplet.
|
|
|
|
For 3MF inputs this lets the slicer fall back on the file's embedded
|
|
`Metadata/project_settings.config`. Used as a fallback when
|
|
`slice_with_profiles` triggers a CLI segfault or other 5xx —
|
|
complex H2D / multi-extruder models hit upstream bugs in both the
|
|
OrcaSlicer and BambuStudio CLIs when invoked via `--load-settings`.
|
|
|
|
Also used by the SliceModal's per-plate filament discovery path:
|
|
for an unsliced project file we run a real preview slice via the
|
|
sidecar to find which AMS slots the picked plate consumes. The
|
|
``request_id`` parameter routes the sidecar's --pipe progress
|
|
events to the ProgressStore so the modal's inline spinner +
|
|
toast can show "Generating G-code (75%)" for that preview as
|
|
well.
|
|
"""
|
|
files = {
|
|
"file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
|
|
}
|
|
data: dict[str, str] = {}
|
|
if plate is not None:
|
|
data["plate"] = str(plate)
|
|
if export_3mf:
|
|
data["exportType"] = "3mf"
|
|
if request_id is not None:
|
|
data["requestId"] = request_id
|
|
|
|
# Same progress-poller wiring as slice_with_profiles. Used by the
|
|
# SliceModal's preview slice (for filament discovery) AND the
|
|
# embedded-settings fallback path triggered by an Orca/Bambu CLI
|
|
# segfault on complex H2D models — both want to keep updating
|
|
# the user's toast through the slow operation.
|
|
progress_task: asyncio.Task | None = None
|
|
if request_id is not None and on_progress is not None:
|
|
progress_task = asyncio.create_task(
|
|
self._poll_progress(request_id, on_progress),
|
|
name=f"slicer-progress-{request_id}",
|
|
)
|
|
|
|
try:
|
|
response = await self._client.post(
|
|
f"{self.base_url}/slice",
|
|
files=files,
|
|
data=data,
|
|
timeout=self.timeout_seconds,
|
|
)
|
|
except httpx.RequestError as exc:
|
|
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
|
finally:
|
|
if progress_task is not None:
|
|
progress_task.cancel()
|
|
try:
|
|
await progress_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
|
|
if response.status_code >= 500:
|
|
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
|
|
if response.status_code >= 400:
|
|
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
|
|
|
|
return SliceResult(
|
|
content=response.content,
|
|
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
|
|
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
|
|
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
|
|
)
|
|
|
|
|
|
def _safe_int(value: str | None) -> int:
|
|
if not value:
|
|
return 0
|
|
try:
|
|
return int(float(value))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _safe_float(value: str | None) -> float:
|
|
if not value:
|
|
return 0.0
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|