feat(printers): show plate name on card for multi-plate active prints (#881)

When two printers were running different plates of the same multi-plate
  3MF, the Printers page cards displayed the same file name on both and
  there was no way to tell them apart. The Queue view already had this
  information by cross-referencing the archive's plate list; the card
  didn't have the linkage.

  Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
  against `PrintArchive.subtask_id` — the bridge introduced in #972 for
  restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
  new shared `parse_plate_id` helper) on the status endpoint. The helper
  is also called from the WebSocket push path so plate transitions
  reflect within 100 ms instead of waiting 30 s for the next REST poll;
  the archive id itself stays REST-only since it's stable for the life
  of a print and shouldn't make the push path touch the DB.

  The card fetches plate metadata via the same `api.getArchivePlates()`
  call QueuePage uses — shared React Query cache keeps it cheap across
  polls — and renders the actual plate name (or a "Plate N" fallback)
  only when `is_multi_plate` is true. Single-plate prints stay clean.
  Falls back to the previous `plate_N.gcode` regex path when there's no
  archive linkage (e.g. prints started directly from the printer LCD).

  Tests cover the plate-id extraction across Bambu Studio path shapes
  (backend parse_plate_id, printer_state_to_dict wiring) and the label
  override precedence in formatPrintName (frontend).
This commit is contained in:
maziggy
2026-04-21 09:46:42 +02:00
parent 07ef042729
commit fa1c46d9a5
10 changed files with 327 additions and 136 deletions
@@ -0,0 +1,46 @@
/**
* Unit tests for the formatPrintName helper on PrintersPage.
*
* Regression coverage for the #881 follow-up: when the printer card has an
* archive-linked plate label (resolved from the backend's current_archive_id
* + the archive's is_multi_plate plate list), the label must take precedence
* over the gcode_file regex fallback, including for plate 1.
*/
import { describe, it, expect } from 'vitest';
import { formatPrintName } from '../../pages/PrintersPage';
// Minimal translator stub: returns the fallback with the plate number interpolated
// the same way i18next would. Keeps these tests independent of the i18n setup.
const t = (_key: string, fallback: string, opts?: Record<string, unknown>) =>
fallback.replace('{{number}}', String(opts?.number ?? ''));
describe('formatPrintName', () => {
it('returns the name unchanged when neither plate source is available', () => {
expect(formatPrintName('Benchy', null, t)).toBe('Benchy');
});
it('appends gcode-file plate number only when > 1 (single-plate noise guard)', () => {
// Plate 1 from gcode_file alone is ambiguous (could be a single-plate 3MF)
// so the legacy fallback path keeps it silent.
expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t)).toBe('Benchy');
expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t)).toBe('Benchy — Plate 2');
});
it('uses plateLabel verbatim when provided, overriding the gcode_file fallback', () => {
// plateLabel comes from the archive lookup and is already disambiguated
// (only set when is_multi_plate === true). It must show even for plate 1.
expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t, 'Plate 1')).toBe('Benchy — Plate 1');
expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, 'Small Parts')).toBe('Benchy — Small Parts');
});
it('returns empty string when name is missing, regardless of plate info', () => {
expect(formatPrintName(null, '/Metadata/plate_2.gcode', t)).toBe('');
expect(formatPrintName(null, null, t, 'Plate 3')).toBe('');
});
it('treats null/empty plateLabel as absent and falls through to gcode_file parsing', () => {
expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, null)).toBe('Benchy — Plate 2');
expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, '')).toBe('Benchy — Plate 2');
});
});
+2
View File
@@ -220,6 +220,8 @@ export interface PrinterStatus {
state: string | null;
current_print: string | null;
subtask_name: string | null;
current_archive_id: number | null;
current_plate_id: number | null;
gcode_file: string | null;
progress: number | null;
remaining_time: number | null;
+32 -4
View File
@@ -90,9 +90,20 @@ import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'
// Color names resolve via getColorName() which reads the backend color_catalog
// (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857.
// Extract plate number from gcode_file path and append to print name
function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
// Append a plate label to the print name. When `plateLabel` is provided (resolved
// by the caller from the linked archive's plate list — see #881 follow-up), it
// is used verbatim, including the explicit "Plate 1" case on multi-plate 3MFs.
// Falls back to parsing `plate_N.gcode` from the MQTT gcode_file path, and in
// that fallback we only show N > 1 because we can't tell from the path alone
// whether the 3MF is multi-plate.
export function formatPrintName(
name: string | null,
gcodeFile: string | null | undefined,
t: (key: string, fallback: string, opts?: Record<string, unknown>) => string,
plateLabel?: string | null,
): string {
if (!name) return '';
if (plateLabel) return `${name} — ${plateLabel}`;
if (!gcodeFile) return name;
const match = gcodeFile.match(/plate_(\d+)\.gcode/);
if (match && parseInt(match[1], 10) > 1) {
@@ -1528,6 +1539,23 @@ function PrinterCard({
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Fetch plate list for the archive linked to the active print (#881 follow-up).
// Only queried when there's a running print backed by an archive; shared
// React Query cache with the Queue / Archives pages keeps it cheap.
const activeArchiveId =
(status?.state === 'RUNNING' || status?.state === 'PAUSE') ? status?.current_archive_id ?? null : null;
const { data: activeArchivePlates } = useQuery({
queryKey: ['archive-plates', activeArchiveId],
queryFn: () => api.getArchivePlates(activeArchiveId!),
enabled: activeArchiveId != null,
staleTime: 5 * 60 * 1000,
});
const activePlateLabel = (() => {
if (!activeArchivePlates?.is_multi_plate || status?.current_plate_id == null) return null;
const plate = activeArchivePlates.plates.find(p => p.index === status.current_plate_id);
return plate?.name || t('printers.plateNumber', 'Plate {{number}}', { number: status.current_plate_id });
})();
// Fetch user-defined AMS friendly names from the database
const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({
queryKey: ['amsLabels', printer.id],
@@ -2718,7 +2746,7 @@ function PrinterCard({
{/* Cover Image */}
<CoverImage
url={(status.state === 'RUNNING' || status.state === 'PAUSE') ? status.cover_url : null}
printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t) || undefined) : undefined}
printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel) || undefined) : undefined}
/>
{/* Print Info */}
<div className="flex-1 min-w-0">
@@ -2729,7 +2757,7 @@ function PrinterCard({
{plateStatusPill}
</div>
<p className="text-white text-sm mb-2 truncate">
{formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t)}
{formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)}
</p>
<div className="flex items-center justify-between text-sm">
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">