Procházet zdrojové kódy

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.
maziggy před 2 měsíci
rodič
revize
e33ab07a93

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 18 - 11
backend/app/services/bambu_cloud.py

@@ -66,14 +66,15 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return 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"
 _SLICER_API_VERSION = "1.0.0.0"
 
 
 
 
@@ -397,13 +398,17 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.get(
             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:
             if response.status_code == 200:
                 return response.json()
                 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:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
             raise BambuCloudError(f"Request failed: {e}")
@@ -557,7 +562,9 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.delete(
             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):
             if response.status_code in (200, 204):

+ 88 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -437,3 +437,91 @@ class TestCloudflareChallengeDetection:
             assert result["success"] is False
             assert result["success"] is False
             assert result["needs_verification"] is False
             assert result["needs_verification"] is False
             assert "Cloudflare" in result["message"]
             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"

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů