fix(cloud): send required ?version= param on singular GET/DELETE of slicer setting endpoint (#1815)

get_setting_detail and delete_setting were hitting
/v1/iot-service/api/slicer/setting/{id} without the version query
parameter Bambu Cloud requires — every call returned HTTP 400
"field 'version' is not set". The sibling plural GET
(get_slicer_settings) has always sent it; the comment above
_SLICER_API_VERSION documents the contract for the endpoint subtree.
Missed when the placeholder landed in the 2026-05-12 compliance rework.

Downstream effect: slicer_filament_resolver.resolve_slicer_filament's
PFUS branch swallowed the 400, fell through to normalize_slicer_filament,
and caller inventory.py generic-material-fell-back tray_info_idx to
GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx
echo, so the user saw "Generic PLA" instead of the custom cloud preset.

Masked for 50 days by two rescue paths in the caller: prior-slot
tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles
realign. Reporter's spool 54 → tray 2 assign had neither.

Adjacent surfaces also fixed by the same two-line change: the delete
cloud preset UI route, the whole update_setting flow (get_setting_detail
→ delete_setting → POST), preset_resolver's cloud branch, and three
UI-facing cloud.py routes that fetch setting detail.

get_setting_detail also includes the truncated response body in the
raised BambuCloudError so the next contract change is self-diagnostic
from support-bundle logs.
This commit is contained in:
maziggy
2026-07-01 08:37:31 +02:00
parent 6b981e4991
commit e33ab07a93
3 changed files with 108 additions and 11 deletions
+2
View File
File diff suppressed because one or more lines are too long
+18 -11
View File
@@ -66,14 +66,15 @@ def _detect_cloudflare_challenge(response) -> str | None:
return None
# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
# API returns HTTP 400 "field 'version' is not set"; non-matching formats like
# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's
# server accepts ANY value within that format — it doesn't validate against a
# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does
# not impersonate any real Bambu Studio release. Our client identity is in the
# User-Agent header.
# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
# for the list, the singular GET/DELETE for a specific preset by setting_id, and
# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
# format Bambu Studio releases use. Without it the API returns HTTP 400
# "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return
# HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value
# within that format — it doesn't validate against a release manifest. We
# therefore use a neutral "1.0.0.0" placeholder that does not impersonate any
# real Bambu Studio release. Our client identity is in the User-Agent header.
_SLICER_API_VERSION = "1.0.0.0"
@@ -397,13 +398,17 @@ class BambuCloudService:
try:
response = await self._client.get(
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
headers=self._get_headers(),
params={"version": _SLICER_API_VERSION},
)
if response.status_code == 200:
return response.json()
raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
# Include body so a future contract change is self-diagnostic from logs.
body = (response.text or "")[:200]
raise BambuCloudError(f"Failed to get setting detail: {response.status_code} {body}")
except httpx.RequestError as e:
raise BambuCloudError(f"Request failed: {e}")
@@ -557,7 +562,9 @@ class BambuCloudService:
try:
response = await self._client.delete(
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
headers=self._get_headers(),
params={"version": _SLICER_API_VERSION},
)
if response.status_code in (200, 204):
@@ -437,3 +437,91 @@ class TestCloudflareChallengeDetection:
assert result["success"] is False
assert result["needs_verification"] is False
assert "Cloudflare" in result["message"]
# ===========================================================================
# Issue #1815: PFUS cloud user preset lookup silently 400s in resolver
# ===========================================================================
class TestSlicerSettingVersionParam:
"""`/v1/iot-service/api/slicer/setting` endpoints require ?version=XX.YY.ZZ.WW.
The plural GET (`get_slicer_settings`) has always sent it. The singular
GET (`get_setting_detail`) and DELETE (`delete_setting`) hit the same
subtree and were silently omitting it since #1013's compliance rework
(2026-05-12), which surfaced as #1815: every PFUS-prefix cloud user preset
lookup in the slicer_filament_resolver 400'd, so BambuStudio saw the
generic-material fallback instead of the user's actual custom profile
(rescued in most cases by slot-tray_info_idx reuse or K-profile realign,
Bgabor997's spool 54 had neither).
"""
def _auth(self) -> BambuCloudService:
cloud = BambuCloudService()
cloud.access_token = "test-token"
return cloud
@pytest.mark.asyncio
async def test_get_setting_detail_sends_version_param(self):
"""`get_setting_detail` must include the version query param — without
it Bambu Cloud returns HTTP 400 'field version is not set'."""
cloud = self._auth()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"filament_id": "P4d64437", "name": "Overture Matte PLA"}
with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
result = await cloud.get_setting_detail("PFUS992454068158eb")
assert result["filament_id"] == "P4d64437"
url = mock_get.call_args[0][0]
assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
params = mock_get.call_args.kwargs.get("params") or {}
assert params.get("version"), "get_setting_detail must send ?version=… to avoid 400"
@pytest.mark.asyncio
async def test_get_setting_detail_error_includes_response_body(self):
"""The 400 body identifies the exact contract violation. Callers include
it in log warnings so a next contract change is self-diagnostic instead
of surfacing an opaque status code (which cost 50 days on #1815)."""
cloud = self._auth()
from backend.app.services.bambu_cloud import BambuCloudError
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "field 'version' is not set"
with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
with pytest.raises(BambuCloudError) as exc:
await cloud.get_setting_detail("PFUS992454068158eb")
assert "400" in str(exc.value)
assert "field 'version'" in str(exc.value)
@pytest.mark.asyncio
async def test_delete_setting_sends_version_param(self):
"""`delete_setting` hits the same subtree; same requirement applies."""
cloud = self._auth()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"{}"
mock_response.json.return_value = {}
with patch.object(cloud._client, "delete", new_callable=AsyncMock) as mock_delete:
mock_delete.return_value = mock_response
result = await cloud.delete_setting("PFUS992454068158eb")
assert result["success"] is True
url = mock_delete.call_args[0][0]
assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
params = mock_delete.call_args.kwargs.get("params") or {}
assert params.get("version"), "delete_setting must send ?version=… to avoid 400"