mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
- 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:
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user