Explorar el Código

Add H2C nozzle rack support — store and display 6-position tool-changer dock

The H2C printer has a tool-changer with a 6-nozzle rack, but
device.nozzle.info entries beyond index 1 were being dropped due to a
hardcoded 2-nozzle limit. This adds full nozzle rack storage, API
exposure, and a frontend card showing all dock positions.
maziggy hace 3 meses
padre
commit
48174d3a08

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

@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
     AMSUnit,
     HMSErrorResponse,
     NozzleInfoResponse,
+    NozzleRackSlot,
     PrinterCreate,
     PrinterResponse,
     PrinterStatus,
@@ -360,6 +361,18 @@ async def get_printer_status(
         for n in (state.nozzles or [])
     ]
 
+    # H2C nozzle rack (tool-changer dock positions)
+    nozzle_rack = [
+        NozzleRackSlot(
+            id=n.get("id", 0),
+            nozzle_type=n.get("type", ""),
+            nozzle_diameter=n.get("diameter", ""),
+            wear=n.get("wear"),
+            stat=n.get("stat"),
+        )
+        for n in (state.nozzle_rack or [])
+    ]
+
     # Convert print options to response format
     print_options = PrintOptionsResponse(
         spaghetti_detector=state.print_options.spaghetti_detector,
@@ -423,6 +436,7 @@ async def get_printer_status(
         ipcam=state.ipcam,
         wifi_signal=state.wifi_signal,
         nozzles=nozzles,
+        nozzle_rack=nozzle_rack,
         print_options=print_options,
         stg_cur=state.stg_cur,
         stg_cur_name=get_derived_status_name(state, printer.model),

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

@@ -145,6 +145,16 @@ class NozzleInfoResponse(BaseModel):
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
+class NozzleRackSlot(BaseModel):
+    """H2C nozzle rack slot (6-position tool-changer dock)."""
+
+    id: int = 0
+    nozzle_type: str = ""
+    nozzle_diameter: str = ""
+    wear: int | None = None
+    stat: int | None = None  # Nozzle status (e.g. mounted/docked)
+
+
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
 
@@ -191,6 +201,7 @@ class PrinterStatus(BaseModel):
     ipcam: bool = False  # Live view enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
+    nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack
     print_options: PrintOptionsResponse | None = None  # AI detection and print options
     # Calibration stage tracking
     stg_cur: int = -1  # Current stage number (-1 = not calibrating)

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

@@ -147,6 +147,8 @@ class PrinterState:
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     h2d_extruder_snow: dict = field(default_factory=dict)
+    # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
+    nozzle_rack: list = field(default_factory=list)
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     # Printable objects for skip object functionality: {identify_id: object_name}
@@ -1740,12 +1742,24 @@ class BambuMQTTClient:
         if "nozzle_diameter_2" in data:
             self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
 
-        # H2D series: Nozzle hardware info is in device.nozzle.info array
+        # H2D/H2C series: Nozzle hardware info is in device.nozzle.info array
         if "device" in data and isinstance(data["device"], dict):
             device = data["device"]
             nozzle_data = device.get("nozzle", {})
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
+                # H2C tool-changer: >2 entries means nozzle rack (6 dock + 1 mounted = 7)
+                if len(nozzle_info) > 2:
+                    self.state.nozzle_rack = [
+                        {
+                            "id": n.get("id", i),
+                            "type": str(n.get("type", "")),
+                            "diameter": str(n.get("diameter", "")),
+                            "wear": n.get("wear"),
+                            "stat": n.get("stat"),
+                        }
+                        for i, n in enumerate(nozzle_info)
+                    ]
                 for nozzle in nozzle_info:
                     idx = nozzle.get("id", 0)
                     if idx < len(self.state.nozzles):

+ 2 - 0
backend/app/services/printer_manager.py

@@ -647,6 +647,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "chamber_light": state.chamber_light,
         # 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 [],
     }
     # 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

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

@@ -129,6 +129,14 @@ export interface NozzleInfo {
   nozzle_diameter: string;  // e.g., "0.4"
 }
 
+export interface NozzleRackSlot {
+  id: number;
+  nozzle_type: string;
+  nozzle_diameter: string;
+  wear: number | null;
+  stat: number | null;  // Nozzle status (e.g. mounted/docked)
+}
+
 export interface PrintOptions {
   // Core AI detectors
   spaghetti_detector: boolean;
@@ -186,6 +194,7 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
   // Calibration stage tracking
   stg_cur: number;  // Current stage number (-1 = not calibrating)

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -207,6 +207,7 @@ export default {
     reconnect: 'Neu verbinden',
     mqttDebug: 'MQTT-Debug',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
+    nozzleRack: 'Düsenhalter',
     // Firmware
     firmwareUpdate: 'Firmware-Update',
     firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -207,6 +207,7 @@ export default {
     reconnect: 'Reconnect',
     mqttDebug: 'MQTT Debug',
     activeNozzle: 'Active: {{nozzle}} nozzle',
+    nozzleRack: 'Nozzle Rack',
     // Firmware
     firmwareUpdate: 'Firmware Update',
     firmwareInstructions: 'On the printer\'s touchscreen, go to',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -191,6 +191,7 @@ export default {
     reconnect: '再接続',
     mqttDebug: 'MQTTデバッグ',
     activeNozzle: 'アクティブ: {{side}}ノズル',
+    nozzleRack: 'ノズルラック',
     toast: {
       printStopped: '印刷を停止しました',
       printPaused: '印刷を一時停止しました',

+ 53 - 0
frontend/src/pages/PrintersPage.tsx

@@ -410,6 +410,55 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   );
 }
 
+// H2C Nozzle Rack Card — 2×3 grid showing 6-position tool-changer dock
+function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
+  const { t } = useTranslation();
+  // Filter to dock slots only (exclude the mounted/active entry which is typically id=0 or stat indicates mounted)
+  // Show up to 6 dock positions
+  const dockSlots = slots.slice(0, 6);
+
+  return (
+    <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" 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) => {
+          const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
+          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' : '';
+
+          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>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
 // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
 function WaterDropEmpty({ className }: { className?: string }) {
   return (
@@ -2009,6 +2058,10 @@ function PrinterCard({
                       <p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
                     </div>
                   )}
+                  {/* H2C nozzle rack (tool-changer dock) */}
+                  {status.nozzle_rack && status.nozzle_rack.length > 0 && (
+                    <NozzleRackCard slots={status.nozzle_rack} />
+                  )}
                 </div>
               );
             })()}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-BAHqB1ol.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-BTJM8cN7.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Brr6L6Pf.js


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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio