Explorar el Código

fix(spoolman): slot assignments are verified, and the spool's own preset is used for the slot

maziggy hace 6 días
padre
commit
53ef17dc56

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman slots** —
+  - Assigning or linking a Spoolman spool to an AMS slot is now checked against what the printer reports back, as with the built-in inventory. A printer that ignored the new filament showed no "not confirmed" notice, and later usage went to the wrong spool.
+  - Configure AMS Slot opens with the slicer preset set on the Spoolman spool when no per-printer-model preset applies, and the slot's K profile is matched under it. Before, the dialog opened with no preset and the K profile was matched against the tray's own filament.
 - **Spool slots** —
   - Archiving a spool, one or in bulk, unassigns it from its AMS slots, with the built-in inventory and with Spoolman, and slots still held by spools archived earlier are released on update. The slot kept showing the archived spool and charged it the next print's usage. A Bambu Lab spool's tag still in the AMS after archiving is not given to another spool or turned into a new one. With Spoolman, a spool feeding a running print keeps its slot until the print's usage is reported.
   - Deleting a Spoolman spool removes its slot assignments too, and a spool archived or deleted in Spoolman itself releases its slots after the next print on that printer. The slot kept pointing at a spool Spoolman no longer had.

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

@@ -2841,7 +2841,7 @@ async def get_slot_spool_defaults(
     from backend.app.services.inventory_mode import spoolman_owns_assignments
     from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
     from backend.app.services.slot_nozzle import resolve_slot_nozzle
-    from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+    from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset_with_own
 
     state = printer_manager.get_status(printer_id)
     model = printer_manager.get_model(printer_id)
@@ -2872,13 +2872,11 @@ async def get_slot_spool_defaults(
             )
         ).scalar_one_or_none()
         if sm_assignment is not None:
