Просмотр исходного кода

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.
maziggy 2 месяцев назад
Родитель
Сommit
fdcc063d9f

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


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

@@ -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();
+  });
 });

+ 162 - 0
frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts

@@ -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);
+  });
+});

+ 48 - 5
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -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
frontend/src/components/spool-form/utils.ts

@@ -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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-lEQPGYKn.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-fRNJA0id.js"></script>
+    <script type="module" crossorigin src="/assets/index-lEQPGYKn.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов