Browse Source

Wired up printer parts button

Martin Ziegler 5 months ago
parent
commit
50a8ed8ea9

+ 26 - 0
backend/app/api/routes/printer_control.py

@@ -595,3 +595,29 @@ async def set_liveview(
         success=success,
         message=f"Live view {'enabled' if request.enable else 'disabled'}" if success else "Failed to set live view"
     )
+
+
+# =============================================================================
+# Status Refresh Endpoint
+# =============================================================================
+
+@router.post("/{printer_id}/control/refresh", response_model=ControlResponse)
+async def refresh_status(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Request a full status update from the printer.
+
+    This sends a 'pushall' command to get the latest data including nozzle info,
+    AMS status, and all other printer state.
+    """
+    await get_printer_or_404(printer_id, db)
+
+    success = printer_manager.request_status_update(printer_id)
+    if not success:
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+    return ControlResponse(
+        success=success,
+        message="Status refresh requested"
+    )

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

@@ -21,6 +21,7 @@ from backend.app.schemas.printer import (
     HMSErrorResponse,
     AMSUnit,
     AMSTray,
+    NozzleInfoResponse,
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.bambu_ftp import (
@@ -183,6 +184,15 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
             k=vt_data.get("k"),
         )
 
+    # Convert nozzle info to response format
+    nozzles = [
+        NozzleInfoResponse(
+            nozzle_type=n.nozzle_type,
+            nozzle_diameter=n.nozzle_diameter,
+        )
+        for n in (state.nozzles or [])
+    ]
+
     return PrinterStatus(
         id=printer_id,
         name=printer.name,
@@ -204,6 +214,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         sdcard=state.sdcard,
         timelapse=state.timelapse,
         ipcam=state.ipcam,
+        nozzles=nozzles,
     )
 
 

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

@@ -58,6 +58,11 @@ class AMSUnit(BaseModel):
     tray: list[AMSTray] = []
 
 
+class NozzleInfoResponse(BaseModel):
+    nozzle_type: str = ""  # "stainless_steel" or "hardened_steel"
+    nozzle_diameter: str = ""  # e.g., "0.4"
+
+
 class PrinterStatus(BaseModel):
     id: int
     name: str
@@ -79,3 +84,4 @@ class PrinterStatus(BaseModel):
     sdcard: bool = False  # SD card inserted
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view enabled
+    nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)

+ 95 - 2
backend/app/services/bambu_mqtt.py

@@ -47,6 +47,13 @@ class KProfile:
     setting_id: str | None = None
 
 
+@dataclass
+class NozzleInfo:
+    """Nozzle hardware configuration."""
+    nozzle_type: str = ""  # "stainless_steel" or "hardened_steel"
+    nozzle_diameter: str = ""  # e.g., "0.4"
+
+
 @dataclass
 class PrinterState:
     connected: bool = False
@@ -66,6 +73,8 @@ class PrinterState:
     sdcard: bool = False  # SD card inserted
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
+    # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
+    nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
 
 
 class BambuMQTTClient:
@@ -120,8 +129,10 @@ class BambuMQTTClient:
         if rc == 0:
             self.state.connected = True
             client.subscribe(self.topic_subscribe)
-            # Request full status update
+            # Request full status update (includes nozzle info in push_status response)
             self._request_push_all()
+            # Note: get_accessories returns stale nozzle data on H2D, so we don't use it.
+            # The correct nozzle data comes from push_status.
             # Prime K-profile request (Bambu printers often ignore first request)
             self._prime_kprofile_request()
             # Immediately broadcast connection state change
@@ -176,13 +187,19 @@ class BambuMQTTClient:
         # Handle xcam data (camera settings) at top level
         if "xcam" in payload:
             xcam_data = payload["xcam"]
-            logger.info(f"[{self.serial_number}] Received xcam data: {xcam_data}")
+            logger.debug(f"[{self.serial_number}] Received xcam data: {xcam_data}")
             if isinstance(xcam_data, dict):
                 if "ipcam_record" in xcam_data:
                     self.state.ipcam = xcam_data.get("ipcam_record") == "enable"
                 if "timelapse" in xcam_data:
                     self.state.timelapse = xcam_data.get("timelapse") == "enable"
 
+        # Handle system responses (accessories info, etc.)
+        if "system" in payload:
+            system_data = payload["system"]
+            logger.info(f"[{self.serial_number}] Received system data: {system_data}")
+            self._handle_system_response(system_data)
+
         if "print" in payload:
             print_data = payload["print"]
             # Log when we see gcode_state changes
@@ -211,6 +228,22 @@ class BambuMQTTClient:
 
             self._update_state(print_data)
 
+    def _handle_system_response(self, data: dict):
+        """Handle system responses including accessories info.
+
+        Note: get_accessories returns stale/incorrect nozzle_type data on H2D.
+        The correct nozzle data comes from push_status, so we don't update
+        nozzle type/diameter from get_accessories. We just log the response
+        for debugging purposes.
+        """
+        command = data.get("command")
+
+        if command == "get_accessories":
+            # Log response for debugging - but DON'T use it to update nozzle data
+            # because it returns stale values (e.g., 'stainless_steel' when the
+            # actual nozzle is 'HH01' hardened steel high-flow)
+            logger.info(f"[{self.serial_number}] Accessories response (not used for nozzle data): {data}")
+
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
 
@@ -283,6 +316,12 @@ class BambuMQTTClient:
         if temp_fields and not hasattr(self, '_temp_fields_logged'):
             logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
             self._temp_fields_logged = True
+
+        # Log nozzle hardware info fields (once)
+        nozzle_fields = {k: v for k, v in data.items() if 'nozzle' in k.lower() or 'hw' in k.lower() or 'extruder' in k.lower() or 'upgrade' in k.lower()}
+        if nozzle_fields and not hasattr(self, '_nozzle_fields_logged'):
+            logger.info(f"[{self.serial_number}] Nozzle/hardware fields in MQTT data: {nozzle_fields}")
+            self._nozzle_fields_logged = True
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -356,6 +395,30 @@ class BambuMQTTClient:
             else:
                 self.state.ipcam = ipcam_data is True
 
+        # Parse nozzle hardware info (single nozzle printers)
+        if "nozzle_type" in data:
+            self.state.nozzles[0].nozzle_type = str(data["nozzle_type"])
+        if "nozzle_diameter" in data:
+            self.state.nozzles[0].nozzle_diameter = str(data["nozzle_diameter"])
+
+        # Parse nozzle hardware info (dual nozzle printers - H2D series)
+        # Left nozzle
+        if "left_nozzle_type" in data:
+            self.state.nozzles[0].nozzle_type = str(data["left_nozzle_type"])
+        if "left_nozzle_diameter" in data:
+            self.state.nozzles[0].nozzle_diameter = str(data["left_nozzle_diameter"])
+        # Right nozzle
+        if "right_nozzle_type" in data:
+            self.state.nozzles[1].nozzle_type = str(data["right_nozzle_type"])
+        if "right_nozzle_diameter" in data:
+            self.state.nozzles[1].nozzle_diameter = str(data["right_nozzle_diameter"])
+
+        # Alternative format for dual nozzle (nozzle_type_2, etc.)
+        if "nozzle_type_2" in data:
+            self.state.nozzles[1].nozzle_type = str(data["nozzle_type_2"])
+        if "nozzle_diameter_2" in data:
+            self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
+
         # Preserve AMS and vt_tray data when updating raw_data
         ams_data = self.state.raw_data.get("ams")
         vt_tray_data = self.state.raw_data.get("vt_tray")
@@ -467,6 +530,36 @@ class BambuMQTTClient:
             message = {"pushing": {"command": "pushall"}}
             self._client.publish(self.topic_publish, json.dumps(message))
 
+    def request_status_update(self) -> bool:
+        """Request a full status update from the printer (public API).
+
+        Sends both pushall and get_accessories commands to refresh all data
+        including nozzle hardware info.
+
+        Returns:
+            True if the request was sent, False if not connected.
+        """
+        if not self._client or not self.state.connected:
+            return False
+        self._request_push_all()
+        # Note: get_accessories returns stale nozzle data on H2D.
+        # The correct nozzle data comes from push_status response.
+        return True
+
+    def _request_accessories(self):
+        """Request accessories info (nozzle type, etc.) from printer."""
+        if self._client:
+            self._sequence_id += 1
+            message = {
+                "system": {
+                    "sequence_id": str(self._sequence_id),
+                    "command": "get_accessories",
+                    "accessory_type": "none"
+                }
+            }
+            logger.debug(f"[{self.serial_number}] Requesting accessories info")
+            self._client.publish(self.topic_publish, json.dumps(message))
+
     def _prime_kprofile_request(self):
         """Send a priming K-profile request on connect.
 

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

@@ -229,6 +229,15 @@ class PrinterManager:
             return self._clients[printer_id].logging_enabled
         return False
 
+    def request_status_update(self, printer_id: int) -> bool:
+        """Request a full status update from the printer.
+
+        This sends a 'pushall' command to get the latest data including nozzle info.
+        """
+        if printer_id in self._clients:
+            return self._clients[printer_id].request_status_update()
+        return False
+
     async def test_connection(
         self,
         ip_address: str,

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

@@ -56,6 +56,11 @@ export interface AMSUnit {
   tray: AMSTray[];
 }
 
+export interface NozzleInfo {
+  nozzle_type: string;  // "stainless_steel" or "hardened_steel"
+  nozzle_diameter: string;  // e.g., "0.4"
+}
+
 export interface PrinterStatus {
   id: number;
   name: string;
@@ -83,6 +88,7 @@ export interface PrinterStatus {
   sdcard: boolean;  // SD card inserted
   timelapse: boolean;  // Timelapse recording active
   ipcam: boolean;  // Live view enabled
+  nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
 }
 
 export interface PrinterCreate {
@@ -1176,4 +1182,10 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ enable }),
     }),
