Sfoglia il codice sorgente

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 10 ore fa
parent
commit
309e64b8a2

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 
 ### Fixed
 ### Fixed
+- **K values missing on a second AMS, and its slots unconfigurable (#3044, reported by @Zib-Astian)** — an X2D with two AMS 2 Pro, one per hotend, showed K 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 it had none bound, and choosing one changed nothing visible. Both came from the same rule. A calibration index can mean two different profiles on a dual-nozzle printer — 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 Bambuddy resolves it against the slot's own hotend and shows nothing rather than the wrong number. But a printer does not always file one profile per hotend. This one files one per filament, so the second AMS's slots point at the same entries as the first, all tagged with a single extruder, and requiring a match found nothing at all. The hotend now has to actually appear in the table the printer sent before it is used to narrow anything; where it does not, the index stands on its own, which is what BambuStudio does for the same card. Printers that do file per hotend are unaffected — the wrong-nozzle number still cannot be shown, and the other hotend's profiles stay under **Other K profiles**. Also fixed alongside it: on a dual-nozzle printer the K-profile dropdown named the hotend twice on every matching option and three times on every other one.
 - **The Timeline ignored Shortest Job First (#3043)** — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say *when* each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving *Any X1C* with *Any X2D* (and *Any P1S* with *Any P1P*) into a single run of rows.
 - **The Timeline ignored Shortest Job First (#3043)** — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say *when* each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving *Any X1C* with *Any X2D* (and *Any P1S* with *Any P1P*) into a single run of rows.
 - **Preheat & Heat Soak delayed PLA prints by minutes with nothing to preheat for (#3041)** — with the feature on, a queued PLA print sat for five to seven minutes before the file was even uploaded, and turning the feature off started it immediately. The per-filament map does what it says and gives PLA a chamber target of 0, and the chamber phase duly skipped — but the stage then heated the bed to the print's own temperature, waited for it, and held the full soak anyway, because the soak had no idea it was holding for a chamber that was never asked for. That is pure delay: the print's G-code sets the bed the moment it starts, so preheating it here only moved the warm-up ahead of the upload instead of overlapping with it, and there was no chamber to condition. A chamber target of 0 derived from the filament map now skips the whole stage, so PLA, PETG, TPU and PVA prints dispatch straight away with the feature left on. The one thing the skip still does is put the airduct flap back to cooling, which is a single command and no wait — an H2D left in heating mode by the ABS job before it would otherwise cook the PLA that follows. Explicit instructions are untouched: a chamber target of **0** typed into a print's own override still heats the bed and runs the soak, which is what the queue documentation has always promised it does, as does forcing a print's Preheat override to **On**. Prints that genuinely want chamber heat are unaffected, including on the P1S/P1P/A1 tier where the bed and the soak timer are the whole mechanism.
 - **Preheat & Heat Soak delayed PLA prints by minutes with nothing to preheat for (#3041)** — with the feature on, a queued PLA print sat for five to seven minutes before the file was even uploaded, and turning the feature off started it immediately. The per-filament map does what it says and gives PLA a chamber target of 0, and the chamber phase duly skipped — but the stage then heated the bed to the print's own temperature, waited for it, and held the full soak anyway, because the soak had no idea it was holding for a chamber that was never asked for. That is pure delay: the print's G-code sets the bed the moment it starts, so preheating it here only moved the warm-up ahead of the upload instead of overlapping with it, and there was no chamber to condition. A chamber target of 0 derived from the filament map now skips the whole stage, so PLA, PETG, TPU and PVA prints dispatch straight away with the feature left on. The one thing the skip still does is put the airduct flap back to cooling, which is a single command and no wait — an H2D left in heating mode by the ABS job before it would otherwise cook the PLA that follows. Explicit instructions are untouched: a chamber target of **0** typed into a print's own override still heats the bed and runs the soak, which is what the queue documentation has always promised it does, as does forcing a print's Preheat override to **On**. Prints that genuinely want chamber heat are unaffected, including on the P1S/P1P/A1 tier where the bed and the soak timer are the whole mechanism.
 - **Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by @kielsucks)** — Bambu Studio writes `0` into `wall_filament`, `sparse_infill_filament` and `solid_infill_filament` to mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright with `wall_filament: 0 not in range [1.000000,...]`, so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the `-1` inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a `-1` on a filament index is a real value and is left alone, as is a `0` on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same `0` on the versions where `0` was legal and `1` on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: **the plate preview never ran the sanitiser**, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no `--load-settings` pass that could supply a replacement for a rejected field. Both paths now share one sanitiser.
 - **Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by @kielsucks)** — Bambu Studio writes `0` into `wall_filament`, `sparse_infill_filament` and `solid_infill_filament` to mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright with `wall_filament: 0 not in range [1.000000,...]`, so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the `-1` inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a `-1` on a filament index is a real value and is left alone, as is a `0` on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same `0` on the versions where `0` was legal and `1` on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: **the plate preview never ran the sanitiser**, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no `--load-settings` pass that could supply a replacement for a rejected field. Both paths now share one sanitiser.

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

@@ -528,13 +528,14 @@ async def get_printer_status(
     ams_exists = False
     ams_exists = False
     raw_data = state.raw_data or {}
     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)
     _kprofile_k = build_slot_k_resolver(state)
 
 
     # Cached active-cycle drying params (filament + target temp) we sent
     # 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.
 """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
 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
 ``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
 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
 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
 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
     # detects the ambiguity: more than one entry means two nozzles' tables both
     # claim this index on this extruder.
     # claim this index on this extruder.
     table: dict[tuple[int, int], dict[str, float]] = {}
     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 []:
     for kp in getattr(state, "kprofiles", None) or []:
         if kp.slot_id is None or not kp.k_value:
         if kp.slot_id is None or not kp.k_value:
             continue
             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)
             extruder = int(kp.extruder_id or 0)
         except (ValueError, TypeError):
         except (ValueError, TypeError):
             extruder = 0
             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}
     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:
     def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
         if cali_idx is None:
         if cali_idx is None:
             return None
             return None
         extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
         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
         # Single-nozzle printers report everything under extruder 0, and that
         # is also the right default when the routing is simply unknown.
         # 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
             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
     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
         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:
 class TestPrimeKProfileTable:
     """Nothing used to read the calibration table on connect.
     """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 () => {
   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).
     // cali_idx == 0 / null means no profile is active (printer default 0.020).
     // The safety net only triggers for activeIdx > 0 — otherwise unrelated
     // 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}`;
   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.
  * 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(
 function findProfileByCaliIdx(
   profiles: KProfile[],
   profiles: KProfile[],
   caliIdx: number,
   caliIdx: number,
   extruderId: number | undefined
   extruderId: number | undefined
 ): KProfile | 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 && (p.extruder_id ?? 0) === extruderId);
   }
   }
   return profiles.find(p => p.slot_id === caliIdx);
   return profiles.find(p => p.slot_id === caliIdx);
@@ -992,13 +1010,18 @@ export function ConfigureAmsSlotModal({
       return false;
       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.
     // Deduplicate genuine duplicates — same nozzle, same name, same K.
     const seen = new Map<string, KProfile>();
     const seen = new Map<string, KProfile>();
@@ -1374,14 +1397,14 @@ export function ConfigureAmsSlotModal({
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
                         {matchingKProfiles.map((profile) => (
                           <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(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>
                           </option>
                         ))}
                         ))}
                         {otherKProfiles.length > 0 && (
                         {otherKProfiles.length > 0 && (
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                             {otherKProfiles.map((profile) => (
                             {otherKProfiles.map((profile) => (
                               <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(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>
                               </option>
                             ))}
                             ))}
                           </optgroup>
                           </optgroup>
@@ -1626,7 +1649,7 @@ export function ConfigureAmsSlotModal({
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           {otherKProfiles.map((profile) => (
                           {otherKProfiles.map((profile) => (
                             <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(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>
                             </option>
                           ))}
                           ))}
                         </optgroup>
                         </optgroup>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DQmhpLf8.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   </head>
   <body>
   <body>

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