-            slicer_filament, slicer_filament_name = await resolve_spoolman_preset(
+            slicer_filament, slicer_filament_name = await resolve_spoolman_preset_with_own(
                 db,
                 spoolman_spool_id=sm_assignment.spoolman_spool_id,
                 printer_model=model,
                 nozzle_diameter=slot_nozzle.diameter,
-                fallback_filament=None,
-                fallback_name=None,
             )
     else:
         assignment = (

+ 15 - 0
backend/app/api/routes/spoolman.py

@@ -1148,7 +1148,9 @@ async def link_spool(
                     setting_id=effective_setting_id,
                 )
 
+                sent_cali_idx: int | None = None
                 if matching_kp and matching_kp.cali_idx is not None:
+                    sent_cali_idx = matching_kp.cali_idx
                     cali_filament_id = (
                         printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
                     ) or effective_tray_info_idx
@@ -1181,6 +1183,7 @@ async def link_spool(
                         live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
                     live_cali_idx = (live_tray or {}).get("cali_idx")
                     if live_cali_idx is not None and live_cali_idx >= 0:
+                        sent_cali_idx = live_cali_idx
                         mqtt_client.extrusion_cali_sel(
                             ams_id=a_id,
                             tray_id=t_id,
@@ -1189,6 +1192,18 @@ async def link_spool(
                             nozzle_diameter=nozzle_diameter,
                         )
 
+                # Read-back verification, as the assign paths register it
+                # (#2582); the K-profile only when one was sent.
+                mqtt_client.register_assignment_verification(
+                    ams_id=a_id,
+                    tray_id=t_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_color=tray_color,
+                    cali_idx=sent_cali_idx,
+                )
+                # Fresh telemetry for the check to compare against.
+                mqtt_client.request_status_update()
+
                 logger.info(
                     "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
                     a_id,

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

@@ -64,7 +64,7 @@ from backend.app.services.location_service import (
 from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
-from backend.app.services.spool_filament_preset import resolve_spoolman_preset
+from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset, resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -999,6 +999,8 @@ async def update_spool(
             new_extra[BAMBU_LAST_DRIED_HOURS_KEY] = json.dumps("")
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
+        if sf_set or sfn_set:
+            forget_spoolman_spool_own_preset(spool_id)
 
     await ws_manager.broadcast({"type": "inventory_changed"})
     return _map_spoolman_spool(updated)
@@ -2060,6 +2062,31 @@ async def assign_spoolman_slot(
                     body.spoolman_spool_id,
                 )
 
+            # Read-back verification, as the built-in assign registers it (#2582):
+            # the AMS pushes that follow confirm the tray took what was sent.
+            # Not for a slot the firmware calls empty, which echoes nothing
+            # back until a spool goes in (the built-in check, inventory.py).
+            from backend.app.api.routes.inventory import _find_tray_in_ams_data  # noqa: PLC0415
+            from backend.app.services.ams_slot_presence import spool_present  # noqa: PLC0415
+
+            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)
+            tray_state = (live_tray or {}).get("state")
+            if not (spool_present(live_tray) is not True and tray_state in (9, 10)):
+                mqtt_client.register_assignment_verification(
+                    ams_id=body.ams_id,
+                    tray_id=body.tray_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_color=tray_color,
+                    cali_idx=matching_kp.cali_idx if matching_kp and matching_kp.cali_idx is not None else -1,
+                )
+                # Fresh telemetry for the check to compare against.
+                mqtt_client.request_status_update()
+
             logger.info(
                 "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
                 body.ams_id,

+ 22 - 15
backend/app/services/slot_kprofile.py

@@ -23,7 +23,8 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.inventory_mode import spoolman_owns_assignments
-from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset_with_own
+from backend.app.utils.filament_ids import normalize_slicer_filament
 
 
 @dataclass(frozen=True)
@@ -166,20 +167,26 @@ async def find_slot_kprofile_for_extruder(
     if sm_profile is None:
         return None
 
-    # A Spoolman K row carries no preset of its own, but the spool can still
-    # have a per-model override stored locally -- that is the same table the
-    # Spoolman assign path writes. Without a model to key on there is nothing
-    # to resolve and the caller falls back to the tray's own tray_info_idx.
-    sm_filament_id = None
-    if printer_model:
-        sm_filament_id, _ = await resolve_spoolman_preset(
-            db,
-            spoolman_spool_id=sm_assignment.spoolman_spool_id,
-            printer_model=printer_model,
-            nozzle_diameter=nozzle_diameter,
-            fallback_filament=None,
-            fallback_name=None,
-        )
+    # A Spoolman K row carries no preset of its own: the spool's per-model
+    # override, stored locally (the table the Spoolman assign path writes),
+    # else the preset set on the spool in Spoolman. With neither the caller
+    # falls back to the tray's own tray_info_idx.
+    sm_preset, _ = await resolve_spoolman_preset_with_own(
+        db,
+        spoolman_spool_id=sm_assignment.spoolman_spool_id,
+        printer_model=printer_model,
+        nozzle_diameter=nozzle_diameter,
+    )
+    # As the assign path sends it: a filament id, not the stored setting-id
+    # form ("GFSL05_07"). Anything that needs the slicer's resolver (a local
+    # preset number, an Orca UUID, a cloud user preset) is left to the tray's
+    # own id, as before.
+    sm_filament_id = normalize_slicer_filament(sm_preset)[0] or None
+    if sm_filament_id and not (
+        sm_filament_id.startswith("GF")
+        or (sm_filament_id.startswith("P") and not sm_filament_id.startswith(("PFUS", "PFCN")))
+    ):
+        sm_filament_id = None
     return SlotKProfile(
         cali_idx=sm_profile.cali_idx,
         k_value=sm_profile.k_value,

+ 61 - 0
backend/app/services/spool_filament_preset.py

@@ -26,6 +26,7 @@ per-model behaves identically before and after this feature.
 from __future__ import annotations
 
 import logging
+import time
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -125,3 +126,63 @@ async def resolve_spoolman_preset(
         select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id)
     )
     return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))
+
+
+# A Spoolman spool's own preset lives in Spoolman (extra.bambu_slicer_filament),
+# so reading it costs a request. Kept briefly per spool: the K-profile drift
+# check asks on AMS pushes, and a preset changed in Spoolman itself applies
+# within a minute. Bambuddy's own edits drop the entry at once.
+_SPOOLMAN_OWN_PRESET_TTL_SECONDS = 60.0
+# What the cascade answers when no override row applies, as opposed to a
+# matching row left blank on purpose.
+_NO_OVERRIDE = "\x00no-override"
+_spoolman_own_presets: dict[int, tuple[float, PresetPair]] = {}
+
+
+def forget_spoolman_spool_own_preset(spoolman_spool_id: int) -> None:
+    _spoolman_own_presets.pop(spoolman_spool_id, None)
+
+
+async def spoolman_spool_own_preset(spoolman_spool_id: int) -> PresetPair:
+    """The preset set on the Spoolman spool itself; (None, None) when it has
+    none or Spoolman cannot be asked (not remembered, so asked again)."""
+    cached = _spoolman_own_presets.get(spoolman_spool_id)
+    if cached is not None and cached[0] > time.monotonic():
+        return cached[1]
+    try:
+        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+        from backend.app.services.spoolman import get_spoolman_client
+
+        client = await get_spoolman_client()
+        if client is None:
+            return (None, None)
+        mapped = _map_spoolman_spool(await client.get_spool(spoolman_spool_id))
+    except Exception as exc:  # noqa: BLE001 — a preset lookup must not fail its caller
+        logger.debug("Could not read the preset of Spoolman spool %s: %s", spoolman_spool_id, exc)
+        return (None, None)
+    pair: PresetPair = (mapped.get("slicer_filament"), mapped.get("slicer_filament_name"))
+    _spoolman_own_presets[spoolman_spool_id] = (time.monotonic() + _SPOOLMAN_OWN_PRESET_TTL_SECONDS, pair)
+    return pair
+
+
+async def resolve_spoolman_preset_with_own(
+    db: AsyncSession,
+    *,
+    spoolman_spool_id: int,
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+) -> PresetPair:
+    """The cascade with the spool's own Spoolman preset as its fallback, read
+    only when no per-model override applies."""
+    pair = await resolve_spoolman_preset(
+        db,
+        spoolman_spool_id=spoolman_spool_id,
+        printer_model=printer_model,
+        nozzle_diameter=nozzle_diameter,
+        fallback_filament=_NO_OVERRIDE,
+        fallback_name=None,
+    )
+    if pair[0] != _NO_OVERRIDE:
+        # An override applies, also a deliberately blank one.
+        return pair
+    return await spoolman_spool_own_preset(spoolman_spool_id)

+ 133 - 0
backend/tests/integration/test_spoolman_assignment_verification.py

@@ -0,0 +1,133 @@
+"""A Spoolman slot assignment is checked against the printer's read-back (#2582).
+
+The built-in assign registers what it pushed, so the AMS pushes that follow
+can confirm the tray took it and a "not confirmed" notice appears when it did
+not. The Spoolman assign and link published the same commands and never
+registered anything: a printer that ignored them left the slot on its old
+filament with no notice, and usage went to the wrong spool.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+SPOOL = {
+    "id": 10,
+    "filament": {"id": 1, "name": "PLA Basic", "material": "PLA", "color_hex": "FF0000", "weight": 1000},
+    "remaining_weight": 800.0,
+    "used_weight": 200.0,
+    "archived": False,
+    "extra": {},
+}
+
+
+@pytest.fixture
+async def printer_id(db_session, printer_factory):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+    return (await printer_factory()).id
+
+
+def _spoolman_client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=SPOOL)
+    client.get_spools = AsyncMock(return_value=[])
+    client.merge_spool_extra = AsyncMock(return_value={"id": 10, "extra": {}})
+    client.get_distinct_locations = AsyncMock(return_value=[])
+    client.has_tag_api = AsyncMock(return_value=False)
+    client.add_native_tags = AsyncMock(return_value=0)
+    client.ensure_tag_extra_field = AsyncMock(return_value=True)
+    client.ensure_extra_field = AsyncMock(return_value=True)
+    return client
+
+
+def _printer_client():
+    mqtt = MagicMock()
+    mqtt.ams_set_filament_setting = MagicMock()
+    mqtt.extrusion_cali_sel = MagicMock()
+    mqtt.register_assignment_verification = MagicMock()
+    return mqtt
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_assignment_is_registered_for_read_back(async_client: AsyncClient, printer_id):
+    mqtt = _printer_client()
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_spoolman_client())),
+        patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+    ):
+        pm.get_client = MagicMock(return_value=mqtt)
+        pm.get_status = MagicMock(return_value=None)
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={"spoolman_spool_id": 10, "printer_id": printer_id, "ams_id": 0, "tray_id": 1},
+        )
+
+    assert response.status_code == 200
+    pushed = mqtt.ams_set_filament_setting.call_args.kwargs
+    mqtt.register_assignment_verification.assert_called_once_with(
+        ams_id=0, tray_id=1, tray_info_idx=pushed["tray_info_idx"], tray_color=pushed["tray_color"], cali_idx=-1
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_link_with_a_slot_is_registered_for_read_back(async_client: AsyncClient, printer_id):
+    mqtt = _printer_client()
+    with (
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client())),
+        patch("backend.app.api.routes.spoolman.printer_manager") as pm,
+    ):
+        pm.get_client = MagicMock(return_value=mqtt)
+        pm.get_status = MagicMock(return_value=None)
+        pm.get_model = MagicMock(return_value="X1C")
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/10/link",
+            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "printer_id": printer_id, "ams_id": 0, "tray_id": 2},
+        )
+
+    assert response.status_code == 200, response.text
+    pushed = mqtt.ams_set_filament_setting.call_args.kwargs
+    # No stored K-profile and none live: the link leaves the K-profile alone,
+    # so only the filament is checked.
+    mqtt.register_assignment_verification.assert_called_once_with(
+        ams_id=0, tray_id=2, tray_info_idx=pushed["tray_info_idx"], tray_color=pushed["tray_color"], cali_idx=None
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_assignment_to_an_empty_slot_is_not_checked(async_client: AsyncClient, printer_id):
+    """An empty slot echoes nothing back until a spool goes in, so a check
+    could only time out into a false "not confirmed"."""
+    from types import SimpleNamespace
+
+    mqtt = _printer_client()
+    state = SimpleNamespace(
+        raw_data={"ams": [{"id": 0, "tray": [{"id": 1, "state": 9}]}]},
+        nozzles=[],
+        ams_extruder_map={},
+        ams_switch_inlet=None,
+        kprofiles=[],
+    )
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=_spoolman_client())),
+        patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+    ):
+        pm.get_client = MagicMock(return_value=mqtt)
+        pm.get_status = MagicMock(return_value=state)
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={"spoolman_spool_id": 10, "printer_id": printer_id, "ams_id": 0, "tray_id": 1},
+        )
+
+    assert response.status_code == 200
+    mqtt.ams_set_filament_setting.assert_called_once()
+    mqtt.register_assignment_verification.assert_not_called()

+ 5 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -834,7 +834,11 @@ class TestSpoolmanInventorySlicerFilament:
         """
         import json as _json
 
+        from backend.app.services import spool_filament_preset
+
         mock_spoolman_client.ensure_extra_field = AsyncMock(return_value=True)
+        # A preset read earlier, still remembered: the edit must replace it at once.
+        spool_filament_preset._spoolman_own_presets[42] = (float("inf"), ("GFA00", "old"))
 
         response = await async_client.patch(
             "/api/v1/spoolman/inventory/spools/42",
@@ -856,6 +860,7 @@ class TestSpoolmanInventorySlicerFilament:
                 "bambu_slicer_filament_name": _json.dumps("Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"),
             },
         )
+        assert 42 not in spool_filament_preset._spoolman_own_presets
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 160 - 0
backend/tests/integration/test_spoolman_slot_own_preset.py

@@ -0,0 +1,160 @@
+"""A Spoolman spool's own slicer preset is used for its slot.
+
+The spool keeps its preset in Spoolman (extra.bambu_slicer_filament). Configure
+AMS Slot and the K-profile lookup passed no fallback for a Spoolman spool, so
+one without a per-model override opened with no preset and the K-profile
+matched against the tray's own filament id. Read from Spoolman, remembered for
+a minute per spool.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import spool_filament_preset
+
+URL = "/api/v1/printers/{pid}/slots/0/0/spool-defaults"
+
+SPOOL = {
+    "id": 42,
+    "filament": {"id": 1, "name": "PLA Matte", "material": "PLA", "color_hex": "000000", "weight": 1000},
+    "remaining_weight": 800.0,
+    "used_weight": 200.0,
+    "archived": False,
+    "extra": {
+        "bambu_slicer_filament": '"GFA01"',
+        "bambu_slicer_filament_name": '"Bambu PLA Matte @BBL X1C"',
+    },
+}
+
+
+@pytest.fixture(autouse=True)
+def _empty_cache():
+    spool_filament_preset._spoolman_own_presets.clear()
+    yield
+    spool_filament_preset._spoolman_own_presets.clear()
+
+
+@pytest.fixture
+async def spoolman_slot(db_session, printer_factory):
+    from backend.app.models.settings import Settings
+    from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    printer = await printer_factory(model="X1C")
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
+    db_session.add(
+        SpoolmanKProfile(
+            spoolman_spool_id=42, printer_id=printer.id, extruder=0, nozzle_diameter="0.4", k_value=0.02, cali_idx=7
+        )
+    )
+    await db_session.commit()
+    return printer.id
+
+
+@pytest.fixture
+def spoolman():
+    client = MagicMock()
+    client.get_spool = AsyncMock(return_value=SPOOL)
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        yield client
+
+
+class _Nozzle:
+    nozzle_diameter = "0.4"
+
+
+@pytest.fixture
+def printer_state():
+    state = MagicMock(nozzles=[_Nozzle()], ams_extruder_map={"0": 0}, ams_switch_inlet=None, raw_data={})
+    with patch("backend.app.api.routes.printers.printer_manager") as pm:
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_model = MagicMock(return_value="X1C")
+        yield pm
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_configure_slot_opens_with_the_spools_own_preset(
+    async_client: AsyncClient, spoolman_slot, spoolman, printer_state
+):
+    body = (await async_client.get(URL.format(pid=spoolman_slot))).json()
+
+    assert body["slicer_filament"] == "GFA01"
+    assert body["slicer_filament_name"] == "Bambu PLA Matte @BBL X1C"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_k_profile_lookup_uses_the_spools_own_preset(db_session, spoolman_slot, spoolman):
+    from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+
+    profile = await find_slot_kprofile_for_extruder(db_session, spoolman_slot, 0, 0, 0, "0.4", "X1C", None)
+
+    assert (profile.cali_idx, profile.filament_id) == (7, "GFA01")
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_per_model_override_wins_without_asking_spoolman(db_session, spoolman_slot, spoolman):
+    from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
+    from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+
+    db_session.add(
+        SpoolmanFilamentPreset(spoolman_spool_id=42, printer_model="X1C", nozzle_diameter=None, slicer_filament="GFA05")
+    )
+    await db_session.commit()
+
+    profile = await find_slot_kprofile_for_extruder(db_session, spoolman_slot, 0, 0, 0, "0.4", "X1C", None)
+
+    assert profile.filament_id == "GFA05"
+    spoolman.get_spool.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_own_preset_is_remembered_and_dropped_on_an_edit(spoolman):
+    await spool_filament_preset.spoolman_spool_own_preset(42)
+    await spool_filament_preset.spoolman_spool_own_preset(42)
+    assert spoolman.get_spool.await_count == 1
+
+    spool_filament_preset.forget_spoolman_spool_own_preset(42)
+    await spool_filament_preset.spoolman_spool_own_preset(42)
+    assert spoolman.get_spool.await_count == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_override_left_blank_on_purpose_keeps_the_slot_without_a_preset(db_session, spoolman_slot, spoolman):
+    from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
+    from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+
+    db_session.add(
+        SpoolmanFilamentPreset(spoolman_spool_id=42, printer_model="X1C", nozzle_diameter=None, slicer_filament=None)
+    )
+    await db_session.commit()
+
+    profile = await find_slot_kprofile_for_extruder(db_session, spoolman_slot, 0, 0, 0, "0.4", "X1C", None)
+
+    assert profile.filament_id is None
+    spoolman.get_spool.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("stored,expected", [('"GFSA01_02"', "GFA01"), ('"38"', None)])
+async def test_the_k_profile_filament_id_is_the_one_the_printer_takes(
+    db_session, spoolman_slot, spoolman, stored, expected
+):
+    """The cloud picker stores the setting-id form; a local preset is stored
+    by its number, which only the slicer resolver turns into a filament id."""
+    from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+
+    spoolman.get_spool = AsyncMock(return_value={**SPOOL, "extra": {"bambu_slicer_filament": stored}})
+
+    profile = await find_slot_kprofile_for_extruder(db_session, spoolman_slot, 0, 0, 0, "0.4", "X1C", None)
+
+    assert profile.filament_id == expected