Browse Source

fix(spool-assign): defer MQTT for empty AMS slot, replay on physical insert

  The SpoolBuddy "weigh-then-assign" workflow tried to configure an empty AMS
  slot at assign time, but Bambu firmware silently drops ams_filament_setting
  and extrusion_cali_sel for unloaded slots — the MQTT calls completed and the
  modal closed, yet BambuStudio kept showing the slot as default-PLA forever.

  assign_spool now detects an empty target slot (fingerprint_type empty) and
  persists the SpoolAssignment without publishing MQTT, returning a new
  pending_config flag so the frontend can swap "Assigned!" for "Slot will
  configure when you insert the spool." on_ams_change watches for the slot
  to load (state == 11, which fires for 3rd-party tags too even when
  tray_type stays empty) and replays the deferred ams_filament_setting +
  extrusion_cali_sel — including the printer-kp realignment that converts
  PFUS-prefix cloud user presets to the P-prefix local-preset filament_id
  the slicer actually accepts.

  The full assign-time MQTT block was extracted into
  apply_spool_to_slot_via_mqtt so both the assign endpoint and the
  on_ams_change replay path use the same resolution logic; the helper takes
  ~270 lines of duplication out of assign_spool.
maziggy 3 weeks ago
parent
commit
b42aaca521

+ 331 - 275
backend/app/api/routes/inventory.py

@@ -59,6 +59,305 @@ MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
 # 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(
