Explorar el Código

fix(ams): resolve a slot's K profile by index when the printer does not file per hotend (issue #3044)

    An X2D with two AMS 2 Pro, one per hotend, showed a K value on every slot
    of the first and nothing on any slot of the second. Configure Slot was
    worse than blank there: the picker offered no matching profile, the slot
    read as though nothing were bound, and choosing one changed nothing the
    user could see. Both symptoms are one rule.

    A calibration index can mean two different profiles on a dual-nozzle
    machine -- on the maintainer's H2C, index 16 is the left hotend's black
    PLA at K=0.018 and 15 is the right's at K=0.020 -- so the index is
    resolved against the slot's own hotend, and a miss shows nothing rather
    than the other nozzle's number. That is right whenever the printer files
    its calibrations per hotend. This one files them per filament: the second
    AMS's slots point at the same entries as the first, every entry tagged
    with one extruder, and requiring a match found nothing at all.

    The hotend now has to appear in the table the printer actually sent
    before it is used to narrow anything. Where it does not, the index stands
    on its own, which is what BambuStudio does for this same card --
    AMSItem.cpp resolves it through get_pa_k_n_value_by_cali_idx, matching
    cali_idx and nothing else. Where it does, nothing changes: the H2C case
    still blanks rather than borrowing, and the other hotend's profiles stay
    reachable under Other K profiles. The relaxed path still refuses an
    answer when the candidates disagree on a value.

    The premise that the table is always numbered per nozzle had been written
    into three comments and two layers of code; it is corrected where it
    appears.

    Alongside it, in the same picker: the K-profile options rendered the
    hotend suffix twice in the matching group and three times under Other, so
    every option on a dual-nozzle printer read "... . Left . Left".
maziggy hace 4 días
padre
commit
5ec99a7e06

+ 7 - 6
backend/app/api/routes/printers.py

