Procházet zdrojové kódy

fix(inventory): make AMS slot config land cleanly for spools with no k-profile

  The assign flow was sending slicer-invalid values for tray_info_idx and an
  empty setting_id, which the slicer rejected — slot detail modal showed
  empty fields. With a stored k-profile the realignment path masked the
  issue; without one, garbage hit MQTT.

  Backend (apply_spool_to_slot_via_mqtt):
  - Discard tray_info_idx values that aren't real preset IDs: literal
    material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
    (valid as setting_id but rejected as tray_info_idx). Same check applied
    to current_tray_info_idx so stale slot values don't get reused as
    garbage.
  - Local-preset path now reads the printer-recognized filament_id from
    the preset's setting JSON (e.g. P4d64437) instead of falling through
    to a generic material ID.
  - Derive setting_id from filament_id_to_setting_id when empty so
    ams_filament_setting always carries a matched pair.
  - No stored k-profile: always send cali_idx=-1 (Default K), regardless
    of the live cali_idx on the slot. The live value belongs to whatever
    filament was there before, so reusing it would apply the wrong K to
    the new spool.

  Frontend (spool-form/utils.ts):
  - Local preset options use String(preset.id) as the unique code instead
    of preset.filament_type — every PLA local preset was collapsing onto
    the same "PLA" code, so picking any of them saved slicer_filament=
    "PLA" and lost the specific preset identity.

  Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
  reset.
maziggy před 1 týdnem
rodič
revize
dd3e3f8039

+ 71 - 48
backend/app/api/routes/inventory.py

@@ -165,12 +165,29 @@ async def apply_spool_to_slot_via_mqtt(
                 lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
                 lp = lp_result.scalar_one_or_none()
                 if lp:
-                    mat = (spool.material or lp.filament_type or "").upper().strip()
-                    tray_info_idx = (
-                        _GENERIC_FILAMENT_IDS.get(mat)
-                        or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
-                        or ""
-                    )
+                    # Local preset's setting JSON carries the printer-recognized
+                    # filament_id (e.g. "P4d64437") — use that directly so the
+                    # slicer can resolve the specific preset. Falls through to
+                    # generic material id only when the JSON doesn't carry one.
+                    lp_filament_id = ""
+                    if lp.setting:
+                        try:
+                            setting_data = json.loads(lp.setting)
+                            raw_fid = setting_data.get("filament_id")
+                            if isinstance(raw_fid, str) and raw_fid:
+                                lp_filament_id = raw_fid
+                        except (json.JSONDecodeError, AttributeError):
+                            pass
+                    if lp_filament_id:
+                        tray_info_idx = lp_filament_id
+                        setting_id = filament_id_to_setting_id(lp_filament_id)
+                    else:
+                        mat = (spool.material or lp.filament_type or "").upper().strip()
+                        tray_info_idx = (
+                            _GENERIC_FILAMENT_IDS.get(mat)
+                            or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
+                            or ""
+                        )
                     if lp.name:
                         tray_sub_brands = lp.name.split("@")[0].strip()
             except (ValueError, TypeError):
@@ -187,10 +204,32 @@ 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
+    # 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
+    #      normalize_slicer_filament unchanged when the spool's slicer_filament
+    #      is free-text rather than a real preset ID.
+    #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
+    #      by the slicer as tray_info_idx (the printer's calibration table
+    #      indexes by filament_id, and a PFUS isn't one). This normally gets
+    #      realigned to a P-prefix local id via printer_kp lookup, but the
+    #      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.
+    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
+    # "P" followed by hex (user/local presets, NOT "PFUS").
+    _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")):
+        tray_info_idx = ""
+        setting_id = ""
+
     if not tray_info_idx:
         if (
             current_tray_info_idx
             and current_tray_info_idx not in _generic_id_values
+            and not current_tray_info_idx.startswith("PFUS")
+            and current_tray_info_idx.upper() not in _known_materials
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
         ):
@@ -205,6 +244,14 @@ async def apply_spool_to_slot_via_mqtt(
             if generic:
                 tray_info_idx = generic
 
+    # Ensure setting_id is always derivable from tray_info_idx. The local-preset
+    # path above sets tray_info_idx to a generic ID (e.g. "GFL99") but leaves
+    # setting_id empty — without this fallback the slicer gets a half-configured
+    # slot (filament id without setting id) and shows empty fields in the slot
+    # detail modal.
+    if tray_info_idx and not setting_id:
+        setting_id = filament_id_to_setting_id(tray_info_idx)
+
     temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
     if spool.nozzle_temp_min is not None:
         temp_min = spool.nozzle_temp_min
@@ -301,48 +348,24 @@ async def apply_spool_to_slot_via_mqtt(
             nozzle_diameter=nozzle_diameter,
         )
     else:
-        # No stored K-profile for this slot — preserve the slot's current live
-        # cali_idx if the printer has one. cali_idx is read from state.raw_data
-        # using the same idiom as the route's `current_tray_info_idx` lookup.
-        # Negative values (e.g. -1) mean "no calibration recorded" and must not
-        # be sent.
-        live_cali_idx: int | None = None
-        if state and getattr(state, "raw_data", None):
-            if ams_id == 255:
-                for vt in state.raw_data.get("vt_tray") or []:
-                    if isinstance(vt, dict) and int(vt.get("id", 254)) == (tray_id + 254):
-                        raw = vt.get("cali_idx")
-                        if isinstance(raw, int):
-                            live_cali_idx = raw
-                        break
-            else:
-                ams_section = state.raw_data.get("ams", {})
-                ams_list = (
-                    ams_section.get("ams", [])
-                    if isinstance(ams_section, dict)
-                    else ams_section
-                    if isinstance(ams_section, list)
-                    else []
-                )
-                tray_dict = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
-                if tray_dict:
-                    raw = tray_dict.get("cali_idx")
-                    if isinstance(raw, int):
-                        live_cali_idx = raw
-        if live_cali_idx is not None and live_cali_idx >= 0:
-            cali_filament_id = spool.slicer_filament or effective_tray_info_idx
-            client.extrusion_cali_sel(
-                ams_id=ams_id,
-                tray_id=tray_id,
-                cali_idx=live_cali_idx,
-                filament_id=cali_filament_id,
-                nozzle_diameter=nozzle_diameter,
-            )
-            logger.info(
-                "No stored K-profile for spool %d — preserved live cali_idx=%d",
-                spool.id,
-                live_cali_idx,
-            )
+        # No stored K-profile for this spool — always reset the slot to Default
+        # K (cali_idx=-1). The live cali_idx on the slot belongs to whatever
+        # filament was there before, so preserving it would apply the wrong
+        # filament's calibration to the new spool. Default K is the firmware's
+        # documented "no specific profile" value (see BambuClient.extrusion_cali_sel
+        # docstring).
+        cali_filament_id = spool.slicer_filament or effective_tray_info_idx
+        client.extrusion_cali_sel(
+            ams_id=ams_id,
+            tray_id=tray_id,
+            cali_idx=-1,
+            filament_id=cali_filament_id,
+            nozzle_diameter=nozzle_diameter,
+        )
+        logger.info(
+            "No stored K-profile for spool %d — reset slot to Default K (cali_idx=-1)",
+            spool.id,
+        )
 
     # Persist slot preset mapping for UI display (preset_name on hover card).
     try:

+ 15 - 23
backend/app/api/routes/spoolman_inventory.py

@@ -1207,29 +1207,21 @@ async def assign_spoolman_slot(
                     body.tray_id,
                 )
             else:
-                # No stored K-profile: preserve the slot's current live cali_idx
-                from backend.app.api.routes.inventory import _find_tray_in_ams_data
-
-                live_tray = None
-                if state and state.raw_data:
-                    ams_raw = state.raw_data.get("ams", [])
-                    if isinstance(ams_raw, dict):
-                        ams_raw = ams_raw.get("ams", [])
-                    live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
-                live_cali_idx = (live_tray or {}).get("cali_idx")
-                if live_cali_idx is not None and live_cali_idx >= 0:
-                    mqtt_client.extrusion_cali_sel(
-                        ams_id=body.ams_id,
-                        tray_id=body.tray_id,
-                        cali_idx=live_cali_idx,
-                        filament_id=effective_tray_info_idx,
-                        nozzle_diameter=nozzle_diameter,
-                    )
-                    logger.info(
-                        "No stored K-profile for Spoolman spool %d — preserved live cali_idx=%d",
-                        body.spoolman_spool_id,
-                        live_cali_idx,
-                    )
+                # No stored K-profile for this spool — always reset the slot to
+                # Default K (cali_idx=-1). The live cali_idx belongs to whatever
+                # filament was there before, so preserving it would apply the
+                # wrong filament's calibration to the new spool.
+                mqtt_client.extrusion_cali_sel(
+                    ams_id=body.ams_id,
+                    tray_id=body.tray_id,
+                    cali_idx=-1,
+                    filament_id=effective_tray_info_idx,
+                    nozzle_diameter=nozzle_diameter,
+                )
+                logger.info(
+                    "No stored K-profile for Spoolman spool %d — reset slot to Default K (cali_idx=-1)",
+                    body.spoolman_spool_id,
+                )
 
             logger.info(
                 "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",

+ 45 - 30
backend/tests/integration/test_inventory_assign.py

@@ -60,8 +60,13 @@ class TestAssignSpoolTrayInfoIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+    async def test_pfus_slicer_filament_falls_back_to_generic(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """PFUS* cloud setting_ids are rejected by the slicer as tray_info_idx, so the
+        no-kp path falls back to the generic material id (PLA → GFL99). The K-profile
+        realignment path translates PFUS → P-prefix when a stored kp exists; that's
+        covered separately."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
@@ -82,14 +87,14 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_spool_preset_takes_priority_over_slot(
-        self, async_client: AsyncClient, printer_factory, spool_factory
-    ):
-        """Spool's own slicer_filament takes priority over slot's existing preset."""
+    async def test_pfus_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When the spool's PFUS gets discarded as slicer-invalid, the slot's existing
+        valid P-prefix preset is reused if it matches the spool's material — preserves
+        the printer's calibration context rather than resetting to generic."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
@@ -113,15 +118,15 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's own preset wins over slot's existing one
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_spool_preset_used_even_if_different_material_on_slot(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Spool's own slicer_filament is used regardless of what's on the slot."""
+        """Spool's material drives the fallback generic id. Slot's existing PLA preset
+        is overridden because the spool is PETG → GFG99."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
 
@@ -145,7 +150,7 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -201,8 +206,11 @@ class TestAssignSpoolTrayInfoIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """Spool's own PFUS preset is used even when slot has a different PFUS."""
+    async def test_spool_pfus_falls_back_to_generic_over_slot_pfus(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Both spool and slot have PFUS values — both rejected as tray_info_idx —
+        falls back to generic material id (PLA → GFL99)."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
 
@@ -226,15 +234,17 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's own preset wins
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_generic_on_slot_not_reused_over_spool_preset(
+    async def test_generic_on_slot_falls_back_to_material_generic(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
+        """When spool's PFUS is discarded and slot only has a generic ID, the result
+        comes from the spool's material (ABS → GFB99) — not from the slot. Important
+        because the generic-id check (`not in _generic_id_values`) prevents stale
+        generic reuse and routes the decision through the material fallback."""
         printer = await printer_factory(name="P2S")
         spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
 
@@ -258,8 +268,7 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's preset wins — generic on slot must not be sticky
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -471,18 +480,21 @@ class TestAssignSpoolPresetMapping:
 
 
 class TestAssignSpoolLiveCaliIdx:
-    """P9-TEST-BE-3: assign_spool falls back to live tray cali_idx when no K-profile stored."""
+    """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """When no KProfile row exists, live tray cali_idx is sent via extrusion_cali_sel."""
+    async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
         printer = await printer_factory()
         spool = await spool_factory()
 
         mock_client = MagicMock()
         mock_client.ams_set_filament_setting.return_value = True
         mock_client.extrusion_cali_sel.return_value = True
+        # Live cali_idx=42 belongs to whatever filament was previously calibrated
+        # in this slot. Applying it to a different spool would use the wrong K
+        # value, so the assign flow must override it with Default K (-1).
         tray_data = {
             "id": 1,
             "cali_idx": 42,
@@ -504,15 +516,14 @@ class TestAssignSpoolLiveCaliIdx:
 
         assert response.status_code == 200
         mock_client.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mock_client.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
+    async def test_no_kprofile_no_live_cali_idx_sends_default(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """When tray has no cali_idx, extrusion_cali_sel is not called."""
+        """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
         printer = await printer_factory()
         spool = await spool_factory()
 
@@ -539,12 +550,15 @@ class TestAssignSpoolLiveCaliIdx:
             )
 
         assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_negative_live_cali_idx_not_sent(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """A negative live cali_idx (-1) is invalid and must not be sent."""
+    async def test_negative_live_cali_idx_sends_default(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
         printer = await printer_factory()
         spool = await spool_factory()
 
@@ -571,7 +585,8 @@ class TestAssignSpoolLiveCaliIdx:
             )
 
         assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 class TestAssignSpoolEmptySlotPreConfig:

+ 19 - 15
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -203,10 +203,10 @@ class TestAssignSlotMqtt:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_on_nozzle_mismatch(
+    async def test_extrusion_cali_sel_resets_default_on_nozzle_mismatch(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
     ):
-        """extrusion_cali_sel is NOT called when nozzle diameter does not match K-profile."""
+        """When nozzle diameter doesn't match K-profile (no usable kp), slot resets to Default K."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 
         kp = SpoolmanKProfile(
@@ -257,14 +257,15 @@ class TestAssignSlotMqtt:
             )
 
         assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_when_cali_idx_none(
+    async def test_extrusion_cali_sel_resets_default_when_cali_idx_none(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
     ):
-        """extrusion_cali_sel is NOT called when K-profile has cali_idx=None."""
+        """When stored K-profile has cali_idx=None (unusable), slot resets to Default K."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 
         kp = SpoolmanKProfile(
@@ -315,7 +316,8 @@ class TestAssignSlotMqtt:
             )
 
         assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 # ---------------------------------------------------------------------------
@@ -474,10 +476,10 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(
+    async def test_no_kprofile_resets_to_default_k(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """When no K-profile exists, live tray cali_idx is sent via extrusion_cali_sel."""
+        """When no K-profile exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
 
         mqtt_mock = MagicMock()
@@ -514,16 +516,16 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
         assert resp.status_code == 200
         mqtt_mock.extrusion_cali_sel.assert_called_once()
         call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
+        assert call_kwargs["cali_idx"] == -1
         assert call_kwargs["ams_id"] == 0
         assert call_kwargs["tray_id"] == 1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
+    async def test_no_kprofile_no_live_cali_idx_sends_default(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """When no K-profile and tray has no cali_idx, extrusion_cali_sel is not called."""
+        """When no K-profile and tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
 
         mqtt_mock = MagicMock()
@@ -558,7 +560,8 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
             )
 
         assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -622,10 +625,10 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_live_cali_idx_not_used_if_negative(
+    async def test_live_cali_idx_negative_falls_back_to_default(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """A negative live cali_idx is invalid and must not be sent."""
+        """A negative live cali_idx falls through and is sent as Default (cali_idx=-1)."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
 
         mqtt_mock = MagicMock()
@@ -660,7 +663,8 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
             )
 
         assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 # ---------------------------------------------------------------------------

+ 11 - 5
frontend/src/components/spool-form/utils.ts

@@ -131,11 +131,17 @@ function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[
   if (filamentPresets.length === 0) return [];
 
   const options: FilamentOption[] = filamentPresets.map(preset => {
-    const code = preset.filament_type || String(preset.id);
-    // allCodes carries every shape an existing saved spool might have stored
-    // for this preset (filament_type and the row id), so findPresetOption
-    // resolves both old and new picks.
-    const allCodes = Array.from(new Set([code, String(preset.id)]));
+    // Use the unique preset.id (stringified) as the code so each local preset
+    // has its own identity. Earlier this was preset.filament_type (e.g. "PLA")
+    // which collapsed every PLA local preset onto the same code — picking any
+    // of them saved slicer_filament="PLA", a material name the backend cannot
+    // resolve back to a specific preset row. The backend handler at
+    // inventory.py expects numeric IDs for local-preset slicer_filament values.
+    // allCodes still carries the legacy filament_type so findPresetOption
+    // resolves existing saved spools that have the old material-name code.
+    const code = String(preset.id);
+    const legacyCode = preset.filament_type || code;
+    const allCodes = Array.from(new Set([code, legacyCode]));
     return {
       code,
       name: preset.name,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DZ9zJnpR.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-BY-X7DlG.js"></script>
+    <script type="module" crossorigin src="/assets/index-DZ9zJnpR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů