mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Add full nozzle rack metrics and per-slot hover cards for H2C printers
Capture 4 additional fields from MQTT nozzle data (max_temp, serial_number, filament_color, filament_id) and surface them through REST/WebSocket APIs. Add per-slot hover popover to the NozzleRackCard showing all metrics, filament color backgrounds on slots, and i18n labels in all 4 locales.
This commit is contained in:
@@ -135,6 +135,10 @@ export interface NozzleRackSlot {
|
||||
nozzle_diameter: string;
|
||||
wear: number | null;
|
||||
stat: number | null; // Nozzle status (e.g. mounted/docked)
|
||||
max_temp: number;
|
||||
serial_number: string;
|
||||
filament_color: string; // RGBA hex ("00000000" = no filament)
|
||||
filament_id: string;
|
||||
}
|
||||
|
||||
export interface PrintOptions {
|
||||
|
||||
@@ -208,6 +208,13 @@ export default {
|
||||
mqttDebug: 'MQTT-Debug',
|
||||
activeNozzle: 'Aktiv: {{nozzle}} Düse',
|
||||
nozzleRack: 'Düsenhalter',
|
||||
nozzleDocked: 'Angedockt',
|
||||
nozzleMounted: 'Montiert',
|
||||
nozzleWear: 'Verschleiß',
|
||||
nozzleMaxTemp: 'Max Temp',
|
||||
nozzleSerial: 'Seriennr.',
|
||||
nozzleHardenedSteel: 'Gehärteter Stahl',
|
||||
nozzleStainlessSteel: 'Edelstahl',
|
||||
// Firmware
|
||||
firmwareUpdate: 'Firmware-Update',
|
||||
firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',
|
||||
|
||||
@@ -208,6 +208,13 @@ export default {
|
||||
mqttDebug: 'MQTT Debug',
|
||||
activeNozzle: 'Active: {{nozzle}} nozzle',
|
||||
nozzleRack: 'Nozzle Rack',
|
||||
nozzleDocked: 'Docked',
|
||||
nozzleMounted: 'Mounted',
|
||||
nozzleWear: 'Wear',
|
||||
nozzleMaxTemp: 'Max Temp',
|
||||
nozzleSerial: 'Serial',
|
||||
nozzleHardenedSteel: 'Hardened Steel',
|
||||
nozzleStainlessSteel: 'Stainless Steel',
|
||||
// Firmware
|
||||
firmwareUpdate: 'Firmware Update',
|
||||
firmwareInstructions: 'On the printer\'s touchscreen, go to',
|
||||
|
||||
@@ -208,6 +208,13 @@ export default {
|
||||
mqttDebug: 'Debug MQTT',
|
||||
activeNozzle: 'Attivo: ugello {{nozzle}}',
|
||||
nozzleRack: 'Rack Ugelli',
|
||||
nozzleDocked: 'Agganciato',
|
||||
nozzleMounted: 'Montato',
|
||||
nozzleWear: 'Usura',
|
||||
nozzleMaxTemp: 'Temp Max',
|
||||
nozzleSerial: 'Seriale',
|
||||
nozzleHardenedSteel: 'Acciaio Temprato',
|
||||
nozzleStainlessSteel: 'Acciaio Inox',
|
||||
// Firmware
|
||||
firmwareUpdate: 'Aggiornamento Firmware',
|
||||
firmwareInstructions: 'Sul touchscreen della stampante, vai a',
|
||||
|
||||
@@ -192,6 +192,13 @@ export default {
|
||||
mqttDebug: 'MQTTデバッグ',
|
||||
activeNozzle: 'アクティブ: {{side}}ノズル',
|
||||
nozzleRack: 'ノズルラック',
|
||||
nozzleDocked: 'ドッキング中',
|
||||
nozzleMounted: 'マウント中',
|
||||
nozzleWear: '摩耗',
|
||||
nozzleMaxTemp: '最高温度',
|
||||
nozzleSerial: 'シリアル',
|
||||
nozzleHardenedSteel: '焼入れ鋼',
|
||||
nozzleStainlessSteel: 'ステンレス鋼',
|
||||
toast: {
|
||||
printStopped: '印刷を停止しました',
|
||||
printPaused: '印刷を一時停止しました',
|
||||
|
||||
@@ -410,6 +410,183 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse RGBA hex to CSS color (skip if empty or all zeros)
|
||||
function parseFilamentColor(rgba: string): string | null {
|
||||
if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
|
||||
const r = rgba.slice(0, 2);
|
||||
const g = rgba.slice(2, 4);
|
||||
const b = rgba.slice(4, 6);
|
||||
const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
|
||||
if (a === 0) return null;
|
||||
return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
|
||||
}
|
||||
|
||||
// Expand nozzle type codes to full names
|
||||
function nozzleTypeName(type: string, t: (key: string) => string): string {
|
||||
if (type.includes('hardened')) return t('printers.nozzleHardenedSteel');
|
||||
if (type.includes('stainless')) return t('printers.nozzleStainlessSteel');
|
||||
return type || '';
|
||||
}
|
||||
|
||||
// Per-slot hover card for nozzle rack
|
||||
function NozzleSlotHoverCard({ slot, index, children }: {
|
||||
slot: import('../api/client').NozzleRackSlot;
|
||||
index: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState<'top' | 'bottom'>('top');
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
|
||||
const isMounted = slot.stat === 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && triggerRef.current && cardRef.current) {
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const cardHeight = cardRef.current.offsetHeight;
|
||||
const headerHeight = 56;
|
||||
const spaceAbove = triggerRect.top - headerHeight;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
||||
if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
|
||||
setPosition('bottom');
|
||||
} else {
|
||||
setPosition('top');
|
||||
}
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filamentCss = parseFilamentColor(slot.filament_color);
|
||||
const typeFull = nozzleTypeName(slot.nozzle_type, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`
|
||||
absolute left-1/2 -translate-x-1/2 z-50
|
||||
${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
|
||||
animate-in fade-in-0 zoom-in-95 duration-150
|
||||
`}
|
||||
style={{ maxWidth: 'calc(100vw - 24px)' }}
|
||||
>
|
||||
<div className="w-44 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm">
|
||||
{isEmpty ? (
|
||||
<div className="px-3 py-2 text-xs text-bambu-gray text-center whitespace-nowrap">
|
||||
Slot {index + 1} — Empty
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2.5 space-y-1.5">
|
||||
{/* Diameter */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Diameter</span>
|
||||
<span className="text-xs text-white font-semibold">{slot.nozzle_diameter} mm</span>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
{typeFull && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Type</span>
|
||||
<span className="text-xs text-white font-semibold truncate max-w-[100px]">{typeFull}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Status</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
|
||||
isMounted
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}>
|
||||
{isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wear (hide if null/0) */}
|
||||
{slot.wear != null && slot.wear > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleWear')}</span>
|
||||
<span className="text-xs text-white font-semibold">{slot.wear}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Max Temp (hide if 0) */}
|
||||
{slot.max_temp > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleMaxTemp')}</span>
|
||||
<span className="text-xs text-white font-semibold">{slot.max_temp}°C</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial (hide if empty) */}
|
||||
{slot.serial_number && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleSerial')}</span>
|
||||
<span className="text-[10px] text-white font-mono truncate max-w-[80px]">{slot.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament color swatch + ID (hide if empty/00000000) */}
|
||||
{filamentCss && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Filament</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm border border-white/20" style={{ backgroundColor: filamentCss }} />
|
||||
{slot.filament_id && (
|
||||
<span className="text-[10px] text-white font-mono">{slot.filament_id}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow pointer */}
|
||||
<div
|
||||
className={`
|
||||
absolute left-1/2 -translate-x-1/2 w-0 h-0
|
||||
border-l-[6px] border-l-transparent
|
||||
border-r-[6px] border-r-transparent
|
||||
${position === 'top'
|
||||
? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'
|
||||
: 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// H2C Nozzle Rack Card — 2×3 grid showing 6-position tool-changer dock
|
||||
function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -418,7 +595,7 @@ function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSl
|
||||
const dockSlots = slots.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" style={{ minWidth: '80px' }}>
|
||||
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex flex-col justify-center" style={{ minWidth: '80px' }}>
|
||||
<p className="text-[9px] text-bambu-gray mb-0.5">{t('printers.nozzleRack')}</p>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{dockSlots.map((slot, i) => {
|
||||
@@ -426,32 +603,38 @@ function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSl
|
||||
const isMounted = slot.stat === 1;
|
||||
// Type abbreviation: S=stainless, H=hardened
|
||||
const typeAbbr = slot.nozzle_type?.includes('hardened') ? 'H' : slot.nozzle_type?.includes('stainless') ? 'S' : '';
|
||||
const filamentBg = parseFilamentColor(slot.filament_color);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.id ?? i}
|
||||
className={`rounded px-0.5 py-0.5 text-center ${
|
||||
isEmpty
|
||||
? 'bg-bambu-dark-tertiary/30 opacity-40'
|
||||
: isMounted
|
||||
? 'bg-green-900/40 ring-1 ring-green-500/60'
|
||||
: 'bg-bambu-dark-tertiary/50'
|
||||
}`}
|
||||
title={isEmpty ? `Slot ${i + 1}: empty` : `Slot ${i + 1}: ${slot.nozzle_diameter}mm ${slot.nozzle_type || ''} ${isMounted ? '(mounted)' : ''}`}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<p className="text-[9px] text-bambu-gray">—</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-[10px] font-medium ${isMounted ? 'text-green-400' : 'text-white'}`}>
|
||||
{slot.nozzle_diameter || '?'}
|
||||
</p>
|
||||
{typeAbbr && (
|
||||
<p className="text-[8px] text-bambu-gray leading-none">{typeAbbr}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<NozzleSlotHoverCard key={slot.id ?? i} slot={slot} index={i}>
|
||||
<div
|
||||
className={`rounded px-0.5 py-0.5 text-center cursor-default ${
|
||||
isEmpty
|
||||
? 'bg-bambu-dark-tertiary/30 opacity-40'
|
||||
: isMounted
|
||||
? 'ring-1 ring-green-500/60'
|
||||
: 'bg-bambu-dark-tertiary/50'
|
||||
}`}
|
||||
style={filamentBg && !isEmpty ? { backgroundColor: filamentBg } : (isMounted && !filamentBg ? { backgroundColor: 'rgba(20, 83, 45, 0.4)' } : undefined)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<p className="text-[9px] text-bambu-gray">—</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-[10px] font-medium ${isMounted ? 'text-green-400' : 'text-white'}`}
|
||||
style={filamentBg ? { textShadow: '0 1px 2px rgba(0,0,0,0.8)' } : undefined}
|
||||
>
|
||||
{slot.nozzle_diameter || '?'}
|
||||
</p>
|
||||
{typeAbbr && (
|
||||
<p className="text-[8px] text-bambu-gray leading-none"
|
||||
style={filamentBg ? { textShadow: '0 1px 2px rgba(0,0,0,0.8)' } : undefined}
|
||||
>{typeAbbr}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</NozzleSlotHoverCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -2014,7 +2197,7 @@ function PrinterCard({
|
||||
const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-stretch gap-1.5">
|
||||
{/* Nozzle temp - combined for dual nozzle */}
|
||||
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
|
||||
<HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-orange-400" isHeating={nozzleHeating} />
|
||||
|
||||
Reference in New Issue
Block a user