From e7aed30c0cca296808c7bb5f95b5f8839de53fb6 Mon Sep 17 00:00:00 2001 From: bgrr74 <48893544+bgrr74@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:35:56 +0200 Subject: [PATCH] Show resolved filament colour and AMS mapping on queue cards (#3184) --- .../spoolbuddy/AmsUnitCard.test.tsx | 4 +- .../src/__tests__/pages/QueuePage.test.tsx | 685 ++++++++++++++++++ .../src/components/spoolbuddy/AmsUnitCard.tsx | 15 +- frontend/src/pages/PrintersPage.tsx | 28 +- frontend/src/pages/QueuePage.tsx | 249 ++++++- frontend/src/utils/amsHelpers.ts | 26 + 6 files changed, 961 insertions(+), 46 deletions(-) diff --git a/frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx b/frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx index 4ad786f3d..f21c5d6b9 100644 --- a/frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx +++ b/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()), getFillBarColor: (fill: number) => { if (fill > 50) return '#00ae42'; if (fill >= 15) return '#f59e0b'; diff --git a/frontend/src/__tests__/pages/QueuePage.test.tsx b/frontend/src/__tests__/pages/QueuePage.test.tsx index f0f3d2130..59f0c1956 100644 --- a/frontend/src/__tests__/pages/QueuePage.test.tsx +++ b/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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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) => { + 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(); + 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( diff --git a/frontend/src/components/spoolbuddy/AmsUnitCard.tsx b/frontend/src/components/spoolbuddy/AmsUnitCard.tsx index 3d5c939fd..070ac7580 100644 --- a/frontend/src/components/spoolbuddy/AmsUnitCard.tsx +++ b/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)}`; diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 31bad1b42..e60da51c2 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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 diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index e48efca9e..085ba17b5 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/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; }) { - // 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(); + 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)} )} + {queueFilaments.length > 2 ? ( + + {queueFilaments.map((filament) => ( + + ))} + {queueFilaments.some((filament) => filament.emptySlot) && ( + + ) : ( + queueFilaments.map((filament) => { + const mappedLabel = queueFilamentLabel(filament); + return ( + + + + {mappedLabel} + + + ); + }) + )} {(() => { // Build plate badge so the user knows which plate to mount before // walking to the printer (#1281). Hidden when the 3MF doesn't diff --git a/frontend/src/utils/amsHelpers.ts b/frontend/src/utils/amsHelpers.ts index faedc3af0..3f242f1d1 100644 --- a/frontend/src/utils/amsHelpers.ts +++ b/frontend/src/utils/amsHelpers.ts @@ -326,6 +326,32 @@ export function findNearestSimilar( 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)