feat(deficit): backup-aware filament deficit check, colour-strict (#1762)

When the printer reports ams_filament_backup=True,
  compute_deficit_for_queue_item pools remaining_grams across spools
  matching (preset, colour) on the same printer (scoped per extruder on
  dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
  same slicer_filament preset AND same colour (alpha-normalised). Two
  PETG HF spools in different colours are NOT pooled — the firmware would
  swap correctly but the print would change colour mid-run. Spoolman side
  mirrors the rule via filament.id + color_hex. Backup OFF falls back to
  the pre-PR per-slot accounting line-for-line.

  8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
  pool insufficient, different presets, backup-OFF regression, dual-
  extruder side scoping, no-preset never pairs, colour-strict, and
  alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
  cases stay green.

  feat(printers): AMS Filament Backup modal with BS-style ring per pair

  Badge click on the Filaments section header (#1766) now opens a
  modal: filament-colour ring per backup pair, material name + rotation
  count in the centre, slot labels distributed around the colour band on
  contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
  widget. Lone slots are intentionally not listed. R / L badges per ring
  when the extruder map carries two distinct values; collapses to no-
  badge rendering for single-nozzle printers misflagged as dual.

  Esc keypress closes the modal. Theme-aware via CSS variables matching
  AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
  defensively dedupes duplicate ams.id entries observed on switch-VP
  aggregations.

  10 modal render cases pin: Esc closes / unmount nulls the listener /
  ring renders for pairs and omits lone slots / R-L badges only when
  extruder map has distinct values / empty state / toggle gating.
  13 frontend cases pin computeBackupGroups identity rules.

  feat(printers): active-print P-N pill on AMS slot tiles during RUNNING

  While the printer is mid-print, each AMS slot tile referenced by
  status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
  right corner, naming which print-slot is mapped to that AMS slot.
  Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
  staged to a printer with mismatched filament, no way to verify mid-
  print. Same wire data (status.ams_mapping is already on the wire) —
  the addition is purely surface.

  The existing ring-bambu-green highlight for effectiveTrayNow keeps its
  meaning (currently extruding RIGHT NOW); the pill is the per-slot
  static assignment for the active print.

  chore(scheduler): log Print Anyway short-circuit at INFO

  _block_on_filament_deficit logs at INFO when it honours
  item.skip_filament_check, so a future "Print Anyway didn't work" report
  (third commenter on #1762 hit this shape) has actionable evidence in
  the standard support bundle without DEBUG. Bundled because the deficit
  fix makes the original symptom disappear for users with backup ON.
This commit is contained in:
maziggy
2026-06-22 10:00:02 +02:00
parent 4206d675eb
commit 9d74f9281b
25 changed files with 1965 additions and 178 deletions
+7
View File
@@ -143,6 +143,8 @@ const DE_COGNATES = [
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
@@ -171,6 +173,7 @@ const DE_COGNATES = [
// French cognates — many UI labels overlap with English exactly.
const FR_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
@@ -209,6 +212,7 @@ const FR_COGNATES = [
// Italian cognates.
const IT_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Email', // common loanword in Italian, used verbatim in UI labels
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
@@ -249,6 +253,7 @@ const JA_COGNATES = [
// Portuguese (BR) cognates.
const PT_BR_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -316,6 +321,7 @@ const KO_COGNATES = [
// Spanish cognates — words/phrases that are genuinely identical in Spanish.
const ES_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -335,6 +341,7 @@ const ES_COGNATES = [
const TR_COGNATES = [
'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
'N/A', 'email',
'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
@@ -0,0 +1,247 @@
/**
* Render tests for the AMS Filament Backup modal (#1762).
*
* Modal now renders one SVG ring per backup pair (BambuStudio Auto Refill
* style); lone slots are intentionally suppressed.
*/
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { AmsBackupModal } from '../../components/AmsBackupModal';
function makeAmsUnits() {
return [
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
],
},
{
id: 1,
tray: [
{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
],
},
];
}
describe('AmsBackupModal', () => {
it('returns null when isOpen=false', () => {
const { container } = render(
<AmsBackupModal
isOpen={false}
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(container.querySelector('[data-testid="ams-backup-modal"]')).toBeNull();
});
it('renders a backup ring for each pair and OMITS lone slots', async () => {
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
// PLA Basic — pair: rendered in centre of its ring
expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
// PETG HF — lone, must NOT appear (no longer listed)
expect(screen.queryByText('PETG HF')).not.toBeInTheDocument();
});
it('closes on Escape keypress while open', async () => {
const onClose = vi.fn();
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={onClose}
/>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does NOT fire onClose on Escape when closed (listener unmounts)', () => {
const onClose = vi.fn();
const { rerender } = render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={onClose}
/>,
);
rerender(
<AmsBackupModal
isOpen={false}
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={onClose}
/>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
it('toggle reflects the ON state and fires onToggle(false) when clicked', async () => {
const onToggle = vi.fn();
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={onToggle}
onClose={vi.fn()}
/>,
);
const toggle = await screen.findByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
expect(onToggle).toHaveBeenCalledWith(false);
});
it('toggle is disabled when state is unknown (A1 family)', async () => {
render(
<AmsBackupModal
isOpen
state={null}
amsUnits={[]}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
const toggle = await screen.findByRole('switch');
expect(toggle).toBeDisabled();
expect(screen.getByText(/Unsupported/i)).toBeInTheDocument();
});
it('toggle is disabled when the user lacks printers:control', async () => {
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle={false}
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
const toggle = await screen.findByRole('switch');
expect(toggle).toBeDisabled();
});
it('shows a no-pairs empty state when AMS has no backup pair', async () => {
render(
<AmsBackupModal
isOpen
state={true}
// Just one slot, can't form any pair.
amsUnits={[
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
]}
amsExtruderMap={undefined}
isDualNozzle={false}
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(await screen.findByText(/No backup pairs/i)).toBeInTheDocument();
});
it('on dual-extruder with distinct map values, renders R / L badges on each ring', async () => {
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={[
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
{ id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
{ id: 2, tray: [{ id: 0, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0FF', tray_info_idx: 'GFG99' }] },
{ id: 3, tray: [{ id: 0, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0FF', tray_info_idx: 'GFG99' }] },
]}
// AMS 0+1 on right (ex 0), AMS 2+3 on left (ex 1) → two pairs, one per side.
amsExtruderMap={{ '0': 0, '1': 0, '2': 1, '3': 1 }}
isDualNozzle
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
// Both rings should be present (PLA Basic and PETG HF in the centres).
expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
expect(screen.getByText('PETG HF')).toBeInTheDocument();
// Both extruder badges visible.
expect(screen.getByText('R')).toBeInTheDocument();
expect(screen.getByText('L')).toBeInTheDocument();
});
it('collapses to single section (no R/L badges) when isDualNozzle=true but map has one distinct value', async () => {
render(
<AmsBackupModal
isOpen
state={true}
amsUnits={makeAmsUnits()}
amsExtruderMap={{ '0': 0, '1': 0 }}
isDualNozzle
canToggle
pending={false}
onToggle={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(screen.queryByText('R')).not.toBeInTheDocument();
expect(screen.queryByText('L')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,223 @@
/**
* Tests for #1762 — `computeBackupGroups` strict identity rule.
*
* Slots pair ONLY when they share the same Bambu preset ID
* (`tray_info_idx`). User-tagged spools without a preset never pair.
* Empty slots are skipped; non-empty slots without a peer come back as
* 1-member entries so the modal can list them as "Slots without a peer".
*/
import { describe, it, expect } from 'vitest';
import { computeBackupGroups } from '../../utils/amsHelpers';
function ams(id: number, tray: Array<{
tray_type?: string | null;
tray_sub_brands?: string | null;
tray_color?: string | null;
tray_info_idx?: string | null;
}>) {
return {
id,
tray: tray.map((t, i) => ({
id: i,
tray_type: t.tray_type ?? null,
tray_sub_brands: t.tray_sub_brands ?? null,
tray_color: t.tray_color ?? null,
tray_info_idx: t.tray_info_idx ?? null,
})),
};
}
describe('computeBackupGroups', () => {
it('returns empty list for missing/empty AMS input', () => {
expect(computeBackupGroups(undefined, {}, false)).toEqual([]);
expect(computeBackupGroups([], {}, false)).toEqual([]);
});
it('skips empty slots entirely', () => {
const groups = computeBackupGroups(
[ams(0, [
{ tray_type: null, tray_color: null, tray_info_idx: null },
{ tray_type: null, tray_color: null, tray_info_idx: null },
])],
{},
false,
);
expect(groups).toEqual([]);
});
it('groups two slots in different AMS units holding the same preset', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
expect(groups).toHaveLength(1);
expect(groups[0].presetId).toBe('GFA00');
expect(groups[0].members.map((m) => m.globalTrayId)).toEqual([0, 4]);
});
it('STRICT rule: two slots without a preset never pair, even with matching material+colour', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
],
{},
false,
);
// Two lone slots — no pair.
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.members.length === 1)).toBe(true);
expect(groups.every((g) => g.presetId === null)).toBe(true);
});
it('does NOT group slots with different presets even if same material', () => {
const groups = computeBackupGroups(
[ams(0, [
{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
{ tray_type: 'PLA', tray_color: '#FFFFFF', tray_info_idx: 'GFA01' },
])],
{},
false,
);
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.members.length === 1)).toBe(true);
});
it('returns lone slots alongside pairs in the same list', () => {
const groups = computeBackupGroups(
[
ams(0, [
{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
{ tray_type: 'PETG', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
]),
ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
// 1 pair + 1 lone, pair first by sort order.
expect(groups).toHaveLength(2);
expect(groups[0].members).toHaveLength(2);
expect(groups[1].members).toHaveLength(1);
expect(groups[1].displayName).toContain('PETG');
});
it('on dual-extruder printers, scopes pairs per extruder side', () => {
// ams 0 = right (0), ams 1 = left (1). Same preset on different sides:
// each comes back as a 1-member entry.
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{ '0': 0, '1': 1 },
true,
);
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.members.length === 1)).toBe(true);
expect(groups[0].extruder).toBe(0);
expect(groups[1].extruder).toBe(1);
});
it('on dual-extruder printers, pairs slots on the same extruder side', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
ams(2, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{ '0': 0, '1': 1, '2': 0 },
true,
);
// Right-side pair (AMS 0 + 2), left-side lone (AMS 1).
const rightPair = groups.find((g) => g.extruder === 0 && g.members.length === 2);
expect(rightPair).toBeDefined();
expect(rightPair!.members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 8]);
const leftLone = groups.find((g) => g.extruder === 1);
expect(leftLone).toBeDefined();
expect(leftLone!.members).toHaveLength(1);
});
it('handles AMS-HT (single-tray, id >= 128) via getGlobalTrayId — pairs with regular AMS slot', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
ams(128, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
expect(groups).toHaveLength(1);
expect(groups[0].members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 128]);
});
it('STRICT colour rule: same preset, different colours do NOT pair', () => {
// Reporter screenshot scenario — three PETG HF slots all sharing the
// same Bambu profile ID (e.g. GFG99) but in three different colours
// cannot back each other up; the firmware would correctly swap PETG HF
// but the print would change colour mid-run.
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
ams(1, [{ tray_type: 'PETG', tray_color: '#FF0000', tray_info_idx: 'GFG99' }]),
ams(2, [{ tray_type: 'PETG', tray_color: '#00FF00', tray_info_idx: 'GFG99' }]),
],
{},
false,
);
// Three lone slots — no pair.
expect(groups).toHaveLength(3);
expect(groups.every((g) => g.members.length === 1)).toBe(true);
});
it('colour normalisation: 6-char and 8-char hex of the same RGB pair correctly', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }]),
ams(1, [{ tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
expect(groups).toHaveLength(1);
expect(groups[0].members).toHaveLength(2);
});
it('defensively dedupes duplicate ams.id entries (first wins)', () => {
// Observed in the wild: status.ams sometimes contains the same ams.id
// twice (VP-aggregated switch printers, MQTT partial-update edge cases).
// The modal must NOT render the same slot label with conflicting
// materials — first occurrence wins, second is dropped.
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
expect(groups).toHaveLength(1);
expect(groups[0].displayName).toContain('PETG');
});
it('preserves display name + tray colour from the first slot for the modal swatch', () => {
const groups = computeBackupGroups(
[
ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
],
{},
false,
);
expect(groups[0].displayName).toBe('PLA Basic');
expect(groups[0].trayColor).toBe('#1A1A1A');
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* #1762 — AMS Filament Backup status modal.
*
* Opens from the AmsBackupBadge click. Shows the global toggle and a
* BambuStudio-style ring graphic per backup pair — each ring represents
* the rotation order the firmware will follow when the active slot runs
* out.
*
* On dual-extruder printers, each ring carries a small "R" / "L" badge
* because the firmware can't cross extruders even with the global backup
* bit set.
*
* Theme-aware via CSS variables, matching AMSHistoryModal — adapts to
* every background variant the user has picked.
*/
import { useEffect } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Toggle } from './Toggle';
import {
computeBackupGroups,
normalizeColor,
type AmsUnitLike,
type BackupGroup,
} from '../utils/amsHelpers';
interface AmsBackupModalProps {
isOpen: boolean;
state: boolean | null;
amsUnits: AmsUnitLike[] | undefined;
amsExtruderMap: Record<string, number> | undefined;
isDualNozzle: boolean;
canToggle: boolean;
pending: boolean;
onToggle: (next: boolean) => void;
onClose: () => void;
}
/**
* Compact slot label like "A·3" / "HT·1" — the ring is small, every char
* counts toward readability.
*/
function formatSlotLabel(amsId: number, slotIdx: number, totalTraysOnUnit: number): string {
const isHt = totalTraysOnUnit === 1 || amsId >= 128;
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
const letter = String.fromCharCode(65 + normalizedId);
return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
}
/** Pick a readable text colour for a given filament hex. */
function pickContrastTextColor(rgbaHex: string | null | undefined): string {
const s = (rgbaHex || '').replace('#', '').slice(0, 6);
if (s.length !== 6) return '#FFFFFF';
const r = parseInt(s.slice(0, 2), 16);
const g = parseInt(s.slice(2, 4), 16);
const b = parseInt(s.slice(4, 6), 16);
if ([r, g, b].some(Number.isNaN)) return '#FFFFFF';
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luma > 0.55 ? '#1A1A1A' : '#FFFFFF';
}
function BackupRing({
group,
trayCountByAms,
innerBg,
textPrimary,
textSecondary,
showExtruderBadge,
extruderLabel,
}: {
group: BackupGroup;
trayCountByAms: Map<number, number>;
innerBg: string;
textPrimary: string;
textSecondary: string;
showExtruderBadge: boolean;
extruderLabel: string;
}) {
const filamentHex = normalizeColor(group.trayColor || undefined);
const ringTextColor = pickContrastTextColor(group.trayColor);
// The pill background that sits behind each slot label — keeps text legible
// regardless of the filament fill colour.
const labelPillBg = ringTextColor === '#FFFFFF' ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.7)';
const n = group.members.length;
// Geometry: -100..100 viewport. Outer ring 92, inner cutout 56.
// Slot labels sit on the colour band at radius 76.
const labelRadius = 76;
return (
<div className="relative flex flex-col items-center">
{showExtruderBadge && (
<span
className="absolute -top-1 -left-1 z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shadow"
style={{
backgroundColor: textPrimary,
color: innerBg,
}}
aria-label={extruderLabel}
title={extruderLabel}
>
{extruderLabel}
</span>
)}
<svg viewBox="-100 -100 200 200" className="w-44 h-44">
{/* Subtle outer ring — gives a crisp edge on light AND dark themes. */}
<circle cx="0" cy="0" r="95" fill="none" stroke={textSecondary} strokeOpacity="0.25" strokeWidth="1" />
{/* Colour band */}
<circle cx="0" cy="0" r="92" fill={filamentHex} />
{/* Inner cutout */}
<circle cx="0" cy="0" r="56" fill={innerBg} />
{/* Inner ring border for definition between centre and colour band */}
<circle cx="0" cy="0" r="56" fill="none" stroke={textSecondary} strokeOpacity="0.3" strokeWidth="1" />
{/* Centre: material name */}
<text
x="0"
y="-4"
textAnchor="middle"
dominantBaseline="middle"
fontSize="14"
fontWeight="700"
fill={textPrimary}
>
{group.displayName || '—'}
</text>
{/* Centre: rotation count */}
<text
x="0"
y="16"
textAnchor="middle"
dominantBaseline="middle"
fontSize="11"
fontWeight="500"
fill={textSecondary}
>
{`${n}× ↻`}
</text>
{/* Slot labels around the ring, each on a pill for legibility */}
{group.members.map((m, i) => {
const angleDeg = (i * 360) / n - 90;
const rad = (angleDeg * Math.PI) / 180;
const x = labelRadius * Math.cos(rad);
const y = labelRadius * Math.sin(rad);
const label = formatSlotLabel(m.amsId, m.slotIdx, trayCountByAms.get(m.amsId) ?? 4);
// Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font).
const pillWidth = Math.max(22, label.length * 7 + 8);
return (
<g key={`${m.amsId}-${m.slotIdx}`}>
<rect
x={x - pillWidth / 2}
y={y - 9}
width={pillWidth}
height={18}
rx={9}
ry={9}
fill={labelPillBg}
/>
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize="12"
fontWeight="700"
fill={ringTextColor}
>
{label}
</text>
</g>
);
})}
</svg>
</div>
);
}
export function AmsBackupModal({
isOpen,
state,
amsUnits,
amsExtruderMap,
isDualNozzle,
canToggle,
pending,
onToggle,
onClose,
}: AmsBackupModalProps) {
const { t } = useTranslation();
// Close on Escape key while the modal is open. Captures at the window
// level so it works even when focus isn't inside the modal subtree
// (e.g. after the Toggle is clicked).
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Theme-aware tokens, matching AMSHistoryModal.
const modalBg = 'var(--bg-secondary)';
const sectionBg = 'var(--bg-primary)';
const borderColor = 'var(--border-color)';
const textPrimary = 'var(--text-primary)';
const textSecondary = 'var(--text-secondary)';
// Effective dual-nozzle detection: only split per extruder if the map
// actually carries 2 distinct values across the AMS units we have data
// for. Empty / single-value maps collapse to a single section to avoid
// misleading badges.
const effectiveDualNozzle = (() => {
if (!isDualNozzle) return false;
if (!amsExtruderMap) return false;
const distinctValues = new Set<number>();
for (const ams of amsUnits || []) {
const raw = amsExtruderMap[String(ams.id)];
if (raw === undefined) continue;
distinctValues.add(Number(raw));
if (distinctValues.size > 1) return true;
}
return false;
})();
const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle);
const trayCountByAms = new Map<number, number>(
(amsUnits || []).map((u) => [u.id, u.tray.length]),
);
// Only pairs are rendered — lone slots are deliberately suppressed.
const pairs = groups.filter((g) => g.members.length >= 2);
const isOn = state === true;
const isUnknown = state === null;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
data-testid="ams-backup-modal"
>
<div
className="rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-xl flex flex-col"
style={{ backgroundColor: modalBg }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="ams-backup-modal-title"
>
<div
className="flex items-center justify-between px-5 py-3 border-b"
style={{ borderColor }}
>
<h2
id="ams-backup-modal-title"
className="text-base font-semibold"
style={{ color: textPrimary }}
>
{t('printers.amsBackup.modalTitle')}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 rounded-md transition-colors hover:bg-black/10"
style={{ color: textSecondary }}
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
<div
className="flex items-center justify-between px-5 py-3 border-b"
style={{ borderColor, backgroundColor: sectionBg }}
>
<div className="min-w-0 mr-3">
<div className="text-sm font-medium" style={{ color: textPrimary }}>
{isUnknown
? t('printers.amsBackup.stateUnknown')
: isOn
? t('printers.amsBackup.stateOn')
: t('printers.amsBackup.stateOff')}
</div>
<p className="text-xs mt-0.5" style={{ color: textSecondary }}>
{t('printers.amsBackup.modalHelp')}
</p>
</div>
<Toggle
checked={isOn}
onChange={onToggle}
disabled={!canToggle || isUnknown || pending}
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-6">
{pairs.length === 0 ? (
<p
className="text-sm text-center py-8"
style={{ color: textSecondary }}
>
{t('printers.amsBackup.modalNoPairs')}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-items-center">
{pairs.map((g) => (
<BackupRing
key={g.key}
group={g}
trayCountByAms={trayCountByAms}
innerBg={modalBg}
textPrimary={textPrimary}
textSecondary={textSecondary}
showExtruderBadge={effectiveDualNozzle}
extruderLabel={
g.extruder === 0
? t('printers.amsBackup.extruderRightShort')
: t('printers.amsBackup.extruderLeftShort')
}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.',
toastEnabled: 'AMS Filament Backup aktiviert',
toastDisabled: 'AMS Filament Backup deaktiviert',
modalTitle: 'AMS Filament Backup',
modalHelp: 'Wenn der aktive Slot leer wird, wechselt der Drucker in dieser Reihenfolge zu Slots mit demselben Preset und derselben Farbe.',
modalNoSlots: 'Kein Filament geladen.',
modalNoPairs: 'Keine Backup-Paare — keine zwei Slots teilen sich Filament-Preset und Farbe.',
extruderRightShort: 'R',
extruderLeftShort: 'L',
stateOn: 'Aktiviert',
stateOff: 'Deaktiviert',
stateUnknown: 'Auf diesem Drucker nicht unterstützt',
},
activeJobSlot: {
title: 'Dieser Slot ist Filament {{n}} im aktiven Druck',
ariaLabel: 'Aktiver Druck-Slot {{n}}',
},
// Filaments section
filaments: 'Filamente',
+13
View File
@@ -551,6 +551,19 @@ export default {
titleUnknown: 'AMS Filament Backup status unavailable on this printer.',
toastEnabled: 'AMS Filament Backup enabled',
toastDisabled: 'AMS Filament Backup disabled',
modalTitle: 'AMS Filament Backup',
modalHelp: 'When the active slot runs out, the printer cycles through any matching same-preset, same-colour slots in this order.',
modalNoSlots: 'No filament loaded.',
modalNoPairs: 'No backup pairs — no two slots share the same filament profile and colour.',
extruderRightShort: 'R',
extruderLeftShort: 'L',
stateOn: 'Enabled',
stateOff: 'Disabled',
stateUnknown: 'Unsupported on this printer',
},
activeJobSlot: {
title: 'This slot is filament {{n}} in the active print',
ariaLabel: 'Active print slot {{n}}',
},
// Filaments section
filaments: 'Filaments',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.',
toastEnabled: 'AMS Filament Backup activado',
toastDisabled: 'AMS Filament Backup desactivado',
modalTitle: 'AMS Filament Backup',
modalHelp: 'Cuando el slot activo se agota, la impresora rota a slots con el mismo preset y color en este orden.',
modalNoSlots: 'Sin filamento cargado.',
modalNoPairs: 'No hay pares de respaldo — no hay dos slots que compartan preset y color.',
extruderRightShort: 'D',
extruderLeftShort: 'I',
stateOn: 'Activado',
stateOff: 'Desactivado',
stateUnknown: 'No compatible con esta impresora',
},
activeJobSlot: {
title: 'Este slot es el filamento {{n}} en la impresión activa',
ariaLabel: 'Slot de impresión activa {{n}}',
},
// Filaments section
filaments: 'Filamentos',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.",
toastEnabled: "AMS Filament Backup activé",
toastDisabled: "AMS Filament Backup désactivé",
modalTitle: 'AMS Filament Backup',
modalHelp: "Lorsque l'emplacement actif s'épuise, l'imprimante passe aux emplacements ayant le même preset et la même couleur dans cet ordre.",
modalNoSlots: "Aucun filament chargé.",
modalNoPairs: "Aucune paire de secours — aucun emplacement ne partage à la fois preset et couleur.",
extruderRightShort: "D",
extruderLeftShort: "G",
stateOn: "Activé",
stateOff: "Désactivé",
stateUnknown: "Non pris en charge par cette imprimante",
},
activeJobSlot: {
title: 'Cet emplacement est le filament {{n}} dans l\'impression active',
ariaLabel: 'Emplacement d\'impression active {{n}}',
},
// Filaments section
filaments: 'Filaments',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.',
toastEnabled: 'AMS Filament Backup abilitato',
toastDisabled: 'AMS Filament Backup disabilitato',
modalTitle: 'AMS Filament Backup',
modalHelp: "Quando lo slot attivo si esaurisce, la stampante passa agli slot con lo stesso preset e colore in quest'ordine.",
modalNoSlots: 'Nessun filamento caricato.',
modalNoPairs: 'Nessuna coppia di backup — nessuno slot condivide preset e colore.',
extruderRightShort: 'D',
extruderLeftShort: 'S',
stateOn: 'Abilitato',
stateOff: 'Disabilitato',
stateUnknown: 'Non supportato su questa stampante',
},
activeJobSlot: {
title: 'Questo slot è il filamento {{n}} nella stampa attiva',
ariaLabel: 'Slot stampa attiva {{n}}',
},
// Filaments section
filaments: 'Filamenti',
+13
View File
@@ -546,6 +546,19 @@ export default {
titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。',
toastEnabled: 'AMSフィラメントバックアップを有効化しました',
toastDisabled: 'AMSフィラメントバックアップを無効化しました',
modalTitle: 'AMS フィラメントバックアップ',
modalHelp: 'アクティブなスロットが空になると、プリンターは同じプリセット・同じ色のスロットをこの順序で循環します。',
modalNoSlots: 'フィラメントが読み込まれていません。',
modalNoPairs: 'バックアップペアがありません — プリセットと色の両方が一致するスロットがありません。',
extruderRightShort: '右',
extruderLeftShort: '左',
stateOn: '有効',
stateOff: '無効',
stateUnknown: 'このプリンターでは未対応',
},
activeJobSlot: {
title: 'このスロットはアクティブな印刷のフィラメント {{n}} です',
ariaLabel: 'アクティブ印刷スロット {{n}}',
},
// Filaments section
filaments: 'フィラメント',
+14 -1
View File
@@ -509,7 +509,20 @@ export default {
titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.',
titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.',
toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다',
toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다'
toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다',
modalTitle: 'AMS 필라멘트 백업',
modalHelp: '활성 슬롯이 소진되면 프린터는 같은 프리셋과 색상의 슬롯을 이 순서로 순환합니다.',
modalNoSlots: '필라멘트가 로드되지 않았습니다.',
modalNoPairs: '백업 쌍이 없습니다 — 프리셋과 색상이 모두 일치하는 슬롯이 없습니다.',
extruderRightShort: '우',
extruderLeftShort: '좌',
stateOn: '활성화됨',
stateOff: '비활성화됨',
stateUnknown: '이 프린터에서 지원되지 않음'
},
activeJobSlot: {
title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
ariaLabel: '활성 인쇄 슬롯 {{n}}'
},
filaments: '필라멘트',
openCameraOverlay: '카메라 오버레이 열기',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.',
toastEnabled: 'AMS Filament Backup ativado',
toastDisabled: 'AMS Filament Backup desativado',
modalTitle: 'AMS Filament Backup',
modalHelp: 'Quando o slot ativo termina, a impressora alterna para slots com o mesmo preset e cor nesta ordem.',
modalNoSlots: 'Nenhum filamento carregado.',
modalNoPairs: 'Sem pares de backup — nenhum par de slots compartilha preset e cor.',
extruderRightShort: 'D',
extruderLeftShort: 'E',
stateOn: 'Ativado',
stateOff: 'Desativado',
stateUnknown: 'Não suportado nesta impressora',
},
activeJobSlot: {
title: 'Este slot é o filamento {{n}} na impressão ativa',
ariaLabel: 'Slot de impressão ativa {{n}}',
},
// Filaments section
filaments: 'Filamentos',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: 'Bu yazıcıda AMS Filament Backup durumu kullanılamıyor.',
toastEnabled: 'AMS Filament Backup etkinleştirildi',
toastDisabled: 'AMS Filament Backup devre dışı bırakıldı',
modalTitle: 'AMS Filament Backup',
modalHelp: 'Aktif slot tükendiğinde, yazıcı aynı ön ayar ve renkteki slotları bu sırayla dolaşır.',
modalNoSlots: 'Yüklenmiş filament yok.',
modalNoPairs: 'Yedek çifti yok — hiçbir slot çifti aynı ön ayar ve rengi paylaşmıyor.',
extruderRightShort: 'S',
extruderLeftShort: 'L',
stateOn: 'Etkin',
stateOff: 'Devre dışı',
stateUnknown: 'Bu yazıcıda desteklenmiyor',
},
activeJobSlot: {
title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament',
ariaLabel: 'Aktif baskı slotu {{n}}',
},
// Filamentler bölümü
filaments: 'Filamentler',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。',
toastEnabled: 'AMS 备用料盘已启用',
toastDisabled: 'AMS 备用料盘已禁用',
modalTitle: 'AMS 备用料盘',
modalHelp: '当前料槽用尽时,打印机会按此顺序在使用相同预设和颜色的料槽之间循环。',
modalNoSlots: '未加载耗材。',
modalNoPairs: '没有备用料盘对 — 没有两个料槽同时匹配预设和颜色。',
extruderRightShort: '右',
extruderLeftShort: '左',
stateOn: '已启用',
stateOff: '已禁用',
stateUnknown: '此打印机不支持',
},
activeJobSlot: {
title: '此料槽在当前打印中是耗材 {{n}}',
ariaLabel: '当前打印料槽 {{n}}',
},
// Filaments section
filaments: '耗材',
+13
View File
@@ -547,6 +547,19 @@ export default {
titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。',
toastEnabled: 'AMS 備用料盤已啟用',
toastDisabled: 'AMS 備用料盤已停用',
modalTitle: 'AMS 備用料盤',
modalHelp: '目前料槽用盡時,印表機會按此順序在使用相同預設和顏色的料槽之間循環。',
modalNoSlots: '未載入耗材。',
modalNoPairs: '沒有備用料盤對 — 沒有兩個料槽同時匹配預設和顏色。',
extruderRightShort: '右',
extruderLeftShort: '左',
stateOn: '已啟用',
stateOff: '已停用',
stateUnknown: '此印表機不支援',
},
activeJobSlot: {
title: '此料槽在目前列印中是耗材 {{n}}',
ariaLabel: '目前列印料槽 {{n}}',
},
// Filaments section
filaments: '耗材',
+66 -16
View File
@@ -98,6 +98,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { AmsBackupModal } from '../components/AmsBackupModal';
import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
import type { HeaterSensorKind } from '../api/client';
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
@@ -697,29 +698,26 @@ function HeaterThermometer({ className, color, isHeating }: HeaterThermometerPro
// AMS Filament Backup tri-state indicator + toggle.
// state=true → ON, click to disable
// state=false → OFF, click to enable
// state=null → unknown/unsupported (e.g. A1 family), no click action
// state=false → OFF, click opens modal
// state=null → unknown/unsupported (e.g. A1 family), click disabled
interface AmsBackupBadgeProps {
state: boolean | null;
canToggle: boolean;
pending: boolean;
onToggle: (next: boolean) => void;
onClick: () => void;
}
function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeProps) {
function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) {
const { t } = useTranslation();
const known = state !== null;
const clickable = canToggle && known && !pending;
let className = 'flex items-center justify-center w-[18px] h-[18px] rounded text-[10px] transition-colors ';
let title: string;
if (state === true) {
className += clickable
className += known
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 cursor-pointer'
: 'bg-blue-500/20 text-blue-400 cursor-default';
title = t('printers.amsBackup.titleOn');
} else if (state === false) {
className += clickable
className += known
? 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80 cursor-pointer'
: 'bg-bambu-dark text-bambu-gray cursor-default';
title = t('printers.amsBackup.titleOff');
@@ -731,8 +729,8 @@ function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeP
return (
<button
type="button"
disabled={!clickable}
onClick={() => clickable && onToggle(!state)}
disabled={!known}
onClick={() => known && onClick()}
className={className}
title={title}
aria-label={title}
@@ -1820,6 +1818,8 @@ function PrinterCard({
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
const [haToggleConfirm, setHaToggleConfirm] = useState<SmartPlug | null>(null);
const [showHMSModal, setShowHMSModal] = useState(false);
// #1762: AMS Filament Backup status / control modal — opens from the badge.
const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
@@ -4412,6 +4412,15 @@ function PrinterCard({
const htAms = amsData.filter(ams => ams.tray.length === 1);
const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
const filamentSlotClass = 'min-w-14';
// #1762 (comment 2): while a print is running/paused, overlay a small
// "P1 / P2 / P3" pill on each slot referenced by the active print's
// mapping. Catches the reporter's scenario — "any X1C" queue job
// staged to a printer with mismatched filament: the wrong-slot pill
// is visible the instant printing starts.
const isPrintingForMapping = status.state === 'RUNNING' || status.state === 'PAUSE';
const activeMapping: number[] = isPrintingForMapping && Array.isArray(status.ams_mapping)
? status.ams_mapping
: [];
const getAmsCardStyle = (slotCount: number): React.CSSProperties => {
const boundedSlotCount = Math.max(1, slotCount);
const gapCount = Math.max(0, boundedSlotCount - 1);
@@ -4431,9 +4440,7 @@ function PrinterCard({
</span>
<AmsBackupBadge
state={status.ams_filament_backup}
canToggle={hasPermission('printers:control')}
pending={setAmsBackupMutation.isPending}
onToggle={(next) => setAmsBackupMutation.mutate(next)}
onClick={() => setAmsBackupModalOpen(true)}
/>
<div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
</div>
@@ -4637,11 +4644,25 @@ function PrinterCard({
const isRefreshing = refreshingSlot?.amsId === ams.id &&
refreshingSlot?.slotId === slotIdx;
// #1762 (comment 2): which print-slot is mapped to THIS AMS slot.
const activePrintSlotIdx = activeMapping.indexOf(globalTrayId);
const activePrintSlotLabel = activePrintSlotIdx >= 0
? `P${activePrintSlotIdx + 1}`
: null;
// Slot visual content (goes inside hover card)
const slotVisual = (
<div
className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
{activePrintSlotLabel && (
<span
aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
title={t('printers.activeJobSlot.title', { n: activePrintSlotIdx + 1 })}
className="absolute top-0.5 right-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-bambu-green rounded pointer-events-none leading-none"
>
{activePrintSlotLabel}
</span>
)}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
@@ -4895,11 +4916,25 @@ function PrinterCard({
const isHtRefreshing = refreshingSlot?.amsId === ams.id &&
refreshingSlot?.slotId === htSlotId;
// #1762 (comment 2): active print-slot index for this HT slot.
const htActivePrintSlotIdx = activeMapping.indexOf(globalTrayId);
const htActivePrintSlotLabel = htActivePrintSlotIdx >= 0
? `P${htActivePrintSlotIdx + 1}`
: null;
// Slot visual content (goes inside hover card)
const slotVisual = (
<div
className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
{htActivePrintSlotLabel && (
<span
aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
title={t('printers.activeJobSlot.title', { n: htActivePrintSlotIdx + 1 })}
className="absolute top-0.5 right-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-bambu-green rounded pointer-events-none leading-none"
>
{htActivePrintSlotLabel}
</span>
)}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
@@ -6134,6 +6169,21 @@ function PrinterCard({
/>
)}
{/* AMS Filament Backup status / control modal (#1762) */}
{amsBackupModalOpen && status && (
<AmsBackupModal
isOpen={amsBackupModalOpen}
state={status.ams_filament_backup}
amsUnits={status.ams}
amsExtruderMap={status.ams_extruder_map}
isDualNozzle={printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined}
canToggle={hasPermission('printers:control')}
pending={setAmsBackupMutation.isPending}
onToggle={(next) => setAmsBackupMutation.mutate(next)}
onClose={() => setAmsBackupModalOpen(false)}
/>
)}
{/* AMS History Modal */}
{amsHistoryModal && (
<AMSHistoryModal
+139
View File
@@ -343,3 +343,142 @@ export function isBambuLabSpool(tray: {
if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
return false;
}
export interface AmsTrayLike {
id: number;
tray_type: string | null | undefined;
tray_sub_brands: string | null | undefined;
tray_color: string | null | undefined;
tray_info_idx: string | null | undefined;
}
export interface AmsUnitLike {
id: number;
tray: AmsTrayLike[];
}
/**
* One row in the AMS Backup modal: a group of slots that back each other up
* (length >= 2), or a single non-empty slot with no peer (length === 1).
*/
export interface BackupGroup {
/** Stable key — same across renders for the same material+extruder. */
key: string;
/** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
presetId: string | null;
/** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
extruder: number;
/** Display name from the first slot's tray_sub_brands (or tray_type). */
displayName: string;
/** Tray colour from the first slot, for the swatch in the modal. */
trayColor: string | null;
/** Member slots, in (ams_id, slot_idx) order. */
members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
}
/**
* Canonicalise a hex colour for identity comparison. Mirrors the backend
* `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
* the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
*/
function normalizeColorForId(raw: string | null | undefined): string {
let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
if (s.length === 8) s = s.slice(0, 6);
return s;
}
/**
* Compute backup pairs for the AMS Backup modal (#1762).
*
* Strict identity rule (mirrors backend `_material_identity_internal` /
* `_material_identity_spoolman`): slots pair ONLY when they share the same
* Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
* preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
* colour pins the variant — three PETG HF spools in different colours
* absolutely don't back each other up. User-tagged spools without a preset
* never pair — Bambu's firmware backup logic relies on the preset, and
* pairing on cosmetic name/colour match alone would let two visually-
* identical but materially-different spools be treated as backups.
*
* Empty slots are skipped entirely. Every non-empty slot is returned — slots
* without a peer come back as 1-member entries so the modal can list them as
* "Slots without a backup peer".
*
* On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
* side — the firmware can't cross extruders even with the global backup bit
* set.
*/
export function computeBackupGroups(
amsUnits: AmsUnitLike[] | undefined,
amsExtruderMap: Record<string, number> | undefined,
isDualNozzle: boolean,
): BackupGroup[] {
if (!amsUnits || amsUnits.length === 0) return [];
// Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
// observed in the wild to occasionally contain duplicate entries (e.g. on
// VP-aggregated switch printers or during MQTT partial-update merges). A
// duplicate would surface as "AMS-A slot 1" rendered twice with different
// materials, which is impossible physically and visually broken. First
// occurrence per `ams.id` wins.
const seenIds = new Set<number>();
const uniqueAms: AmsUnitLike[] = [];
for (const ams of amsUnits) {
if (seenIds.has(ams.id)) continue;
seenIds.add(ams.id);
uniqueAms.push(ams);
}
const byKey = new Map<string, BackupGroup>();
for (const ams of uniqueAms) {
const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
ams.tray.forEach((tray, slotIdx) => {
if (!tray?.tray_type) return; // empty slot
const preset = (tray.tray_info_idx || '').trim();
const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
const member = { amsId: ams.id, slotIdx, globalTrayId };
let key: string;
let presetId: string | null;
if (preset) {
// Same Bambu profile is necessary but NOT sufficient — different colours
// of the same PETG HF profile can't back each other up. Bake the colour
// into the identity key, normalised to strip alpha and case.
const color = normalizeColorForId(tray.tray_color);
key = `preset:${preset}|color:${color}#${extruder}`;
presetId = preset;
} else {
// No preset → never group with anything else. Unique-per-slot key.
key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
presetId = null;
}
const existing = byKey.get(key);
if (existing) {
existing.members.push(member);
} else {
byKey.set(key, {
key,
presetId,
extruder,
displayName: tray.tray_sub_brands || tray.tray_type || '',
trayColor: tray.tray_color ?? null,
members: [member],
});
}
});
}
// Stable sort: extruder first (so the modal can section per side on
// dual-nozzle), then pairs before lone slots, then by name, then by first
// member's global tray id for deterministic rendering.
return Array.from(byKey.values()).sort((a, b) => {
if (a.extruder !== b.extruder) return a.extruder - b.extruder;
const aLone = a.members.length === 1 ? 1 : 0;
const bLone = b.members.length === 1 ? 1 : 0;
if (aLone !== bLone) return aLone - bLone;
if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
return a.members[0].globalTrayId - b.members[0].globalTrayId;
});
}