@@ -528,13 +528,14 @@ async def get_printer_status(
     ams_exists = False
     raw_data = state.raw_data or {}
 
-    # K value for a slot's bound profile, resolved against its own nozzle.
+    # K value for a slot's bound profile, preferring the slot's own nozzle.
     #
-    # Keyed on more than cali_idx: the printer numbers its calibration table
-    # per nozzle, so entry 16 exists on each and means a different profile on
-    # each. A cali_idx-only map let whichever profile the printer happened to
-    # list last overwrite the other, and the slot then displayed the wrong
-    # nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool.
+    # cali_idx alone is not enough: two profiles can share an index and differ
+    # by extruder, and a cali_idx-only map let whichever the printer listed
+    # last overwrite the other — on the maintainer's H2C, 0.018 and 0.020 for
+    # the same spool. Nor is the extruder a requirement: one profile can be
+    # what both extruders' slots point at, and demanding a match blanked every
+    # slot on a second AMS (#3044). The resolver does both in order.
     _kprofile_k = build_slot_k_resolver(state)
 
     # Cached active-cycle drying params (filament + target temp) we sent

+ 9 - 6
backend/app/utils/fts_routing.py

@@ -1,11 +1,14 @@
 """Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
 
-K-profiles are per-nozzle, and the printer's calibration tables are numbered
-per-nozzle too: ``cali_idx: 16`` means "entry 16 of whichever nozzle feeds this
-tray". Without a switch that is unambiguous, because each AMS is wired to one
-extruder and says so in its ``info`` bits. With a switch installed every AMS
-reports 0xE instead and is bound to a switch *inlet*, so the answer has to come
-from the inlet binding.
+K-profiles are per-nozzle: a calibration run belongs to the hotend it ran on,
+and ``cali_idx: 16`` can name a different profile on each. (It need not — one
+profile can also be what both extruders' slots point at, which is why the K
+lookup in ``kprofile_lookup`` treats the extruder as a preference rather than a
+filter — but the routing question below is the same either way.) Without a
+switch the answer is unambiguous, because each AMS is wired to one extruder and
+says so in its ``info`` bits. With a switch installed every AMS reports 0xE
+instead and is bound to a switch *inlet*, so the answer has to come from the
+inlet binding.
 
 Every caller that resolves a slot to an extruder should go through
 ``slot_extruder`` here. Three separate copies of that logic used to end in

+ 65 - 19
backend/app/utils/kprofile_lookup.py

@@ -2,21 +2,41 @@
 
 H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
 K value on the AMS slot card (#2854) is looked up from the printer's
-calibration table in ``state.kprofiles``. That table is not flat: the printer
-numbers it **per nozzle**, so entry 16 exists under every nozzle it holds
-profiles for and means a different profile on each.
+calibration table in ``state.kprofiles``.
 
-``state.kprofiles`` is the union across nozzle diameters (see
-``BambuMQTTClient._store_kprofiles``), which is what the assign paths need but
-makes ``cali_idx`` alone ambiguous. Resolution here is therefore:
+That table is not a clean per-nozzle numbering, and treating it as one is what
+blanked every slot on a second AMS (#3044). Both of these happen:
 
-1. the slot's own extruder, which separates the two nozzles of a dual-nozzle
-   machine outright;
-2. failing that, the diameters currently installed, which separates a live
-   table from one left behind by a nozzle that has since been swapped out.
+* Two profiles can share a ``cali_idx`` and differ by extruder — measured on
+  the maintainer's H2C, where one spool read 0.018 on the left nozzle and
+  0.020 on the right. Resolving on ``cali_idx`` alone showed the wrong one.
+* One profile can be what *both* extruders' slots point at. In the #3044
+  capture an X2D's B1 and B3 carried exactly the K values of A4 and A1 — the
+  same entries, tagged with one extruder. Demanding an extruder match left
+  every slot on the right-hand AMS blank.
 
-If both fail to single out one profile the answer is ``None``. A blank space on
-the card is a smaller error than confidently printing the other nozzle's number.
+The two are told apart by whether the table distinguishes extruders *at all*:
+
+1. a profile filed under the slot's own extruder wins outright;
+2. if the slot's extruder appears nowhere in the table, its tagging carries no
+   information about this slot, so match on ``cali_idx`` alone — taking the
+   answer only when the candidates agree on one K value, with the diameters
+   currently installed as the tie-break (which separates a live table from one
+   left behind by a nozzle that has since been swapped out).
+
+The condition on step 2 is what keeps the H2C case fixed. There extruder 0 does
+hold profiles, so a right-hand slot pointing at an index only the left hotend
+has is a real miss — the index means entry 16 *of the right nozzle's table*,
+and the left's entry 16 is a different profile. Falling back there is how the
+wrong K got shown in the first place.
+
+BambuStudio is looser still: ``AMSItem.cpp`` fills the same card through
+``CalibUtils::get_pa_k_n_value_by_cali_idx``, which scans the whole history for
+a matching ``cali_idx`` and takes the first hit regardless of nozzle.
+
+If neither step singles out one value the answer is ``None``. A blank space on
+the card is a smaller error than confidently printing the other nozzle's
+number.
 """
 
 from collections.abc import Callable
@@ -34,6 +54,9 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
     # detects the ambiguity: more than one entry means two nozzles' tables both
     # claim this index on this extruder.
     table: dict[tuple[int, int], dict[str, float]] = {}
+    # cali_idx -> [(nozzle_diameter, k)], every extruder together. The fallback
+    # for an index no profile claims on the slot's own extruder.
+    shared: dict[int, list[tuple[str, float]]] = {}
     for kp in getattr(state, "kprofiles", None) or []:
         if kp.slot_id is None or not kp.k_value:
             continue
@@ -45,22 +68,45 @@ def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | Non
             extruder = int(kp.extruder_id or 0)
         except (ValueError, TypeError):
             extruder = 0
-        table.setdefault((extruder, kp.slot_id), {})[str(kp.nozzle_diameter or "")] = k_value
+        nozzle = str(kp.nozzle_diameter or "")
+        table.setdefault((extruder, kp.slot_id), {})[nozzle] = k_value
+        shared.setdefault(kp.slot_id, []).append((nozzle, k_value))
+
+    # Which extruders the table names at all. An extruder missing from this is
+    # one the printer is not filing profiles under, which is what makes the
+    # cali_idx-only fallback safe for it.
+    extruders_filed = {extruder for extruder, _ in table}
 
     installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
 
+    def _agreed(candidates: list[tuple[str, float]]) -> float | None:
+        """The one K these candidates describe, or None if they disagree.
+
+        Values rather than entries: two nozzles listing the same number is not
+        an ambiguity, it is the shared profile the fallback exists for.
+        """
+        values = {k for _, k in candidates}
+        if len(values) == 1:
+            return values.pop()
+        live = {k for nozzle, k in candidates if nozzle in installed}
+        return live.pop() if len(live) == 1 else None
+
     def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
         if cali_idx is None:
             return None
         extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
         # Single-nozzle printers report everything under extruder 0, and that
         # is also the right default when the routing is simply unknown.
-        by_nozzle = table.get((extruder if extruder is not None else 0, cali_idx))
-        if not by_nozzle:
+        own = extruder if extruder is not None else 0
+        by_nozzle = table.get((own, cali_idx))
+        if by_nozzle:
+            if len(by_nozzle) == 1:
+                return next(iter(by_nozzle.values()))
+            live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
+            return live[0] if len(live) == 1 else None
+        if own in extruders_filed:
             return None
-        if len(by_nozzle) == 1:
-            return next(iter(by_nozzle.values()))
-        live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
-        return live[0] if len(live) == 1 else None
+        candidates = shared.get(cali_idx)
+        return _agreed(candidates) if candidates else None
 
     return resolve

+ 99 - 0
backend/tests/unit/services/test_kprofile_nozzle_buckets_2854.py

@@ -190,6 +190,105 @@ class TestSlotKResolver:
         assert resolve(3, 0, 0) is None
 
 
+class TestASlotWhoseExtruderClaimsNoProfile:
+    """#3044: an X2D showed K on its first AMS and nothing on its second.
+
+    The printer does not always file a profile per hotend. In the reporter's
+    capture the second AMS's slots pointed at the same table entries as the
+    first -- B1 read the K of A4, B3 the K of A1 -- and those entries carry one
+    extruder. Requiring the slot's own extruder to match therefore found
+    nothing for every slot on the right-hand AMS.
+
+    BambuStudio, filling the same card, does not scope by extruder at all:
+    ``AMSItem.cpp`` resolves through ``get_pa_k_n_value_by_cali_idx``, which
+    takes the first entry with a matching ``cali_idx``.
+    """
+
+    def test_a_shared_profile_resolves_on_the_other_extruder(self):
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.021000", "0.4", extruder=0)],
+                ams_extruder_map={"0": 0, "1": 1},
+            )
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.021)
+        assert resolve(3, 1, 0) == pytest.approx(0.021)
+
+    def test_the_slots_own_extruder_still_wins_over_the_fallback(self):
+        """The H2C case has to keep winning: the fallback is a last resort, not
+        a replacement. Index 3 exists on both hotends with different K."""
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.018000", "0.4", extruder=1)],
+                ams_extruder_map={"0": 1, "1": 0},
+            )
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.018)
+        assert resolve(3, 1, 0) == pytest.approx(0.020)
+
+    def test_two_entries_agreeing_on_one_k_is_not_an_ambiguity(self):
+        """Both hotends calibrated to the same number says nothing is in doubt."""
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.021000", "0.4", extruder=0), _profile(3, "0.021000", "0.6", extruder=0)],
+                nozzles=("0.4", "0.6"),
+                ams_extruder_map={"0": 1},
+            )
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.021)
+
+    def test_the_fallback_still_prefers_the_nozzle_that_is_fitted(self):
+        """A table left behind by a swapped-out nozzle loses to the live one."""
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.017000", "0.6", extruder=0)],
+                nozzles=("0.6",),
+                ams_extruder_map={"0": 1},
+            )
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.017)
+
+    def test_the_fallback_refuses_when_the_candidates_disagree(self):
+        """Blank still beats confidently printing one of two different numbers."""
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.017000", "0.6", extruder=0)],
+                nozzles=("0.4", "0.6"),
+                ams_extruder_map={"0": 1},
+            )
+        )
+
+        assert resolve(3, 0, 0) is None
+
+    def test_a_hotend_with_its_own_profiles_does_not_borrow_the_others(self):
+        """The H2C guard, which the fallback must not reopen.
+
+        Index 16 is the left hotend's entry and index 15 the right's. A
+        right-hand slot bound to 16 means "entry 16 of the right nozzle's
+        table", which this printer does not have -- and the left's entry 16 is
+        a different profile, not a stand-in. Blank is the honest answer.
+        """
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(16, "0.018000", "0.4", extruder=1), _profile(15, "0.020000", "0.4", extruder=0)],
+                ams_extruder_map={"0": 0, "1": 1},
+            )
+        )
+
+        assert resolve(16, 0, 0) is None
+        assert resolve(15, 0, 0) == pytest.approx(0.020)
+        assert resolve(16, 1, 0) == pytest.approx(0.018)
+
+    def test_an_index_no_profile_holds_is_still_nothing(self):
+        resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4", extruder=0)], ams_extruder_map={"0": 1}))
+
+        assert resolve(9, 0, 0) is None
+
+
 class TestPrimeKProfileTable:
     """Nothing used to read the calibration table on connect.
 

+ 120 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -869,6 +869,126 @@ describe('ConfigureAmsSlotModal', () => {
     });
   });
 
+  describe('A slot whose extruder claims no profile (#3044)', () => {
+    // Reporter's X2D: two AMS 2 Pro, one per hotend, the same filaments in
+    // both. The printer files one profile per filament rather than one per
+    // hotend, so the second AMS's slots point at entries tagged extruder 0.
+    // Scoping the picker to the slot's own extruder left it with nothing:
+    // the slot read as unconfigured and choosing a profile changed nothing
+    // the user could see.
+    const sharedProfiles = [
+      { name: 'PLA', k_value: '0.021', slot_id: 1 },
+      { name: 'PETG', k_value: '0.026', slot_id: 4 },
+    ].map(p => ({
+      ...p,
+      extruder_id: 0,
+      nozzle_id: 'HH00-0.4',
+      nozzle_diameter: '0.4',
+      filament_id: 'GFL99',
+      n_coef: '0',
+      ams_id: 0,
+      tray_id: 0,
+      setting_id: '',
+    }));
+
+    // AMS-B, right-hand hotend, already bound to the PLA profile the A-side
+    // slots use.
+    const rightHandSlot = {
+      ...defaultProps.slotInfo,
+      amsId: 1,
+      savedPresetId: 'builtin_GFL99',
+      extruderId: 1,
+      caliIdx: 1,
+    };
+
+    beforeEach(() => {
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' },
+      ]);
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({ profiles: sharedProfiles });
+    });
+
+    it('offers the profiles as matches rather than an empty picker', async () => {
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={rightHandSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /PLA \(K=0.021\)/ })).toBeInTheDocument();
+      });
+      // Matches are the select's direct children; anything demoted to the
+      // "Other K profiles" group sits inside an optgroup instead. Being merely
+      // present is what the slot already had, and it read as unconfigured.
+      const matches = Array.from(screen.getByRole('combobox').querySelectorAll(':scope > option'))
+        .map(o => (o as HTMLOptionElement).value)
+        .filter(Boolean);
+      expect(matches).toEqual(['0|PLA|0.021', '0|PETG|0.026']);
+    });
+
+    it('shows the slot as already bound to its active profile', async () => {
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={rightHandSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /PLA \(K=0.021\)/ })).toBeInTheDocument();
+      });
+      // Not the "no K profile" placeholder, which is what the slot showed while
+      // the active profile could not be found on this extruder.
+      expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('0|PLA|0.021');
+    });
+
+    it('still scopes to the slot own hotend when that hotend has its own profiles', async () => {
+      // The H2C case behind the scoping: one filament calibrated on both
+      // hotends, two profiles, one right answer per slot. The fallback is a
+      // last resort and must not reopen this.
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...sharedProfiles[0], extruder_id: 0, k_value: '0.020', slot_id: 1 },
+          { ...sharedProfiles[0], extruder_id: 1, k_value: '0.018', slot_id: 2 },
+        ],
+      });
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...rightHandSlot, caliIdx: 2 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /K=0.018/ })).toBeInTheDocument();
+      });
+      expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('1|PLA|0.018');
+      // The left hotend's copy is still reachable, but under "Other".
+      const other = screen.getByRole('option', { name: /K=0.020/ });
+      expect(other.parentElement?.tagName).toBe('OPTGROUP');
+    });
+
+    it('names each hotend once, not two or three times', async () => {
+      // The label rendered kProfileNozzleSuffix twice in the matching group and
+      // three times under "Other", so every option on a dual-nozzle printer
+      // read "... . Left . Left".
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...sharedProfiles[0], extruder_id: 0, k_value: '0.020', slot_id: 1 },
+          { ...sharedProfiles[0], extruder_id: 1, k_value: '0.018', slot_id: 2 },
+        ],
+      });
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...rightHandSlot, caliIdx: 2 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /K=0.018/ })).toBeInTheDocument();
+      });
+      for (const option of screen.getAllByRole('option')) {
+        // The suffix is the only thing that puts a middot in an option label,
+        // so counting separators counts how many times the hotend was named.
+        const separators = (option.textContent ?? '').match(/\u00b7/g) ?? [];
+        expect(separators.length).toBeLessThanOrEqual(1);
+      }
+    });
+  });
+
   it('does not include the active K-profile when caliIdx is 0 or null (#1689 guard)', async () => {
     // cali_idx == 0 / null means no profile is active (printer default 0.020).
     // The safety net only triggers for activeIdx > 0 — otherwise unrelated

+ 39 - 16
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -114,21 +114,39 @@ function kProfileOptionValue(profile: KProfile): string {
   return `${profile.extruder_id ?? 0}|${profile.name}|${profile.k_value}`;
 }
 
+/**
+ * Does the printer file its calibration table per hotend?
+ *
+ * It is not a property of the machine but of the table it sent. When the
+ * slot's own hotend appears in it, an index means "entry N of *that* hotend's
+ * table" and the tagging is load-bearing. When the hotend appears nowhere, the
+ * tagging says nothing about this slot and scoping by it only hides profiles
+ * the slot really does use.
+ */
+function tableNamesThisHotend(profiles: KProfile[], extruderId: number | undefined): boolean {
+  if (extruderId === undefined) return false;
+  return profiles.some(p => (p.extruder_id ?? 0) === extruderId);
+}
+
 /**
  * The profile a slot's `cali_idx` points at.
  *
- * An index is only meaningful together with a nozzle: the printer numbers its
- * calibration table per hotend, so entry 16 exists on both and means a
- * different profile on each. On the maintainer's H2C, index 16 is the left
- * hotend's black PLA at K=0.018 and index 15 is the right's at K=0.020.
- * Matching on the index alone returns whichever the printer listed first.
+ * Scoped to the slot's own hotend where the table names it: on the
+ * maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and
+ * index 15 is the right's at K=0.020, so a right-hand slot bound to 16 must
+ * come up empty rather than follow the index into the left's table.
+ *
+ * Where the table does not name the hotend, the printer is filing one profile
+ * per filament instead — an X2D's second AMS points at the same entries as its
+ * first — and demanding a match found nothing at all, leaving every slot on
+ * that AMS unconfigured with no error (#3044). There the index stands alone.
  */
 function findProfileByCaliIdx(
   profiles: KProfile[],
   caliIdx: number,
   extruderId: number | undefined
 ): KProfile | undefined {
-  if (extruderId !== undefined) {
+  if (tableNamesThisHotend(profiles, extruderId)) {
     return profiles.find(p => p.slot_id === caliIdx && (p.extruder_id ?? 0) === extruderId);
   }
   return profiles.find(p => p.slot_id === caliIdx);
@@ -992,13 +1010,18 @@ export function ConfigureAmsSlotModal({
       return false;
     });
 
-    // Scope to the slot's own nozzle when it is known: a K-profile calibrated on
-    // the other hotend is not a match for this slot, and offering it as one is
-    // how the wrong K got bound. Those profiles are still reachable below under
-    // "Other", where the option label names the hotend.
-    const onThisNozzle = slotInfo.extruderId === undefined
-      ? filtered
-      : filtered.filter(p => (p.extruder_id ?? 0) === slotInfo.extruderId);
+    // Scope to the slot's own nozzle where the table names it: a K-profile
+    // calibrated on the other hotend is not a match for this slot, and offering
+    // it as one is how the wrong K got bound. Those profiles are still
+    // reachable below under "Other", where the option label names the hotend.
+    //
+    // Where the table names no profile on this hotend at all, scoping emptied
+    // the list instead — the X2D case in #3044, where the second AMS's slots
+    // point at the first's entries — so the tagging is ignored rather than
+    // enforced.
+    const onThisNozzle = tableNamesThisHotend(kprofilesData.profiles, slotInfo.extruderId)
+      ? filtered.filter(p => (p.extruder_id ?? 0) === slotInfo.extruderId)
+      : filtered;
 
     // Deduplicate genuine duplicates — same nozzle, same name, same K.
     const seen = new Map<string, KProfile>();
@@ -1374,14 +1397,14 @@ export function ConfigureAmsSlotModal({
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
                           <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                            {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                            {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                           </option>
                         ))}
                         {otherKProfiles.length > 0 && (
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                             {otherKProfiles.map((profile) => (
                               <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                                {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                                {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                               </option>
                             ))}
                           </optgroup>
@@ -1626,7 +1649,7 @@ export function ConfigureAmsSlotModal({
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           {otherKProfiles.map((profile) => (
                             <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                              {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}{kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                              {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                             </option>
                           ))}
                         </optgroup>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DQmhpLf8.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-DgrwpFp7.js"></script>
+    <script type="module" crossorigin src="/assets/index-DQmhpLf8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio