mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
The FTS routes any AMS slot to either extruder, so AMS info reports
bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
The print modal's per-nozzle dropdown filter then hides every loaded
slot, leaving the user with an empty filament dropdown.
Detection: parse print.device.fila_switch from MQTT push_status into a
new FilaSwitchState dataclass on PrinterState; surface it through the
GET /printers/{id}/status response as a nullable FilaSwitchResponse.
Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
filter when fila_switch.installed is true. Slots currently fed into a
track display an [L]/[R] routing badge in the dropdown so the user
can see where the FTS is currently routing them.
Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
integration (status route), 2 hook regression, 2 component regression.
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import { api } from '../../api/client';
|
|
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
|
import { getGlobalTrayId } from '../../utils/amsHelpers';
|
|
import { getColorName } from '../../utils/colors';
|
|
import type { FilamentMappingProps } from './types';
|
|
|
|
/**
|
|
* Filament mapping UI for comparing required filaments with loaded AMS slots.
|
|
* Shows auto-matched and manually overridden slot assignments.
|
|
*/
|
|
export function FilamentMapping({
|
|
printerId,
|
|
filamentReqs,
|
|
manualMappings,
|
|
onManualMappingChange,
|
|
currencySymbol,
|
|
defaultCostPerKg,
|
|
defaultExpanded = false,
|
|
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
|
|
|
// Fetch printer status
|
|
const { data: printerStatus } = useQuery({
|
|
queryKey: ['printer-status', printerId],
|
|
queryFn: () => api.getPrinterStatus(printerId),
|
|
enabled: !!printerId,
|
|
});
|
|
|
|
const { data: assignments } = useQuery({
|
|
queryKey: ['spool-assignments', printerId],
|
|
queryFn: () => api.getAssignments(printerId),
|
|
enabled: !!printerId,
|
|
});
|
|
|
|
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
|
|
useFilamentMapping(filamentReqs, printerStatus, manualMappings);
|
|
|
|
const trayCostMap = useMemo(() => {
|
|
const map = new Map<number, number | null>();
|
|
for (const assignment of assignments || []) {
|
|
const isExternal = assignment.ams_id === 255;
|
|
const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
|
|
map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null);
|
|
}
|
|
return map;
|
|
}, [assignments]);
|
|
|
|
const trayRemainingWeightMap = useMemo(() => {
|
|
const map = new Map<number, number | null>();
|
|
for (const assignment of assignments || []) {
|
|
const isExternal = assignment.ams_id === 255;
|
|
const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
|
|
const spool = assignment.spool;
|
|
if (!spool) {
|
|
map.set(globalTrayId, null);
|
|
continue;
|
|
}
|
|
map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0))));
|
|
}
|
|
return map;
|
|
}, [assignments]);
|
|
|
|
const totalCost = useMemo(() => {
|
|
let total = 0;
|
|
for (const item of filamentComparison) {
|
|
const trayId = item.loaded?.globalTrayId;
|
|
if (trayId == null) continue;
|
|
const assignedCost = trayCostMap.get(trayId) ?? null;
|
|
const costPerKg = assignedCost ?? defaultCostPerKg;
|
|
if (costPerKg > 0) {
|
|
total += (item.used_grams / 1000) * costPerKg;
|
|
}
|
|
}
|
|
return total;
|
|
}, [filamentComparison, trayCostMap, defaultCostPerKg]);
|
|
|
|
const hasAnyCost = useMemo(
|
|
() => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
|
|
[trayCostMap]
|
|
);
|
|
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
|
|
const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
|
|
|
|
// Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
|
|
// (any slot can be routed to either extruder), so the per-nozzle dropdown
|
|
// filter is suppressed. fila_switch.in_slots[track] = currently fed slot,
|
|
// fila_switch.out_extruders[track] = extruder that track terminates at. See #1162.
|
|
const ftsInstalled = printerStatus?.fila_switch?.installed === true;
|
|
const ftsExtruderForSlot = (globalTrayId: number): number | null => {
|
|
const fs = printerStatus?.fila_switch;
|
|
if (!fs?.installed) return null;
|
|
const track = fs.in_slots.indexOf(globalTrayId);
|
|
if (track < 0) return null;
|
|
return fs.out_extruders[track] ?? null;
|
|
};
|
|
|
|
// Don't render if no filament requirements
|
|
if (!hasFilamentReqs) {
|
|
return null;
|
|
}
|
|
|
|
// Don't render until we have printer status to do the comparison
|
|
if (!printerStatus) {
|
|
return null;
|
|
}
|
|
|
|
// Determine status indicator color
|
|
const statusColor = hasTypeMismatch
|
|
? '#f97316' // orange
|
|
: hasColorMismatch
|
|
? '#facc15' // yellow
|
|
: '#00ae42'; // green
|
|
|
|
const handleSlotChange = (slotId: number, value: string) => {
|
|
if (slotId > 0) {
|
|
if (value === '') {
|
|
// Clear manual override
|
|
const next = { ...manualMappings };
|
|
delete next[slotId];
|
|
onManualMappingChange(next);
|
|
} else {
|
|
onManualMappingChange({
|
|
...manualMappings,
|
|
[slotId]: parseInt(value, 10),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
// Request fresh data from printer via MQTT pushall command
|
|
await api.refreshPrinterStatus(printerId);
|
|
// Wait a moment for printer to respond, then refetch
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
|
|
>
|
|
<Circle className="w-4 h-4" fill={statusColor} stroke="none" />
|
|
<span>{t('printModal.filamentMapping')}</span>
|
|
{hasTypeMismatch ? (
|
|
<span className="text-xs text-orange-400">(Type not found)</span>
|
|
) : hasColorMismatch ? (
|
|
<span className="text-xs text-yellow-400">(Color mismatch)</span>
|
|
) : (
|
|
<span className="text-xs text-bambu-green">(Ready)</span>
|
|
)}
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 ml-auto" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 ml-auto" />
|
|
)}
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleRefresh}
|
|
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"
|
|
disabled={isRefreshing}
|
|
>
|
|
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
<span>Re-read</span>
|
|
</button>
|
|
</div>
|
|
{filamentComparison.map((item, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="grid items-center gap-2 text-xs"
|
|
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
|
|
>
|
|
{/* Required color */}
|
|
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
|
|
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
|
|
</span>
|
|
{/* Required type + grams + nozzle badge */}
|
|
<span className="text-white truncate flex items-center gap-1">
|
|
{isDualNozzle && item.nozzle_id != null && (
|
|
<span
|
|
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
|
|
title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
|
|
>
|
|
{item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
|
|
</span>
|
|
)}
|
|
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
|
|
</span>
|
|
{/* Arrow */}
|
|
<span className="text-bambu-gray">→</span>
|
|
{/* Slot selector dropdown */}
|
|
<select
|
|
value={item.loaded?.globalTrayId ?? ''}
|
|
onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
|
|
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
|
item.status === 'match'
|
|
? 'border-bambu-green/50 text-bambu-green'
|
|
: item.status === 'type_only'
|
|
? 'border-yellow-400/50 text-yellow-400'
|
|
: 'border-orange-400/50 text-orange-400'
|
|
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
|
|
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
|
|
>
|
|
<option value="" className="bg-bambu-dark text-bambu-gray">
|
|
-- Select slot --
|
|
</option>
|
|
{loadedFilaments
|
|
.filter(
|
|
(f) =>
|
|
item.nozzle_id == null ||
|
|
ftsInstalled ||
|
|
f.extruderId === item.nozzle_id,
|
|
)
|
|
.map((f) => {
|
|
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
|
|
const remainingLabel = remainingWeight != null
|
|
? t('printModal.slotRemainingShort', {
|
|
grams: remainingWeight,
|
|
defaultValue: ` - ${remainingWeight}g left`,
|
|
})
|
|
: '';
|
|
// FTS routing badge: if this slot is currently fed into an FTS
|
|
// track, show the destination extruder. Idle (not-loaded) slots
|
|
// get no badge — they can be routed to either extruder on demand.
|
|
const ftsTargetExtruder = ftsInstalled
|
|
? ftsExtruderForSlot(f.globalTrayId)
|
|
: null;
|
|
const ftsBadge =
|
|
ftsTargetExtruder == null
|
|
? ''
|
|
: ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
|
|
return (
|
|
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
|
|
{f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
{/* Status icon */}
|
|
{item.status === 'match' ? (
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
) : item.status === 'type_only' ? (
|
|
<span title="Same type, different color">
|
|
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
|
</span>
|
|
) : (
|
|
<span title="Filament type not loaded">
|
|
<AlertTriangle className="w-3 h-3 text-orange-400" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="text-xs text-bambu-gray">
|
|
{t('printModal.totalCost')}{' '}
|
|
<span className="text-white">
|
|
{totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
|
|
</span>
|
|
</div>
|
|
{hasTypeMismatch && (
|
|
<p className="text-xs text-orange-400 mt-2">Required filament type not found in printer.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|