feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)

Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
This commit is contained in:
maziggy
2026-06-20 12:07:20 +02:00
parent a39ed5b42e
commit b1cb26f6ee
39 changed files with 1155 additions and 190 deletions
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
import { api } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getGlobalTrayId } from '../../utils/amsHelpers';
import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
import { getColorName } from '../../utils/colors';
import { useFilamentLabels } from './useFilamentLabels';
import type { FilamentMappingProps } from './types';
@@ -42,8 +42,35 @@ export function FilamentMapping({
enabled: !!printerId,
});
// Settings + inventory map drive the same prefer-lowest + AMS-backup gate
// the dispatcher uses (#1766). Without this, the per-slot dropdown's
// auto-suggestion could disagree with what actually gets dispatched.
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const { data: inventoryRemain } = useQuery({
queryKey: ['printer-inventory-remain', printerId],
queryFn: () => api.getInventoryRemain(printerId),
enabled: !!printerId,
staleTime: 30 * 1000,
});
const inventoryByTrayId = useMemo(() => {
if (!inventoryRemain?.inventory_remain_g) return undefined;
const map = new Map<number, number>();
Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => {
const gtid = Number(key);
if (!Number.isNaN(gtid)) map.set(gtid, grams);
});
return map;
}, [inventoryRemain]);
const gatedPreferLowest = effectivePreferLowest(
settings?.prefer_lowest_filament,
printerStatus?.ams_filament_backup,
);
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
useFilamentMapping(filamentReqs, printerStatus, manualMappings);
useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId);
// Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
// shared hook the model-mode FilamentOverride uses so both panels render
@@ -18,6 +18,7 @@ import {
colorsAreSimilar,
autoMatchFilament,
filterFilamentsByNozzle,
effectivePreferLowest,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
@@ -108,7 +109,13 @@ function InlineMappingEditor({
} else {
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;
loaded = autoMatchFilament(
req,
printerResult.loadedFilaments,
usedTrayIds,
effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
printerResult.inventoryByTrayId,
) as LoadedFilament | undefined;
}
// Determine status
+46 -3
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,7 +15,7 @@ import { getColorName } from '../../utils/colors';
import { getCurrencySymbol } from '../../utils/currency';
import { getBedTypeInfo } from '../../utils/bedType';
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
import { FilamentMapping } from './FilamentMapping';
import { FilamentOverride } from './FilamentOverride';
import { PlateSelector } from './PlateSelector';
@@ -270,6 +270,34 @@ export function PrintModal({
enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
});
// Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
// backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
// so internal and Spoolman modes both work uniformly, VT/external slots are
// excluded, and negative grams are clamped — single source of truth between
// the client-side preview and dispatch-time picks.
const inventoryRemainQueries = useQueries({
queries: selectedPrinters.map((printerId) => ({
queryKey: ['printer-inventory-remain', printerId],
queryFn: () => api.getInventoryRemain(printerId),
staleTime: 30 * 1000,
enabled: selectedPrinters.length > 0,
})),
});
const inventoryByTrayIdPerPrinter = useMemo(() => {
const result = new Map<number, Map<number, number>>();
selectedPrinters.forEach((printerId, idx) => {
const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
if (!data) return;
const printerMap = new Map<number, number>();
Object.entries(data).forEach(([key, grams]) => {
const gtid = Number(key);
if (!Number.isNaN(gtid)) printerMap.set(gtid, grams);
});
result.set(printerId, printerMap);
});
return result;
}, [selectedPrinters, inventoryRemainQueries]);
// Fetch archive details to get sliced_for_model
const { data: archiveDetails } = useQuery({
queryKey: ['archive', archiveId],
@@ -346,8 +374,22 @@ export function PrintModal({
enabled: !!effectivePrinterId,
});
// Single-printer flow: gate prefer_lowest on this printer's backup state.
// Multi-printer flow gates per-printer inside the hook (different printers
// may have different backup states), so we pass the raw setting down.
const singlePrinterPreferLowest = effectivePreferLowest(
settings?.prefer_lowest_filament,
printerStatus?.ams_filament_backup,
);
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);
const { amsMapping } = useFilamentMapping(
effectiveFilamentReqs,
printerStatus,
manualMappings,
singlePrinterPreferLowest,
effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
);
// Multi-printer filament mapping (for per-printer configuration)
const multiPrinterMapping = useMultiPrinterFilamentMapping(
@@ -358,6 +400,7 @@ export function PrintModal({
perPrinterConfigs,
setPerPrinterConfigs,
settings?.prefer_lowest_filament,
inventoryByTrayIdPerPrinter,
);
// Auto-select first plate when plates load (single or multi-plate)