Merge branch 'dev' into feature/save-ams-mapping-toggle

This commit is contained in:
MartinNYHC
2026-07-31 15:39:13 +02:00
committed by GitHub
63 changed files with 4647 additions and 493 deletions
@@ -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>
+4 -2
View File
@@ -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 ${
+43 -5
View File
@@ -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) => {
+57 -5
View File
@@ -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 || '';