Files
bambuddy/frontend/src/hooks/useMultiPrinterFilamentMapping.ts
T
maziggy e1224316a7 Add per-printer AMS mapping for multi-printer selection
- New useMultiPrinterFilamentMapping hook for parallel printer status fetching
- Per-printer custom slot configuration with "Custom mapping" checkbox
- Auto-configure using RFID data to match filaments by type/color
- Match status indicator (exact/partial/missing) under each printer
- Re-read button to refresh loaded filaments
- New setting: "Expand custom mapping by default" in Settings → Filament
- FilamentMapping component now accepts defaultExpanded prop

Frontend:
- PrinterSelector: Inline mapping editor for each selected printer
- PrintModal: Fetches settings, auto-expands mapping when setting enabled
- SettingsPage: Toggle for per_printer_mapping_expanded

Backend:
- Added per_printer_mapping_expanded to AppSettings schema
- Added boolean parsing in settings routes
- Added integration tests for new setting

Closes #104
2026-01-20 16:59:18 +01:00

386 lines
12 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,
} 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;
}
/**
* 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>
): { 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
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && 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>
): 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
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && 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>>>
): 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;
// Compute auto mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
// 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);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings
);
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,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
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);
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,
};
}