Browse Source

fix(inventory): K-profile silently dropped to default in three apply paths

  Three independent gaps were causing AMS slots to end up on the firmware-
  default K-profile after operations that should have re-applied a stored
  SpoolKProfile. All surface as "BambuStudio shows generic PLA / default K"
  even though Bambuddy's own UI shows the correct spool config.

  1) MQTT auto-detect (main.py:on_ams_change)

     When SpoolAssignment already existed for a slot and the printer pushed
     a fresh tray reading, the existing-assignment branch ran weight-sync
     only and returned. The firmware-default cali_idx left from a slot
     reset would persist. Repro: reset slot, trigger re-read - SA row stays
     but the printer's cali_idx is reset, and nothing re-asserts the
     stored profile.

     Fix: after weight-sync, if the spool has a matching SpoolKProfile and
     the live tray.cali_idx differs from the stored value, send
     extrusion_cali_sel to realign. Eager-load Spool.k_profiles on the SA
     query (was missing). The drift check avoids MQTT spam during
     steady-state pushes.

  2) Re-read endpoint (printers.py:_apply_pa_after_refresh)

     Stage 1 looked up the spool only via SpoolAssignment(printer, ams,
     slot). After a slot reset deletes that row, no path connected the
     live tray's tray_uuid back to the Spool, even though Spool.tray_uuid
     was populated. Cascade fell through to live cali_idx (firmware
     default).

     Fix: Stage 1b tag-based fallback. When the SA lookup misses but the
     tray has a non-zero tray_uuid / tag_uid, look up the Spool by
     Spool.tray_uuid / Spool.tag_uid (with sentinel-zero rejection).
     Stage 2 gate widened from `not assignment` to `spool is None` so
     Spoolman doesn't double-fire when the tag fallback succeeded.

  3) Manual assign (inventory.py:assign_spool)

     Two issues. First, hard-skipping kp on extruder mismatch silently
     dropped valid stored profiles when the AMS-extruder mapping had
     shifted since calibration time. Second, ams_set_filament_setting and
     extrusion_cali_sel were sending different filament-preset contexts:
     the slot ended up declared under one preset while the cali_idx was
     selected under another, so the printer couldn't link them and fell
     back to default.

     Fix: relax the extruder filter to prefer-exact-fall-back-to-any.
     Reorder so the kp lookup runs before the MQTT commands. When a kp
     matches, resolve the calibration entry from the printer's live
     state.kprofiles list (Bambuddy already pulls it via
     extrusion_cali_get) and realign both tray_info_idx and setting_id to
     the printer-reported filament_id / setting_id of that entry. Without
     this, P-prefix local presets registered on the printer (filament_id
     like "P4d64437") were never matched against any of our spool fields
     and the cali_idx silently no-op'd.

  Diagnostic warnings now surface stale-data conditions: spool's
  slicer_filament drifted from kp's calibration preset, kp not present in
  printer's table, etc. Previously these failed silently.

  Tests:
   - 6 new in TestApplyPaAfterRefresh: tag-fallback (uuid-only, tag-only,
     zero-sentinel guard), extruder-as-fallback, exact-vs-fallback
     preference
   - Renamed test_extruder_mismatch_falls_through_to_live to
     test_extruder_mismatch_uses_kp_as_fallback to match the new
     behavior
   - 105 -> 106 in test_printers_api.py, all green
maziggy 3 weeks ago
parent
commit
219bad76

+ 166 - 22
backend/app/api/routes/inventory.py

@@ -1045,20 +1045,12 @@ async def assign_spool(
             if spool.nozzle_temp_max is not None:
             if spool.nozzle_temp_max is not None:
                 temp_max = spool.nozzle_temp_max
                 temp_max = spool.nozzle_temp_max
 
 
-            # a. Set filament setting
-            client.ams_set_filament_setting(
-                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,
-            )
-
-            # b. Look up K-profile for this spool + printer + nozzle + extruder
+            # Look up the K-profile FIRST so we can align ams_filament_setting's
+            # setting_id with the kp's calibration context. tray_info_idx
+            # stays as the resolved GF* filament_id (slicer requires that).
+            # Only setting_id is realigned — when the spool's preset and kp's
+            # calibration preset have drifted apart, the printer needs the
+            # slot's setting_id to match the cali_sel's filament context.
             nozzle_diameter = "0.4"
             nozzle_diameter = "0.4"
             if state and state.nozzles:
             if state and state.nozzles:
                 nd = state.nozzles[0].nozzle_diameter
                 nd = state.nozzles[0].nozzle_diameter
@@ -1074,39 +1066,191 @@ async def assign_spool(
                 else:
                 else:
                     slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
                     slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
 
 
-            matching_kp = None
+            # Prefer exact extruder match, fall back to extruder-agnostic kp
+            # for the same printer + nozzle. Hard-skipping on extruder mismatch
+            # silently drops valid stored profiles when the AMS-extruder
+            # mapping has shifted since calibration time.
+            exact_kp = None
+            fallback_kp = None
+            kp_count = len(spool.k_profiles)
             for kp in spool.k_profiles:
             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
+                if kp.printer_id != data.printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
+                    continue
+                if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                    exact_kp = kp
                     break
                     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. That list is what the printer
+            # itself reported via extrusion_cali_get and is the authoritative
+            # source for filament_id (the key the printer indexes the
+            # calibration table by). Our stored SpoolKProfile only has
+            # setting_id, not filament_id, so without this lookup we have
+            # to *guess* what filament_id to send in extrusion_cali_sel.
+            printer_kp = None
+            if matching_kp and state and state.kprofiles:
+                for pkp in state.kprofiles:
+                    if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
+                        printer_kp = pkp
+                        break
+                if printer_kp is None:
+                    logger.warning(
+                        "Spool assign: cali_idx=%d not present in printer's calibration "
+                        "table (have %d entries for this nozzle). Stored kp is stale — "
+                        "re-calibrate in BambuStudio. Falling back to setting_id-based "
+                        "filament_id (kp may not apply).",
+                        matching_kp.cali_idx,
+                        sum(1 for p in state.kprofiles if p.nozzle_diameter == nozzle_diameter),
+                    )
+                else:
+                    logger.info(
+                        "Spool assign: matched printer kp cali_idx=%d filament_id=%r name=%r setting_id=%r",
+                        printer_kp.slot_id,
+                        printer_kp.filament_id,
+                        printer_kp.name,
+                        printer_kp.setting_id,
+                    )
+
+            # Align both tray_info_idx and setting_id to the printer's
+            # calibration entry. The printer keys its calibration table by
+            # (filament_id, cali_idx) — for the cali_idx to stick to the
+            # slot, the slot's filament_id must match the kp's. We use the
+            # printer-reported filament_id which is the live truth.
+            # P-prefix local presets are valid for tray_info_idx; we
+            # previously avoided overriding tray_info_idx because PFUS-prefix
+            # (cloud user preset) breaks the slicer modal — that constraint
+            # doesn't apply to P-prefix local presets.
+            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 (kp_id=%d, source=%s)",
+                    tray_info_idx,
+                    effective_tray_info_idx,
+                    setting_id,
+                    effective_setting_id,
+                    matching_kp.id if matching_kp else -1,
+                    "printer" if printer_kp else "stored",
+                )
+
+            # a. Set filament setting (uses realigned tray_info_idx /
+            # setting_id when a kp matched and the printer reports a
+            # different filament context for that cali_idx)
+            client.ams_set_filament_setting(
+                ams_id=data.ams_id,
+                tray_id=data.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:
             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 in the printer's calibration table.
+                # Priority order:
+                #   1. printer_kp.filament_id — live data from the printer,
+                #      always correct (authoritative)
+                #   2. matching_kp.setting_id — our stored calibration context
+                #      (used when printer didn't report a list, e.g. just
+                #      reconnected and no extrusion_cali_get yet)
+                #   3. spool.slicer_filament — last-resort guess
+                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 tray_info_idx
+                # Diagnostic when spool and kp reference different presets.
+                # The printer keys its calibration table by (filament_id,
+                # cali_idx) — if these drift, the kp can't be applied.
+                if matching_kp.setting_id and spool.slicer_filament and matching_kp.setting_id != spool.slicer_filament:
+                    logger.warning(
+                        "Spool assign: spool.slicer_filament (%s) and kp.setting_id (%s) "
+                        "are different presets — kp may not apply on the printer. "
+                        "Re-calibrate the K-profile under the spool's current preset, or "
+                        "set the spool's preset to match the calibration context.",
+                        spool.slicer_filament,
+                        matching_kp.setting_id,
+                    )
                 client.extrusion_cali_sel(
                 client.extrusion_cali_sel(
                     ams_id=data.ams_id,
                     ams_id=data.ams_id,
                     tray_id=data.tray_id,
                     tray_id=data.tray_id,
                     cali_idx=matching_kp.cali_idx,
                     cali_idx=matching_kp.cali_idx,
-                    filament_id=tray_info_idx,
+                    filament_id=cali_filament_id,
                     nozzle_diameter=nozzle_diameter,
                     nozzle_diameter=nozzle_diameter,
                 )
                 )
+                logger.info(
+                    "Applied K-profile cali_idx=%d (kp_id=%d, extruder=%s, "
+                    "filament_id=%s, kp.setting_id=%s) for spool %d on printer %d AMS%d-T%d",
+                    matching_kp.cali_idx,
+                    matching_kp.id,
+                    matching_kp.extruder,
+                    cali_filament_id,
+                    matching_kp.setting_id,
+                    spool.id,
+                    data.printer_id,
+                    data.ams_id,
+                    data.tray_id,
+                )
             else:
             else:
+                # Diagnostic log so users can see exactly why their stored
+                # K-profile didn't get applied. Without this, the endpoint
+                # silently fell through to live cali_idx (which is -1 on an
+                # empty slot) and the slot stayed on the firmware default.
+                if kp_count > 0:
+                    available = [
+                        f"id={kp.id} printer={kp.printer_id} nozzle={kp.nozzle_diameter} "
+                        f"extruder={kp.extruder} cali_idx={kp.cali_idx}"
+                        for kp in spool.k_profiles
+                    ]
+                    logger.warning(
+                        "No matching K-profile for spool %d on printer %d nozzle=%s extruder=%s (have %d kp(s): %s)",
+                        spool.id,
+                        data.printer_id,
+                        nozzle_diameter,
+                        slot_extruder,
+                        kp_count,
+                        " | ".join(available),
+                    )
+                else:
+                    logger.info(
+                        "Spool %d has no stored K-profiles — slot will use live cali_idx",
+                        spool.id,
+                    )
                 # No stored K-profile: preserve the slot's current live cali_idx
                 # No stored K-profile: preserve the slot's current live cali_idx
                 live_cali_idx = None
                 live_cali_idx = None
                 if data.ams_id == 255:
                 if data.ams_id == 255:
                     if state and state.raw_data:
                     if state and state.raw_data:
-                        for vt in (state.raw_data.get("vt_tray") or []):
+                        for vt in state.raw_data.get("vt_tray") or []:
                             if isinstance(vt, dict) and int(vt.get("id", 254)) == (data.tray_id + 254):
                             if isinstance(vt, dict) and int(vt.get("id", 254)) == (data.tray_id + 254):
                                 live_cali_idx = vt.get("cali_idx")
                                 live_cali_idx = vt.get("cali_idx")
                                 break
                                 break
                 elif tray:
                 elif tray:
                     live_cali_idx = tray.get("cali_idx")
                     live_cali_idx = tray.get("cali_idx")
                 if live_cali_idx is not None and live_cali_idx >= 0:
                 if live_cali_idx is not None and live_cali_idx >= 0:
+                    # Same filament_id rule as the stored-kp branch — use the
+                    # spool's original slicer_filament so the printer can find
+                    # the live cali_idx in its calibration table.
+                    cali_filament_id = spool.slicer_filament or tray_info_idx
                     client.extrusion_cali_sel(
                     client.extrusion_cali_sel(
                         ams_id=data.ams_id,
                         ams_id=data.ams_id,
                         tray_id=data.tray_id,
                         tray_id=data.tray_id,
                         cali_idx=live_cali_idx,
                         cali_idx=live_cali_idx,
-                        filament_id=tray_info_idx,
+                        filament_id=cali_filament_id,
                         nozzle_diameter=nozzle_diameter,
                         nozzle_diameter=nozzle_diameter,
                     )
                     )
                     logger.info(
                     logger.info(

+ 107 - 39
backend/app/api/routes/printers.py

@@ -2129,6 +2129,7 @@ async def configure_ams_slot(
     if cali_idx >= 0:
     if cali_idx >= 0:
         try:
         try:
             from sqlalchemy.orm import selectinload
             from sqlalchemy.orm import selectinload
+
             from backend.app.models.spool_assignment import SpoolAssignment
             from backend.app.models.spool_assignment import SpoolAssignment
             from backend.app.models.spool_k_profile import SpoolKProfile
             from backend.app.models.spool_k_profile import SpoolKProfile
             from backend.app.models.spoolman_k_profile import SpoolmanKProfile
             from backend.app.models.spoolman_k_profile import SpoolmanKProfile
@@ -2171,20 +2172,26 @@ async def configure_ams_slot(
                     kp.setting_id = kprofile_setting_id or None
                     kp.setting_id = kprofile_setting_id or None
                     kp.name = tray_sub_brands or None
                     kp.name = tray_sub_brands or None
                 else:
                 else:
-                    db.add(SpoolmanKProfile(
-                        spoolman_spool_id=sm_assignment.spoolman_spool_id,
-                        printer_id=printer_id,
-                        extruder=kp_extruder,
-                        nozzle_diameter=nozzle_diameter,
-                        k_value=k_value or 0.0,
-                        name=tray_sub_brands or None,
-                        cali_idx=cali_idx,
-                        setting_id=kprofile_setting_id or None,
-                    ))
+                    db.add(
+                        SpoolmanKProfile(
+                            spoolman_spool_id=sm_assignment.spoolman_spool_id,
+                            printer_id=printer_id,
+                            extruder=kp_extruder,
+                            nozzle_diameter=nozzle_diameter,
+                            k_value=k_value or 0.0,
+                            name=tray_sub_brands or None,
+                            cali_idx=cali_idx,
+                            setting_id=kprofile_setting_id or None,
+                        )
+                    )
                 await db.commit()
                 await db.commit()
                 logger.info(
                 logger.info(
                     "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
                     "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
-                    sm_assignment.spoolman_spool_id, printer_id, ams_id, tray_id, cali_idx,
+                    sm_assignment.spoolman_spool_id,
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    cali_idx,
                 )
                 )
             else:
             else:
                 # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
                 # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
@@ -2218,26 +2225,35 @@ async def configure_ams_slot(
                         kp.setting_id = kprofile_setting_id or None
                         kp.setting_id = kprofile_setting_id or None
                         kp.name = tray_sub_brands or None
                         kp.name = tray_sub_brands or None
                     else:
                     else:
-                        db.add(SpoolKProfile(
-                            spool_id=local_assignment.spool.id,
-                            printer_id=printer_id,
-                            extruder=kp_extruder,
-                            nozzle_diameter=nozzle_diameter,
-                            k_value=k_value or 0.0,
-                            name=tray_sub_brands or None,
-                            cali_idx=cali_idx,
-                            setting_id=kprofile_setting_id or None,
-                        ))
+                        db.add(
+                            SpoolKProfile(
+                                spool_id=local_assignment.spool.id,
+                                printer_id=printer_id,
+                                extruder=kp_extruder,
+                                nozzle_diameter=nozzle_diameter,
+                                k_value=k_value or 0.0,
+                                name=tray_sub_brands or None,
+                                cali_idx=cali_idx,
+                                setting_id=kprofile_setting_id or None,
+                            )
+                        )
                     await db.commit()
                     await db.commit()
                     logger.info(
                     logger.info(
                         "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
                         "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
-                        local_assignment.spool.id, printer_id, ams_id, tray_id, cali_idx,
+                        local_assignment.spool.id,
+                        printer_id,
+                        ams_id,
+                        tray_id,
+                        cali_idx,
                     )
                     )
         except Exception:
         except Exception:
             # MQTT command was already sent successfully — DB persist is best-effort.
             # MQTT command was already sent successfully — DB persist is best-effort.
             logger.exception(
             logger.exception(
                 "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
                 "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
-                printer_id, ams_id, tray_id, cali_idx,
+                printer_id,
+                ams_id,
+                tray_id,
+                cali_idx,
             )
             )
             try:
             try:
                 await db.rollback()
                 await db.rollback()
@@ -2974,7 +2990,15 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         from backend.app.models.spool_assignment import SpoolAssignment as SA
         from backend.app.models.spool_assignment import SpoolAssignment as SA
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-        from backend.app.services.spool_tag_matcher import is_bambu_tag
+        from backend.app.services.spool_tag_matcher import (
+            ZERO_TAG_UID,
+            ZERO_TRAY_UUID,
+            is_bambu_tag,
+        )
+        from backend.app.utils.tag_normalization import (
+            normalize_tag_uid,
+            normalize_tray_uuid,
+        )
 
 
         client = printer_manager.get_client(printer_id)
         client = printer_manager.get_client(printer_id)
         if not client:
         if not client:
@@ -3023,7 +3047,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         matching_filament_id: str = tray_info_idx
         matching_filament_id: str = tray_info_idx
 
 
         async with async_session() as db:
         async with async_session() as db:
-            from sqlalchemy import select as sa_select
+            from sqlalchemy import or_, select as sa_select
             from sqlalchemy.orm import selectinload
             from sqlalchemy.orm import selectinload
 
 
             # Stage 1: local SpoolAssignment + SpoolKProfile match
             # Stage 1: local SpoolAssignment + SpoolKProfile match
@@ -3033,23 +3057,66 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                 .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
                 .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
             )
             )
             assignment = result.scalar_one_or_none()
             assignment = result.scalar_one_or_none()
-            if assignment and assignment.spool and assignment.spool.k_profiles:
-                spool = assignment.spool
+            spool: Spool | None = assignment.spool if assignment else None
+
+            # Stage 1b: tag-based fallback. The slot may have just been reset
+            # (SpoolAssignment row deleted) before the user triggered a re-read.
+            # The live tray already carries the spool's tray_uuid/tag_uid from
+            # the RFID re-read, but the SA row hasn't been re-created yet.
+            # Without this fallback we miss the stored SpoolKProfile and Stage 3
+            # ends up re-asserting whatever cali_idx the firmware reset to
+            # (typically the default profile).
+            if spool is None:
+                norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
+                norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
+                tag_filters = []
+                if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
+                    tag_filters.append(Spool.tray_uuid == norm_uuid)
+                if norm_tag and norm_tag != ZERO_TAG_UID:
+                    tag_filters.append(Spool.tag_uid == norm_tag)
+                if tag_filters:
+                    tag_lookup = await db.execute(
+                        sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
+                    )
+                    spool = tag_lookup.scalar_one_or_none()
+                    if spool is not None:
+                        logger.info(
+                            "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
+                            "(SpoolAssignment row missing, likely after slot reset)",
+                            ams_id,
+                            slot_id,
+                            spool.id,
+                        )
+
+            if spool is not None and spool.k_profiles:
+                # Prefer exact extruder match, fall back to extruder-agnostic kp
+                # for the same printer + nozzle. Hard-skipping on extruder
+                # mismatch made the cascade refuse perfectly valid stored
+                # profiles whenever the AMS-extruder mapping had shifted since
+                # calibration time, falling all the way through to Stage 3 and
+                # re-asserting the firmware default.
+                exact_kp = None
+                fallback_kp = None
                 for kp in spool.k_profiles:
                 for kp in spool.k_profiles:
-                    if kp.printer_id == 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
-                        if kp.cali_idx is not None:
-                            matching_cali_idx = kp.cali_idx
-                            # The filament_id in extrusion_cali_sel must match the preset
-                            # under which the K-profile was calibrated. Prefer the spool's
-                            # slicer_filament setting, falling back to the tray's RFID value.
-                            matching_filament_id = spool.slicer_filament or tray_info_idx
+                    if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
+                        continue
+                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                        exact_kp = kp
                         break
                         break
+                    if fallback_kp is None:
+                        fallback_kp = kp
+                chosen_kp = exact_kp or fallback_kp
+                if chosen_kp is not None:
+                    matching_cali_idx = chosen_kp.cali_idx
+                    # The filament_id in extrusion_cali_sel must match the preset
+                    # under which the K-profile was calibrated. Prefer the spool's
+                    # slicer_filament setting, falling back to the tray's RFID value.
+                    matching_filament_id = spool.slicer_filament or tray_info_idx
 
 
             # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
             # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
-            # (only when no local assignment exists — local takes priority)
-            if matching_cali_idx is None and not assignment:
+            # (only when no local spool was matched — local takes priority,
+            # including the tag-based fallback above)
+            if matching_cali_idx is None and spool is None:
                 sm_result = await db.execute(
                 sm_result = await db.execute(
                     sa_select(SpoolmanSlotAssignment).where(
                     sa_select(SpoolmanSlotAssignment).where(
                         SpoolmanSlotAssignment.printer_id == printer_id,
                         SpoolmanSlotAssignment.printer_id == printer_id,
@@ -3086,7 +3153,8 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         if matching_cali_idx is None:
         if matching_cali_idx is None:
             logger.debug(
             logger.debug(
                 "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
                 "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
-                ams_id, slot_id,
+                ams_id,
+                slot_id,
             )
             )
             return
             return
 
 

+ 90 - 4
backend/app/main.py

@@ -1053,6 +1053,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
     try:
     try:
         async with _get_ams_assignment_lock(printer_id), async_session() as db:
         async with _get_ams_assignment_lock(printer_id), async_session() as db:
             from backend.app.api.routes.settings import get_setting
             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.models.spool_assignment import SpoolAssignment as SA
             from backend.app.services.spool_tag_matcher import (
             from backend.app.services.spool_tag_matcher import (
                 auto_assign_spool,
                 auto_assign_spool,
@@ -1082,7 +1083,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         # Check if assignment already exists for this slot
                         # Check if assignment already exists for this slot
                         existing = await db.execute(
                         existing = await db.execute(
                             select(SA)
                             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)
                             .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
                         )
                         )
                         existing_assignment = existing.scalar_one_or_none()
                         existing_assignment = existing.scalar_one_or_none()
@@ -1120,6 +1121,93 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         )
                                         )
                                         existing_assignment.spool.weight_used = new_used
                                         existing_assignment.spool.weight_used = new_used
                                         await db.commit()
                                         await db.commit()
+
+                            # Re-apply stored K-profile when the live tray's
+                            # cali_idx drifted from the spool's stored profile.
+                            # This catches "reset slot → re-read" and any other
+                            # path where the firmware loses the user's K-profile
+                            # selection while the SpoolAssignment row persists.
+                            # Per the maintainer's rule: any time a spool tag is
+                            # identified and matches inventory, the slot must be
+                            # configured with the spool's stored settings. Without
+                            # this block the existing-assignment branch only ran
+                            # weight-sync and let the firmware-default cali_idx win.
+                            try:
+                                spool = existing_assignment.spool
+                                if (
+                                    spool is not None
+                                    and is_bambu_tag(tag_uid, tray_uuid, tray_info_idx)
+                                    and spool.k_profiles
+                                ):
+                                    state = printer_manager.get_status(printer_id)
+                                    nozzle_diameter = "0.4"
+                                    if state and state.nozzles:
+                                        nd = state.nozzles[0].nozzle_diameter
+                                        if nd:
+                                            nozzle_diameter = nd
+                                    slot_extruder: int | None = None
+                                    if state and state.ams_extruder_map:
+                                        if ams_id == 255:
+                                            slot_extruder = 1 - tray_id
+                                        else:
+                                            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+                                    # Prefer exact extruder match, fall back to
+                                    # extruder-agnostic kp for the same printer +
+                                    # nozzle. Avoids hard-skipping when the AMS is
+                                    # mapped differently than at calibration time.
+                                    matching_kp = None
+                                    fallback_kp = None
+                                    for kp in spool.k_profiles:
+                                        if (
+                                            kp.printer_id != printer_id
+                                            or kp.nozzle_diameter != nozzle_diameter
+                                            or kp.cali_idx is None
+                                        ):
+                                            continue
+                                        if (
+                                            slot_extruder is not None
+                                            and kp.extruder is not None
+                                            and kp.extruder == slot_extruder
+                                        ):
+                                            matching_kp = kp
+                                            break
+                                        if fallback_kp is None:
+                                            fallback_kp = kp
+                                    chosen_kp = matching_kp or fallback_kp
+                                    if chosen_kp is not None:
+                                        live_cali_idx = tray.get("cali_idx")
+                                        # Only fire MQTT when the printer's live
+                                        # cali_idx differs from the stored value.
+                                        # Avoids spamming the broker on every
+                                        # MQTT push during steady-state operation.
+                                        if live_cali_idx != chosen_kp.cali_idx:
+                                            client = printer_manager.get_client(printer_id)
+                                            if client:
+                                                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                                                client.extrusion_cali_sel(
+                                                    ams_id=ams_id,
+                                                    tray_id=tray_id,
+                                                    cali_idx=chosen_kp.cali_idx,
+                                                    filament_id=cali_filament_id,
+                                                    nozzle_diameter=nozzle_diameter,
+                                                )
+                                                logger.info(
+                                                    "Re-applied K-profile cali_idx=%d for spool %d "
+                                                    "on printer %d AMS%d-T%d (live=%s drift detected)",
+                                                    chosen_kp.cali_idx,
+                                                    spool.id,
+                                                    printer_id,
+                                                    ams_id,
+                                                    tray_id,
+                                                    live_cali_idx,
+                                                )
+                            except Exception:
+                                logger.exception(
+                                    "K-profile re-apply failed for printer %d AMS%d-T%d",
+                                    printer_id,
+                                    ams_id,
+                                    tray_id,
+                                )
                             continue
                             continue
 
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
@@ -4424,9 +4512,7 @@ async def lifespan(app: FastAPI):
                     # Ensure the 'tag' extra field exists for RFID/UUID storage
                     # Ensure the 'tag' extra field exists for RFID/UUID storage
                     field_ok = await client.ensure_tag_extra_field()
                     field_ok = await client.ensure_tag_extra_field()
                     if not field_ok:
                     if not field_ok:
-                        logging.error(
-                            "Spoolman tag extra field registration failed — NFC tag links may not persist"
-                        )
+                        logging.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
                 else:
                 else:
                     logging.warning("Spoolman at %s is not reachable", spoolman_url)
                     logging.warning("Spoolman at %s is not reachable", spoolman_url)
             except Exception as e:
             except Exception as e:

+ 550 - 136
backend/tests/integration/test_printers_api.py

@@ -1655,13 +1655,24 @@ class TestApplyPaAfterRefresh:
         spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
         spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=2,
-        ))
-        db_session.add(SpoolKProfile(
-            spool_id=spool.id, printer_id=printer.id,
-            extruder=0, nozzle_diameter="0.4", k_value=0.025, cali_idx=42,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+            )
+        )
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1693,9 +1704,14 @@ class TestApplyPaAfterRefresh:
         spool = Spool(material="PLA")
         spool = Spool(material="PLA")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=2,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1724,13 +1740,24 @@ class TestApplyPaAfterRefresh:
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory()
         printer = await printer_factory()
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=99,
-        ))
-        db_session.add(SpoolmanKProfile(
-            spoolman_spool_id=99, printer_id=printer.id,
-            extruder=0, nozzle_diameter="0.4", k_value=0.030, cali_idx=77,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+                spoolman_spool_id=99,
+            )
+        )
+        db_session.add(
+            SpoolmanKProfile(
+                spoolman_spool_id=99,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.030,
+                cali_idx=77,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1758,9 +1785,14 @@ class TestApplyPaAfterRefresh:
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory()
         printer = await printer_factory()
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=2, spoolman_spool_id=99,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+                spoolman_spool_id=99,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1847,17 +1879,21 @@ class TestApplyPaAfterRefresh:
         state.nozzles = [nozzle]
         state.nozzles = [nozzle]
         state.ams_extruder_map = {"0": 0}
         state.ams_extruder_map = {"0": 0}
         state.raw_data = {
         state.raw_data = {
-            "ams": [{
-                "id": 0,
-                "tray": [{
-                    "id": 2,
-                    "tray_type": "PLA",
-                    "tag_uid": "AABBCC1122334400",
-                    "tray_uuid": "11223344556677880011223344556677",
-                    "tray_info_idx": "GFL05",
-                    # cali_idx field intentionally omitted
-                }],
-            }]
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 2,
+                            "tray_type": "PLA",
+                            "tag_uid": "AABBCC1122334400",
+                            "tray_uuid": "11223344556677880011223344556677",
+                            "tray_info_idx": "GFL05",
+                            # cali_idx field intentionally omitted
+                        }
+                    ],
+                }
+            ]
         }
         }
 
 
         with (
         with (
@@ -1873,8 +1909,17 @@ class TestApplyPaAfterRefresh:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_extruder_mismatch_falls_through_to_live(self, db_session, printer_factory):
-        """K-profile for extruder=1 but slot is extruder=0 → KP filtered, live fallback used."""
+    async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
+        """K-profile for extruder=1 but slot is extruder=0 → no exact match,
+        but the kp is used as extruder-agnostic fallback rather than dropped.
+
+        Hard-skipping on extruder mismatch was the previous behavior; in
+        practice it caused stored K-profiles to be silently ignored whenever
+        the AMS-extruder mapping had shifted (or when only one of the two
+        extruders was ever calibrated for a given spool). The cascade now
+        prefers an exact extruder match but falls back to any matching kp
+        for the same printer + nozzle.
+        """
         from backend.app.api.routes.printers import _apply_pa_after_refresh
         from backend.app.api.routes.printers import _apply_pa_after_refresh
         from backend.app.models.spool import Spool
         from backend.app.models.spool import Spool
         from backend.app.models.spool_assignment import SpoolAssignment
         from backend.app.models.spool_assignment import SpoolAssignment
@@ -1884,14 +1929,25 @@ class TestApplyPaAfterRefresh:
         spool = Spool(material="PLA")
         spool = Spool(material="PLA")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=2,
-        ))
-        # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0 → mismatch
-        db_session.add(SpoolKProfile(
-            spool_id=spool.id, printer_id=printer.id,
-            extruder=1, nozzle_diameter="0.4", k_value=0.025, cali_idx=42,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+            )
+        )
+        # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=1,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1907,9 +1963,78 @@ class TestApplyPaAfterRefresh:
             mock_pm.get_status.return_value = state
             mock_pm.get_status.return_value = state
             await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
             await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
 
 
-        # K-profile mismatch → falls through to live cali_idx=5 (NOT 42)
+        # No exact extruder match, but the stored kp wins as the
+        # extruder-agnostic fallback over live cali_idx=5.
         mock_client.extrusion_cali_sel.assert_called_once()
         mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
+        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extruder_exact_match_preferred_over_fallback(
+        self,
+        db_session,
+        printer_factory,
+    ):
+        """When two kp rows exist, one with matching extruder and one without,
+        the exact-extruder kp wins (extruder-agnostic fallback only fires when
+        no exact match exists).
+        """
+        from backend.app.api.routes.printers import _apply_pa_after_refresh
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_assignment import SpoolAssignment
+        from backend.app.models.spool_k_profile import SpoolKProfile
+
+        printer = await printer_factory()
+        spool = Spool(material="PLA")
+        db_session.add(spool)
+        await db_session.flush()
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+            )
+        )
+        # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=1,
+                nozzle_diameter="0.4",
+                k_value=0.030,
+                cali_idx=99,
+            )
+        )
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
+        await db_session.commit()
+
+        mock_client = MagicMock()
+        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
+        state = _build_h2d_state(cali_idx=5)
+
+        with (
+            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            _patch_async_session_to(db_session),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = state
+            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
+
+        # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -1930,14 +2055,25 @@ class TestApplyPaAfterRefresh:
         spool = Spool(material="PLA")
         spool = Spool(material="PLA")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=2,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=2,
+            )
+        )
         # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
         # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
-        db_session.add(SpoolKProfile(
-            spool_id=spool.id, printer_id=printer.id,
-            extruder=0, nozzle_diameter="0.4", k_value=0.025, cali_idx=42,
-        ))
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -1958,6 +2094,185 @@ class TestApplyPaAfterRefresh:
         mock_client.extrusion_cali_sel.assert_called_once()
         mock_client.extrusion_cali_sel.assert_called_once()
         assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
         assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_fallback_finds_spool_when_assignment_missing(
+        self,
+        db_session,
+        printer_factory,
+    ):
+        """Stage 1b regression for the maintainer's #2 reproducer on H2D:
+        reset slot, trigger re-read → slot ends up on the default K-profile
+        instead of the spool's stored profile.
+
+        Setup mirrors the bug:
+          - Spool has tray_uuid set (the RFID tag was registered earlier).
+          - SpoolKProfile exists for that spool with cali_idx=42.
+          - NO SpoolAssignment row — the reset deleted it before the re-read
+            triggered _apply_pa_after_refresh, and tag-auto-detect has not
+            re-created it yet within the 5 s sleep window.
+          - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
+
+        Without Stage 1b the cascade falls through to Stage 3 and re-asserts
+        the firmware-default cali_idx=5. With Stage 1b it locates the spool by
+        the live tray's tray_uuid and applies the stored cali_idx=42.
+        """
+        from backend.app.api.routes.printers import _apply_pa_after_refresh
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_k_profile import SpoolKProfile
+
+        printer = await printer_factory()
+        # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
+        spool = Spool(
+            material="PLA",
+            color_name="Red",
+            rgba="FF0000FF",
+            tray_uuid="11223344556677880011223344556677",
+            tag_uid="AABBCC1122334400",
+        )
+        db_session.add(spool)
+        await db_session.flush()
+        # K-profile is bound to the spool, not to a slot
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
+        # NOTE: deliberately no SpoolAssignment — that's the bug condition.
+        await db_session.commit()
+
+        mock_client = MagicMock()
+        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
+        state = _build_h2d_state(cali_idx=5)
+
+        with (
+            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            _patch_async_session_to(db_session),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = state
+            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
+
+        # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
+        # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
+        self,
+        db_session,
+        printer_factory,
+    ):
+        """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
+        match by tag_uid. Older firmwares occasionally report a zero tray_uuid
+        right after RFID re-read while the tag_uid is already populated."""
+        from backend.app.api.routes.printers import _apply_pa_after_refresh
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_k_profile import SpoolKProfile
+
+        printer = await printer_factory()
+        # Spool indexed by tag_uid, not tray_uuid
+        spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
+        db_session.add(spool)
+        await db_session.flush()
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=99,
+            )
+        )
+        await db_session.commit()
+
+        mock_client = MagicMock()
+        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
+        # Build a state where the tray reports a real tag_uid but a zero tray_uuid
+        # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
+        state = _build_h2d_state(cali_idx=5)
+        state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
+
+        with (
+            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            _patch_async_session_to(db_session),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = state
+            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
+
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_fallback_skipped_when_zero_sentinels(
+        self,
+        db_session,
+        printer_factory,
+    ):
+        """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
+        fallback must not match any spool (would otherwise pick up an
+        unrelated spool created with empty/zero tag fields). Falls through
+        to Stage 3 live cali_idx as before.
+        """
+        from backend.app.api.routes.printers import _apply_pa_after_refresh
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_k_profile import SpoolKProfile
+
+        printer = await printer_factory()
+        # Decoy spool with no tag info — must NOT match
+        spool = Spool(material="PLA")
+        db_session.add(spool)
+        await db_session.flush()
+        db_session.add(
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=0,
+                nozzle_diameter="0.4",
+                k_value=0.025,
+                cali_idx=42,
+            )
+        )
+        await db_session.commit()
+
+        mock_client = MagicMock()
+        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
+        state = _build_h2d_state(cali_idx=7)
+        # Force both tag fields to the zero sentinels but keep tray_info_idx
+        # so is_bambu_tag still passes (preset present)
+        state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
+        state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
+
+        with (
+            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            _patch_async_session_to(db_session),
+        ):
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = state
+            # is_bambu_tag actually rejects both-zero + only-preset, so the
+            # function returns early. We just want to confirm we didn't blow
+            # up scanning for a tag-fallback spool.
+            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
+
+        # is_bambu_tag short-circuits early when both UID and UUID are zero,
+        # so no MQTT call should fire and the decoy spool's cali_idx=42 must
+        # NOT leak through.
+        if mock_client.extrusion_cali_sel.called:
+            assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
+
 
 
 class TestConfigureAmsSlotPersistsKProfile:
 class TestConfigureAmsSlotPersistsKProfile:
     """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
     """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
