fix(queue): enforce sliced-model compatibility on cross-model dispatch (#2578)

A queue item's "Any <model>" button labeled itself from the file's slice
metadata while the scheduler used the row's target_model, so an X1C-sliced
item targeting H2D showed "Any X1C" above "assign to first idle H2D". The
mismatch itself was created silently: sliced-for metadata loads async, and
switching to model mode before it arrived pre-selected the alphabetically
first model (H2D on a mixed farm), after which the model dropdown hid
itself. Nothing validated compatibility, so the scheduler would hand X1C
G-code to an H2D.

Frontend: never default the target silently, keep the dropdown visible in
model mode (incompatible models disabled), label from the actual target,
warn on mismatch, block submit when incompatible.

Backend: new GCODE_COMPAT_FAMILIES table (X1/X1C/X1E/P1P/P1S interchange;
everything else exact-match; missing metadata never blocks). Queue create
and update reject incompatible targets with 400; the scheduler holds back
pre-existing mismatched rows with an actionable waiting_reason instead of
dispatching them.
This commit is contained in:
maziggy
2026-07-17 06:51:51 +02:00
parent a6e7d671f2
commit e97413edc7
12 changed files with 729 additions and 174 deletions
+40 -1
View File
@@ -8,7 +8,7 @@
*/
import { describe, it, expect } from 'vitest';
import { getPrinterImage } from '../../utils/printer';
import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
describe('getPrinterImage', () => {
describe('X2D (#988)', () => {
@@ -97,3 +97,42 @@ describe('getPrinterImage', () => {
});
});
});
// Mirrors backend/tests/unit/test_scheduler_model_mismatch.py — the frontend
// table must stay in sync with backend GCODE_COMPAT_FAMILIES (#2578).
describe('isGcodeCompatible', () => {
it('accepts same model', () => {
expect(isGcodeCompatible('X1C', 'X1C')).toBe(true);
expect(isGcodeCompatible('H2D', 'H2D')).toBe(true);
});
it('accepts the X1/P1 interchange family', () => {
expect(isGcodeCompatible('X1C', 'P1S')).toBe(true);
expect(isGcodeCompatible('X1C', 'P1P')).toBe(true);
expect(isGcodeCompatible('P1S', 'X1E')).toBe(true);
expect(isGcodeCompatible('X1', 'P1P')).toBe(true);
});
it('rejects cross-family targets', () => {
// The reporter's case: X1C-sliced G-code targeted at an H2D (#2578)
expect(isGcodeCompatible('X1C', 'H2D')).toBe(false);
expect(isGcodeCompatible('P1S', 'H2D')).toBe(false);
expect(isGcodeCompatible('X1C', 'A1')).toBe(false);
expect(isGcodeCompatible('A1', 'A1 Mini')).toBe(false);
expect(isGcodeCompatible('H2D', 'H2S')).toBe(false);
expect(isGcodeCompatible('X1C', 'P2S')).toBe(false);
});
it('is fail-safe on unknown metadata', () => {
expect(isGcodeCompatible(null, 'H2D')).toBe(true);
expect(isGcodeCompatible('X1C', null)).toBe(true);
expect(isGcodeCompatible(undefined, undefined)).toBe(true);
expect(isGcodeCompatible('', 'H2D')).toBe(true);
});
it('normalizes case, spaces and dashes', () => {
expect(isGcodeCompatible('x1c', 'X1C')).toBe(true);
expect(isGcodeCompatible('A1 Mini', 'A1-MINI')).toBe(true);
expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
});
});
@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { api, type PrinterStatus } from '../../api/client';
import { getColorName } from '../../utils/colors';
import { isGcodeCompatible } from '../../utils/printer';
import {
normalizeColorForCompare,
colorsAreSimilar,
@@ -409,10 +410,15 @@ export function PrinterSelector({
onClick={() => {
onAssignmentModeChange!('model');
onMultiSelect([]);
// Pre-select the sliced-for model if available, otherwise first model
// Pre-select the sliced-for model when it has printers. NEVER
// silently fall back to another model (#2578): uniqueModels is
// alphabetically sorted, so the old `uniqueModels[0]` default
// quietly targeted e.g. H2D for an X1C-sliced file whenever the
// metadata hadn't loaded yet. Leave it unset and let the user
// pick from the dropdown instead.
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
? slicedForModel
: uniqueModels[0];
: null;
onTargetModelChange!(defaultModel);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
@@ -422,7 +428,12 @@ export function PrinterSelector({
}`}
>
<Users className="w-4 h-4" />
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
{/* In model mode the label must reflect the ACTUAL scheduler
target, not the slice metadata — an edited queue item can
target a different model than the file was sliced for (#2578). */}
<span className="text-sm">
Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'}
</span>
</button>
</div>
)}
@@ -430,29 +441,58 @@ export function PrinterSelector({
{/* Model selection and location filter (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && (
<div className="space-y-3 mb-4">
{/* Model selector — only show when sliced model is unknown */}
{!slicedForModel && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Target Model</label>
<select
value={targetModel || ''}
onChange={(e) => {
onTargetModelChange!(e.target.value || null);
// Clear location when model changes
if (onTargetLocationChange) {
onTargetLocationChange(null);
}
}}
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 text-sm"
>
<option value="">Select a model...</option>
{uniqueModels.map((model) => (
<option key={model} value={model}>
{/* Model selector — always visible in model mode so a wrong target
on an existing queue item can be seen and fixed (#2578).
Incompatible models are disabled: G-code sliced for one model
must only go to that model or its interchange family. */}
<div>
<label className="block text-xs text-bambu-gray mb-1">
Target Model
{slicedForModel && <span className="ml-2 text-bambu-gray/70">(sliced for {slicedForModel})</span>}
</label>
<select
value={targetModel || ''}
onChange={(e) => {
onTargetModelChange!(e.target.value || null);
// Clear location when model changes
if (onTargetLocationChange) {
onTargetLocationChange(null);
}
}}
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 text-sm"
>
<option value="">Select a model...</option>
{uniqueModels.map((model) => {
const compatible = isGcodeCompatible(slicedForModel, model);
return (
<option key={model} value={model} disabled={!compatible}>
{model}
{!compatible ? ` — incompatible with ${slicedForModel} G-code` : ''}
</option>
))}
</select>
</div>
);
})}
</select>
</div>
{/* Cross-model state on an existing row: same file/target mismatch
the specific-printer path already warns about (#2578). */}
{slicedForModel && targetModel && targetModel !== slicedForModel && (
isGcodeCompatible(slicedForModel, targetModel) ? (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-700 dark:text-yellow-400">
File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
</span>
</div>
) : (
<div className="p-3 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<span className="text-sm text-red-700 dark:text-red-400">
File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers —
select a compatible model
</span>
</div>
)
)}
{/* Location filter (only show when target model is selected and locations exist) */}
@@ -17,6 +17,7 @@ import {
} from '../../hooks/useFilamentMapping';
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import { getColorName } from '../../utils/colors';
import { isGcodeCompatible } from '../../utils/printer';
import { getCurrencySymbol } from '../../utils/currency';
import { getBedTypeInfo } from '../../utils/bedType';
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -576,6 +577,17 @@ export function PrintModal({
}
}, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
// The sliced-for metadata loads async. If the user switched to model mode
// before it arrived, the target is still empty (we never silently default
// to another model, #2578) — fill it with the sliced-for model once known,
// provided an active printer of that model exists.
useEffect(() => {
if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
setTargetModel(slicedForModel);
}
}, [assignmentMode, targetModel, slicedForModel, printers]);
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
// Only applies once per printer on initial selection, not when user unchecks
useEffect(() => {
@@ -781,6 +793,12 @@ export function PrintModal({
showToast('Please select a target printer model', 'error');
return;
}
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
// gets inline feedback instead of a failed request.
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
return;
}
setIsSubmitting(true);
// Calculate total API calls: plates × printers (or 1 for model-based)
@@ -1070,6 +1088,8 @@ export function PrintModal({
// Need valid printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
if (assignmentMode === 'model' && !targetModel) return false;
// Cross-model mismatch cannot be queued (#2578)
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
// For multi-plate files, need at least one plate selected
if (isMultiPlate && selectedPlates.size === 0) return false;
@@ -1085,6 +1105,7 @@ export function PrintModal({
selectedPrinters.length,
assignmentMode,
targetModel,
slicedForModel,
isMultiPlate,
selectedPlates.size,
isPending,
+22
View File
@@ -18,6 +18,28 @@ export function getPrinterImage(model: string | null | undefined): string {
return '/img/printers/default.png';
}
// G-code interchange families (#2578). Mirrors backend GCODE_COMPAT_FAMILIES
// in backend/app/utils/printer_models.py — keep the two in sync. A sliced 3MF
// may target a different model ONLY within its family; everything else is
// exact-match only.
const GCODE_COMPAT_FAMILIES: ReadonlyArray<ReadonlySet<string>> = [
new Set(['X1', 'X1C', 'X1E', 'P1P', 'P1S']),
];
/** True when G-code sliced for one model may be dispatched to the other.
* Unknown/missing metadata on either side returns true (can't validate). */
export function isGcodeCompatible(
slicedForModel: string | null | undefined,
targetModel: string | null | undefined,
): boolean {
if (!slicedForModel || !targetModel) return true;
const norm = (m: string) => m.trim().toUpperCase().replace(/[\s-]/g, '');
const a = norm(slicedForModel);
const b = norm(targetModel);
if (a === b) return true;
return GCODE_COMPAT_FAMILIES.some((family) => family.has(a) && family.has(b));
}
export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };