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

fix(ams): resolve a custom filament's own id from every preset source (issue #3003)

A custom filament profile reaches an AMS slot as itself through exactly one
field, tray_info_idx, and every source we can read that id from was reading it
from the wrong place or not reading it at all.

Bambu Cloud returns a preset's own filament_id either on the response envelope
or inside the preset JSON under `setting`, and only the envelope was read.
Presets of the second shape fell through to the base_id branch and reached the
slicer as the Bambu filament they inherit from. filament_type next door already
handled both spreads; filament_id now does too.

Orca Cloud was absent from the resolver entirely. A spool stores the bare
profile UUID, which matched no branch and fell through normalize_slicer_filament
-- a function that passes anything it does not recognise straight through -- so
a 36-character UUID went into the field. Orca profiles carry their own
filament_id in the slicer JSON that OrcaProfileDetail already exposes under
`setting`, so the lookup is the same one the Bambu branch does. It is
best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
degrades to the fallback rather than failing the assignment, and it passes
clear_on_auth_failure=False because a background caller cannot tell a real
revocation from a lost refresh-rotation race.

configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
real filament id. That field is 8 characters on the printer -- exactly the width
of a local preset id, less than half a cloud one. Measured on the reporter's A1:
sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
The slot then resolved to nothing, so the slicer showed Generic anyway and the
calibration table, keyed by the same field, lost the slot. It now falls back to
the slot's existing filament id or the generic for the material, and the route's
guard was aligned with the resolver's so both refuse the same four shapes from
one shared definition.

This reverses the contract #1053 pinned. Six tests asserted that the PFUS
belonged in tray_info_idx; the A1 capture shows it never worked, so they were
rewritten with the measurement in their docstrings.

Verified against 874 AMS trays across twelve models in the support archive: 92
already carry a custom "P" + 7 hex filament id, which is what confirms the
mechanism works and this is a lookup failure rather than a platform limit. No
tray on any model carries a setting_id, so a profile with no filament_id of its
own still cannot be told apart from its base.
maziggy 40 минут назад
Родитель
Сommit
9434875fa1

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 43 - 3
backend/app/api/routes/printers.py

@@ -87,9 +87,10 @@ 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.slot_nozzle import resolve_slot_nozzle
 from backend.app.utils.filament_ids import filament_id_to_setting_id
-from backend.app.utils.filament_types import printer_filament_type
+from backend.app.utils.filament_types import is_material_name, printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
@@ -2849,10 +2850,49 @@ async def configure_ams_slot(
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
 
+    # Discard a tray_info_idx the printer cannot store (#3003).
+    #
+    # The field is 8 characters wide. A local preset id ("P" + 7 hex) is
+    # exactly 8, which is presumably why nobody noticed -- but a cloud
+    # *setting* id is 18, and the firmware keeps the first 8 and reports
+    # success. Measured on @marivo's A1 in the #3003 bundle:
+    #
+    #   sent      tray_info_idx=PFUS9ddc938fe3ab8f
+    #   printer   Assignment NOT confirmed: tray shows PFUS9DDC
+    #
+    # `PFUS9ddc` resolves to nothing anywhere, so the slot came out of the
+    # Configure modal as "Generic <material>" in the slicer -- strictly worse
+    # than the base filament it would have got from the fallback below, and it
+    # also breaks the calibration table, which is keyed by this field.
+    #
+    # Blanking it here is what hands the slot to the reuse / generic branch.
+    # The preset reference is not lost: it stays in setting_id, the field that
+    # does accept a PFUS. Same four rejected shapes, and the same reasoning, as
+    # `slicer_filament_resolver`'s closing guard -- which the assignment path
+    # has run since #1815 while Configure had none. The Orca profile UUID is on
+    # the list for the same reason as the rest: the modal no longer sends one,
+    # but this route is public API and 36 characters is the worst of the four
+    # against an 8-character field.
+    if tray_info_idx and (
+        tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+        or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
+        or is_material_name(tray_info_idx)
+    ):
+        logger.info(
+            "[configure_ams_slot] tray_info_idx %r is not storable as a filament id — "
+            "falling back to slot reuse / generic (kept as setting_id %r)",
+            tray_info_idx,
+            setting_id or tray_info_idx,
+        )
+        if not setting_id and (tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")):
+            setting_id = tray_info_idx
+        tray_info_idx = ""
+
     # Resolve tray_info_idx for the MQTT command.
     # Priority:
-    #   1. Use the provided tray_info_idx if set (including cloud-synced
-    #      custom presets like PFUS* / P*).
+    #   1. Use the provided tray_info_idx if set, once the guard above has had
+    #      its say (so: a GF* official or P* local id, never a PFUS/PFCN one).
     #   2. Reuse the slot's existing tray_info_idx if it's a specific
     #      (non-generic) preset for the same material.
     #   3. Fall back to a generic Bambu filament ID.

+ 103 - 4
backend/app/services/slicer_filament_resolver.py

@@ -35,10 +35,12 @@ from __future__ import annotations
 
 import json
 import logging
+import re
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -49,6 +51,65 @@ from backend.app.utils.filament_types import is_material_name
 
 logger = logging.getLogger(__name__)
 
+# Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
+# with no letter prefix to key off. A spool stores the bare id (the spool form
+# persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
+_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(
+    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.
+    """
+    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)
+
+    svc = None
+    try:
+        from backend.app.api.routes.orca_cloud import _build_authenticated_service
+
+        svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
+        profile = await svc.get_profile(profile_id)
+    except Exception as e:
+        logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
+        return ("", None, None)
+    finally:
+        # A raise in `finally` escapes the `except` above, so guard it: closing
+        # an httpx client must never be what fails a spool assignment.
+        if svc is not None:
+            try:
+                await svc.close()
+            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")),
+    )
+
 
 def _preset_filament_type(raw: object) -> str | None:
     """Read a slicer preset's ``filament_type`` field.
@@ -129,7 +190,26 @@ async def resolve_slicer_filament(
     # All three need a cloud-detail lookup to extract the underlying
     # filament_id; without it the raw cloud id ends up in tray_info_idx
     # and the printer's calibration table can't resolve it.
-    if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
+    # Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
+    # Orca goes first because its ids are the only ones identified by shape
+    # rather than prefix -- and because, before #3003, a UUID fell through every
+    # branch below into ``normalize_slicer_filament``, which passes anything it
+    # does not recognise straight through. A 36-character UUID then went into
+    # tray_info_idx, an 8-character field, and the slot ended up pointing at the
+    # first 8 characters of a UUID: the same failure the PFUS guard at the
+    # bottom of this function exists for.
+    if _ORCA_PROFILE_ID.fullmatch(base_sf):
+        tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
+        if orca_type:
+            type_override = orca_type
+        if orca_name:
+            sub_brand_override = orca_name.split("@")[0].strip()
+        # setting_id is left empty here: the UUID is what the slicer cannot
+        # resolve, and unlike a PFUS there is no cloud id form it accepts
+        # instead. All three callers then derive one from the filament_id
+        # (`filament_id_to_setting_id`), which is what keeps the slot from
+        # going out half configured -- the same path a local import takes.
+    elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
         setting_id = base_sf
         try:
             from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -147,8 +227,21 @@ async def resolve_slicer_filament(
                     type_override = _preset_filament_type(
                         (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
                     )
-                    if detail.get("filament_id"):
-                        tray_info_idx = detail["filament_id"]
+                    # A custom preset's OWN filament_id is the only thing that
+                    # gets it into an AMS slot as itself: the printer stores
+                    # that id, the slicer matches its presets against it, and
+                    # the 8-character field fits it exactly ("P" + 7 hex).
+                    # Bambu Cloud puts it in either of two places -- on the
+                    # envelope, or inside the preset JSON under `setting` --
+                    # the same spread `filament_type` above already handles.
+                    # Reading only the envelope is how a custom preset fell
+                    # through to the base_id branch below and reached the
+                    # slicer as the Bambu profile it inherits from (#3003).
+                    own_filament_id = detail.get("filament_id") or (
+                        cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
+                    )
+                    if own_filament_id:
+                        tray_info_idx = own_filament_id
                         cloud_name = detail.get("name", "")
                         if cloud_name:
                             sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
@@ -245,10 +338,16 @@ async def resolve_slicer_filament(
     #      the original assign.
     #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
     #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
+    #   4. Orca Cloud profile UUIDs, when the branch above could not reach the
+    #      profile to trade one for its filament_id (#3003). Worst of the four
+    #      at 36 characters against an 8-character field.
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     if tray_info_idx and (
-        is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
+        is_material_name(tray_info_idx)
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+        or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
     ):
         tray_info_idx = ""
         # Preserve setting_id when it's still a valid slicer reference

+ 89 - 21
backend/tests/integration/test_printers_api.py

@@ -1708,8 +1708,19 @@ class TestConfigureAMSSlotAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
-        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+    async def test_configure_pfus_never_reaches_tray_info_idx(self, async_client: AsyncClient, printer_factory):
+        """A PFUS* cloud setting_id is refused as tray_info_idx (#3003).
+
+        The printer's tray_info_idx field is 8 characters. An 18-character PFUS
+        is stored truncated and acknowledged as a success -- measured on the A1
+        in the #3003 bundle, which sent PFUS9ddc938fe3ab8f and read back
+        PFUS9DDC. That leaves the slot holding an id nothing resolves, so the
+        slicer shows "Generic" and the calibration table loses the slot too.
+        A generic for the material is strictly better, and the preset reference
+        survives in setting_id, which does accept a PFUS.
+
+        Reverses the contract #1053 pinned; see the route's own comment.
+        """
         printer = await printer_factory(name="H2D")
 
         mock_client = MagicMock()
@@ -1738,12 +1749,20 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            # No tray to reuse -> generic for the material, never the raw PFUS.
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            # The preset reference is not lost: it moves to the field that holds it.
+            assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
-        """Provided PFUS* preset takes priority over slot's existing preset."""
+    async def test_configure_pfus_falls_back_to_slot_preset(self, async_client: AsyncClient, printer_factory):
+        """With a PFUS refused, the slot's own resolvable preset is reused (#3003).
+
+        The slot already carries P4d64437 -- a local preset id, 8 characters, so
+        the printer can actually store it -- for the same material. That beats a
+        generic, and it is what the slot's calibration is keyed by.
+        """
         printer = await printer_factory(name="H2D")
 
         mock_client = MagicMock()
@@ -1789,13 +1808,19 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Provided preset wins over slot's existing one
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            # Slot's own storable preset wins over both the PFUS and a generic.
+            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_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
-        """Provided PFUS* preset is used even when slot has a different material."""
+    async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):
+        """A slot holding a different material is not reused (#3003).
+
+        Slot has generic PETG, the user is configuring PLA. Neither the refused
+        PFUS nor the mismatched slot can supply a filament id, so the generic
+        for the requested material does.
+        """
         printer = await printer_factory(name="H2D")
 
         mock_client = MagicMock()
@@ -1834,8 +1859,8 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Provided preset wins — slot's material is irrelevant
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1873,13 +1898,19 @@ class TestConfigureAMSSlotAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
-        """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
-
-        Pins the end-to-end contract the frontend #1053 fix relies on: when the
-        user configures a slot with a custom cloud preset whose cloud detail
-        has filament_id=null, the frontend sends the setting_id in BOTH fields
-        and the backend must not collapse either to a generic GF* ID.
+    async def test_configure_pfus_pair_splits_into_generic_and_setting_id(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """A PFUS sent in BOTH fields is kept only in setting_id (#3003).
+
+        This is the shape the frontend produced when a custom cloud preset's
+        detail had filament_id=null, and what #1053 pinned. The A1 measurement
+        in #3003 showed where it ends up: the printer truncates tray_info_idx
+        to 8 characters, so the slot resolves to nothing and the slicer falls
+        back to "Generic" anyway -- the very outcome #1053 set out to avoid,
+        plus a broken calibration key. Sending the generic deliberately gets
+        the same slicer result honestly and keeps the slot calibratable, and
+        setting_id still carries the user's preset.
         """
         printer = await printer_factory(name="H2D")
 
@@ -1910,10 +1941,47 @@ class TestConfigureAMSSlotAPI:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
             assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
-            # Explicitly assert no generic-collapse happened for this HT slot.
-            assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfcn_refused_as_tray_info_idx(self, async_client: AsyncClient, printer_factory):
+        """PFCN* shared / partner presets are refused the same way (#3003, #1648).
+
+        Same 18-character shape as a PFUS, same truncation. Polymaker's
+        "(Custom)" H2D variants are the ones that reach this in the wild.
+        """
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "PFCN2a91c7d0e4b118",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "Polymaker PETG (Custom)",
+                    "tray_color": "0000FFFF",
+                    "nozzle_temp_min": 220,
+                    "nozzle_temp_max": 260,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
+            assert call_kwargs.kwargs["setting_id"] == "PFCN2a91c7d0e4b118"
 
 
 class TestSkipObjectsAPI:

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

@@ -260,3 +260,226 @@ class TestThePresetsOwnType:
                 material="PETG",
             )
         assert type_override is None
+
+
+class TestACustomPresetsOwnFilamentId:
+    """Where the id that carries a custom preset into an AMS slot comes from.
+
+    The slot holds one filament reference and the printer truncates it to 8
+    characters, so a custom preset reaches the slicer as itself only when its
+    own filament_id ("P" + 7 hex) goes into ``tray_info_idx``. 92 trays across
+    eight models in the support archive do exactly that, so the mechanism
+    works -- what #3003 found is that we only ever read one of the two places
+    Bambu Cloud returns that id from.
+    """
+
+    @pytest.mark.asyncio
+    async def test_filament_id_is_read_from_inside_the_preset_json(self):
+        """The envelope has none, the preset JSON does -- and it wins over base_id.
+
+        Before #3003 this fell through to the base_id branch and the slot came
+        out as the Bambu profile the custom preset inherits from.
+        """
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(
+            return_value={
+                "name": "SUNLU PLA Transparent @BBL A1",
+                "base_id": "GFSNLS03",
+                "setting": {"filament_id": "P4d64437", "filament_type": ["PLA"]},
+            }
+        )
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            idx, sid, brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="PFUSfb87cd50b76616",
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == "P4d64437"
+        assert sid == "PFUSfb87cd50b76616"
+        assert brand == "SUNLU PLA Transparent"
+
+    @pytest.mark.asyncio
+    async def test_the_envelope_still_wins_when_it_has_one(self):
+        """Unchanged behaviour for the presets that already resolved."""
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(
+            return_value={
+                "filament_id": "P285e239",
+                "name": "Jayo PETG HF @P1S",
+                "base_id": "GFSG02",
+                "setting": {"filament_id": "P999aaaa"},
+            }
+        )
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            idx, _sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="PFUS992454068158eb",
+                slicer_filament_name=None,
+                material="PETG",
+            )
+        assert idx == "P285e239"
+
+    @pytest.mark.asyncio
+    async def test_base_id_is_still_the_fallback_when_neither_place_has_one(self):
+        """A preset with no filament_id of its own genuinely is its base, and
+        the base id is storable, so it is the right answer -- just not one to
+        reach for while the preset's own id is sitting under ``setting``."""
+        db = MagicMock()
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(
+            return_value={"name": "My PLA @BBL A1", "base_id": "GFSNLS03", "setting": {}}
+        )
+        cloud.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            AsyncMock(return_value=cloud),
+        ):
+            idx, sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament="PFUSfb87cd50b76616",
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == "GFNLS03"
+        assert sid == "PFUSfb87cd50b76616"
+
+
+class TestOrcaCloudIsTheFirstSource:
+    """Source order is Orca Cloud, Bambu Cloud, local import, generic.
+
+    Orca was absent from the resolver entirely: a spool referencing an Orca
+    profile stores the bare UUID, which matched no branch and fell through
+    ``normalize_slicer_filament`` -- a function that passes anything it does
+    not recognise straight through. The UUID reached tray_info_idx, a field
+    the printer truncates to 8 characters (#3003).
+    """
+
+    ORCA_ID = "3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30"
+
+    @staticmethod
+    def _svc(profile):
+        svc = MagicMock()
+        svc.get_profile = AsyncMock(return_value=profile)
+        svc.close = AsyncMock()
+        return svc
+
+    @pytest.mark.asyncio
+    async def test_the_profiles_own_filament_id_is_used(self):
+        db = MagicMock()
+        svc = self._svc(
+            {
+                "id": self.ORCA_ID,
+                "name": "Overture Matte PLA @Orca",
+                "content": {"filament_id": "P56e1be0", "filament_type": ["PLA"]},
+            }
+        )
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            idx, sid, brand, type_override = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == "P56e1be0"
+        # The UUID is foreign to the slicer in either field, so nothing carries it.
+        assert sid == ""
+        assert brand == "Overture Matte PLA"
+        assert type_override == "PLA"
+        svc.close.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_profile_with_no_filament_id_leaves_the_caller_its_fallback(self):
+        db = MagicMock()
+        svc = self._svc({"id": self.ORCA_ID, "name": "My PLA", "content": {"filament_type": ["PLA"]}})
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            idx, sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == ""
+        assert sid == ""
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_never_leaks_the_uuid(self):
+        """No pairing, dead token, Orca down -- all the same answer. The UUID
+        must not reach tray_info_idx, which is what happened before the branch
+        existed at all."""
+        db = MagicMock()
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(side_effect=RuntimeError("Orca Cloud is not connected")),
+        ):
+            idx, sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == ""
+        assert sid == ""
+
+    @pytest.mark.asyncio
+    async def test_a_caller_without_the_permission_skips_the_lookup(self):
+        db = MagicMock()
+        user = MagicMock()
+        user.has_permission = MagicMock(return_value=False)
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(side_effect=AssertionError("must not be called")),
+        ):
+            idx, _sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=user,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == ""
+
+    @pytest.mark.asyncio
+    async def test_a_uuid_is_refused_as_tray_info_idx_by_the_closing_guard(self):
+        """Belt and braces: a profile whose content names itself by UUID still
+        does not put one in the field."""
+        db = MagicMock()
+        svc = self._svc({"id": self.ORCA_ID, "name": "Odd", "content": {"filament_id": self.ORCA_ID}})
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            idx, sid, _brand, _type = await resolve_slicer_filament(
+                db=db,
+                current_user=None,
+                slicer_filament=self.ORCA_ID,
+                slicer_filament_name=None,
+                material="PLA",
+            )
+        assert idx == ""
+        assert sid == ""

+ 124 - 8
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -24,6 +24,8 @@ vi.mock('../../api/client', () => ({
     updateSettings: vi.fn().mockResolvedValue({}),
     getLocalPresets: vi.fn(),
     getBuiltinFilaments: vi.fn(),
+    orcaCloudListProfiles: vi.fn(),
+    orcaCloudGetProfile: vi.fn(),
     searchColors: vi.fn(),
     getColorCatalog: vi.fn(),
     resetAmsSlot: vi.fn(),
@@ -85,6 +87,9 @@ describe('ConfigureAmsSlotModal', () => {
     (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
     (api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
     (api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });
+    (api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament: [], printer: [], process: [],
+    });
     (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
     (api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);
     (api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);
@@ -206,7 +211,7 @@ describe('ConfigureAmsSlotModal', () => {
     expect(colorInput).toHaveValue('Red');
   });
 
-  it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', async () => {
+  it('sends no tray_info_idx when cloud detail has filament_id: null (#3003)', async () => {
     // Cloud returns a user preset that inherits from a generic Bambu base and
     // has no distinct filament_id of its own — this is how Bambu Cloud responds
     // for custom presets built on top of "Generic ABS @BBL H2D" etc.
@@ -233,9 +238,118 @@ describe('ConfigureAmsSlotModal', () => {
     });
 
     const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
-    // Before the fix, this collapsed to 'GFB99' (Generic ABS's filament_id),
-    // which made OrcaSlicer/BambuStudio Sync Filaments resolve to "Generic ABS".
-    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    // #1053 sent the PFUS here instead, to stop the slot collapsing to
+    // "Generic ABS" in the slicer. The A1 capture in #3003 showed that does not
+    // work: tray_info_idx is 8 characters on the printer, so an 18-character
+    // PFUS is stored truncated (PFUScd84 here) and acknowledged as a success.
+    // The slot then resolves to nothing — "Generic" anyway, plus a calibration
+    // table keyed by an id that does not exist. Sending nothing lets the
+    // backend pick a filament id the printer can actually hold; setting_id
+    // still carries the preset.
+    expect(payload.tray_info_idx).toBe('');
+    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.
+    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'] },
+    });
+
+    const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture Matte PLA @Orca')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('P56e1be0');
+    // The UUID is what the slicer cannot resolve; it goes in neither field.
+    expect(payload.setting_id).toBe('');
+  });
+
+  it('falls back to a generic when an Orca profile has no filament_id (#3003)', 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: {},
+    });
+
+    const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Homebrew PLA @Orca')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('GFL99');
+    expect(payload.setting_id).toBe('');
+  });
+
+  it('uses the filament_id nested under setting when the envelope has none (#3003)', async () => {
+    // Bambu Cloud returns a custom preset's own filament_id on the envelope for
+    // some presets and inside the preset JSON for others. Only the envelope was
+    // read, so presets of the second shape reached the slot as their inherited
+    // base — the "Bambu profile instead of my profile" half of #3003.
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament_id: null,
+      base_id: 'GFSB99_07',
+      name: '# Overture Matte PLA @BBL H2D',
+      setting: { filament_id: 'P4d64437' },
+    });
+
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    // 8 characters, so the printer stores it whole and the slicer matches it.
+    expect(payload.tray_info_idx).toBe('P4d64437');
     expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
   });
 
@@ -293,9 +407,11 @@ describe('ConfigureAmsSlotModal', () => {
     expect(api.getCloudSettingDetail).not.toHaveBeenCalled();
   });
 
-  it('keeps default PFUS tray_info_idx when cloud detail fetch fails', async () => {
-    // Network/5xx from /cloud/settings/{id} must not abort the configure flow
-    // nor leave tray_info_idx empty — we fall back to the setting_id default.
+  it('sends no tray_info_idx when the cloud detail fetch fails (#3003)', async () => {
+    // Network/5xx from /cloud/settings/{id} must not abort the configure flow.
+    // It leaves no filament_id to send, and the PFUS default is not a usable
+    // stand-in (see the filament_id: null case above), so the field goes out
+    // empty and the backend resolves it.
     (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(
       new Error('cloud unreachable')
     );
@@ -317,7 +433,7 @@ describe('ConfigureAmsSlotModal', () => {
     });
 
     const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
-    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    expect(payload.tray_info_idx).toBe('');
     expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
   });
 

+ 61 - 12
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -469,16 +469,40 @@ export function ConfigureAmsSlotModal({
           || '';
         settingId = '';
       } else if (isOrca) {
-        // Orca Cloud presets have a UUID setting_id that Bambu printers can't
-        // resolve; treat them like local imports — derive a generic tray_info
-        // _idx from the parsed material, leave settingId empty so the slicer
-        // doesn't get a foreign cloud ID it can't look up.
-        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]]
-          || '';
+        // 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).
+        //
+        // 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 = '';
       } else if (isBuiltin) {
         // Built-in presets use the filament_id directly as tray_info_idx
@@ -495,13 +519,38 @@ export function ConfigureAmsSlotModal({
         if (!selectedPresetId.startsWith('GFS')) {
           try {
             const detail = await api.getCloudSettingDetail(selectedPresetId);
-            if (detail.filament_id) {
-              trayInfoIdx = detail.filament_id;
+            // The preset's own filament_id is what puts it in the slot as
+            // itself — the printer stores that id and the slicer matches its
+            // presets against it. Bambu Cloud returns it on the envelope for
+            // some presets and inside the preset JSON under `setting` for
+            // others; looking only at the envelope is how a custom preset
+            // silently became its inherited base profile (#3003).
+            const nested = detail.setting?.filament_id;
+            const ownFilamentId = detail.filament_id || (typeof nested === 'string' ? nested : '');
+            if (ownFilamentId) {
+              trayInfoIdx = ownFilamentId;
             }
           } catch (e) {
             console.warn('Failed to fetch preset detail for filament_id:', e);
           }
         }
+
+        // Last resort: neither place had one, so trayInfoIdx is still the
+        // cloud setting_id — and sending one is worse than sending nothing
+        // (#3003). tray_info_idx is 8 characters on the printer; an
+        // 18-character PFUS is stored truncated and acknowledged as a success,
+        // so the slot ends up holding an id that resolves nowhere — the slicer
+        // shows "Generic", and the calibration table, keyed by this field,
+        // loses the slot too. Blank means the backend picks the slot's
+        // existing filament id or a generic for the material; settingId still
+        // carries the preset.
+        if (/^(PFUS|PFSP|PFCN)/.test(trayInfoIdx)) {
+          console.warn(
+            `Cloud preset ${selectedPresetId} carries no filament_id in either place; ` +
+            'sending no tray_info_idx so the printer keeps a resolvable one.'
+          );
+          trayInfoIdx = '';
+        }
       }
 
       // Default temp range — use local preset core fields if available

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BMnY4ID9.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-DQoG4yeu.js"></script>
+    <script type="module" crossorigin src="/assets/index-BMnY4ID9.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов