Browse Source

Fix AMS slot auto-config falling back to Generic instead of spool's slicer preset

Two bugs caused spool assignments to always configure AMS slots with
generic Bambu filament IDs (e.g. GFB99 "Generic ABS") instead of the
spool's actual slicer preset:

1. PFUS* IDs (cloud-synced custom presets) were blanket-rejected and
   replaced with generic IDs in both assign_spool and configure_ams_slot
2. Generic fallback IDs (GFB99, GFL99, etc.) were treated as "good"
   presets by the slot-reuse logic, making them sticky once set

New priority: spool's own slicer_filament > slot's non-generic preset
(same material) > generic fallback.
maziggy 3 months ago
parent
commit
294bd74940

+ 1 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
 - **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
 - **Finish Photo Not Shown on Archives for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty `file_path`. The finish photo was saved correctly to `data/photos/`, but the photo serving endpoint resolved the path as `(base_dir / "").parent / "photos/"` which evaluates to `base_dir.parent/photos/` — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in `get_photo`, `upload_photo`, and `delete_photo` to use `base_dir / Path(file_path).parent` (same pattern as the save code), which correctly resolves to `base_dir/photos/` when `file_path` is empty.
 - **Finish Photo Not Shown on Archives for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty `file_path`. The finish photo was saved correctly to `data/photos/`, but the photo serving endpoint resolved the path as `(base_dir / "").parent / "photos/"` which evaluates to `base_dir.parent/photos/` — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in `get_photo`, `upload_photo`, and `delete_photo` to use `base_dir / Path(file_path).parent` (same pattern as the save code), which correctly resolves to `base_dir/photos/` when `file_path` is empty.
 - **Archive Endpoints Crash With "Is a directory" for BambuStudio Prints** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with `file_path=""`. The archive endpoints used `Path.exists()` to check if the 3MF file was available, but `settings.base_dir / ""` resolves to the base directory itself — which `exists()` reports as True. Subsequent `ZipFile()` calls then failed with `[Errno 21] Is a directory`. Replaced all `.exists()` checks on archive file paths with `.is_file()` across 15 locations in the archive routes and 1 in the main module. Also added a `file_path` truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.
 - **Archive Endpoints Crash With "Is a directory" for BambuStudio Prints** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with `file_path=""`. The archive endpoints used `Path.exists()` to check if the 3MF file was available, but `settings.base_dir / ""` resolves to the base directory itself — which `exists()` reports as True. Subsequent `ZipFile()` calls then failed with `[Errno 21] Is a directory`. Replaced all `.exists()` checks on archive file paths with `.is_file()` across 15 locations in the archive routes and 1 in the main module. Also added a `file_path` truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.
+- **AMS Slot Auto-Configuration Falls Back to Generic Instead of Spool's Slicer Preset** ([#479](https://github.com/maziggy/bambuddy/issues/479)) — When assigning a spool with a custom slicer preset (e.g., PFUS* cloud-synced profiles from BambuStudio) to an AMS slot, the slot was always configured with a generic Bambu filament ID (e.g., "Generic ABS" / GFB99) instead of the spool's actual preset. Two bugs caused this. First, all PFUS* IDs were blanket-rejected as "user-local IDs unknown to other slicers" and replaced with generic IDs — but PFUS presets are cloud-synced custom profiles that the printer understands. Second, the slot-reuse logic preserved generic fallback IDs (GFB99, GFL99, etc.) as if they were specific presets: once a slot was set to generic, every subsequent same-material assignment reused it, making generic IDs "sticky". Fixed priority order: (1) spool's own `slicer_filament` if set (including PFUS*/P* custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Both `assign_spool` and `configure_ams_slot` code paths are fixed.
 - **ntfy Notifications Fail With "Illegal header value"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP `Message` header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal `\n` in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.
 - **ntfy Notifications Fail With "Illegal header value"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP `Message` header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal `\n` in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.
 - **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
 - **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.

+ 29 - 25
backend/app/api/routes/inventory.py

