mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch 'dev' into feature/save-ams-mapping-toggle
This commit is contained in:
@@ -6,6 +6,7 @@ import { api } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
|
||||
|
||||
interface BatchProjectModalProps {
|
||||
selectedIds: number[];
|
||||
@@ -43,14 +44,9 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Helper to invalidate all project-related queries
|
||||
const invalidateProjectQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
// Invalidate project detail pages (partial match catches all project IDs)
|
||||
queryClient.invalidateQueries({ queryKey: ['project'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-archives'] });
|
||||
};
|
||||
// Helper to invalidate all project-related queries. The shared version also
|
||||
// covers the timeline and file-progress views, which this list was missing.
|
||||
const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient);
|
||||
|
||||
// Assign to project mutation (uses bulk API)
|
||||
const assignMutation = useMutation({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
|
||||
import { api } from '../api/client';
|
||||
import type { KProfile } from '../api/client';
|
||||
import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
|
||||
import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
|
||||
import { toFilamentId } from './spool-form/utils';
|
||||
import { Button } from './Button';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
|
||||
@@ -99,6 +99,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia
|
||||
return { material: withoutSuffix, brand: '', variant: '' };
|
||||
}
|
||||
|
||||
// Identity of a K-profile inside the picker. Both profile lists are
|
||||
// deduplicated on name+k_value, so this is unique across the whole option set
|
||||
// — unlike the bare name, which two profiles can share (#2710).
|
||||
function kProfileOptionValue(profile: KProfile): string {
|
||||
return `${profile.name}|${profile.k_value}`;
|
||||
}
|
||||
|
||||
// Check if a preset is a user preset (not built-in)
|
||||
function isUserPreset(settingId: string): boolean {
|
||||
// Built-in presets have specific patterns, user presets are UUIDs
|
||||
@@ -870,7 +877,12 @@ export function ConfigureAmsSlotModal({
|
||||
const { fullName, material, brand, filamentId } = selectedPresetInfo;
|
||||
const upperFullName = fullName.toUpperCase();
|
||||
const upperMaterial = material.toUpperCase();
|
||||
const upperBrand = brand.toUpperCase();
|
||||
// "Generic" leads every built-in Bambu preset name ("Generic PLA",
|
||||
// "Generic PETG") but is not a manufacturer (#2710). Treating it as one
|
||||
// put the filter into brand-gated mode and demanded "GENERIC" in the
|
||||
// K-profile name, which no real profile has — so selecting a built-in
|
||||
// generic preset matched nothing at all.
|
||||
const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
|
||||
const presetFid = filamentId; // already normalised via toFilamentId
|
||||
|
||||
// Material must be at least 2 chars to avoid false positives
|
||||
@@ -880,11 +892,18 @@ export function ConfigureAmsSlotModal({
|
||||
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.
|
||||
// both sides agree on filament_id.
|
||||
//
|
||||
// Generic GFx99 ids count here too (#2710). The equality test already
|
||||
// means both sides carry the *same* id, so the old "generic ids
|
||||
// over-match" exclusion could only ever fire when the selected preset
|
||||
// was itself the generic one — precisely the case where the match is
|
||||
// right. The printer keeps one calibration table per filament_id, so a
|
||||
// slot on "Generic PLA" should offer every profile calibrated under
|
||||
// Generic PLA, whatever the user named them.
|
||||
if (presetFid) {
|
||||
const calFid = toFilamentId(p.filament_id);
|
||||
if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
|
||||
if (calFid && calFid === presetFid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -966,6 +985,46 @@ export function ConfigureAmsSlotModal({
|
||||
return result;
|
||||
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
|
||||
|
||||
// Every remaining K-profile the printer holds, offered under a separate group
|
||||
// after the matching ones (#2710). The matcher works off preset names and
|
||||
// filament ids, neither of which the user controls when they name a profile
|
||||
// after its colour — so there is always a residual chance it filters out a
|
||||
// profile the user wants. This makes that recoverable in the UI instead of
|
||||
// sending them to the slicer: the printer's own calibration table is the
|
||||
// authority on what can be selected, and the backend realigns the slot's
|
||||
// filament context to whichever profile is picked.
|
||||
const otherKProfiles = useMemo(() => {
|
||||
if (!kprofilesData?.profiles) return [];
|
||||
const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
|
||||
// Same name+k_value dedup as the matching list, so a multi-nozzle printer's
|
||||
// duplicate rows don't show up twice here either.
|
||||
const seen = new Map<string, KProfile>();
|
||||
for (const profile of kprofilesData.profiles) {
|
||||
const key = kProfileOptionValue(profile);
|
||||
if (matched.has(key)) continue;
|
||||
const existing = seen.get(key);
|
||||
if (!existing) {
|
||||
seen.set(key, profile);
|
||||
} else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
|
||||
seen.set(key, profile);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
|
||||
|
||||
const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
|
||||
|
||||
const selectKProfileByValue = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setSelectedKProfile(null);
|
||||
return;
|
||||
}
|
||||
const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
|
||||
|| otherKProfiles.find(p => kProfileOptionValue(p) === value)
|
||||
|| null;
|
||||
setSelectedKProfile(profile);
|
||||
}, [matchingKProfiles, otherKProfiles]);
|
||||
|
||||
// Pre-select current profile when modal opens, reset when closes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -1234,22 +1293,28 @@ export function ConfigureAmsSlotModal({
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{matchingKProfiles.length > 0 ? (
|
||||
{hasAnyKProfile ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedKProfile?.name || ''}
|
||||
onChange={(e) => {
|
||||
const profile = matchingKProfiles.find(p => p.name === e.target.value);
|
||||
setSelectedKProfile(profile || null);
|
||||
}}
|
||||
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
|
||||
onChange={(e) => selectKProfileByValue(e.target.value)}
|
||||
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 appearance-none pr-10"
|
||||
>
|
||||
<option value="">{t('configureAmsSlot.noKProfile')}</option>
|
||||
{matchingKProfiles.map((profile) => (
|
||||
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
|
||||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||||
{profile.name} (K={profile.k_value})
|
||||
</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})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
@@ -1473,22 +1538,28 @@ export function ConfigureAmsSlotModal({
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{matchingKProfiles.length > 0 ? (
|
||||
{hasAnyKProfile ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedKProfile?.name || ''}
|
||||
onChange={(e) => {
|
||||
const profile = matchingKProfiles.find(p => p.name === e.target.value);
|
||||
setSelectedKProfile(profile || null);
|
||||
}}
|
||||
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
|
||||
onChange={(e) => selectKProfileByValue(e.target.value)}
|
||||
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 appearance-none pr-10"
|
||||
>
|
||||
<option value="">{t('configureAmsSlot.noKProfile')}</option>
|
||||
{matchingKProfiles.map((profile) => (
|
||||
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
|
||||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||||
{profile.name} (K={profile.k_value})
|
||||
</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})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client';
|
||||
import type { Archive } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { PrintLogTable } from './PrintLogTable';
|
||||
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
|
||||
|
||||
// Keys for failure reasons - translated at render time.
|
||||
// Exported so the Print Log per-row classification editor (#1687 part 4)
|
||||
@@ -147,8 +148,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
|
||||
api.updateArchive(archive.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
// This form can change the archive's project, so the project detail
|
||||
// views need refreshing too — not just the overview cards (#2731).
|
||||
invalidateArchiveAndProjectViews(queryClient);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export function FailureDetectionSettings() {
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [mlUrl, setMlUrl] = useState('');
|
||||
const [mlToken, setMlToken] = useState('');
|
||||
const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
|
||||
const [pollInterval, setPollInterval] = useState(10);
|
||||
@@ -44,6 +45,7 @@ export function FailureDetectionSettings() {
|
||||
if (!settings) return;
|
||||
setEnabled(settings.obico_enabled ?? false);
|
||||
setMlUrl(settings.obico_ml_url ?? '');
|
||||
setMlToken(settings.obico_ml_token ?? '');
|
||||
setSensitivity(settings.obico_sensitivity ?? 'medium');
|
||||
setAction(settings.obico_action ?? 'notify');
|
||||
setPollInterval(settings.obico_poll_interval ?? 10);
|
||||
@@ -63,6 +65,7 @@ export function FailureDetectionSettings() {
|
||||
api.updateSettings({
|
||||
obico_enabled: enabled,
|
||||
obico_ml_url: mlUrl,
|
||||
obico_ml_token: mlToken,
|
||||
obico_sensitivity: sensitivity,
|
||||
obico_action: action,
|
||||
obico_poll_interval: pollInterval,
|
||||
@@ -84,6 +87,7 @@ export function FailureDetectionSettings() {
|
||||
const changed =
|
||||
settings.obico_enabled !== enabled ||
|
||||
settings.obico_ml_url !== mlUrl ||
|
||||
(settings.obico_ml_token ?? '') !== mlToken ||
|
||||
settings.obico_sensitivity !== sensitivity ||
|
||||
settings.obico_action !== action ||
|
||||
settings.obico_poll_interval !== pollInterval ||
|
||||
@@ -92,14 +96,23 @@ export function FailureDetectionSettings() {
|
||||
const id = setTimeout(() => saveMutation.mutate(), 500);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
|
||||
}, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await api.testObicoConnection(mlUrl);
|
||||
const res = await api.testObicoConnection(mlUrl, mlToken);
|
||||
if (res.ok) {
|
||||
setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
|
||||
// auth_ok is null when the token could not be checked — don't claim it
|
||||
// works. It is true both for an accepted token and for a server that
|
||||
// requires none, which is the same outcome for the user.
|
||||
setTestResult({
|
||||
ok: true,
|
||||
message:
|
||||
res.auth_ok === null
|
||||
? t('failureDetection.testSuccessTokenUnknown')
|
||||
: t('failureDetection.testSuccess'),
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
ok: false,
|
||||
@@ -163,6 +176,19 @@ export function FailureDetectionSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
|
||||
<label className="block text-sm text-bambu-gray mb-1 mt-3">
|
||||
{t('failureDetection.mlToken')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={mlToken}
|
||||
onChange={(e) => setMlToken(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t('failureDetection.mlTokenPlaceholder')}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-start gap-2 mt-2 text-sm ${
|
||||
|
||||
@@ -33,9 +33,20 @@ const AMS_RUNOUT_SHORT_CODES = new Set([
|
||||
'0705_8011', '0706_8011', '0707_8011', '07FF_8011',
|
||||
]);
|
||||
|
||||
// Comprehensive error code database (short format: XXXX_YYYY)
|
||||
// Auto-generated from ha-bambulab - 853 codes
|
||||
// "MQTT command verification failed" — the firmware's authorization check
|
||||
// refusing a control command. Keyed by its full 16-char code on purpose: this
|
||||
// error's meaning lives in attr's low half (0500) and code's high half (0001),
|
||||
// both of which getShortCode() discards, so the short form is a useless
|
||||
// "0500_0007". Before #2732 that meant filterKnownHMSErrors dropped it and the
|
||||
// user was never shown the one message that explained why nothing printed.
|
||||
export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
|
||||
|
||||
// Comprehensive error code database, keyed by short code (XXXX_YYYY) or, where
|
||||
// the short code cannot express the error, by full code (16 hex chars).
|
||||
// Short-code entries auto-generated from ha-bambulab - 853 codes
|
||||
const ERROR_DESCRIPTIONS: Record<string, string> = {
|
||||
[HMS_MQTT_VERIFY_FAILED]:
|
||||
'The printer rejected a command because it could not verify it. Prints, temperature changes and filament loads sent from Bambuddy will be ignored until this is fixed.',
|
||||
'0300_4000': 'Z axis homing failed; the task has been stopped.',
|
||||
'0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',
|
||||
'0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',
|
||||
@@ -913,6 +924,15 @@ function getShortCode(attr: number, code: number): string {
|
||||
return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
|
||||
}
|
||||
|
||||
// Catalog lookup. full_code (16 hex chars for hms[]-sourced faults) is tried
|
||||
// first because it is lossless; shortCode is the fallback that the bulk of the
|
||||
// catalog is keyed by. Returns undefined for an uncataloged error so callers can
|
||||
// tell "no description" from "empty description".
|
||||
function lookupDescription(fullCode: string | undefined, shortCode: string): string | undefined {
|
||||
if (fullCode && ERROR_DESCRIPTIONS[fullCode] !== undefined) return ERROR_DESCRIPTIONS[fullCode];
|
||||
return ERROR_DESCRIPTIONS[shortCode];
|
||||
}
|
||||
|
||||
// Helper to filter HMS errors the UI should surface (exported for use in badge counts).
|
||||
// Keeps an error if EITHER:
|
||||
// - it's in the bundled ERROR_DESCRIPTIONS catalog (known, has a description), OR
|
||||
@@ -925,7 +945,7 @@ export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
|
||||
return errors.filter((error) => {
|
||||
const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
|
||||
const shortCode = getShortCode(error.attr, codeNum);
|
||||
if (ERROR_DESCRIPTIONS[shortCode] !== undefined) return true;
|
||||
if (lookupDescription(error.full_code, shortCode) !== undefined) return true;
|
||||
return (error.actions?.length ?? 0) > 0;
|
||||
});
|
||||
}
|
||||
@@ -1023,7 +1043,17 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
|
||||
// Runout guidance (#2587): for an AMS per-slot runout on a paused
|
||||
// print, name the slot the firmware now expects rather than the
|
||||
// misleading generic "insert into the same slot" text.
|
||||
let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
|
||||
const matchedFullCode =
|
||||
!!error.full_code && ERROR_DESCRIPTIONS[error.full_code] !== undefined;
|
||||
let description =
|
||||
lookupDescription(error.full_code, shortCode) ?? t('hmsErrors.unknownCode');
|
||||
// The remedy is Bambuddy's, not Bambu's — their wiki says "update
|
||||
// Studio or Handy", which is no help to someone printing from
|
||||
// Bambuddy. Same override shape as the runout guidance below.
|
||||
const remedy =
|
||||
error.full_code === HMS_MQTT_VERIFY_FAILED
|
||||
? t('hmsErrors.mqttVerifyFailedRemedy')
|
||||
: null;
|
||||
if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
|
||||
if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
|
||||
description = t('hmsErrors.runoutExpectedSlot', {
|
||||
@@ -1039,7 +1069,14 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
|
||||
}
|
||||
}
|
||||
const hmsHomeUrl = getHMSHomeUrl();
|
||||
const displayCode = shortCode.replace('_', '-');
|
||||
// Show the printer's own four-group notation when the short code
|
||||
// could not identify the error — for those, "0500-0007" matches
|
||||
// nothing the user can look up, while "0500-0500-0001-0007" is
|
||||
// exactly what the printer screen and the Bambu wiki show.
|
||||
const displayCode =
|
||||
matchedFullCode && error.full_code!.length === 16
|
||||
? error.full_code!.match(/.{4}/g)!.join('-')
|
||||
: shortCode.replace('_', '-');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1056,6 +1093,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-bambu-gray mb-2">{description}</p>
|
||||
{remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
|
||||
{error.actions && error.actions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 my-2">
|
||||
{error.actions.map((action) => {
|
||||
|
||||
@@ -378,13 +378,50 @@ export function toFilamentId(id: string | null | undefined): string {
|
||||
}
|
||||
|
||||
// "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.
|
||||
// IDs — one per material, shared across every physical filament the user hasn't
|
||||
// given a specific preset. They still identify a material unambiguously, so an
|
||||
// exact generic id match is only ambiguous about *brand*, never about material.
|
||||
export function isGenericFilamentId(id: string | null | undefined): boolean {
|
||||
return !!id && /^GF[A-Z]99$/i.test(id);
|
||||
}
|
||||
|
||||
// The material each generic Bambu filament ID stands for. Used to sanity-check
|
||||
// a generic id-match against the material the caller already knows (#2710): a
|
||||
// PETG spool must never claim GFL99 (generic PLA) profiles just because both
|
||||
// sides happen to have stored the same generic id.
|
||||
const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
|
||||
GFB99: 'ABS',
|
||||
GFC99: 'PC',
|
||||
GFG99: 'PETG',
|
||||
GFL99: 'PLA',
|
||||
GFN99: 'PA',
|
||||
GFP99: 'PE',
|
||||
GFR99: 'EVA',
|
||||
GFS99: 'PVA',
|
||||
GFU99: 'TPU',
|
||||
};
|
||||
|
||||
// Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
|
||||
// ID isn't a known generic one.
|
||||
export function materialForGenericFilamentId(id: string | null | undefined): string {
|
||||
if (!id) return '';
|
||||
return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
|
||||
}
|
||||
|
||||
// Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
|
||||
// through this so the two spellings agree.
|
||||
function normaliseMaterial(material: string): string {
|
||||
const upper = material.trim().toUpperCase();
|
||||
return upper === 'NYLON' ? 'PA' : upper;
|
||||
}
|
||||
|
||||
// True when a generic filament ID may stand in for the given material — i.e.
|
||||
// the ID is generic and describes that same material.
|
||||
export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
|
||||
const generic = materialForGenericFilamentId(id);
|
||||
return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
|
||||
}
|
||||
|
||||
// Check if a calibration matches based on brand, material, and variant
|
||||
export function isMatchingCalibration(
|
||||
cal: { name?: string; filament_id?: string },
|
||||
@@ -399,8 +436,23 @@ export function isMatchingCalibration(
|
||||
// "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;
|
||||
if (spoolFid && calFid && spoolFid === calFid) {
|
||||
if (!isGenericFilamentId(calFid)) {
|
||||
return true;
|
||||
}
|
||||
// Both sides carry the same *generic* id (#2710). That still pins the
|
||||
// material, so the only thing left ambiguous is brand — a printer holds
|
||||
// one flat calibration table per generic id and users routinely name
|
||||
// those entries by colour ("Dark Brown", "Marble"), which no amount of
|
||||
// name parsing can tie back to a material. Accept the match when the
|
||||
// material agrees and the spool claims no brand of its own; a spool that
|
||||
// does name a brand keeps the stricter name-based path below so its
|
||||
// suggestions stay brand-specific.
|
||||
const brand = formData.brand.trim();
|
||||
const brandIsGeneric = !brand || brand.toUpperCase() === 'GENERIC';
|
||||
if (brandIsGeneric && genericFilamentIdMatchesMaterial(calFid, formData.material)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const profileName = cal.name || '';
|
||||
|
||||
Reference in New Issue
Block a user