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
@@ -11,6 +11,9 @@ import {
import {
normalizeColorForCompare,
colorsAreSimilar,
preferLowestSortKey,
compareSortKeys,
effectivePreferLowest,
} from '../utils/amsHelpers';
/**
@@ -58,6 +61,8 @@ export interface PrinterMappingResult {
totalSlots: number;
/** Per-printer config */
config: PerPrinterConfig;
/** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */
inventoryByTrayId?: Map<number, number>;
}
/**
@@ -90,6 +95,7 @@ function computeMatchDetails(
loadedFilaments: LoadedFilament[],
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
if (!filamentReqs || filamentReqs.length === 0) {
return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
@@ -135,11 +141,12 @@ function computeMatchDetails(
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
@@ -194,6 +201,7 @@ function computeMappingWithOverrides(
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
@@ -222,11 +230,12 @@ function computeMappingWithOverrides(
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
@@ -290,6 +299,7 @@ export function useMultiPrinterFilamentMapping(
perPrinterConfigs: Record<number, PerPrinterConfig>,
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
preferLowest?: boolean,
inventoryByTrayIdPerPrinter?: Map<number, Map<number, number>>,
): UseMultiPrinterFilamentMappingResult {
// Fetch printer status for all selected printers in parallel
const statusQueries = useQueries({
@@ -311,9 +321,14 @@ export function useMultiPrinterFilamentMapping(
const loadedFilaments = buildLoadedFilaments(printerStatus);
const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
const inventoryByTrayId = inventoryByTrayIdPerPrinter?.get(printerId);
// Per-printer gate (#1766): two printers in the same dispatch can have
// different AMS Backup states; the sort must be skipped on the OFF ones
// and kept on the ON ones. Computing inside the loop captures both.
const printerPreferLowest = effectivePreferLowest(preferLowest, printerStatus?.ams_filament_backup);
// Compute auto mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);
const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId);
// Determine which mappings to use:
// If printer has override (useDefault=false), use its custom mappings
@@ -323,14 +338,15 @@ export function useMultiPrinterFilamentMapping(
: defaultMappings;
// Compute final mapping with overrides
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings,
preferLowest,
printerPreferLowest,
inventoryByTrayId,
);
return {
@@ -347,9 +363,10 @@ export function useMultiPrinterFilamentMapping(
missingTypes: matchDetails.missingTypes,
totalSlots: matchDetails.totalSlots,
config,
inventoryByTrayId,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
const isLoading = statusQueries.some((q) => q.isLoading);
@@ -370,7 +387,12 @@ export function useMultiPrinterFilamentMapping(
if (!result || !result.status || !filamentReqs?.filaments) return;
// Compute optimal mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);
const autoMapping = computeAmsMapping(
filamentReqs,
result.status,
effectivePreferLowest(preferLowest, result.status?.ams_filament_backup),
inventoryByTrayIdPerPrinter?.get(printerId),
);
if (!autoMapping) return;
// Convert autoMapping array to manualMappings record