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".
This commit is contained in:
maziggy
2026-09-20 13:31:43 +02:00
8 changed files with 414 additions and 122 deletions
+7 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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.