+
+  // Request full status update from printer (pushall)
+  refreshStatus: (printerId: number) =>
+    request<ControlResponse>(`/printers/${printerId}/control/refresh`, {
+      method: 'POST',
+    }),
 };

+ 192 - 0
frontend/src/components/control/PrinterPartsModal.tsx

@@ -0,0 +1,192 @@
+import { useEffect } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { Printer, PrinterStatus } from '../../api/client';
+import { X, RefreshCw, Loader2 } from 'lucide-react';
+import { Card, CardContent } from '../Card';
+
+interface PrinterPartsModalProps {
+  printer: Printer;
+  status: PrinterStatus | null | undefined;
+  onClose: () => void;
+}
+
+// Convert API nozzle_type to display name
+// Bambu nozzle codes: SS = Stainless Steel, HS = Hardened Steel, HH = Hardened Steel High-flow
+function getNozzleTypeName(type: string): string {
+  const upperType = type.toUpperCase();
+
+  // Handle Bambu nozzle codes (e.g., HH01, HS00, SS00)
+  if (upperType.startsWith('HH')) {
+    return 'Hardened Steel';
+  }
+  if (upperType.startsWith('HS')) {
+    return 'Hardened Steel';
+  }
+  if (upperType.startsWith('SS')) {
+    return 'Stainless Steel';
+  }
+
+  // Handle full names from API
+  switch (type) {
+    case 'hardened_steel':
+      return 'Hardened Steel';
+    case 'stainless_steel':
+      return 'Stainless Steel';
+    default:
+      return type || 'Unknown';
+  }
+}
+
+// Determine flow type based on nozzle type code
+// HH = High-flow, HS/SS = Standard
+function getFlowType(type: string): string {
+  const upperType = type.toUpperCase();
+  if (upperType.startsWith('HH')) {
+    return 'High flow';
+  }
+  return 'Standard';
+}
+
+export function PrinterPartsModal({ printer, status, onClose }: PrinterPartsModalProps) {
+  const queryClient = useQueryClient();
+  const isDualNozzle = printer.nozzle_count > 1;
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Get nozzle data from status
+  // On H2D, both nozzles must be identical - if right nozzle is empty, copy from left
+  const leftNozzle = status?.nozzles?.[0];
+  const rightNozzleRaw = status?.nozzles?.[1];
+  const rightNozzle = (rightNozzleRaw?.nozzle_type || rightNozzleRaw?.nozzle_diameter)
+    ? rightNozzleRaw
+    : leftNozzle;
+
+  // Refresh mutation - sends pushall command to printer
+  const refreshMutation = useMutation({
+    mutationFn: () => api.refreshStatus(printer.id),
+    onSuccess: () => {
+      // Invalidate queries to get updated data
+      queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
+    },
+  });
+
+  const handleRefresh = () => {
+    refreshMutation.mutate();
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-2xl" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+            <span className="text-sm font-medium text-white">Printer Parts</span>
+            <button
+              onClick={onClose}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+
+          {/* Content */}
+          <div className="p-6 space-y-6">
+            {/* Left Nozzle */}
+            <div>
+              <h3 className="text-base font-semibold text-white mb-3">
+                {isDualNozzle ? 'Left Nozzle' : 'Nozzle'}
+              </h3>
+              <div className="grid grid-cols-3 gap-4">
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1.5">Type</label>
+                  <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                    {getNozzleTypeName(leftNozzle?.nozzle_type || '')}
+                  </div>
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1.5">Diameter</label>
+                  <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                    {leftNozzle?.nozzle_diameter || '—'}
+                  </div>
+                </div>
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1.5">Flow</label>
+                  <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                    {getFlowType(leftNozzle?.nozzle_type || '')}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            {/* Right Nozzle - only for dual nozzle printers */}
+            {isDualNozzle && (
+              <div>
+                <h3 className="text-base font-semibold text-white mb-3">Right Nozzle</h3>
+                <div className="grid grid-cols-3 gap-4">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1.5">Type</label>
+                    <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                      {getNozzleTypeName(rightNozzle?.nozzle_type || '')}
+                    </div>
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1.5">Diameter</label>
+                    <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                      {rightNozzle?.nozzle_diameter || '—'}
+                    </div>
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1.5">Flow</label>
+                    <div className="px-3 py-2 bg-bambu-dark rounded border border-bambu-dark-tertiary text-sm text-bambu-gray">
+                      {getFlowType(rightNozzle?.nozzle_type || '')}
+                    </div>
+                  </div>
+                </div>
+              </div>
+            )}
+
+            {/* Info text */}
+            <p className="text-sm text-bambu-gray">
+              Please change the nozzle settings on the printer.{' '}
+              <a
+                href="https://wiki.bambulab.com"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="text-bambu-green hover:underline"
+              >
+                View wiki
+              </a>
+            </p>
+          </div>
+
+          {/* Footer */}
+          <div className="flex justify-end px-4 py-3 border-t border-bambu-dark-tertiary">
+            <button
+              onClick={handleRefresh}
+              disabled={refreshMutation.isPending}
+              className="flex items-center gap-2 px-4 py-2 bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 text-white text-sm font-medium rounded"
+            >
+              {refreshMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <RefreshCw className="w-4 h-4" />
+              )}
+              Refresh
+            </button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 15 - 1
frontend/src/pages/ControlPage.tsx

@@ -11,12 +11,14 @@ import { BedControls } from '../components/control/BedControls';
 import { ExtruderControls } from '../components/control/ExtruderControls';
 import { AMSSectionDual } from '../components/control/AMSSectionDual';
 import { CameraSettingsModal } from '../components/control/CameraSettingsModal';
+import { PrinterPartsModal } from '../components/control/PrinterPartsModal';
 import { Loader2, WifiOff, Video, Webcam, Settings } from 'lucide-react';
 
 export function ControlPage() {
   const [searchParams, setSearchParams] = useSearchParams();
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [showCameraSettings, setShowCameraSettings] = useState(false);
+  const [showPrinterParts, setShowPrinterParts] = useState(false);
 
   // Fetch all printers
   const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -170,7 +172,10 @@ export function ControlPage() {
             <div className="flex items-center justify-between px-3 py-2.5 border-b border-bambu-dark-tertiary min-h-[44px]">
               <span className="text-sm text-bambu-gray">Control</span>
               <div className="flex gap-2">
-                <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
+                <button
+                  onClick={() => setShowPrinterParts(true)}
+                  className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark"
+                >
                   Printer Parts
                 </button>
                 <button className="px-4 py-1.5 text-xs rounded bg-bambu-green text-white hover:bg-bambu-green-dark">
@@ -247,6 +252,15 @@ export function ControlPage() {
           onClose={() => setShowCameraSettings(false)}
         />
       )}
+
+      {/* Printer Parts Modal */}
+      {showPrinterParts && selectedPrinter && (
+        <PrinterPartsModal
+          printer={selectedPrinter}
+          status={selectedStatus}
+          onClose={() => setShowPrinterParts(false)}
+        />
+      )}
     </div>
   );
 }

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


+ 1 - 1
static/index.html

@@ -7,7 +7,7 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CHgTUeZT.js"></script>
+    <script type="module" crossorigin src="/assets/index-DVDHG02m.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DaEsKk03.css">
   </head>
   <body>

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