Просмотр исходного кода

fix(queue): a plate printed from the external spool keeps the AMS off on multi-plate files; slot reuse respects the finish; any-model VPs keep the AMS on; cloud filament id read from the preset too

maziggy 4 дней назад
Родитель
Сommit
26e9a05707

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A plate printed only from the external spool starts without the AMS on multi-plate files too** — The queue already sends such a plate with the AMS off (#3087), but when another plate of the same file used an AMS slot, the print command turned the AMS back on and the printer refused the job, also for a job whose AMS was already switched off. The plate's own answer now stands.
+- **A Silk spool no longer takes a slot's Matte preset** — A spool without a slicer preset reuses the filament preset already in the slot when the material matches, to keep a calibrated preset, and so does **Configure Slot** for a preset without a filament id. That now also requires the finish to match when both name one (Basic, Matte, Silk, HF, ...); otherwise the slot gets the generic preset for the material and finish, such as Generic PLA Silk for a Silk+ spool. The generic fallback picks the finish's own generic in Spoolman mode too (Generic PLA Silk, PLA High Speed, PETG HF).
+- **A virtual printer for any printer of a model keeps the AMS on** — It stored the slicer's AMS on/off with the job. The slicer was looking at the virtual printer, which has no AMS, so that choice did not describe the printer the job went to. Only a virtual printer with a target printer keeps it now.
+- **The K-profile filament names from Bambu Cloud read a preset's id from either place** — The name lookup read a cloud preset's filament id only from the response envelope, while slicing also looked inside the preset itself; a preset with the id only there showed its raw id.
 - **A K-profile chosen on purpose is no longer undone when the AMS reports a change** — When a spool with a stored K-profile sat in a slot, an AMS report that changed the slot (its remaining amount, for example) set it back to the stored profile, so one picked in Bambu Studio or on the printer's screen did not last. That re-apply is gone: the existing lost-profile check puts the stored profile back when the slot has lost its selection (the default K after a reset or power cycle) while the printer is idle, and leaves a deliberately chosen profile, a chosen **Default** and the other nozzle's calibration alone.
 - **K-profile commands no longer change the shown nozzle size** — The printer's answer to saving, deleting or selecting a K-profile names the nozzle size of that profile, and Bambuddy took it as the installed nozzle. Only the printer's status updates the nozzle now.
 - **A K-profile save says when the printer did not confirm it** — A save, batch save or delete the printer did not acknowledge was reported as done. It now says the profile was sent but not confirmed (older firmware never answers), and an import or bulk delete says how many of its writes went unconfirmed. An edit on a single-nozzle printer, which removes the profile and adds it again, reads the printer's table back and fails when the edited profile is not there.

+ 4 - 1
backend/app/api/routes/cloud.py

@@ -1343,7 +1343,10 @@ async def get_filament_id_map(
                 continue
             try:
                 detail = await cloud.get_setting_detail(setting_id)
-                fid = detail.get("filament_id", "")
+                # On the envelope normally; in the preset JSON under `setting`
+                # when not (the same fallback as slicer_filament_resolver).
+                nested = detail.get("setting")
+                fid = detail.get("filament_id") or (nested.get("filament_id") if isinstance(nested, dict) else "")
                 name = detail.get("name", "")
                 if fid and name:
                     # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"

+ 11 - 1
backend/app/api/routes/inventory.py

@@ -86,6 +86,7 @@ from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch
 from backend.app.services.tag_conflict import tag_already_linked
+from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_IDS_REPLACED_ON_REUSE,
@@ -133,6 +134,7 @@ async def apply_spool_to_slot_via_mqtt(
     tray_id: int,
     current_tray_info_idx: str = "",
     current_tray_type: str = "",
+    current_tray_sub_brands: str = "",
 ) -> bool:
     """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
 
@@ -223,6 +225,9 @@ async def apply_spool_to_slot_via_mqtt(
             and not is_material_name(current_tray_info_idx)
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
+            # The same type in another finish is another preset: a Silk spool
+            # does not take the slot's Matte id. Only when both name a finish.
+            and not different_finish(f"{spool.material or ''} {spool.subtype or ''}", current_tray_sub_brands)
         ):
             tray_info_idx = current_tray_info_idx
         elif tray_type:
@@ -232,7 +237,8 @@ async def apply_spool_to_slot_via_mqtt(
             # (GFG96) that reducing it to "PETG" would trade away for GFG99.
             material = (spool.material or "").upper().strip()
             generic = (
-                GENERIC_FILAMENT_IDS.get(material)
+                finish_generic_id(tray_type, f"{spool.material or ''} {spool.subtype or ''}")
+                or GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
@@ -2202,6 +2208,7 @@ async def assign_spool(
     fingerprint_color = None
     fingerprint_type = None
     current_tray_info_idx = ""
+    current_tray_sub_brands = ""
     tray_state: int | None = None
     # Firmware's tray_exist_bits answer for this slot, when the payload carries
     # one. Outranks tray_state below — see services/ams_slot_presence.py.
@@ -2217,6 +2224,7 @@ async def assign_spool(
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_type = vt.get("tray_type", "")
                     current_tray_info_idx = vt.get("tray_info_idx", "")
+                    current_tray_sub_brands = vt.get("tray_sub_brands", "")
                     raw_state = vt.get("state")
                     if isinstance(raw_state, int):
                         tray_state = raw_state
@@ -2240,6 +2248,7 @@ async def assign_spool(
                 fingerprint_color = tray.get("tray_color", "")
                 fingerprint_type = tray.get("tray_type", "")
                 current_tray_info_idx = tray.get("tray_info_idx", "")
+                current_tray_sub_brands = tray.get("tray_sub_brands", "")
                 raw_state = tray.get("state")
                 if isinstance(raw_state, int):
                     tray_state = raw_state
@@ -2340,6 +2349,7 @@ async def assign_spool(
                 tray_id=data.tray_id,
                 current_tray_info_idx=current_tray_info_idx,
                 current_tray_type=fingerprint_type or "",
+                current_tray_sub_brands=current_tray_sub_brands,
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)

+ 8 - 1
backend/app/api/routes/printers.py

@@ -100,6 +100,7 @@ from backend.app.services.printer_media import (
 )
 from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
 from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
+from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_IDS_REPLACED_ON_REUSE,
@@ -3017,6 +3018,7 @@ async def configure_ams_slot(
         # No preset provided — try slot reuse or generic fallback
         current_tray_info_idx = ""
         current_tray_type = ""
+        current_tray_sub_brands = ""
         state = printer_manager.get_status(printer_id)
         if state and state.raw_data:
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
@@ -3028,6 +3030,7 @@ async def configure_ams_slot(
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         current_tray_info_idx = vt.get("tray_info_idx", "")
                         current_tray_type = vt.get("tray_type", "")
+                        current_tray_sub_brands = vt.get("tray_sub_brands", "")
                         break
             else:
                 ams_data = state.raw_data.get("ams", {})
@@ -3042,6 +3045,7 @@ async def configure_ams_slot(
                 if cur_tray:
                     current_tray_info_idx = cur_tray.get("tray_info_idx", "")
                     current_tray_type = cur_tray.get("tray_type", "")
+                    current_tray_sub_brands = cur_tray.get("tray_sub_brands", "")
 
         if (
             # An Orca profile without an id must not inherit whatever specific
@@ -3052,6 +3056,8 @@ async def configure_ams_slot(
             and current_tray_info_idx not in GENERIC_IDS_REPLACED_ON_REUSE
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
+            # The same type in another finish is another preset.
+            and not different_finish(tray_sub_brands, current_tray_sub_brands)
         ):
             logger.info(
                 "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
@@ -3066,7 +3072,8 @@ async def configure_ams_slot(
             # to "PETG" would trade away for GFG99.
             material = requested_tray_type.upper().strip()
             generic = (
-                GENERIC_FILAMENT_IDS.get(material)
+                finish_generic_id(tray_type, f"{requested_tray_type} {tray_sub_brands}")
+                or GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""

+ 5 - 1
backend/app/api/routes/spoolman_inventory.py

@@ -76,6 +76,7 @@ from backend.app.services.spoolman import (
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot, is_slot_fallback_tag
 from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.color_utils import spoolman_color_hex
+from backend.app.utils.filament_finish import finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -1919,7 +1920,10 @@ async def assign_spoolman_slot(
             # preset (GFG96) rather than trading it for "PETG"'s GFG99.
             if not tray_info_idx:
                 tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
+                    # The finish first, as in internal mode: a Silk spool
+                    # gets Generic PLA Silk.
+                    finish_generic_id(tray_type, f"{material} {subtype}")
+                    or GENERIC_FILAMENT_IDS.get(material_upper)
                     or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
                     or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                     or ""

+ 1 - 0
backend/app/main.py

@@ -2595,6 +2595,7 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                                 tray_id=assignment.tray_id,
                                 current_tray_info_idx=current_tray.get("tray_info_idx", ""),
                                 current_tray_type=cur_type,
+                                current_tray_sub_brands=current_tray.get("tray_sub_brands", ""),
                             )
                             logger.info(
                                 "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",

+ 9 - 1
backend/app/services/bambu_mqtt.py

@@ -6098,6 +6098,7 @@ class BambuMQTTClient:
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
         nozzle_slot_extruders: str | None = None,
+        plate_uses_only_external: bool = False,
     ):
         """Start a print job on the printer.
 
@@ -6115,6 +6116,10 @@ class BambuMQTTClient:
             vibration_cali: Vibration compensation calibration
             layer_inspect: First layer AI inspection
             use_ams: Use AMS for automatic filament changes
+            plate_uses_only_external: The caller determined from the plate's
+                own filament list that it prints only from the spool holder;
+                use_ams=False then stands even if the mapping names a real
+                tray (one another plate of the file uses, #3087)
             nozzle_offset_cali: Nozzle offset calibration — "off"/"on"/"auto"
                 (dual-nozzle printers only — silently ignored on single-nozzle).
             nozzle_mapping: Opaque JSON string captured from BambuStudio's
@@ -6262,7 +6267,10 @@ class BambuMQTTClient:
             if ams_mapping and not is_dual_nozzle:
                 has_real_tray = any(t is not None and 0 <= int(t) <= 253 for t in ams_mapping)
                 all_external = all(t is None or int(t) >= 254 for t in ams_mapping)
-                if has_real_tray and not use_ams:
+                # (Not when the scheduler worked out from the plate's own
+                # filament list that it prints only from the spool holder: a
+                # real tray here is then one another plate of the file uses.)
+                if has_real_tray and not use_ams and not plate_uses_only_external:
                     use_ams = True
                     logger.info(
                         "[%s] AMS mapping resolved a real slot — setting use_ams=True (#2595)",

+ 8 - 2
backend/app/services/print_scheduler.py

@@ -9259,9 +9259,9 @@ class PrintScheduler:
         # — on a dual-nozzle machine use_ams is which extruder to feed, not
         # whether to use the AMS, so it is not ours to rewrite.
         effective_use_ams = item.use_ams
+        plate_uses_only_external = False
         if (
-            effective_use_ams
-            and ams_mapping
+            ams_mapping
             and file_path is not None
             # Cheap gate before opening the file: with nothing on the spool
             # holder anywhere in the mapping, no subset of it can be all
@@ -9279,6 +9279,11 @@ class PrintScheduler:
                 ams_mapping, extract_filament_requirements(file_path, plate_id=effective_plate_id)
             )
             if consumed and all(_is_external_tray(t) for t in consumed):
+                # Exact for this plate: the command builder must not turn the
+                # AMS back on for a real tray another plate of the file uses,
+                # whether the job asked for the AMS or already had it off.
+                plate_uses_only_external = True
+            if plate_uses_only_external and effective_use_ams:
                 effective_use_ams = False
                 logger.info(
                     "Queue item %s: every filament plate %s prints is on the external spool "
@@ -9315,6 +9320,7 @@ class PrintScheduler:
             layer_inspect=item.layer_inspect,
             timelapse=effective_timelapse,
             use_ams=effective_use_ams,
+            plate_uses_only_external=plate_uses_only_external,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping
             or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),

+ 2 - 0
backend/app/services/printer_manager.py

@@ -990,6 +990,7 @@ class PrinterManager:
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
         nozzle_slot_extruders: str | None = None,
+        plate_uses_only_external: bool = False,
     ) -> bool:
         """Start a print on a connected printer.
 
@@ -1025,6 +1026,7 @@ class PrinterManager:
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
                 nozzle_slot_extruders=nozzle_slot_extruders,
+                plate_uses_only_external=plate_uses_only_external,
             )
         return False
 

+ 15 - 2
backend/app/services/virtual_printer/manager.py

@@ -427,6 +427,16 @@ class VirtualPrinterInstance:
         """
         return self.target_printer_id is not None or not is_nozzle_rack_model(self.model)
 
+    def _keeps_slicer_use_ams(self) -> bool:
+        """Whether the slicer's ``use_ams`` is stored on the queue item.
+
+        The slicer was looking at this VP, which advertises no AMS. Only a VP
+        bound to one printer stands for a machine whose AMS the user chose to
+        use or not; a VP for any printer of the model leaves it on and the
+        dispatch reconciles it against the printer it picks (#2595).
+        """
+        return self.target_printer_id is not None
+
     def _target_is_dual_nozzle(self) -> bool:
         """Whether the target printer has two nozzles, which decides the global
         tray of an external spool in the slicer's mapping (#3237).
@@ -564,6 +574,8 @@ class VirtualPrinterInstance:
             ("timelapse", "timelapse"),
             ("use_ams", "use_ams"),
         ):
+            if mqtt_field == "use_ams" and not self._keeps_slicer_use_ams():
+                continue
             if mqtt_field in data:
                 patch[column] = bool(data[mqtt_field])
 
@@ -971,8 +983,9 @@ class VirtualPrinterInstance:
                 vibration_cali = _slicer_or("vibration_cali", defaults["vibration_cali"])
                 layer_inspect = _slicer_or("layer_inspect", defaults["layer_inspect"])
                 timelapse = _slicer_or("timelapse", defaults["timelapse"])
-                # No setting for it: the column default (on) when the slicer is silent.
-                use_ams = _slicer_or("use_ams", True)
+                # No setting for it: the column default (on) when the slicer is
+                # silent, or when the VP does not stand for one printer.
+                use_ams = _slicer_or("use_ams", True) if self._keeps_slicer_use_ams() else True
 
                 # "Ask for Outcome" is a default print option like the ones
                 # above. A plate sent here from Bambu Studio is also one of the

+ 54 - 0
backend/app/utils/filament_finish.py

@@ -0,0 +1,54 @@
+"""The finish a filament's wording names: Basic, Matte, Silk, ...
+
+A slot preset of one finish is not the preset of another, even for the same
+material: a Silk spool must not take a slot's Matte preset. The same words pick
+the generic preset for a finish that has one (Generic PLA Silk, PETG HF).
+"""
+
+from backend.app.utils.filament_ids import GENERIC_FILAMENT_IDS
+
+# Finishes Bambu names in a sub-brand ("PLA Matte", "PLA Silk+", "PETG HF").
+KNOWN_FINISHES = (
+    "basic",
+    "matte",
+    "silk",
+    "glow",
+    "marble",
+    "metal",
+    "sparkle",
+    "galaxy",
+    "translucent",
+    "tough",
+    "wood",
+    "aero",
+    "lite",
+    "gradient",
+    "high speed",
+    "dual color",
+    "hf",
+)
+
+
+def finishes(text: str | None) -> list[str]:
+    """The known finishes named in ``text``, "+" and "-" read as spaces
+    ("Silk+", "Silk Multi-Color")."""
+    words = f" {(text or '').lower().replace('+', ' ').replace('-', ' ')} "
+    return [finish for finish in KNOWN_FINISHES if f" {finish} " in words]
+
+
+def different_finish(spool_text: str | None, slot_text: str | None) -> bool:
+    """True when both name a finish and they share none."""
+    spool_finishes = set(finishes(spool_text))
+    slot_finishes = set(finishes(slot_text))
+    return bool(spool_finishes and slot_finishes and not spool_finishes & slot_finishes)
+
+
+def finish_generic_id(material: str | None, text: str | None) -> str:
+    """The generic preset for ``material`` in a finish ``text`` names, e.g.
+    PLA + "Silk+" -> GFL96 (Generic PLA Silk); "" when there is none."""
+    base = (material or "").upper().strip()
+    for finish in finishes(text):
+        generic = GENERIC_FILAMENT_IDS.get(f"{base} {finish.upper()}")
+        if generic:
+            return generic
+    return ""

+ 24 - 0
backend/tests/integration/test_cloud_filament_id_map_accounts.py

@@ -115,3 +115,27 @@ async def test_a_preset_one_account_resolved_is_not_served_to_another(async_clie
     assert for_anna["P5a1b2c3"]["name"] == "Anna Private PLA @Bambu Lab X1C"
     assert for_ben["P5a1b2c3"]["name"] != for_anna["P5a1b2c3"]["name"]
     assert for_ben["P5a1b2c3"]["k"] is None
+
+
+async def test_a_filament_id_inside_the_preset_json_is_read_too(async_client: AsyncClient):
+    """Same fallback as the slicer filament resolver: the preset JSON under
+    ``setting`` can carry the id when the envelope does not."""
+    token = await _admin_token(async_client)
+    group = await _group(async_client, token, "readers", permissions=["filaments:read"])
+    anna, _ = await _user(async_client, token, "anna", [group])
+    cloud = SimpleNamespace(
+        is_authenticated=True,
+        get_slicer_settings=AsyncMock(return_value={"filament": {"private": [{"setting_id": "S0"}]}}),
+        get_setting_detail=AsyncMock(
+            return_value={"filament_id": None, "name": "Nested PLA @Bambu Lab X1C", "setting": {"filament_id": "P9"}}
+        ),
+        close=AsyncMock(),
+    )
+
+    async def build(db, user):
+        return cloud
+
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=build):
+        body = (await async_client.get("/api/v1/cloud/filament-id-map", headers=_auth(anna))).json()
+
+    assert body == {"P9": "Nested PLA"}

+ 23 - 0
backend/tests/integration/test_external_spool_use_ams_3087.py

@@ -181,6 +181,27 @@ class TestThePlateThatOnlyPrintsFromTheSpoolHolder:
         # turns 254 into -1 plus ams_mapping2, and none of that changes.
         assert call.kwargs["ams_mapping"] == [-1, -1, -1, -1, -1, -1, 254]
 
+    async def test_a_real_tray_another_plate_uses_does_not_bring_the_ams_back(self, dispatch_case):
+        """Multi-plate: filament 1 (an AMS tray) is printed by another plate.
+        The command builder turns use_ams back on for any real tray in the
+        mapping, so the scheduler tells it this plate's answer is exact."""
+        ids = await dispatch_case.build([5, 254], filaments='<filament id="2" used_g="9.0" type="PLA"/>', plate_id=4)
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+        assert call.kwargs["plate_uses_only_external"] is True
+
+    async def test_a_job_that_already_has_the_ams_off_says_so_too(self, dispatch_case):
+        """use_ams=False from a bulk edit or the API: the builder would turn it
+        back on for the other plate's real tray."""
+        ids = await dispatch_case.build(
+            [5, 254], filaments='<filament id="2" used_g="9.0" type="PLA"/>', plate_id=4, use_ams=False
+        )
+        call = await _dispatch(dispatch_case, ids)
+
+        assert call.kwargs["use_ams"] is False
+        assert call.kwargs["plate_uses_only_external"] is True
+
     async def test_the_main_nozzle_sentinel_counts_too(self, dispatch_case):
         ids = await dispatch_case.build([-1, -1, -1, -1, -1, -1, 255])
         call = await _dispatch(dispatch_case, ids)
@@ -246,6 +267,8 @@ class TestWhatMustNotChange:
         call = await _dispatch(dispatch_case, ids)
 
         assert call.kwargs["use_ams"] is True
+        # The builder keeps its own reconcile for it.
+        assert call.kwargs["plate_uses_only_external"] is False
 
     async def test_use_ams_false_is_never_promoted_here(self, dispatch_case):
         """Promotion is the builder's job (#2595) and stays there."""

+ 130 - 0
backend/tests/integration/test_inventory_assign.py

@@ -368,6 +368,80 @@ class TestAssignSpoolTrayInfoIdx:
             assert call_kwargs.kwargs["tray_info_idx"] == slot_generic
 
 
+class TestSlotReuseRespectsTheSubtype:
+    """A preset-less spool reuses the slot's Bambu filament id when the bare
+    type matches, to keep a calibrated preset. A Silk spool must not inherit a
+    slot that the printer describes as Matte, though."""
+
+    async def _sent_id(self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands):
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="PLA", subtype=subtype)
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": slot_sub_brands}
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
+
+        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
+        return mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_silk_spool_does_not_take_a_matte_slot(self, async_client, printer_factory, spool_factory):
+        sent = await self._sent_id(async_client, printer_factory, spool_factory, "Silk", "PLA Matte")
+
+        assert sent == "GFL96"  # Generic PLA Silk, the spool's own subtype
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_silk_plus_spool_gets_generic_pla_silk(self, async_client, printer_factory, spool_factory):
+        assert await self._sent_id(async_client, printer_factory, spool_factory, "Silk+", "PLA Matte") == "GFL96"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_finish_in_the_material_column_counts(self, async_client, printer_factory, spool_factory):
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament=None, material="PLA Silk", subtype=None)
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        tray = {"id": 0, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": "PLA Matte"}
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray]}])
+
+        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
+            await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+            )
+
+        assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_subtype_still_reuses_the_slot(self, async_client, printer_factory, spool_factory):
+        assert await self._sent_id(async_client, printer_factory, spool_factory, "Matte", "PLA Matte") == "GFA01"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(("subtype", "slot_sub_brands"), [(None, "PLA Matte"), ("Silk", ""), ("Silk", "Sunlu PLA")])
+    async def test_without_two_known_subtypes_it_still_reuses(
+        self, async_client, printer_factory, spool_factory, subtype, slot_sub_brands
+    ):
+        sent = await self._sent_id(async_client, printer_factory, spool_factory, subtype, slot_sub_brands)
+
+        assert sent == "GFA01"
+
+
 class TestAssignSpoolPresetMapping:
     """Tests that assign_spool saves the slot preset mapping for correct UI display."""
 