+    *,
+    db: AsyncSession,
+    current_user: User | None,
+    spool: Spool,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    current_tray_info_idx: str = "",
+    current_tray_type: str = "",
+) -> bool:
+    """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
+
+    Shared by `assign_spool` (initial assign for a loaded slot) and
+    `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
+    empty → loaded). Returns True when MQTT commands were published, False if
+    no client was available or setup failed mid-way.
+
+    `current_tray_info_idx` / `current_tray_type` describe the live tray state
+    used as fallback hints when the spool's slicer_filament can't be resolved.
+    Caller should not pass these for the empty-slot re-fire path (they'll be
+    the freshly-loaded values, which is the intended fallback).
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    client = printer_manager.get_client(printer_id)
+    if client is None:
+        return False
+
+    state = printer_manager.get_status(printer_id)
+
+    tray_type = spool.material
+    tray_sub_brands = (
+        f"{spool.brand} {spool.material} {spool.subtype}".strip()
+        if spool.brand
+        else f"{spool.material} {spool.subtype}"
+        if spool.subtype
+        else spool.material
+    )
+    tray_color = spool.rgba or "FFFFFFFF"
+
+    _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
+
+    tray_info_idx = ""
+    setting_id = ""
+    sf = spool.slicer_filament or ""
+
+    if sf:
+        base_sf = sf.split("_")[0] if "_" in sf else sf
+        if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+            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:
+                    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
+
+    if not tray_info_idx:
+        if (
+            current_tray_info_idx
+            and current_tray_info_idx not in _generic_id_values
+            and current_tray_type
+            and current_tray_type.upper() == tray_type.upper()
+        ):
+            tray_info_idx = current_tray_info_idx
+        elif tray_type:
+            material = tray_type.upper().strip()
+            generic = (
+                _GENERIC_FILAMENT_IDS.get(material)
+                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or ""
+            )
+            if generic:
+                tray_info_idx = generic
+
+    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
+    if spool.nozzle_temp_max is not None:
+        temp_max = spool.nozzle_temp_max
+
+    nozzle_diameter = "0.4"
+    if state and state.nozzles:
+        nd = state.nozzles[0].nozzle_diameter
+        if nd:
+            nozzle_diameter = nd
+
+    slot_extruder = None
+    if state and state.ams_extruder_map:
+        if ams_id == 255:
+            slot_extruder = 1 - tray_id  # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+        else:
+            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+    # Prefer exact extruder match, fall back to extruder-agnostic kp for the
+    # same nozzle. Hard-skipping on mismatch silently drops valid stored
+    # profiles when the AMS-extruder mapping has shifted.
+    exact_kp = None
+    fallback_kp = None
+    for kp in spool.k_profiles:
+        if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
+            continue
+        if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+            exact_kp = kp
+            break
+        if fallback_kp is None:
+            fallback_kp = kp
+    matching_kp = exact_kp or fallback_kp
+
+    # Resolve the printer-side calibration entry by looking up the cali_idx
+    # in state.kprofiles. The printer keys its calibration table by
+    # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
+    # filament_id must match the kp's. PFUS-prefix cloud user presets are
+    # rejected by the slicer in tray_info_idx; the printer-reported
+    # filament_id is typically a P-prefix local preset which is valid.
+    printer_kp = None
+    if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
+        for pkp in state.kprofiles:
+            if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
+                printer_kp = pkp
+                break
+
+    effective_tray_info_idx = tray_info_idx
+    effective_setting_id = setting_id
+    if printer_kp and printer_kp.filament_id:
+        effective_tray_info_idx = printer_kp.filament_id
+    target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
+        matching_kp.setting_id if matching_kp else None
+    )
+    if target_setting_id:
+        effective_setting_id = target_setting_id
+    if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
+        logger.info(
+            "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
+            tray_info_idx,
+            effective_tray_info_idx,
+            setting_id,
+            effective_setting_id,
+            "printer" if printer_kp else "stored",
+        )
+
+    client.ams_set_filament_setting(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_type=tray_type,
+        tray_sub_brands=tray_sub_brands,
+        tray_color=tray_color,
+        nozzle_temp_min=temp_min,
+        nozzle_temp_max=temp_max,
+        setting_id=effective_setting_id,
+    )
+
+    if matching_kp and matching_kp.cali_idx is not None:
+        # filament_id for cali_sel must match the preset under which the kp
+        # was registered. Priority: live printer kp > stored kp.setting_id >
+        # spool.slicer_filament > realigned tray_info_idx.
+        if printer_kp and printer_kp.filament_id:
+            cali_filament_id = printer_kp.filament_id
+        elif matching_kp.setting_id:
+            cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
+        else:
+            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=matching_kp.cali_idx,
+            filament_id=cali_filament_id,
+            nozzle_diameter=nozzle_diameter,
+        )
+
+    # Persist slot preset mapping for UI display (preset_name on hover card).
+    try:
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+        preset_source = "cloud"
+        if sf:
+            base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+            try:
+                int(base_sf_mapping)
+                preset_id_to_save = f"local_{base_sf_mapping}"
+                preset_source = "local"
+            except (ValueError, TypeError):
+                preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+        else:
+            preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+        if preset_id_to_save:
+            existing_mapping = await db.execute(
+                select(SlotPresetMapping).where(
+                    SlotPresetMapping.printer_id == printer_id,
+                    SlotPresetMapping.ams_id == ams_id,
+                    SlotPresetMapping.tray_id == tray_id,
+                )
+            )
+            mapping = existing_mapping.scalar_one_or_none()
+            if mapping:
+                mapping.preset_id = preset_id_to_save
+                mapping.preset_name = preset_name
+                mapping.preset_source = preset_source
+            else:
+                mapping = SlotPresetMapping(
+                    printer_id=printer_id,
+                    ams_id=ams_id,
+                    tray_id=tray_id,
+                    preset_id=preset_id_to_save,
+                    preset_name=preset_name,
+                    preset_source=preset_source,
+                )
+                db.add(mapping)
+            await db.commit()
+    except Exception as e:
+        logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
+
+    logger.info(
+        "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
+        ams_id,
+        tray_id,
+        spool.id,
+        printer_id,
+    )
+    return True
+
 
 # ── Spool Catalog Schemas ──────────────────────────────────────────────────
 
@@ -894,285 +1193,32 @@ async def assign_spool(
     await db.commit()
     await db.refresh(assignment)
 
-    # 4. Auto-configure AMS slot via MQTT
+    # 4. Auto-configure AMS slot via MQTT.
+    #
+    # Skip the publish entirely when the target slot is empty: Bambu firmware
+    # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
+    # slots (there is no filament context for the cali_idx to attach to). The
+    # SpoolAssignment row is preserved with an empty fingerprint_type, which
+    # acts as the "pending config" marker — when the spool is physically
+    # inserted later, on_ams_change re-fires the full configuration. This is
+    # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
+    slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
     configured = False
-    try:
-        client = printer_manager.get_client(data.printer_id)
-        if client:
-            # Build filament setting from spool data
-            tray_type = spool.material
-            tray_sub_brands = (
-                f"{spool.brand} {spool.material} {spool.subtype}".strip()
-                if spool.brand
-                else f"{spool.material} {spool.subtype}"
-                if spool.subtype
-                else spool.material
-            )
-            tray_color = spool.rgba or "FFFFFFFF"
-
-            _GENERIC_IDS = {
-                "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",
-            }
-            _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
-
-            # Resolve tray_info_idx + setting_id for the MQTT command.
-            # Three sources in priority order:
-            #   1. Cloud profile (if cloud connected) — resolve filament_id
-            #      from setting_id via cloud API
-            #   2. Local profile — use generic filament ID for material
-            #   3. Hard-coded fallback — generic Bambu filament IDs
-            tray_info_idx = ""
-            setting_id = ""
-            sf = spool.slicer_filament or ""
-
-            if sf:
-                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
-                base_sf = sf.split("_")[0] if "_" in sf else sf
-                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
-                    # Cloud setting_id — need to resolve real filament_id
-                    # Use base_sf (version suffix stripped) for cloud API + MQTT
-                    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"]
-                                    logger.info(
-                                        "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
-                                        tray_info_idx,
-                                        sf,
-                                    )
-                                    # Use cloud preset name for tray_sub_brands if available
-                                    cloud_name = detail.get("name", "")
-                                    if cloud_name:
-                                        tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                                elif detail.get("base_id"):
-                                    # Derive from base_id (e.g. "GFSL05" → "GFL05")
-                                    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
-                                    logger.info(
-                                        "Spool assign: derived filament_id=%r from base_id=%r",
-                                        tray_info_idx,
-                                        detail["base_id"],
-                                    )
-                            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:
-                        # Cloud lookup failed — use normalize as fallback
-                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
-                elif base_sf.startswith("GF"):
-                    # Official Bambu filament_id (e.g. "GFL05")
-                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
-                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
-
-                else:
-                    # Could be a local preset ID or material type — try local DB
-                    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:
-                            mat = (spool.material or lp.filament_type or "").upper().strip()
-                            tray_info_idx = (
-                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
-                            )
-                            # Use local preset name for tray_sub_brands
-                            if lp.name:
-                                tray_sub_brands = lp.name.split("@")[0].strip()
-                            logger.info(
-                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
-                                local_id,
-                                mat,
-                                tray_info_idx,
-                            )
-                    except (ValueError, TypeError):
-                        # Not a numeric ID — treat as material type string
-                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
-
-            # Cross-check: the cloud API returns the base filament_id for
-            # versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).
-            # If the spool has a specific preset name (e.g. "Generic PLA Silk"),
-            # reverse-lookup the correct filament_id from the built-in table.
-            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:
-                            logger.info(
-                                "Spool assign: corrected filament_id %r→%r (name=%r)",
-                                tray_info_idx,
-                                fid,
-                                spool.slicer_filament_name,
-                            )
-                            tray_info_idx = fid
-                            setting_id = filament_id_to_setting_id(fid)
-                            break
-
-            if not tray_info_idx:
-                # Fallback: reuse slot's existing tray_info_idx or generic ID
-                if (
-                    current_tray_info_idx
-                    and current_tray_info_idx not in _GENERIC_ID_VALUES
-                    and fingerprint_type
-                    and fingerprint_type.upper() == tray_type.upper()
-                ):
-                    logger.info(
-                        "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
-                        current_tray_info_idx,
-                        tray_type,
-                    )
-                    tray_info_idx = current_tray_info_idx
-                elif tray_type:
-                    material = tray_type.upper().strip()
-                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
-                    if generic:
-                        logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
-                        tray_info_idx = generic
-
-            # Temperature: use spool overrides if set, else material defaults
-            temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
-            if spool.nozzle_temp_min is not None:
-                temp_min = spool.nozzle_temp_min
-            if spool.nozzle_temp_max is not None:
-                temp_max = spool.nozzle_temp_max
-
-            # a. Set filament setting
-            client.ams_set_filament_setting(
+    pending_config = slot_is_empty
+    if not slot_is_empty:
+        try:
+            configured = await apply_spool_to_slot_via_mqtt(
+                db=db,
+                current_user=current_user,
+                spool=spool,
+                printer_id=data.printer_id,
                 ams_id=data.ams_id,
                 tray_id=data.tray_id,
-                tray_info_idx=tray_info_idx,
-                tray_type=tray_type,
-                tray_sub_brands=tray_sub_brands,
-                tray_color=tray_color,
-                nozzle_temp_min=temp_min,
-                nozzle_temp_max=temp_max,
-                setting_id=setting_id,
+                current_tray_info_idx=current_tray_info_idx,
+                current_tray_type=fingerprint_type or "",
             )
-
-            # b. Look up K-profile for this spool + printer + nozzle + extruder
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            # Determine slot's extruder from ams_extruder_map
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if data.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    slot_extruder = 1 - data.tray_id  # 0→1, 1→0
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
-
-            matching_kp = None
-            for kp in spool.k_profiles:
-                if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
-                        continue
-                    matching_kp = kp
-                    break
-
-            if matching_kp and matching_kp.cali_idx is not None:
-                client.extrusion_cali_sel(
-                    ams_id=data.ams_id,
-                    tray_id=data.tray_id,
-                    cali_idx=matching_kp.cali_idx,
-                    filament_id=tray_info_idx,
-                    nozzle_diameter=nozzle_diameter,
-                )
-
-            configured = True
-            logger.info(
-                "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
-                data.ams_id,
-                data.tray_id,
-                spool.id,
-                data.printer_id,
-            )
-
-            # Save slot preset mapping so the UI shows the correct preset name.
-            # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
-            try:
-                from backend.app.models.slot_preset import SlotPresetMapping
-
-                preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
-                preset_source = "cloud"
-                if sf:
-                    base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
-                    try:
-                        local_id = int(base_sf_mapping)
-                        preset_id_to_save = f"local_{local_id}"
-                        preset_source = "local"
-                    except (ValueError, TypeError):
-                        # Cloud or builtin preset — convert filament_id to setting_id
-                        preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
-                else:
-                    preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
-
-                if preset_id_to_save:
-                    existing_mapping = await db.execute(
-                        select(SlotPresetMapping).where(
-                            SlotPresetMapping.printer_id == data.printer_id,
-                            SlotPresetMapping.ams_id == data.ams_id,
-                            SlotPresetMapping.tray_id == data.tray_id,
-                        )
-                    )
-                    mapping = existing_mapping.scalar_one_or_none()
-                    if mapping:
-                        mapping.preset_id = preset_id_to_save
-                        mapping.preset_name = preset_name
-                        mapping.preset_source = preset_source
-                    else:
-                        mapping = SlotPresetMapping(
-                            printer_id=data.printer_id,
-                            ams_id=data.ams_id,
-                            tray_id=data.tray_id,
-                            preset_id=preset_id_to_save,
-                            preset_name=preset_name,
-                            preset_source=preset_source,
-                        )
-                        db.add(mapping)
-                    await db.commit()
-                    logger.info(
-                        "Saved slot preset mapping: preset_id=%r, preset_name=%r",
-                        preset_id_to_save,
-                        preset_name,
-                    )
-            except Exception as e:
-                logger.warning("Failed to save slot preset mapping: %s", e)
-
-    except Exception as e:
-        logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+        except Exception as e:
+            logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
 
     # Return assignment with spool data
     result = await db.execute(
@@ -1186,6 +1232,16 @@ async def assign_spool(
     resp = result.scalar_one()
     response = SpoolAssignmentResponse.model_validate(resp)
     response.configured = configured
+    response.pending_config = pending_config
+
+    if pending_config:
+        logger.info(
+            "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
+            spool.id,
+            data.printer_id,
+            data.ams_id,
+            data.tray_id,
+        )
 
     await ws_manager.broadcast(
         {

+ 3 - 1
backend/app/api/routes/library.py

@@ -2977,7 +2977,9 @@ async def slice_and_persist(
     )
     db.add(new_file)
     await db.commit()
-    await db.refresh(new_file)
+    # No refresh: expire_on_commit=False keeps id/filename accessible, and
+    # refreshing here flakes under pytest-xdist when teardown of a sibling
+    # test races the SELECT.
 
     return SliceResponse(
         library_file_id=new_file.id,

+ 56 - 3
backend/app/main.py

@@ -928,9 +928,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
             from sqlalchemy.orm import selectinload
 
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
+            from backend.app.models.spool import Spool as _Spool
             from backend.app.models.spool_assignment import SpoolAssignment as SA
 
-            result = await db.execute(select(SA).where(SA.printer_id == printer_id).options(selectinload(SA.spool)))
+            result = await db.execute(
+                select(SA)
+                .where(SA.printer_id == printer_id)
+                .options(selectinload(SA.spool).selectinload(_Spool.k_profiles))
+            )
             stale = []
             for assignment in result.scalars().all():
                 # External spool assignments (ams_id=255) live in vt_tray, not AMS data
@@ -1001,8 +1006,56 @@ async def on_ams_change(printer_id: int, ams_data: list):
                 else:
                     cur_color = current_tray.get("tray_color", "")
                     cur_type = current_tray.get("tray_type", "")
+                    cur_state = current_tray.get("state")
                     fp_color = assignment.fingerprint_color or ""
                     fp_type = assignment.fingerprint_type or ""
+
+                    # SpoolBuddy pre-config replay: fingerprint_type empty means
+                    # the slot was empty when the user pre-assigned via SpoolBuddy
+                    # (the firmware drops ams_filament_setting on empty slots, so
+                    # MQTT was deferred). The moment any filament gets inserted
+                    # — Bambu RFID, 3rd-party, or even an existing-but-now-
+                    # reconfigured spool — fire the deferred configuration.
+                    # The "loaded" signal is `state == 11` (Bambu's "filament fed
+                    # to extruder" code), NOT tray_type — 3rd-party spools without
+                    # readable RFID report state=11 but tray_type="" because the
+                    # AMS sensor reads no filament metadata. Requiring a non-empty
+                    # tray_type would lock out the exact users this feature targets.
+                    if not fp_type.strip() and cur_state == 11 and assignment.spool:
+                        try:
+                            from backend.app.api.routes.inventory import (
+                                apply_spool_to_slot_via_mqtt,
+                            )
+
+                            await apply_spool_to_slot_via_mqtt(
+                                db=db,
+                                current_user=None,
+                                spool=assignment.spool,
+                                printer_id=printer_id,
+                                ams_id=assignment.ams_id,
+                                tray_id=assignment.tray_id,
+                                current_tray_info_idx=current_tray.get("tray_info_idx", ""),
+                                current_tray_type=cur_type,
+                            )
+                            logger.info(
+                                "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",
+                                assignment.spool_id,
+                                printer_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                        except Exception:
+                            logger.exception(
+                                "Pre-config apply failed for spool %d on printer %d AMS%d-T%d",
+                                assignment.spool_id,
+                                printer_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                        assignment.fingerprint_color = cur_color
+                        assignment.fingerprint_type = cur_type
+                        continue
+
                     if not _colors_similar(cur_color, fp_color) or cur_type.upper() != fp_type.upper():
                         # Fingerprint mismatch — but check if tray now matches the
                         # assigned spool (e.g. auto-configure changed the tray).
@@ -1011,7 +1064,6 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             spool_color = (spool.rgba or "FFFFFFFF").upper()
                             spool_type = (spool.material or "").upper()
                             if _colors_similar(cur_color, spool_color) and cur_type.upper() == spool_type:
-                                # Tray was reconfigured to match the spool — update fingerprint
                                 logger.info(
                                     "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
                                     assignment.spool_id,
@@ -1053,6 +1105,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
     try:
         async with _get_ams_assignment_lock(printer_id), async_session() as db:
             from backend.app.api.routes.settings import get_setting
+            from backend.app.models.spool import Spool
             from backend.app.models.spool_assignment import SpoolAssignment as SA
             from backend.app.services.spool_tag_matcher import (
                 auto_assign_spool,
@@ -1082,7 +1135,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         # Check if assignment already exists for this slot
                         existing = await db.execute(
                             select(SA)
-                            .options(selectinload(SA.spool))
+                            .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
                             .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
                         )
                         existing_assignment = existing.scalar_one_or_none()

+ 1 - 0
backend/app/schemas/spool.py

@@ -228,6 +228,7 @@ class SpoolAssignmentResponse(BaseModel):
     created_at: datetime
     spool: SpoolResponse | None = None
     configured: bool = False
+    pending_config: bool = False  # True when slot was empty at assign time; will configure on insert
     ams_label: str | None = None  # User-defined friendly name for the AMS unit
 
     class Config:

+ 230 - 6
backend/tests/integration/test_inventory_assign.py

@@ -69,7 +69,7 @@ class TestAssignSpoolTrayInfoIdx:
         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": ""}]}])
+        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
@@ -158,7 +158,7 @@ class TestAssignSpoolTrayInfoIdx:
         mock_client.ams_set_filament_setting.return_value = True
         mock_client.extrusion_cali_sel.return_value = True
 
-        status = _make_mock_status(ams_data=[])
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
@@ -184,7 +184,7 @@ class TestAssignSpoolTrayInfoIdx:
         mock_client.ams_set_filament_setting.return_value = True
         mock_client.extrusion_cali_sel.return_value = True
 
-        status = _make_mock_status(ams_data=[])
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "ABS"}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
@@ -348,7 +348,7 @@ class TestAssignSpoolPresetMapping:
         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=[])
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 1, "tray_type": "PLA"}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
@@ -404,7 +404,7 @@ class TestAssignSpoolPresetMapping:
         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=[])
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 2, "tray_type": "PLA"}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
@@ -447,7 +447,7 @@ class TestAssignSpoolPresetMapping:
         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=[])
+        status = _make_mock_status(ams_data=[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
@@ -468,3 +468,227 @@ class TestAssignSpoolPresetMapping:
         assert "0" in presets
         # Falls back to tray_sub_brands ("Overture PLA Matte")
         assert presets["0"]["preset_name"] == "Overture PLA Matte"
+
+
+class TestAssignSpoolEmptySlotPreConfig:
+    """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
+
+    Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
+    unloaded slots — there's no filament context for the cali_idx to attach to.
+    The endpoint persists the SpoolAssignment row with an empty fingerprint_type
+    (the "pending config" marker) and skips the MQTT publish; on_ams_change
+    re-fires the full configuration when filament is later inserted.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slot_skips_mqtt_but_persists_assignment(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Assigning to an empty slot skips MQTT and returns pending_config=True."""
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # Slot found but empty (tray_type=""): the SpoolBuddy scenario
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
+
+        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
+        body = response.json()
+        assert body["pending_config"] is True
+        assert body["configured"] is False
+        # Critical: no MQTT was published (firmware would drop it)
+        mock_client.ams_set_filament_setting.assert_not_called()
+        mock_client.extrusion_cali_sel.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        mock_client = MagicMock()
+
+        # No AMS data — fingerprint_type stays None, treated as empty
+        status = _make_mock_status(ams_data=[])
+
+        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": 0, "tray_id": 0},
+            )
+
+        assert response.status_code == 200
+        assert response.json()["pending_config"] is True
+        mock_client.ams_set_filament_setting.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_loaded_slot_publishes_mqtt_immediately(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Loaded slot (tray_type non-empty) → MQTT fires + pending_config=False."""
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFL05", 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": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_info_idx": "GFL05"}]}]
+        )
+
+        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": 0, "tray_id": 0},
+            )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["pending_config"] is False
+        assert body["configured"] is True
+        mock_client.ams_set_filament_setting.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_ams_change_fires_config_when_pre_assigned_slot_loads(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """Pre-config replay: SpoolAssignment with empty fingerprint + slot now loaded → MQTT fires."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="H2D")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        # Pre-existing assignment with empty fingerprint (the SpoolBuddy state)
+        pre_assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=2,
+            tray_id=3,
+            fingerprint_color=None,
+            fingerprint_type=None,
+        )
+        db_session.add(pre_assignment)
+        await db_session.commit()
+
+        # Filament has now been physically inserted into the slot.
+        # state=11 ("filament fed to extruder") is the load signal we trigger on.
+        ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
+
+        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=ams_data)
+        printer_info = MagicMock(name="H2D", serial_number="0948BB540200427")
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = printer_info
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "H2D"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        # Full filament setting was published when the slot transitioned to loaded
+        mock_client.ams_set_filament_setting.assert_called_once()
+        call_kwargs = mock_client.ams_set_filament_setting.call_args.kwargs
+        assert call_kwargs["ams_id"] == 2
+        assert call_kwargs["tray_id"] == 3
+        assert call_kwargs["tray_info_idx"] == "GFL05"
+
+        # Fingerprint was updated so the next push doesn't re-fire
+        await db_session.refresh(pre_assignment)
+        assert pre_assignment.fingerprint_type == "PLA"
+        assert pre_assignment.fingerprint_color == "FF0000FF"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_ams_change_does_not_refire_for_already_configured_slot(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """Once fingerprint_type is set, subsequent AMS pushes must not re-fire MQTT."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        # Assignment already configured (fingerprint stamped)
+        configured_assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=0,
+            fingerprint_color="FF0000FF",
+            fingerprint_type="PLA",
+        )
+        db_session.add(configured_assignment)
+        await db_session.commit()
+
+        ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
+
+        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=ams_data)
+        printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = printer_info
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "X1C"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        # Fingerprint was already set — re-fire path skipped
+        mock_client.ams_set_filament_setting.assert_not_called()

