Sfoglia il codice sorgente

Show resolved filament colour and AMS mapping on queue cards (#3184)

bgrr74 2 giorni fa
parent
commit
e7aed30c0c

+ 3 - 1
frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx

@@ -11,7 +11,9 @@ import React from 'react';
 import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
 import type { AMSUnit, AMSTray } from '../../../api/client';
 
-vi.mock('../../../utils/amsHelpers', () => ({
+vi.mock('../../../utils/amsHelpers', async (importOriginal) => ({
+  // Keep the real getEmptySlotKind: the "Empty" vs "?" cases below test it.
+  ...(await importOriginal<typeof import('../../../utils/amsHelpers')>()),
   getFillBarColor: (fill: number) => {
     if (fill > 50) return '#00ae42';
     if (fill >= 15) return '#f59e0b';

+ 685 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -197,6 +197,691 @@ describe('QueuePage', () => {
       });
     });
 
+    it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
+      const item = {
+        ...mockQueueItems[0],
+        id: 82,
+        printer_id: null,
+        target_model: 'P2S',
+        archive_id: null,
+        library_file_id: 13,
+        archive_name: null,
+        library_file_name: 'Override colour test',
+        plate_id: 1,
+        filament_used_grams: 23.66,
+        filament_type: 'PLA',
+        filament_color: '#7C4B00',
+        filament_overrides: [
+          {
+            slot_id: 1,
+            type: 'PLA',
+            color: '#C2BAA7FF',
+            color_name: 'Bone White',
+            force_color_match: false,
+          },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/13/plates', () =>
+          HttpResponse.json({
+            file_id: 13,
+            filename: 'override-colour.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3095,
+                filament_used_grams: 23.66,
+                filaments: [
+                  {
+                    slot_id: 1,
+                    type: 'PLA',
+                    color: '#7C4B00',
+                    used_grams: 23.7,
+                    used_meters: 7.93,
+                  },
+                ],
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      const colourName = await screen.findByText('Bone White');
+      const row = colourName.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
+        'title',
+        '#C2BAA7',
+      );
+    });
+
+    it('shows the resolved AMS slot and bound spool when a physical mapping is stored (#3132)', async () => {
+      const item = {
+        ...mockQueueItems[0],
+        id: 85,
+        printer_id: 1,
+        printer_name: 'Test Printer',
+        archive_id: null,
+        library_file_id: 16,
+        archive_name: null,
+        library_file_name: 'Resolved AMS mapping test',
+        plate_id: 1,
+        ams_mapping: [1],
+        filament_overrides: [
+          {
+            slot_id: 1,
+            type: 'PLA',
+            color: '#8E351BFF',
+            color_name: 'Caramel',
+            force_color_match: false,
+          },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/16/plates', () =>
+          HttpResponse.json({
+            file_id: 16,
+            filename: 'resolved-mapping.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3600,
+                filament_used_grams: 20,
+                filaments: [
+                  {
+                    slot_id: 1,
+                    type: 'PLA',
+                    color: '#7C4B00',
+                    used_grams: 20,
+                    used_meters: 6.7,
+                  },
+                ],
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({
+            id: 1,
+            name: 'Test Printer',
+            connected: true,
+            state: 'IDLE',
+            ams: [
+              {
+                id: 0,
+                // Regular AMS units expose four tray records. Keeping all four
+                // here matters because buildLoadedFilaments distinguishes a
+                // regular AMS from a single-slot AMS-HT by tray count.
+                tray: [
+                  { id: 0, tray_type: null },
+                  {
+                    id: 1,
+                    tray_type: 'PLA',
+                    tray_color: 'C2BAA7FF',
+                    tray_sub_brands: 'PLA Basic',
+                    tray_info_idx: 'GFA00',
+                    remain: 80,
+                  },
+                  { id: 2, tray_type: null },
+                  { id: 3, tray_type: null },
+                ],
+              },
+            ],
+            vt_tray: [],
+            nozzles: [],
+            ams_extruder_map: {},
+          }),
+        ),
+        http.get('/api/v1/printers/:id/inventory-remain', () =>
+          HttpResponse.json({
+            inventory_remain_g: { '1': 800 },
+            slot_materials: [
+              {
+                ams_id: 0,
+                tray_id: 1,
+                global_tray_id: 1,
+                material_key: 'PLA|basic|bone-white',
+                remaining_g: 800,
+                extruder: 0,
+                spool: {
+                  brand: 'eSUN',
+                  material: 'PLA',
+                  subtype: 'Basic',
+                  color_name: 'Bone White',
+                  rgba: 'C2BAA7FF',
+                },
+              },
+            ],
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Resolved AMS mapping test');
+      const row = name.closest('.group');
+      expect(row).not.toBeNull();
+
+      await waitFor(() => {
+        expect(
+          within(row as HTMLElement).getByText('A2 · eSUN PLA Basic · Bone White'),
+        ).toBeInTheDocument();
+      });
+      expect(within(row as HTMLElement).queryByText('Caramel')).not.toBeInTheDocument();
+    });
+
+    it('resolves a mapped tray on a second regular AMS unit (#3132)', async () => {
+      const item = {
+        ...mockQueueItems[0],
+        id: 86,
+        printer_id: 1,
+        printer_name: 'Test Printer',
+        archive_id: null,
+        library_file_id: 17,
+        archive_name: null,
+        library_file_name: 'Second AMS mapping test',
+        plate_id: 1,
+        ams_mapping: [5],
+        filament_overrides: null,
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/17/plates', () =>
+          HttpResponse.json({
+            file_id: 17,
+            filename: 'second-ams.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3600,
+                filament_used_grams: 20,
+                filaments: [
+                  {
+                    slot_id: 1,
+                    type: 'PLA',
+                    color: '#111111',
+                    used_grams: 20,
+                    used_meters: 6.7,
+                  },
+                ],
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({
+            id: 1,
+            name: 'Test Printer',
+            connected: true,
+            state: 'IDLE',
+            ams: [
+              {
+                id: 1,
+                tray: [
+                  { id: 0, tray_type: null },
+                  {
+                    id: 1,
+                    tray_type: 'PLA',
+                    tray_color: 'C2BAA7FF',
+                    tray_sub_brands: 'PLA Basic',
+                    tray_info_idx: 'GFA00',
+                    remain: 80,
+                  },
+                  { id: 2, tray_type: null },
+                  { id: 3, tray_type: null },
+                ],
+              },
+            ],
+            vt_tray: [],
+            nozzles: [],
+            ams_extruder_map: {},
+          }),
+        ),
+        http.get('/api/v1/printers/:id/inventory-remain', () =>
+          HttpResponse.json({
+            inventory_remain_g: { '5': 800 },
+            slot_materials: [
+              {
+                ams_id: 1,
+                tray_id: 1,
+                global_tray_id: 5,
+                material_key: 'PLA|basic|bone-white',
+                remaining_g: 800,
+                extruder: 0,
+                spool: {
+                  brand: 'eSUN',
+                  material: 'PLA',
+                  subtype: 'Basic',
+                  color_name: 'Bone White',
+                  rgba: 'C2BAA7FF',
+                },
+              },
+            ],
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Second AMS mapping test');
+      const row = name.closest('.group');
+      expect(row).not.toBeNull();
+
+      await waitFor(() => {
+        expect(
+          within(row as HTMLElement).getByText('B2 · eSUN PLA Basic · Bone White'),
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('falls back to the selected plate 3MF colour when there is no override (#3132)', async () => {
+      const item = {
+        ...mockQueueItems[0],
+        id: 83,
+        printer_id: null,
+        target_model: 'P2S',
+        archive_id: null,
+        library_file_id: 14,
+        archive_name: null,
+        library_file_name: 'Original colour test',
+        plate_id: 1,
+        filament_color: '#FFFFFF',
+        filament_overrides: null,
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/14/plates', () =>
+          HttpResponse.json({
+            file_id: 14,
+            filename: 'original-colour.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3600,
+                filament_used_grams: 20,
+                filaments: [
+                  {
+                    slot_id: 1,
+                    type: 'PLA',
+                    color: '#7C4B00',
+                    used_grams: 20,
+                    used_meters: 6.7,
+                  },
+                ],
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      await screen.findByText('Original colour test');
+      const row = screen.getByText('Original colour test').closest('.group');
+
+      expect(row).not.toBeNull();
+      await waitFor(() => {
+        expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
+          'title',
+          '#7C4B00',
+        );
+      });
+    });
+
+    it('shows all used plate colours and only overrides the matching slot (#3132)', async () => {
+      const item = {
+        ...mockQueueItems[0],
+        id: 84,
+        printer_id: null,
+        target_model: 'P2S',
+        archive_id: null,
+        library_file_id: 15,
+        archive_name: null,
+        library_file_name: 'Multi colour test',
+        plate_id: 1,
+        filament_overrides: [
+          {
+            slot_id: 2,
+            type: 'PLA',
+            color: '#C2BAA7FF',
+            color_name: 'Bone White',
+            force_color_match: false,
+          },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/15/plates', () =>
+          HttpResponse.json({
+            file_id: 15,
+            filename: 'multi-colour.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3600,
+                filament_used_grams: 30,
+                filaments: [
+                  {
+                    slot_id: 1,
+                    type: 'PLA',
+                    color: '#000000',
+                    used_grams: 20,
+                    used_meters: 6.7,
+                  },
+                  {
+                    slot_id: 2,
+                    type: 'PLA',
+                    color: '#7C4B00',
+                    used_grams: 10,
+                    used_meters: 3.35,
+                  },
+                ],
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      const overrideName = await screen.findByText('Bone White');
+      const row = overrideName.closest('.group');
+
+      expect(row).not.toBeNull();
+      await waitFor(() => {
+        const swatches = within(row as HTMLElement).getAllByTestId('filament-swatch');
+        expect(swatches).toHaveLength(2);
+        expect(swatches.map((swatch) => swatch.getAttribute('title'))).toEqual([
+          '#000000',
+          '#C2BAA7',
+        ]);
+      });
+    });
+
+    it('keeps an eight-colour job compact and exposes details in the tooltip (#3132)', async () => {
+      const colours = [
+        '#FF0000',
+        '#00FF00',
+        '#0000FF',
+        '#FFFF00',
+        '#FF00FF',
+        '#00FFFF',
+        '#FFFFFF',
+        '#000000',
+      ];
+      const item = {
+        ...mockQueueItems[0],
+        id: 87,
+        printer_id: null,
+        target_model: 'P2S',
+        archive_id: null,
+        library_file_id: 18,
+        archive_name: null,
+        library_file_name: 'Eight colour test',
+        plate_id: 1,
+        filament_overrides: colours.map((color, index) => ({
+          slot_id: index + 1,
+          type: 'PLA',
+          color,
+          color_name: `Colour ${index + 1}`,
+          force_color_match: false,
+        })),
+      };
+
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+        http.get('/api/v1/library/files/18/plates', () =>
+          HttpResponse.json({
+            file_id: 18,
+            filename: 'eight-colour.3mf',
+            plates: [
+              {
+                index: 1,
+                name: 'Plate 1',
+                objects: ['Part'],
+                has_thumbnail: false,
+                thumbnail_url: null,
+                print_time_seconds: 3600,
+                filament_used_grams: 40,
+                filaments: colours.map((color, index) => ({
+                  slot_id: index + 1,
+                  type: 'PLA',
+                  color,
+                  used_grams: 5,
+                  used_meters: 1.7,
+                })),
+              },
+            ],
+            is_multi_plate: false,
+          }),
+        ),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Eight colour test');
+      const row = name.closest('.group');
+      expect(row).not.toBeNull();
+
+      await waitFor(() => {
+        const compact = within(row as HTMLElement).getByTestId('queue-filament-compact');
+        expect(compact).toHaveAttribute(
+          'title',
+          colours.map((_, index) => `Colour ${index + 1}`).join('\n'),
+        );
+        expect(within(compact).getAllByTestId('filament-swatch')).toHaveLength(8);
+      });
+      // Touch screens never show the tooltip; the same lines are the group's
+      // accessible name.
+      expect(screen.getByRole('img', { name: /^Colour 1, Colour 2, .*Colour 8$/ })).toBe(
+        within(row as HTMLElement).getByTestId('queue-filament-compact'),
+      );
+
+      // Compact mode keeps the long labels out of the metadata row itself.
+      expect(within(row as HTMLElement).queryByText('Colour 1')).not.toBeInTheDocument();
+      expect(within(row as HTMLElement).queryByText('Colour 8')).not.toBeInTheDocument();
+    });
+
+    describe('stored mapping on the live printer (#3132)', () => {
+      const mappedItem = (amsMapping: number[]) => ({
+        ...mockQueueItems[0],
+        id: 88,
+        printer_id: 1,
+        printer_name: 'Test Printer',
+        archive_id: null,
+        library_file_id: 19,
+        archive_name: null,
+        library_file_name: 'Mapped slot test',
+        plate_id: 1,
+        ams_mapping: amsMapping,
+        filament_overrides: [
+          {
+            slot_id: 1,
+            type: 'PLA',
+            color: '#8E351BFF',
+            color_name: 'Caramel',
+            force_color_match: false,
+          },
+        ],
+      });
+
+      const useMappedPrinter = (amsMapping: number[], status: Record<string, unknown>) => {
+        server.use(
+          http.get('/api/v1/queue/', () => HttpResponse.json([mappedItem(amsMapping)])),
+          http.get('/api/v1/library/files/19/plates', () =>
+            HttpResponse.json({
+              file_id: 19,
+              filename: 'mapped-slot.3mf',
+              plates: [
+                {
+                  index: 1,
+                  name: 'Plate 1',
+                  objects: ['Part'],
+                  has_thumbnail: false,
+                  thumbnail_url: null,
+                  print_time_seconds: 3600,
+                  filament_used_grams: 20,
+                  filaments: [
+                    { slot_id: 1, type: 'PLA', color: '#7C4B00', used_grams: 20, used_meters: 6.7 },
+                  ],
+                },
+              ],
+              is_multi_plate: false,
+            }),
+          ),
+          http.get('/api/v1/printers/:id/status', () =>
+            HttpResponse.json({
+              id: 1,
+              name: 'Test Printer',
+              state: 'IDLE',
+              nozzles: [],
+              ams_extruder_map: {},
+              ...status,
+            }),
+          ),
+          http.get('/api/v1/printers/:id/inventory-remain', () =>
+            HttpResponse.json({ inventory_remain_g: {}, slot_materials: [] }),
+          ),
+        );
+      };
+
+      // Regular AMS units expose four tray records; slot A3 (global 2) is empty.
+      const amsWithEmptyA3 = [
+        {
+          id: 0,
+          tray: [
+            { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
+            { id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
+            // Firmware confirms A3 has no spool (tray_exist_bits bit clear).
+            { id: 2, tray_type: null, exists: false, state: 9 },
+            { id: 3, tray_type: null },
+          ],
+        },
+      ];
+
+      const mappedRow = async () => {
+        render(<QueuePage />);
+        const name = await screen.findByText('Mapped slot test');
+        const row = name.closest('.group');
+        expect(row).not.toBeNull();
+        return row as HTMLElement;
+      };
+
+      it('resolves a mapping to the external spool', async () => {
+        useMappedPrinter([254], {
+          connected: true,
+          ams: [],
+          vt_tray: [{ id: 254, tray_type: 'PETG', tray_color: 'C2BAA7FF', remain: 60 }],
+        });
+        const row = await mappedRow();
+
+        await waitFor(() => {
+          expect(within(row).getByText(/^External · PETG · /)).toBeInTheDocument();
+        });
+        expect(within(row).queryByText('Caramel')).not.toBeInTheDocument();
+      });
+
+      it('says the mapped slot is empty, keeping the intended colour', async () => {
+        useMappedPrinter([2], { connected: true, ams: amsWithEmptyA3, vt_tray: [] });
+        const row = await mappedRow();
+
+        await waitFor(() => {
+          expect(within(row).getByText('A3 · Empty · Caramel')).toBeInTheDocument();
+        });
+        expect(within(row).getByTestId('filament-swatch')).toHaveAttribute('title', '#8E351B');
+      });
+
+      it('never calls the external spool empty, having no presence signal', async () => {
+        // Shape the status route really sends: an external tray carries no
+        // state / exists, so an unconfigured spool and no spool look the same.
+        let statusServed = false;
+        useMappedPrinter([254], { connected: true, ams: [], vt_tray: [] });
+        server.use(
+          http.get('/api/v1/printers/:id/status', () => {
+            statusServed = true;
+            return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams: [], vt_tray: [{ id: 254, tray_type: '' }], nozzles: [], ams_extruder_map: {} });
+          }),
+        );
+        const row = await mappedRow();
+
+        await waitFor(() => expect(statusServed).toBe(true));
+        expect(await within(row).findByText('Caramel')).toBeInTheDocument();
+        expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
+      });
+
+      it('does not call a loaded but unconfigured spool empty (#2527)', async () => {
+        // A non-RFID spool the firmware can't identify has no tray_type but is
+        // physically present; the Printers page draws it as "?", not "Empty".
+        const ams = [
+          {
+            id: 0,
+            tray: [
+              { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
+              { id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
+              { id: 2, tray_type: '', exists: true, state: 3 },
+              { id: 3, tray_type: null },
+            ],
+          },
+        ];
+        let statusServed = false;
+        useMappedPrinter([2], { connected: true, ams, vt_tray: [] });
+        server.use(
+          http.get('/api/v1/printers/:id/status', () => {
+            statusServed = true;
+            return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams, vt_tray: [], nozzles: [], ams_extruder_map: {} });
+          }),
+        );
+        const row = await mappedRow();
+
+        await waitFor(() => expect(statusServed).toBe(true));
+        expect(await within(row).findByText('Caramel')).toBeInTheDocument();
+        expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
+      });
+
+      it('does not call a slot empty while the printer is offline', async () => {
+        useMappedPrinter([2], { connected: false, ams: amsWithEmptyA3, vt_tray: [] });
+        const row = await mappedRow();
+
+        expect(await within(row).findByText('Caramel')).toBeInTheDocument();
+        expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
+      });
+    });
+
     it('shows one if-started-now ETA for an eligible pending item', async () => {
       // Printer 1 is free: nothing is printing on it and nothing is queued ahead.
       server.use(

+ 1 - 14
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -1,6 +1,6 @@
 import { useTranslation } from 'react-i18next';
 import type { AMSUnit, AMSTray } from '../../api/client';
-import { getFillBarColor } from '../../utils/amsHelpers';
+import { getEmptySlotKind, getFillBarColor } from '../../utils/amsHelpers';
 
 function trayColorToCSS(color: string | null): string {
   if (!color) return '#808080';
@@ -11,19 +11,6 @@ function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
 }
 
-// Mirror of PrintersPage.getEmptySlotKind (#1694, #2527): 'physical' when
-// firmware confirms no spool, 'reset' when a spool is present but has no
-// tray_type (= loaded, slot just unconfigured — e.g. a non-RFID spool).
-// tray_exist_bits (exists) is authoritative when present; otherwise fall back
-// to the state=9/10 heuristic.
-function getEmptySlotKind(tray: AMSTray): 'physical' | 'reset' | null {
-  if (tray.tray_type) return null;
-  if (tray.exists === true) return 'reset';
-  if (tray.exists === false) return 'physical';
-  const state = tray.state ?? null;
-  return state === 9 || state === 10 ? 'physical' : 'reset';
-}
-
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
   if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;

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

@@ -195,7 +195,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -1074,32 +1074,6 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
 
 
 
-/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
- *
- *  "physical" — firmware positively confirmed no spool (state 9 or 10). The
- *  bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
- *  every empty-by-bitmask slot lands here regardless of firmware payload
- *  shape.
- *
- *  "reset" — tray_type is missing/empty but firmware hasn't confirmed
- *  emptiness (state is null, 3, or any non-9/10 value). Typically a slot
- *  the user cleared with "Reset Slot" where a physical spool may still be
- *  loaded but unassigned.
- *
- *  Returns null when the slot is loaded (tray_type is present).
- */
-function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
-  if (tray?.tray_type) return null;
-  // tray_exist_bits is firmware's authoritative presence signal: a non-RFID
-  // spool the firmware can't identify is physically present (exists === true)
-  // but carries no tray_type, so it must read as "?" (loaded, unconfigured),
-  // never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
-  // the state=9/10 heuristic when the bitmask was unavailable (exists == null).
-  if (tray?.exists === true) return 'reset';
-  if (tray?.exists === false) return 'physical';
-  return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
-}
-
 // How long to wait for an AMS to report a live drying cycle after the printer
 // acked the start command (#2533). Firmware moves to DryStatus 1 (Checking)
 // within a couple of seconds; 30s covers the slowest observed push cadence

+ 245 - 4
frontend/src/pages/QueuePage.tsx

@@ -68,8 +68,12 @@ import { api, ApiError } from '../api/client';
 import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
-import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
+import { getColorName } from '../utils/colors';
+import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
+import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
+import type { PlateMetadata } from '../types/plates';
 import { Card } from '../components/Card';
+import { FilamentSwatch } from '../components/FilamentSwatch';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
@@ -80,6 +84,154 @@ import { CompactHistoryRow } from '../components/CompactHistoryRow';
 import { QueueTimelineView } from '../components/QueueTimelineView';
 import { compareQueueOrder, compareQueueOrderAcrossLanes } from '../utils/queueOrder';
 import { BatchOrdersView } from '../components/BatchOrdersView';
+import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
+
+type QueueFilamentDisplay = {
+  slotId: number;
+  type: string;
+  color: string;
+  colorName: string;
+  slotLabel?: string;
+  spoolName?: string;
+  extraColors?: string;
+  effectType?: string;
+  subtype?: string;
+  /** The stored mapping names a slot the printer reports as empty. */
+  emptySlot?: boolean;
+};
+
+/**
+ * Slot label for a mapped tray the printer positively reports as empty.
+ *
+ * The scheduler dispatches a stored mapping as-is, so a spool unloaded after
+ * queueing leaves the job pointed at an empty slot. Returns undefined when the
+ * status doesn't describe that slot (offline, still loading, unit removed) or
+ * when the slot may still hold an unconfigured spool (a non-RFID spool has no
+ * tray_type either, #2527), so the card only warns on a positive finding.
+ */
+function emptyMappedSlotLabel(status: PrinterStatus | undefined, trayId: number): string | undefined {
+  // The external holder reports no presence signal (the status route sends
+  // vt_tray without state / exists), so its emptiness can't be confirmed.
+  if (!status?.connected || trayId >= 254) return undefined;
+  const isHt = trayId >= 128;
+  const amsId = isHt ? trayId : Math.floor(trayId / 4);
+  const slot = isHt ? 0 : trayId % 4;
+  const unit = status.ams?.find((ams) => ams.id === amsId);
+  const tray = isHt ? unit?.tray[0] : unit?.tray.find((candidate) => candidate.id === slot);
+  if (getEmptySlotKind(tray) !== 'physical') return undefined;
+  return formatSlotLabel(amsId, slot, isHt, false);
+}
+
+function queueFilamentLabel(filament: QueueFilamentDisplay): string {
+  return filament.slotLabel
+    ? [
+        filament.slotLabel,
+        filament.spoolName || filament.type,
+        filament.colorName,
+      ].filter(Boolean).join(' · ')
+    : filament.colorName;
+}
+
+/**
+ * Resolve the filament colours a queued job is actually configured to use.
+ *
+ * Plate metadata is the source of truth for which 3MF slots the selected plate
+ * consumes. A queue-level override replaces only its matching slot, so a
+ * multi-colour job can mix original 3MF colours and user-selected overrides
+ * without losing either (#3132).
+ */
+function resolveQueueFilaments(
+  item: PrintQueueItem,
+  plates: PlateMetadata[],
+  loadedFilaments: LoadedFilament[] = [],
+  status?: PrinterStatus,
+  emptyLabel = 'Empty',
+): QueueFilamentDisplay[] {
+  // A stored AMS mapping names the physical tray that will actually feed a 3MF
+  // slot. Once its live tray data is available, that is more specific than
+  // either the queue override or the original slice. When the printer reports
+  // that tray as empty, keep the intended colour but say the slot is empty.
+  const resolveSlot = (planned: QueueFilamentDisplay): QueueFilamentDisplay => {
+    const slotId = planned.slotId;
+    const mappedTrayId = slotId > 0 ? item.ams_mapping?.[slotId - 1] : undefined;
+    if (mappedTrayId == null || mappedTrayId < 0) return planned;
+    const loaded = loadedFilaments.find((filament) => filament.globalTrayId === mappedTrayId);
+    if (loaded) {
+      return {
+        slotId,
+        type: loaded.type,
+        color: loaded.color,
+        colorName: loaded.colorName,
+        slotLabel: loaded.label,
+        spoolName: loaded.spoolName,
+        extraColors: loaded.extraColors,
+        effectType: loaded.effectType,
+        subtype: loaded.spoolSubtype,
+      };
+    }
+    const emptySlotLabel = emptyMappedSlotLabel(status, mappedTrayId);
+    if (emptySlotLabel) {
+      return { ...planned, slotLabel: emptySlotLabel, spoolName: emptyLabel, emptySlot: true };
+    }
+    return planned;
+  };
+
+  const selectedPlate =
+    item.plate_id != null
+      ? plates.find((plate) => plate.index === item.plate_id)
+      : plates[0];
+
+  if (selectedPlate) {
+    const overrides = new Map(
+      (item.filament_overrides ?? []).map((override) => [override.slot_id, override]),
+    );
+
+    return selectedPlate.filaments
+      .filter((filament) => filament.used_in_plate !== false && filament.used_grams > 0)
+      .map((filament) => {
+        const override = overrides.get(filament.slot_id);
+        const color = override?.color ?? filament.color;
+        const type = override?.type ?? filament.type;
+
+        return resolveSlot({
+          slotId: filament.slot_id,
+          type,
+          color,
+          colorName: override?.color_name?.trim() || getColorName(color, type),
+        });
+      });
+  }
+
+  // The queue row can render before plate metadata arrives (or an old source
+  // may no longer expose it). Prefer a resolved physical tray when possible,
+  // then explicit queue overrides because they describe the user's intention.
+  if (item.filament_overrides?.length) {
+    return item.filament_overrides.map((override) =>
+      resolveSlot({
+        slotId: override.slot_id,
+        type: override.type,
+        color: override.color,
+        colorName:
+          override.color_name?.trim() ||
+          getColorName(override.color, override.type),
+      }),
+    );
+  }
+
+  // Last-resort compatibility fallback for older/simpler queue responses.
+  if (item.filament_color) {
+    return [
+      resolveSlot({
+        slotId: 1,
+        type: item.filament_type ?? '',
+        color: item.filament_color,
+        colorName: getColorName(item.filament_color, item.filament_type),
+      }),
+    ];
+  }
+
+  return [];
+}
 
 function formatWeight(g: number, useKg = false): string {
   if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
@@ -403,14 +555,46 @@ function SortableQueueItem({
   etaNow?: number;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
-  // Fetch printer status every 30 seconds while printing to monitor progress
+  const hasPhysicalAmsMapping =
+    item.printer_id != null && (item.ams_mapping?.some((trayId) => trayId >= 0) ?? false);
+
+  // Printing rows already need live status for progress. A queued item with a
+  // stored AMS mapping also needs it so the card can turn global tray ids into
+  // the actual slot / colour that will feed the print (#3132). React Query
+  // deduplicates rows sharing a printer.
   const { data: status } = useQuery({
     queryKey: ['printerStatus', item.printer_id],
     queryFn: () => api.getPrinterStatus(item.printer_id!),
-    refetchInterval: 30000,
-    enabled: item.printer_id != null && printerState === 'printing',
+    refetchInterval: printerState === 'printing' ? 30000 : false,
+    enabled: item.printer_id != null && (printerState === 'printing' || hasPhysicalAmsMapping),
   });
 
+  // Inventory identity is display-only: printer telemetry knows the tray colour
+  // and material but not that a third-party spool is e.g. "eSUN PLA Basic".
+  // Reuse the same payload / key as PrintModal so a mapped queue row names the
+  // physical spool consistently with the mapping picker.
+  const { data: inventoryRemain } = useQuery({
+    queryKey: ['printer-inventory-remain', item.printer_id],
+    queryFn: () => api.getInventoryRemain(item.printer_id!),
+    enabled: hasPhysicalAmsMapping,
+    staleTime: 30 * 1000,
+  });
+
+  const slotSpools = useMemo(() => {
+    const slots = inventoryRemain?.slot_materials;
+    if (!slots?.length) return undefined;
+    const map = new Map<number, SlotSpoolIdentity>();
+    slots.forEach((slot) => {
+      if (slot.spool) map.set(slot.global_tray_id, slot.spool);
+    });
+    return map.size > 0 ? map : undefined;
+  }, [inventoryRemain]);
+
+  const loadedFilaments = useMemo(
+    () => buildLoadedFilaments(status, slotSpools),
+    [status, slotSpools],
+  );
+
   // Determine if we're printing a library file
   const isLibraryFile = !!item.library_file_id && !item.archive_id;
   // Fetch archive plate details. Skip when the linked archive has been
@@ -432,6 +616,7 @@ function SortableQueueItem({
   // Combine plates data from either source
   const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
   const plates = platesData?.plates ?? [];
+  const queueFilaments = resolveQueueFilaments(item, plates, loadedFilaments, status, t('ams.empty'));
 
   const canReorder = hasPermission('queue:reorder');
   const {
@@ -657,6 +842,62 @@ function SortableQueueItem({
                 {formatWeight(item.filament_used_grams)}
               </span>
             )}
+            {queueFilaments.length > 2 ? (
+              <span
+                data-testid="queue-filament-compact"
+                className="flex items-center gap-0.5 flex-shrink-0"
+                title={queueFilaments.map(queueFilamentLabel).join('\n')}
+                // The tooltip needs a hover; touch screens and screen readers
+                // get the same lines through the accessible name instead.
+                role="img"
+                aria-label={queueFilaments.map(queueFilamentLabel).join(', ')}
+              >
+                {queueFilaments.map((filament) => (
+                  <FilamentSwatch
+                    key={`filament-${filament.slotId}`}
+                    rgba={filament.color}
+                    extraColors={filament.extraColors}
+                    effectType={filament.effectType}
+                    subtype={filament.subtype}
+                    className="w-2.5 h-2.5 sm:w-3 sm:h-3 pointer-events-none"
+                    effectSize="table"
+                  />
+                ))}
+                {queueFilaments.some((filament) => filament.emptySlot) && (
+                  <AlertCircle
+                    className="w-3 h-3 ml-0.5 text-yellow-700 dark:text-yellow-400"
+                    aria-hidden="true"
+                  />
+                )}
+              </span>
+            ) : (
+              queueFilaments.map((filament) => {
+                const mappedLabel = queueFilamentLabel(filament);
+                return (
+                  <span
+                    key={`filament-${filament.slotId}`}
+                    className="flex items-center gap-1 sm:gap-1.5 min-w-0"
+                    title={mappedLabel}
+                  >
+                    <FilamentSwatch
+                      rgba={filament.color}
+                      extraColors={filament.extraColors}
+                      effectType={filament.effectType}
+                      subtype={filament.subtype}
+                      className="w-3 h-3 sm:w-3.5 sm:h-3.5"
+                      effectSize="table"
+                    />
+                    <span
+                      className={`truncate max-w-[110px] sm:max-w-[260px]${
+                        filament.emptySlot ? ' text-yellow-700 dark:text-yellow-400' : ''
+                      }`}
+                    >
+                      {mappedLabel}
+                    </span>
+                  </span>
+                );
+              })
+            )}
             {(() => {
               // Build plate badge so the user knows which plate to mount before
               // walking to the printer (#1281). Hidden when the 3MF doesn't

+ 26 - 0
frontend/src/utils/amsHelpers.ts

@@ -326,6 +326,32 @@ export function findNearestSimilar<T>(
   return best;
 }
 
+/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
+ *
+ *  "physical" — firmware positively confirmed no spool (state 9 or 10). The
+ *  bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
+ *  every empty-by-bitmask slot lands here regardless of firmware payload
+ *  shape.
+ *
+ *  "reset" — tray_type is missing/empty but firmware hasn't confirmed
+ *  emptiness (state is null, 3, or any non-9/10 value). Typically a slot
+ *  the user cleared with "Reset Slot" where a physical spool may still be
+ *  loaded but unassigned.
+ *
+ *  Returns null when the slot is loaded (tray_type is present).
+ */
+export function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
+  if (tray?.tray_type) return null;
+  // tray_exist_bits is firmware's authoritative presence signal: a non-RFID
+  // spool the firmware can't identify is physically present (exists === true)
+  // but carries no tray_type, so it must read as "?" (loaded, unconfigured),
+  // never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
+  // the state=9/10 heuristic when the bitmask was unavailable (exists == null).
+  if (tray?.exists === true) return 'reset';
+  if (tray?.exists === false) return 'physical';
+  return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
+}
+
 /**
  * Format slot label for display in the UI.
  * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)