Files
bambuddy/frontend/src/hooks/useMultiPrinterFilamentMapping.ts
T
maziggy b1cb26f6ee 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.
2026-06-20 12:07:20 +02:00

440 lines
14 KiB
TypeScript

import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { api } from '../api/client';
import type { PrinterStatus, Printer } from '../api/client';
import {
buildLoadedFilaments,
computeAmsMapping,
type LoadedFilament,
type FilamentRequirement,
} from './useFilamentMapping';
import {
normalizeColorForCompare,
colorsAreSimilar,
preferLowestSortKey,
compareSortKeys,
effectivePreferLowest,
} from '../utils/amsHelpers';
/**
* Match status for a single printer's filament configuration.
*/
export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
/**
* Per-printer configuration for AMS mapping.
*/
export interface PerPrinterConfig {
/** Whether this printer uses the default mapping or has custom config */
useDefault: boolean;
/** Manual slot overrides for this printer (slot_id -> globalTrayId) */
manualMappings: Record<number, number>;
/** Whether this mapping was auto-configured */
autoConfigured: boolean;
}
/**
* Result of filament mapping for a single printer.
*/
export interface PrinterMappingResult {
printerId: number;
printerName: string;
/** Printer status data */
status: PrinterStatus | undefined;
/** Whether status is still loading */
isLoading: boolean;
/** List of loaded filaments in this printer */
loadedFilaments: LoadedFilament[];
/** Auto-computed AMS mapping for this printer */
autoMapping: number[] | undefined;
/** Final AMS mapping (considering manual overrides) */
finalMapping: number[] | undefined;
/** Match status: full (all exact), partial (some mismatches), missing (type not found) */
matchStatus: PrinterMatchStatus;
/** Number of slots with exact match (type + color) */
exactMatches: number;
/** Number of slots with type-only match */
typeOnlyMatches: number;
/** Number of slots with missing type */
missingTypes: number;
/** Total required slots */
totalSlots: number;
/** Per-printer config */
config: PerPrinterConfig;
/** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */
inventoryByTrayId?: Map<number, number>;
}
/**
* Result of the useMultiPrinterFilamentMapping hook.
*/
export interface UseMultiPrinterFilamentMappingResult {
/** Results for each selected printer */
printerResults: PrinterMappingResult[];
/** Whether any printer data is still loading */
isLoading: boolean;
/** Per-printer configurations */
perPrinterConfigs: Record<number, PerPrinterConfig>;
/** Update config for a specific printer */
updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
/** Auto-configure all printers based on their loaded filaments */
autoConfigureAll: () => void;
/** Auto-configure a specific printer */
autoConfigurePrinter: (printerId: number) => void;
/** Get final mapping for a specific printer (for submission) */
getFinalMapping: (printerId: number) => number[] | undefined;
/** Check if all printers have acceptable mappings */
allPrintersReady: boolean;
}
/**
* Compute match details for a printer given filament requirements and loaded filaments.
*/
function computeMatchDetails(
filamentReqs: FilamentRequirement[] | undefined,
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' };
}
let exactMatches = 0;
let typeOnlyMatches = 0;
let missingTypes = 0;
const usedTrayIds = new Set<number>(Object.values(manualMappings));
for (const req of filamentReqs) {
const slotId = req.slot_id || 0;
// Check manual override first
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
if (typeMatch && colorMatch) {
exactMatches++;
} else if (typeMatch) {
typeOnlyMatches++;
} else {
missingTypes++;
}
continue;
}
}
// Auto-match with nozzle-aware filtering
let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
if (req.nozzle_id != null) {
const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
if (nozzleFiltered.length > 0) {
candidates = nozzleFiltered;
}
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: candidates.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
if (exactMatch || similarMatch) {
exactMatches++;
} else if (typeOnlyMatch) {
typeOnlyMatches++;
} else {
missingTypes++;
}
}
const totalSlots = filamentReqs.length;
let status: PrinterMatchStatus = 'full';
if (missingTypes > 0) {
status = 'missing';
} else if (typeOnlyMatches > 0) {
status = 'partial';
}
return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
}
/**
* Compute AMS mapping with manual overrides applied.
*/
function computeMappingWithOverrides(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
const loadedFilaments = buildLoadedFilaments(printerStatus);
if (loadedFilaments.length === 0) return undefined;
const usedTrayIds = new Set<number>(Object.values(manualMappings));
const comparisons: { slot_id: number; globalTrayId: number }[] = [];
for (const req of filamentReqs.filaments) {
const slotId = req.slot_id || 0;
// Check manual override first
if (slotId > 0 && manualMappings[slotId] !== undefined) {
comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
continue;
}
// Auto-match with nozzle-aware filtering
let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
if (req.nozzle_id != null) {
const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
if (nozzleFiltered.length > 0) {
candidates = nozzleFiltered;
}
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: candidates.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
}
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
const mapping = new Array(maxSlotId).fill(-1);
comparisons.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.globalTrayId;
}
});
return mapping;
}
/**
* Default per-printer config (use default mapping).
*/
const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
useDefault: true,
manualMappings: {},
autoConfigured: false,
};
/**
* Hook to manage filament mapping for multiple printers.
* Fetches printer status for all selected printers and computes per-printer mappings.
*/
export function useMultiPrinterFilamentMapping(
selectedPrinterIds: number[],
printers: Printer[] | undefined,
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
defaultMappings: Record<number, number>,
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({
queries: selectedPrinterIds.map((printerId) => ({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
enabled: selectedPrinterIds.length > 0,
staleTime: 5000, // Consider data fresh for 5 seconds
})),
});
// Build results for each printer
const printerResults = useMemo((): PrinterMappingResult[] => {
return selectedPrinterIds.map((printerId, index) => {
const query = statusQueries[index];
const printerStatus = query?.data;
const printer = printers?.find((p) => p.id === printerId);
const printerName = printer?.name || `Printer ${printerId}`;
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, printerPreferLowest, inventoryByTrayId);
// Determine which mappings to use:
// If printer has override (useDefault=false), use its custom mappings
// Otherwise use the default mappings
const effectiveMappings = !config.useDefault
? config.manualMappings
: defaultMappings;
// Compute final mapping with overrides
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings,
printerPreferLowest,
inventoryByTrayId,
);
return {
printerId,
printerName,
status: printerStatus,
isLoading: query?.isLoading ?? false,
loadedFilaments,
autoMapping,
finalMapping,
matchStatus: matchDetails.status,
exactMatches: matchDetails.exactMatches,
typeOnlyMatches: matchDetails.typeOnlyMatches,
missingTypes: matchDetails.missingTypes,
totalSlots: matchDetails.totalSlots,
config,
inventoryByTrayId,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
const isLoading = statusQueries.some((q) => q.isLoading);
// Update config for a specific printer
const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
setPerPrinterConfigs((prev) => ({
...prev,
[printerId]: {
...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
...updates,
},
}));
};
// Auto-configure a specific printer based on its loaded filaments
const autoConfigurePrinter = (printerId: number) => {
const result = printerResults.find((r) => r.printerId === printerId);
if (!result || !result.status || !filamentReqs?.filaments) return;
// Compute optimal mapping for this printer
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
const manualMappings: Record<number, number> = {};
autoMapping.forEach((globalTrayId, index) => {
if (globalTrayId !== -1) {
manualMappings[index + 1] = globalTrayId;
}
});
updatePrinterConfig(printerId, {
useDefault: false,
manualMappings,
autoConfigured: true,
});
};
// Auto-configure all printers
const autoConfigureAll = () => {
for (const printerId of selectedPrinterIds) {
autoConfigurePrinter(printerId);
}
};
// Get final mapping for a specific printer (for submission)
const getFinalMapping = (printerId: number): number[] | undefined => {
const result = printerResults.find((r) => r.printerId === printerId);
return result?.finalMapping;
};
// Check if all printers have acceptable mappings (no missing types)
const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
return {
printerResults,
isLoading,
perPrinterConfigs,
updatePrinterConfig,
autoConfigureAll,
autoConfigurePrinter,
getFinalMapping,
allPrintersReady,
};
}