@@ -1970,16 +2285,24 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_writes_spoolman_kprofile_when_spoolman_assigned(
     async def test_writes_spoolman_kprofile_when_spoolman_assigned(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
         """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory(model="H2D")
         printer = await printer_factory(model="H2D")
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=3, spoolman_spool_id=216,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+                spoolman_spool_id=216,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -2012,9 +2335,7 @@ class TestConfigureAmsSlotPersistsKProfile:
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        kp_result = await db_session.execute(
-            select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)
-        )
+        kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
         kp = kp_result.scalar_one_or_none()
         kp = kp_result.scalar_one_or_none()
         assert kp is not None
         assert kp is not None
         assert kp.cali_idx == 5
         assert kp.cali_idx == 5
@@ -2026,7 +2347,10 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_writes_spool_kprofile_when_local_assigned(
     async def test_writes_spool_kprofile_when_local_assigned(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """Local SpoolAssignment present → SpoolKProfile row created."""
         """Local SpoolAssignment present → SpoolKProfile row created."""
         from backend.app.models.spool import Spool
         from backend.app.models.spool import Spool
@@ -2037,9 +2361,14 @@ class TestConfigureAmsSlotPersistsKProfile:
         spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
         spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=3,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
         spool_id = spool.id
         spool_id = spool.id
 
 
@@ -2073,9 +2402,7 @@ class TestConfigureAmsSlotPersistsKProfile:
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        kp_result = await db_session.execute(
-            select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id)
-        )
+        kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
         kp = kp_result.scalar_one_or_none()
         kp = kp_result.scalar_one_or_none()
         assert kp is not None
         assert kp is not None
         assert kp.cali_idx == 7
         assert kp.cali_idx == 7
@@ -2087,7 +2414,10 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_no_assignment_no_persist(
     async def test_no_assignment_no_persist(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
         """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
         from backend.app.models.spool_k_profile import SpoolKProfile
         from backend.app.models.spool_k_profile import SpoolKProfile
@@ -2111,10 +2441,14 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 5, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 5,
+                    "k_value": 0.020,
                 },
                 },
             )
             )
 
 