+ 1 - 0
frontend/src/api/client.ts

@@ -2346,6 +2346,7 @@ export interface SpoolAssignment {
   fingerprint_type: string | null;
   spool?: InventorySpool | null;
   configured: boolean;
+  pending_config?: boolean;  // Slot was empty at assign time; will configure on insert
   created_at: string;
   ams_label?: string | null;  // User-defined friendly name for the AMS unit
 }

+ 19 - 9
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -167,27 +167,37 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
   }, [isDualNozzle, amsExtruderMap]);
 
   // Assign spool to AMS slot — single API call, backend handles both
-  // DB record AND MQTT auto-configuration (same as SpoolStation).
+  // DB record AND MQTT auto-configuration (same as SpoolStation). When the
+  // target slot is currently empty, the backend persists the assignment and
+  // skips the MQTT publish (firmware drops it anyway); on_ams_change re-fires
+  // the full configuration when filament is later inserted. The response's
+  // `pending_config` flag distinguishes that from the immediate-apply path
+  // so we can adjust the success toast.
   const configureMutation = useMutation({
     mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
       if (!printerId) throw new Error('No printer selected');
 
-      await api.assignSpool({
+      return await api.assignSpool({
         spool_id: spool.id,
         printer_id: printerId,
         ams_id: amsId,
         tray_id: trayId,
       });
-
-      // Slot preset mapping is now saved by the backend in assign_spool()
-      // after successful MQTT configuration, using the authoritative
-      // slicer_filament_name from the spool record.
     },
-    onSuccess: () => {
+    onSuccess: (assignment) => {
       setStatusType('success');
-      setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      if (assignment?.pending_config) {
+        setStatusMessage(
+          t(
+            'spoolbuddy.modal.assignPendingInsert',
+            'Assigned. Slot will configure when you insert the spool.',
+          ),
+        );
+      } else {
+        setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      }
       queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
-      setTimeout(() => onClose(), 1500);
+      setTimeout(() => onClose(), assignment?.pending_config ? 2500 : 1500);
     },
     onError: (err) => {
       setStatusType('error');

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -5032,6 +5032,7 @@ export default {
       assign: 'Zuweisen',
       assigning: 'Zuweisen...',
       assignSuccess: 'Zugewiesen!',
+      assignPendingInsert: 'Zugewiesen. Slot wird beim Einsetzen der Spule konfiguriert.',
       assignError: 'Fehler beim Zuweisen. Bitte erneut versuchen.',
       noPrinterSelected: 'Drucker auswählen...',
       noAmsDetected: 'Kein AMS an diesem Drucker erkannt',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -5041,6 +5041,7 @@ export default {
       assign: 'Assign',
       assigning: 'Assigning...',
       assignSuccess: 'Assigned!',
+      assignPendingInsert: 'Assigned. Slot will configure when you insert the spool.',
       assignError: 'Failed to assign spool. Please try again.',
       noPrinterSelected: 'Select a printer...',
       noAmsDetected: 'No AMS detected on this printer',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -5020,6 +5020,7 @@ export default {
       assign: 'Assigner',
       assigning: 'Attribution...',
       assignSuccess: 'Assigné !',
+      assignPendingInsert: 'Assigné. Le slot sera configuré lors de l\'insertion de la bobine.',
       assignError: 'Échec de l\'attribution de la bobine. Veuillez réessayer.',
       noPrinterSelected: 'Sélectionner une imprimante...',
       noAmsDetected: 'Aucun AMS détecté sur cette imprimante',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -5019,6 +5019,7 @@ export default {
       assign: 'Assegna',
       assigning: 'Assegnazione...',
       assignSuccess: 'Assegnato!',
+      assignPendingInsert: 'Assegnato. Lo slot verrà configurato all\'inserimento della bobina.',
       assignError: 'Impossibile assegnare la bobina. Riprovare.',
       noPrinterSelected: 'Seleziona una stampante...',
       noAmsDetected: 'Nessun AMS rilevato su questa stampante',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -5032,6 +5032,7 @@ export default {
       assign: '割り当て',
       assigning: '割り当て中...',
       assignSuccess: '割り当て完了!',
+      assignPendingInsert: '割り当てました。スプールを挿入したときにスロットが設定されます。',
       assignError: 'スプールの割り当てに失敗しました。再試行してください。',
       noPrinterSelected: 'プリンターを選択...',
       noAmsDetected: 'このプリンターにAMSが検出されません',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5019,6 +5019,7 @@ export default {
       assign: 'Atribuir',
       assigning: 'Atribuindo...',
       assignSuccess: 'Atribuído!',
+      assignPendingInsert: 'Atribuído. O slot será configurado quando você inserir o carretel.',
       assignError: 'Falha ao atribuir carretel. Tente novamente.',
       noPrinterSelected: 'Selecionar uma impressora...',
       noAmsDetected: 'Nenhum AMS detectado nesta impressora',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5019,6 +5019,7 @@ export default {
       assign: '分配',
       assigning: '分配中...',
       assignSuccess: '已分配!',
+      assignPendingInsert: '已分配。插入耗材后将配置槽位。',
       assignError: '分配耗材失败。请重试。',
       noPrinterSelected: '选择打印机...',
       noAmsDetected: '此打印机未检测到 AMS',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5019,6 +5019,7 @@ export default {
       assign: '分配',
       assigning: '分配中...',
       assignSuccess: '已分配!',
+      assignPendingInsert: '已分配。插入耗材後將設定槽位。',
       assignError: '分配耗材失敗。請重試。',
       noPrinterSelected: '選擇印表機...',
       noAmsDetected: '此印表機未偵測到 AMS',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-vSS22vRm.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-DjTddrY6.js"></script>
+    <script type="module" crossorigin src="./assets/index-vSS22vRm.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff