fix(k-profile): match by filament_id, surface active profile in Configure Slot (#1688 + #1689)

Two related bugs in K-profile matching, same root cause.

  #1688 — spool form's PA-profile suggester (PAProfileSection via
  isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
  parsing the profile NAME for material/brand/variant. Spools already
  store slicer_filament (the slicer preset id) and K-profiles already
  carry filament_id, but both were ignored — so a user's custom
  K-profile whose name doesn't agree with the slicer preset got silently
  dropped from suggestions even when the underlying filament_id was
  identical.

  #1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
  name-only logic on the slot's selected preset. A spool assigned under
  "Generic PLA" with a custom K-profile actively bound on the printer
  landed in the modal as "K profile not assigned, default 0.020 will
  be used", while the printer-card hover-card correctly showed the
  active profile. Two paths, only one was filtering by name.

  Shared root: spool preset ids and K-profile filament_ids look
  different but are equivalent after normalising. Spools store
  slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
  the variant suffix, the S infix marks setting_id form); K-profiles
  store filament_id as the bare form ("GFG98"). Plain === doesn't
  work; both need normalising. This conversion already existed in the
  other direction at buildFilamentOptions (filament_id → "GFS" +
  filament_id.slice(2)), so the inverse toFilamentId helper is just
  the matching reverse, not new ground.

  Fix — one shared helper, two surfaces:

  - spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
    variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
    and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
    which are shared across many filaments and must NOT id-match —
    the name fallback handles those correctly).

  - isMatchingCalibration: gains slicer_filament?: string in formData,
    tries id-match (with generic exclusion) before the existing name
    parse. PAProfileSection already passes the full formData so no
    caller edit needed. Strictly additive precedence.

  - ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
    field (toFilamentId(cp.setting_id) for cloud presets,
    toFilamentId(builtinFilamentId) for builtin; empty for local /
    orca paths which fall through to name match).

  - ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
    of the per-profile predicate (preferred when both sides agree
    after normalisation), then the existing name-parse logic, then
    ALWAYS unshifts the slot's currently-active K-profile by
    slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
    0/null doesn't leak unrelated profiles in), extruder-matched when
    slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
    verbatim with the activeIdx > 0 guard added.

  SpoolBuddy: both kiosk K-profile surfaces reuse the shared
  components. SpoolBuddyWriteTagPage renders PAProfileSection;
  SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
  propagate automatically, no kiosk-specific edits.

  What this does NOT change: spools without slicer_filament, K-profiles
  without filament_id, and generic GFx99 ids all fall through to the
  existing name-based matching path. Strictly additive precedence; no
  input shape that matched under the old logic fails to match under the
  new. The #1053 cloud-preset PFUS* path is preserved because the
  toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
This commit is contained in:
maziggy
2026-06-09 09:53:28 +02:00
parent e9b6fefe95
commit fdcc063d9f
7 changed files with 815 additions and 462 deletions
@@ -424,4 +424,111 @@ describe('ConfigureAmsSlotModal', () => {
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument();
});
it('surfaces a K-profile whose name does not match the preset when filament_id agrees (#1688)', async () => {
// Spool was edited with slicer_filament = "GFSL05_09" (the setting_id form
// for Bambu PLA Basic). The printer has a *custom* K-profile saved on the
// same filament_id, but the user named it something that doesn't include
// "PLA". Pre-fix, the name-only filter dropped it; the id-match path now
// surfaces it because both sides normalise to "GFL05".
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: [
{
slot_id: 3,
extruder_id: 0,
nozzle_id: 'HH00-0.4',
nozzle_diameter: '0.4',
filament_id: 'GFL05',
name: 'my-custom-tune',
k_value: '0.025',
n_coef: '0',
ams_id: 0,
tray_id: 0,
setting_id: '',
},
],
});
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'GFSL05_09', // setting_id form for Bambu PLA Basic
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
// Renders as an <option> on the K-profile select even though
// "my-custom-tune" doesn't contain "PLA" anywhere.
expect(screen.getByRole('option', { name: /my-custom-tune/ })).toBeInTheDocument();
});
});
it("always includes the slot's currently-active K-profile when name and id don't match (#1689)", async () => {
// Reporter scenario: spool assigned under "Generic PLA" but the slot has
// a custom K-profile (filament_id "GFG98" = PETG-something) actively
// selected via cali_idx. Pre-fix the modal showed "default 0.020"; the
// safety net now surfaces the active profile so Configure Slot reflects
// what the printer is actually using.
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: [
{
slot_id: 7, // matches caliIdx below
extruder_id: 0,
nozzle_id: 'HH00-0.4',
nozzle_diameter: '0.4',
filament_id: 'GFG98', // unrelated to "Generic PLA"
name: 'unrelated-petg-tune',
k_value: '0.030',
n_coef: '0',
ams_id: 0,
tray_id: 0,
setting_id: '',
},
],
});
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'GFSL05_09', // Generic PLA preset
caliIdx: 7,
extruderId: 0,
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: /unrelated-petg-tune/ })).toBeInTheDocument();
});
});
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
// profiles whose slot_id happens to equal 0 would leak in.
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
profiles: [
{
slot_id: 0,
extruder_id: 0,
nozzle_id: 'HH00-0.4',
nozzle_diameter: '0.4',
filament_id: 'GFG98',
name: 'should-not-appear',
k_value: '0.030',
n_coef: '0',
ams_id: 0,
tray_id: 0,
setting_id: '',
},
],
});
const slotInfo = {
...defaultProps.slotInfo,
savedPresetId: 'GFSL05_09',
caliIdx: 0,
extruderId: 0,
};
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
await waitFor(() => {
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
});
expect(screen.queryByRole('option', { name: /should-not-appear/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,162 @@
/**
* Tests for K-profile matching helpers (#1688 + #1689).
*
* `isMatchingCalibration` is the predicate used by both the spool form's
* PA-profile suggester and ConfigureAmsSlotModal's K-profile filter. The
* #1688 enhancement adds a filament_id-first match path with id
* normalisation; the old name-parsing logic stays as the fallback.
*/
import { describe, it, expect } from 'vitest';
import {
isMatchingCalibration,
toFilamentId,
isGenericFilamentId,
} from '../../../components/spool-form/utils';
describe('toFilamentId', () => {
it('strips the variant suffix', () => {
expect(toFilamentId('GFG98_09')).toBe('GFG98');
});
it('strips the "S" infix from a setting_id', () => {
// Spool stores setting_id "GFSG98", K-profile stores filament_id "GFG98".
// Normalisation has to drop the "S" so both sides agree.
expect(toFilamentId('GFSG98')).toBe('GFG98');
});
it('strips both the "S" infix and the variant suffix', () => {
expect(toFilamentId('GFSG98_09')).toBe('GFG98');
});
it('returns bare filament_id unchanged', () => {
expect(toFilamentId('GFL05')).toBe('GFL05');
});
it('uppercases the result', () => {
expect(toFilamentId('gfsg98_09')).toBe('GFG98');
});
it('returns empty string for null/undefined/empty', () => {
expect(toFilamentId(null)).toBe('');
expect(toFilamentId(undefined)).toBe('');
expect(toFilamentId('')).toBe('');
});
it('passes through non-Bambu IDs (numeric local-preset, Orca UUID) without crashing', () => {
// Numeric local-preset ID — caller falls through to name match, no crash.
expect(toFilamentId('42')).toBe('42');
// Orca UUID — same.
expect(toFilamentId('orca-uuid-abc')).toBe('ORCA-UUID-ABC');
});
});
describe('isGenericFilamentId', () => {
it('flags GFx99 patterns as generic', () => {
expect(isGenericFilamentId('GFL99')).toBe(true);
expect(isGenericFilamentId('GFG99')).toBe(true);
expect(isGenericFilamentId('GFB99')).toBe(true);
});
it('does not flag non-generic IDs', () => {
expect(isGenericFilamentId('GFL05')).toBe(false);
expect(isGenericFilamentId('GFG98')).toBe(false);
});
it('returns false for null/undefined/empty', () => {
expect(isGenericFilamentId(null)).toBe(false);
expect(isGenericFilamentId(undefined)).toBe(false);
expect(isGenericFilamentId('')).toBe(false);
});
});
describe('isMatchingCalibration (#1688)', () => {
const formData = {
material: 'PETG',
brand: 'Generic',
subtype: '',
slicer_filament: 'GFSG98_09', // spool's setting_id form
};
it('matches by filament_id when ids agree after normalisation', () => {
// K-profile stores bare filament_id; spool stores setting_id. Both
// normalise to "GFG98" — match without any name parsing.
expect(
isMatchingCalibration(
{ name: 'My Custom K Profile', filament_id: 'GFG98' },
formData,
),
).toBe(true);
});
it('id-match wins even when the K-profile name would not parse to anything sensible', () => {
expect(
isMatchingCalibration(
{ name: 'literal-garbage-no-material', filament_id: 'GFG98' },
formData,
),
).toBe(true);
});
it('skips id-match for generic GFx99 ids and falls through to name match', () => {
// GFL99 = generic PLA, shared across many real filaments. Even if the
// spool stored GFL99, name parsing must drive the decision.
const result = isMatchingCalibration(
{ name: 'Random thing with no PETG in it', filament_id: 'GFL99' },
{ ...formData, slicer_filament: 'GFL99' },
);
expect(result).toBe(false);
});
it('falls through to name match when spool has no slicer_filament', () => {
expect(
isMatchingCalibration(
{ name: 'Generic PETG', filament_id: 'GFG98' },
{ material: 'PETG', brand: 'Generic', subtype: '' },
),
).toBe(true);
});
it('falls through to name match when K-profile has no filament_id', () => {
expect(
isMatchingCalibration(
{ name: 'Generic PETG' },
formData,
),
).toBe(true);
});
it('falls through to name match when normalised ids differ', () => {
// Spool says GFG98, K-profile says GFL05 — id-match fails, fall to name.
expect(
isMatchingCalibration(
{ name: 'Generic PETG', filament_id: 'GFL05' },
formData,
),
).toBe(true);
expect(
isMatchingCalibration(
{ name: 'Bambu PLA Basic', filament_id: 'GFL05' },
formData,
),
).toBe(false);
});
it('rejects calibrations whose material does not match (name fallback path)', () => {
expect(
isMatchingCalibration(
{ name: 'Bambu PLA Basic', filament_id: 'GFL05' },
{ material: 'PETG', brand: '', subtype: '' },
),
).toBe(false);
});
it('returns false when formData has no material', () => {
expect(
isMatchingCalibration(
{ name: 'PETG', filament_id: 'GFG98' },
{ material: '', brand: '', subtype: '' },
),
).toBe(false);
});
});
@@ -5,6 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { matchesPrinterModelSuffix } from '../utils/slicerPrinterMatch';
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
import { Button } from './Button';
interface SlotInfo {
@@ -656,15 +657,21 @@ export function ConfigureAmsSlotModal({
// Resolve the name from orca, cloud, local, or builtin presets. The
// Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
// configure-mutation comment for why the raw UUID also reaches us.
// ``filamentId`` is the bare Bambu filament_id (e.g. "GFG98") when the
// preset has one — used to id-match K-profiles directly without name
// parsing (#1688). Empty string for paths with no usable filament_id
// (orca presets, local presets), which makes the id-match branch skip.
let presetName: string | null = null;
let filamentId = '';
if (selectedPresetId.startsWith('local_')) {
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
const lp = localPresets?.filament.find(p => p.id === localId);
presetName = lp?.name || null;
} else if (selectedPresetId.startsWith('builtin_')) {
const filamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
const builtinFilamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === builtinFilamentId);
presetName = bf?.name || null;
filamentId = toFilamentId(builtinFilamentId);
} else {
const orcaCandidateId = selectedPresetId.startsWith('orca_')
? selectedPresetId.replace('orca_', '')
@@ -675,6 +682,11 @@ export function ConfigureAmsSlotModal({
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
if (cp) {
// SlicerSetting only carries setting_id ("GFSG98_09"); toFilamentId
// drops the variant suffix and the "S" infix to yield "GFG98".
filamentId = toFilamentId(cp.setting_id);
}
}
}
if (!presetName) {
@@ -693,6 +705,7 @@ export function ConfigureAmsSlotModal({
fullName: nameWithoutSuffix,
material: parsed.material,
brand: parsed.brand,
filamentId,
};
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
@@ -737,16 +750,28 @@ export function ConfigureAmsSlotModal({
const matchingKProfiles = useMemo(() => {
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
const { fullName, material, brand } = selectedPresetInfo;
const { fullName, material, brand, filamentId } = selectedPresetInfo;
const upperFullName = fullName.toUpperCase();
const upperMaterial = material.toUpperCase();
const upperBrand = brand.toUpperCase();
const presetFid = filamentId; // already normalised via toFilamentId
// Material must be at least 2 chars to avoid false positives
if (!upperMaterial || upperMaterial.length < 2) return [];
// Filter profiles - require brand match if brand is present in selected preset
const filtered = kprofilesData.profiles.filter(p => {
// Preferred: exact filament_id match (#1688). A user's custom K-profile
// whose name doesn't agree with the slicer preset still surfaces when
// both sides agree on filament_id. Generic GFx99 IDs are excluded —
// they're shared across many filaments and over-match if id-compared.
if (presetFid) {
const calFid = toFilamentId(p.filament_id);
if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
return true;
}
}
const profileName = p.name.toUpperCase();
// If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
@@ -803,8 +828,26 @@ export function ConfigureAmsSlotModal({
seen.set(key, profile);
}
}
return Array.from(seen.values());
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);
const result = Array.from(seen.values());
// Always include the slot's currently-active K-profile (by cali_idx / slot_id),
// even if its name and filament_id didn't match the selected preset (#1689).
// A spool assigned under "Generic PLA" can have a K-profile actively bound on
// the printer whose filament_id differs from "Generic PLA"; without this
// safety net the modal shows "not assigned, default 0.020" while the printer
// card's hover-card correctly shows the active profile.
const activeIdx = slotInfo.caliIdx;
if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
const active = kprofilesData.profiles.find(
p => p.slot_id === activeIdx
&& (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
);
if (active) result.unshift(active);
}
return result;
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
// Pre-select current profile when modal opens, reset when closes
useEffect(() => {
+40 -1
View File
@@ -291,13 +291,52 @@ export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]
return updated;
}
// Normalise a Bambu filament identifier to its bare filament_id form (#1688).
// Spools store ``slicer_filament`` as a setting_id like "GFSG98_09" (the "_NN"
// suffix is the variant, the "S" infix marks it as a setting_id); printer
// K-profiles store ``filament_id`` as "GFG98" (bare). Both shapes need
// normalising before comparison.
//
// This is the inverse of the filament_id→setting_id mapping at
// ``buildFilamentOptions`` ("GFS" + filament_id.slice(2)), so a round-trip
// stays consistent. Non-Bambu IDs (numeric local-preset IDs, Orca UUIDs)
// are returned unchanged uppercase — they won't match any K-profile's
// filament_id and the caller falls through to name-based matching.
export function toFilamentId(id: string | null | undefined): string {
if (!id) return '';
// Drop "_NN" variant suffix.
let s = id.split('_')[0];
// Strip the "S" infix in "GFS..." so "GFSG98" → "GFG98".
if (/^GFS/i.test(s)) s = s.slice(0, 2) + s.slice(3);
return s.toUpperCase();
}
// "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
// IDs — shared across many different physical filaments. Matching K-profiles
// by an exact generic ID would over-match, so the id-match path skips them and
// the caller falls through to name-based matching.
export function isGenericFilamentId(id: string | null | undefined): boolean {
return !!id && /^GF[A-Z]99$/i.test(id);
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
formData: { material: string; brand: string; subtype: string },
formData: { material: string; brand: string; subtype: string; slicer_filament?: string },
): boolean {
if (!formData.material) return false;
// Preferred path: exact filament_id match after normalising both sides
// (#1688). When the spool has a non-generic preset assigned and it agrees
// with the K-profile's filament_id, this is unambiguous — no name parsing
// needed. A spool storing "GFSG98_09" matches a K-profile with filament_id
// "GFG98" without going anywhere near parsePresetName.
const spoolFid = toFilamentId(formData.slicer_filament);
const calFid = toFilamentId(cal.filament_id);
if (spoolFid && calFid && spoolFid === calFid && !isGenericFilamentId(calFid)) {
return true;
}
const profileName = cal.name || '';
// Remove flow type prefixes