Browse Source

fix(ams): keep PFUS preset id when cloud filament_id is null (#1053)

  Bambu Cloud returns filament_id=null for user presets that only override
  fields of a generic base (e.g. "Sting3D ABS" inheriting from
  "Generic ABS @BBL H2D"). ConfigureAmsSlotModal fell back to
  convertToTrayInfoIdx(base_id), which strips "S" and the version suffix
  from "GFSB99_07" to "GFB99" — Generic ABS's filament_id. The printer
  accepted and echoed back GFB99, so OrcaSlicer / BambuStudio Sync
  Filaments resolved the slot to "Generic ABS" and the custom preset
  never appeared on the printer LCD.

  The preceding default already set tray_info_idx to the PFUS*/PFSP*
  setting_id unchanged, and the rest of the stack round-trips that
  format (configure_ams_slot, inventory Assign Spool, and print
  scheduler slot-matching on P* short-form IDs). The base_id branch
  overwrote the correct default.

  Remove the base_id fallback. When cloud detail returns a distinct
  filament_id we still prefer it; otherwise the setting_id default
  stands. BambuStudio Sync now resolves the custom preset cleanly.
  OrcaSlicer falls back to the inherited generic because OrcaSlicer
  user-preset JSONs don't carry a filament_id field — that is an
  OrcaSlicer limitation and behaviour is strictly not worse than before.

  Regression tests (frontend):
    - filament_id=null keeps PFUS* as tray_info_idx
    - concrete filament_id wins over the default
    - GFS* path skips the cloud-detail fetch entirely
    - fetch failure degrades gracefully to the PFUS* default

  Regression tests (backend):
    - test_configure_pfus_preserves_setting_id_pair: HT slot endpoint
      forwards both tray_info_idx=PFUS… and setting_id=PFUS… untouched

  Thanks to @mrnoisytiger for the browser-console / network / backend-log
  data that isolated the fallback path and the OrcaSlicer preset JSON
  that showed the missing filament_id field.
maziggy 1 month ago
parent
commit
28f80f948a

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 44 - 0
backend/tests/integration/test_printers_api.py

@@ -803,6 +803,50 @@ class TestConfigureAMSSlotAPI:
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
             assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
+        """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
+
+        Pins the end-to-end contract the frontend #1053 fix relies on: when the
+        user configures a slot with a custom cloud preset whose cloud detail
+        has filament_id=null, the frontend sends the setting_id in BOTH fields
+        and the backend must not collapse either to a generic GF* ID.
+        """
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/128/0/configure",
+                params={
+                    "tray_info_idx": "PFUSa8fb76f9733e3c",
+                    "tray_type": "ABS",
+                    "tray_sub_brands": "Sting3D ABS",
+                    "tray_color": "000000FF",
+                    "nozzle_temp_min": 240,
+                    "nozzle_temp_max": 280,
+                    "setting_id": "PFUSa8fb76f9733e3c",
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
+            assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
+            # Explicitly assert no generic-collapse happened for this HT slot.
+            assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
+
 
 
 class TestSkipObjectsAPI:
 class TestSkipObjectsAPI:
     """Integration tests for skip objects endpoints."""
     """Integration tests for skip objects endpoints."""

+ 104 - 9
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -185,24 +185,119 @@ describe('ConfigureAmsSlotModal', () => {
     expect(colorInput).toHaveValue('Red');
     expect(colorInput).toHaveValue('Red');
   });
   });
 
 
-  it('derives tray_info_idx from base_id when filament_id is null', async () => {
-    // Mock the detail API to return base_id but no filament_id
+  it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', async () => {
+    // Cloud returns a user preset that inherits from a generic Bambu base and
+    // has no distinct filament_id of its own — this is how Bambu Cloud responds
+    // for custom presets built on top of "Generic ABS @BBL H2D" etc.
     (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
     (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
       filament_id: null,
       filament_id: null,
-      base_id: 'GFSL05_09',
+      base_id: 'GFSB99_07',
       name: '# Overture Matte PLA @BBL H2D',
       name: '# Overture Matte PLA @BBL H2D',
     });
     });
 
 
-    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    // Before the fix, this collapsed to 'GFB99' (Generic ABS's filament_id),
+    // which made OrcaSlicer/BambuStudio Sync Filaments resolve to "Generic ABS".
+    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
+  });
+
+  it('uses cloud detail filament_id when present', async () => {
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament_id: 'P285e239',
+      base_id: 'GFSB99_07',
+      name: '# Overture Matte PLA @BBL H2D',
+    });
+
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('P285e239');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
+  });
+
+  it('sends short GF filament_id for Bambu GFS* presets (cloud detail not consulted)', async () => {
+    // Bambu-provided presets (GFS*) convert the setting_id → filament_id locally.
+    // The cloud detail endpoint must NOT be consulted for them; the rewrite that
+    // fixed #1053 preserves this pre-existing shortcut.
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('GFL05');
+    expect(payload.setting_id).toBe('GFSL05_09');
+    expect(api.getCloudSettingDetail).not.toHaveBeenCalled();
+  });
+
+  it('keeps default PFUS tray_info_idx when cloud detail fetch fails', async () => {
+    // Network/5xx from /cloud/settings/{id} must not abort the configure flow
+    // nor leave tray_info_idx empty — we fall back to the setting_id default.
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(
+      new Error('cloud unreachable')
+    );
+
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
 
 
-    // Wait for presets to load
     await waitFor(() => {
     await waitFor(() => {
-      expect(api.getCloudSettings).toHaveBeenCalled();
+      expect(api.configureAmsSlot).toHaveBeenCalled();
     });
     });
 
 
-    // Select a user preset (one without filament_id)
-    // Find and click the preset - this would require the preset to be in the list
-    // The actual tray_info_idx derivation happens during the configure mutation
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
   });
   });
 
 
   it('renders configure slot button', async () => {
   it('renders configure slot button', async () => {

+ 4 - 5
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -369,19 +369,18 @@ export function ConfigureAmsSlotModal({
         trayInfoIdx = builtinFilamentId!;
         trayInfoIdx = builtinFilamentId!;
         settingId = '';
         settingId = '';
       } else {
       } else {
-        // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
         trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
         trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
         settingId = selectedPresetId;
         settingId = selectedPresetId;
 
 
-        // For user presets (not starting with GF), fetch the detail to get the real filament_id
+        // User cloud presets may carry a distinct filament_id in the cloud detail
+        // (e.g. "P285e239"); prefer it when present. Never fall back to base_id —
+        // that collapses custom presets to the inherited generic's filament_id and
+        // makes the slicer resolve the slot to "Generic …" instead (#1053).
         if (!selectedPresetId.startsWith('GFS')) {
         if (!selectedPresetId.startsWith('GFS')) {
           try {
           try {
             const detail = await api.getCloudSettingDetail(selectedPresetId);
             const detail = await api.getCloudSettingDetail(selectedPresetId);
             if (detail.filament_id) {
             if (detail.filament_id) {
               trayInfoIdx = detail.filament_id;
               trayInfoIdx = detail.filament_id;
-            } else if (detail.base_id) {
-              trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
-              console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
             }
             }
           } catch (e) {
           } catch (e) {
             console.warn('Failed to fetch preset detail for filament_id:', e);
             console.warn('Failed to fetch preset detail for filament_id:', e);

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D-cysdnw.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CASdUlGi.js"></script>
+    <script type="module" crossorigin src="/assets/index-D-cysdnw.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff