Browse Source

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.
maziggy 3 months ago
parent
commit
6661bbf866

+ 4 - 0
backend/app/api/routes/printers.py

@@ -369,6 +369,10 @@ async def get_printer_status(
             nozzle_diameter=n.get("diameter", ""),
             wear=n.get("wear"),
             stat=n.get("stat"),
+            max_temp=n.get("max_temp", 0),
+            serial_number=n.get("serial_number", ""),
+            filament_color=n.get("filament_color", ""),
+            filament_id=n.get("filament_id", ""),
         )
         for n in (state.nozzle_rack or [])
     ]

+ 4 - 0
backend/app/schemas/printer.py

@@ -153,6 +153,10 @@ class NozzleRackSlot(BaseModel):
     nozzle_diameter: str = ""
     wear: int | None = None
     stat: int | None = None  # Nozzle status (e.g. mounted/docked)
+    max_temp: int = 0  # Max temperature rating °C (0 = not set)
+    serial_number: str = ""  # Nozzle serial number
+    filament_color: str = ""  # RGBA hex ("00000000" = no filament)
+    filament_id: str = ""  # Bambu filament ID
 
 
 class PrintOptionsResponse(BaseModel):

+ 11 - 0
backend/app/services/bambu_mqtt.py

@@ -1757,9 +1757,20 @@ class BambuMQTTClient:
                             "diameter": str(n.get("diameter", "")),
                             "wear": n.get("wear"),
                             "stat": n.get("stat"),
+                            "max_temp": n.get("max_temp", 0),
+                            "serial_number": str(n.get("serial_number", "")),
+                            "filament_color": str(n.get("filament_colour", "")),
+                            "filament_id": str(n.get("filament_id", "")),
                         }
                         for i, n in enumerate(nozzle_info)
                     ]
+                    if not hasattr(self, "_nozzle_rack_logged") and nozzle_info:
+                        self._nozzle_rack_logged = True
+                        logger.info(
+                            "[%s] Nozzle rack raw keys: %s",
+                            self.serial_number,
+                            [list(n.keys()) for n in nozzle_info[:2]],
+                        )
                 for nozzle in nozzle_info:
                     idx = nozzle.get("id", 0)
                     if idx < len(self.state.nozzles):

+ 15 - 1
backend/app/services/printer_manager.py

@@ -648,7 +648,21 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         "active_extruder": state.active_extruder,
         # H2C nozzle rack (tool-changer dock positions)
-        "nozzle_rack": state.nozzle_rack or [],
+        # Map raw MQTT field names (type/diameter) to schema names (nozzle_type/nozzle_diameter)
+        "nozzle_rack": [
+            {
+                "id": n.get("id", 0),
+                "nozzle_type": n.get("type", ""),
+                "nozzle_diameter": n.get("diameter", ""),
+                "wear": n.get("wear"),
+                "stat": n.get("stat"),
+                "max_temp": n.get("max_temp", 0),
+                "serial_number": n.get("serial_number", ""),
+                "filament_color": n.get("filament_color", ""),
+                "filament_id": n.get("filament_id", ""),
+            }
+            for n in (state.nozzle_rack or [])
+        ],
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover

+ 4 - 0
frontend/src/api/client.ts

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -192,6 +192,13 @@ export default {
     mqttDebug: 'MQTTデバッグ',
     activeNozzle: 'アクティブ: {{side}}ノズル',
     nozzleRack: 'ノズルラック',
+    nozzleDocked: 'ドッキング中',
+    nozzleMounted: 'マウント中',
+    nozzleWear: '摩耗',
+    nozzleMaxTemp: '最高温度',
+    nozzleSerial: 'シリアル',
+    nozzleHardenedSteel: '焼入れ鋼',
+    nozzleStainlessSteel: 'ステンレス鋼',
     toast: {
       printStopped: '印刷を停止しました',
       printPaused: '印刷を一時停止しました',

+ 209 - 26
frontend/src/pages/PrintersPage.tsx

@@ -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} />

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BTJM8cN7.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DzOw4aza.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-HwDBvXUG.css


+ 2 - 2
static/index.html

@@ -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-CBPKqOAD.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BTJM8cN7.css">
+    <script type="module" crossorigin src="/assets/index-DzOw4aza.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-HwDBvXUG.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff