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

fix(inventory): handle PFCN cloud preset IDs in assign-via-MQTT (#1648)

  Reporter on an H2D + Polymaker PLA Matte spool noticed that assigning
  the spool from the Dashboard left the slicer's filament dropdown
  showing "unknown", but clicking Configure right after made the
  slicer recognize it correctly. "Configure" felt like a mandatory
  follow-up step rather than a refinement.

  Bambu cloud uses three preset-ID shapes:
    GFS…   — Bambu official cloud preset
    PFUS…  — cloud user-created preset
    PFCN…  — cloud shared / partner preset (Polymaker's "(Custom)"
             Bambu Lab H2D variants ship this prefix)

  apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the
  cloud-detail lookup that extracts the underlying filament_id. PFCN
  slipped past the cloud-lookup branch, fell into the local-preset
  int() parse path, raised ValueError, dropped into
  normalize_slicer_filament which returns any P-prefix unchanged, and
  the raw PFCN landed in tray_info_idx. The printer's calibration
  table can't index that, so the slicer rendered "unknown". The
  Configure modal rescued every assign because it does its own
  getCloudSettingDetail and writes the resolved filament_id.

  Extend the cloud-detail-lookup branch (inventory.py:129) and the
  discard safety net (inventory.py:223) to include PFCN alongside
  GFS/PFUS. Three behaviours fall out:

    * Cloud-authenticated: the real filament_id from
      detail["filament_id"] ships as tray_info_idx (Polymaker PLA
      Matte resolves to GFL05).
    * Cloud unavailable: raw PFCN discarded, the slot reuses an
      existing valid P-prefix preset if material matches.

  Source comment now lists all three cloud-ID shapes so the next time
  Bambu invents a new prefix the maintainer doesn't have to re-derive
  the structure from a bug report.
maziggy 3 месяцев назад
Родитель
Сommit
d82f4e032f
3 измененных файлов с 153 добавлено и 4 удалено
  1. 0 0
      CHANGELOG.md
  2. 19 4
      backend/app/api/routes/inventory.py
  3. 134 0
      backend/tests/integration/test_inventory_assign.py

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


+ 19 - 4
backend/app/api/routes/inventory.py

@@ -126,7 +126,15 @@ async def apply_spool_to_slot_via_mqtt(
 
     if sf:
         base_sf = sf.split("_")[0] if "_" in sf else sf
-        if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+        # Cloud-side preset IDs in three known shapes:
+        #   GFS…   — Bambu official cloud preset
+        #   PFUS…  — cloud user-created preset
+        #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's
+        #            "(Custom)" Bambu Lab H2D variant, #1648)
+        # 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"):
             setting_id = base_sf
             try:
                 from backend.app.api.routes.cloud import build_authenticated_cloud
@@ -204,7 +212,7 @@ async def apply_spool_to_slot_via_mqtt(
                     setting_id = filament_id_to_setting_id(fid)
                     break
 
-    # Defend against tray_info_idx values the slicer cannot resolve. Two
+    # Defend against tray_info_idx values the slicer cannot resolve. Three
     # shapes leak through and must be discarded so the generic-material
     # fallback below can rescue the slot:
     #   1. Literal material names ("PLA", "PETG-CF") that pass through
@@ -217,10 +225,16 @@ async def apply_spool_to_slot_via_mqtt(
     #      replay path in main.py.on_ams_change passes current_user=None,
     #      which skips cloud auth and leaves the raw PFUS in tray_info_idx —
     #      overwriting the correctly-configured slot from the original assign.
+    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
+    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
-    # "P" followed by hex (user/local presets, NOT "PFUS").
+    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
-    if tray_info_idx and (tray_info_idx.upper() in _known_materials or tray_info_idx.startswith("PFUS")):
+    if tray_info_idx and (
+        tray_info_idx.upper() in _known_materials
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+    ):
         tray_info_idx = ""
         setting_id = ""
 
@@ -229,6 +243,7 @@ async def apply_spool_to_slot_via_mqtt(
             current_tray_info_idx
             and current_tray_info_idx not in _generic_id_values
             and not current_tray_info_idx.startswith("PFUS")
+            and not current_tray_info_idx.startswith("PFCN")
             and current_tray_info_idx.upper() not in _known_materials
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()

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

@@ -1141,3 +1141,137 @@ class TestAssignSpoolEmptyDetection:
         body = response.json()
         assert body["pending_config"] is False
         assert body["configured"] is True
+
+
+class TestAssignSpoolPfcnCloudPreset:
+    """Assign path for PFCN-prefix cloud presets (#1648).
+
+    PFCN is a third Bambu cloud preset shape alongside PFUS (cloud user-created)
+    and GFS (Bambu official) — used for cloud-shared / partner-uploaded
+    presets like Polymaker's "(Custom)" Bambu Lab H2D variants. Before #1648
+    the assign path skipped the cloud-detail lookup and left the raw PFCN
+    string in tray_info_idx, which the printer's calibration table can't
+    resolve. ConfigureAmsSlotModal rescued each assignment by doing the lookup
+    itself, making "Configure" feel like a mandatory follow-up step.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_falls_back_to_generic_when_cloud_unavailable(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When cloud auth isn't available (e.g. user not logged into Bambu Cloud),
+        the raw PFCN must be discarded as slicer-invalid and the slot configures
+        with the spool's generic material id (PLA → GFL99). Pre-fix behaviour
+        was to leak the raw PFCN, which the slicer can't resolve."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # PFCN never leaks into tray_info_idx — must resolve to the
+            # generic-material fallback when cloud lookup couldn't.
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
+            assert not call_kwargs.kwargs["tray_info_idx"].startswith("PFCN")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """Symmetry with the PFUS case: when the spool's PFCN is discarded as
+        slicer-invalid, the slot's existing valid P-prefix preset is reused
+        if material matches — preserves calibration context instead of
+        resetting to generic."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(
+            ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pfcn_resolves_to_filament_id_via_cloud_lookup(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When the user is authenticated against Bambu Cloud, the PFCN setting_id
+        triggers the same cloud-detail lookup as PFUS / GFS — extracts the real
+        filament_id from `detail["filament_id"]` and ships that as
+        tray_info_idx. This is the happy path the Configure modal already had
+        but the assign path didn't, #1648."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="PFCN80e80c1f79db85", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": "PLA"}]}])
+
+        # Cloud responds with a real filament_id for the PFCN preset — exactly
+        # what the Configure modal already exploits.
+        mock_cloud = MagicMock()
+        mock_cloud.is_authenticated = True
+
+        async def fake_get_detail(setting_id):
+            assert setting_id == "PFCN80e80c1f79db85"
+            return {"filament_id": "GFL05", "name": "Polymaker PLA Matte"}
+
+        async def fake_close():
+            return None
+
+        mock_cloud.get_setting_detail = fake_get_detail
+        mock_cloud.close = fake_close
+
+        async def fake_build_cloud(_db, _user):
+            return mock_cloud
+
+        with (
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.cloud.build_authenticated_cloud", new=fake_build_cloud),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            # tray_info_idx is the resolved cloud filament_id; setting_id is the
+            # original PFCN (which the slicer needs separately).
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
+            assert call_kwargs.kwargs["setting_id"] == "PFCN80e80c1f79db85"

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