|
|
@@ -1,6 +1,6 @@
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
|
-import { useQuery, useMutation } from '@tanstack/react-query';
|
|
|
-import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
+import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw } from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
|
import { Card, CardContent } from './Card';
|
|
|
import { Button } from './Button';
|
|
|
@@ -13,7 +13,9 @@ interface ReprintModalProps {
|
|
|
}
|
|
|
|
|
|
export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
|
|
|
+ const queryClient = useQueryClient();
|
|
|
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
|
|
|
+ const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
|
|
// Close on Escape key
|
|
|
useEffect(() => {
|
|
|
@@ -55,93 +57,126 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|
|
|
|
|
const activePrinters = printers?.filter((p) => p.is_active) || [];
|
|
|
|
|
|
- // Build a map of AMS slot positions to loaded filaments
|
|
|
- // Bambu Lab slot numbering: slot = amsId * 4 + trayId + 1 (for regular AMS)
|
|
|
- // AMS-HT (id >= 128) is special - uses its position in the array
|
|
|
- // External spool: slot 254, No filament: slot 255
|
|
|
- const loadedFilaments = useMemo(() => {
|
|
|
- if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
|
|
|
-
|
|
|
- const map = new Map<number, { type: string; color: string }>();
|
|
|
+ // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
|
|
|
+ const normalizeColor = (color: string | null | undefined): string => {
|
|
|
+ if (!color) return '#808080';
|
|
|
+ // Remove alpha channel if present (8-char hex to 6-char)
|
|
|
+ const hex = color.replace('#', '').substring(0, 6);
|
|
|
+ return `#${hex}`;
|
|
|
+ };
|
|
|
|
|
|
- // Sort AMS units by ID to get consistent ordering, filter out AMS-HT for now
|
|
|
- const regularAms = printerStatus.ams
|
|
|
- .filter((ams) => ams.id < 128)
|
|
|
- .sort((a, b) => a.id - b.id);
|
|
|
+ // Helper to format slot label for display
|
|
|
+ const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
|
|
|
+ if (isExternal) return 'External';
|
|
|
+ const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
|
|
|
+ if (isHt) return `HT-${letter}`;
|
|
|
+ return `AMS-${letter} Slot ${trayId + 1}`;
|
|
|
+ };
|
|
|
|
|
|
- // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
|
|
|
- const normalizeColor = (color: string | null | undefined): string => {
|
|
|
- if (!color) return '#808080';
|
|
|
- // Remove alpha channel if present (8-char hex to 6-char)
|
|
|
- const hex = color.replace('#', '').substring(0, 6);
|
|
|
- return `#${hex}`;
|
|
|
- };
|
|
|
-
|
|
|
- regularAms.forEach((amsUnit) => {
|
|
|
- amsUnit.tray.forEach((tray) => {
|
|
|
- // Calculate global slot ID (1-based to match 3MF)
|
|
|
- // AMS 0 tray 0 = slot 1, AMS 0 tray 1 = slot 2, etc.
|
|
|
- const globalSlotId = amsUnit.id * 4 + tray.id + 1;
|
|
|
- if (tray.tray_type) {
|
|
|
- map.set(globalSlotId, {
|
|
|
- type: tray.tray_type,
|
|
|
- color: normalizeColor(tray.tray_color),
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
+ // Build a list of all loaded filaments from printer's AMS/HT/External with location info
|
|
|
+ const loadedFilaments = useMemo(() => {
|
|
|
+ const filaments: Array<{
|
|
|
+ type: string;
|
|
|
+ color: string;
|
|
|
+ amsId: number;
|
|
|
+ trayId: number;
|
|
|
+ isHt: boolean;
|
|
|
+ isExternal: boolean;
|
|
|
+ label: string;
|
|
|
+ }> = [];
|
|
|
|
|
|
- // AMS-HT units get slots after regular AMS slots
|
|
|
- const amsHtUnits = printerStatus.ams.filter((ams) => ams.id >= 128);
|
|
|
- let htSlotBase = regularAms.length * 4 + 1;
|
|
|
- amsHtUnits.forEach((amsUnit) => {
|
|
|
+ // Add filaments from all AMS units (regular and HT)
|
|
|
+ printerStatus?.ams?.forEach((amsUnit) => {
|
|
|
+ const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
|
|
|
amsUnit.tray.forEach((tray) => {
|
|
|
if (tray.tray_type) {
|
|
|
- map.set(htSlotBase + tray.id, {
|
|
|
+ filaments.push({
|
|
|
type: tray.tray_type,
|
|
|
color: normalizeColor(tray.tray_color),
|
|
|
+ amsId: amsUnit.id,
|
|
|
+ trayId: tray.id,
|
|
|
+ isHt,
|
|
|
+ isExternal: false,
|
|
|
+ label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
- htSlotBase += amsUnit.tray.length;
|
|
|
});
|
|
|
|
|
|
- // Add virtual tray (external spool) as slot 254 (Bambu standard)
|
|
|
- if (printerStatus.vt_tray?.tray_type) {
|
|
|
- map.set(254, {
|
|
|
+ // Add external spool if loaded
|
|
|
+ if (printerStatus?.vt_tray?.tray_type) {
|
|
|
+ filaments.push({
|
|
|
type: printerStatus.vt_tray.tray_type,
|
|
|
color: normalizeColor(printerStatus.vt_tray.tray_color),
|
|
|
+ amsId: -1,
|
|
|
+ trayId: 0,
|
|
|
+ isHt: false,
|
|
|
+ isExternal: true,
|
|
|
+ label: 'External',
|
|
|
});
|
|
|
}
|
|
|
- return map;
|
|
|
+
|
|
|
+ return filaments;
|
|
|
}, [printerStatus]);
|
|
|
|
|
|
// Compare required filaments with loaded filaments
|
|
|
+ // Match by filament TYPE (not slot), since the printer dynamically maps slots
|
|
|
const filamentComparison = useMemo(() => {
|
|
|
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
|
|
|
|
|
|
// Helper to normalize color for comparison (case-insensitive, strip #)
|
|
|
const normalizeColorForCompare = (color: string | undefined): string => {
|
|
|
if (!color) return '';
|
|
|
- return color.replace('#', '').toLowerCase();
|
|
|
+ return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
|
|
|
+ };
|
|
|
+
|
|
|
+ // Helper to check if two colors are similar (within threshold)
|
|
|
+ const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
|
|
|
+ const hex1 = normalizeColorForCompare(color1);
|
|
|
+ const hex2 = normalizeColorForCompare(color2);
|
|
|
+ if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
|
|
|
+
|
|
|
+ const r1 = parseInt(hex1.substring(0, 2), 16);
|
|
|
+ const g1 = parseInt(hex1.substring(2, 4), 16);
|
|
|
+ const b1 = parseInt(hex1.substring(4, 6), 16);
|
|
|
+ const r2 = parseInt(hex2.substring(0, 2), 16);
|
|
|
+ const g2 = parseInt(hex2.substring(2, 4), 16);
|
|
|
+ const b2 = parseInt(hex2.substring(4, 6), 16);
|
|
|
+
|
|
|
+ // Check if each RGB component is within threshold
|
|
|
+ return Math.abs(r1 - r2) <= threshold &&
|
|
|
+ Math.abs(g1 - g2) <= threshold &&
|
|
|
+ Math.abs(b1 - b2) <= threshold;
|
|
|
};
|
|
|
|
|
|
return filamentReqs.filaments.map((req) => {
|
|
|
- const loaded = loadedFilaments.get(req.slot_id);
|
|
|
+ // Find a loaded filament that matches by TYPE (printer will auto-map the slot)
|
|
|
+ // Priority: exact color match > similar color match > type-only match
|
|
|
+ const exactMatch = loadedFilaments.find(
|
|
|
+ (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
|
|
+ normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
|
|
+ );
|
|
|
+ const similarMatch = !exactMatch && loadedFilaments.find(
|
|
|
+ (f) => f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
|
|
+ colorsAreSimilar(f.color, req.color)
|
|
|
+ );
|
|
|
+ const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
|
|
|
+ (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
|
|
|
+ );
|
|
|
+ const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
|
|
|
+
|
|
|
const hasFilament = !!loaded;
|
|
|
- const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
|
|
|
- const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
|
|
|
+ const typeMatch = hasFilament;
|
|
|
+ const colorMatch = !!exactMatch || !!similarMatch;
|
|
|
|
|
|
- // Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
|
|
|
+ // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
|
|
|
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
|
|
|
- if (!hasFilament) {
|
|
|
- status = 'empty';
|
|
|
- } else if (typeMatch && colorMatch) {
|
|
|
+ if (exactMatch || similarMatch) {
|
|
|
status = 'match';
|
|
|
- } else if (typeMatch) {
|
|
|
- status = 'type_only'; // Same type, different color
|
|
|
+ } else if (typeOnlyMatch) {
|
|
|
+ status = 'type_only';
|
|
|
} else {
|
|
|
- status = 'mismatch'; // Different type
|
|
|
+ status = 'mismatch';
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
@@ -155,8 +190,7 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|
|
});
|
|
|
}, [filamentReqs, loadedFilaments]);
|
|
|
|
|
|
- const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
|
|
|
- const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
|
|
|
+ const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
|
|
|
|
|
|
return (
|
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
|
|
|
@@ -226,15 +260,31 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|
|
<div className="mb-4">
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
<span className="text-sm text-bambu-gray">Filament Check</span>
|
|
|
- {hasEmptySlots ? (
|
|
|
- <span className="text-xs text-orange-400 flex items-center gap-1">
|
|
|
- <AlertTriangle className="w-3 h-3" />
|
|
|
- Empty slots
|
|
|
- </span>
|
|
|
- ) : filamentComparison.some((f) => f.status === 'mismatch') ? (
|
|
|
+ <button
|
|
|
+ onClick={async () => {
|
|
|
+ if (!selectedPrinter) return;
|
|
|
+ setIsRefreshing(true);
|
|
|
+ try {
|
|
|
+ // Request fresh data from printer via MQTT pushall command
|
|
|
+ await api.refreshPrinterStatus(selectedPrinter);
|
|
|
+ // Wait a moment for printer to respond, then refetch
|
|
|
+ await new Promise((r) => setTimeout(r, 500));
|
|
|
+ await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
|
|
|
+ } finally {
|
|
|
+ setIsRefreshing(false);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
|
|
|
+ title="Re-read AMS status from printer"
|
|
|
+ disabled={isRefreshing}
|
|
|
+ >
|
|
|
+ <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
|
+ <span>Re-read</span>
|
|
|
+ </button>
|
|
|
+ {hasTypeMismatch ? (
|
|
|
<span className="text-xs text-orange-400 flex items-center gap-1">
|
|
|
<AlertTriangle className="w-3 h-3" />
|
|
|
- Type mismatch
|
|
|
+ Type not found
|
|
|
</span>
|
|
|
) : filamentComparison.some((f) => f.status === 'type_only') ? (
|
|
|
<span className="text-xs text-yellow-400 flex items-center gap-1">
|
|
|
@@ -249,20 +299,20 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|
|
)}
|
|
|
</div>
|
|
|
<div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
|
|
|
- {filamentComparison.map((item) => (
|
|
|
+ {filamentComparison.map((item, idx) => (
|
|
|
<div
|
|
|
- key={item.slot_id}
|
|
|
+ key={idx}
|
|
|
className="grid items-center gap-2"
|
|
|
- style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
|
|
|
+ style={{ gridTemplateColumns: '16px 1fr auto 16px 1fr 16px' }}
|
|
|
>
|
|
|
- {/* Slot label */}
|
|
|
- <span className="text-bambu-gray">Slot {item.slot_id}</span>
|
|
|
{/* Required color */}
|
|
|
- <Circle
|
|
|
- className="w-3 h-3 flex-shrink-0"
|
|
|
- fill={item.color}
|
|
|
- stroke={item.color}
|
|
|
- />
|
|
|
+ <span title={`Required: ${item.color}`}>
|
|
|
+ <Circle
|
|
|
+ className="w-3 h-3 flex-shrink-0"
|
|
|
+ fill={item.color}
|
|
|
+ stroke={item.color}
|
|
|
+ />
|
|
|
+ </span>
|
|
|
{/* Required type + grams */}
|
|
|
<span className="text-white truncate">
|
|
|
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
|
|
|
@@ -271,38 +321,46 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|
|
<span className="text-bambu-gray">→</span>
|
|
|
{/* Loaded color */}
|
|
|
{item.loaded ? (
|
|
|
- <Circle
|
|
|
- className="w-3 h-3 flex-shrink-0"
|
|
|
- fill={item.loaded.color}
|
|
|
- stroke={item.loaded.color}
|
|
|
- />
|
|
|
+ <span title={`Loaded: ${item.loaded.color}`}>
|
|
|
+ <Circle
|
|
|
+ className="w-3 h-3 flex-shrink-0"
|
|
|
+ fill={item.loaded.color}
|
|
|
+ stroke={item.loaded.color}
|
|
|
+ />
|
|
|
+ </span>
|
|
|
) : (
|
|
|
<span />
|
|
|
)}
|
|
|
- {/* Loaded type */}
|
|
|
+ {/* Loaded type + slot */}
|
|
|
<span className={
|
|
|
item.status === 'match' ? 'text-bambu-green' :
|
|
|
item.status === 'type_only' ? 'text-yellow-400' :
|
|
|
'text-orange-400'
|
|
|
}>
|
|
|
- {item.loaded?.type || 'Empty'}
|
|
|
+ {item.loaded ? (
|
|
|
+ <>{item.loaded.type} <span className="text-bambu-gray">({item.loaded.label})</span></>
|
|
|
+ ) : (
|
|
|
+ 'Not loaded'
|
|
|
+ )}
|
|
|
</span>
|
|
|
{/* Status icon */}
|
|
|
{item.status === 'match' ? (
|
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
|
) : item.status === 'type_only' ? (
|
|
|
- <span title="Color mismatch">
|
|
|
+ <span title="Same type, different color">
|
|
|
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
|
|
</span>
|
|
|
) : (
|
|
|
- <AlertTriangle className="w-3 h-3 text-orange-400" />
|
|
|
+ <span title="Filament type not loaded">
|
|
|
+ <AlertTriangle className="w-3 h-3 text-orange-400" />
|
|
|
+ </span>
|
|
|
)}
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
- {(hasAnyMismatch || hasEmptySlots) && (
|
|
|
+ {hasTypeMismatch && (
|
|
|
<p className="text-xs text-orange-400 mt-2">
|
|
|
- The printer may load different filaments than expected.
|
|
|
+ Required filament type not found in printer.
|
|
|
</p>
|
|
|
)}
|
|
|
</div>
|