@@ -2129,16 +2463,24 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_negative_cali_idx_no_persist(
     async def test_negative_cali_idx_no_persist(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
         """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory(model="H2D")
         printer = await printer_factory(model="H2D")
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=3, spoolman_spool_id=216,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+                spoolman_spool_id=216,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -2158,32 +2500,46 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": -1, "k_value": 0.0,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": -1,
+                    "k_value": 0.0,
                 },
                 },
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        sm_kps = (await db_session.execute(
-            select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)
-        )).scalars().all()
+        sm_kps = (
+            (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
+            .scalars()
+            .all()
+        )
         assert len(sm_kps) == 0  # cali_idx=-1 means "no profile" — don't write
         assert len(sm_kps) == 0  # cali_idx=-1 means "no profile" — don't write
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_zero_cali_idx_persists(
     async def test_zero_cali_idx_persists(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
         """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory(model="H2D")
         printer = await printer_factory(model="H2D")
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=3, spoolman_spool_id=216,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+                spoolman_spool_id=216,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -2202,33 +2558,45 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 0, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 0,
+                    "k_value": 0.020,
                 },
                 },
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        kp = (await db_session.execute(
-            select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)
-        )).scalar_one_or_none()
+        kp = (
+            await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
+        ).scalar_one_or_none()
         assert kp is not None
         assert kp is not None
         assert kp.cali_idx == 0  # explicitly testing 0 is valid
         assert kp.cali_idx == 0  # explicitly testing 0 is valid
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_upsert_idempotent(
     async def test_upsert_idempotent(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
         """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory(model="H2D")
         printer = await printer_factory(model="H2D")
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=3, spoolman_spool_id=216,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+                spoolman_spool_id=216,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -2248,27 +2616,37 @@ class TestConfigureAmsSlotPersistsKProfile:
             await async_client.post(
             await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 5, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 5,
+                    "k_value": 0.020,
                 },
                 },
             )
             )
             # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
             # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
             await async_client.post(
             await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Matte", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 10, "k_value": 0.025,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Matte",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 10,
+                    "k_value": 0.025,
                 },
                 },
             )
             )
 
 
         # Should be exactly ONE row (updated), not two
         # Should be exactly ONE row (updated), not two
-        kps = (await db_session.execute(
-            select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)
-        )).scalars().all()
+        kps = (
+            (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
+            .scalars()
+            .all()
+        )
         assert len(kps) == 1
         assert len(kps) == 1
         assert kps[0].cali_idx == 10  # updated to most recent
         assert kps[0].cali_idx == 10  # updated to most recent
         assert kps[0].name == "PLA Matte"
         assert kps[0].name == "PLA Matte"
@@ -2276,7 +2654,10 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_external_slot_extruder_inversion(
     async def test_external_slot_extruder_inversion(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
         """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
         from backend.app.models.spool import Spool
         from backend.app.models.spool import Spool
@@ -2290,9 +2671,14 @@ class TestConfigureAmsSlotPersistsKProfile:
         # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
         # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
         # under the ck_tray_id_range constraint (0-3 valid). External-slot
         # under the ck_tray_id_range constraint (0-3 valid). External-slot
         # K-profile persistence is therefore tested via local SpoolAssignment.
         # K-profile persistence is therefore tested via local SpoolAssignment.
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=255, tray_id=0,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=255,
+                tray_id=0,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
         spool_id = spool.id
         spool_id = spool.id
 
 
@@ -2312,17 +2698,21 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/255/0/configure",
                 f"/api/v1/printers/{printer.id}/slots/255/0/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 5, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 5,
+                    "k_value": 0.020,
                 },
                 },
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        kp = (await db_session.execute(
-            select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id)
-        )).scalar_one_or_none()
+        kp = (
+            await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
+        ).scalar_one_or_none()
         assert kp is not None
         assert kp is not None
         # tray_id=0 → extruder = 1 - 0 = 1
         # tray_id=0 → extruder = 1 - 0 = 1
         assert kp.extruder == 1
         assert kp.extruder == 1
