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

This commit is contained in:
maziggy
2026-09-30 16:28:15 +02:00
6 changed files with 961 additions and 46 deletions
@@ -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';
@@ -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,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
View File
@@ -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
View File
@@ -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
View File
@@ -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)