mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch 'dev' into feature/oidc-env-config
This commit is contained in:
@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
|
||||
import { toFilamentId } from './spool-form/utils';
|
||||
import { Button } from './Button';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||||
|
||||
interface SlotInfo {
|
||||
amsId: number;
|
||||
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showExtendedColors, setShowExtendedColors] = useState(false);
|
||||
const scrolledToRef = useRef<string>('');
|
||||
// The success state is held briefly before the modal closes itself; that
|
||||
// timer must not outlive the modal.
|
||||
const { schedule: scheduleClose } = useCancellableTimeout();
|
||||
|
||||
// Fetch cloud settings (gracefully handle 401 when logged out)
|
||||
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
|
||||
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
// Close after showing success briefly
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
|
||||
onSuccess: () => {
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
|
||||
@@ -21,10 +21,17 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
|
||||
import {
|
||||
buildFilamentPresetOptions,
|
||||
resolveFilamentId,
|
||||
type FilamentPresetOption,
|
||||
type FilamentPresetSource,
|
||||
} from '../utils/filamentPresets';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||||
|
||||
interface KProfileCardProps {
|
||||
profile: KProfile;
|
||||
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
|
||||
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
||||
};
|
||||
|
||||
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
|
||||
const getFlowTypeLabel = (nozzleId: string) => {
|
||||
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
|
||||
return 'S'; // Standard Flow (default)
|
||||
};
|
||||
// nozzle_id encodes the flow type, per the slicer's own generator:
|
||||
// "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
|
||||
// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
|
||||
// not a material code.
|
||||
const STANDARD_FLOW = 'HS00';
|
||||
const HIGH_FLOW = 'HH00';
|
||||
|
||||
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
|
||||
// Many printers omit nozzle_id from their extrusion_cali_get response entirely
|
||||
// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
|
||||
// Standard (its parser falls back to nvtStandard when the key is absent), and
|
||||
// so do we: the flow type stays a real, editable value rather than a blank.
|
||||
const getNozzleTypePrefix = (nozzleId: string) => {
|
||||
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
||||
return match ? match[1] : 'HH00';
|
||||
return match ? match[1] : STANDARD_FLOW;
|
||||
};
|
||||
|
||||
// Short label for the profile list.
|
||||
const getFlowTypeLabel = (nozzleId: string) =>
|
||||
getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
|
||||
|
||||
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
||||
const extractFilamentName = (profileName: string) => {
|
||||
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
||||
@@ -149,9 +164,11 @@ interface KProfileModalProps {
|
||||
profile?: KProfile;
|
||||
printerId: number;
|
||||
nozzleDiameter: string;
|
||||
existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
||||
existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
|
||||
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
|
||||
filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
|
||||
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
||||
supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
|
||||
initialNote?: string; // Initial note value for the profile
|
||||
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
||||
onClose: () => void;
|
||||
@@ -166,7 +183,9 @@ function KProfileModal({
|
||||
nozzleDiameter,
|
||||
existingProfiles = [],
|
||||
builtinFilaments = [],
|
||||
filamentPresets = [],
|
||||
isDualNozzle = false,
|
||||
supportsFlowType = true,
|
||||
initialNote = '',
|
||||
initialNoteKey = null,
|
||||
onClose,
|
||||
@@ -181,10 +200,19 @@ function KProfileModal({
|
||||
const [kValue, setKValue] = useState(
|
||||
profile?.k_value ? truncateK(profile.k_value) : '0.020'
|
||||
);
|
||||
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
|
||||
// What the Filament select is bound to. When editing, the printer's own
|
||||
// filament_id (the select is read-only). For a new profile, the *preset
|
||||
// handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
|
||||
// setting_id or a builtin filament id — which is resolved to a real
|
||||
// filament_id on submit, since only some tiers carry one directly.
|
||||
const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
|
||||
// Split nozzle into type and diameter
|
||||
// Both selects are read-only while editing: they report what the printer
|
||||
// holds, they don't set it. '' means the printer reported no nozzle_id, which
|
||||
// single-nozzle models never do (#1748) — showing "High Flow" there was the
|
||||
// UI inventing a value the printer never sent.
|
||||
const [nozzleType, setNozzleType] = useState(
|
||||
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
|
||||
profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
|
||||
);
|
||||
const [modalDiameter, setModalDiameter] = useState(
|
||||
profile?.nozzle_diameter || nozzleDiameter
|
||||
@@ -197,40 +225,49 @@ function KProfileModal({
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
||||
const [note, setNote] = useState(initialNote);
|
||||
const [filamentQuery, setFilamentQuery] = useState('');
|
||||
// The modal defers its own close so the printer has time to process the
|
||||
// command; that timer must not outlive the modal.
|
||||
const { schedule: scheduleClose } = useCancellableTimeout();
|
||||
|
||||
// Extract unique filaments from existing K-profiles on the printer
|
||||
// Use builtin filament table for accurate name resolution (filament_id → name)
|
||||
// Falls back to extracting from profile name for custom/unknown presets
|
||||
const knownFilaments = React.useMemo(() => {
|
||||
// Build lookup map from builtin filament names (includes cloud presets from parent)
|
||||
const builtinMap = new Map<string, string>();
|
||||
for (const bf of builtinFilaments) {
|
||||
builtinMap.set(bf.filament_id, bf.name);
|
||||
}
|
||||
// Name for the filament an existing profile is bound to. The builtin table
|
||||
// (which the parent has already merged with the user's cloud presets) is
|
||||
// authoritative; a profile whose filament_id is in neither falls back to the
|
||||
// name the printer stored for it.
|
||||
const editedFilamentName = React.useMemo(() => {
|
||||
if (!profile?.filament_id) return '';
|
||||
const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
|
||||
if (builtinName) return builtinName;
|
||||
const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
|
||||
return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
|
||||
}, [profile, existingProfiles, builtinFilaments]);
|
||||
|
||||
const filamentMap = new Map<string, { id: string; name: string }>();
|
||||
for (const p of existingProfiles) {
|
||||
if (p.filament_id && !filamentMap.has(p.filament_id)) {
|
||||
// Prefer builtin name (accurate), fall back to extracting from profile name
|
||||
const builtinName = builtinMap.get(p.filament_id);
|
||||
const filamentName = builtinName || extractFilamentName(p.name || '');
|
||||
filamentMap.set(p.filament_id, {
|
||||
id: p.filament_id,
|
||||
name: filamentName || p.filament_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(filamentMap.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}, [existingProfiles, builtinFilaments]);
|
||||
// The tiered list, grouped for rendering. Order is fixed app-wide —
|
||||
// imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
|
||||
// and buildFilamentPresetOptions has already sorted by it, so grouping is
|
||||
// just a partition that preserves that order.
|
||||
const presetGroups = React.useMemo(() => {
|
||||
const labels: [FilamentPresetSource, string][] = [
|
||||
['local', t('kProfiles.modal.source.local')],
|
||||
['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
|
||||
['cloud', t('kProfiles.modal.source.bambuCloud')],
|
||||
['builtin', t('kProfiles.modal.source.builtin')],
|
||||
];
|
||||
const query = filamentQuery.trim().toLowerCase();
|
||||
const matches = query
|
||||
? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
|
||||
: filamentPresets;
|
||||
return labels
|
||||
.map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
|
||||
.filter(g => g.items.length > 0);
|
||||
}, [filamentPresets, filamentQuery, t]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: KProfileCreate) => {
|
||||
console.log('[KProfile] Calling API...');
|
||||
return api.setKProfile(printerId, data);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
onSuccess: (result, variables) => {
|
||||
console.log('[KProfile] Save success:', result);
|
||||
showToast(t('kProfiles.toast.profileSaved'));
|
||||
// Save note if it changed (including clearing it)
|
||||
@@ -243,8 +280,10 @@ function KProfileModal({
|
||||
// Editing: use setting_id if available, or composite key with slot_id
|
||||
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
|
||||
} else {
|
||||
// New profile: use name as key (will be matched when profile is loaded)
|
||||
profileKey = `name_${name}_${filamentId}`;
|
||||
// New profile: use name as key (matched against the reloaded profile,
|
||||
// so it has to be the resolved filament_id that was sent — not the
|
||||
// preset handle the user picked).
|
||||
profileKey = `name_${name}_${variables.filament_id}`;
|
||||
}
|
||||
onSaveNote(profileKey, note);
|
||||
}
|
||||
@@ -252,7 +291,7 @@ function KProfileModal({
|
||||
setIsSyncing(true);
|
||||
// Add delay before closing to give printer time to process the save
|
||||
// onSave will trigger refetch in the parent component
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
onSave();
|
||||
}, 2500);
|
||||
@@ -276,7 +315,7 @@ function KProfileModal({
|
||||
setIsSyncing(true);
|
||||
// Add longer delay for delete - printer needs more time to process
|
||||
// before it can return the updated profile list
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
onClose();
|
||||
}, 4000);
|
||||
@@ -316,14 +355,46 @@ function KProfileModal({
|
||||
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
|
||||
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
||||
|
||||
// An edit is delete + re-add on single-nozzle printers, so the nozzle
|
||||
// fields have to survive the round trip — both selects are disabled while
|
||||
// editing. Rebuilding them blindly from the selects is what let a 0.6mm
|
||||
// profile come back as "HH00-0.4" once the parse defaults had stamped it
|
||||
// 0.4 (#1748), so prefer whatever the printer reported. Where it reported
|
||||
// no nozzle_id at all, send the rebuilt one rather than an empty string —
|
||||
// the field is part of the profile's identity on the wire and the slicer
|
||||
// always populates it.
|
||||
const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
|
||||
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
|
||||
|
||||
// The printer indexes its calibration table by filament_id, so the preset
|
||||
// the user picked has to be reduced to one before anything is sent. Only
|
||||
// the builtin tier and Bambu's official cloud presets carry one outright;
|
||||
// a cloud *user* preset needs its detail fetched, and imported / Orca
|
||||
// presets have no Bambu id at all and map to the generic for their
|
||||
// material. Refuse rather than guess when nothing resolves — a profile
|
||||
// filed under the wrong filament is invisible to the slot that needs it.
|
||||
let resolvedFilamentId = profile?.filament_id || '';
|
||||
if (!profile) {
|
||||
const picked = filamentPresets.find(p => p.id === filamentChoice);
|
||||
if (!picked) {
|
||||
showToast(t('kProfiles.toast.selectFilament'), 'error');
|
||||
return;
|
||||
}
|
||||
resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
|
||||
if (!resolvedFilamentId) {
|
||||
showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For editing or single extruder: just save one profile
|
||||
if (profile || selectedExtruders.length === 1) {
|
||||
const payload = {
|
||||
name: name,
|
||||
k_value: formattedKValue,
|
||||
filament_id: filamentId,
|
||||
nozzle_id: nozzleId,
|
||||
nozzle_diameter: modalDiameter,
|
||||
filament_id: resolvedFilamentId,
|
||||
nozzle_id: editNozzleId,
|
||||
nozzle_diameter: editDiameter,
|
||||
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
|
||||
setting_id: profile?.setting_id,
|
||||
slot_id: profile?.slot_id ?? 0,
|
||||
@@ -341,7 +412,7 @@ function KProfileModal({
|
||||
const batchPayload = selectedExtruders.map(extruderId => ({
|
||||
name: name,
|
||||
k_value: formattedKValue,
|
||||
filament_id: filamentId,
|
||||
filament_id: resolvedFilamentId,
|
||||
nozzle_id: nozzleId,
|
||||
nozzle_diameter: modalDiameter,
|
||||
extruder_id: extruderId,
|
||||
@@ -356,7 +427,7 @@ function KProfileModal({
|
||||
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
|
||||
// Save note for new batch profiles
|
||||
if (onSaveNote && note) {
|
||||
const profileKey = `name_${name}_${filamentId}`;
|
||||
const profileKey = `name_${name}_${resolvedFilamentId}`;
|
||||
onSaveNote(profileKey, note);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -370,7 +441,7 @@ function KProfileModal({
|
||||
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
|
||||
// Wait for final sync before closing
|
||||
// onSave will trigger refetch in the parent component
|
||||
setTimeout(() => {
|
||||
scheduleClose(() => {
|
||||
setIsSyncing(false);
|
||||
setSavingProgress({ current: 0, total: 0 });
|
||||
onSave();
|
||||
@@ -454,49 +525,77 @@ function KProfileModal({
|
||||
{/* Filament - read-only when editing */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
|
||||
<select
|
||||
value={filamentId}
|
||||
onChange={(e) => {
|
||||
const newFilamentId = e.target.value;
|
||||
setFilamentId(newFilamentId);
|
||||
// Auto-generate profile name when filament is selected (for new profiles)
|
||||
// Only auto-generate if name is empty - don't overwrite user input
|
||||
if (!profile && newFilamentId && !name) {
|
||||
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
|
||||
if (selectedFilament) {
|
||||
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${selectedFilament.name}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!!profile}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
required={!profile}
|
||||
>
|
||||
<option value="">{t('kProfiles.modal.selectFilament')}</option>
|
||||
{/* Show current filament when editing - look up from knownFilaments */}
|
||||
{profile?.filament_id && (
|
||||
<option key={profile.filament_id} value={profile.filament_id}>
|
||||
{knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
|
||||
</option>
|
||||
)}
|
||||
{/* Show known filaments from existing K-profiles (for new profiles) */}
|
||||
{!profile && knownFilaments.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!profile && knownFilaments.length === 0 && (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('kProfiles.modal.noFilamentsHelp')}
|
||||
</p>
|
||||
{profile ? (
|
||||
// Editing or copying: the filament is fixed, so this is a
|
||||
// readout rather than a control.
|
||||
<div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
|
||||
{editedFilamentName || profile.filament_id}
|
||||
</div>
|
||||
) : (
|
||||
// A real list rather than a <select>: Chrome ignores almost
|
||||
// every CSS property on <optgroup>, so a source heading inside
|
||||
// a native dropdown can't be made to stand out.
|
||||
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<div className="relative border-b border-bambu-dark-tertiary">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={filamentQuery}
|
||||
onChange={(e) => setFilamentQuery(e.target.value)}
|
||||
placeholder={t('kProfiles.modal.searchFilaments')}
|
||||
className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto bg-bambu-dark">
|
||||
{presetGroups.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs text-bambu-gray">
|
||||
{filamentPresets.length === 0
|
||||
? t('kProfiles.modal.noFilamentsHelp')
|
||||
: t('kProfiles.modal.noFilamentMatches')}
|
||||
</p>
|
||||
) : presetGroups.map((group) => (
|
||||
<div key={group.source}>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-bambu-gray">{group.items.length}</span>
|
||||
</div>
|
||||
{group.items.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilamentChoice(f.id);
|
||||
// Auto-generate the profile name, but never over
|
||||
// an entry the user typed.
|
||||
if (!name) {
|
||||
const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${f.name}`);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
|
||||
filamentChoice === f.id
|
||||
? 'bg-bambu-green/20 text-white'
|
||||
: 'text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flow Type and Nozzle Size - read-only when editing */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
{/* Flow Type and Nozzle Size - read-only when editing. Flow type
|
||||
is hidden on models sold with a single nozzle variant (the
|
||||
A-series), where the choice would be meaningless — same gate
|
||||
the slicer applies via support_nozzle_volume(). */}
|
||||
<div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
|
||||
<div className={supportsFlowType ? '' : 'hidden'}>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
||||
<select
|
||||
value={nozzleType}
|
||||
@@ -505,10 +604,10 @@ function KProfileModal({
|
||||
setNozzleType(newNozzleType);
|
||||
// Update profile name when flow type changes (for new profiles)
|
||||
// Only auto-generate if name is empty - don't overwrite user input
|
||||
if (!profile && filamentId && !name) {
|
||||
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
||||
if (!profile && filamentChoice && !name) {
|
||||
const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
|
||||
if (selectedFilament) {
|
||||
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
|
||||
const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${selectedFilament.name}`);
|
||||
}
|
||||
}
|
||||
@@ -516,8 +615,8 @@ function KProfileModal({
|
||||
disabled={!!profile}
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value="HS00">{t('kProfiles.modal.standard')}</option>
|
||||
<option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -772,13 +871,12 @@ export function KProfilesView() {
|
||||
refetchOnMount: 'always', // Always refetch when component mounts
|
||||
});
|
||||
|
||||
// Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
|
||||
const { data: allProfiles } = useQuery({
|
||||
queryKey: ['kprofiles', selectedPrinter, '0.4'],
|
||||
queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
|
||||
enabled: !!selectedPrinter,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
});
|
||||
// A second fetch for 0.4mm profiles used to seed the Add-Profile filament
|
||||
// dropdown. The dropdown is built from the filament preset tiers now
|
||||
// (#2719), so the round trip bought nothing — and it fired concurrently
|
||||
// with the fetch above whenever a different nozzle was selected, which is
|
||||
// exactly the two-requests-in-flight case that made K-profile fetches time
|
||||
// out (#1748).
|
||||
|
||||
// Fetch builtin filament names for accurate filament_id → name resolution
|
||||
const { data: builtinFilaments } = useQuery({
|
||||
@@ -794,6 +892,28 @@ export function KProfilesView() {
|
||||
staleTime: 300000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// The other three filament tiers, so a printer with no K-profiles yet can
|
||||
// still be given its first one (#2719). Each query stands alone and fails
|
||||
// quietly: not being signed in to a cloud should thin the list, not break
|
||||
// the page, and the builtin tier above guarantees it is never empty.
|
||||
const { data: localPresets } = useQuery({
|
||||
queryKey: ['localPresets'],
|
||||
queryFn: () => api.getLocalPresets(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: orcaCloudList } = useQuery({
|
||||
queryKey: ['orcaCloudProfilesForKProfiles'],
|
||||
queryFn: () => api.orcaCloudListProfiles(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: cloudSettings } = useQuery({
|
||||
queryKey: ['cloudSettings'],
|
||||
queryFn: () => api.getCloudSettings(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch K-profile notes (stored locally)
|
||||
const {
|
||||
data: notesData,
|
||||
@@ -860,6 +980,19 @@ export function KProfilesView() {
|
||||
}));
|
||||
}, [builtinFilamentMap]);
|
||||
|
||||
// Every filament this install knows about, ranked in the app-wide order:
|
||||
// imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
|
||||
// built-in table as the floor.
|
||||
const filamentPresets = React.useMemo(
|
||||
() => buildFilamentPresetOptions({
|
||||
localPresets: localPresets?.filament,
|
||||
orcaProfiles: orcaCloudList?.filament,
|
||||
cloudSettings: cloudSettings?.filament,
|
||||
builtinFilaments,
|
||||
}),
|
||||
[localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
|
||||
);
|
||||
|
||||
// Resolve filament name: builtin table first, then extract from profile name
|
||||
const resolveFilamentName = React.useCallback((profile: KProfile) => {
|
||||
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
|
||||
@@ -911,6 +1044,18 @@ export function KProfilesView() {
|
||||
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
||||
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
||||
|
||||
// Whether this printer model is sold with both Standard and High Flow
|
||||
// nozzles. Comes from the model, not from whether the payload happened to
|
||||
// carry a nozzle_id — most printers omit that field entirely (#1748) while
|
||||
// still offering both flows. Only the A-series has a single variant.
|
||||
const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
|
||||
|
||||
// Don't strand the list behind a filter whose control just disappeared.
|
||||
useEffect(() => {
|
||||
if (!supportsFlowType) setFlowTypeFilter('all');
|
||||
}, [supportsFlowType]);
|
||||
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
|
||||
name: p.name,
|
||||
k_value: parseFloat(p.k_value).toFixed(6),
|
||||
filament_id: p.filament_id,
|
||||
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
|
||||
// An export from a printer that reports no nozzle_id carries
|
||||
// none; fall back to Standard, the same default the slicer's
|
||||
// parser uses for a missing field.
|
||||
nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
|
||||
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
|
||||
extruder_id: p.extruder_id ?? 0,
|
||||
slot_id: 0, // Always create new
|
||||
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={flowTypeFilter}
|
||||
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="all">{t('kProfiles.allFlow')}</option>
|
||||
<option value="hf">{t('kProfiles.hfOnly')}</option>
|
||||
<option value="s">{t('kProfiles.sOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{supportsFlowType && (
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={flowTypeFilter}
|
||||
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="all">{t('kProfiles.allFlow')}</option>
|
||||
<option value="hf">{t('kProfiles.hfOnly')}</option>
|
||||
<option value="s">{t('kProfiles.sOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={sortOption}
|
||||
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
|
||||
profile={editingProfile}
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
initialNote={note}
|
||||
initialNoteKey={key}
|
||||
onSaveNote={handleSaveNote}
|
||||
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
|
||||
<KProfileModal
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
onClose={() => {
|
||||
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
|
||||
<KProfileModal
|
||||
printerId={selectedPrinter}
|
||||
nozzleDiameter={nozzleDiameter}
|
||||
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
||||
existingProfiles={kprofiles?.profiles}
|
||||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
// Pass profile data but without slot_id to create a new profile
|
||||
|
||||
Reference in New Issue
Block a user