@@ -733,14 +733,34 @@ async def assign_spool(
 
 
             # Resolve tray_info_idx for the MQTT command.
             # Resolve tray_info_idx for the MQTT command.
             # Priority:
             # Priority:
-            #   1. Reuse the slot's existing tray_info_idx if it's a recognised
-            #      preset (GF*/P*) for the same material — this preserves the
-            #      slicer's K-profile association.
-            #   2. Replace PFUS* (user-local IDs unknown to other slicers) and
-            #      empty IDs with a generic Bambu filament ID.
-            if (
+            #   1. Use the spool's own slicer_filament if set (including
+            #      cloud-synced custom presets like PFUS* / P*).
+            #   2. Reuse the slot's existing tray_info_idx if it's a specific
+            #      (non-generic) preset for the same material.
+            #   3. Fall back to a generic Bambu filament ID.
+            _GENERIC_IDS = {
+                "PLA": "GFL99",
+                "PETG": "GFG99",
+                "ABS": "GFB99",
+                "ASA": "GFB98",
+                "PC": "GFC99",
+                "PA": "GFN99",
+                "NYLON": "GFN99",
+                "TPU": "GFU99",
+                "PVA": "GFS99",
+                "HIPS": "GFS98",
+                "PLA-CF": "GFL98",
+                "PETG-CF": "GFG98",
+                "PA-CF": "GFN98",
+                "PETG HF": "GFG96",
+            }
+            _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
+
+            if tray_info_idx:
+                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
+            elif (
                 current_tray_info_idx
                 current_tray_info_idx
-                and not current_tray_info_idx.startswith("PFUS")
+                and current_tray_info_idx not in _GENERIC_ID_VALUES
                 and fingerprint_type
                 and fingerprint_type
                 and fingerprint_type.upper() == tray_type.upper()
                 and fingerprint_type.upper() == tray_type.upper()
             ):
             ):
@@ -750,27 +770,11 @@ async def assign_spool(
                     tray_type,
                     tray_type,
                 )
                 )
                 tray_info_idx = current_tray_info_idx
                 tray_info_idx = current_tray_info_idx
-            elif (not tray_info_idx or tray_info_idx.startswith("PFUS")) and tray_type:
-                _GENERIC_IDS = {
-                    "PLA": "GFL99",
-                    "PETG": "GFG99",
-                    "ABS": "GFB99",
-                    "ASA": "GFB98",
-                    "PC": "GFC99",
-                    "PA": "GFN99",
-                    "NYLON": "GFN99",
-                    "TPU": "GFU99",
-                    "PVA": "GFS99",
-                    "HIPS": "GFS98",
-                    "PLA-CF": "GFL98",
-                    "PETG-CF": "GFG98",
-                    "PA-CF": "GFN98",
-                    "PETG HF": "GFG96",
-                }
+            elif tray_type:
                 material = tray_type.upper().strip()
                 material = tray_type.upper().strip()
                 generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
                 generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
                 if generic:
                 if generic:
-                    logger.info("Spool assign: replacing %r with generic %r", tray_info_idx, generic)
+                    logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
                     tray_info_idx = generic
                     tray_info_idx = generic
 
 
             # Temperature: use spool overrides if set, else material defaults
             # Temperature: use spool overrides if set, else material defaults

+ 10 - 13
backend/app/api/routes/printers.py

@@ -1667,13 +1667,12 @@ async def configure_ams_slot(
         raise HTTPException(status_code=400, detail="Printer not connected")
         raise HTTPException(status_code=400, detail="Printer not connected")
 
 
     # Resolve tray_info_idx for the MQTT command.
     # Resolve tray_info_idx for the MQTT command.
-    # PFUS* IDs are user-local preset IDs that only the originating slicer
-    # recognises.  When Bambuddy sends them, BambuStudio sees an unknown
-    # filament and actively resets the slot to empty.
     # Priority:
     # Priority:
-    #   1. If the slot already has a recognised preset (GF*/P*) for the same
-    #      material, reuse it — preserves slicer's K-profile association.
-    #   2. Replace PFUS* / empty with a generic Bambu filament ID.
+    #   1. Use the provided tray_info_idx if set (including cloud-synced
+    #      custom presets like PFUS* / P*).
+    #   2. Reuse the slot's existing tray_info_idx if it's a specific
+    #      (non-generic) preset for the same material.
+    #   3. Fall back to a generic Bambu filament ID.
     _GENERIC_FILAMENT_IDS = {
     _GENERIC_FILAMENT_IDS = {
         "PLA": "GFL99",
         "PLA": "GFL99",
         "PETG": "GFG99",
         "PETG": "GFG99",
@@ -1690,10 +1689,11 @@ async def configure_ams_slot(
         "PA-CF": "GFN98",
         "PA-CF": "GFN98",
         "PETG HF": "GFG96",
         "PETG HF": "GFG96",
     }
     }
+    _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())
     effective_tray_info_idx = tray_info_idx
     effective_tray_info_idx = tray_info_idx
 
 
-    if not tray_info_idx or tray_info_idx.startswith("PFUS"):
-        # Check if the slot already has a recognised preset for same material
+    if not tray_info_idx:
+        # No preset provided — try slot reuse or generic fallback
         current_tray_info_idx = ""
         current_tray_info_idx = ""
         current_tray_type = ""
         current_tray_type = ""
         state = printer_manager.get_status(printer_id)
         state = printer_manager.get_status(printer_id)
@@ -1724,7 +1724,7 @@ async def configure_ams_slot(
 
 
         if (
         if (
             current_tray_info_idx
             current_tray_info_idx
-            and not current_tray_info_idx.startswith("PFUS")
+            and current_tray_info_idx not in _GENERIC_ID_VALUES
             and current_tray_type
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
             and current_tray_type.upper() == tray_type.upper()
         ):
         ):
@@ -1742,10 +1742,7 @@ async def configure_ams_slot(
                 or ""
                 or ""
             )
             )
             if generic:
             if generic:
-                if tray_info_idx:
-                    logger.info("[configure_ams_slot] Replacing user-local %r with generic %r", tray_info_idx, generic)
-                else:
-                    logger.info("[configure_ams_slot] Deriving tray_info_idx=%r from tray_type=%r", generic, tray_type)
+                logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
                 effective_tray_info_idx = generic
                 effective_tray_info_idx = generic
 
 
     # Send filament setting + K-profile commands
     # Send filament setting + K-profile commands

+ 117 - 16
backend/tests/integration/test_inventory_assign.py

@@ -1,7 +1,8 @@
 """Integration tests for inventory spool assignment — tray_info_idx resolution.
 """Integration tests for inventory spool assignment — tray_info_idx resolution.
 
 
-Tests that PFUS* user-local preset IDs are replaced with generic Bambu IDs,
-and that existing recognised presets on slots are reused when the material matches.
+Tests that the spool's own slicer_filament (including PFUS* cloud-synced
+custom presets) takes priority, with slot reuse and generic fallback as
+lower-priority fallbacks.
 """
 """
 
 
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
@@ -59,8 +60,8 @@ class TestAssignSpoolTrayInfoIdx:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """PFUS* user-local IDs are replaced with generic Bambu IDs."""
+    async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
 
@@ -81,12 +82,14 @@ class TestAssignSpoolTrayInfoIdx:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             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"] == "GFL99"
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_reuses_existing_recognised_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """When slot already has a recognised preset for same material, reuse it."""
+    async def test_spool_preset_takes_priority_over_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool's own slicer_filament takes priority over slot's existing preset."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
 
@@ -110,13 +113,15 @@ class TestAssignSpoolTrayInfoIdx:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Should reuse the slicer's cloud-synced ID
-            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+            # Spool's own preset wins over slot's existing one
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_different_material_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """When slot has a preset for a DIFFERENT material, use generic ID."""
+    async def test_spool_preset_used_even_if_different_material_on_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool's own slicer_filament is used regardless of what's on the slot."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
 
 
@@ -140,7 +145,7 @@ class TestAssignSpoolTrayInfoIdx:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             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"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -196,8 +201,8 @@ class TestAssignSpoolTrayInfoIdx:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_existing_pfus_on_slot_not_reused(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """A PFUS* ID already on the slot should NOT be reused (it's also user-local)."""
+    async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Spool's own PFUS preset is used even when slot has a different PFUS."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
         spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
 
 
@@ -221,5 +226,101 @@ class TestAssignSpoolTrayInfoIdx:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Should NOT reuse the PFUS on the slot — use generic instead
-            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            # Spool's own preset wins
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_generic_on_slot_not_reused_over_spool_preset(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
+        printer = await printer_factory(name="P2S")
+        spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot stuck on generic ABS from a previous assignment
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Spool's preset wins — generic on slot must not be sticky
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_preset_with_generic_on_slot_still_uses_generic(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool without preset + generic on slot → generic fallback (not slot reuse)."""
+        printer = await printer_factory(name="P2S")
+        spool = await spool_factory(slicer_filament=None, material="ABS")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has generic ABS
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 1, "tray_info_idx": "GFB99", "tray_type": "ABS"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Still gets generic, but via fallback — not via sticky reuse
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_preset_reuses_specific_slot_preset(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Spool without preset + specific preset on slot → reuse slot's preset."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot has a specific Bambu PLA preset (not generic)
+        status = _make_mock_status(
+            ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "GFA05", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # Slot's specific preset is reused when spool has no own preset
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"

+ 11 - 11
backend/tests/integration/test_printers_api.py

@@ -640,8 +640,8 @@ class TestConfigureAMSSlotAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory):
-        """PFUS* user-local IDs are replaced with generic Bambu IDs."""
+    async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
+        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -670,12 +670,12 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             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"] == "GFL99"
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_reuses_existing_slot_id(self, async_client: AsyncClient, printer_factory):
-        """When slot already has a recognised preset for same material, reuse it."""
+    async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
+        """Provided PFUS* preset takes priority over slot's existing preset."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -721,13 +721,13 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Should reuse the slicer's P4d64437, not replace with GFL99
-            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+            # Provided preset wins over slot's existing one
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_configure_pfus_different_material_uses_generic(self, async_client: AsyncClient, printer_factory):
-        """When slot has a recognised preset but for DIFFERENT material, use generic."""
+    async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
+        """Provided PFUS* preset is used even when slot has a different material."""
         printer = await printer_factory(name="H2D")
         printer = await printer_factory(name="H2D")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -766,8 +766,8 @@ class TestConfigureAMSSlotAPI:
 
 
             assert response.status_code == 200
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Different material → should NOT reuse PETG ID, use generic PLA
-            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            # Provided preset wins — slot's material is irrelevant
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration