Selaa lähdekoodia

Configure Orca Cloud filaments with their own filament ID (#3216)

OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
ID alone. The Configure dialog looked up an Orca profile's ID in the
browser and quietly sent the generic for the material when that came back
empty, so Orca custom filaments reached the slicer as Generic and the log
showed nothing. configure now resolves the ID on the server from
orca_profile_id, follows inherits to the parent profile or the Bambu
filament it was copied from, logs the outcome and reports a fallback,
which the dialog shows as a warning.

Slot presets also store the filament ID they were written with, so the
slot card and the Configure dialog stop showing a preset once the slot is
changed from OrcaSlicer's Device tab or the printer.

Re-configuring a slot for another filament no longer carries over the old
filament's active K-profile, which switched the slot back to the old
filament.
maziggy 5 tuntia sitten
vanhempi
sitoutus
02e39b18b9
38 muutettua tiedostoa jossa 926 lisäystä ja 113 poistoa
  1. 0 0
      CHANGELOG.md
  2. 1 0
      backend/app/api/routes/inventory.py
  3. 110 4
      backend/app/api/routes/printers.py
  4. 5 0
      backend/app/core/database.py
  5. 6 0
      backend/app/models/slot_preset.py
  6. 144 30
      backend/app/services/slicer_filament_resolver.py
  7. 12 0
      backend/app/services/slot_preset_writer.py
  8. 6 0
      backend/tests/integration/test_inventory_assign.py
  9. 268 0
      backend/tests/integration/test_orca_slot_filament_id_3216.py
  10. 93 0
      backend/tests/unit/services/test_slicer_filament_resolver.py
  11. 82 25
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  12. 21 0
      frontend/src/__tests__/utils/slotPresetDescribesTray.test.ts
  13. 19 3
      frontend/src/api/client.ts
  14. 43 38
      frontend/src/components/ConfigureAmsSlotModal.tsx
  15. 6 0
      frontend/src/i18n/locales/de.ts
  16. 6 0
      frontend/src/i18n/locales/en.ts
  17. 6 0
      frontend/src/i18n/locales/es.ts
  18. 6 0
      frontend/src/i18n/locales/fr.ts
  19. 6 0
      frontend/src/i18n/locales/it.ts
  20. 6 0
      frontend/src/i18n/locales/ja.ts
  21. 7 1
      frontend/src/i18n/locales/ko.ts
  22. 6 0
      frontend/src/i18n/locales/nl.ts
  23. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  24. 6 0
      frontend/src/i18n/locales/ru.ts
  25. 6 0
      frontend/src/i18n/locales/sv.ts
  26. 6 0
      frontend/src/i18n/locales/tr.ts
  27. 6 0
      frontend/src/i18n/locales/uk.ts
  28. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 6 6
      frontend/src/pages/PrintersPage.tsx
  31. 8 3
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  32. 9 0
      frontend/src/utils/amsHelpers.ts
  33. 0 0
      static/assets/ImagePreviewModal-_TSWhu8N.js
  34. 0 1
      static/assets/PdfPreviewModal-DxLjIO4z.js
  35. 0 0
      static/assets/SpreadsheetPreviewModal-sHZPv4Ot.js
  36. 1 1
      static/assets/index-DCfEMLCI.js
  37. 0 0
      static/assets/pdf-BGtyfwRp.js
  38. 1 1
      static/index.html

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


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

@@ -389,6 +389,7 @@ async def apply_spool_to_slot_via_mqtt(
         tray_sub_brands=tray_sub_brands,
         tray_type=tray_type,
         setting_id=setting_id,
+        configured_tray_info_idx=effective_tray_info_idx,
     )
 
     logger.info(

+ 110 - 4
backend/app/api/routes/printers.py

@@ -88,7 +88,7 @@ from backend.app.services.printer_media import (
     remove_printer_files_zip,
     start_printer_files_job,
 )
-from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID
+from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_ids import filament_id_to_setting_id
@@ -2556,6 +2556,7 @@ async def get_slot_presets(
             "tray_id": mapping.tray_id,
             "preset_id": mapping.preset_id,
             "preset_name": mapping.preset_name,
+            "tray_info_idx": mapping.tray_info_idx,
         }
         for mapping in mappings
     }
@@ -2587,6 +2588,7 @@ async def get_slot_preset(
         "tray_id": mapping.tray_id,
         "preset_id": mapping.preset_id,
         "preset_name": mapping.preset_name,
+        "tray_info_idx": mapping.tray_info_idx,
     }
 
 
@@ -2598,10 +2600,20 @@ async def save_slot_preset(
     preset_id: str,
     preset_name: str,
     preset_source: str = "cloud",
+    tray_info_idx: str | None = None,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Save a preset mapping for a specific slot."""
+    """Save a preset mapping for a specific slot.
+
+    ``tray_info_idx`` is the filament id the slot was configured with (what
+    ``configure`` returns). Recording it lets the slot card notice a later
+    re-configuration from elsewhere and stop showing this preset (#3216).
+    Omitted, it is cleared, so a stale id never outlives the preset it came with.
+    """
+    # Longer than the column is not an id the printer holds: record nothing,
+    # which keeps the row shown, rather than refuse the whole save.
+    tray_info_idx = tray_info_idx if tray_info_idx and len(tray_info_idx) <= 32 else None
     # Check printer exists
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     if not result.scalar_one_or_none():
@@ -2622,6 +2634,7 @@ async def save_slot_preset(
         mapping.preset_id = preset_id
         mapping.preset_name = preset_name
         mapping.preset_source = preset_source
+        mapping.tray_info_idx = tray_info_idx
     else:
         # Create new
         mapping = SlotPresetMapping(
@@ -2631,6 +2644,7 @@ async def save_slot_preset(
             preset_id=preset_id,
             preset_name=preset_name,
             preset_source=preset_source,
+            tray_info_idx=tray_info_idx,
         )
         db.add(mapping)
 
@@ -2643,6 +2657,7 @@ async def save_slot_preset(
         "preset_id": mapping.preset_id,
         "preset_name": mapping.preset_name,
         "preset_source": mapping.preset_source,
+        "tray_info_idx": mapping.tray_info_idx,
     }
 
 
@@ -2771,6 +2786,46 @@ async def get_slot_spool_defaults(
     }
 
 
+# Generic Bambu filament ids by material, as the Configure dialog has always
+# picked them for a preset without an id of its own (#3216 moved the Orca case
+# here). Wider than configure_ams_slot's own table: Silk, High Speed, PCTG, PE
+# and PP have generics of their own.
+_ORCA_GENERIC_IDS = {
+    "PLA": "GFL99",
+    "PLA-CF": "GFL98",
+    "PLA SILK": "GFL96",
+    "PLA HIGH SPEED": "GFL95",
+    "PETG": "GFG99",
+    "PETG HF": "GFG96",
+    "PETG-CF": "GFG98",
+    "PCTG": "GFG97",
+    "ABS": "GFB99",
+    "ASA": "GFB98",
+    "PC": "GFC99",
+    "PA": "GFN99",
+    "PA-CF": "GFN98",
+    "NYLON": "GFN99",
+    "TPU": "GFU99",
+    "PVA": "GFS99",
+    "HIPS": "GFS98",
+    "PE": "GFP99",
+    "PP": "GFP97",
+}
+
+
+def _orca_generic_filament_id(material: str) -> str:
+    """The generic for a material, tried as given, without a CF suffix, without
+    a trailing "+", then by its first word -- the dialog's order."""
+    material = (material or "").upper().strip()
+    return (
+        _ORCA_GENERIC_IDS.get(material)
+        or _ORCA_GENERIC_IDS.get(re.sub(r"[-\s]?CF$", "", material))
+        or _ORCA_GENERIC_IDS.get(re.sub(r"\+$", "", material))
+        or _ORCA_GENERIC_IDS.get(re.split(r"[-\s]", material)[0])
+        or ""
+    )
+
+
 @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
 async def configure_ams_slot(
     printer_id: int,
@@ -2788,8 +2843,9 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
+    orca_profile_id: str = Query(""),
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
 
@@ -2812,6 +2868,9 @@ async def configure_ams_slot(
         setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
         kprofile_filament_id: K profile's filament_id for proper K profile linking
         k_value: Direct K value to set (0.0 to skip direct K value setting)
+        orca_profile_id: Orca Cloud profile the slot is being set to. With no
+            tray_info_idx, the profile's own filament id is looked up here
+            (#3216); the response says when none was found.
     """
     logger = logging.getLogger(__name__)
     logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
@@ -2877,6 +2936,40 @@ async def configure_ams_slot(
             setting_id = tray_info_idx
         tray_info_idx = ""
 
+    # An Orca Cloud profile: look up the filament id it puts in the slot. It is
+    # the one value OrcaSlicer's "Sync filaments" matches a slot by, so a
+    # generic here is what turned every Orca custom filament into "Generic
+    # <material>" in the slicer (#3216). Done here rather than in the browser so
+    # the outcome is in the log, and so a failure can be reported back.
+    orca_fallback_reason = ""
+    if orca_profile_id and not tray_info_idx:
+        orca_lookup = await lookup_orca_filament_id(db, current_user, orca_profile_id)
+        found = orca_lookup.filament_id
+        reason = orca_lookup.reason
+        if found and (
+            found.startswith("PFUS")
+            or found.startswith("PFCN")
+            or _ORCA_PROFILE_ID.fullmatch(found)
+            or is_material_name(found)
+        ):
+            # Same refusal as the guard above: never put an id the printer
+            # cannot store in the slot.
+            found, reason = "", "no_filament_id"
+        if found:
+            tray_info_idx = found
+        else:
+            orca_fallback_reason = reason or "no_filament_id"
+            # The generic the Configure dialog picked for an Orca profile before
+            # the lookup moved here -- its table, not the shorter one below, so
+            # PLA Silk, PCTG, PP and PE keep their own generics.
+            tray_info_idx = _orca_generic_filament_id(requested_tray_type)
+        logger.info(
+            "[configure_ams_slot] Orca profile %r → tray_info_idx=%r (%s)",
+            orca_profile_id,
+            tray_info_idx,
+            orca_lookup.source or orca_fallback_reason,
+        )
+
     # Resolve tray_info_idx for the MQTT command.
     # Priority:
     #   1. Use the provided tray_info_idx if set, once the guard above has had
@@ -2934,7 +3027,11 @@ async def configure_ams_slot(
                     current_tray_type = cur_tray.get("tray_type", "")
 
         if (
-            current_tray_info_idx
+            # An Orca profile without an id must not inherit whatever specific
+            # filament the slot held before: that would put the previous spool's
+            # preset in front of the slicer. It gets the generic for its material.
+            not orca_profile_id
+            and current_tray_info_idx
             and current_tray_info_idx not in _GENERIC_ID_VALUES
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
@@ -2981,11 +3078,13 @@ async def configure_ams_slot(
     # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
     # which the slicer rejects in tray_info_idx).
     effective_setting_id = setting_id
+    realigned_to_kprofile = False
     if (
         kprofile_filament_id
         and kprofile_filament_id != effective_tray_info_idx
         and not kprofile_filament_id.startswith("PFUS")
     ):
+        realigned_to_kprofile = True
         logger.info(
             "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
             effective_tray_info_idx,
@@ -3243,6 +3342,13 @@ async def configure_ams_slot(
     return {
         "success": True,
         "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
+        # What the slot was actually given, for the slot preset row (#3216).
+        "tray_info_idx": effective_tray_info_idx,
+        # Set when an Orca profile's own filament id could not be used, so the
+        # slicer will see the generic for the material: "no_filament_id",
+        # "lookup_failed" or "no_permission". Cleared when a K-profile realigned
+        # the slot to its own specific filament after all.
+        "orca_fallback_reason": "" if realigned_to_kprofile else orca_fallback_reason,
     }
 
 

+ 5 - 0
backend/app/core/database.py

@@ -5197,6 +5197,11 @@ async def run_migrations(conn):
             text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
         )
 
+    # Migration: the filament id a slot preset was written with, so the slot
+    # card can tell when something else re-configured the slot (#3216).
+    # Nullable: existing rows have none and keep being shown as before.
+    await _safe_execute(conn, "ALTER TABLE slot_preset_mappings ADD COLUMN tray_info_idx VARCHAR(32)")
+
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 6 - 0
backend/app/models/slot_preset.py

@@ -25,6 +25,12 @@ class SlotPresetMapping(Base):
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
     preset_source: Mapped[str] = mapped_column(String(20), default="cloud")  # cloud or local
+    # The filament id the slot was configured with alongside this preset. When
+    # the printer later reports a different one, something else (the slicer's
+    # Device tab, the printer screen) re-configured the slot and this row no
+    # longer describes it (#3216). None for rows written before it was recorded
+    # and by callers that do not know it.
+    tray_info_idx: Mapped[str | None] = mapped_column(String(32), nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 144 - 30
backend/app/services/slicer_filament_resolver.py

@@ -36,6 +36,7 @@ from __future__ import annotations
 import json
 import logging
 import re
+from dataclasses import dataclass
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -57,28 +58,80 @@ logger = logging.getLogger(__name__)
 _ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
 
 
-async def _orca_filament_id(
+@dataclass(frozen=True)
+class OrcaFilamentLookup:
+    """Outcome of resolving an Orca Cloud profile to a printer filament id.
+
+    ``filament_id`` is empty when nothing usable was found; ``reason`` then says
+    why, so a caller in front of a user can say so instead of quietly sending
+    a generic id (#3216). ``source`` is ``"own"`` for the profile's own id,
+    ``"inherited"`` for one taken from a parent.
+    """
+
+    filament_id: str = ""
+    name: str | None = None
+    filament_type: str | None = None
+    source: str = ""
+    reason: str = ""
+
+
+# How many ``inherits`` hops to follow before giving up. Orca presets are one
+# or two deep in practice; the cap only guards against a cycle in hand-edited
+# profiles.
+_MAX_INHERITS_DEPTH = 5
+
+
+def _content_filament_id(content: dict) -> str:
+    raw = content.get("filament_id")
+    if isinstance(raw, list):
+        raw = raw[0] if raw else None
+    return raw.strip() if isinstance(raw, str) else ""
+
+
+def _catalog_filament_id(preset_name: str) -> str:
+    """Bambu catalog id for a Bambu system preset name, or ``""``.
+
+    An Orca profile that inherits a system preset ("Bambu PLA Basic @BBL H2D")
+    carries no id of its own. Its parent's id is the one the printer and the
+    slicer both know -- OrcaSlicer since 2.5 translates the Bambu catalog ids
+    at the printer boundary -- so it beats a generic for the material.
+    """
+    from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+    base = preset_name.split("@")[0].strip().lstrip("#").strip().lower()
+    if not base:
+        return ""
+    for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
+        if fname.lower() == base:
+            return fid
+    return ""
+
+
+async def lookup_orca_filament_id(
     db: AsyncSession,
     current_user: User | None,
     profile_id: str,
-) -> tuple[str, str | None, str | None]:
-    """Look up an Orca Cloud profile's own filament_id.
-
-    Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
-    profile cannot be fetched or carries no id of its own, which leaves the
-    caller on its generic fallback.
-
-    Best-effort by construction: this runs inside spool assignment, not a user
-    request, so a missing pairing, a revoked token or a lapsed permission must
-    degrade to the fallback rather than fail the assignment. That is also why
-    ``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
-    one composite reason, so a background caller cannot tell a real revocation
-    from a lost rotation race and must not wipe a working pairing on it. The
-    route path hits the same failure in front of a user and clears there.
+) -> OrcaFilamentLookup:
+    """Look up the filament id an Orca Cloud profile puts in an AMS slot.
+
+    OrcaSlicer resolves a slot to a preset by ``filament_id`` alone, so this is
+    the value that decides whether "Sync filaments" finds the user's profile or
+    falls back to Generic (#3216). Tried in order: the profile's own id; the
+    nearest parent among the user's Orca profiles that has one; the Bambu
+    catalog id of a Bambu system parent.
+
+    Best-effort by construction: this runs inside spool assignment and slot
+    configuration, so a missing pairing, a revoked token or a lapsed permission
+    must degrade to the caller's fallback rather than fail the request. That is
+    also why ``clear_on_auth_failure=False`` -- Orca reports every refresh
+    rejection with one composite reason, so this caller cannot tell a real
+    revocation from a lost rotation race and must not wipe a working pairing on
+    it. The Orca Cloud routes hit the same failure in front of a user and clear
+    there.
     """
     if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
-        logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
-        return ("", None, None)
+        logger.info("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
+        return OrcaFilamentLookup(reason="no_permission")
 
     svc = None
     try:
@@ -86,9 +139,70 @@ async def _orca_filament_id(
 
         svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
         profile = await svc.get_profile(profile_id)
+        content = profile.get("content") if isinstance(profile, dict) else None
+        if not isinstance(content, dict):
+            logger.info("Orca filament lookup for %r: profile has no content", profile_id)
+            return OrcaFilamentLookup(reason="no_filament_id")
+        raw_name = profile.get("name")
+        name = raw_name if isinstance(raw_name, str) and raw_name else None
+        filament_type = _preset_filament_type(content.get("filament_type"))
+
+        own = _content_filament_id(content)
+        if own:
+            logger.info("Orca filament lookup for %r: own filament_id %r", profile_id, own)
+            return OrcaFilamentLookup(own, name, filament_type, source="own")
+
+        parent = content.get("inherits")
+        parent = parent.strip() if isinstance(parent, str) else ""
+        by_name: dict[str, dict] | None = None
+        for _ in range(_MAX_INHERITS_DEPTH):
+            if not parent:
+                break
+            if by_name is None:
+                # One more pull, only for a profile that needs its parent. A
+                # failure here keeps the name and type already read, as the
+                # lookup returned them before it followed parents.
+                by_name = {}
+                try:
+                    entries = await svc.list_profiles()
+                except Exception as e:
+                    logger.info("Orca filament lookup for %r: could not read its parents: %s", profile_id, e)
+                    return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="lookup_failed")
+                for entry in entries:
+                    entry_content = entry.get("content") if isinstance(entry, dict) else None
+                    if not isinstance(entry_content, dict):
+                        continue
+                    for key in (entry.get("name"), entry_content.get("name")):
+                        if isinstance(key, str) and key:
+                            by_name.setdefault(key, entry_content)
+            parent_content = by_name.get(parent)
+            if parent_content is None:
+                catalog = _catalog_filament_id(parent)
+                if catalog:
+                    logger.info(
+                        "Orca filament lookup for %r: catalog filament_id %r of system parent %r",
+                        profile_id,
+                        catalog,
+                        parent,
+                    )
+                    return OrcaFilamentLookup(catalog, name, filament_type, source="inherited")
+                break
+            inherited = _content_filament_id(parent_content)
+            if inherited:
+                logger.info(
+                    "Orca filament lookup for %r: filament_id %r inherited from %r", profile_id, inherited, parent
+                )
+                return OrcaFilamentLookup(inherited, name, filament_type, source="inherited")
+            next_parent = parent_content.get("inherits")
+            parent = next_parent.strip() if isinstance(next_parent, str) else ""
+
+        logger.info("Orca filament lookup for %r: no filament_id on the profile or its parents", profile_id)
+        return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="no_filament_id")
     except Exception as e:
-        logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
-        return ("", None, None)
+        # INFO, not DEBUG: this is the line that explains a slot coming out as
+        # Generic in a support bundle (#3216).
+        logger.info("Orca filament lookup failed for %r: %s", profile_id, e)
+        return OrcaFilamentLookup(reason="lookup_failed")
     finally:
         # A raise in `finally` escapes the `except` above, so guard it: closing
         # an httpx client must never be what fails a spool assignment.
@@ -98,17 +212,17 @@ async def _orca_filament_id(
             except Exception as e:  # noqa: BLE001 - close() is best-effort
                 logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
 
-    content = profile.get("content") if isinstance(profile, dict) else None
-    if not isinstance(content, dict):
-        return ("", None, None)
-    raw_fid = content.get("filament_id")
-    filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
-    name = profile.get("name") if isinstance(profile, dict) else None
-    return (
-        filament_id,
-        name if isinstance(name, str) and name else None,
-        _preset_filament_type(content.get("filament_type")),
-    )
+
+async def _orca_filament_id(
+    db: AsyncSession,
+    current_user: User | None,
+    profile_id: str,
+) -> tuple[str, str | None, str | None]:
+    """``(filament_id, name, filament_type)`` for the spool resolver below --
+    all empty/None when nothing was found, which leaves it on its generic
+    fallback. See ``lookup_orca_filament_id``."""
+    found = await lookup_orca_filament_id(db, current_user, profile_id)
+    return (found.filament_id, found.name, found.filament_type)
 
 
 def _preset_filament_type(raw: object) -> str | None:

+ 12 - 0
backend/app/services/slot_preset_writer.py

@@ -36,11 +36,17 @@ async def upsert_slot_preset(
     preset_id: str,
     preset_name: str,
     preset_source: str = "cloud",
+    configured_tray_info_idx: str | None = None,
 ) -> None:
     """Primitive upsert. No-op when ``preset_id`` is empty (the column is
     NOT NULL on the model, and an empty string isn't a useful key to
     overwrite by). Soft-fails on DB errors so a broken upsert never
     cascades into the surrounding spool-assign flow.
+
+    ``configured_tray_info_idx`` is the filament id the slot was given along
+    with this preset, when the caller knows it; the slot card stops trusting
+    the row once the printer reports a different one (#3216). It is replaced
+    on every write, so a stale id never outlives the preset it came with.
     """
     if not preset_id:
         return
@@ -57,6 +63,7 @@ async def upsert_slot_preset(
             mapping.preset_id = preset_id
             mapping.preset_name = preset_name
             mapping.preset_source = preset_source
+            mapping.tray_info_idx = configured_tray_info_idx or None
         else:
             mapping = SlotPresetMapping(
                 printer_id=printer_id,
@@ -65,6 +72,7 @@ async def upsert_slot_preset(
                 preset_id=preset_id,
                 preset_name=preset_name,
                 preset_source=preset_source,
+                tray_info_idx=configured_tray_info_idx or None,
             )
             db.add(mapping)
         await db.commit()
@@ -89,6 +97,7 @@ async def upsert_slot_preset_for_spool(
     tray_sub_brands: str = "",
     tray_type: str = "",
     setting_id: str = "",
+    configured_tray_info_idx: str | None = None,
 ) -> None:
     """Convenience wrapper for internal-mode call sites — derives the
     (preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
@@ -120,6 +129,7 @@ async def upsert_slot_preset_for_spool(
         preset_id=preset_id,
         preset_name=preset_name,
         preset_source=preset_source,
+        configured_tray_info_idx=configured_tray_info_idx,
     )
 
 
@@ -155,4 +165,6 @@ async def upsert_slot_preset_for_spoolman_spool(
         preset_id=preset_id,
         preset_name=preset_name or "",
         preset_source="cloud",
+        # The AMS-reported id this row was derived from.
+        configured_tray_info_idx=tray_info_idx,
     )

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

@@ -379,6 +379,9 @@ class TestAssignSpoolPresetMapping:
         # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
         assert presets["1"]["preset_name"] == "Bambu PLA Silk"
         assert presets["1"]["preset_id"] == "GFSA05"
+        # The filament id the slot was given goes with it, so the slot card can
+        # tell a later re-configuration from elsewhere (#3216).
+        assert presets["1"]["tray_info_idx"] == "GFA05"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -398,6 +401,7 @@ class TestAssignSpoolPresetMapping:
             preset_id="GFSA01",
             preset_name="Bambu PLA Matte",
             preset_source="cloud",
+            tray_info_idx="GFA01",
         )
         db_session.add(old_mapping)
         await db_session.commit()
@@ -435,6 +439,8 @@ class TestAssignSpoolPresetMapping:
         # Old "Bambu PLA Matte" must be overwritten
         assert presets["2"]["preset_name"] == "Generic PLA Silk"
         assert presets["2"]["preset_id"] == "GFSL96"
+        # ...and so must the filament id recorded with it (#3216).
+        assert presets["2"]["tray_info_idx"] == "GFL96"
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 268 - 0
backend/tests/integration/test_orca_slot_filament_id_3216.py

@@ -0,0 +1,268 @@
+"""An Orca Cloud profile reaches the AMS slot with its own filament id (#3216).
+
+OrcaSlicer's "Sync filaments" resolves a slot to a preset by ``filament_id``
+alone. The Configure dialog used to look the id up in the browser and, when
+that came back empty, quietly sent the generic for the material -- so every
+Orca custom filament reached the slicer as "Generic <material>", and nothing
+in the log said why. The lookup now runs in ``configure`` and its outcome is
+reported back.
+
+The slot preset row also records the filament id it was written with, so the
+slot card can tell when OrcaSlicer's Device tab re-configured the slot.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.slicer_filament_resolver import OrcaFilamentLookup
+
+ORCA_ID = "34d8f588-860b-5be1-bcbe-c0d46d96324b"
+
+
+def _client():
+    client = MagicMock()
+    client.ams_set_filament_setting.return_value = True
+    client.extrusion_cali_sel.return_value = True
+    client.request_status_update.return_value = True
+    return client
+
+
+def _status(tray_info_idx: str = "", tray_type: str = ""):
+    status = MagicMock()
+    status.raw_data = {
+        "ams": {
+            "ams": [
+                {"id": 0, "tray": [{"id": 2, "tray_info_idx": tray_info_idx, "tray_type": tray_type}]},
+            ]
+        }
+    }
+    return status
+
+
+async def _configure(async_client: AsyncClient, printer_id: int, **extra):
+    params = {
+        "tray_info_idx": "",
+        "tray_type": "PLA",
+        "tray_sub_brands": "XXXXX EXAMPLE PLA Example",
+        "tray_color": "FCECD6FF",
+        "nozzle_temp_min": 190,
+        "nozzle_temp_max": 230,
+        "orca_profile_id": ORCA_ID,
+    }
+    params.update(extra)
+    return await async_client.post(f"/api/v1/printers/{printer_id}/slots/0/2/configure", params=params)
+
+
+class TestConfigureWithOrcaProfile:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_profiles_own_filament_id_goes_to_the_slot(self, async_client: AsyncClient, printer_factory):
+        """The id OrcaSlicer itself sends from its Device tab: Pfc74047 in the
+        reporter's log, where Bambuddy sent GFL99 for the same profile."""
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock(return_value=OrcaFilamentLookup("Pfc74047", source="own"))
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id)
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["tray_info_idx"] == "Pfc74047"
+        assert body["orca_fallback_reason"] == ""
+        assert lookup.await_args.args[2] == ORCA_ID
+        kwargs = client.ams_set_filament_setting.call_args.kwargs
+        assert kwargs["tray_info_idx"] == "Pfc74047"
+        # Never the profile UUID: it is foreign to the printer and to the slicer.
+        assert ORCA_ID not in kwargs["setting_id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_id_falls_back_to_the_generic_and_says_so(self, async_client: AsyncClient, printer_factory):
+        """Not the slot's previous filament: reusing it would put the previous
+        spool's preset in front of the slicer for a profile the user just chose."""
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="lookup_failed"))
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status("P4d64437", "PLA")
+            response = await _configure(async_client, printer.id)
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["tray_info_idx"] == "GFL99"
+        assert body["orca_fallback_reason"] == "lookup_failed"
+        assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_explicit_filament_id_skips_the_lookup(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id, tray_info_idx="Pad3f856")
+
+        assert response.status_code == 200
+        lookup.assert_not_awaited()
+        assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "Pad3f856"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_presets_report_no_fallback(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="H2D")
+        client = _client()
+
+        with patch("backend.app.api.routes.printers.printer_manager") as pm:
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id, tray_info_idx="GFA00", orca_profile_id="")
+
+        assert response.status_code == 200
+        assert response.json()["orca_fallback_reason"] == ""
+        assert response.json()["tray_info_idx"] == "GFA00"
+
+
+class TestOrcaFallbackKeepsTheDialogsGenerics:
+    """The fallback the Configure dialog used to pick in the browser, kept when
+    the lookup moved to the server: its table has Silk, PCTG, PP and PE."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("material", "expected"),
+        [
+            ("PLA SILK", "GFL96"),
+            ("PCTG", "GFG97"),
+            ("PP", "GFP97"),
+            ("PETG-CF", "GFG98"),
+            ("ASA-CF", "GFB98"),
+            ("PLA+", "GFL99"),
+        ],
+    )
+    async def test_material_generic(self, async_client: AsyncClient, printer_factory, material, expected):
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="no_filament_id"))
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id, tray_type=material)
+
+        assert response.status_code == 200
+        assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == expected
+        assert response.json()["orca_fallback_reason"] == "no_filament_id"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_unstorable_looked_up_id_is_refused(self, async_client: AsyncClient, printer_factory):
+        """Same refusal as the #3003 guard: an 18-character PFUS would be
+        stored truncated and resolve nowhere."""
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock(return_value=OrcaFilamentLookup("PFUS9ac902733670a9", source="own"))
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id)
+
+        assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL99"
+        assert response.json()["orca_fallback_reason"] == "no_filament_id"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_kprofile_realignment_clears_the_warning(self, async_client: AsyncClient, printer_factory):
+        """The slot ends up on the K-profile's own filament, not a generic."""
+        printer = await printer_factory(name="H2D")
+        client = _client()
+        lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="no_filament_id"))
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as pm,
+            patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
+        ):
+            pm.get_client.return_value = client
+            pm.get_status.return_value = _status()
+            response = await _configure(async_client, printer.id, kprofile_filament_id="P4d64437")
+
+        assert response.status_code == 200
+        assert response.json()["tray_info_idx"] == "P4d64437"
+        assert response.json()["orca_fallback_reason"] == ""
+
+
+class TestSlotPresetRecordsItsFilamentId:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_saved_and_returned(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="H2D")
+        url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
+        response = await async_client.put(
+            url,
+            params={
+                "preset_id": f"orca_{ORCA_ID}",
+                "preset_name": "XXXXX EXAMPLE PLA Example",
+                "preset_source": "orca_cloud",
+                "tray_info_idx": "Pfc74047",
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["tray_info_idx"] == "Pfc74047"
+
+        assert (await async_client.get(url)).json()["tray_info_idx"] == "Pfc74047"
+        listed = (await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")).json()
+        assert listed["2"]["tray_info_idx"] == "Pfc74047"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_save_without_one_clears_the_old_one(self, async_client: AsyncClient, printer_factory):
+        """A stale id must not outlive the preset it came with: the slot card
+        would compare the new preset against the old preset's filament."""
+        printer = await printer_factory(name="H2D")
+        url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
+        await async_client.put(
+            url, params={"preset_id": "GFSL99", "preset_name": "Generic PLA", "tray_info_idx": "GFL99"}
+        )
+        response = await async_client.put(
+            url, params={"preset_id": "local_5", "preset_name": "Mine", "preset_source": "local"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["tray_info_idx"] is None
+        assert (await async_client.get(url)).json()["tray_info_idx"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_overlong_id_is_dropped_not_refused(self, async_client: AsyncClient, printer_factory):
+        """Refusing it would lose the preset save itself, which the dialog only
+        logs; recording nothing keeps the row shown as before."""
+        printer = await printer_factory(name="H2D")
+        url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
+        response = await async_client.put(url, params={"preset_id": "x", "preset_name": "X", "tray_info_idx": "P" * 40})
+        assert response.status_code == 200
+        assert response.json()["tray_info_idx"] is None

+ 93 - 0
backend/tests/unit/services/test_slicer_filament_resolver.py

@@ -483,3 +483,96 @@ class TestOrcaCloudIsTheFirstSource:
             )
         assert idx == ""
         assert sid == ""
+
+
+class TestOrcaLookupFollowsParents:
+    """``lookup_orca_filament_id`` (#3216).
+
+    A profile that inherits carries only its overrides, so its filament id lives
+    on a parent: another of the user's Orca profiles, or a system preset. The
+    parent's id is what OrcaSlicer resolves the slot to, which beats a generic.
+    """
+
+    ORCA_ID = "34d8f588-860b-5be1-bcbe-c0d46d96324b"
+
+    @staticmethod
+    def _svc(profile, others=()):
+        svc = MagicMock()
+        svc.get_profile = AsyncMock(return_value=profile)
+        svc.list_profiles = AsyncMock(return_value=[profile, *others])
+        svc.close = AsyncMock()
+        return svc
+
+    async def _lookup(self, svc):
+        from backend.app.services.slicer_filament_resolver import lookup_orca_filament_id
+
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            return await lookup_orca_filament_id(MagicMock(), None, self.ORCA_ID)
+
+    @pytest.mark.asyncio
+    async def test_own_id_needs_no_second_pull(self):
+        svc = self._svc({"id": self.ORCA_ID, "name": "Mine", "content": {"filament_id": "Pfc74047", "inherits": ""}})
+        found = await self._lookup(svc)
+        assert (found.filament_id, found.source, found.reason) == ("Pfc74047", "own", "")
+        svc.list_profiles.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_inherited_from_a_user_profile(self):
+        child = {"id": self.ORCA_ID, "name": "Mine v2", "content": {"inherits": "Mine v1", "filament_type": ["PLA"]}}
+        parent = {"id": "p", "name": "Mine v1", "content": {"name": "Mine v1", "filament_id": "Pad3f856"}}
+        found = await self._lookup(self._svc(child, [parent]))
+        assert (found.filament_id, found.source) == ("Pad3f856", "inherited")
+        assert found.filament_type == "PLA"
+
+    @pytest.mark.asyncio
+    async def test_through_two_user_profiles_to_a_bambu_system_preset(self):
+        child = {"id": self.ORCA_ID, "name": "C", "content": {"inherits": "B"}}
+        middle = {"id": "b", "name": "B", "content": {"inherits": "Bambu PLA Basic @BBL H2D"}}
+        found = await self._lookup(self._svc(child, [middle]))
+        # The catalog id OrcaSlicer translates at the printer boundary.
+        assert (found.filament_id, found.source) == ("GFA00", "inherited")
+
+    @pytest.mark.asyncio
+    async def test_an_unknown_system_parent_leaves_the_fallback(self):
+        child = {"id": self.ORCA_ID, "name": "C", "content": {"inherits": "Someone PLA @Somewhere"}}
+        found = await self._lookup(self._svc(child))
+        assert (found.filament_id, found.reason) == ("", "no_filament_id")
+
+    @pytest.mark.asyncio
+    async def test_an_inherits_cycle_terminates(self):
+        a = {"id": self.ORCA_ID, "name": "A", "content": {"inherits": "B"}}
+        b = {"id": "b", "name": "B", "content": {"inherits": "A"}}
+        found = await self._lookup(self._svc(a, [b]))
+        assert (found.filament_id, found.reason) == ("", "no_filament_id")
+
+    @pytest.mark.asyncio
+    async def test_a_failed_lookup_says_so(self):
+        svc = MagicMock()
+        svc.get_profile = AsyncMock(side_effect=RuntimeError("network down"))
+        svc.close = AsyncMock()
+        found = await self._lookup(svc)
+        assert (found.filament_id, found.reason) == ("", "lookup_failed")
+        svc.close.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_caller_without_orca_access_is_told_so(self):
+        from backend.app.services.slicer_filament_resolver import lookup_orca_filament_id
+
+        user = MagicMock()
+        user.has_permission.return_value = False
+        found = await lookup_orca_filament_id(MagicMock(), user, self.ORCA_ID)
+        assert found.reason == "no_permission"
+
+    @pytest.mark.asyncio
+    async def test_a_failed_parent_pull_keeps_name_and_type(self):
+        """The spool resolver uses them for the slot's brand and type, as it did
+        before the lookup followed parents."""
+        child = {"id": self.ORCA_ID, "name": "Mine v2", "content": {"inherits": "Mine v1", "filament_type": ["PETG"]}}
+        svc = self._svc(child)
+        svc.list_profiles = AsyncMock(side_effect=RuntimeError("timeout"))
+        found = await self._lookup(svc)
+        assert (found.filament_id, found.reason) == ("", "lookup_failed")
+        assert (found.name, found.filament_type) == ("Mine v2", "PETG")

+ 82 - 25
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -250,22 +250,19 @@ describe('ConfigureAmsSlotModal', () => {
     expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
   });
 
-  it('uses an Orca Cloud profile\'s own filament_id instead of a generic (#3003)', async () => {
-    // Orca profile ids are UUIDs the printer cannot hold, so this branch used
-    // to skip the lookup entirely and send a generic — every Orca custom
-    // filament reached the slicer as "Generic PLA". The profile's slicer JSON
-    // carries a filament_id of exactly the storable shape; use it.
+  it('has the backend resolve an Orca Cloud profile\'s own filament_id (#3003, #3216)', async () => {
+    // OrcaSlicer's "Sync filaments" matches a slot by filament_id alone, so a
+    // generic here turns the profile into "Generic PLA" in the slicer. The
+    // lookup used to run here and fall back silently; it now runs in configure,
+    // which logs the outcome and reports a fallback.
     const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
     (api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
       filament: [{ setting_id: ORCA_ID, name: 'Overture Matte PLA @Orca', type: 'filament', is_custom: true }],
       printer: [],
       process: [],
     });
-    (api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
-      setting_id: ORCA_ID,
-      name: 'Overture Matte PLA @Orca',
-      type: 'filament',
-      setting: { filament_id: 'P56e1be0', filament_type: ['PLA'] },
+    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({
+      success: true, tray_info_idx: 'P56e1be0', orca_fallback_reason: '',
     });
 
     const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
@@ -278,27 +275,32 @@ describe('ConfigureAmsSlotModal', () => {
     fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
 
     await waitFor(() => {
-      expect(api.configureAmsSlot).toHaveBeenCalled();
+      expect(api.saveSlotPreset).toHaveBeenCalled();
     });
 
     const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
-    expect(payload.tray_info_idx).toBe('P56e1be0');
+    expect(payload.orca_profile_id).toBe(ORCA_ID);
+    expect(payload.tray_info_idx).toBe('');
     // The UUID is what the slicer cannot resolve; it goes in neither field.
     expect(payload.setting_id).toBe('');
+    expect(api.orcaCloudGetProfile).not.toHaveBeenCalled();
+    // The slot preset row records the id the slot was actually given, so the
+    // slot card notices when the Device tab re-configures the slot.
+    const saveArgs = (api.saveSlotPreset as ReturnType<typeof vi.fn>).mock.calls[0];
+    expect(saveArgs[5]).toBe('orca_cloud');
+    expect(saveArgs[6]).toBe('P56e1be0');
+    expect(screen.queryByText(/OrcaSlicer will see this slot as Generic/)).not.toBeInTheDocument();
   });
 
-  it('falls back to a generic when an Orca profile has no filament_id (#3003)', async () => {
+  it('warns when an Orca profile went into the slot as a generic (#3216)', async () => {
     const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
     (api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
       filament: [{ setting_id: ORCA_ID, name: 'Homebrew PLA @Orca', type: 'filament', is_custom: true }],
       printer: [],
       process: [],
     });
-    (api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
-      setting_id: ORCA_ID,
-      name: 'Homebrew PLA @Orca',
-      type: 'filament',
-      setting: {},
+    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({
+      success: true, tray_info_idx: 'GFL99', orca_fallback_reason: 'no_filament_id',
     });
 
     const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
@@ -310,13 +312,11 @@ describe('ConfigureAmsSlotModal', () => {
 
     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('GFL99');
-    expect(payload.setting_id).toBe('');
+    expect(
+      await screen.findByText(
+        'This Orca profile has no filament ID of its own, so OrcaSlicer will see this slot as Generic PLA.',
+      ),
+    ).toBeInTheDocument();
   });
 
   it('uses the filament_id nested under setting when the envelope has none (#3003)', async () => {
@@ -634,6 +634,63 @@ describe('ConfigureAmsSlotModal', () => {
     });
   });
 
+  it("does not carry the slot's K-profile over to a different filament (#3216)", async () => {
+    // X1C slot on Devil Design PLA with its K-profile active, re-configured for
+    // Azurefilm PLA Wood. The active profile used to join Azurefilm's list and
+    // win the auto-select; the backend then realigned the slot to that
+    // profile's filament, so the printer -- and OrcaSlicer -- stayed on Devil
+    // Design. Only a slot reset first made it work.
+    const DEVIL = '0ed901cd-6700-5772-80b2-25ed659df0d1';
+    const AZURE = '2161df52-3d0e-579a-b4b6-dfbc62b93b76';
+    (api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament: [
+        { setting_id: DEVIL, name: 'Devil Design PLA @Orca', type: 'filament', is_custom: true },
+        { setting_id: AZURE, name: 'Azurefilm PLA Wood @Orca', type: 'filament', is_custom: true },
+      ],
+      printer: [],
+      process: [],
+    });
+    const kp = (slot_id: number, name: string, filament_id: string, setting_id: string) => ({
+      slot_id, extruder_id: 0, nozzle_id: 'HS00-0.4', nozzle_diameter: '0.4', filament_id, name,
+      k_value: '0.020', n_coef: '0', ams_id: 0, tray_id: 0, setting_id,
+    });
+    (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+      profiles: [
+        kp(7, 'Devil Design PLA', 'P4d64437', 'PFUSedbf16b803ff3e'),
+        kp(9, 'Azurefilm PLA Wood', 'P285e239', 'PFUS220b72bdc39f90'),
+      ],
+    });
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: `orca_${DEVIL}`,
+      trayInfoIdx: 'P4d64437',
+      caliIdx: 7,
+      extruderId: 0,
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    // Reopening on the slot's own filament still shows its active profile.
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /Devil Design PLA/ })).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByText('Azurefilm PLA Wood @Orca'));
+    // Azurefilm's own profile is offered and picked; Devil Design's is not.
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /Azurefilm PLA Wood/ })).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.orca_profile_id).toBe(AZURE);
+    expect(payload.kprofile_filament_id).toBe('P285e239');
+    expect(payload.cali_idx).toBe(9);
+  });
+
   it("surfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)", async () => {
     // Repro from Spionkiller01: slot is physically loaded but unconfigured —
     // tray_type='', tray_info_idx='', no slot_preset_mappings row — so

+ 21 - 0
frontend/src/__tests__/utils/slotPresetDescribesTray.test.ts

@@ -67,4 +67,25 @@ describe('slotPresetDescribesTray', () => {
       expect(slotPresetDescribesTray(undefined, undefined)).toBe(true);
     });
   });
+
+  describe('with the filament id recorded alongside the row (#3216)', () => {
+    it('keeps an Orca preset while the slot still holds its id', () => {
+      expect(slotPresetDescribesTray('orca_34d8f588-860b-5be1-bcbe-c0d46d96324b', 'Pfc74047', 'Pfc74047')).toBe(true);
+    });
+
+    it('drops it once the slicer\'s Device tab re-configured the slot', () => {
+      // The reporter's case: Bambuddy's row said Generic PVA while the slot
+      // held the Orca filament OrcaSlicer had just written.
+      expect(slotPresetDescribesTray('GFSS99', 'Pfc74047', 'GFS99')).toBe(false);
+      expect(slotPresetDescribesTray('local_68', 'GFA00', 'P56e1be0')).toBe(false);
+    });
+
+    it('compares case-insensitively and without a version suffix', () => {
+      expect(slotPresetDescribesTray('PFUSa3b8b0c664c142', 'P8A85D5A', 'p8a85d5a')).toBe(true);
+    });
+
+    it('cannot judge a slot reporting no id, so keeps the row', () => {
+      expect(slotPresetDescribesTray('local_68', '', 'P56e1be0')).toBe(true);
+    });
+  });
 });

+ 19 - 3
frontend/src/api/client.ts

@@ -2985,6 +2985,9 @@ export interface SlotPresetMapping {
   tray_id: number;
   preset_id: string;
   preset_name: string;
+  // Filament id the slot was configured with alongside this preset; null for
+  // rows that predate it or whose writer did not know it (#3216).
+  tray_info_idx?: string | null;
 }
 
 // Filament types
@@ -6422,8 +6425,8 @@ export const api = {
     request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
   getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
     request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
-  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>
-    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {
+  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud', trayInfoIdx?: string) =>
+    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}${trayInfoIdx ? `&tray_info_idx=${encodeURIComponent(trayInfoIdx)}` : ''}`, {
       method: 'PUT',
     }),
   deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
@@ -6464,6 +6467,9 @@ export const api = {
       kprofile_filament_id?: string;
       kprofile_setting_id?: string;
       k_value?: number;
+      // Orca Cloud profile the slot is set to; the backend looks up its
+      // filament id when tray_info_idx is empty (#3216).
+      orca_profile_id?: string;
     }
   ) => {
     const params = new URLSearchParams({
@@ -6488,7 +6494,17 @@ export const api = {
     if (config.k_value !== undefined && config.k_value > 0) {
       params.set('k_value', config.k_value.toString());
     }
-    return request<{ success: boolean; message: string }>(
+    if (config.orca_profile_id) {
+      params.set('orca_profile_id', config.orca_profile_id);
+    }
+    return request<{
+      success: boolean;
+      message: string;
+      // The filament id the slot was actually given.
+      tray_info_idx?: string;
+      // Why an Orca profile went out as the generic for its material, or "".
+      orca_fallback_reason?: '' | 'no_filament_id' | 'lookup_failed' | 'no_permission';
+    }>(
       `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
       { method: 'POST' }
     );

+ 43 - 38
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -10,6 +10,7 @@ import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
 import { getSwatchStyle } from '../utils/colors';
 import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
+import { useToast } from '../contexts/ToastContext';
 
 interface SlotInfo {
   amsId: number;
@@ -274,7 +275,12 @@ export function ConfigureAmsSlotModal({
   fullScreen,
 }: ConfigureAmsSlotModalProps) {
   const { t } = useTranslation();
+  const { showToast } = useToast();
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
+  // The preset the dialog preselected on open, i.e. what the slot is already
+  // on. Lets the K-profile list tell "reopened the slot" from "picked another
+  // filament" (#3216).
+  const [openedPresetId, setOpenedPresetId] = useState<string>('');
   const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
   // The same value, readable at mutation-execute time rather than at
   // closure-capture time. useMutation hands its options to the observer from an
@@ -459,8 +465,7 @@ export function ConfigureAmsSlotModal({
       const parsedMat = parsed.material.toUpperCase();
 
       // Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
-      // presets that don't carry a Bambu setting_id of their own (local
-      // imports and Orca Cloud sync both fall in this bucket). The printer's
+      // local imports, which carry no Bambu setting_id of their own. The printer's
       // firmware needs SOMETHING in tray_info_idx to recognize the filament
       // type for HMS / drying / colour-matching; the closest generic Bambu
       // filament for the parsed material is the right choice.
@@ -487,40 +492,16 @@ export function ConfigureAmsSlotModal({
           || '';
         settingId = '';
       } else if (isOrca) {
-        // An Orca Cloud profile's setting_id is a UUID the printer cannot hold,
-        // so this used to skip straight to a generic — which meant every Orca
-        // custom filament reached the slicer as "Generic <material>", without
-        // ever asking whether the profile had a usable id (#3003).
+        // The slot needs the profile's own filament_id: it is the one value
+        // OrcaSlicer's "Sync filaments" matches a slot by, so anything else
+        // reaches the slicer as "Generic <material>" (#3003, #3216). The
+        // backend looks it up from orca_profile_id -- following the profile's
+        // parents when it has none of its own -- logs the outcome, and falls
+        // back to the generic for the material only when there is none.
         //
-        // It usually does. The profile's slicer JSON carries its own
-        // filament_id, the same 8-character "P…" the printer stores for a
-        // Bambu custom preset, and OrcaProfileDetail deliberately exposes that
-        // JSON under `setting` in the shape SlicerSettingDetail uses. So the
-        // lookup is the same one the Bambu Cloud branch does below.
-        //
-        // settingId stays empty either way: it is the UUID that is foreign to
-        // the slicer, not the filament id.
-        let orcaFilamentId = '';
-        try {
-          const orcaDetail = await api.orcaCloudGetProfile(orcaSettingId);
-          const fromProfile = orcaDetail.setting?.filament_id;
-          if (typeof fromProfile === 'string' && fromProfile) {
-            orcaFilamentId = fromProfile;
-          }
-        } catch (e) {
-          console.warn('Failed to fetch Orca Cloud profile for filament_id:', e);
-        }
-        trayInfoIdx = orcaFilamentId;
-        if (!trayInfoIdx) {
-          // No id of its own — fall back to the generic for the material, the
-          // same last resort every other source ends at.
-          const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
-          trayInfoIdx = GENERIC_IDS[material]
-            || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
-            || GENERIC_IDS[material.replace(/\+$/, '')]
-            || GENERIC_IDS[material.split(/[-\s]/)[0]]
-            || '';
-        }
+        // settingId stays empty: the profile id is a UUID, foreign to the
+        // printer and to the slicer's slot matching alike.
+        trayInfoIdx = '';
         settingId = '';
       } else if (isBuiltin) {
         // Built-in presets use the filament_id directly as tray_info_idx
@@ -636,8 +617,16 @@ export function ConfigureAmsSlotModal({
         kprofile_setting_id: selectedKProfileRef.current?.setting_id || undefined,
         // Also pass the K value directly for extrusion_cali_set command
         k_value: kValue,
+        orca_profile_id: isOrca ? orcaSettingId : undefined,
       });
 
+      if (isOrca && result.orca_fallback_reason) {
+        showToast(
+          t(`configureAmsSlot.orcaFallback.${result.orca_fallback_reason}`, { material: trayType }),
+          'warning',
+        );
+      }
+
       // Save the preset mapping so we can display the correct name in the UI
       // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
       // which can't be resolved to a name via the filamentInfo API
@@ -656,7 +645,9 @@ export function ConfigureAmsSlotModal({
             ? 'orca_cloud'
             : 'cloud';
       try {
-        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
+        // The filament id goes with it, so the slot card can tell when the slot
+        // is re-configured from elsewhere and stop showing this preset (#3216).
+        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource, result.tray_info_idx);
       } catch (e) {
         console.warn('Failed to save slot preset mapping:', e);
         // Don't fail the whole operation - slot was configured successfully
@@ -1038,14 +1029,23 @@ export function ConfigureAmsSlotModal({
     // the printer whose filament_id differs from "Generic PLA"; without this
     // safety net the modal shows "not assigned, default 0.020" while the printer
     // card's hover-card correctly shows the active profile.
+    //
+    // Only while the selected preset is still the slot's own, though: the
+    // active profile belongs to whatever filament the slot is on now. Offered
+    // for a different filament, the auto-select below picked it, and the
+    // backend then realigned the slot to that profile's filament -- so
+    // configuring Azurefilm on a slot that held Devil Design re-sent Devil
+    // Design (#3216).
     const activeIdx = slotInfo.caliIdx;
-    if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
+    const onSlotsOwnPreset = (!!openedPresetId && selectedPresetId === openedPresetId)
+      || (!!presetFid && presetFid === toFilamentId(slotInfo.trayInfoIdx));
+    if (onSlotsOwnPreset && activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
       const active = findProfileByCaliIdx(kprofilesData.profiles, activeIdx, slotInfo.extruderId);
       if (active) result.unshift(active);
     }
 
     return result;
-  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
+  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx, slotInfo.trayInfoIdx, selectedPresetId, openedPresetId]);
 
   // Every remaining K-profile the printer holds, offered under a separate group
   // after the matching ones (#2710). The matcher works off preset names and
@@ -1088,8 +1088,10 @@ export function ConfigureAmsSlotModal({
       // The spool's own per-model preset first -- see the query above.
       if (slotSpoolDefaults?.slicer_filament) {
         setSelectedPresetId(slotSpoolDefaults.slicer_filament);
+        setOpenedPresetId(slotSpoolDefaults.slicer_filament);
       } else if (slotInfo.savedPresetId) {
         setSelectedPresetId(slotInfo.savedPresetId);
+        setOpenedPresetId(slotInfo.savedPresetId);
       } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
         // Fallback: try to match by tray_info_idx in cloud presets
         // First try exact match on setting_id
@@ -1104,6 +1106,7 @@ export function ConfigureAmsSlotModal({
         }
         if (currentPreset) {
           setSelectedPresetId(currentPreset.setting_id);
+          setOpenedPresetId(currentPreset.setting_id);
         }
       } else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {
         // Last resort: match trayInfoIdx against builtin presets
@@ -1111,6 +1114,7 @@ export function ConfigureAmsSlotModal({
         const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);
         if (match) {
           setSelectedPresetId(`builtin_${match.filament_id}`);
+          setOpenedPresetId(`builtin_${match.filament_id}`);
         }
       }
 
@@ -1124,6 +1128,7 @@ export function ConfigureAmsSlotModal({
     } else {
       // Reset when modal closes
       setSelectedPresetId('');
+      setOpenedPresetId('');
       setSelectedKProfile(null);
       setColorHex('');
       setColorInput('');

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -6861,6 +6861,12 @@ export default {
     cancel: 'Abbrechen',
     configuring: 'Wird konfiguriert...',
     configureSlot: 'Slot konfigurieren',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Dieses Orca-Profil hat keine eigene Filament-ID, daher sieht OrcaSlicer diesen Slot als Generic {{material}}.',
+      lookup_failed: 'Die Filament-ID dieses Profils konnte nicht aus der Orca Cloud gelesen werden, daher sieht OrcaSlicer diesen Slot als Generic {{material}}. Versuche es gleich noch einmal.',
+      no_permission: 'Du hast keinen Zugriff auf die Orca Cloud, daher konnte die Filament-ID dieses Profils nicht gelesen werden. OrcaSlicer sieht diesen Slot als Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -6912,6 +6912,12 @@ export default {
     cancel: 'Cancel',
     configuring: 'Configuring...',
     configureSlot: 'Configure Slot',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'This Orca profile has no filament ID of its own, so OrcaSlicer will see this slot as Generic {{material}}.',
+      lookup_failed: 'Couldn\'t read this profile\'s filament ID from Orca Cloud, so OrcaSlicer will see this slot as Generic {{material}}. Try again in a moment.',
+      no_permission: 'You don\'t have Orca Cloud access, so this profile\'s filament ID couldn\'t be read. OrcaSlicer will see this slot as Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -6869,6 +6869,12 @@ export default {
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar ranura',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Este perfil de Orca no tiene un ID de filamento propio, así que OrcaSlicer verá esta ranura como Generic {{material}}.',
+      lookup_failed: 'No se pudo leer el ID de filamento de este perfil desde Orca Cloud, así que OrcaSlicer verá esta ranura como Generic {{material}}. Inténtalo de nuevo en un momento.',
+      no_permission: 'No tienes acceso a Orca Cloud, así que no se pudo leer el ID de filamento de este perfil. OrcaSlicer verá esta ranura como Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -6849,6 +6849,12 @@ export default {
     cancel: 'Annuler',
     configuring: 'Configuration...',
     configureSlot: 'Configurer le slot',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Ce profil Orca n’a pas d’identifiant de filament propre : OrcaSlicer verra cet emplacement comme Generic {{material}}.',
+      lookup_failed: 'Impossible de lire l’identifiant de filament de ce profil depuis Orca Cloud : OrcaSlicer verra cet emplacement comme Generic {{material}}. Réessayez dans un instant.',
+      no_permission: 'Vous n’avez pas accès à Orca Cloud, l’identifiant de filament de ce profil n’a donc pas pu être lu. OrcaSlicer verra cet emplacement comme Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -6848,6 +6848,12 @@ export default {
     cancel: 'Annulla',
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Questo profilo Orca non ha un ID filamento proprio, quindi OrcaSlicer vedrà questo slot come Generic {{material}}.',
+      lookup_failed: 'Impossibile leggere l’ID filamento di questo profilo da Orca Cloud, quindi OrcaSlicer vedrà questo slot come Generic {{material}}. Riprova tra poco.',
+      no_permission: 'Non hai accesso a Orca Cloud, quindi non è stato possibile leggere l’ID filamento di questo profilo. OrcaSlicer vedrà questo slot come Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -6862,6 +6862,12 @@ export default {
     cancel: 'キャンセル',
     configuring: '設定中...',
     configureSlot: 'スロットを設定',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'このOrcaプロファイルには独自のフィラメントIDがないため、OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。',
+      lookup_failed: 'Orca CloudからこのプロファイルのフィラメントIDを読み取れなかったため、OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。しばらくしてから再試行してください。',
+      no_permission: 'Orca Cloudへのアクセス権がないため、このプロファイルのフィラメントIDを読み取れませんでした。OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。',
+    },
   },
 
   // Git Backup Settings

+ 7 - 1
frontend/src/i18n/locales/ko.ts

@@ -6546,7 +6546,13 @@ export default {
     resetSlot: '슬롯 초기화',
     cancel: '취소',
     configuring: '구성 중...',
-    configureSlot: '슬롯 구성'
+    configureSlot: '슬롯 구성',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: '이 Orca 프로필에는 자체 필라멘트 ID가 없어 OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다.',
+      lookup_failed: 'Orca Cloud에서 이 프로필의 필라멘트 ID를 읽지 못해 OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다. 잠시 후 다시 시도하세요.',
+      no_permission: 'Orca Cloud 접근 권한이 없어 이 프로필의 필라멘트 ID를 읽을 수 없었습니다. OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다.',
+    },
   },
   githubBackup: {
     title: 'Git 백업',

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -6912,6 +6912,12 @@ export default {
     cancel: 'Annuleren',
     configuring: 'Configureren...',
     configureSlot: 'Sleuf configureren',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Dit Orca-profiel heeft geen eigen filament-ID, dus OrcaSlicer ziet deze sleuf als Generic {{material}}.',
+      lookup_failed: 'De filament-ID van dit profiel kon niet uit Orca Cloud worden gelezen, dus OrcaSlicer ziet deze sleuf als Generic {{material}}. Probeer het zo opnieuw.',
+      no_permission: 'Je hebt geen toegang tot Orca Cloud, dus de filament-ID van dit profiel kon niet worden gelezen. OrcaSlicer ziet deze sleuf als Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6848,6 +6848,12 @@ export default {
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar Slot',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Este perfil do Orca não tem um ID de filamento próprio, então o OrcaSlicer verá este slot como Generic {{material}}.',
+      lookup_failed: 'Não foi possível ler o ID de filamento deste perfil no Orca Cloud, então o OrcaSlicer verá este slot como Generic {{material}}. Tente novamente em instantes.',
+      no_permission: 'Você não tem acesso ao Orca Cloud, então o ID de filamento deste perfil não pôde ser lido. O OrcaSlicer verá este slot como Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -6534,6 +6534,12 @@ export default {
     cancel: "Отмена",
     configuring: "Настройка...",
     configureSlot: "Настроить слот",
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'У этого профиля Orca нет собственного ID филамента, поэтому OrcaSlicer увидит этот слот как Generic {{material}}.',
+      lookup_failed: 'Не удалось прочитать ID филамента этого профиля из Orca Cloud, поэтому OrcaSlicer увидит этот слот как Generic {{material}}. Повторите попытку чуть позже.',
+      no_permission: 'У вас нет доступа к Orca Cloud, поэтому ID филамента этого профиля прочитать не удалось. OrcaSlicer увидит этот слот как Generic {{material}}.',
+    },
   },
   githubBackup: {
     title: "Резервное копирование в Git",

+ 6 - 0
frontend/src/i18n/locales/sv.ts

@@ -6910,6 +6910,12 @@ errors: {
     cancel: 'Avbryt',
     configuring: 'Konfigurerar...',
     configureSlot: 'Konfigurera fack',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Den här Orca-profilen har inget eget filament-ID, så OrcaSlicer kommer att se den här platsen som Generic {{material}}.',
+      lookup_failed: 'Det gick inte att läsa profilens filament-ID från Orca Cloud, så OrcaSlicer kommer att se den här platsen som Generic {{material}}. Försök igen om en stund.',
+      no_permission: 'Du har inte åtkomst till Orca Cloud, så profilens filament-ID kunde inte läsas. OrcaSlicer kommer att se den här platsen som Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -6803,6 +6803,12 @@ export default {
     cancel: 'İptal',
     configuring: 'Yapılandırılıyor...',
     configureSlot: 'Yuvayı Yapılandır',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Bu Orca profilinin kendine ait bir filament kimliği yok, bu yüzden OrcaSlicer bu yuvayı Generic {{material}} olarak görecek.',
+      lookup_failed: 'Bu profilin filament kimliği Orca Cloud\'dan okunamadı, bu yüzden OrcaSlicer bu yuvayı Generic {{material}} olarak görecek. Birazdan tekrar deneyin.',
+      no_permission: 'Orca Cloud erişiminiz yok, bu yüzden bu profilin filament kimliği okunamadı. OrcaSlicer bu yuvayı Generic {{material}} olarak görecek.',
+    },
   },
 
   // Git Yedekleme Ayarları

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -6902,6 +6902,12 @@ export default {
     cancel: "Скасувати",
     configuring: "Налаштування...",
     configureSlot: "Налаштувати слот",
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: 'Цей профіль Orca не має власного ID філаменту, тому OrcaSlicer бачитиме цей слот як Generic {{material}}.',
+      lookup_failed: 'Не вдалося прочитати ID філаменту цього профілю з Orca Cloud, тому OrcaSlicer бачитиме цей слот як Generic {{material}}. Спробуйте ще раз за хвилину.',
+      no_permission: 'У вас немає доступу до Orca Cloud, тому ID філаменту цього профілю не вдалося прочитати. OrcaSlicer бачитиме цей слот як Generic {{material}}.',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6846,6 +6846,12 @@ export default {
     cancel: '取消',
     configuring: '配置中...',
     configureSlot: '配置槽位',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: '此 Orca 配置文件没有自己的耗材 ID,因此 OrcaSlicer 会将此槽位识别为 Generic {{material}}。',
+      lookup_failed: '无法从 Orca Cloud 读取此配置文件的耗材 ID,因此 OrcaSlicer 会将此槽位识别为 Generic {{material}}。请稍后重试。',
+      no_permission: '你没有 Orca Cloud 访问权限,因此无法读取此配置文件的耗材 ID。OrcaSlicer 会将此槽位识别为 Generic {{material}}。',
+    },
   },
 
   // Git Backup Settings

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6846,6 +6846,12 @@ export default {
     cancel: '取消',
     configuring: '設定中...',
     configureSlot: '設定槽位',
+    // Why an Orca profile went into the slot as the generic for its material (#3216)
+    orcaFallback: {
+      no_filament_id: '此 Orca 設定檔沒有自己的線材 ID,因此 OrcaSlicer 會將此槽位識別為 Generic {{material}}。',
+      lookup_failed: '無法從 Orca Cloud 讀取此設定檔的線材 ID,因此 OrcaSlicer 會將此槽位識別為 Generic {{material}}。請稍後再試。',
+      no_permission: '你沒有 Orca Cloud 存取權限,因此無法讀取此設定檔的線材 ID。OrcaSlicer 會將此槽位識別為 Generic {{material}}。',
+    },
   },
 
   // Git Backup Settings

+ 6 - 6
frontend/src/pages/PrintersPage.tsx

@@ -5525,7 +5525,7 @@ function PrinterCard({
                                 // Only trusted while it still describes what the printer reports in the
                                 // slot: the row survives a spool swap, and the display chain below puts
                                 // it ahead of the live filament id (see slotPresetDescribesTray).
-                                const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                                const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
                                   ? slotPreset?.preset_name
                                   : undefined;
 
@@ -5775,7 +5775,7 @@ function PrinterCard({
                                             trayInfoIdx: tray?.tray_info_idx || undefined,
                                             extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                             caliIdx: tray?.cali_idx,
-                                            savedPresetId: slotPreset?.preset_id,
+                                            savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx) ? slotPreset?.preset_id : undefined,
                                           }),
                                         }}
                                       >
@@ -5842,7 +5842,7 @@ function PrinterCard({
                       // Only trusted while it still describes what the printer reports in the
                       // slot: the row survives a spool swap, and the display chain below puts
                       // it ahead of the live filament id (see slotPresetDescribesTray).
-                      const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                      const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
                         ? slotPreset?.preset_name
                         : undefined;
                       const htSlotId = tray?.id ?? 0;
@@ -6191,7 +6191,7 @@ function PrinterCard({
                                         trayInfoIdx: tray?.tray_info_idx || undefined,
                                         extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                         caliIdx: tray?.cali_idx,
-                                        savedPresetId: slotPreset?.preset_id,
+                                        savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx) ? slotPreset?.preset_id : undefined,
                                       }),
                                     }}
                                   >
@@ -6292,7 +6292,7 @@ function PrinterCard({
                               // Only trusted while it still describes what the printer reports in the
                               // slot: the row survives a spool swap, and the display chain below puts
                               // it ahead of the live filament id (see slotPresetDescribesTray).
-                              const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx)
+                              const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
                                 ? extSlotPreset?.preset_name
                                 : undefined;
 
@@ -6483,7 +6483,7 @@ function PrinterCard({
                                           trayInfoIdx: extTray.tray_info_idx || undefined,
                                           extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
                                           caliIdx: extTray.cali_idx,
-                                          savedPresetId: extSlotPreset?.preset_id,
+                                          savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx) ? extSlotPreset?.preset_id : undefined,
                                         }),
                                       }}
                                     >

+ 8 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -6,7 +6,7 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
-import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
+import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter, slotPresetDescribesTray } from '../../utils/amsHelpers';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { mapModelCode } from '../../utils/printerModel';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
@@ -393,7 +393,10 @@ export function SpoolBuddyAmsPage() {
       trayInfoIdx: tray?.tray_info_idx || undefined,
       extruderId: isDualNozzle ? extruderId : undefined,
       caliIdx: tray?.cali_idx,
-      savedPresetId: slotPreset?.preset_id,
+      // Only while it still describes the slot (#3216).
+      savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
+        ? slotPreset?.preset_id
+        : undefined,
       location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
     };
 
@@ -415,7 +418,9 @@ export function SpoolBuddyAmsPage() {
       trayInfoIdx: extTray.tray_info_idx || undefined,
       extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
       caliIdx: extTray.cali_idx,
-      savedPresetId: extSlotPreset?.preset_id,
+      savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
+        ? extSlotPreset?.preset_id
+        : undefined,
       location: isDualNozzle
         ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
         : 'External',

+ 9 - 0
frontend/src/utils/amsHelpers.ts

@@ -675,13 +675,22 @@ export function isBambuLabSpool(tray: {
  * and a local preset (`local_68`) has no printer-side id at all, so neither can
  * be checked here and both keep the stored name. Same for a slot reporting no
  * id (generic filament with no tag), which is the case the row exists for.
+ *
+ * Rows written since #3216 also record the filament id the slot was given
+ * with the preset (`presetTrayInfoIdx`). That settles every kind of preset:
+ * once the printer reports a different id, something else — the slicer's
+ * Device tab, the printer's screen — re-configured the slot, and the row no
+ * longer describes it.
  */
 export function slotPresetDescribesTray(
   presetId: string | null | undefined,
   trayInfoIdx: string | null | undefined,
+  presetTrayInfoIdx?: string | null,
 ): boolean {
   const preset = (presetId || '').split('_')[0].toUpperCase();
   const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
+  const recorded = (presetTrayInfoIdx || '').split('_')[0].toUpperCase();
+  if (recorded && tray) return recorded === tray;
   if (!preset.startsWith('GFS') || !tray.startsWith('GF') || tray.startsWith('GFS')) return true;
   return `GF${preset.slice(3)}` === tray;
 }

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ImagePreviewModal-_TSWhu8N.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
static/assets/PdfPreviewModal-DxLjIO4z.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpreadsheetPreviewModal-sHZPv4Ot.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
static/assets/index-DCfEMLCI.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/pdf-BGtyfwRp.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-7qfiHY5t.js"></script>
+    <script type="module" crossorigin src="/assets/index-DCfEMLCI.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-CyW_3l56.css">
   </head>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä