useMultiPrinterFilamentMapping.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import { useMemo } from 'react';
  2. import { useQueries } from '@tanstack/react-query';
  3. import { api } from '../api/client';
  4. import type { PrinterStatus, Printer } from '../api/client';
  5. import {
  6. buildLoadedFilaments,
  7. computeAmsMapping,
  8. type LoadedFilament,
  9. type FilamentRequirement,
  10. } from './useFilamentMapping';
  11. import {
  12. normalizeColorForCompare,
  13. colorsAreSimilar,
  14. preferLowestSortKey,
  15. compareSortKeys,
  16. effectivePreferLowest,
  17. } from '../utils/amsHelpers';
  18. /**
  19. * Match status for a single printer's filament configuration.
  20. */
  21. export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
  22. /**
  23. * Per-printer configuration for AMS mapping.
  24. */
  25. export interface PerPrinterConfig {
  26. /** Whether this printer uses the default mapping or has custom config */
  27. useDefault: boolean;
  28. /** Manual slot overrides for this printer (slot_id -> globalTrayId) */
  29. manualMappings: Record<number, number>;
  30. /** Whether this mapping was auto-configured */
  31. autoConfigured: boolean;
  32. }
  33. /**
  34. * Result of filament mapping for a single printer.
  35. */
  36. export interface PrinterMappingResult {
  37. printerId: number;
  38. printerName: string;
  39. /** Printer status data */
  40. status: PrinterStatus | undefined;
  41. /** Whether status is still loading */
  42. isLoading: boolean;
  43. /** List of loaded filaments in this printer */
  44. loadedFilaments: LoadedFilament[];
  45. /** Auto-computed AMS mapping for this printer */
  46. autoMapping: number[] | undefined;
  47. /** Final AMS mapping (considering manual overrides) */
  48. finalMapping: number[] | undefined;
  49. /** Match status: full (all exact), partial (some mismatches), missing (type not found) */
  50. matchStatus: PrinterMatchStatus;
  51. /** Number of slots with exact match (type + color) */
  52. exactMatches: number;
  53. /** Number of slots with type-only match */
  54. typeOnlyMatches: number;
  55. /** Number of slots with missing type */
  56. missingTypes: number;
  57. /** Total required slots */
  58. totalSlots: number;
  59. /** Per-printer config */
  60. config: PerPrinterConfig;
  61. /** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */
  62. inventoryByTrayId?: Map<number, number>;
  63. }
  64. /**
  65. * Result of the useMultiPrinterFilamentMapping hook.
  66. */
  67. export interface UseMultiPrinterFilamentMappingResult {
  68. /** Results for each selected printer */
  69. printerResults: PrinterMappingResult[];
  70. /** Whether any printer data is still loading */
  71. isLoading: boolean;
  72. /** Per-printer configurations */
  73. perPrinterConfigs: Record<number, PerPrinterConfig>;
  74. /** Update config for a specific printer */
  75. updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
  76. /** Auto-configure all printers based on their loaded filaments */
  77. autoConfigureAll: () => void;
  78. /** Auto-configure a specific printer */
  79. autoConfigurePrinter: (printerId: number) => void;
  80. /** Get final mapping for a specific printer (for submission) */
  81. getFinalMapping: (printerId: number) => number[] | undefined;
  82. /** Check if all printers have acceptable mappings */
  83. allPrintersReady: boolean;
  84. }
  85. /**
  86. * Compute match details for a printer given filament requirements and loaded filaments.
  87. */
  88. function computeMatchDetails(
  89. filamentReqs: FilamentRequirement[] | undefined,
  90. loadedFilaments: LoadedFilament[],
  91. manualMappings: Record<number, number>,
  92. preferLowest?: boolean,
  93. inventoryByTrayId?: Map<number, number>,
  94. ): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
  95. if (!filamentReqs || filamentReqs.length === 0) {
  96. return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
  97. }
  98. let exactMatches = 0;
  99. let typeOnlyMatches = 0;
  100. let missingTypes = 0;
  101. const usedTrayIds = new Set<number>(Object.values(manualMappings));
  102. for (const req of filamentReqs) {
  103. const slotId = req.slot_id || 0;
  104. // Check manual override first
  105. if (slotId > 0 && manualMappings[slotId] !== undefined) {
  106. const manualTrayId = manualMappings[slotId];
  107. const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
  108. if (manualLoaded) {
  109. const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
  110. const colorMatch =
  111. normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
  112. colorsAreSimilar(manualLoaded.color, req.color);
  113. if (typeMatch && colorMatch) {
  114. exactMatches++;
  115. } else if (typeMatch) {
  116. typeOnlyMatches++;
  117. } else {
  118. missingTypes++;
  119. }
  120. continue;
  121. }
  122. }
  123. // Auto-match with nozzle-aware filtering
  124. let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
  125. if (req.nozzle_id != null) {
  126. const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
  127. if (nozzleFiltered.length > 0) {
  128. candidates = nozzleFiltered;
  129. }
  130. }
  131. if (preferLowest) {
  132. candidates = [...candidates].sort((a, b) =>
  133. compareSortKeys(
  134. preferLowestSortKey(a, inventoryByTrayId),
  135. preferLowestSortKey(b, inventoryByTrayId),
  136. ),
  137. );
  138. }
  139. const exactMatch = candidates.find(
  140. (f) =>
  141. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  142. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  143. );
  144. const similarMatch = exactMatch
  145. ? undefined
  146. : candidates.find(
  147. (f) =>
  148. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  149. colorsAreSimilar(f.color, req.color)
  150. );
  151. const typeOnlyMatch =
  152. exactMatch || similarMatch
  153. ? undefined
  154. : candidates.find(
  155. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  156. );
  157. const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
  158. if (loaded) {
  159. usedTrayIds.add(loaded.globalTrayId);
  160. }
  161. if (exactMatch || similarMatch) {
  162. exactMatches++;
  163. } else if (typeOnlyMatch) {
  164. typeOnlyMatches++;
  165. } else {
  166. missingTypes++;
  167. }
  168. }
  169. const totalSlots = filamentReqs.length;
  170. let status: PrinterMatchStatus = 'full';
  171. if (missingTypes > 0) {
  172. status = 'missing';
  173. } else if (typeOnlyMatches > 0) {
  174. status = 'partial';
  175. }
  176. return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
  177. }
  178. /**
  179. * Compute AMS mapping with manual overrides applied.
  180. */
  181. function computeMappingWithOverrides(
  182. filamentReqs: { filaments: FilamentRequirement[] } | undefined,
  183. printerStatus: PrinterStatus | undefined,
  184. manualMappings: Record<number, number>,
  185. preferLowest?: boolean,
  186. inventoryByTrayId?: Map<number, number>,
  187. ): number[] | undefined {
  188. if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
  189. const loadedFilaments = buildLoadedFilaments(printerStatus);
  190. if (loadedFilaments.length === 0) return undefined;
  191. const usedTrayIds = new Set<number>(Object.values(manualMappings));
  192. const comparisons: { slot_id: number; globalTrayId: number }[] = [];
  193. for (const req of filamentReqs.filaments) {
  194. const slotId = req.slot_id || 0;
  195. // Check manual override first
  196. if (slotId > 0 && manualMappings[slotId] !== undefined) {
  197. comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
  198. continue;
  199. }
  200. // Auto-match with nozzle-aware filtering
  201. let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
  202. if (req.nozzle_id != null) {
  203. const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
  204. if (nozzleFiltered.length > 0) {
  205. candidates = nozzleFiltered;
  206. }
  207. }
  208. if (preferLowest) {
  209. candidates = [...candidates].sort((a, b) =>
  210. compareSortKeys(
  211. preferLowestSortKey(a, inventoryByTrayId),
  212. preferLowestSortKey(b, inventoryByTrayId),
  213. ),
  214. );
  215. }
  216. const exactMatch = candidates.find(
  217. (f) =>
  218. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  219. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  220. );
  221. const similarMatch = exactMatch
  222. ? undefined
  223. : candidates.find(
  224. (f) =>
  225. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  226. colorsAreSimilar(f.color, req.color)
  227. );
  228. const typeOnlyMatch =
  229. exactMatch || similarMatch
  230. ? undefined
  231. : candidates.find(
  232. (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
  233. );
  234. const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
  235. if (loaded) {
  236. usedTrayIds.add(loaded.globalTrayId);
  237. }
  238. comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
  239. }
  240. const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
  241. if (maxSlotId <= 0) return undefined;
  242. const mapping = new Array(maxSlotId).fill(-1);
  243. comparisons.forEach((f) => {
  244. if (f.slot_id && f.slot_id > 0) {
  245. mapping[f.slot_id - 1] = f.globalTrayId;
  246. }
  247. });
  248. return mapping;
  249. }
  250. /**
  251. * Default per-printer config (use default mapping).
  252. */
  253. const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
  254. useDefault: true,
  255. manualMappings: {},
  256. autoConfigured: false,
  257. };
  258. /**
  259. * Hook to manage filament mapping for multiple printers.
  260. * Fetches printer status for all selected printers and computes per-printer mappings.
  261. */
  262. export function useMultiPrinterFilamentMapping(
  263. selectedPrinterIds: number[],
  264. printers: Printer[] | undefined,
  265. filamentReqs: { filaments: FilamentRequirement[] } | undefined,
  266. defaultMappings: Record<number, number>,
  267. perPrinterConfigs: Record<number, PerPrinterConfig>,
  268. setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
  269. preferLowest?: boolean,
  270. inventoryByTrayIdPerPrinter?: Map<number, Map<number, number>>,
  271. ): UseMultiPrinterFilamentMappingResult {
  272. // Fetch printer status for all selected printers in parallel
  273. const statusQueries = useQueries({
  274. queries: selectedPrinterIds.map((printerId) => ({
  275. queryKey: ['printer-status', printerId],
  276. queryFn: () => api.getPrinterStatus(printerId),
  277. enabled: selectedPrinterIds.length > 0,
  278. staleTime: 5000, // Consider data fresh for 5 seconds
  279. })),
  280. });
  281. // Build results for each printer
  282. const printerResults = useMemo((): PrinterMappingResult[] => {
  283. return selectedPrinterIds.map((printerId, index) => {
  284. const query = statusQueries[index];
  285. const printerStatus = query?.data;
  286. const printer = printers?.find((p) => p.id === printerId);
  287. const printerName = printer?.name || `Printer ${printerId}`;
  288. const loadedFilaments = buildLoadedFilaments(printerStatus);
  289. const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
  290. const inventoryByTrayId = inventoryByTrayIdPerPrinter?.get(printerId);
  291. // Per-printer gate (#1766): two printers in the same dispatch can have
  292. // different AMS Backup states; the sort must be skipped on the OFF ones
  293. // and kept on the ON ones. Computing inside the loop captures both.
  294. const printerPreferLowest = effectivePreferLowest(preferLowest, printerStatus?.ams_filament_backup);
  295. // Compute auto mapping for this printer
  296. const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId);
  297. // Determine which mappings to use:
  298. // If printer has override (useDefault=false), use its custom mappings
  299. // Otherwise use the default mappings
  300. const effectiveMappings = !config.useDefault
  301. ? config.manualMappings
  302. : defaultMappings;
  303. // Compute final mapping with overrides
  304. const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
  305. // Compute match details
  306. const matchDetails = computeMatchDetails(
  307. filamentReqs?.filaments,
  308. loadedFilaments,
  309. effectiveMappings,
  310. printerPreferLowest,
  311. inventoryByTrayId,
  312. );
  313. return {
  314. printerId,
  315. printerName,
  316. status: printerStatus,
  317. isLoading: query?.isLoading ?? false,
  318. loadedFilaments,
  319. autoMapping,
  320. finalMapping,
  321. matchStatus: matchDetails.status,
  322. exactMatches: matchDetails.exactMatches,
  323. typeOnlyMatches: matchDetails.typeOnlyMatches,
  324. missingTypes: matchDetails.missingTypes,
  325. totalSlots: matchDetails.totalSlots,
  326. config,
  327. inventoryByTrayId,
  328. };
  329. });
  330. }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
  331. const isLoading = statusQueries.some((q) => q.isLoading);
  332. // Update config for a specific printer
  333. const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
  334. setPerPrinterConfigs((prev) => ({
  335. ...prev,
  336. [printerId]: {
  337. ...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
  338. ...updates,
  339. },
  340. }));
  341. };
  342. // Auto-configure a specific printer based on its loaded filaments
  343. const autoConfigurePrinter = (printerId: number) => {
  344. const result = printerResults.find((r) => r.printerId === printerId);
  345. if (!result || !result.status || !filamentReqs?.filaments) return;
  346. // Compute optimal mapping for this printer
  347. const autoMapping = computeAmsMapping(
  348. filamentReqs,
  349. result.status,
  350. effectivePreferLowest(preferLowest, result.status?.ams_filament_backup),
  351. inventoryByTrayIdPerPrinter?.get(printerId),
  352. );
  353. if (!autoMapping) return;
  354. // Convert autoMapping array to manualMappings record
  355. const manualMappings: Record<number, number> = {};
  356. autoMapping.forEach((globalTrayId, index) => {
  357. if (globalTrayId !== -1) {
  358. manualMappings[index + 1] = globalTrayId;
  359. }
  360. });
  361. updatePrinterConfig(printerId, {
  362. useDefault: false,
  363. manualMappings,
  364. autoConfigured: true,
  365. });
  366. };
  367. // Auto-configure all printers
  368. const autoConfigureAll = () => {
  369. for (const printerId of selectedPrinterIds) {
  370. autoConfigurePrinter(printerId);
  371. }
  372. };
  373. // Get final mapping for a specific printer (for submission)
  374. const getFinalMapping = (printerId: number): number[] | undefined => {
  375. const result = printerResults.find((r) => r.printerId === printerId);
  376. return result?.finalMapping;
  377. };
  378. // Check if all printers have acceptable mappings (no missing types)
  379. const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
  380. return {
  381. printerResults,
  382. isLoading,
  383. perPrinterConfigs,
  384. updatePrinterConfig,
  385. autoConfigureAll,
  386. autoConfigurePrinter,
  387. getFinalMapping,
  388. allPrintersReady,
  389. };
  390. }