mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Misc Spoolbuddy frontend fixes
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { AMSUnit, AMSTray } from '../../api/client';
|
||||
import { getFillBarColor } from '../../utils/amsHelpers';
|
||||
|
||||
function trayColorToCSS(color: string | null): string {
|
||||
if (!color) return '#808080';
|
||||
@@ -140,16 +141,18 @@ interface SpoolSlotProps {
|
||||
slotIndex: number;
|
||||
isActive: boolean;
|
||||
fillOverride?: number | null;
|
||||
spoolmanFill?: number | null;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSlotProps) {
|
||||
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {
|
||||
const isEmpty = isTrayEmpty(tray);
|
||||
const color = trayColorToCSS(tray.tray_color);
|
||||
const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;
|
||||
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
|
||||
const resolvedOverride = (fillOverride === 0 && amsFill !== null && amsFill > 0) ? null : fillOverride;
|
||||
const effectiveFill = resolvedOverride ?? amsFill;
|
||||
// Fill level fallback chain: Spoolman → Inventory → AMS remain
|
||||
const effectiveFill = spoolmanFill ?? resolvedOverride ?? amsFill;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -188,7 +191,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, onClick }: SpoolSl
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${effectiveFill}%`,
|
||||
backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
|
||||
backgroundColor: getFillBarColor(effectiveFill),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -215,9 +218,10 @@ interface AmsUnitCardProps {
|
||||
nozzleSide?: 'L' | 'R' | null;
|
||||
thresholds?: AmsThresholds;
|
||||
fillOverrides?: Record<string, number>;
|
||||
spoolmanFillOverrides?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides }: AmsUnitCardProps) {
|
||||
export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides, spoolmanFillOverrides }: AmsUnitCardProps) {
|
||||
const trays = unit.tray || [];
|
||||
const isHt = unit.is_ams_ht;
|
||||
const slotCount = isHt ? 1 : 4;
|
||||
@@ -275,6 +279,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
|
||||
slotIndex={i}
|
||||
isActive={activeSlot === i}
|
||||
fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
|
||||
spoolmanFill={spoolmanFillOverrides?.[`${unit.id}-${i}`] ?? null}
|
||||
onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
|
||||
import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
|
||||
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
|
||||
import type { AmsThresholds } from './AmsUnitCard';
|
||||
import { getFillBarColor } from '../../utils/amsHelpers';
|
||||
|
||||
function getAmsName(id: number): string {
|
||||
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
||||
@@ -62,6 +64,41 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
enabled: isOpen && printerId !== null,
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => api.getSettings(),
|
||||
enabled: isOpen,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments', printerId],
|
||||
queryFn: () => api.getAssignments(printerId!),
|
||||
enabled: isOpen && printerId !== null,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Build fill-level override map from inventory assignments
|
||||
const fillOverrides = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
if (!assignments) return map;
|
||||
for (const a of assignments) {
|
||||
const sp = a.spool;
|
||||
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
|
||||
const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
|
||||
map[`${a.ams_id}-${a.tray_id}`] = fill;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [assignments]);
|
||||
|
||||
const amsThresholds: AmsThresholds | undefined = settings ? {
|
||||
humidityGood: Number(settings.ams_humidity_good) || 40,
|
||||
humidityFair: Number(settings.ams_humidity_fair) || 60,
|
||||
tempGood: Number(settings.ams_temp_good) || 28,
|
||||
tempFair: Number(settings.ams_temp_fair) || 35,
|
||||
} : undefined;
|
||||
|
||||
const isConnected = status?.connected ?? false;
|
||||
const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
|
||||
const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
|
||||
@@ -130,6 +167,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
const items: {
|
||||
key: string; label: string; amsId: number; trayId: number;
|
||||
tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
|
||||
effectiveFill: number | null;
|
||||
}[] = [];
|
||||
|
||||
for (const unit of htAms) {
|
||||
@@ -138,28 +176,37 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
|
||||
cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
|
||||
};
|
||||
const invFill = fillOverrides[`${unit.id}-0`] ?? null;
|
||||
const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
|
||||
const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
|
||||
items.push({
|
||||
key: `ht-${unit.id}`, label: getAmsName(unit.id),
|
||||
amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
|
||||
nozzleSide: getNozzleSide(unit.id),
|
||||
effectiveFill: resolvedInvFill ?? amsFill,
|
||||
});
|
||||
}
|
||||
|
||||
for (const extTray of vtTrays) {
|
||||
const extTrayId = extTray.id ?? 254;
|
||||
const extSlotTrayId = extTrayId - 254;
|
||||
const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
|
||||
const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
|
||||
const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
|
||||
items.push({
|
||||
key: `ext-${extTrayId}`,
|
||||
label: isDualNozzle
|
||||
? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
|
||||
: t('printers.ext', 'Ext'),
|
||||
amsId: 255, trayId: extTrayId - 254, tray: extTray,
|
||||
amsId: 255, trayId: extSlotTrayId, tray: extTray,
|
||||
isEmpty: isTrayEmpty(extTray),
|
||||
nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
|
||||
effectiveFill: extResolvedInvFill ?? extAmsFill,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
|
||||
}, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -236,6 +283,8 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
|
||||
isDualNozzle={isDualNozzle}
|
||||
nozzleSide={getNozzleSide(unit.id)}
|
||||
thresholds={amsThresholds}
|
||||
fillOverrides={fillOverrides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -244,7 +293,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
{/* Single-slot items (HT + External) */}
|
||||
{singleSlots.length > 0 && (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
|
||||
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
|
||||
const color = trayColorToCSS(tray.tray_color);
|
||||
return (
|
||||
<div
|
||||
@@ -278,13 +327,13 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
{isEmpty ? 'Empty' : tray.tray_type || '?'}
|
||||
</div>
|
||||
</div>
|
||||
{!isEmpty && tray.remain != null && tray.remain >= 0 && (
|
||||
{!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
|
||||
<div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
|
||||
<div
|
||||
className="w-full rounded-full"
|
||||
style={{
|
||||
height: `${tray.remain}%`,
|
||||
backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
|
||||
height: `${effectiveFill}%`,
|
||||
backgroundColor: getFillBarColor(effectiveFill),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
|
||||
import { FileUploadModal } from '../components/FileUploadModal';
|
||||
import { PrintModal } from '../components/PrintModal';
|
||||
import { PrinterInfoModal } from '../components/PrinterInfoModal';
|
||||
import { getGlobalTrayId } from '../utils/amsHelpers';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';
|
||||
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
|
||||
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
|
||||
import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
|
||||
@@ -999,23 +999,6 @@ function getAmsLabel(amsId: number | string, trayCount: number): string {
|
||||
return isHt ? `HT-${letter}` : `AMS-${letter}`;
|
||||
}
|
||||
|
||||
// Get fill bar color based on spool fill level
|
||||
function getFillBarColor(fillLevel: number): string {
|
||||
if (fillLevel > 50) return '#00ae42'; // Green - good
|
||||
if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
|
||||
return '#ef4444'; // Red - critical (< 15%)
|
||||
}
|
||||
|
||||
// Calculate fill level from Spoolman weight data (used as fallback when AMS reports 0%)
|
||||
function getSpoolmanFillLevel(
|
||||
linkedSpool: LinkedSpoolInfo | undefined
|
||||
): number | null {
|
||||
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|
||||
|| linkedSpool.filament_weight <= 0) return null;
|
||||
return Math.min(100, Math.round(
|
||||
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tray contains a Bambu Lab spool (RFID-tagged).
|
||||
@@ -1042,26 +1025,6 @@ function isBambuLabSpool(tray: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function toFixedHex(value: number, width: number): string {
|
||||
const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
|
||||
}
|
||||
|
||||
// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
|
||||
function hashSerialToHex32(serial: string): string {
|
||||
const input = (serial || '').trim().toUpperCase();
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
||||
}
|
||||
|
||||
function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
|
||||
// 16-char stable hex tag for slots without RFID identifiers
|
||||
return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
|
||||
}
|
||||
|
||||
function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Layers } from 'lucide-react';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus, AMSTray } from '../../api/client';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../../utils/amsHelpers';
|
||||
import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
|
||||
import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
|
||||
import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
|
||||
@@ -70,6 +71,23 @@ export function SpoolBuddyAmsPage() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch Spoolman status to enable fill-level chain
|
||||
const { data: spoolmanStatus } = useQuery({
|
||||
queryKey: ['spoolman-status'],
|
||||
queryFn: api.getSpoolmanStatus,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;
|
||||
|
||||
// Fetch linked spools map (tag -> spool info) for Spoolman fill levels
|
||||
const { data: linkedSpoolsData } = useQuery({
|
||||
queryKey: ['linked-spools'],
|
||||
queryFn: api.getLinkedSpools,
|
||||
enabled: !!spoolmanEnabled,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
const linkedSpools = linkedSpoolsData?.linked;
|
||||
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments', selectedPrinterId],
|
||||
queryFn: () => api.getAssignments(selectedPrinterId!),
|
||||
@@ -92,11 +110,58 @@ export function SpoolBuddyAmsPage() {
|
||||
return map;
|
||||
}, [assignments]);
|
||||
|
||||
// Look up Spoolman fill level for a given tray
|
||||
const printerSerial = printer?.serial_number ?? '';
|
||||
const getSpoolmanFillForSlot = useCallback((amsId: number, trayId: number, tray: AMSTray | null): number | null => {
|
||||
if (!linkedSpools || !printerSerial) return null;
|
||||
const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
|
||||
const linkedSpool = tag ? linkedSpools[tag] : undefined;
|
||||
return getSpoolmanFillLevel(linkedSpool);
|
||||
}, [linkedSpools, printerSerial]);
|
||||
|
||||
const isConnected = status?.connected ?? false;
|
||||
const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
|
||||
|
||||
// Cache AMS data to prevent it disappearing on idle/offline printers
|
||||
const cachedAmsData = useRef<PrinterStatus['ams']>([]);
|
||||
useEffect(() => {
|
||||
if (status?.ams && status.ams.length > 0) {
|
||||
cachedAmsData.current = status.ams;
|
||||
}
|
||||
}, [status?.ams]);
|
||||
const amsUnits = useMemo(() => {
|
||||
const live = status?.ams;
|
||||
return (live && live.length > 0) ? live : (cachedAmsData.current ?? []);
|
||||
}, [status?.ams]);
|
||||
const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
|
||||
const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
|
||||
const trayNow = status?.tray_now ?? 255;
|
||||
|
||||
// Build Spoolman fill-level override map for regular AMS cards
|
||||
const spoolmanFillOverrides = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
if (!linkedSpools || !printerSerial) return map;
|
||||
for (const unit of regularAms) {
|
||||
for (let i = 0; i < (unit.tray?.length ?? 0); i++) {
|
||||
const tray = unit.tray![i];
|
||||
const fill = getSpoolmanFillForSlot(unit.id, i, isTrayEmpty(tray) ? null : tray);
|
||||
if (fill !== null) map[`${unit.id}-${i}`] = fill;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [linkedSpools, printerSerial, regularAms, getSpoolmanFillForSlot]);
|
||||
|
||||
// Cache tray_now to prevent flickering when undefined values come in
|
||||
// Valid tray IDs: 0-253 for AMS, 254 for external spool
|
||||
// tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
|
||||
const cachedTrayNow = useRef<number | undefined>(undefined);
|
||||
const currentTrayNow = status?.tray_now;
|
||||
if (currentTrayNow !== undefined && currentTrayNow !== 255) {
|
||||
cachedTrayNow.current = currentTrayNow;
|
||||
} else if (currentTrayNow === 255) {
|
||||
cachedTrayNow.current = undefined;
|
||||
}
|
||||
const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
|
||||
? currentTrayNow
|
||||
: cachedTrayNow.current;
|
||||
const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
|
||||
const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
|
||||
|
||||
@@ -141,17 +206,17 @@ export function SpoolBuddyAmsPage() {
|
||||
} | null>(null);
|
||||
|
||||
const getActiveSlotForAms = useCallback((amsId: number): number | null => {
|
||||
if (trayNow === 255 || trayNow === 254) return null;
|
||||
if (effectiveTrayNow === undefined) return null;
|
||||
if (amsId <= 3) {
|
||||
const activeAmsId = Math.floor(trayNow / 4);
|
||||
if (activeAmsId === amsId) return trayNow % 4;
|
||||
const activeAmsId = Math.floor(effectiveTrayNow / 4);
|
||||
if (activeAmsId === amsId) return effectiveTrayNow % 4;
|
||||
}
|
||||
if (amsId >= 128 && amsId <= 135) {
|
||||
const htIndex = amsId - 128;
|
||||
if (trayNow === 16 + htIndex) return 0;
|
||||
// AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)
|
||||
if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;
|
||||
}
|
||||
return null;
|
||||
}, [trayNow]);
|
||||
}, [effectiveTrayNow]);
|
||||
|
||||
const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
|
||||
const globalTrayId = amsId >= 128 ? (amsId - 128) * 4 + trayId + 64 : amsId * 4 + trayId;
|
||||
@@ -226,6 +291,8 @@ export function SpoolBuddyAmsPage() {
|
||||
tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
|
||||
cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
|
||||
};
|
||||
// Fill level fallback chain: Spoolman → Inventory → AMS remain
|
||||
const spoolmanFill = getSpoolmanFillForSlot(unit.id, 0, isTrayEmpty(tray) ? null : tray);
|
||||
const invFill = fillOverrides[`${unit.id}-0`] ?? null;
|
||||
const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
|
||||
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
|
||||
@@ -239,20 +306,23 @@ export function SpoolBuddyAmsPage() {
|
||||
temp: unit.temp,
|
||||
humidity: unit.humidity,
|
||||
nozzleSide: getNozzleSide(unit.id),
|
||||
effectiveFill: resolvedInvFill ?? amsFill,
|
||||
effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill,
|
||||
onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
|
||||
});
|
||||
}
|
||||
|
||||
for (const extTray of vtTrays) {
|
||||
const extTrayId = extTray.id ?? 254;
|
||||
// tray_now=255 means "no tray loaded" (idle) — never active
|
||||
const isExtActive = trayNow === 255 ? false
|
||||
: isDualNozzle && trayNow === 254
|
||||
? (extTrayId === 254 && status?.active_extruder === 1) ||
|
||||
(extTrayId === 255 && status?.active_extruder === 0)
|
||||
: trayNow === extTrayId;
|
||||
// On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
|
||||
// generically — use active_extruder to determine L vs R:
|
||||
// extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255)
|
||||
const isExtActive = isDualNozzle && effectiveTrayNow === 254
|
||||
? (extTrayId === 254 && status?.active_extruder === 1) ||
|
||||
(extTrayId === 255 && status?.active_extruder === 0)
|
||||
: effectiveTrayNow === extTrayId;
|
||||
const extSlotTrayId = extTrayId - 254;
|
||||
// Fill level fallback chain: Spoolman → Inventory → AMS remain
|
||||
const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
|
||||
const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
|
||||
const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
|
||||
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
|
||||
@@ -266,13 +336,13 @@ export function SpoolBuddyAmsPage() {
|
||||
isEmpty: isTrayEmpty(extTray),
|
||||
isActive: isExtActive,
|
||||
nozzleSide: null,
|
||||
effectiveFill: extResolvedInvFill ?? extAmsFill,
|
||||
effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill,
|
||||
onClick: () => handleExtSlotClick(extTray),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides]);
|
||||
}, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
@@ -312,6 +382,7 @@ export function SpoolBuddyAmsPage() {
|
||||
nozzleSide={getNozzleSide(unit.id)}
|
||||
thresholds={amsThresholds}
|
||||
fillOverrides={fillOverrides}
|
||||
spoolmanFillOverrides={spoolmanFillOverrides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -381,7 +452,7 @@ export function SpoolBuddyAmsPage() {
|
||||
className="w-full rounded-full"
|
||||
style={{
|
||||
height: `${effectiveFill}%`,
|
||||
backgroundColor: effectiveFill > 50 ? '#22c55e' : effectiveFill > 20 ? '#f59e0b' : '#ef4444',
|
||||
backgroundColor: getFillBarColor(effectiveFill),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,6 +129,54 @@ export function getGlobalTrayId(
|
||||
return amsId * 4 + trayId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fill bar color based on spool fill level.
|
||||
* Matches PrintersPage thresholds and Bambu Lab brand green.
|
||||
*/
|
||||
export function getFillBarColor(fillLevel: number): string {
|
||||
if (fillLevel > 50) return '#00ae42'; // Green - good
|
||||
if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
|
||||
return '#ef4444'; // Red - critical (< 15%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fill level from Spoolman weight data.
|
||||
* Used as the first source in the Spoolman → Inventory → AMS fill chain.
|
||||
*/
|
||||
export function getSpoolmanFillLevel(
|
||||
linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
|
||||
): number | null {
|
||||
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|
||||
|| linkedSpool.filament_weight <= 0) return null;
|
||||
return Math.min(100, Math.round(
|
||||
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
|
||||
));
|
||||
}
|
||||
|
||||
function toFixedHex(value: number, width: number): string {
|
||||
const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
|
||||
}
|
||||
|
||||
// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
|
||||
function hashSerialToHex32(serial: string): string {
|
||||
const input = (serial || '').trim().toUpperCase();
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable fallback spool tag for slots without RFID identifiers.
|
||||
* Returns a 16-char hex string derived from the printer serial + slot position.
|
||||
*/
|
||||
export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
|
||||
return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum datetime for scheduling (now + 1 minute).
|
||||
* Returns ISO string format for datetime-local input.
|
||||
|
||||
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-DUZN_weu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DI9VPacx.css">
|
||||
<script type="module" crossorigin src="/assets/index-D5Vkuxg1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CJ-drcFM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user