- Fix AMS filament matching in reprint modal

- Add fuzzy color matching for filament comparison
    Colors within RGB threshold of 40 are considered a match, fixing
    cases where 3MF and printer report slightly different hex values
    for the same filament color (e.g., beige #F5F5DC vs #FCECD6)

  - Add POST /printers/{id}/refresh-status endpoint
    Sends MQTT pushall command to request fresh AMS data from printer

  - Improve Re-read button in filament check section
    - Now triggers backend refresh before refetching status
    - More prominent styling with text label and border
    - 500ms delay after pushall to allow printer to respond

  - Add AMS slot labels to matched filament display
    Shows location like "PLA (AMS-B Slot 3)" instead of just "PLA"

  - Add color hex tooltips on filament color circles
This commit is contained in:
maziggy
2025-12-29 10:03:51 +01:00
parent a008be96ee
commit 3cba1c0989
7 changed files with 248 additions and 171 deletions
+15
View File
@@ -334,6 +334,21 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
)
@router.post("/{printer_id}/refresh-status")
async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
"""Request a full status refresh from the printer (sends pushall command)."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
success = printer_manager.request_status_update(printer_id)
if not success:
raise HTTPException(400, "Printer not connected")
return {"status": "refresh_requested"}
@router.post("/{printer_id}/connect")
async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
"""Manually connect to a printer."""
+4
View File
@@ -1253,6 +1253,10 @@ export const api = {
),
getPrinterStatus: (id: number) =>
request<PrinterStatus>(`/printers/${id}/status`),
refreshPrinterStatus: (id: number) =>
request<{ status: string }>(`/printers/${id}/refresh-status`, {
method: 'POST',
}),
connectPrinter: (id: number) =>
request<{ connected: boolean }>(`/printers/${id}/connect`, {
method: 'POST',
+146 -88
View File
@@ -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
// 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}`;
};
// 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}`;
};
// Build a list of all loaded filaments from printer's AMS/HT/External with location info
const loadedFilaments = useMemo(() => {
if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
const filaments: Array<{
type: string;
color: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
}> = [];
const map = new Map<number, { type: string; color: string }>();
// 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 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) => {
// 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) => {
// 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, {
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),
});
}
});
});
// 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) => {
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
map.set(htSlotBase + tray.id, {
type: tray.tray_type,
color: normalizeColor(tray.tray_color),
});
}
});
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);
const hasFilament = !!loaded;
const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
// 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;
// Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
// 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 ? (
<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" />
Empty slots
</span>
) : filamentComparison.some((f) => f.status === 'mismatch') ? (
<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>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-303ohz8i.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CiDcmh7W.css">
<script type="module" crossorigin src="/assets/index-Bv0AILUD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BOEy1ke3.css">
</head>
<body>
<div id="root"></div>