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

fix(inventory): PA-Profil picker fetches K-profiles across all installed nozzles (#2618)

The Edit Spool "PA-Profil" tab and the SpoolBuddy write-tag page fetched a
printer's calibrations with getKProfiles(printer.id), which defaults the nozzle
filter to 0.4. The printer/MQTT layer filters strictly by that diameter, so on
a multi-nozzle printer a same-filament 0.6mm K-profile was never retrieved and
the picker showed only the 0.4mm entry ("1 match"). (The AMS-Slot config dialog
was already fixed in #1899; these two pickers were not.)

Add installedNozzleDiameters(status) and a shared fetchPrinterCalibrations()
that queries every reported nozzle diameter and merges the results, falling
back to 0.4 when the printer hasn't reported nozzle hardware. Each profile row
now shows a nozzle-diameter badge so identically-named profiles are distinct.
maziggy 1 месяц назад
Родитель
Сommit
9bb5a1b999

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
 - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.
 - **The Print Queue's History tab showed a count of all prints but only ever displayed the first 50, with no way to reach the rest (#2682, reporter @pchulpjoost)** — The History header read e.g. `History (311 items)`, but only 50 rows rendered and there was no "load more" control, so 261 finished prints were unreachable. **Root cause.** The full history is already loaded client-side (the queue endpoint has no limit) and sorted correctly — the header counts the whole list — but the row builder hard-sliced it to `items.slice(0, 50)`, a fixed cap with no accompanying control. Nothing was missing server-side; it simply wasn't drawn. **Fix.** History now paginates: it draws the 50 most-recent prints and, when there are more, shows a **Show more** button (with a `Showing X of Y` count) that loads the next 50, repeating until the whole history is on screen. The page size resets to the first page only when you re-sort or change the location filter — deliberately not on the periodic queue poll, so an expanded view doesn't collapse mid-scroll. Frontend-only; batch grouping and per-row actions are unchanged. Covered by a test asserting the 50-row cap, the `Showing 50 of 60` count, and that Show more reveals the remainder. Wiki updated.
 - **LDAP Distinguished Names weren't redacted from the support bundle / bug report (#2681, reporter @MaxBareiss)** — With LDAP auth in use, the debug log carried lines like `LDAP authentication successful for user: … (DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, …)`. A DN's leaf `CN` is the user's real name — PII on par with the email address Bambuddy already redacts — and it passed straight through into an uploaded support bundle. **Fix.** The log sanitizer (used by both the support bundle and the in-app bug report) now redacts LDAP DNs to `[DN]` wherever they appear — the auth line, ldap3 exception strings, and group DNs alike — matching a run of `attr=value` RDN components (`CN/OU/DC/UID/…`) so ordinary `key=value` log text isn't affected. As primary hygiene the LDAP service also no longer logs the raw DN on successful auth (the username plus group count is enough). Covered by tests, including the exact reported line and non-DN `key=value` lines that must be left intact. Redaction list on the Bug Report wiki page updated.

+ 56 - 0
frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx

@@ -0,0 +1,56 @@
+/**
+ * Regression test for the PA-Profil picker's nozzle blindness (#2618).
+ *
+ * When a printer has two K-profiles for the same filament that differ only in
+ * nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the picker
+ * must offer BOTH and label each with its nozzle diameter — not collapse to a
+ * single entry. The underlying cause lived in the fetch (it defaulted to the
+ * 0.4mm nozzle and never retrieved the 0.6mm profile); this test guards the
+ * rendering half: given both calibrations, the section shows both with a
+ * nozzle badge so they are distinguishable.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../../i18n';
+import { PAProfileSection } from '../../../components/spool-form/PAProfileSection';
+import { defaultFormData } from '../../../components/spool-form/types';
+import type { PrinterWithCalibrations } from '../../../components/spool-form/types';
+
+const printers = [
+  {
+    printer: { id: 1, name: 'H2D', connected: true },
+    calibrations: [
+      // Same filament (non-generic id → id-match), different nozzle + extruder.
+      { cali_idx: 10, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.042, n_coef: 0, extruder_id: 0, nozzle_diameter: '0.4' },
+      { cali_idx: 11, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.028, n_coef: 0, extruder_id: 1, nozzle_diameter: '0.6' },
+    ],
+  },
+] as unknown as PrinterWithCalibrations[];
+
+describe('PAProfileSection nozzle-specific profiles (#2618)', () => {
+  it('renders both nozzle profiles for one filament, each with a nozzle badge', () => {
+    render(
+      <I18nextProvider i18n={i18n}>
+        <PAProfileSection
+          formData={{ ...defaultFormData, material: 'PAHT-CF', slicer_filament: 'GFN05' }}
+          updateField={vi.fn()}
+          printersWithCalibrations={printers}
+          selectedProfiles={new Set()}
+          setSelectedProfiles={vi.fn()}
+          expandedPrinters={new Set(['1'])}
+          setExpandedPrinters={vi.fn()}
+        />
+      </I18nextProvider>,
+    );
+
+    // Both nozzle-specific K values are offered — not just the 0.4mm one.
+    expect(screen.getByText('K=0.042')).toBeInTheDocument();
+    expect(screen.getByText('K=0.028')).toBeInTheDocument();
+
+    // Each is labelled by its nozzle so identically-named profiles are distinct.
+    expect(screen.getByText('0.4mm')).toBeInTheDocument();
+    expect(screen.getByText('0.6mm')).toBeInTheDocument();
+  });
+});

+ 54 - 0
frontend/src/__tests__/utils/installedNozzleDiameters.test.ts

@@ -0,0 +1,54 @@
+/**
+ * Tests for installedNozzleDiameters helper (#2618).
+ *
+ * The spool PA-Profil picker must fetch K-profiles across every nozzle the
+ * printer actually has installed, not just the hardcoded 0.4mm default — else
+ * a 0.6mm profile for the same filament is never surfaced. This helper lists
+ * the distinct reported diameters, skipping empty/non-positive defaults, and
+ * returns an empty array when the hardware hasn't been reported so the caller
+ * can keep its own fallback.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { installedNozzleDiameters } from '../../utils/amsHelpers';
+
+describe('installedNozzleDiameters', () => {
+  it('returns an empty array when status is null or undefined', () => {
+    expect(installedNozzleDiameters(null)).toEqual([]);
+    expect(installedNozzleDiameters(undefined)).toEqual([]);
+  });
+
+  it('returns an empty array when no nozzles are reported', () => {
+    expect(installedNozzleDiameters({ nozzles: [] })).toEqual([]);
+    expect(installedNozzleDiameters({})).toEqual([]);
+  });
+
+  it('skips empty-string and non-positive nozzle defaults', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '' }, { nozzle_diameter: '0' }] }),
+    ).toEqual([]);
+  });
+
+  it('returns the single installed diameter', () => {
+    expect(installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }] })).toEqual(['0.4']);
+  });
+
+  it('returns both diameters on a dual-nozzle printer, in order', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }] }),
+    ).toEqual(['0.4', '0.6']);
+  });
+
+  it('dedupes repeated diameters (two 0.4 hotends report once)', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.4' }] }),
+    ).toEqual(['0.4']);
+  });
+
+  it('keeps only the valid diameter when one hotend is still an empty default', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.6' }, { nozzle_diameter: '' }] }),
+    ).toEqual(['0.6']);
+  });
+});

+ 4 - 16
frontend/src/components/SpoolFormModal.tsx

@@ -8,7 +8,7 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
@@ -201,21 +201,9 @@ export function SpoolFormModal({
               const connected = status?.connected ?? false;
               let calibrations: PrinterWithCalibrations['calibrations'] = [];
               if (connected) {
-                try {
-                  const kRes = await api.getKProfiles(printer.id);
-                  calibrations = kRes.profiles.map(p => ({
-                    cali_idx: p.slot_id,
-                    filament_id: p.filament_id,
-                    setting_id: p.setting_id || '',
-                    name: p.name,
-                    k_value: parseFloat(p.k_value) || 0,
-                    n_coef: parseFloat(p.n_coef) || 0,
-                    extruder_id: p.extruder_id,
-                    nozzle_diameter: p.nozzle_diameter,
-                  }));
-                } catch {
-                  // Printer may not support K-profiles
-                }
+                // Fetch across every installed nozzle so dual-nozzle printers
+                // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
+                calibrations = await fetchPrinterCalibrations(printer.id, status);
               }
               results.push({ printer: { ...printer, connected }, calibrations });
             }

+ 5 - 0
frontend/src/components/spool-form/PAProfileSection.tsx

@@ -127,6 +127,11 @@ export function PAProfileSection({
           </span>
         </div>
         <div className="flex items-center gap-2 shrink-0">
+          {cal.nozzle_diameter && (
+            <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
+              {cal.nozzle_diameter}mm
+            </span>
+          )}
           <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
             K={cal.k_value.toFixed(3)}
           </span>

+ 42 - 1
frontend/src/components/spool-form/utils.ts

@@ -1,7 +1,48 @@
+import { api } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
-import type { ColorPreset, FilamentOption } from './types';
+import { installedNozzleDiameters } from '../../utils/amsHelpers';
+import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
 
+/**
+ * Fetch a printer's K-profiles across every nozzle it actually has installed
+ * (#2618) and flatten them into CalibrationProfile rows for the PA-Profil
+ * picker. `getKProfiles` filters strictly by nozzle diameter and defaults to
+ * "0.4", so a single call hides non-0.4 profiles (e.g. a 0.6mm PAHT-CF K
+ * value) — the picker then shows only one of two nozzle-specific profiles.
+ * We query each installed diameter and merge. Falls back to "0.4" when the
+ * printer hasn't reported nozzle hardware, preserving prior behaviour.
+ * Per-diameter failures are swallowed (a printer that doesn't support the
+ * endpoint just yields no rows), matching the callers' previous try/catch.
+ */
+export async function fetchPrinterCalibrations(
+  printerId: number,
+  status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
+): Promise<CalibrationProfile[]> {
+  const diameters = installedNozzleDiameters(status);
+  const toFetch = diameters.length > 0 ? diameters : ['0.4'];
+  const responses = await Promise.all(
+    toFetch.map(d => api.getKProfiles(printerId, d).catch(() => null)),
+  );
+  const calibrations: CalibrationProfile[] = [];
+  for (const res of responses) {
+    if (!res) continue;
+    for (const p of res.profiles) {
+      calibrations.push({
+        cali_idx: p.slot_id,
+        filament_id: p.filament_id,
+        setting_id: p.setting_id || '',
+        name: p.name,
+        k_value: parseFloat(p.k_value) || 0,
+        n_coef: parseFloat(p.n_coef) || 0,
+        extruder_id: p.extruder_id,
+        nozzle_diameter: p.nozzle_diameter,
+      });
+    }
+  }
+  return calibrations;
+}
+
 // Fallback filament presets when cloud is not available
 const FALLBACK_PRESETS: FilamentOption[] = [
   { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },

+ 4 - 15
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -24,6 +24,7 @@ import { defaultFormData, validateForm } from '../../components/spool-form/types
 import {
   buildFilamentOptions,
   extractBrandsFromPresets,
+  fetchPrinterCalibrations,
   findPresetOption,
   loadRecentColors,
   parsePresetName,
@@ -521,21 +522,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
           const connected = status?.connected ?? false;
           let calibrations: PrinterWithCalibrations['calibrations'] = [];
           if (connected) {
-            try {
-              const kRes = await api.getKProfiles(printer.id);
-              calibrations = kRes.profiles.map(p => ({
-                cali_idx: p.slot_id,
-                filament_id: p.filament_id,
-                setting_id: p.setting_id || '',
-                name: p.name,
-                k_value: parseFloat(p.k_value) || 0,
-                n_coef: parseFloat(p.n_coef) || 0,
-                extruder_id: p.extruder_id,
-                nozzle_diameter: p.nozzle_diameter,
-              }));
-            } catch {
-              // ignore per-printer unsupported profile endpoints
-            }
+            // Fetch across every installed nozzle so dual-nozzle printers
+            // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
+            calibrations = await fetchPrinterCalibrations(printer.id, status);
           }
           results.push({ printer: { ...printer, connected }, calibrations });
         }

+ 26 - 0
frontend/src/utils/amsHelpers.ts

@@ -347,6 +347,32 @@ export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
   );
 }
 
+/**
+ * List the distinct nozzle diameters the printer actually reports (#2618).
+ * Mirrors the backend `_installed_nozzle_diameters`: reads each
+ * `status.nozzles[].nozzle_diameter`, skips the empty-string / non-positive
+ * defaults that populate a NozzleInfo before MQTT fills it in, and dedupes.
+ *
+ * Returns e.g. `['0.4']` (single-nozzle) or `['0.4', '0.6']` (dual-nozzle). An
+ * empty array means "the printer hasn't told us its nozzle hardware" — callers
+ * that need to fetch per-nozzle should fall back to their own default rather
+ * than treating it as "no nozzles". Preserves the bare decimal string form the
+ * status carries so it can be passed straight to `getKProfiles`.
+ */
+export function installedNozzleDiameters(
+  status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
+): string[] {
+  const seen = new Set<string>();
+  const result: string[] = [];
+  for (const nozzle of status?.nozzles ?? []) {
+    const raw = (nozzle?.nozzle_diameter ?? '').trim();
+    if (!raw || !(parseFloat(raw) > 0) || seen.has(raw)) continue;
+    seen.add(raw);
+    result.push(raw);
+  }
+  return result;
+}
+
 /**
  * Resolve the installed nozzle diameter feeding a given AMS unit, so the
  * Configure-AMS-Slot picker filters filament presets by the nozzle actually on

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

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