diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 5443e7cad..f757f9227 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -38,6 +38,7 @@ from backend.app.schemas.spool import ( normalize_extra_colors, ) from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse +from backend.app.services.slicer_filament_resolver import resolve_slicer_filament from backend.app.services.spool_csv import ( MAX_CSV_IMPORT_BYTES, ImportPreview, @@ -66,27 +67,6 @@ _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024 # FilamentColors.xyz API FILAMENT_COLORS_API = "https://filamentcolors.xyz/api" -# Generic Bambu filament IDs by material — fallback when no specific -# preset is resolvable. Keep aligned with the inline table in -# apply_spool_to_slot_via_mqtt below; both paths must produce the same -# value for a given material. -_GENERIC_FILAMENT_IDS: dict[str, str] = { - "PLA": "GFL99", - "PETG": "GFG99", - "ABS": "GFB99", - "ASA": "GFB98", - "PC": "GFC99", - "PA": "GFN99", - "NYLON": "GFN99", - "TPU": "GFU99", - "PVA": "GFS99", - "HIPS": "GFS98", - "PLA-CF": "GFL98", - "PETG-CF": "GFG98", - "PA-CF": "GFN98", - "PETG HF": "GFG96", -} - async def apply_spool_to_slot_via_mqtt( *, @@ -129,125 +109,24 @@ async def apply_spool_to_slot_via_mqtt( ) tray_color = spool.rgba or "FFFFFFFF" - _generic_id_values = set(_GENERIC_FILAMENT_IDS.values()) + _generic_id_values = _GENERIC_ID_VALUES + _known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys()) - tray_info_idx = "" - setting_id = "" - sf = spool.slicer_filament or "" - - if sf: - base_sf = sf.split("_")[0] if "_" in sf else sf - # 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 - - cloud = await build_authenticated_cloud(db, current_user) - if cloud is not None and cloud.is_authenticated: - try: - detail = await cloud.get_setting_detail(base_sf) - if detail.get("filament_id"): - tray_info_idx = detail["filament_id"] - cloud_name = detail.get("name", "") - if cloud_name: - tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip() - elif detail.get("base_id"): - bid = detail["base_id"].split("_")[0] - if bid.startswith("GFS") and len(bid) >= 5: - tray_info_idx = f"GF{bid[3:]}" - else: - tray_info_idx = bid - finally: - await cloud.close() - elif cloud is not None: - await cloud.close() - except Exception as e: - logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e) - - if not tray_info_idx: - tray_info_idx, setting_id = normalize_slicer_filament(sf) - elif base_sf.startswith("GF"): - tray_info_idx, setting_id = normalize_slicer_filament(sf) - else: - try: - local_id = int(sf) - from backend.app.models.local_preset import LocalPreset as LP - - 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: - # 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): - tray_info_idx, setting_id = normalize_slicer_filament(sf) - - if tray_info_idx and spool.slicer_filament_name: - from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES - - expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "") - if expected_name and expected_name != spool.slicer_filament_name: - for fid, fname in _BUILTIN_FILAMENT_NAMES.items(): - if fname == spool.slicer_filament_name: - tray_info_idx = fid - setting_id = filament_id_to_setting_id(fid) - break - - # 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 - # 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. - # 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" 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") - or tray_info_idx.startswith("PFCN") - ): - tray_info_idx = "" - setting_id = "" + # slicer_filament → (tray_info_idx, setting_id) resolution is shared with + # the Spoolman-mode route via this helper (#1713). The helper handles + # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id, + # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name + # sanitization. When it returns an empty tray_info_idx the local + # current-tray-state + generic-material fallback below rescues the slot. + tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament( + db=db, + current_user=current_user, + slicer_filament=spool.slicer_filament, + slicer_filament_name=spool.slicer_filament_name, + material=spool.material, + ) + if sub_brand_override: + tray_sub_brands = sub_brand_override if not tray_info_idx: if ( @@ -263,8 +142,8 @@ async def apply_spool_to_slot_via_mqtt( elif tray_type: material = tray_type.upper().strip() generic = ( - _GENERIC_FILAMENT_IDS.get(material) - or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0]) + GENERIC_FILAMENT_IDS.get(material) + or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0]) or "" ) if generic: diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py index 8eb7be390..2a7cf2033 100644 --- a/backend/app/api/routes/spoolman_inventory.py +++ b/backend/app/api/routes/spoolman_inventory.py @@ -43,6 +43,7 @@ from backend.app.models.user import User from backend.app.schemas.spool import SpoolKProfileBase from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched from backend.app.services.printer_manager import printer_manager +from backend.app.services.slicer_filament_resolver import resolve_slicer_filament from backend.app.services.spoolman import ( SpoolmanClient, SpoolmanClientError, @@ -55,6 +56,7 @@ from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_sl from backend.app.utils.filament_ids import ( GENERIC_FILAMENT_IDS, MATERIAL_TEMPS, + filament_id_to_setting_id, normalize_slicer_filament, ) @@ -1221,7 +1223,7 @@ async def sync_spoolman_ams_weights( async def assign_spoolman_slot( body: SpoolSlotAssignmentRequest, db: AsyncSession = Depends(get_db), - _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), + current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ) -> dict: """Assign a Spoolman spool to a printer AMS slot (stored in local DB only). @@ -1306,13 +1308,43 @@ async def assign_spoolman_slot( if len(tray_color) == 6: tray_color = tray_color + "FF" - material_upper = tray_type.upper().strip() - tray_info_idx = ( - GENERIC_FILAMENT_IDS.get(material_upper) - or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0]) - or "" + # #1713: resolve the spool's stored slicer_filament reference + # (cloud preset, local preset, GF-prefix builtin, or numeric + # LocalPreset id) to the printer-side tray_info_idx + setting_id. + # Previously the Spoolman path dropped slicer_filament on the + # floor and only the generic-material fallback fired; the user- + # configured profile never reached the printer. Shared with the + # internal-mode route via the same helper so the two flows can't + # drift again. + tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament( + db=db, + current_user=current_user, + slicer_filament=mapped.get("slicer_filament"), + slicer_filament_name=mapped.get("slicer_filament_name"), + material=tray_type, ) - setting_id = "" + if sub_brand_override: + tray_sub_brands = sub_brand_override + + material_upper = tray_type.upper().strip() + # Fall back to generic-material id when slicer_filament is empty + # or the resolver discarded an unresolvable value. Matches the + # internal-mode tail in inventory.py:_apply_spool_to_slot_inner. + if not tray_info_idx: + tray_info_idx = ( + GENERIC_FILAMENT_IDS.get(material_upper) + or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0]) + or "" + ) + + # Ensure setting_id is always derivable from tray_info_idx. The + # local-preset path can leave it empty when the LP's setting JSON + # has no filament_id and falls through to the generic material id; + # without this fallback the slicer gets a half-configured slot + # (filament id without setting id) and the slot detail modal + # renders empty fields. Same pattern as the internal-mode tail. + if tray_info_idx and not setting_id: + setting_id = filament_id_to_setting_id(tray_info_idx) temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240)) temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0] diff --git a/backend/app/services/slicer_filament_resolver.py b/backend/app/services/slicer_filament_resolver.py new file mode 100644 index 000000000..38b9c1d9f --- /dev/null +++ b/backend/app/services/slicer_filament_resolver.py @@ -0,0 +1,205 @@ +"""Shared spool ``slicer_filament`` → ``(tray_info_idx, setting_id)`` resolver. + +The internal-inventory and Spoolman-inventory routes both need to translate +a spool's stored slicer-preset reference (cloud preset ID / local preset ID / +GF-prefix Bambu filament ID / free-text material name) into the two MQTT +fields ``ams_filament_setting`` consumes: the printer-side ``tray_info_idx`` +(filament_id) and the slicer-side ``setting_id``. The two routes were drifting +in lockstep before #1713 — internal mode resolved everything, Spoolman mode +silently dropped slicer_filament on the floor and only the generic-material +fallback fired. This module is the single chokepoint so the two flows can't +diverge again. + +Resolver outcomes: + +- Returns ``("", "", None)`` when ``slicer_filament`` is empty, unresolvable, + or sanitised away as a slicer-rejected value (literal material name, + PFUS / PFCN cloud setting_id). The caller is responsible for the + generic-material fallback when this happens. +- Returns ``(tray_info_idx, setting_id, sub_brand_override)`` otherwise. + The third element is non-empty when a cloud-detail lookup or a local- + preset name provides a more specific brand label than the spool's own + ``" "`` concatenation — the caller should + prefer it over its computed default. + +The resolver is async because the GFS / PFUS / PFCN branches need cloud +authentication and the local-preset branch reads ``LocalPreset`` from the +DB. Pass ``current_user=None`` to skip cloud auth (the on_ams_change +replay path uses this); cloud-prefix presets then fall back to a static +``normalize_slicer_filament`` parse, which is correct when the slot was +already configured by an earlier authenticated assign and the printer's +calibration table preserves the real filament_id. +""" + +from __future__ import annotations + +import json +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.user import User +from backend.app.utils.filament_ids import ( + GENERIC_FILAMENT_IDS, + MATERIAL_TEMPS, + filament_id_to_setting_id, + normalize_slicer_filament, +) + +logger = logging.getLogger(__name__) + +_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys()) + + +async def resolve_slicer_filament( + *, + db: AsyncSession, + current_user: User | None, + slicer_filament: str | None, + slicer_filament_name: str | None, + material: str | None, +) -> tuple[str, str, str | None]: + """Resolve a spool's slicer-preset reference to printer-side ids. + + ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``, + ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or + free-text). May be empty or None — returns the empty tuple in that case. + + ``slicer_filament_name``: optional builtin-name realignment hint. When + set and the resolved tray_info_idx maps to a different builtin name, + the resolver swaps to the builtin whose name matches (e.g. user picked + "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic"). + + ``material``: spool material string for the local-preset fallback + branch when the LocalPreset's setting JSON doesn't carry a filament_id. + + Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty + when nothing resolved. ``sub_brand_override`` is non-None when a more + specific brand label is available (cloud detail name or local preset + name); ``None`` means the caller should use its own default. + """ + sf = (slicer_filament or "").strip() + if not sf: + return ("", "", None) + + tray_info_idx = "" + setting_id = "" + sub_brand_override: str | None = None + + base_sf = sf.split("_")[0] if "_" in sf else sf + + # 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 + + cloud = await build_authenticated_cloud(db, current_user) + if cloud is not None and cloud.is_authenticated: + try: + detail = await cloud.get_setting_detail(base_sf) + if detail.get("filament_id"): + tray_info_idx = detail["filament_id"] + cloud_name = detail.get("name", "") + if cloud_name: + sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip() + elif detail.get("base_id"): + bid = detail["base_id"].split("_")[0] + if bid.startswith("GFS") and len(bid) >= 5: + tray_info_idx = f"GF{bid[3:]}" + else: + tray_info_idx = bid + finally: + await cloud.close() + elif cloud is not None: + await cloud.close() + except Exception as e: + logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e) + + if not tray_info_idx: + tray_info_idx, setting_id = normalize_slicer_filament(sf) + elif base_sf.startswith("GF"): + tray_info_idx, setting_id = normalize_slicer_filament(sf) + else: + try: + local_id = int(sf) + from backend.app.models.local_preset import LocalPreset as LP + + 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: + # 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 = (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: + sub_brand_override = lp.name.split("@")[0].strip() + except (ValueError, TypeError): + tray_info_idx, setting_id = normalize_slicer_filament(sf) + + # Realign tray_info_idx to a builtin whose name matches slicer_filament_name + # when the current resolution lands on a builtin with a different name + # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte). + if tray_info_idx and slicer_filament_name: + from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES + + expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "") + if expected_name and expected_name != slicer_filament_name: + for fid, fname in _BUILTIN_FILAMENT_NAMES.items(): + if fname == slicer_filament_name: + tray_info_idx = fid + setting_id = filament_id_to_setting_id(fid) + break + + # Defend against tray_info_idx values the slicer cannot resolve. Three + # shapes leak through and must be discarded so the caller's generic- + # material fallback 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 the caller's printer_kp + # lookup, but on the replay path in main.py.on_ams_change + # current_user=None 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" or "PFCN"). + 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 = "" + + return (tray_info_idx, setting_id, sub_brand_override) diff --git a/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py b/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py index 663ca8887..81a7a1d1a 100644 --- a/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py +++ b/backend/tests/integration/test_spoolman_slot_assignment_mqtt.py @@ -6,6 +6,7 @@ Covers: - MQTT failure does NOT roll back the slot assignment """ +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -868,3 +869,171 @@ class TestAssignSpoolmanSlotKProfileRealignment: # extruder mismatch was hard-skipped pre-fix; now used as fallback cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1] assert cs_kwargs["cali_idx"] == 42 + + +# ---- #1713: slicer_filament resolved into tray_info_idx + setting_id -------- +# +# Before this fix the Spoolman-mode assign route ignored the spool's stored +# slicer_filament (the user's configured Bambu Studio / Orca filament profile) +# and only filled tray_info_idx from the generic-material fallback. The user +# saw ams_filament_setting publish with tray_info_idx=GFL99 / setting_id="" +# even though they had assigned a real profile to the spool, and had to +# manually re-configure each slot through the printer card. The internal-mode +# route did the resolution correctly via _apply_spool_to_slot_inner; the +# Spoolman route was never ported. +# +# These tests pin the parity: an assign of a Spoolman spool whose +# bambu_slicer_filament extra-field points at a real preset must publish that +# preset's tray_info_idx + setting_id, not the generic-material bucket. + + +class TestSlicerFilamentResolutionParity: + """#1713: Spoolman-mode assign honours the spool's configured slicer + filament profile, matching internal-mode behaviour.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_gf_prefix_slicer_filament_resolves_to_tray_info_idx( + self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client + ): + """GF-prefix Bambu official preset (e.g. ``GFA01``) routes straight + through ``normalize_slicer_filament`` — the simplest path and the + most common shape for users who picked their preset in the slicer.""" + mock_spoolman_client.get_spool = AsyncMock( + return_value={**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": '"GFA01"'}} + ) + + mqtt_mock = MagicMock() + mqtt_mock.ams_set_filament_setting = MagicMock() + mqtt_mock.extrusion_cali_sel = MagicMock() + mqtt_mock.printer_state = None + + with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock: + pm_mock.get_client = MagicMock(return_value=mqtt_mock) + pm_mock.get_status = MagicMock(return_value=None) + + response = await async_client.post( + "/api/v1/spoolman/inventory/slot-assignments", + json={ + "spoolman_spool_id": 10, + "printer_id": test_printer.id, + "ams_id": 0, + "tray_id": 0, + }, + ) + + assert response.status_code == 200 + call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1] + assert call_kwargs["tray_info_idx"] == "GFA01", ( + "Pre-fix: dropped slicer_filament and published GFL99 generic-PLA bucket. " + "Post-fix: must publish the actual preset id." + ) + assert call_kwargs["setting_id"].startswith("GFSA01"), ( + "setting_id must be derived from the resolved filament_id, not left empty as the pre-fix path did." + ) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_local_preset_int_id_resolves_to_filament_id_from_json( + self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session + ): + """#1713 regression: shaddowlink's exact case. Spool's slicer_filament + is the integer id of a LocalPreset whose setting JSON carries the + printer-side ``filament_id`` (e.g. ``P20bd830``). The publish must + carry that filament_id + its derived setting_id — not the generic + material bucket. + + From his support bundle: + 11:33:01 — assign_spoolman_slot published tray_info_idx=GFL99 (BUG) + 11:33:13 — user manually fired /printers/.../configure with + tray_info_idx=P20bd830, setting_id=PFUS3822acb73c88cc + """ + from backend.app.models.local_preset import LocalPreset + + lp = LocalPreset( + name="AMOLEN PLA Silk @0.4 nozzle", + preset_type="filament", + filament_type="PLA", + setting=json.dumps({"filament_id": "P20bd830"}), + ) + db_session.add(lp) + await db_session.commit() + await db_session.refresh(lp) + + # Spoolman spool whose bambu_slicer_filament points at this LocalPreset + # by integer id (the shape the inventory UI persists when the user + # picks a local preset in the filament dropdown). + mock_spoolman_client.get_spool = AsyncMock( + return_value={ + **SAMPLE_SPOOL, + "extra": {"bambu_slicer_filament": json.dumps(str(lp.id))}, + } + ) + + mqtt_mock = MagicMock() + mqtt_mock.ams_set_filament_setting = MagicMock() + mqtt_mock.extrusion_cali_sel = MagicMock() + mqtt_mock.printer_state = None + + with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock: + pm_mock.get_client = MagicMock(return_value=mqtt_mock) + pm_mock.get_status = MagicMock(return_value=None) + + response = await async_client.post( + "/api/v1/spoolman/inventory/slot-assignments", + json={ + "spoolman_spool_id": 10, + "printer_id": test_printer.id, + "ams_id": 255, + "tray_id": 0, + }, + ) + + assert response.status_code == 200 + call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1] + # Pre-fix the publish here was tray_info_idx="GFL99", setting_id="". + assert call_kwargs["tray_info_idx"] == "P20bd830" + assert call_kwargs["setting_id"], "setting_id must not be empty post-fix" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_empty_slicer_filament_still_falls_back_to_generic_material( + self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client + ): + """Spools without a configured slicer_filament must still get the + generic-material fallback so the slot is at least minimally + configured. Guards against the resolver path swallowing the empty + case and leaving tray_info_idx empty.""" + # extra dict has no bambu_slicer_filament key + mock_spoolman_client.get_spool = AsyncMock(return_value={**SAMPLE_SPOOL, "extra": {}}) + + mqtt_mock = MagicMock() + mqtt_mock.ams_set_filament_setting = MagicMock() + mqtt_mock.extrusion_cali_sel = MagicMock() + mqtt_mock.printer_state = None + + with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock: + pm_mock.get_client = MagicMock(return_value=mqtt_mock) + pm_mock.get_status = MagicMock(return_value=None) + + response = await async_client.post( + "/api/v1/spoolman/inventory/slot-assignments", + json={ + "spoolman_spool_id": 10, + "printer_id": test_printer.id, + "ams_id": 0, + "tray_id": 0, + }, + ) + + assert response.status_code == 200 + call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1] + # PLA → GFL99 (the generic-PLA bucket from GENERIC_FILAMENT_IDS). + assert call_kwargs["tray_info_idx"] == "GFL99" + # The generic-fallback path must STILL produce a non-empty setting_id + # (matches the internal-mode tail). Pre-fix this was "". + assert call_kwargs["setting_id"], ( + "Even on the generic-material fallback, setting_id must be " + "filament_id_to_setting_id-derived so the slot detail modal " + "doesn't render with empty fields." + )