@@ -2330,7 +2720,10 @@ class TestConfigureAmsSlotPersistsKProfile:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_dual_nozzle_extruder_persists(
     async def test_dual_nozzle_extruder_persists(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
         """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
         from backend.app.models.spool import Spool
         from backend.app.models.spool import Spool
@@ -2341,9 +2734,14 @@ class TestConfigureAmsSlotPersistsKProfile:
         spool = Spool(material="PLA")
         spool = Spool(material="PLA")
         db_session.add(spool)
         db_session.add(spool)
         await db_session.flush()
         await db_session.flush()
-        db_session.add(SpoolAssignment(
-            spool_id=spool.id, printer_id=printer.id, ams_id=2, tray_id=3,
-        ))
+        db_session.add(
+            SpoolAssignment(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                ams_id=2,
+                tray_id=3,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
         spool_id = spool.id
         spool_id = spool.id
 
 
@@ -2363,24 +2761,31 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/2/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/2/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 5, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 5,
+                    "k_value": 0.020,
                 },
                 },
             )
             )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
-        kp = (await db_session.execute(
-            select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id)
-        )).scalar_one_or_none()
+        kp = (
+            await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
+        ).scalar_one_or_none()
         assert kp is not None
         assert kp is not None
         assert kp.extruder == 1
         assert kp.extruder == 1
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_db_error_does_not_fail_endpoint(
     async def test_db_error_does_not_fail_endpoint(
-        self, async_client: AsyncClient, db_session, printer_factory,
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
     ):
     ):
         """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
         """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
 
 
@@ -2393,9 +2798,14 @@ class TestConfigureAmsSlotPersistsKProfile:
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
         from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
         printer = await printer_factory(model="H2D")
         printer = await printer_factory(model="H2D")
-        db_session.add(SpoolmanSlotAssignment(
-            printer_id=printer.id, ams_id=0, tray_id=3, spoolman_spool_id=216,
-        ))
+        db_session.add(
+            SpoolmanSlotAssignment(
+                printer_id=printer.id,
+                ams_id=0,
+                tray_id=3,
+                spoolman_spool_id=216,
+            )
+        )
         await db_session.commit()
         await db_session.commit()
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
@@ -2424,10 +2834,14 @@ class TestConfigureAmsSlotPersistsKProfile:
             response = await async_client.post(
             response = await async_client.post(
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 f"/api/v1/printers/{printer.id}/slots/0/3/configure",
                 params={
                 params={
-                    "tray_info_idx": "GFL05", "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic", "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190, "nozzle_temp_max": 230,
-                    "cali_idx": 5, "k_value": 0.020,
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "PLA Basic",
+                    "tray_color": "FFFFFFFF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": 5,
+                    "k_value": 0.020,
                 },
                 },
             )
             )