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:
maziggy
2026-02-09 15:10:49 +01:00
parent d9895aae5b
commit 6661bbf866
14 changed files with 712 additions and 455 deletions
+4
View File
@@ -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 {
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -192,6 +192,13 @@ export default {
mqttDebug: 'MQTTデバッグ',
activeNozzle: 'アクティブ: {{side}}ノズル',
nozzleRack: 'ノズルラック',
nozzleDocked: 'ドッキング中',
nozzleMounted: 'マウント中',
nozzleWear: '摩耗',
nozzleMaxTemp: '最高温度',
nozzleSerial: 'シリアル',
nozzleHardenedSteel: '焼入れ鋼',
nozzleStainlessSteel: 'ステンレス鋼',
toast: {
printStopped: '印刷を停止しました',
printPaused: '印刷を一時停止しました',
+209 -26
View File
@@ -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} />