Переглянути джерело

fix(scheduler): pin the force-color variant when selecting the AMS slot (#2650)

Follow-up to 0f203ce: force color match now picks the right AMS slot, not
just the right printer. The slot mapper cleared tray_info_idx when applying
an override, so on a printer holding two same-colour PLA spools of different
variants (Basic GFA00 / Matte GFA01 / Silk GFA06) it could map to the wrong
one. It now keeps the variant for force_color_match overrides (both the 3MF
and no-3MF fallback paths) so the matcher pins the matching tray, and falls
back to type+colour when that variant isn't loaded. A manual filament swap
(a preference override) still clears the idx so it matches the swapped-in
spool rather than the old one.

The printer-card queue-compatibility hint applies the same variant rule.
maziggy 1 місяць тому
батько
коміт
800c45536e

Різницю між файлами не показано, бо вона завелика
+ 0 - 1
CHANGELOG.md


+ 17 - 4
backend/app/services/print_scheduler.py

@@ -1388,9 +1388,18 @@ class PrintScheduler:
                         override = override_map[req["slot_id"]]
                         req["type"] = override["type"]
                         req["color"] = override["color"]
-                        # Clear tray_info_idx so matching uses type+color instead of
-                        # the original 3MF's tray_info_idx (which would match the old filament)
-                        req["tray_info_idx"] = ""
+                        # A manual/preference override SWAPS the slot's filament, so the
+                        # 3MF's original tray_info_idx now points at the old spool and must
+                        # be cleared — matching then falls back to type+colour. A
+                        # force_color_match override is not a swap: it carries the 3MF's
+                        # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
+                        # it here too, letting the matcher pin the correct variant slot on a
+                        # printer holding two same-colour spools of different variants (#2650).
+                        # If that variant isn't loaded the matcher falls back to type+colour,
+                        # so an eligible printer never fails to map.
+                        req["tray_info_idx"] = (
+                            override.get("tray_info_idx", "") if override.get("force_color_match") else ""
+                        )
                         logger.debug(
                             "Queue item %s: Override slot %d -> %s %s",
                             item.id,
@@ -1454,7 +1463,11 @@ class PrintScheduler:
                 "slot_id": o["slot_id"],
                 "type": o.get("type", ""),
                 "color": o.get("color", ""),
-                "tray_info_idx": "",
+                # These are all force_color_match overrides, so the idx (when the
+                # 3MF carried one) is the intended variant, not a stale swap —
+                # keep it so the matcher pins the right variant slot, falling back
+                # to type+colour when it isn't loaded (#2650).
+                "tray_info_idx": o.get("tray_info_idx", ""),
             }
             for o in force_overrides
         ]

+ 99 - 0
backend/tests/unit/test_scheduler_force_color_ams_fallback.py

@@ -124,6 +124,27 @@ class TestBuildOverrideDirectMapping:
         # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
         assert result == [0]
 
+    def test_direct_mapping_pins_variant_when_override_carries_idx(self, scheduler):
+        """#2650: the no-3MF fallback honours a force override's own tray_info_idx,
+        so two same-colour PLA variants map to the intended slot rather than the
+        first same-colour tray."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                        {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                    ],
+                }
+            ]
+        )
+        overrides = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
+        ]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
 
 class TestComputeAmsMappingFallback:
     """Integration tests for the force-color fallback inside
@@ -242,6 +263,84 @@ class TestComputeAmsMappingFallback:
 
         assert result is None
 
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_force_color_override_pins_the_matching_variant_slot(self, mock_pm, scheduler):
+        """#2650 slot selection: with two same-colour PLA spools of different
+        variants loaded, applying a force_color_match override must map to the
+        tray whose tray_info_idx matches the 3MF (Matte GFA01), not the first
+        same-colour tray (Basic GFA00)."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        item = self._make_item(
+            filament_overrides_json=(
+                '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", '
+                '"tray_info_idx": "GFA01", "force_color_match": true}]'
+            )
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
+            patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
+        ):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_preference_override_still_clears_idx_so_a_swap_matches_by_colour(self, mock_pm, scheduler):
+        """A non-force (preference) override is a filament SWAP: its slot must
+        match by the new type+colour, never by a stale 3MF variant that would pin
+        the old spool. Only force_color_match overrides keep their idx — anything
+        else is cleared, exactly as before #2650."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            # The 3MF's original variant (blue GFA01) and the swapped-to red.
+                            {"id": 0, "tray_type": "PLA", "tray_color": "0000FFFF", "tray_info_idx": "GFA01"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFA00"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        # 3MF wants blue GFA01; the user swaps this slot to red via a preference
+        # override that (defensively) also carries the stale GFA01 idx.
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA01"}]'
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#0000FF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
+            patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
+        ):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        # idx cleared → matches the swapped-to red spool (global 1), not the stale
+        # GFA01 blue (global 0) that a preserved idx would have pinned.
+        assert result == [1]
+
 
 class TestGetMissingForceColorSlotsVariant:
     """force_color_match must distinguish Bambu PLA variants that share a base

+ 41 - 1
frontend/src/__tests__/utils/printer.test.ts

@@ -8,7 +8,8 @@
  */
 
 import { describe, it, expect } from 'vitest';
-import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
+import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '../../utils/printer';
+import type { PrintQueueItem } from '../../api/client';
 
 describe('getPrinterImage', () => {
   describe('X2D (#988)', () => {
@@ -136,3 +137,42 @@ describe('isGcodeCompatible', () => {
     expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
   });
 });
+
+describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () => {
+  const makeItem = (
+    overrides: Array<{ slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match?: boolean }>,
+  ): PrintQueueItem => ({ id: 1, filament_overrides: overrides } as unknown as PrintQueueItem);
+
+  // A job sliced for White PLA Matte (GFA01).
+  const matteJob = makeItem([
+    { slot_id: 1, type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA01', force_color_match: true },
+  ]);
+  const loadedTypes = new Set(['PLA']);
+  const loaded = new Set(['PLA:ffffff']);
+
+  it('rejects a printer loaded only with other white PLA variants (Basic/Silk)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(0);
+  });
+
+  it('accepts a printer loaded with the matching variant (Matte GFA01)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA01']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('accepts a same-colour spool that reports no tray_info_idx (custom/third-party)', () => {
+    const variants = new Set(['PLA:ffffff:']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('falls back to type+colour when no variant data is supplied', () => {
+    // loadedVariants omitted → the hint is never stricter than the data it has.
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded)).toHaveLength(1);
+  });
+
+  it('an override without a tray_info_idx keeps the old type+colour behaviour', () => {
+    const noIdxJob = makeItem([{ slot_id: 1, type: 'PLA', color: '#FFFFFF', force_color_match: true }]);
+    const variants = new Set(['PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+});

+ 1 - 1
frontend/src/api/client.ts

@@ -2192,7 +2192,7 @@ export interface PrintQueueItem {
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
-  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
+  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   bed_levelling: CalibrationMode;

+ 3 - 2
frontend/src/components/PrinterQueueWidget.tsx

@@ -11,10 +11,11 @@ interface PrinterQueueWidgetProps {
   printerModel?: string | null;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
+  loadedVariants?: Set<string>;  // "TYPE:rrggbb:idx" triples for PLA sub-variant matching (#2650)
   variant?: 'card' | 'panelExtension';
 }
 
-export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, variant = 'card' }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, loadedVariants, variant = 'card' }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
@@ -23,7 +24,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
   });
 
   // Filter queue to items this printer can actually print (filament type + color check)
-  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
+  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments, loadedVariants) : undefined;
   const totalPending = compatibleQueue?.length || 0;
 
   if (totalPending === 0) {

+ 27 - 2
frontend/src/pages/PrintersPage.tsx

@@ -1990,6 +1990,30 @@ function PrinterCard({
     return filaments;
   }, [status?.ams, status?.vt_tray]);
 
+  // Collect loaded type+color+tray_info_idx triples for variant-aware force-color
+  // matching. Format: "TYPE:rrggbb:idx" (idx "" for custom/third-party spools) —
+  // distinguishes Bambu PLA sub-variants that share a base type+colour (#2650).
+  const loadedVariants = useMemo(() => {
+    const variants = new Set<string>();
+    if (status?.ams) {
+      for (const ams of status.ams) {
+        for (const tray of ams.tray || []) {
+          if (tray.tray_type && tray.tray_color) {
+            const color = tray.tray_color.replace('#', '').toLowerCase().slice(0, 6);
+            variants.add(`${tray.tray_type.toUpperCase()}:${color}:${tray.tray_info_idx || ''}`);
+          }
+        }
+      }
+    }
+    for (const vt of status?.vt_tray ?? []) {
+      if (vt.tray_type && vt.tray_color) {
+        const color = vt.tray_color.replace('#', '').toLowerCase().slice(0, 6);
+        variants.add(`${vt.tray_type.toUpperCase()}:${color}:${vt.tray_info_idx || ''}`);
+      }
+    }
+    return variants;
+  }, [status?.ams, status?.vt_tray]);
+
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   const { data: filamentInfo } = useQuery({
     queryKey: ['filamentInfo', trayInfoIds],
@@ -2163,8 +2187,8 @@ function PrinterCard({
   // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.
   const queueCount = useMemo(() => {
     if (!queueItems?.length) return 0;
-    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length;
-  }, [queueItems, loadedFilamentTypes, loadedFilaments]);
+    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants).length;
+  }, [queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants]);
 
   // Fetch currently printing queue item to show who started it (Issue #206)
   const { data: printingQueueItems } = useQuery({
@@ -3740,6 +3764,7 @@ function PrinterCard({
                         printerModel={printer.model}
                         loadedFilamentTypes={loadedFilamentTypes}
                         loadedFilaments={loadedFilaments}
+                        loadedVariants={loadedVariants}
                         variant="panelExtension"
                       />
                     </div>

+ 18 - 3
frontend/src/utils/printer.ts

@@ -56,12 +56,19 @@ import type { PrintQueueItem } from '../api/client';
  * @param items - Array of queue items to filter
  * @param loadedFilamentTypes - Set of loaded filament types (e.g., "PLA", "PETG")
  * @param loadedFilaments - Set of loaded filament type+color pairs (e.g., "PLA:ffffff", "PETG:ff0000")
+ * @param loadedVariants - Set of loaded type+color+tray_info_idx triples
+ *   (e.g., "PLA:ffffff:GFA01"; the idx is "" for custom/third-party spools). Used to
+ *   distinguish Bambu PLA sub-variants (Basic GFA00 / Matte GFA01 / Silk GFA06) that
+ *   share a base type+colour, mirroring the backend _get_missing_force_color_slots (#2650).
+ *   When omitted, force matching falls back to type+colour so the hint is never stricter
+ *   than the data available.
  * @returns Array of compatible queue items
  */
 export function filterCompatibleQueueItems(
   items: PrintQueueItem[],
   loadedFilamentTypes?: Set<string>,
-  loadedFilaments?: Set<string>
+  loadedFilaments?: Set<string>,
+  loadedVariants?: Set<string>
 ): PrintQueueItem[] {
   return items.filter(item => {
     // Type check: all required filament types must be loaded
@@ -78,12 +85,20 @@ export function filterCompatibleQueueItems(
       const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);
       const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);
 
-      // All force-matched slots must have exact type+color on this printer
+      // All force-matched slots must have an exact type+color match — and, when the
+      // override carries a tray_info_idx, the same variant too (a loaded tray with a
+      // blank idx still satisfies it, matching the backend's type+colour fallback).
       if (forceOverrides.length > 0) {
         const allForceMatch = forceOverrides.every(o => {
           const oType = (o.type || '').toUpperCase();
           const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
-          return loadedFilaments.has(`${oType}:${oColor}`);
+          const oIdx = o.tray_info_idx || '';
+          // No variant on the override, or no variant data supplied → type+colour only.
+          if (!oIdx || loadedVariants === undefined) {
+            return loadedFilaments.has(`${oType}:${oColor}`);
+          }
+          // Variant-specific: same idx, or a same-colour tray that reports no idx.
+          return loadedVariants.has(`${oType}:${oColor}:${oIdx}`) || loadedVariants.has(`${oType}:${oColor}:`);
         });
         if (!allForceMatch) return false;
       }

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-Bx0t8q7D.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-ByvLwk1T.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bx0t8q7D.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
   </head>
   <body>

Деякі файли не було показано, через те що забагато файлів було змінено