mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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.
440 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|