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-07 19:23:48 +02:00
parent f5cdc86689
commit 309e64b8a2
9 changed files with 415 additions and 122 deletions
@@ -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>