@@ -799,6 +873,62 @@ class TestAssignSpoolEmptySlotPreConfig:
         assert pre_assignment.fingerprint_type == "PLA"
         assert pre_assignment.fingerprint_color == "FF0000FF"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_replay_passes_the_slots_finish(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """A preset-less Silk spool loaded into a slot reporting Bambu Matte."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament=None, material="PLA", subtype="Silk")
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=2,
+                tray_id=3,
+                fingerprint_color=None,
+                fingerprint_type=None,
+            )
+        )
+        await db_session.commit()
+        tray = {
+            "id": 3,
+            "tray_type": "PLA",
+            "tray_color": "FF0000FF",
+            "state": 11,
+            "tray_info_idx": "GFA01",
+            "tray_sub_brands": "PLA Matte",
+        }
+        ams_data = [{"id": 2, "tray": [tray]}]
+        mock_client = MagicMock()
+        status = _make_mock_status(ams_data=ams_data)
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "H2D"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_on_ams_change_does_not_refire_for_already_configured_slot(

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

@@ -1907,6 +1907,38 @@ class TestConfigureAMSSlotAPI:
             assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
             assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_a_silk_preset_does_not_take_a_matte_slot(self, async_client: AsyncClient, printer_factory):
+        """Same material, another finish: the slot's Matte id is not reused, and
+        the generic is the one for the preset's finish."""
+        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()
+        tray = {"id": 3, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": "PLA Matte"}
+        mock_status.raw_data = {"ams": {"ams": [{"id": 2, "tray": [tray]}]}}
+
+        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/2/3/configure",
+                params={
+                    "tray_info_idx": "PFUS9ac902733670a9",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "My PLA Silk",
+                    "tray_color": "FF0000FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                },
+            )
+
+        assert response.status_code == 200
+        assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):

+ 22 - 0
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -110,6 +110,28 @@ class TestAssignSlotMqtt:
         assert call_kwargs["tray_id"] == 1
         assert call_kwargs["tray_type"] == "PLA"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_silk_spool_gets_the_silk_generic(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        """Same generic as internal mode: the finish picks Generic PLA Silk."""
+        mock_spoolman_client.get_spool = AsyncMock(
+            return_value={**SAMPLE_SPOOL, "filament": {**SAMPLE_SPOOL["filament"], "name": "PLA Silk+"}}
+        )
+        mqtt_mock = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+        assert response.status_code == 200
+        assert mqtt_mock.ams_set_filament_setting.call_args[1]["tray_info_idx"] == "GFL96"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_mqtt_failure_does_not_rollback_assignment(

+ 10 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -467,9 +467,19 @@ class TestPrinterManager:
             nozzle_offset_cali="auto",
             nozzle_mapping=None,
             nozzle_slot_extruders=None,
+            plate_uses_only_external=False,
         )
         assert result is True
 
+    def test_start_print_passes_the_plates_external_spool_answer(self, manager, mock_client):
+        """The scheduler's exact use_ams=False for the plate (#3087) reaches the builder."""
+        mock_client.start_print.return_value = True
+        manager._clients[1] = mock_client
+
+        manager.start_print(1, "test.gcode", use_ams=False, plate_uses_only_external=True)
+
+        assert mock_client.start_print.call_args.kwargs["plate_uses_only_external"] is True
+
     def test_start_print_returns_false_for_unknown(self, manager):
         """Verify start_print returns False for unknown printer."""
         result = manager.start_print(999, "test.gcode")

+ 31 - 0
backend/tests/unit/services/test_vp_queue_intake_d2.py

@@ -183,6 +183,37 @@ class TestAmsOnOff:
 
         assert added[0].use_ams is True
 
+    @pytest.mark.asyncio
+    async def test_a_vp_for_any_printer_leaves_it_on(self, tmp_path):
+        """The slicer saw the VP, which has no AMS; the printer the job goes to
+        is picked later and may have one (the dispatch reconciles it, #2595)."""
+        added: list = []
+        factory, _ = _session(added)
+        inst = _vp(tmp_path, factory)
+
+        await _enqueue(inst, tmp_path, {"use_ams": False})
+
+        assert added[0].use_ams is True
+
+    @pytest.mark.asyncio
+    async def test_a_vp_for_any_printer_does_not_patch_it_on_late(self, tmp_path):
+        added: list = []
+        position_max = MagicMock(scalar=MagicMock(return_value=None))
+        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        factory, db = _session(added, [position_max, pending, MagicMock()])
+        inst = _vp(tmp_path, factory)
+        inst._mqtt = MagicMock()
+
+        file_path = await _enqueue(inst, tmp_path, None)
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "file": file_path.name, "use_ams": False, "timelapse": True},
+        )
+
+        params = dict(db.execute.await_args_list[2].args[0].compile().params)
+        assert params["timelapse"] is True
+        assert "use_ams" not in params
+
 
 class TestLateAmsMappingPerPlate:
     @pytest.mark.asyncio

+ 48 - 0
backend/tests/unit/test_filament_finish.py

@@ -0,0 +1,48 @@
+"""Finish words in filament wording, for slot reuse and generic presets."""
+
+import pytest
+
+from backend.app.utils.filament_finish import different_finish, finish_generic_id, finishes
+
+
+@pytest.mark.parametrize(
+    ("text", "expected"),
+    [
+        ("PLA Silk+", ["silk"]),
+        ("Silk Multi-Color", ["silk"]),
+        ("Bambu PETG HF", ["hf"]),
+        ("PLA High Speed", ["high speed"]),
+        ("Sunlu PLA", []),
+        (None, []),
+    ],
+)
+def test_finishes(text, expected):
+    assert finishes(text) == expected
+
+
+@pytest.mark.parametrize(
+    ("spool", "slot", "differ"),
+    [
+        ("PLA Silk", "PLA Matte", True),
+        ("PLA Silk+", "Bambu PLA Silk", False),
+        ("PETG Basic", "PETG HF", True),
+        ("PLA", "PLA Matte", False),
+        ("PLA Silk", "", False),
+    ],
+)
+def test_different_finish(spool, slot, differ):
+    assert different_finish(spool, slot) is differ
+
+
+@pytest.mark.parametrize(
+    ("material", "text", "generic"),
+    [
+        ("PLA", "PLA Silk+", "GFL96"),
+        ("PLA", "Silk Multi-Color", "GFL96"),
+        ("PETG", "PETG HF", "GFG96"),
+        ("PLA", "PLA Matte", ""),
+        ("PLA", "", ""),
+    ],
+)
+def test_finish_generic_id(material, text, generic):
+    assert finish_generic_id(material, text) == generic

+ 6 - 0
backend/tests/unit/test_use_ams_reconcile_2595.py

@@ -53,6 +53,12 @@ class TestUseAmsReconcile:
         cmd = self._sent_command(mqtt_client)
         assert cmd["use_ams"] is True
 
+    def test_the_schedulers_plate_answer_is_not_overridden(self, mqtt_client):
+        """#3087 on a multi-plate file: this plate prints only from the spool
+        holder; the real tray belongs to another plate."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[5, 254], use_ams=False, plate_uses_only_external=True)
+        assert self._sent_command(mqtt_client)["use_ams"] is False
+
     def test_false_all_external_stays_false(self, mqtt_client):
         """An explicit external selection must NOT be force-enabled."""
         assert mqtt_client.start_print("shell.3mf", ams_mapping=[254], use_ams=False) is True